package jellyfin import ( "context" "database/sql" "sort" "strconv" "strings" "github.com/mithral/vellum/internal/auth" ) // 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 ) // itemColumns / itemFrom are shared by the list and single-item queries so the // two paths can never disagree about what a dbItem contains. // // File facts come from the item's first media_files row (multi-file items such // as audiobook chapters are still one browsable item). Per-user state comes // from a windowed progress join that picks the most recently updated row per // item, plus item_views and favorites. const itemColumns = `i.id, i.library_id, i.kind, i.title, i.sort_title, COALESCE(i.year,0), i.parent_id, COALESCE(f.container,''), COALESCE(f.duration_ms,0), COALESCE(f.vcodec,''), COALESCE(f.acodec,''), COALESCE(f.width,0), COALESCE(f.height,0), COALESCE(f.size,0), COALESCE(f.path,''), p.item_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 itemFrom = `FROM media_items i LEFT JOIN media_files f ON f.id = (SELECT id FROM media_files WHERE item_id=i.id ORDER BY id LIMIT 1) 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` // 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. func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dbItem, 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 + ` ` + itemFrom + ` 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 := []dbItem{} for rows.Next() { it, err := scanItem(rows) if err != nil { return nil, 0, err } out = append(out, it) } return out, total, rows.Err() } // loadDBItem loads one item with the same columns as the browse query. func (h *Handlers) loadDBItem(ctx context.Context, userID, id int64) (dbItem, error) { row := h.db.QueryRowContext(ctx, `SELECT `+itemColumns+` `+itemFrom+` WHERE i.id = ?`, userID, userID, userID, id) return scanItem(row) } // itemVisible reports whether the caller may read the item's library. A // missing item is false, not an error. func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool, error) { var libID int64 err := h.db.QueryRowContext(ctx, `SELECT library_id FROM media_items WHERE id = ?`, id).Scan(&libID) if err == sql.ErrNoRows { return false, nil } if err != nil { return false, err } return auth.CanAccessLibrary(ctx, h.db, u, libID) } // scanItem reads one row of the shared item column list. func scanItem(row interface{ Scan(...any) error }) (dbItem, error) { var ( it dbItem parentID sql.NullInt64 hasProgress sql.NullInt64 finished int favUser int64 hasCover int ) err := row.Scan( &it.ID, &it.LibraryID, &it.Kind, &it.Title, &it.SortTitle, &it.Year, &parentID, &it.Container, &it.DurationMS, &it.VCodec, &it.ACodec, &it.Width, &it.Height, &it.Size, &it.Path, &hasProgress, &it.PositionMS, &it.Percent, &finished, &it.PlayCount, &favUser, &hasCover, ) if err != nil { return dbItem{}, err } it.ParentID = parentID it.HasProgress = hasProgress.Valid it.Finished = finished == 1 it.Favorite = favUser != 0 it.HasCover = hasCover == 1 return it, 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 }