- jobs: a table-backed queue in the same SQLite file. No Redis, no second process, included in the same backup. claim() is a single UPDATE...RETURNING so a job cannot run twice even with several workers. Failures back off exponentially; a missing handler fails permanently rather than retrying a bug. - metadata: Provider interface plus Open Library and Google Books (both keyless). The enricher tries providers in registration order, falls back on empty, and continues past a provider error. - migration 0004: metadata_fetch records the attempt, not just the match, so a rescan does not re-hammer a rate-limited API for an item that will never match. Errors are always retried; ok/notfound are cached for 30 days. - enrichment writes provider_ids, updates title/year, links authors through people/item_people, and downloads the cover as an asset. The cover download runs outside the transaction: a slow image host must not hold a write lock. - endpoints: queue enrichment per library, and inspect an item's matches. - web: Enrich button per library. Verified live against Open Library: "The Hobbit" matched /works/OL27482W, title and year 1937 written, a 301x500 JPEG cover downloaded and served.
245 lines
6.8 KiB
Go
245 lines
6.8 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
"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)
|
|
}
|
|
}
|