package jellyfin import ( "context" "database/sql" "errors" "net/http" "strings" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/httpx" "github.com/mithral/vellum/internal/jellyfin/dto" "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/stream" ) // handleItems is the browse workhorse for GET /Items. The /Users/{userId}/Items // form delegates here; the token's user is authoritative for scoping. func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.User) { ctx := r.Context() q := r.URL.Query() libs, err := auth.VisibleLibraryIDs(ctx, h.db, u) if err != nil { h.fail(w, "visible libraries", err) return } if libs == nil { libs = []int64{} } startIndex := atoiDefault(q.Get("StartIndex"), 0) if startIndex < 0 { startIndex = 0 } limit := atoiDefault(q.Get("Limit"), defaultLimit) if limit <= 0 || limit > maxLimit { limit = maxLimit } parentRaw := strings.TrimSpace(q.Get("ParentId")) recursive := parseBool(q.Get("Recursive")) search := strings.TrimSpace(q.Get("SearchTerm")) types := splitCSV(q.Get("IncludeItemTypes")) // Top level: no parent, not recursive, no search. Real clients expect the // user's views here, so the visible libraries are returned as // CollectionFolder items to drill into. if parentRaw == "" && !recursive && search == "" { h.writeViews(w, ctx, libs, startIndex, limit) return } // A user with no grants gets an empty page, never an error and never // content from another library. if len(libs) == 0 { writeItems(w, startIndex, 0, nil) return } query := listQuery{ userID: u.ID, libraryIDs: libs, sortColumn: sortColumn(q.Get("SortBy")), descending: isDescending(q.Get("SortOrder")), limit: limit, offset: startIndex, searchTerm: search, filters: splitCSV(q.Get("Filters")), kinds: dto.KindsForTypes(types), episodesOnly: dto.EpisodesOnly(types), includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"), } if parentRaw != "" { pid, isLib, ok := dto.DecodeID(parentRaw) if !ok { // An unknown ParentId is an empty page, not an error: clients probe // ids and expect to be told there is nothing there. writeItems(w, startIndex, 0, nil) return } if isLib { if !containsInt64(libs, pid) { writeItems(w, startIndex, 0, nil) return } query.libraryIDs = []int64{pid} } else { // Children of an item, and only if that item is visible. visible, err := h.itemVisible(ctx, u, pid) if err != nil { h.fail(w, "check parent", err) return } if !visible { writeItems(w, startIndex, 0, nil) return } query.parentItemID = pid } } items, total, err := h.queryItems(ctx, query) if err != nil { h.fail(w, "list items", err) return } apiKey := tokenFromRequest(r) dtos := make([]dto.BaseItemDto, 0, len(items)) for _, it := range items { dtos = append(dtos, dto.ToBaseItemDto(it, query.includeSources, apiKey)) } writeItems(w, startIndex, total, dtos) } // handleUserItems serves GET /Users/{userId}/Items. The token's user is // authoritative; a mismatched path id is a 404 (cross-user reads are not // implemented — see the package notes). func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) { id, _, ok := dto.DecodeID(r.PathValue("userId")) if !ok || id != u.ID { httpx.WriteError(w, http.StatusNotFound, "user not found") return } h.handleItems(w, r, u) } // handleItem serves one BaseItemDto. A library id is answered as a folder so a // client that treats views as items still works. func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) { id, isLib, ok := dto.DecodeID(r.PathValue("itemId")) if !ok { httpx.WriteError(w, http.StatusNotFound, "item not found") return } if isLib { lib, err := library.Get(r.Context(), h.db, id) if errors.Is(err, library.ErrNotFound) { httpx.WriteError(w, http.StatusNotFound, "item not found") return } if err != nil { h.fail(w, "load library", err) return } allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id) if err != nil { h.fail(w, "check access", err) return } if !allowed { httpx.WriteError(w, http.StatusNotFound, "item not found") return } httpx.WriteJSON(w, http.StatusOK, dto.LibraryDto(lib)) return } it, ok := h.authorizedItem(w, r, u, id) if !ok { return } v := dto.ItemView{Item: it} if err := h.attachFile(r.Context(), &v); err != nil { h.fail(w, "load file", err) return } if err := h.loadOverlay(r.Context(), u.ID, it.ID, &v); err != nil { if errors.Is(err, sql.ErrNoRows) { httpx.WriteError(w, http.StatusNotFound, "item not found") return } h.fail(w, "load item state", err) return } httpx.WriteJSON(w, http.StatusOK, dto.ToBaseItemDto(v, true, tokenFromRequest(r))) } // handleImagePrimary streams the cover bytes the scanner/web already extracted. // // It does not trigger extraction: NewHandlers has no cache directory, and the // extraction path belongs to the web layer. An item whose cover has not been // extracted yet simply 404s, which clients treat as "no artwork". func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) { id, isLib, ok := dto.DecodeID(r.PathValue("itemId")) if !ok || isLib { httpx.WriteError(w, http.StatusNotFound, "no cover available") return } if _, ok := h.authorizedItem(w, r, u, id); !ok { return } var path string err := h.db.QueryRowContext(r.Context(), `SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, id).Scan(&path) if errors.Is(err, sql.ErrNoRows) { httpx.WriteError(w, http.StatusNotFound, "no cover available") return } if err != nil { h.fail(w, "load cover", err) return } w.Header().Set("Cache-Control", "public, max-age=86400") if err := stream.ServeFile(w, r, path); err != nil { httpx.WriteError(w, http.StatusNotFound, "no cover available") return } } // authorizedItem loads an item and confirms access, writing a 404 (never a 403, // so existence is not disclosed) on failure. Both the load and the grant check // live in catalog.LoadFor, which returns the same ErrNotFound for a missing // item and an unreadable one. func (h *Handlers) authorizedItem(w http.ResponseWriter, r *http.Request, u auth.User, id int64) (catalog.Item, bool) { it, err := catalog.LoadFor(r.Context(), h.db, u, id) if errors.Is(err, catalog.ErrNotFound) { httpx.WriteError(w, http.StatusNotFound, "item not found") return catalog.Item{}, false } if err != nil { h.fail(w, "load item", err) return catalog.Item{}, false } return it, true } // writeViews returns the caller's visible libraries as CollectionFolder items, // paginated like any other browse result. func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs []int64, startIndex, limit int) { list, err := library.ListVisible(ctx, h.db, libs) if err != nil { h.fail(w, "list libraries", err) return } total := len(list) if startIndex > total { startIndex = total } end := startIndex + limit if end > total { end = total } items := make([]dto.BaseItemDto, 0, end-startIndex) for _, lib := range list[startIndex:end] { items = append(items, dto.LibraryDto(lib)) } writeItems(w, startIndex, total, items) } func writeItems(w http.ResponseWriter, startIndex, total int, items []dto.BaseItemDto) { if items == nil { items = []dto.BaseItemDto{} } httpx.WriteJSON(w, http.StatusOK, dto.ItemQueryResult{ Items: items, TotalRecordCount: total, StartIndex: startIndex, }) }