Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.
Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
credential that is not the session cookie, and letting two agents each invent
a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
value so only its hash is kept, while a Subsonic key must be recoverable
because verifying `t` means computing md5(key + salt) server-side. token_plain
is therefore the one column in the schema holding a usable credential. It is a
generated random secret, never a user password, and it is revocable.
Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
/Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
/Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
/Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
with a DirectStreamUrl carrying the api_key. A client that disables both
direct modes is demanding a transcode and is refused with ErrorCode
"NoCompatibleStream" — rather than handed a TranscodingUrl that would never
resolve, which is how a client hangs forever.
Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.
Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.
Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
PlaybackInfo showing SupportsTranscoding=false, and a Range request to
/Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
/api/v1/version all still answer correctly.
Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
291 lines
10 KiB
Go
291 lines
10 KiB
Go
package jellyfin
|
|
|
|
import "database/sql"
|
|
|
|
// This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's
|
|
// BaseItemDto has roughly a hundred fields; real clients tolerate the absence
|
|
// of most of them, and the ones below are what browse and playback actually
|
|
// consume. Every struct is PascalCase on the wire because that is the Jellyfin
|
|
// convention (the web app's own API is snake_case and is untouched).
|
|
|
|
// UserDto is the subset of the Jellyfin user shape clients read.
|
|
type UserDto struct {
|
|
Name string `json:"Name"`
|
|
ServerId string `json:"ServerId,omitempty"`
|
|
Id string `json:"Id"`
|
|
HasPassword bool `json:"HasPassword"`
|
|
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
|
|
Policy *UserPolicy `json:"Policy,omitempty"`
|
|
Configuration *UserConfig `json:"Configuration,omitempty"`
|
|
}
|
|
|
|
// UserPolicy carries the two things a client needs from a user's policy: the
|
|
// admin flag and which library folders the account can see. EnabledFolders is
|
|
// the user's VisibleLibraryIDs rendered as Jellyfin ids, so a client can hide
|
|
// views it is not allowed to open.
|
|
type UserPolicy struct {
|
|
IsAdministrator bool `json:"IsAdministrator"`
|
|
IsDisabled bool `json:"IsDisabled"`
|
|
EnableAllFolders bool `json:"EnableAllFolders"`
|
|
EnabledFolders []string `json:"EnabledFolders"`
|
|
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
|
|
EnableContentDownloading bool `json:"EnableContentDownloading"`
|
|
}
|
|
|
|
// UserConfig is a near-empty subset; clients only require the key to exist.
|
|
type UserConfig struct {
|
|
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
|
|
}
|
|
|
|
// UserItemDataDto is per-user state for one item.
|
|
//
|
|
// Ticks are 100ns units. Vellum stores milliseconds, so the conversion is
|
|
// x10000 (ms -> ticks) and /10000 (ticks -> ms).
|
|
type UserItemDataDto struct {
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
|
|
PlayCount int `json:"PlayCount"`
|
|
IsFavorite bool `json:"IsFavorite"`
|
|
Played bool `json:"Played"`
|
|
}
|
|
|
|
// BaseItemDto is the browse shape. Type is the Jellyfin item type
|
|
// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from
|
|
// Vellum's media kind.
|
|
type BaseItemDto struct {
|
|
Id string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
Type string `json:"Type"`
|
|
MediaType string `json:"MediaType,omitempty"`
|
|
CollectionType string `json:"CollectionType,omitempty"`
|
|
Container string `json:"Container,omitempty"`
|
|
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
|
ProductionYear int `json:"ProductionYear,omitempty"`
|
|
ParentId string `json:"ParentId,omitempty"`
|
|
IsFolder bool `json:"IsFolder"`
|
|
SortName string `json:"SortName,omitempty"`
|
|
Path string `json:"Path,omitempty"`
|
|
ImageTags map[string]string `json:"ImageTags,omitempty"`
|
|
UserData *UserItemDataDto `json:"UserData,omitempty"`
|
|
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
|
|
}
|
|
|
|
// MediaSourceInfo is one playable source. Vellum never transcodes, so
|
|
// SupportsTranscoding is always false and TranscodingUrl is never set.
|
|
type MediaSourceInfo struct {
|
|
Id string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
Path string `json:"Path,omitempty"`
|
|
Container string `json:"Container,omitempty"`
|
|
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
|
Size int64 `json:"Size,omitempty"`
|
|
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
|
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
|
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
|
DirectStreamUrl string `json:"DirectStreamUrl,omitempty"`
|
|
Protocol string `json:"Protocol"`
|
|
Type string `json:"Type"`
|
|
IsRemote bool `json:"IsRemote"`
|
|
MediaStreams []MediaStream `json:"MediaStreams,omitempty"`
|
|
}
|
|
|
|
// MediaStream describes one track inside a file, using the codec facts the
|
|
// scanner wrote from ffprobe.
|
|
type MediaStream struct {
|
|
Type string `json:"Type"` // "Video" | "Audio"
|
|
Codec string `json:"Codec,omitempty"`
|
|
Index int `json:"Index"`
|
|
IsDefault bool `json:"IsDefault"`
|
|
Width int `json:"Width,omitempty"`
|
|
Height int `json:"Height,omitempty"`
|
|
}
|
|
|
|
// PlaybackInfoResponse is the response to PlaybackInfo. ErrorCode is set only
|
|
// when the client demanded a transcode Vellum will not perform.
|
|
type PlaybackInfoResponse struct {
|
|
MediaSources []MediaSourceInfo `json:"MediaSources"`
|
|
PlaySessionId string `json:"PlaySessionId,omitempty"`
|
|
ErrorCode string `json:"ErrorCode,omitempty"`
|
|
}
|
|
|
|
// itemQueryResult is the browse envelope.
|
|
type itemQueryResult struct {
|
|
Items []BaseItemDto `json:"Items"`
|
|
TotalRecordCount int `json:"TotalRecordCount"`
|
|
StartIndex int `json:"StartIndex"`
|
|
}
|
|
|
|
// dbItem is one media_items row plus the file facts and per-user state a DTO
|
|
// needs. It is the single shape both the list and single-item paths build.
|
|
type dbItem struct {
|
|
ID int64
|
|
LibraryID int64
|
|
Kind string
|
|
Title string
|
|
SortTitle string
|
|
Year int
|
|
ParentID sql.NullInt64
|
|
Container string
|
|
DurationMS int64
|
|
VCodec string
|
|
ACodec string
|
|
Width int
|
|
Height int
|
|
Size int64
|
|
Path string
|
|
HasCover bool
|
|
|
|
// Per-user state, zero when the user has never touched the item.
|
|
PositionMS int64
|
|
Percent float64
|
|
Finished bool
|
|
HasProgress bool
|
|
PlayCount int
|
|
Favorite bool
|
|
}
|
|
|
|
// itemType maps a Vellum media kind to the Jellyfin item type. The four types
|
|
// the task requires are Movie, Audio, Book and Photo; anything Vellum adds
|
|
// later (games) degrades to Folder so a client can still show it.
|
|
func itemType(kind string) string {
|
|
switch kind {
|
|
case "video":
|
|
return "Movie"
|
|
case "audiobooks", "music":
|
|
return "Audio"
|
|
case "photos":
|
|
return "Photo"
|
|
case "ebooks", "comics":
|
|
return "Book"
|
|
default:
|
|
return "Folder"
|
|
}
|
|
}
|
|
|
|
// mediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
|
|
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
|
|
func mediaType(kind string) string {
|
|
switch kind {
|
|
case "video":
|
|
return "Video"
|
|
case "audiobooks", "music":
|
|
return "Audio"
|
|
case "photos":
|
|
return "Photo"
|
|
case "ebooks", "comics":
|
|
return "Book"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// kindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds,
|
|
// so a client asking for Movies does not receive ebooks. An unknown type maps
|
|
// to nothing rather than to everything.
|
|
func kindsForTypes(types []string) []string {
|
|
var out []string
|
|
for _, t := range types {
|
|
switch t {
|
|
case "Movie":
|
|
out = append(out, "video")
|
|
case "Audio":
|
|
out = append(out, "audiobooks", "music")
|
|
case "Book":
|
|
out = append(out, "ebooks", "comics")
|
|
case "Photo":
|
|
out = append(out, "photos")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// toBaseItemDto renders one dbItem. includeMediaSources is set from the Fields
|
|
// query parameter; lists normally omit sources to keep responses small.
|
|
func toBaseItemDto(it dbItem, includeMediaSources bool, apiKey string) BaseItemDto {
|
|
dto := BaseItemDto{
|
|
Id: itemID(it.ID),
|
|
Name: it.Title,
|
|
Type: itemType(it.Kind),
|
|
MediaType: mediaType(it.Kind),
|
|
Container: it.Container,
|
|
RunTimeTicks: msToTicks(it.DurationMS),
|
|
ProductionYear: it.Year,
|
|
IsFolder: false,
|
|
SortName: it.SortTitle,
|
|
Path: it.Path,
|
|
}
|
|
if it.ParentID.Valid {
|
|
dto.ParentId = itemID(it.ParentID.Int64)
|
|
}
|
|
if it.HasCover {
|
|
// The tag is the item id: stable, and it changes when the item changes.
|
|
dto.ImageTags = map[string]string{"Primary": itemID(it.ID)}
|
|
}
|
|
dto.UserData = &UserItemDataDto{
|
|
PlaybackPositionTicks: msToTicks(it.PositionMS),
|
|
PlayedPercentage: it.Percent * 100,
|
|
PlayCount: it.PlayCount,
|
|
IsFavorite: it.Favorite,
|
|
Played: it.Finished,
|
|
}
|
|
if includeMediaSources {
|
|
dto.MediaSources = []MediaSourceInfo{buildMediaSource(it, apiKey)}
|
|
}
|
|
return dto
|
|
}
|
|
|
|
// buildMediaSource renders the single direct-play source for an item. It is
|
|
// shared by PlaybackInfo and by list responses that requested the
|
|
// MediaSources field.
|
|
func buildMediaSource(it dbItem, apiKey string) MediaSourceInfo {
|
|
ms := MediaSourceInfo{
|
|
Id: itemID(it.ID),
|
|
Name: it.Title,
|
|
Path: it.Path,
|
|
Container: it.Container,
|
|
RunTimeTicks: msToTicks(it.DurationMS),
|
|
Size: it.Size,
|
|
SupportsDirectPlay: true,
|
|
SupportsDirectStream: true,
|
|
// The hard constraint: no transcoding, ever.
|
|
SupportsTranscoding: false,
|
|
Protocol: "File",
|
|
Type: "Default",
|
|
IsRemote: false,
|
|
}
|
|
ms.DirectStreamUrl = directStreamURL(it, apiKey)
|
|
|
|
if it.VCodec != "" {
|
|
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
|
|
Type: "Video", Codec: it.VCodec, Index: 0, IsDefault: true,
|
|
Width: it.Width, Height: it.Height,
|
|
})
|
|
}
|
|
if it.ACodec != "" {
|
|
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
|
|
Type: "Audio", Codec: it.ACodec, Index: 1, IsDefault: true,
|
|
})
|
|
}
|
|
return ms
|
|
}
|
|
|
|
// directStreamURL builds the URL a client should play. Video items stream from
|
|
// /Videos, everything else (audio, books, photos) from /Audio, which is the
|
|
// same file-serving path either way. The api_key is embedded because media
|
|
// elements cannot set headers.
|
|
func directStreamURL(it dbItem, apiKey string) string {
|
|
base := "/Audio/" + itemID(it.ID) + "/stream"
|
|
if it.Kind == "video" {
|
|
base = "/Videos/" + itemID(it.ID) + "/stream"
|
|
}
|
|
if apiKey == "" {
|
|
return base
|
|
}
|
|
return base + "?api_key=" + apiKey
|
|
}
|
|
|
|
// msToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks.
|
|
func msToTicks(ms int64) int64 { return ms * 10000 }
|
|
|
|
// ticksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds.
|
|
func ticksToMS(ticks int64) int64 { return ticks / 10000 }
|