The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.
Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.
Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].
Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.
Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.
Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.
Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
452 lines
17 KiB
Go
452 lines
17 KiB
Go
// Package catalog is the one place that reads items and files out of the
|
|
// database, and the one place that enforces "may this user read this item".
|
|
//
|
|
// It exists because that check had been reimplemented five times — in media,
|
|
// comics, games, jellyfin and subsonic — each with its own SQL and its own idea
|
|
// of what to do when access is denied. Five copies of one authorization rule
|
|
// means four of them can drift, and the failure mode is not a crash: it is
|
|
// silently serving somebody a library they were not granted. One implementation
|
|
// is one thing to review.
|
|
//
|
|
// Consumers depend on this package rather than on *sql.DB, which is also what
|
|
// makes the storage engine swappable: a Postgres implementation would satisfy
|
|
// this package's surface instead of editing 186 statements scattered across the
|
|
// tree.
|
|
//
|
|
// # What the Store covers, and what it deliberately does not
|
|
//
|
|
// Store is the seam, and it holds the per-item load path only: Item, ItemFor,
|
|
// Files, FilesFor, FileByID, FirstFile, FileCount. Those are the queries whose
|
|
// correctness is a security property (ItemFor is the authorization chokepoint)
|
|
// and whose shape is identical for every media kind, so one implementation is
|
|
// worth having.
|
|
//
|
|
// Aggregate and list queries — browse lists, home shelves, Subsonic albums —
|
|
// deliberately stay OUT of it. Each is a presentation concern of the package
|
|
// that owns the surface, with its own columns, ordering and JSON contract, and
|
|
// none of them is shared. Pulling them in would mean rewriting all 186
|
|
// statements to match one interface, which is the exact cost this seam exists to
|
|
// avoid, and the result would be an interface nobody could implement without
|
|
// writing the whole application's query layer twice. The seam buys a swappable
|
|
// per-item load; it is not a repository.
|
|
//
|
|
// The television tree is the one exception, and for the same reason ItemFor is
|
|
// here rather than in a caller: Seasons, Episodes and NextEpisode are not
|
|
// presentation, they are a grant-enforcing read of a parent the caller has not
|
|
// yet proved it may read. Implementing them per surface would mean implementing
|
|
// "may this user read this series" three more times.
|
|
//
|
|
// This package holds no HTTP knowledge. It returns typed errors and lets the
|
|
// caller choose a status code.
|
|
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// ErrNotFound means the item does not exist, OR exists but the caller may not
|
|
// read it.
|
|
//
|
|
// The two are deliberately indistinguishable. A caller that could tell them
|
|
// apart would leak the existence of other people's libraries through a 403, so
|
|
// every handler turns this into a plain 404.
|
|
var ErrNotFound = errors.New("catalog: item not found")
|
|
|
|
// Item is one media item plus the root of its library.
|
|
type Item struct {
|
|
ID int64
|
|
LibraryID int64
|
|
Kind string
|
|
Path string // path relative to the library root
|
|
Title string
|
|
Year int
|
|
Missing bool
|
|
|
|
// SeasonNumber and EpisodeNumber are the television tree (migration 0013).
|
|
// Both are zero for anything that is not part of it, which includes every
|
|
// film: the columns are nullable and a film leaves both NULL. An EPISODE
|
|
// stays kind='video' (see Kind), so an episode is recognised by its kind
|
|
// together with a non-zero EpisodeNumber, never by kind alone.
|
|
SeasonNumber int
|
|
EpisodeNumber int
|
|
|
|
// RootPath is the absolute library root, so AbsPath can be computed.
|
|
RootPath string
|
|
|
|
// ModTime is the backing file's modification time, used for conditional
|
|
// GETs. Zero when unknown.
|
|
ModTime time.Time
|
|
}
|
|
|
|
// AbsPath returns the absolute filesystem path of the item's file.
|
|
//
|
|
// item.path is stored relative to the library root and the root is stored
|
|
// resolved, so this join cannot escape the library.
|
|
func (i Item) AbsPath() string {
|
|
return filepath.Join(i.RootPath, filepath.FromSlash(i.Path))
|
|
}
|
|
|
|
// File is one physical file backing an item, including the ffprobe facts.
|
|
//
|
|
// The JSON tags are the frozen MediaFile contract in web/src/lib/api.ts. The
|
|
// codec fields are what let a CLIENT decide whether it can direct-play, since
|
|
// Vellum never transcodes and playability depends on the client, not the file.
|
|
type File struct {
|
|
ID int64 `json:"id"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
Container string `json:"container"`
|
|
Missing bool `json:"missing"`
|
|
DurationMS int64 `json:"duration_ms"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
}
|
|
|
|
// Store is the storage seam. Implement it to run this package's per-item load
|
|
// path against something other than SQLite.
|
|
//
|
|
// The contract is the one the SQL implementation has always had: absent item and
|
|
// forbidden item both come back as ErrNotFound, and every other failure is a
|
|
// wrapped error. A caller that can distinguish the two has a bug.
|
|
type Store interface {
|
|
Item(ctx context.Context, id int64) (Item, error)
|
|
ItemFor(ctx context.Context, u auth.User, id int64) (Item, error)
|
|
Files(ctx context.Context, itemID int64) ([]File, error)
|
|
FilesFor(ctx context.Context, u auth.User, itemID int64) ([]File, error)
|
|
FileByID(ctx context.Context, itemID, fileID int64) (File, error)
|
|
FirstFile(ctx context.Context, itemID int64) (File, error)
|
|
FileCount(ctx context.Context, itemID int64) (int, error)
|
|
|
|
// Seasons returns a series' seasons in season order, Episodes a season's
|
|
// episodes in episode order, and NextEpisode the series' first episode the
|
|
// caller has not finished.
|
|
//
|
|
// All three take the caller rather than an already-authorized item because
|
|
// the grant is the whole point of them: they read a parent the caller has
|
|
// not named yet, so the parent has to be checked before its children are
|
|
// listed. As everywhere else in this package, a parent the caller may not
|
|
// read and a parent that does not exist are the same ErrNotFound.
|
|
Seasons(ctx context.Context, u auth.User, seriesID int64) ([]Item, error)
|
|
Episodes(ctx context.Context, u auth.User, seasonID int64) ([]Item, error)
|
|
NextEpisode(ctx context.Context, u auth.User, seriesID int64) (Item, error)
|
|
}
|
|
|
|
const itemColumns = `
|
|
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing,
|
|
l.root_path,
|
|
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), ''),
|
|
COALESCE(i.season_number, 0), COALESCE(i.episode_number, 0)
|
|
FROM media_items i
|
|
JOIN libraries l ON l.id = i.library_id`
|
|
|
|
const itemSelect = itemColumns + ` WHERE i.id = ?`
|
|
|
|
// scanner is one row, so the single-item and list paths share the same scan.
|
|
type scanner interface{ Scan(dest ...any) error }
|
|
|
|
// scanItem reads one row of itemColumns. A missing row is ErrNotFound, so every
|
|
// caller inherits the "absent and forbidden are the same" rule rather than
|
|
// re-deciding it.
|
|
func scanItem(row scanner) (Item, error) {
|
|
var (
|
|
it Item
|
|
mtime string
|
|
)
|
|
if err := row.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title,
|
|
&it.Year, &it.Missing, &it.RootPath, &mtime,
|
|
&it.SeasonNumber, &it.EpisodeNumber); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Item{}, ErrNotFound
|
|
}
|
|
return Item{}, err
|
|
}
|
|
if nanos := parseUnixNano(mtime); nanos > 0 {
|
|
it.ModTime = time.Unix(0, nanos).UTC()
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// SQL is the SQLite implementation of Store, and the only thing in this package
|
|
// that knows the query text.
|
|
type SQL struct{ db *sql.DB }
|
|
|
|
var _ Store = (*SQL)(nil)
|
|
|
|
// New returns a Store backed by db. The db must already be migrated; this
|
|
// package never creates schema.
|
|
func New(db *sql.DB) *SQL { return &SQL{db: db} }
|
|
|
|
// Item reads an item WITHOUT checking access. Prefer ItemFor in any handler that
|
|
// serves a request: this exists for the scanner, jobs and tests, which have
|
|
// already established the right to touch the row.
|
|
func (s *SQL) Item(ctx context.Context, id int64) (Item, error) {
|
|
it, err := scanItem(s.db.QueryRowContext(ctx, itemSelect, id))
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
// Returned unwrapped on purpose: this message is part of the "absent
|
|
// and forbidden are indistinguishable" contract and must stay the same
|
|
// one every caller gets.
|
|
return Item{}, ErrNotFound
|
|
case err != nil:
|
|
return Item{}, fmt.Errorf("catalog: load item: %w", err)
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// ItemFor reads an item and confirms the user may read its library.
|
|
//
|
|
// This is the authorization chokepoint. Every HTTP surface — the web app, the
|
|
// modules, Jellyfin, Subsonic — is expected to go through it, so there is one
|
|
// implementation to audit rather than five to keep in sync.
|
|
func (s *SQL) ItemFor(ctx context.Context, u auth.User, id int64) (Item, error) {
|
|
it, err := s.Item(ctx, id)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
allowed, err := auth.CanAccessLibrary(ctx, s.db, u, it.LibraryID)
|
|
if err != nil {
|
|
return Item{}, fmt.Errorf("catalog: check access: %w", err)
|
|
}
|
|
if !allowed {
|
|
// Deliberately the same error as a missing row: see ErrNotFound.
|
|
return Item{}, ErrNotFound
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// Files returns an item's files in order, with codec facts.
|
|
func (s *SQL) Files(ctx context.Context, itemID int64) ([]File, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, path, size, container, missing,
|
|
duration_ms, vcodec, acodec, width, height
|
|
FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("catalog: list files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []File{}
|
|
for rows.Next() {
|
|
var f File
|
|
if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
|
|
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height); err != nil {
|
|
return nil, fmt.Errorf("catalog: scan file: %w", err)
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// FilesFor returns an item's files after confirming access. Callers that
|
|
// already hold an authorized Item should use Files to avoid a second check.
|
|
func (s *SQL) FilesFor(ctx context.Context, u auth.User, itemID int64) ([]File, error) {
|
|
if _, err := s.ItemFor(ctx, u, itemID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Files(ctx, itemID)
|
|
}
|
|
|
|
// FileByID returns one file of an item, scoped to that item so a file id from
|
|
// another item cannot be fetched through it.
|
|
func (s *SQL) FileByID(ctx context.Context, itemID, fileID int64) (File, error) {
|
|
var f File
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, path, size, container, missing,
|
|
duration_ms, vcodec, acodec, width, height
|
|
FROM media_files WHERE item_id = ? AND id = ?`, itemID, fileID).Scan(
|
|
&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
|
|
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return File{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return File{}, fmt.Errorf("catalog: load file: %w", err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// FirstFile returns the item's first file, which is what single-file playback
|
|
// and cover extraction both want.
|
|
func (s *SQL) FirstFile(ctx context.Context, itemID int64) (File, error) {
|
|
var f File
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, path, size, container, missing,
|
|
duration_ms, vcodec, acodec, width, height
|
|
FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(
|
|
&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
|
|
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return File{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return File{}, fmt.Errorf("catalog: first file: %w", err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// FileCount reports how many files back an item.
|
|
func (s *SQL) FileCount(ctx context.Context, itemID int64) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("catalog: file count: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ── the television tree ─────────────────────────────────────────────────
|
|
//
|
|
// library -> series -> season -> episode, by parent_id (migration 0013). Each
|
|
// read below loads the parent through ItemFor first, so a caller with no grant
|
|
// on the series' library gets the same ErrNotFound an absent series would give,
|
|
// and only then walks its children — which are additionally scoped to the
|
|
// parent's own library, so a corrupt parent_id can never reach across it.
|
|
|
|
// Seasons returns a series' seasons, ordered by season number.
|
|
//
|
|
// The number is the order, not the title and not the insertion order: a rescan
|
|
// upserts by path, so "Season 10" and "Season 2" arrive in whatever order the
|
|
// filesystem walk produced, and a title sort would interleave them
|
|
// lexicographically ("Season 10" before "Season 2"). id breaks a tie, so two
|
|
// rows claiming the same number still come back deterministically.
|
|
func (s *SQL) Seasons(ctx context.Context, u auth.User, seriesID int64) ([]Item, error) {
|
|
series, err := s.ItemFor(ctx, u, seriesID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, itemColumns+`
|
|
WHERE i.parent_id = ? AND i.library_id = ? AND i.kind = 'season' AND i.missing = 0
|
|
ORDER BY COALESCE(i.season_number, 0), i.id`, series.ID, series.LibraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("catalog: list seasons: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// Episodes returns a season's episodes, ordered by episode number, for the same
|
|
// reason Seasons orders by season number.
|
|
func (s *SQL) Episodes(ctx context.Context, u auth.User, seasonID int64) ([]Item, error) {
|
|
season, err := s.ItemFor(ctx, u, seasonID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, itemColumns+`
|
|
WHERE i.parent_id = ? AND i.library_id = ? AND i.kind = 'video' AND i.missing = 0
|
|
ORDER BY COALESCE(i.episode_number, 0), i.id`, season.ID, season.LibraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("catalog: list episodes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// NextEpisode returns the first episode of a series the caller has not finished,
|
|
// in season-then-episode order, so a finished season 1 rolls into season 2 rather
|
|
// than reporting the show as caught up. A half-watched episode is the next one:
|
|
// it is what the caller is already in the middle of.
|
|
//
|
|
// It returns ErrNotFound when every episode is finished, and — like every other
|
|
// read here — when the series is missing or the caller may not read it.
|
|
func (s *SQL) NextEpisode(ctx context.Context, u auth.User, seriesID int64) (Item, error) {
|
|
series, err := s.ItemFor(ctx, u, seriesID)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
// The join reaches one level down (series -> season -> episode) because the
|
|
// parent of an episode is a season, and a series holds no episodes of its
|
|
// own. episode_number IS NOT NULL is what makes the order total: a video row
|
|
// with no number cannot be placed in a series at all.
|
|
it, err := scanItem(s.db.QueryRowContext(ctx, itemColumns+`
|
|
WHERE i.library_id = ? AND i.kind = 'video' AND i.missing = 0
|
|
AND i.episode_number IS NOT NULL
|
|
AND i.parent_id IN (SELECT c.id FROM media_items c
|
|
WHERE c.parent_id = ? AND c.kind = 'season')
|
|
AND NOT EXISTS (SELECT 1 FROM progress p
|
|
WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)
|
|
ORDER BY COALESCE((SELECT c.season_number FROM media_items c WHERE c.id = i.parent_id), 0),
|
|
i.episode_number, i.id
|
|
LIMIT 1`, series.LibraryID, series.ID, u.ID))
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
return Item{}, ErrNotFound
|
|
case err != nil:
|
|
return Item{}, fmt.Errorf("catalog: next episode: %w", err)
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// scanItems drains a query of itemColumns.
|
|
func scanItems(rows *sql.Rows) ([]Item, error) {
|
|
out := []Item{}
|
|
for rows.Next() {
|
|
it, err := scanItem(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// The functions below are the original package API, kept so that existing
|
|
// callers compile and behave exactly as before. Each one is a one-line
|
|
// delegation to the SQL implementation above.
|
|
//
|
|
// NEW CODE SHOULD TAKE A Store, NOT A *sql.DB. A caller that wants to be
|
|
// swappable off SQLite takes the Store interface and receives New(db) at the
|
|
// wiring point; reaching for these functions bakes the engine back in.
|
|
|
|
func LoadUnchecked(ctx context.Context, db *sql.DB, id int64) (Item, error) {
|
|
return New(db).Item(ctx, id)
|
|
}
|
|
|
|
func LoadFor(ctx context.Context, db *sql.DB, u auth.User, id int64) (Item, error) {
|
|
return New(db).ItemFor(ctx, u, id)
|
|
}
|
|
|
|
func Files(ctx context.Context, db *sql.DB, itemID int64) ([]File, error) {
|
|
return New(db).Files(ctx, itemID)
|
|
}
|
|
|
|
func FilesFor(ctx context.Context, db *sql.DB, u auth.User, itemID int64) ([]File, error) {
|
|
return New(db).FilesFor(ctx, u, itemID)
|
|
}
|
|
|
|
func FileByID(ctx context.Context, db *sql.DB, itemID, fileID int64) (File, error) {
|
|
return New(db).FileByID(ctx, itemID, fileID)
|
|
}
|
|
|
|
func FirstFile(ctx context.Context, db *sql.DB, itemID int64) (File, error) {
|
|
return New(db).FirstFile(ctx, itemID)
|
|
}
|
|
|
|
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
|
|
return New(db).FileCount(ctx, itemID)
|
|
}
|
|
|
|
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
|
|
// that is not all digits.
|
|
func parseUnixNano(s string) int64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
var n int64
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return 0
|
|
}
|
|
n = n*10 + int64(s[i]-'0')
|
|
}
|
|
return n
|
|
}
|