Vellum/internal/media/items.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

124 lines
3.3 KiB
Go

package media
import (
"net/http"
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// itemRow is the browse-list shape.
type itemRow struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Path string `json:"path"`
Title string `json:"title"`
Year int `json:"year"`
// SeasonNumber and EpisodeNumber are the television tree (migration 0013).
// They are omitted rather than sent as 0 for everything else, so a payload
// for a library with no television in it is byte-for-byte what it was.
SeasonNumber int `json:"season_number,omitempty"`
EpisodeNumber int `json:"episode_number,omitempty"`
}
// handleListItems lists items across every library the caller may read,
// optionally filtered by media kind or a single library.
//
// This is what powers "Browse > Ebooks" — one request, scoped by the same
// access-control chokepoint as everything else.
func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u auth.User) {
ctx := r.Context()
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
limit := 300
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
offset := 0
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
if len(libs) == 0 {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"items": []itemRow{},
"total": 0,
"offset": offset,
"limit": limit,
})
return
}
in, args := inClause(libs)
where := `missing = 0 AND library_id IN (` + in + `)`
if kind := r.URL.Query().Get("kind"); kind != "" {
where += ` AND kind = ?`
args = append(args, kind)
}
if libStr := r.URL.Query().Get("library_id"); libStr != "" {
libID, err := strconv.ParseInt(libStr, 10, 64)
if err != nil || libID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid library_id")
return
}
where += ` AND library_id = ?`
args = append(args, libID)
}
// total counts every matching row, independent of the page window.
var total int
if err := h.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM media_items WHERE `+where, args...).Scan(&total); err != nil {
h.fail(w, "count items", err)
return
}
query := `SELECT id, library_id, kind, path, title, COALESCE(year, 0),
COALESCE(season_number, 0), COALESCE(episode_number, 0)
FROM media_items
WHERE ` + where + ` ORDER BY sort_title LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), limit, offset)
rows, err := h.db.QueryContext(ctx, query, pageArgs...)
if err != nil {
h.fail(w, "list items", err)
return
}
defer rows.Close()
items := []itemRow{}
for rows.Next() {
var it itemRow
if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year,
&it.SeasonNumber, &it.EpisodeNumber); err != nil {
h.fail(w, "scan item", err)
return
}
items = append(items, it)
}
if err := rows.Err(); err != nil {
h.fail(w, "iterate items", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}