Vellum/internal/jellyfin/query.go
mithral 35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00

360 lines
10 KiB
Go

package jellyfin
import (
"context"
"database/sql"
"errors"
"sort"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/jellyfin/dto"
)
// 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
)
// overlayColumns / overlayFrom carry the per-user state and the two browse-only
// columns (sort title, parent) that catalog.Item does not expose. They never
// read media_files and never check a grant: catalog.LoadFor loads the item and
// enforces the grant, and catalog.FirstFile reads the file, so the one
// authorization rule lives in one place instead of a sixth copy here.
//
// Per-user state comes from a windowed progress join that picks the most
// recently updated row per item, plus item_views and favorites.
const overlayColumns = `i.sort_title, i.parent_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 overlayFrom = `FROM media_items i
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`
// itemColumns is the browse projection: the catalog.Item identity columns plus
// the per-user overlay. The list and single-item paths share it so the two can
// never disagree about what a browse row contains. File facts are attached
// separately via catalog.FirstFile.
const itemColumns = `i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.missing, ` +
overlayColumns
// 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.
//
// The browse rows are drained before the per-item file lookups run: catalog
// reads one file at a time, and issuing those queries against a still-open
// result set is the classic nested-query footgun.
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dto.ItemView, 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 + ` ` + overlayFrom +
` 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 := []dto.ItemView{}
for rows.Next() {
v, err := scanItem(rows)
if err != nil {
return nil, 0, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
rows.Close()
for i := range out {
if err := h.attachFile(ctx, &out[i]); err != nil {
return nil, 0, err
}
}
return out, total, nil
}
// attachFile fills the first-file facts from internal/catalog, the one place
// that reads media_files. An item with no file keeps a zero File, which is
// exactly the defaults the old media_files LEFT JOIN produced.
func (h *Handlers) attachFile(ctx context.Context, v *dto.ItemView) error {
f, err := h.firstFile(ctx, v.Item.ID)
if err != nil {
return err
}
v.File = f
return nil
}
// firstFile returns the item's first file, or a zero File when the item has
// none. It is catalog.FirstFile with the "no file" case flattened, for callers
// that only need the facts rather than a 404.
func (h *Handlers) firstFile(ctx context.Context, itemID int64) (catalog.File, error) {
f, err := catalog.FirstFile(ctx, h.db, itemID)
if errors.Is(err, catalog.ErrNotFound) {
return catalog.File{}, nil
}
return f, err
}
// loadOverlay fills the browse-only and per-user fields of an already-loaded
// item. It reads no file and performs no access check: catalog.LoadFor already
// did both.
func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *dto.ItemView) error {
row := h.db.QueryRowContext(ctx,
`SELECT `+overlayColumns+` `+overlayFrom+` WHERE i.id = ?`,
userID, userID, userID, itemID)
var (
parentID sql.NullInt64
finished int
favUser int64
hasCover int
)
if err := row.Scan(
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
); err != nil {
return err
}
v.ParentID = parentID.Int64
v.Finished = finished == 1
v.Favorite = favUser != 0
v.HasCover = hasCover == 1
return nil
}
// itemVisible reports whether the caller may read the item's library. A missing
// item is false, not an error. It delegates to catalog.LoadFor, the same
// chokepoint every other surface uses, rather than reimplementing the grant
// rule here.
func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool, error) {
if _, err := catalog.LoadFor(ctx, h.db, u, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
// scanItem reads one row of the shared item column list.
func scanItem(row interface{ Scan(...any) error }) (dto.ItemView, error) {
var (
v dto.ItemView
parentID sql.NullInt64
finished int
favUser int64
hasCover int
)
err := row.Scan(
&v.Item.ID, &v.Item.LibraryID, &v.Item.Kind, &v.Item.Path, &v.Item.Title,
&v.Item.Year, &v.Item.Missing,
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
)
if err != nil {
return dto.ItemView{}, err
}
v.ParentID = parentID.Int64
v.Finished = finished == 1
v.Favorite = favUser != 0
v.HasCover = hasCover == 1
return v, 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
}