EXPAND ONLY, per the migration order (expand, migrate, verify, contract). No
Postgres implementation, no behaviour change.
internal/catalog now exposes:
type Store interface {
Item / ItemFor / Files / FilesFor / FileByID / FirstFile / FileCount
}
type SQL struct{ db *sql.DB }; func New(db *sql.DB) *SQL
The seven bodies are the old functions moved verbatim. ItemFor still returns the
same ErrNotFound for absent and forbidden, so the security property is
unchanged. A compile-time `var _ Store = (*SQL)(nil)` keeps the implementation
honest.
Deliberately NOT moved: browse lists, home shelves and subsonic album queries.
Those are each package's own presentation concern, and pulling all of them into
the seam is exactly the 186-statement rewrite the seam exists to avoid. The
package doc says so.
Proven substitutable, not just declared: store_test.go implements a fake Store
in memory with no database at all and drives a caller through the interface. A
seam nobody can substitute proves nothing.
The agent's own risk note was right and I acted on it: the seam only pays off
once something actually threads it, and initially nothing did. So internal/
playback — the smallest consumer — now takes a catalog.Store instead of *sql.DB
and is wired with catalog.New(db) at the composition point. It has no database
reference left outside a comment. The remaining consumers (media, jellyfin,
subsonic, metadata) still call the package-level wrappers; threading them is
mechanical, and I would rather do it deliberately than in one sweep.
Verified: gofmt clean, build exit 0, vet exit 0, 25 packages pass, arch rules
pass. End to end against a running server: /playback/resolve still returns
vcodec:hevc for html5 and engine mpv when mpv is offered, an unknown item 404s,
/home and Jellyfin /Items still answer.
Known gap, unchanged by this commit: internal/arch does not enforce "new code
must not take *sql.DB", so the seam can erode by drift. That rule would be the
natural next step if this is to hold.
308 lines
11 KiB
Go
308 lines
11 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.
|
|
//
|
|
// 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
|
|
|
|
// 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)
|
|
}
|
|
|
|
const itemSelect = `
|
|
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), '')
|
|
FROM media_items i
|
|
JOIN libraries l ON l.id = i.library_id
|
|
WHERE i.id = ?`
|
|
|
|
// 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) {
|
|
var (
|
|
it Item
|
|
mtime string
|
|
)
|
|
err := s.db.QueryRowContext(ctx, itemSelect, id).Scan(
|
|
&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title,
|
|
&it.Year, &it.Missing, &it.RootPath, &mtime)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Item{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Item{}, fmt.Errorf("catalog: load item: %w", err)
|
|
}
|
|
if nanos := parseUnixNano(mtime); nanos > 0 {
|
|
it.ModTime = time.Unix(0, nanos).UTC()
|
|
}
|
|
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 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
|
|
}
|