Vellum/internal/jellyfin/items.go
mithral 6da194045b Waves 9-10: Jellyfin and Subsonic clients can browse and play
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.
2026-10-11 15:05:43 -04:00

284 lines
7.8 KiB
Go

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,
})
}