A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.
- registry: MediaType.Group (file | directory). Audiobooks and music group by
directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
their directory disappears, and keeps the file-level added/updated/missing
accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
verifies the file belongs to the item, so an item id cannot be paired with
another item's file id.
No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.
Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
205 lines
5.7 KiB
Go
205 lines
5.7 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
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 := 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, _ := 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, _ := 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, _ := 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 := 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 := 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")
|
|
}
|
|
}
|