diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 89aef3f..21b54e9 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -18,6 +18,7 @@ import ( "github.com/mithral/vellum/internal/cli" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/httpapi" + "github.com/mithral/vellum/internal/jellyfin" "github.com/mithral/vellum/internal/jobs" "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/media" @@ -28,6 +29,7 @@ import ( _ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor "github.com/mithral/vellum/internal/playback" "github.com/mithral/vellum/internal/search" + "github.com/mithral/vellum/internal/subsonic" "github.com/mithral/vellum/migrations" ) @@ -110,6 +112,15 @@ func run(cfg core.Config, log *slog.Logger) error { // CLIENT declares — a browser offers "html5", a native client can offer mpv. playback.NewHandlers(db, log, authenticator).Register(api.Mux()) + // Client compatibility layers. Neither speaks the session cookie; both + // authenticate with an api_tokens credential and then go through the same + // grant chokepoint as the web app. They mount at the root because that is + // where the real clients look, and both report SupportsTranscoding=false: + // this server has no CPU for it, so a client that demands a transcode is + // refused rather than left waiting for one that will never start. + jellyfin.NewHandlers(db, log, authenticator).Register(api.Mux()) + subsonic.NewHandlers(db, log, authenticator).Register(api.Mux()) + // Scans run on the queue rather than inside the request: a real library // takes longer than any sensible timeout, and a hanging request is // indistinguishable from a broken server. The result is written back so diff --git a/internal/apitoken/apitoken.go b/internal/apitoken/apitoken.go new file mode 100644 index 0000000..affa49f --- /dev/null +++ b/internal/apitoken/apitoken.go @@ -0,0 +1,172 @@ +// Package apitoken issues and verifies the tokens non-browser clients use. +// +// Jellyfin clients send `X-Emby-Token` (or `api_key=`); Subsonic clients send +// `t=md5(key+salt)`. Neither speaks the web app's session cookie, so these +// tokens are a second, revocable way to be a user. They do not weaken the cookie +// model: a token resolves to the same auth.User and every caller still goes +// through the same grant chokepoint. +// +// Two storage shapes, deliberately: +// +// - A Jellyfin token is a bearer value, so only its hash is stored — the same +// reasoning as session tokens. A stolen database yields nothing usable. +// - A Subsonic key must be recoverable, because verifying `t` means computing +// md5(key + salt) server-side. So Subsonic keys are stored in the clear. +// That is the Navidrome approach, and it is why the key is a generated +// random secret rather than anything derived from the user's password. +package apitoken + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + + "github.com/mithral/vellum/internal/core" +) + +// Kinds of token. A client family, not a permission. +const ( + KindJellyfin = "jellyfin" + KindSubsonic = "subsonic" +) + +// ErrNotFound means no token matched. +var ErrNotFound = errors.New("apitoken: not found") + +// Token is one issued credential. It never carries the secret itself. +type Token struct { + ID int64 + UserID int64 + Kind string + Label string + CreatedAt string + LastUsedAt string + ExpiresAt string +} + +// Mint issues a new token for a user and returns the secret exactly once. +// +// The caller must show it to the user immediately; it is not recoverable for +// Jellyfin afterwards (only its hash is kept). For Subsonic it is recoverable, +// because that protocol requires it — see the package comment. +func Mint(ctx context.Context, db *sql.DB, userID int64, kind, label string) (string, error) { + if kind != KindJellyfin && kind != KindSubsonic { + return "", fmt.Errorf("apitoken: unknown kind %q", kind) + } + + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("apitoken: read random: %w", err) + } + secret := hex.EncodeToString(raw[:]) + + plain := "" + if kind == KindSubsonic { + // Recoverable by necessity: verifying `t` needs the key itself. + plain = secret + } + + _, err := db.ExecContext(ctx, ` + INSERT INTO api_tokens (user_id, kind, label, token_hash, token_plain, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + userID, kind, label, Hash(secret), plain, core.Now()) + if err != nil { + return "", fmt.Errorf("apitoken: insert: %w", err) + } + return secret, nil +} + +// Hash is the lookup digest for a token. Exported so the Jellyfin layer can +// resolve a bearer value without duplicating the scheme. +func Hash(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// Resolve finds the user a token belongs to, and records the use. +// +// An empty or unknown token is ErrNotFound; the caller turns that into a 401. +// An expired token is also ErrNotFound, so callers cannot accidentally treat an +// expired credential as valid by checking only the error's absence. +func Resolve(ctx context.Context, db *sql.DB, secret string) (int64, error) { + if secret == "" { + return 0, ErrNotFound + } + var ( + id int64 + userID int64 + expires string + ) + err := db.QueryRowContext(ctx, + `SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`, + Hash(secret)).Scan(&id, &userID, &expires) + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } + if err != nil { + return 0, fmt.Errorf("apitoken: resolve: %w", err) + } + if expires != "" && expires < core.Now() { + return 0, ErrNotFound + } + + // Best-effort: a failed touch must not fail the request. + _, _ = db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, core.Now(), id) + return userID, nil +} + +// Plain returns the recoverable secret for a Subsonic token. Jellyfin tokens +// have none by design, so this returns ErrNotFound for them. +func Plain(ctx context.Context, db *sql.DB, userID int64) (string, error) { + var plain string + err := db.QueryRowContext(ctx, + `SELECT token_plain FROM api_tokens + WHERE user_id = ? AND kind = ? AND token_plain != '' + ORDER BY id LIMIT 1`, + userID, KindSubsonic).Scan(&plain) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("apitoken: plain: %w", err) + } + return plain, nil +} + +// ForUser lists a user's tokens, newest first. +func ForUser(ctx context.Context, db *sql.DB, userID int64) ([]Token, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, user_id, kind, label, created_at, last_used_at, expires_at + FROM api_tokens WHERE user_id = ? ORDER BY id DESC`, userID) + if err != nil { + return nil, fmt.Errorf("apitoken: list: %w", err) + } + defer rows.Close() + + out := []Token{} + for rows.Next() { + var t Token + if err := rows.Scan(&t.ID, &t.UserID, &t.Kind, &t.Label, + &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// Revoke deletes a token, scoped to its owner so one user cannot revoke +// another's. Reports whether anything was removed. +func Revoke(ctx context.Context, db *sql.DB, userID, tokenID int64) (bool, error) { + res, err := db.ExecContext(ctx, + `DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID) + if err != nil { + return false, fmt.Errorf("apitoken: revoke: %w", err) + } + n, err := res.RowsAffected() + return n > 0, err +} diff --git a/internal/jellyfin/auth.go b/internal/jellyfin/auth.go new file mode 100644 index 0000000..0080268 --- /dev/null +++ b/internal/jellyfin/auth.go @@ -0,0 +1,89 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/mithral/vellum/internal/apitoken" + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/httpx" +) + +// require resolves the caller to an auth.User from a Jellyfin credential and +// hands them to fn. It is the Jellyfin twin of auth.Authenticator.Require: +// the same shape, but token-based. +// +// On any failure it has already written a 401. Note the deliberate difference +// from the web layer: a Jellyfin token resolves to the same auth.User, and +// every handler below still filters through VisibleLibraryIDs / +// CanAccessLibrary, so a token can never see another user's libraries. +func (h *Handlers) require(fn func(http.ResponseWriter, *http.Request, auth.User)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := h.currentUser(r) + if !ok { + httpx.WriteError(w, http.StatusUnauthorized, "authentication required") + return + } + fn(w, r, u) + }) +} + +// currentUser resolves the request's Jellyfin credential through +// apitoken.Resolve and loads the owning user. +func (h *Handlers) currentUser(r *http.Request) (auth.User, bool) { + secret := tokenFromRequest(r) + if secret == "" { + return auth.User{}, false + } + userID, err := apitoken.Resolve(r.Context(), h.db, secret) + if err != nil { + // ErrNotFound and any storage error both mean "not authenticated". + return auth.User{}, false + } + u, err := auth.GetUserByID(r.Context(), h.db, userID) + if err != nil { + return auth.User{}, false + } + return u, true +} + +// tokenFromRequest extracts the credential from the three places Jellyfin +// clients put it, in order of reliability: +// +// - X-Emby-Token header (modern clients) +// - api_key query parameter (media elements cannot set headers) +// - Authorization: MediaBrowser Token="..." (older clients) +// +// X-MediaBrowser-Token is also accepted; some clients send it. +func tokenFromRequest(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get("X-Emby-Token")); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get("X-MediaBrowser-Token")); v != "" { + return v + } + if v := strings.TrimSpace(r.URL.Query().Get("api_key")); v != "" { + return v + } + return mediaBrowserToken(r.Header.Get("Authorization")) +} + +// mediaBrowserToken parses an Authorization header of the form +// +// MediaBrowser Token="abc", UserId="...", Client="...", Device="..." +// +// The token may be quoted or bare, and the parameter list may be comma- or +// space-separated in the wild. +func mediaBrowserToken(header string) string { + if header == "" { + return "" + } + // Normalise commas to spaces so both separators split the same way. + fields := strings.Fields(strings.ReplaceAll(header, ",", " ")) + for _, f := range fields { + if v, ok := strings.CutPrefix(f, "Token="); ok { + return strings.Trim(v, `"`) + } + } + return "" +} diff --git a/internal/jellyfin/dto.go b/internal/jellyfin/dto.go new file mode 100644 index 0000000..efc6e09 --- /dev/null +++ b/internal/jellyfin/dto.go @@ -0,0 +1,291 @@ +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 } diff --git a/internal/jellyfin/ids.go b/internal/jellyfin/ids.go new file mode 100644 index 0000000..4768704 --- /dev/null +++ b/internal/jellyfin/ids.go @@ -0,0 +1,51 @@ +package jellyfin + +import ( + "fmt" + "strconv" + "strings" +) + +// Jellyfin identifies every object with an opaque 32-character lowercase hex +// id (a GUID with the dashes removed). Vellum's ids are int64, and both +// libraries and items must appear as Jellyfin ids — including as a ParentId, +// which a client hands back verbatim. Rendering both as plain decimal would +// make ParentId ambiguous between a library and an item, so libraries get a +// leading 'f' marker. A positive int64 rendered as 32 hex digits always starts +// with 0-7, so 'f' can never collide with an item id. +// +// The mapping is stable and deterministic: the same Vellum row always yields +// the same Jellyfin id, which clients rely on for caching and favourites. + +// itemID renders an item's Jellyfin id. +func itemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) } + +// libraryID renders a library's Jellyfin id. The leading 'f' namespaces it +// away from item ids. +func libraryID(id int64) string { return fmt.Sprintf("f%031x", uint64(id)) } + +// decodeID reverses itemID/libraryID. isLibrary reports which namespace the id +// came from; ok is false for anything that is not one of our 32-char hex ids. +// Input is lowercased first, because clients sometimes normalise case. +func decodeID(s string) (id int64, isLibrary, ok bool) { + s = strings.ToLower(strings.TrimSpace(s)) + if len(s) != 32 { + return 0, false, false + } + if s[0] == 'f' { + n, err := strconv.ParseUint(s[1:], 16, 64) + if err != nil { + return 0, false, false + } + return int64(n), true, true + } + n, err := strconv.ParseUint(s, 16, 64) + if err != nil { + return 0, false, false + } + // Item ids are positive int64; a value with the top bit set is malformed. + if n > 1<<63-1 { + return 0, false, false + } + return int64(n), false, true +} diff --git a/internal/jellyfin/items.go b/internal/jellyfin/items.go new file mode 100644 index 0000000..fa5d99f --- /dev/null +++ b/internal/jellyfin/items.go @@ -0,0 +1,284 @@ +package jellyfin + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strings" + + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/library" + "github.com/mithral/vellum/internal/stream" +) + +// handleItems is the browse workhorse for GET /Items. The /Users/{userId}/Items +// form delegates here; the token's user is authoritative for scoping. +func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.User) { + ctx := r.Context() + q := r.URL.Query() + + libs, err := auth.VisibleLibraryIDs(ctx, h.db, u) + if err != nil { + h.fail(w, "visible libraries", err) + return + } + if libs == nil { + libs = []int64{} + } + + startIndex := atoiDefault(q.Get("StartIndex"), 0) + if startIndex < 0 { + startIndex = 0 + } + limit := atoiDefault(q.Get("Limit"), defaultLimit) + if limit <= 0 || limit > maxLimit { + limit = maxLimit + } + + parentRaw := strings.TrimSpace(q.Get("ParentId")) + recursive := parseBool(q.Get("Recursive")) + search := strings.TrimSpace(q.Get("SearchTerm")) + + // Top level: no parent, not recursive, no search. Real clients expect the + // user's views here, so the visible libraries are returned as + // CollectionFolder items to drill into. + if parentRaw == "" && !recursive && search == "" { + h.writeViews(w, ctx, libs, startIndex, limit) + return + } + + // A user with no grants gets an empty page, never an error and never + // content from another library. + if len(libs) == 0 { + writeItems(w, startIndex, 0, nil) + return + } + + query := listQuery{ + userID: u.ID, + libraryIDs: libs, + sortColumn: sortColumn(q.Get("SortBy")), + descending: isDescending(q.Get("SortOrder")), + limit: limit, + offset: startIndex, + searchTerm: search, + filters: splitCSV(q.Get("Filters")), + kinds: kindsForTypes(splitCSV(q.Get("IncludeItemTypes"))), + includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"), + } + + if parentRaw != "" { + pid, isLib, ok := decodeID(parentRaw) + if !ok { + // An unknown ParentId is an empty page, not an error: clients probe + // ids and expect to be told there is nothing there. + writeItems(w, startIndex, 0, nil) + return + } + if isLib { + if !containsInt64(libs, pid) { + writeItems(w, startIndex, 0, nil) + return + } + query.libraryIDs = []int64{pid} + } else { + // Children of an item, and only if that item is visible. + visible, err := h.itemVisible(ctx, u, pid) + if err != nil { + h.fail(w, "check parent", err) + return + } + if !visible { + writeItems(w, startIndex, 0, nil) + return + } + query.parentItemID = pid + } + } + + items, total, err := h.queryItems(ctx, query) + if err != nil { + h.fail(w, "list items", err) + return + } + + apiKey := tokenFromRequest(r) + dtos := make([]BaseItemDto, 0, len(items)) + for _, it := range items { + dtos = append(dtos, toBaseItemDto(it, query.includeSources, apiKey)) + } + writeItems(w, startIndex, total, dtos) +} + +// handleUserItems serves GET /Users/{userId}/Items. The token's user is +// authoritative; a mismatched path id is a 404 (cross-user reads are not +// implemented — see the package notes). +func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) { + id, _, ok := decodeID(r.PathValue("userId")) + if !ok || id != u.ID { + httpx.WriteError(w, http.StatusNotFound, "user not found") + return + } + h.handleItems(w, r, u) +} + +// handleItem serves one BaseItemDto. A library id is answered as a folder so a +// client that treats views as items still works. +func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) { + id, isLib, ok := decodeID(r.PathValue("itemId")) + if !ok { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return + } + if isLib { + lib, err := library.Get(r.Context(), h.db, id) + if errors.Is(err, library.ErrNotFound) { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return + } + if err != nil { + h.fail(w, "load library", err) + return + } + allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id) + if err != nil { + h.fail(w, "check access", err) + return + } + if !allowed { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return + } + httpx.WriteJSON(w, http.StatusOK, libraryDTO(lib)) + return + } + + it, ok := h.authorizedItem(w, r, u, id) + if !ok { + return + } + httpx.WriteJSON(w, http.StatusOK, toBaseItemDto(it, true, tokenFromRequest(r))) +} + +// handleImagePrimary streams the cover bytes the scanner/web already extracted. +// +// It does not trigger extraction: NewHandlers has no cache directory, and the +// extraction path belongs to the web layer. An item whose cover has not been +// extracted yet simply 404s, which clients treat as "no artwork". +func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) { + id, isLib, ok := decodeID(r.PathValue("itemId")) + if !ok || isLib { + httpx.WriteError(w, http.StatusNotFound, "no cover available") + return + } + if _, ok := h.authorizedItem(w, r, u, id); !ok { + return + } + + var path string + err := h.db.QueryRowContext(r.Context(), + `SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, id).Scan(&path) + if errors.Is(err, sql.ErrNoRows) { + httpx.WriteError(w, http.StatusNotFound, "no cover available") + return + } + if err != nil { + h.fail(w, "load cover", err) + return + } + + w.Header().Set("Cache-Control", "public, max-age=86400") + if err := stream.ServeFile(w, r, path); err != nil { + httpx.WriteError(w, http.StatusNotFound, "no cover available") + return + } +} + +// authorizedItem loads an item and confirms access, writing a 404 (never a 403, +// so existence is not disclosed) on failure. +func (h *Handlers) authorizedItem(w http.ResponseWriter, r *http.Request, u auth.User, id int64) (dbItem, bool) { + it, err := h.loadDBItem(r.Context(), u.ID, id) + if errors.Is(err, sql.ErrNoRows) { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return dbItem{}, false + } + if err != nil { + h.fail(w, "load item", err) + return dbItem{}, false + } + allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID) + if err != nil { + h.fail(w, "check access", err) + return dbItem{}, false + } + if !allowed { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return dbItem{}, false + } + return it, true +} + +// writeViews returns the caller's visible libraries as CollectionFolder items, +// paginated like any other browse result. +func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs []int64, startIndex, limit int) { + list, err := library.ListVisible(ctx, h.db, libs) + if err != nil { + h.fail(w, "list libraries", err) + return + } + total := len(list) + if startIndex > total { + startIndex = total + } + end := startIndex + limit + if end > total { + end = total + } + items := make([]BaseItemDto, 0, end-startIndex) + for _, lib := range list[startIndex:end] { + items = append(items, libraryDTO(lib)) + } + writeItems(w, startIndex, total, items) +} + +// libraryDTO renders a library as a Jellyfin folder. +func libraryDTO(lib library.Library) BaseItemDto { + return BaseItemDto{ + Id: libraryID(lib.ID), + Name: lib.Name, + Type: "CollectionFolder", + CollectionType: collectionType(lib.Kind), + IsFolder: true, + SortName: lib.Name, + } +} + +// collectionType maps a Vellum kind to the Jellyfin collection type, which +// tells a client how to render the folder. +func collectionType(kind string) string { + switch kind { + case "video": + return "movies" + case "audiobooks", "music": + return "music" + case "photos": + return "homevideos" + case "ebooks", "comics": + return "books" + default: + return "" + } +} + +func writeItems(w http.ResponseWriter, startIndex, total int, items []BaseItemDto) { + if items == nil { + items = []BaseItemDto{} + } + httpx.WriteJSON(w, http.StatusOK, itemQueryResult{ + Items: items, + TotalRecordCount: total, + StartIndex: startIndex, + }) +} diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go new file mode 100644 index 0000000..6fe27bd --- /dev/null +++ b/internal/jellyfin/jellyfin.go @@ -0,0 +1,157 @@ +// Package jellyfin translates Vellum's existing storage into the subset of the +// Jellyfin HTTP API that real clients (iOS, Android, Roku, TV apps) use to +// browse and play. +// +// It is a translation layer, not a second media server. Every query goes +// through the existing media_items / media_files / progress / assets tables, +// and every request resolves to an auth.User through the same grant chokepoint +// the web app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). A Jellyfin +// token minted here is an apitoken.KindJellyfin token, so revoking it in the +// web UI revokes Jellyfin access too. +// +// # No transcoding +// +// Vellum runs on a headless, GPU-less host. PlaybackInfo therefore honestly +// reports SupportsTranscoding=false and hands out a direct stream URL. +// A client that demands a transcode (it disabled both direct play and direct +// stream) is told so via PlaybackInfo's ErrorCode "NoCompatibleStream" rather +// than being handed a TranscodingUrl that would 404 or burn CPU the host does +// not have. +// +// # Root mounting +// +// The routes are mounted at the ROOT because that is where Jellyfin clients +// look (/System/Info/Public, /Users/AuthenticateByName, /Items, ...). They do +// not collide with the web app: internal/httpapi/static.go owns "/", "/healthz" +// and "/api/", and every Jellyfin path is a more specific ServeMux pattern, so +// requests route here first. isAPIPath in static.go does not know these +// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an +// unknown /Items/... path returns JSON rather than the SPA shell. +package jellyfin + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "log/slog" + "net/http" + "sync" + + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/httpx" +) + +// Handlers serves the Jellyfin-compatible subset. +type Handlers struct { + db *sql.DB + log *slog.Logger + // a is kept for parity with the other HTTP layers and for ClientIP, but + // token resolution is done here: the authenticator's middleware only knows + // session cookies, which Jellyfin clients do not send. + a *auth.Authenticator + + mu sync.Mutex + serverID string +} + +// NewHandlers builds the Jellyfin HTTP layer. It deliberately takes no cache +// directory: cover bytes are served from the assets table the scanner already +// populated, rather than triggering a fresh extraction. +func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers { + return &Handlers{db: db, log: log, a: a} +} + +// Register mounts the routes. See the package comment for why they live at the +// root and why each subtree has a JSON 404 fallback. +func (h *Handlers) Register(mux *http.ServeMux) { + // Public discovery. + mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic) + mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName) + + // Authenticated: users. + mux.Handle("GET /Users/Me", h.require(h.handleUserMe)) + mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID)) + + // Authenticated: browsing. + mux.Handle("GET /Items", h.require(h.handleItems)) + mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems)) + mux.Handle("GET /Items/{itemId}", h.require(h.handleItem)) + mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary)) + mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo)) + + // Media. These MUST accept api_key in the query string: