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.
299 lines
9.6 KiB
Go
299 lines
9.6 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
// ErrNotFound means no library matched.
|
|
ErrNotFound = errors.New("library: not found")
|
|
// ErrNameExists means the library name is taken.
|
|
ErrNameExists = errors.New("library: name already in use")
|
|
// ErrInvalidRoot means the root path is missing, not a directory, or not
|
|
// readable.
|
|
ErrInvalidRoot = errors.New("library: invalid root path")
|
|
)
|
|
|
|
// Library is a media root of a single kind.
|
|
type Library struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
RootPath string `json:"root_path"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
const librarySelect = `SELECT id, name, kind, root_path, created_at FROM libraries`
|
|
|
|
// Create validates and inserts a library.
|
|
//
|
|
// The root path is resolved to an absolute, symlink-free path and must exist.
|
|
// Storing the resolved form matters: the scanner compares filesystem paths
|
|
// against this value, and "/media/../media" would silently match nothing.
|
|
func Create(ctx context.Context, db *sql.DB, name, kind, rootPath string) (Library, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return Library{}, errors.New("library: name must not be empty")
|
|
}
|
|
if err := ValidateKind(kind); err != nil {
|
|
return Library{}, err
|
|
}
|
|
|
|
resolved, err := resolveRoot(rootPath)
|
|
if err != nil {
|
|
return Library{}, err
|
|
}
|
|
|
|
var lib Library
|
|
var created string
|
|
err = db.QueryRowContext(ctx, `
|
|
INSERT INTO libraries (name, kind, root_path)
|
|
VALUES (?, ?, ?)
|
|
RETURNING id, created_at`,
|
|
name, strings.ToLower(kind), resolved,
|
|
).Scan(&lib.ID, &created)
|
|
if err != nil {
|
|
if isUnique(err) {
|
|
return Library{}, ErrNameExists
|
|
}
|
|
return Library{}, fmt.Errorf("library: create: %w", err)
|
|
}
|
|
lib.Name, lib.Kind, lib.RootPath = name, strings.ToLower(kind), resolved
|
|
lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
|
|
return lib, nil
|
|
}
|
|
|
|
// Get loads one library by id.
|
|
func Get(ctx context.Context, db *sql.DB, id int64) (Library, error) {
|
|
return scanLibrary(db.QueryRowContext(ctx, librarySelect+` WHERE id = ?`, id))
|
|
}
|
|
|
|
// List returns all libraries.
|
|
func List(ctx context.Context, db *sql.DB) ([]Library, error) {
|
|
return queryLibraries(ctx, db, librarySelect+` ORDER BY name`)
|
|
}
|
|
|
|
// ListVisible returns the libraries in the given id set. An empty set yields an
|
|
// empty slice, never "all" — that distinction is the whole point of the
|
|
// access-control model.
|
|
func ListVisible(ctx context.Context, db *sql.DB, ids []int64) ([]Library, error) {
|
|
if len(ids) == 0 {
|
|
return []Library{}, nil
|
|
}
|
|
placeholders := make([]string, len(ids))
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
q := librarySelect + ` WHERE id IN (` + strings.Join(placeholders, ",") + `) ORDER BY name`
|
|
return queryLibraries(ctx, db, q, args...)
|
|
}
|
|
|
|
// Delete removes a library. Items, files, and grants cascade.
|
|
func Delete(ctx context.Context, db *sql.DB, id int64) error {
|
|
res, err := db.ExecContext(ctx, `DELETE FROM libraries WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("library: delete: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CountItems returns how many items a library holds (excluding missing).
|
|
func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM media_items WHERE library_id = ? AND missing = 0`, id).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// MissingEntry is one missing file, surfaced so an admin can see what the
|
|
// scanner has flagged and decide whether to purge it.
|
|
//
|
|
// It carries both the file (file_id) and its owning item (item_id) because the
|
|
// file is what actually vanished, while the item is what a purge deletes. A
|
|
// multi-file item (an audiobook of tracks) produces one entry per missing file.
|
|
type MissingEntry struct {
|
|
ItemID int64 `json:"item_id"`
|
|
FileID int64 `json:"file_id"`
|
|
LibraryID int64 `json:"library_id"`
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title"`
|
|
Path string `json:"path"`
|
|
MissingSince string `json:"missing_since"`
|
|
}
|
|
|
|
// ListMissing returns the missing files in the given libraries, grouped by
|
|
// library and then by title.
|
|
//
|
|
// It is keyed on media_files because a missing file is the thing the scanner
|
|
// saw disappear; an item whose whole group is gone has each of its files
|
|
// flagged, so every missing item still appears. ids is the caller's visible
|
|
// set, resolved by the auth layer — an empty set yields nothing, never "all",
|
|
// which is the same rule ListVisible follows.
|
|
func ListMissing(ctx context.Context, db *sql.DB, ids []int64) ([]MissingEntry, error) {
|
|
if len(ids) == 0 {
|
|
return []MissingEntry{}, nil
|
|
}
|
|
placeholders := make([]string, len(ids))
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT i.id, f.id, i.library_id, i.kind, i.title, f.path,
|
|
CASE WHEN f.missing_since <> '' THEN f.missing_since ELSE i.missing_since END
|
|
FROM media_files f
|
|
JOIN media_items i ON i.id = f.item_id
|
|
WHERE f.missing = 1 AND i.library_id IN (`+strings.Join(placeholders, ",")+`)
|
|
ORDER BY i.library_id, i.sort_title, f.path`, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: list missing: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []MissingEntry{}
|
|
for rows.Next() {
|
|
var e MissingEntry
|
|
if err := rows.Scan(&e.ItemID, &e.FileID, &e.LibraryID, &e.Kind, &e.Title, &e.Path, &e.MissingSince); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PurgeMissing deletes the items in a library that are genuinely missing:
|
|
// flagged missing and with no file left present. It returns how many items were
|
|
// removed.
|
|
//
|
|
// This is the ONLY place media rows are deleted on purpose, and it is an
|
|
// explicit admin action rather than part of a scan because the delete is a
|
|
// one-way door. Deleting a media_items row cascades (foreign_keys is on) into:
|
|
//
|
|
// - media_files.item_id — the files themselves
|
|
// - progress.item_id — watch/read positions, which cannot be
|
|
// regenerated; this is why a scan never deletes
|
|
// - progress.file_id — via the cascaded media_files rows
|
|
// - item_people.item_id — cast and author credits
|
|
// - provider_ids.item_id — external metadata matches
|
|
// - metadata_fetch.item_id — provider attempt history
|
|
// - assets.item_id — covers, thumbnails, extracted pages
|
|
// - collection_items.item_id — collection membership
|
|
// - favorites.item_id
|
|
// - item_views.item_id — per-user view counts
|
|
// - game_saves.item_id — emulator save states
|
|
// - child media_items — the parent_id self-reference
|
|
//
|
|
// The WHERE clause is deliberately narrow: an item is purged only when it is
|
|
// flagged missing AND has no present file, so an item missing one track while
|
|
// its other tracks remain on disk is never touched.
|
|
func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, error) {
|
|
res, err := db.ExecContext(ctx, `
|
|
DELETE FROM media_items
|
|
WHERE library_id = ?
|
|
AND missing = 1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM media_files f
|
|
WHERE f.item_id = media_items.id AND f.missing = 0
|
|
)`, libraryID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("library: purge missing: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("library: purge missing: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// RootExists reports whether a library's root is currently reachable.
|
|
func RootExists(lib Library) bool {
|
|
info, err := os.Stat(lib.RootPath)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────
|
|
|
|
func resolveRoot(rootPath string) (string, error) {
|
|
if strings.TrimSpace(rootPath) == "" {
|
|
return "", fmt.Errorf("%w: path is empty", ErrInvalidRoot)
|
|
}
|
|
abs, err := filepath.Abs(rootPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
abs = resolved
|
|
} else {
|
|
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
|
|
}
|
|
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("%w: %s is not a directory", ErrInvalidRoot, abs)
|
|
}
|
|
// Confirm readability; a directory can exist and still deny access.
|
|
f, err := os.Open(abs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
|
|
}
|
|
_ = f.Close()
|
|
return abs, nil
|
|
}
|
|
|
|
func queryLibraries(ctx context.Context, db *sql.DB, q string, args ...any) ([]Library, error) {
|
|
rows, err := db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: query: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Library{}
|
|
for rows.Next() {
|
|
lib, err := scanLibrary(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, lib)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
type scanner interface{ Scan(dest ...any) error }
|
|
|
|
func scanLibrary(row scanner) (Library, error) {
|
|
var lib Library
|
|
var created string
|
|
if err := row.Scan(&lib.ID, &lib.Name, &lib.Kind, &lib.RootPath, &created); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Library{}, ErrNotFound
|
|
}
|
|
return Library{}, err
|
|
}
|
|
lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
|
|
return lib, nil
|
|
}
|
|
|
|
func isUnique(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToUpper(err.Error())
|
|
return strings.Contains(msg, "UNIQUE CONSTRAINT FAILED") || strings.Contains(msg, "CONSTRAINT FAILED: UNIQUE")
|
|
}
|