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) } }