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.
430 lines
13 KiB
Go
430 lines
13 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/internal/library"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "scan.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 testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestScanLifecycle(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
|
|
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
|
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
|
|
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
|
|
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
|
|
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
|
|
|
|
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// First scan: three supported files, nothing else.
|
|
res, err := Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if res.Added != 3 || res.Scanned != 3 {
|
|
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
|
|
}
|
|
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
|
|
t.Errorf("item count = %d, want 3", n)
|
|
}
|
|
|
|
// Second scan: nothing changed.
|
|
res, err = Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
|
|
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
|
|
}
|
|
|
|
// Modify a.epub (different size).
|
|
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
res, err = Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Updated != 1 || res.Unchanged != 2 {
|
|
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
|
|
}
|
|
|
|
// Delete b.epub: it goes missing, it is not deleted.
|
|
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err = Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Missing != 1 {
|
|
t.Errorf("after delete missing=%d, want 1", res.Missing)
|
|
}
|
|
if n, _ := library.CountItems(ctx, db, lib.ID); n != 2 {
|
|
t.Errorf("item count (excluding missing) = %d, want 2", n)
|
|
}
|
|
|
|
// The row still exists, flagged missing.
|
|
var missing int
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
|
|
t.Fatalf("missing item row was deleted: %v", err)
|
|
}
|
|
if missing != 1 {
|
|
t.Errorf("missing flag = %d, want 1", missing)
|
|
}
|
|
|
|
// Restore b.epub.
|
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
|
res, err = Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Restored != 1 {
|
|
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
|
|
}
|
|
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
|
|
t.Errorf("item count after restore = %d, want 3", n)
|
|
}
|
|
}
|
|
|
|
func TestScanGroupsDirectoryKinds(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
|
|
// An audiobook is a folder of chapters.
|
|
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
|
|
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
|
|
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
|
|
// A loose file at the root groups under the library name.
|
|
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
|
|
|
|
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
res, err := Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if res.Added != 4 {
|
|
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
|
|
}
|
|
|
|
// Two items: the directory, and the root group.
|
|
var items int
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if items != 2 {
|
|
t.Fatalf("items = %d, want 2", items)
|
|
}
|
|
|
|
var title string
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
|
|
t.Fatalf("grouped item not found: %v", err)
|
|
}
|
|
if title != "Book" {
|
|
t.Errorf("grouped title = %q, want Book", title)
|
|
}
|
|
|
|
// Tracks must come back in natural order, not lexical.
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
|
|
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var paths []string
|
|
for rows.Next() {
|
|
var p string
|
|
if err := rows.Scan(&p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
paths = append(paths, p)
|
|
}
|
|
want := []string{
|
|
"Author/Book/01 - Chapter One.mp3",
|
|
"Author/Book/02 - Chapter Two.mp3",
|
|
"Author/Book/10 - Chapter Ten.mp3",
|
|
}
|
|
if len(paths) != len(want) {
|
|
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
|
|
}
|
|
for i := range want {
|
|
if paths[i] != want[i] {
|
|
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanUnreadableRoot(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
writeFile(t, filepath.Join(root, "a.epub"), "x")
|
|
|
|
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Index it once so there is a known file to go missing.
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Remove the root out from under the library.
|
|
if err := os.RemoveAll(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := Scan(ctx, db, lib, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("scan should not fail on a missing root: %v", err)
|
|
}
|
|
if res.Added != 0 {
|
|
t.Errorf("added=%d, want 0", res.Added)
|
|
}
|
|
if res.Missing != 1 {
|
|
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
|
|
}
|
|
if len(res.Errors) == 0 {
|
|
t.Error("expected the walk error to be recorded")
|
|
}
|
|
}
|
|
func TestMissingSinceLifecycle(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
|
|
|
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatalf("first Scan: %v", err)
|
|
}
|
|
|
|
// A present file has no missing timestamp.
|
|
var since string
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if since != "" {
|
|
t.Errorf("present file missing_since = %q, want empty", since)
|
|
}
|
|
|
|
// Delete b.epub: it goes missing and the timestamp is recorded.
|
|
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatalf("missing Scan: %v", err)
|
|
}
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if since == "" {
|
|
t.Fatal("missing_since was not set on the transition into missing")
|
|
}
|
|
first := since
|
|
|
|
// A rescan must not refresh the age.
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatalf("rescan: %v", err)
|
|
}
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if since != first {
|
|
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
|
|
}
|
|
|
|
// ListMissing surfaces the file with its age.
|
|
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListMissing: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
|
|
}
|
|
e := entries[0]
|
|
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
|
|
t.Errorf("unexpected missing entry: %+v", e)
|
|
}
|
|
if e.MissingSince != first {
|
|
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
|
|
}
|
|
|
|
// Restore the file: missing_since must be cleared, not left behind.
|
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatalf("restore Scan: %v", err)
|
|
}
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if since != "" {
|
|
t.Errorf("missing_since after restore = %q, want empty", since)
|
|
}
|
|
if got, _ := library.ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
|
|
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
|
|
// Two audiobooks, each a folder of tracks.
|
|
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
|
|
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
|
|
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
|
|
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
|
|
|
|
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
|
|
// A second library with its own missing item, to prove purge is scoped.
|
|
otherRoot := t.TempDir()
|
|
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
|
|
other, err := library.Create(ctx, db, "Other", "ebooks", otherRoot)
|
|
if err != nil {
|
|
t.Fatalf("Create other: %v", err)
|
|
}
|
|
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
|
t.Fatalf("Scan other: %v", err)
|
|
}
|
|
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
|
t.Fatalf("Scan other (missing): %v", err)
|
|
}
|
|
|
|
// Remove one track from Book A: the item is not missing, only that file.
|
|
// Remove Book B entirely: the item and both its files are missing.
|
|
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
t.Fatalf("second Scan: %v", err)
|
|
}
|
|
|
|
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListMissing: %v", err)
|
|
}
|
|
if len(entries) != 3 {
|
|
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
|
|
}
|
|
|
|
// Purge must remove only Book B.
|
|
n, err := library.PurgeMissing(ctx, db, lib.ID)
|
|
if err != nil {
|
|
t.Fatalf("PurgeMissing: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
|
|
}
|
|
|
|
// Book A survives, with its present and missing files intact.
|
|
var items int
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if items != 1 {
|
|
t.Errorf("media_items after purge = %d, want 1", items)
|
|
}
|
|
var bookATitle string
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
|
|
t.Fatalf("a non-missing item was purged: %v", err)
|
|
}
|
|
var files int
|
|
if err := db.QueryRowContext(ctx, `
|
|
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
|
|
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if files != 2 {
|
|
t.Errorf("Book A files after purge = %d, want 2", files)
|
|
}
|
|
|
|
// The other library's missing item is untouched.
|
|
var otherItems int
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if otherItems != 1 {
|
|
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
|
|
}
|
|
|
|
// A second purge has nothing left to remove.
|
|
if n, err := library.PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
|
|
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
|
|
}
|
|
}
|