Vellum/internal/jellyfin/query.go
mithral 2c424ae67c Expose the television tree: catalog reads, API routes, Jellyfin shapes
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.
2026-10-12 01:41:05 -04:00

372 lines
11 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.
//
// season_number and episode_number are the television tree (migration 0013) and
// they are load-bearing here: an episode is kind 'video' exactly like a film, so
// the episode number is the only thing that tells a client which is which.
const itemColumns = `i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.missing, ` +
`COALESCE(i.season_number,0), COALESCE(i.episode_number,0), ` +
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
episodesOnly bool // IncludeItemTypes asked for episodes and not films
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.Item.SeasonNumber, &v.Item.EpisodeNumber,
&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...)
}
// An episode and a film are the same kind, so "Episodes, not Movies" is a
// filter on the number rather than the kind — applied only to the video
// rows, or it would throw away the series and seasons in the same query.
if q.episodesOnly {
clauses = append(clauses, "(i.kind <> 'video' OR i.episode_number IS NOT NULL)")
}
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
}