package metadata import ( "context" "database/sql" "errors" "io" "log/slog" "path/filepath" "testing" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/media" "github.com/mithral/vellum/migrations" ) func testDB(t *testing.T) *sql.DB { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "meta.db")) if err != nil { t.Fatalf("OpenDB: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := core.Migrate(context.Background(), db, migrations.FS, discard()); err != nil { t.Fatalf("Migrate: %v", err) } return db } func discard() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func seedItem(t *testing.T, db *sql.DB, title string) int64 { t.Helper() ctx := context.Background() var libID int64 if err := db.QueryRowContext(ctx, `INSERT INTO libraries (name, kind, root_path) VALUES ('Ebooks','ebooks','/tmp/e') RETURNING id`).Scan(&libID); err != nil { t.Fatal(err) } var itemID int64 if err := db.QueryRowContext(ctx, ` INSERT INTO media_items (library_id, kind, path, title, sort_title) VALUES (?, 'ebooks', 'book.epub', ?, ?) RETURNING id`, libID, title, title).Scan(&itemID); err != nil { t.Fatal(err) } // A file row so media.LoadItem can resolve a modtime. if _, err := db.ExecContext(ctx, `INSERT INTO media_files (item_id, path, size, mtime) VALUES (?, 'book.epub', 1, '1')`, itemID); err != nil { t.Fatal(err) } return itemID } // fakeProvider is a Provider with scripted results. type fakeProvider struct { name string kinds []string results []Candidate err error calls int } func (f *fakeProvider) Name() string { return f.name } func (f *fakeProvider) Kinds() []string { return f.kinds } func (f *fakeProvider) Search(context.Context, Query) ([]Candidate, error) { f.calls++ return f.results, f.err } func enricherWith(t *testing.T, db *sql.DB, provs ...Provider) *Enricher { t.Helper() e := NewEnricher(db, discard(), t.TempDir()) e.providers = func(string) []Provider { return provs } return e } func TestEnrichAppliesCandidate(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "the hobbit") fake := &fakeProvider{ name: "fake", kinds: []string{"ebooks"}, results: []Candidate{{ Provider: "fake", ExternalID: "OL123W", Title: "The Hobbit", Authors: []string{"J.R.R. Tolkien"}, Year: 1937, Publisher: "Allen & Unwin", }}, } e := enricherWith(t, db, fake) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatalf("EnrichItem: %v", err) } ctx := context.Background() var title string var year sql.NullInt64 if err := db.QueryRowContext(ctx, `SELECT title, year FROM media_items WHERE id = ?`, itemID).Scan(&title, &year); err != nil { t.Fatal(err) } if title != "The Hobbit" { t.Errorf("title = %q, want The Hobbit", title) } if !year.Valid || year.Int64 != 1937 { t.Errorf("year = %v, want 1937", year) } var provider, external string if err := db.QueryRowContext(ctx, `SELECT provider, external_id FROM provider_ids WHERE item_id = ?`, itemID).Scan(&provider, &external); err != nil { t.Fatalf("provider_ids not written: %v", err) } if provider != "fake" || external != "OL123W" { t.Errorf("provider_ids = %s/%s", provider, external) } var author string if err := db.QueryRowContext(ctx, ` SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = ? AND ip.role = 'author'`, itemID).Scan(&author); err != nil { t.Fatalf("author not linked: %v", err) } if author != "J.R.R. Tolkien" { t.Errorf("author = %q", author) } var status string if err := db.QueryRowContext(ctx, `SELECT status FROM metadata_fetch WHERE item_id = ? AND provider = 'fake'`, itemID).Scan(&status); err != nil { t.Fatal(err) } if status != "ok" { t.Errorf("fetch status = %q, want ok", status) } } func TestEnrichFallsBackToSecondProvider(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "some book") empty := &fakeProvider{name: "empty", kinds: []string{"ebooks"}} good := &fakeProvider{ name: "good", kinds: []string{"ebooks"}, results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, } e := enricherWith(t, db, empty, good) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatalf("EnrichItem: %v", err) } if empty.calls != 1 || good.calls != 1 { t.Errorf("calls: empty=%d good=%d, want 1/1", empty.calls, good.calls) } var provider string if err := db.QueryRow( `SELECT provider FROM provider_ids WHERE item_id = ?`, itemID).Scan(&provider); err != nil { t.Fatal(err) } if provider != "good" { t.Errorf("matched provider = %q, want good", provider) } } func TestEnrichContinuesAfterProviderError(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "some book") broken := &fakeProvider{name: "broken", kinds: []string{"ebooks"}, err: errors.New("network down")} good := &fakeProvider{ name: "good", kinds: []string{"ebooks"}, results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, } e := enricherWith(t, db, broken, good) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatalf("a provider error must not fail the item: %v", err) } if good.calls != 1 { t.Error("fallback was not consulted after an error") } var status string if err := db.QueryRow( `SELECT status FROM metadata_fetch WHERE item_id = ? AND provider = 'broken'`, itemID).Scan(&status); err != nil { t.Fatal(err) } if status != "error" { t.Errorf("error status = %q, want error", status) } } func TestEnrichSkipsAlreadyMatched(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "book") fake := &fakeProvider{name: "fake", kinds: []string{"ebooks"}, results: []Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}} e := enricherWith(t, db, fake) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if fake.calls != 1 { t.Errorf("provider called %d times, want 1 (second run must skip)", fake.calls) } } func TestEnrichDoesNotRetryRecentNotFound(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "unmatchable") empty := &fakeProvider{name: "empty", kinds: []string{"ebooks"}} e := enricherWith(t, db, empty) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if empty.calls != 1 { t.Errorf("provider called %d times, want 1 (notfound is cached)", empty.calls) } } func TestEnrichIgnoresUnknownKind(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "book") e := enricherWith(t, db) // no providers at all if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Errorf("EnrichItem with no providers errored: %v", err) } } func TestNormalizeTitle(t *testing.T) { cases := map[string]string{ "Random Access Memories": "random access memories", "The Hobbit, or There and Back Again": "the hobbit or there and back again", "AC/DC — Back in Black!": "ac dc back in black", " spaced out ": "spaced out", } for in, want := range cases { if got := normalizeTitle(in); got != want { t.Errorf("normalizeTitle(%q) = %q, want %q", in, got, want) } } } func TestSearchTitleStripsReleaseTags(t *testing.T) { cases := map[string]string{ "Sonic the Hedgehog (USA)": "Sonic the Hedgehog", "Super Mario Bros. (World)": "Super Mario Bros.", "Game [!]": "Game", "Pokemon (USA) (Rev 1)": "Pokemon", "The Legend of Zelda": "The Legend of Zelda", "Chrono Trigger (USA) [En,Fr]": "Chrono Trigger", "(Just Brackets)": "(Just Brackets)", } for in, want := range cases { if got := searchTitle(in); got != want { t.Errorf("searchTitle(%q) = %q, want %q", in, got, want) } } } func TestPickBestRejectsUnrelated(t *testing.T) { candidates := []Candidate{ {Title: "Random Access Memories", ExternalID: "right"}, } // The exact title is accepted. if c, ok := pickBest(candidates, "Random Access Memories"); !ok || c.ExternalID != "right" { t.Errorf("exact match rejected: ok=%v c=%+v", ok, c) } // Containment in either direction is accepted. long := []Candidate{{Title: "The Hobbit, or There and Back Again"}} if _, ok := pickBest(long, "The Hobbit"); !ok { t.Error("containment should be accepted") } // A same-named release by a different artist is still accepted by title — // the artist hint, not the title check, is what disambiguates that. // But a genuinely unrelated title must be rejected. if _, ok := pickBest(candidates, "Franz Kirmann"); ok { t.Error("unrelated title should be rejected") } // Too-short titles are never matched, to avoid substring false positives. if _, ok := pickBest(candidates, "a"); ok { t.Error("a one-character query should be rejected") } } func TestArtistHint(t *testing.T) { // Directory-grouped kinds take the artist from the parent directory. album := media.Item{Kind: "music", Path: "Daft Punk/Random Access Memories"} if got := artistHint(album); got != "Daft Punk" { t.Errorf("artistHint(album) = %q, want Daft Punk", got) } // A loose root-level file has no artist to infer. loose := media.Item{Kind: "music", Path: "."} if got := artistHint(loose); got != "" { t.Errorf("artistHint(loose) = %q, want empty", got) } // File-grouped kinds (ebooks) never take a hint: their parent directory is // usually a series, not an author. book := media.Item{Kind: "ebooks", Path: "Series/Book.epub"} if got := artistHint(book); got != "" { t.Errorf("artistHint(ebook) = %q, want empty", got) } }