Vellum/internal/jellyfin/query.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

301 lines
8.5 KiB
Go

package jellyfin
import (
"context"
"database/sql"
"sort"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
)
// defaultLimit caps an unbounded browse so a client that omits Limit cannot
// pull an entire library in one response. maxLimit is the hard ceiling.
const (
defaultLimit = 300
maxLimit = 1000
)
// itemColumns / itemFrom are shared by the list and single-item queries so the
// two paths can never disagree about what a dbItem contains.
//
// File facts come from the item's first media_files row (multi-file items such
// as audiobook chapters are still one browsable item). Per-user state comes
// from a windowed progress join that picks the most recently updated row per
// item, plus item_views and favorites.
const itemColumns = `i.id, i.library_id, i.kind, i.title, i.sort_title, COALESCE(i.year,0),
i.parent_id,
COALESCE(f.container,''), COALESCE(f.duration_ms,0), COALESCE(f.vcodec,''), COALESCE(f.acodec,''),
COALESCE(f.width,0), COALESCE(f.height,0), COALESCE(f.size,0), COALESCE(f.path,''),
p.item_id, COALESCE(p.position,0), COALESCE(p.percent,0), COALESCE(p.finished,0),
COALESCE(v.views,0), COALESCE(fa.user_id,0),
COALESCE((SELECT 1 FROM assets a WHERE a.item_id=i.id AND a.role='cover' LIMIT 1),0)`
const itemFrom = `FROM media_items i
LEFT JOIN media_files f ON f.id = (SELECT id FROM media_files WHERE item_id=i.id ORDER BY id LIMIT 1)
LEFT JOIN (SELECT item_id, position, percent, finished,
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY updated_at DESC) AS rn
FROM progress WHERE user_id = ?) p ON p.item_id = i.id AND p.rn = 1
LEFT JOIN item_views v ON v.user_id = ? AND v.item_id = i.id
LEFT JOIN favorites fa ON fa.user_id = ? AND fa.item_id = i.id`
// listQuery is everything the browse query needs.
type listQuery struct {
userID int64
libraryIDs []int64
parentItemID int64 // 0 means "not scoped to an item's children"
kinds []string
sortColumn string
descending bool
limit int
offset int
searchTerm string
filters []string
includeSources bool
}
// queryItems runs the browse query and its matching count.
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dbItem, int, error) {
where, whereArgs := buildWhere(q)
var total int
if err := h.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM media_items i WHERE `+where, whereArgs...).Scan(&total); err != nil {
return nil, 0, err
}
order := q.sortColumn
if q.descending {
order += " DESC"
} else {
order += " ASC"
}
sqlText := `SELECT ` + itemColumns + ` ` + itemFrom +
` WHERE ` + where + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args := []any{q.userID, q.userID, q.userID}
args = append(args, whereArgs...)
args = append(args, q.limit, q.offset)
rows, err := h.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []dbItem{}
for rows.Next() {
it, err := scanItem(rows)
if err != nil {
return nil, 0, err
}
out = append(out, it)
}
return out, total, rows.Err()
}
// loadDBItem loads one item with the same columns as the browse query.
func (h *Handlers) loadDBItem(ctx context.Context, userID, id int64) (dbItem, error) {
row := h.db.QueryRowContext(ctx,
`SELECT `+itemColumns+` `+itemFrom+` WHERE i.id = ?`,
userID, userID, userID, id)
return scanItem(row)
}
// itemVisible reports whether the caller may read the item's library. A
// missing item is false, not an error.
func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool, error) {
var libID int64
err := h.db.QueryRowContext(ctx,
`SELECT library_id FROM media_items WHERE id = ?`, id).Scan(&libID)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return auth.CanAccessLibrary(ctx, h.db, u, libID)
}
// scanItem reads one row of the shared item column list.
func scanItem(row interface{ Scan(...any) error }) (dbItem, error) {
var (
it dbItem
parentID sql.NullInt64
hasProgress sql.NullInt64
finished int
favUser int64
hasCover int
)
err := row.Scan(
&it.ID, &it.LibraryID, &it.Kind, &it.Title, &it.SortTitle, &it.Year,
&parentID,
&it.Container, &it.DurationMS, &it.VCodec, &it.ACodec,
&it.Width, &it.Height, &it.Size, &it.Path,
&hasProgress, &it.PositionMS, &it.Percent, &finished,
&it.PlayCount, &favUser, &hasCover,
)
if err != nil {
return dbItem{}, err
}
it.ParentID = parentID
it.HasProgress = hasProgress.Valid
it.Finished = finished == 1
it.Favorite = favUser != 0
it.HasCover = hasCover == 1
return it, nil
}
// buildWhere assembles the WHERE clause and its arguments. An empty library set
// matches nothing, which is the correct outcome for a user with no grants.
func buildWhere(q listQuery) (string, []any) {
clauses := []string{"i.missing = 0"}
var args []any
if len(q.libraryIDs) == 0 {
clauses = append(clauses, "1 = 0")
} else {
in, inArgs := placeholders(q.libraryIDs)
clauses = append(clauses, "i.library_id IN ("+in+")")
args = append(args, inArgs...)
}
if q.parentItemID > 0 {
clauses = append(clauses, "i.parent_id = ?")
args = append(args, q.parentItemID)
}
if len(q.kinds) > 0 {
in, inArgs := placeholdersStr(q.kinds)
clauses = append(clauses, "i.kind IN ("+in+")")
args = append(args, inArgs...)
}
if q.searchTerm != "" {
clauses = append(clauses, "i.title LIKE ?")
args = append(args, "%"+q.searchTerm+"%")
}
// Filters are sorted so the SQL (and its argument order) is deterministic.
filters := append([]string(nil), q.filters...)
sort.Strings(filters)
for _, f := range filters {
switch f {
case "IsResumable":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 0 AND p.position > 0)`)
args = append(args, q.userID)
case "IsPlayed":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
args = append(args, q.userID)
case "IsUnplayed":
clauses = append(clauses,
`NOT EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
args = append(args, q.userID)
case "IsFavorite":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM favorites f WHERE f.user_id = ? AND f.item_id = i.id)`)
args = append(args, q.userID)
}
}
return strings.Join(clauses, " AND "), args
}
// ── small parsing helpers ──────────────────────────────────────────────
func placeholders(ids []int64) (string, []any) {
parts := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
parts[i] = "?"
args[i] = id
}
return strings.Join(parts, ","), args
}
func placeholdersStr(vals []string) (string, []any) {
parts := make([]string, len(vals))
args := make([]any, len(vals))
for i, v := range vals {
parts[i] = "?"
args[i] = v
}
return strings.Join(parts, ","), args
}
// splitCSV splits a Jellyfin comma list, dropping empties.
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func containsFold(list []string, want string) bool {
for _, v := range list {
if strings.EqualFold(v, want) {
return true
}
}
return false
}
func containsInt64(list []int64, want int64) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes":
return true
}
return false
}
// sortColumn maps a Jellyfin SortBy value to a SQL column. Only the first
// recognised name is used; unknown names fall back to sort_title.
func sortColumn(sortBy string) string {
for _, s := range strings.Split(sortBy, ",") {
switch strings.TrimSpace(s) {
case "DateCreated":
return "i.added_at"
case "ProductionYear":
return "i.year"
case "Random":
return "RANDOM()"
case "SortName":
return "i.sort_title"
}
}
return "i.sort_title"
}
// isDescending reads a Jellyfin SortOrder ("Descending"/"Desc"/"desc").
func isDescending(order string) bool {
switch strings.ToLower(strings.TrimSpace(order)) {
case "descending", "desc":
return true
}
return false
}