Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.
internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
the package that authorizes the item; it imports home.RecordView for that one
thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
return different explore ids, which is the non-determinism the Explore rewrite
exists for.
internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
are not the scanner (modules register types, handlers serve the list, metadata
reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
not of the scan. The one genuinely scanner-owned helper became
library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
detection still short-circuits.
internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
the database writes. The split's whole point is that a provider is testable
WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
because real false matches were found.
dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
grep. That is what makes them reviewable.
- No field name, tag or error code changed.
internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.
Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.
Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.
Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
360 lines
10 KiB
Go
360 lines
10 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.
|
|
const itemColumns = `i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.missing, ` +
|
|
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
|
|
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.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...)
|
|
}
|
|
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
|
|
}
|