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 }