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.
193 lines
6.2 KiB
Go
193 lines
6.2 KiB
Go
package media
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
// These fixtures are duplicated from internal/home. Go test helpers cannot be
|
|
// shared across packages without being exported from non-test code, and the home
|
|
// screen's version is a different shape: it seeds progress and views so the
|
|
// shelves have something to aggregate.
|
|
|
|
// ── shared helpers ─────────────────────────────────────────────────────
|
|
|
|
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
|
t.Helper()
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return NewHandlers(db, log, nil, t.TempDir())
|
|
}
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
|
|
t.Fatalf("query %q: %v", q, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
|
t.Helper()
|
|
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
|
|
t.Fatalf("exec %q: %v", q, err)
|
|
}
|
|
}
|
|
|
|
func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
|
|
t.Helper()
|
|
return mustQueryID(t, db,
|
|
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
|
|
}
|
|
|
|
func newUser(t *testing.T, db *sql.DB, name string) int64 {
|
|
t.Helper()
|
|
return mustQueryID(t, db,
|
|
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
|
|
}
|
|
|
|
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
|
|
t.Helper()
|
|
mustExec(t, db,
|
|
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
|
|
userID, libID)
|
|
}
|
|
|
|
// newItem inserts a media item plus a backing file, because progress rows carry
|
|
// a NOT NULL file_id foreign key.
|
|
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
|
|
t.Helper()
|
|
m := 0
|
|
if missing {
|
|
m = 1
|
|
}
|
|
id := mustQueryID(t, db, `
|
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
|
libID, kind, path, title, sortTitle, m, addedAt)
|
|
mustExec(t, db,
|
|
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
|
|
id, "file-"+strconv.FormatInt(id, 10))
|
|
return id
|
|
}
|
|
|
|
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
if err := db.QueryRowContext(context.Background(),
|
|
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
|
|
t.Fatalf("file for item %d: %v", itemID, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
|
|
t.Helper()
|
|
f := 0
|
|
if finished {
|
|
f = 1
|
|
}
|
|
mustExec(t, db, `
|
|
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
|
|
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
|
|
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
|
|
}
|
|
|
|
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
|
|
t.Helper()
|
|
mustExec(t, db,
|
|
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
|
|
userID, itemID, views, lastViewedAt)
|
|
}
|
|
|
|
// setupHome builds the two-library, three-user fixture the item-detail tests
|
|
// use: one item the caller may read, one in a library they may not, and one user
|
|
// with no grants at all.
|
|
func setupHome(t *testing.T) homeFixture {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
f := homeFixture{db: db}
|
|
|
|
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
|
|
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
|
|
|
|
f.userA = newUser(t, db, "alice")
|
|
f.userB = newUser(t, db, "bob")
|
|
f.userNone = newUser(t, db, "carol")
|
|
|
|
grantRead(t, db, f.userA, f.libA)
|
|
grantRead(t, db, f.userB, f.libB)
|
|
|
|
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
|
|
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
|
|
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
|
|
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
|
|
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
|
|
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
|
|
|
|
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
|
|
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
|
|
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
|
|
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
|
|
|
|
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
|
|
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
|
|
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
|
|
|
|
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
|
|
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
|
|
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
|
|
|
|
return f
|
|
}
|
|
|
|
func itoa(n int64) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b [20]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(b[i:])
|
|
}
|
|
|
|
type homeFixture struct {
|
|
db *sql.DB
|
|
libA int64
|
|
libB int64
|
|
userA int64
|
|
userB int64
|
|
userNone int64
|
|
a1 int64
|
|
a2 int64
|
|
a3 int64
|
|
a4 int64
|
|
a5 int64
|
|
b1 int64
|
|
}
|