diff --git a/changelog.d/scan-symlinked-library-root.md b/changelog.d/scan-symlinked-library-root.md new file mode 100644 index 000000000..7b4c295d2 --- /dev/null +++ b/changelog.d/scan-symlinked-library-root.md @@ -0,0 +1,2 @@ +### Fixed +- **Library Scan finds books in a library folder that is a symlink.** A library or audiobook folder configured as a link (say `/books` pointing at `/mnt/storage/books`, or a container volume path that is a link) was never entered, so a scan found nothing in it. The scan now follows the folder itself and records books under the path you configured. Adding an author now recognises the books you already own in such a folder too. Linked author folders inside the library are still not followed by a scan. diff --git a/docs/User-Guide-Wiki.md b/docs/User-Guide-Wiki.md index 3cb5c91d1..81e15126d 100644 --- a/docs/User-Guide-Wiki.md +++ b/docs/User-Guide-Wiki.md @@ -1083,10 +1083,11 @@ Knowing the edges saves time: linked to another disk (`/books/Author -> /mnt/disk2/Author`), is written through on import and followed when serving a book, including when it points outside your library folders, because only you can create one. - Library Scan is the exception: it does not descend into any linked folder, - including a library folder that is itself a symlink, so books under a linked - author folder (or a linked library root) are not found by a scan. A bind - mount is not a link and scans normally. A linked *file* is + Library Scan follows a library or audiobook folder that is itself a symlink + and records the books under the path you configured, but it does not + descend into a linked folder *inside* it, so books under a linked author + folder are not found by a scan. A bind mount is not a link and scans + normally. A linked *file* is not a book file: imports place regular files only (a symlink inside a download never lands in your library, and a download folder that is itself a symlink is refused in every import mode, including the drop folder diff --git a/internal/importer/library_snapshot.go b/internal/importer/library_snapshot.go index 0a5c5afbc..03545b349 100644 --- a/internal/importer/library_snapshot.go +++ b/internal/importer/library_snapshot.go @@ -178,7 +178,9 @@ func (ls *LibrarySnapshot) entriesFor(ctx context.Context, root string) ([]libra func walkLibraryEntries(ctx context.Context, root string) ([]libraryEntry, bool) { var entries []libraryEntry audioDirs := make(map[string]bool) - _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + // walkRoot enters a root that is itself a symlink, reporting paths under + // root as configured, so FindExisting's answer matches book_files rows. + _ = walkRoot(root, func(path string, info os.FileInfo, err error) error { if ctx.Err() != nil { return filepath.SkipAll } diff --git a/internal/importer/scan_symlinked_root_test.go b/internal/importer/scan_symlinked_root_test.go new file mode 100644 index 000000000..72f302929 --- /dev/null +++ b/internal/importer/scan_symlinked_root_test.go @@ -0,0 +1,169 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/models" +) + +// A library folder configured as a symlink (/books -> /mnt/storage/books, or +// a container volume path that is a link) used to be invisible to Library +// Scan: filepath.Walk Lstats the root, sees a link, and never descends. These +// tests pin that the root itself is followed, that what gets stored is still +// under the configured path, and that links INSIDE the root stay unfollowed. + +// symlinkedRootLibrary builds a real library folder holding +// "Andy Weir/Project Hail Mary.epub" plus a linked author folder +// "Martha Wells -> /Martha Wells" holding "All Systems Red.epub", +// and returns a symlink to the real folder (the configured root). +func symlinkedRootLibrary(t *testing.T) (root string) { + t.Helper() + real := filepath.Join(t.TempDir(), "storage", "books") + if err := os.MkdirAll(filepath.Join(real, "Andy Weir"), 0o755); err != nil { + t.Fatal(err) + } + writeEpubAt(t, filepath.Join(real, "Andy Weir", "Project Hail Mary.epub"), "Project Hail Mary", "Andy Weir", "") + + elsewhere := filepath.Join(t.TempDir(), "Martha Wells") + if err := os.MkdirAll(elsewhere, 0o755); err != nil { + t.Fatal(err) + } + writeEpubAt(t, filepath.Join(elsewhere, "All Systems Red.epub"), "All Systems Red", "Martha Wells", "") + symlinkOrSkip(t, elsewhere, filepath.Join(real, "Martha Wells")) + + root = filepath.Join(t.TempDir(), "books") + symlinkOrSkip(t, real, root) + return root +} + +func TestScanLibrary_SymlinkedLibraryRootIsScanned(t *testing.T) { + root := symlinkedRootLibrary(t) + + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + ctx := context.Background() + books := db.NewBookRepo(database) + authors := db.NewAuthorRepo(database) + s := NewScanner(db.NewDownloadRepo(database), db.NewDownloadClientRepo(database), books, authors, + db.NewHistoryRepo(database), root, "", "", "", "") + s.WithSettings(db.NewSettingsRepo(database)) + s.WithUnmatchedUnits(db.NewUnmatchedUnitRepo(database)) + + seed := func(foreign, name, sort, bookForeign, title string) *models.Book { + t.Helper() + a := &models.Author{ForeignID: foreign, Name: name, SortName: sort, MetadataProvider: "openlibrary"} + if err := authors.Create(ctx, a); err != nil { + t.Fatal(err) + } + b := &models.Book{ForeignID: bookForeign, AuthorID: a.ID, Title: title, + Status: models.BookStatusWanted, MediaType: models.MediaTypeEbook, MetadataProvider: "openlibrary"} + if err := books.Create(ctx, b); err != nil { + t.Fatal(err) + } + return b + } + phm := seed("ol:weir", "Andy Weir", "Weir, Andy", "ol:phm", "Project Hail Mary") + asr := seed("ol:wells", "Martha Wells", "Wells, Martha", "ol:asr", "All Systems Red") + + s.ScanLibrary(ctx) + + got, err := books.GetByID(ctx, phm.ID) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "Andy Weir", "Project Hail Mary.epub") + if got.EbookFilePath != want { + t.Fatalf("ebook path = %q, want %q: a library folder that is a symlink must be scanned, and the stored path must stay under the configured root", got.EbookFilePath, want) + } + files, err := books.ListFiles(ctx, phm.ID) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0].Path != want { + t.Fatalf("book files = %+v, want one at %q", files, want) + } + + // The linked author folder inside the root is still not followed. + other, err := books.GetByID(ctx, asr.ID) + if err != nil { + t.Fatal(err) + } + if other.FilePath != "" || other.EbookFilePath != "" { + t.Fatalf("book under a linked author folder was attached (%q / %q); links inside the root must stay unfollowed", other.FilePath, other.EbookFilePath) + } + if fs, _ := books.ListFiles(ctx, asr.ID); len(fs) != 0 { + t.Fatalf("book under a linked author folder has files %+v", fs) + } +} + +func TestLibrarySnapshot_SymlinkedRootIsWalked(t *testing.T) { + root := symlinkedRootLibrary(t) + + snap := NewLibrarySnapshot(root, "") + want := filepath.Join(root, "Andy Weir", "Project Hail Mary.epub") + if got := snap.FindExisting(context.Background(), "Project Hail Mary", "Andy Weir", models.MediaTypeEbook); got != want { + t.Fatalf("FindExisting = %q, want %q", got, want) + } + if got := snap.FindExisting(context.Background(), "All Systems Red", "Martha Wells", models.MediaTypeEbook); got != "" { + t.Fatalf("FindExisting followed a linked author folder inside the root: %q", got) + } + + // The same holds for an audiobook root that is a symlink. + abSnap := NewLibrarySnapshot(t.TempDir(), root) + if got := abSnap.FindExisting(context.Background(), "Project Hail Mary", "Andy Weir", ""); got != want { + t.Fatalf("audiobook root FindExisting = %q, want %q", got, want) + } +} + +func TestWalkRoot_ReportsConfiguredPaths(t *testing.T) { + root := symlinkedRootLibrary(t) + var got []string + if err := walkRoot(root, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + got = append(got, path) + } + return nil + }); err != nil { + t.Fatal(err) + } + want := []string{ + filepath.Join(root, "Andy Weir", "Project Hail Mary.epub"), + filepath.Join(root, "Martha Wells"), // the link itself, reported, not entered + } + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("walk = %v, want %v", got, want) + } + + // A plain directory root and a missing root behave exactly as + // filepath.Walk does. + plain := t.TempDir() + writeFile(t, filepath.Join(plain, "a.epub")) + got = nil + _ = walkRoot(plain, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + got = append(got, path) + } + return nil + }) + if len(got) != 1 || got[0] != filepath.Join(plain, "a.epub") { + t.Fatalf("plain root walk = %v", got) + } + missing := filepath.Join(plain, "nope") + var sawErr bool + _ = walkRoot(missing, func(path string, _ os.FileInfo, err error) error { + if path == missing && err != nil { + sawErr = true + } + return nil + }) + if !sawErr { + t.Fatal("missing root: want the Walk error reported against the configured path") + } +} diff --git a/internal/importer/scanner.go b/internal/importer/scanner.go index 00e99a5e6..d585cc595 100644 --- a/internal/importer/scanner.go +++ b/internal/importer/scanner.go @@ -3267,6 +3267,32 @@ func (s *Scanner) ScanLibrary(ctx context.Context) { s.scanLibrary(ctx) } +// walkRoot is filepath.Walk for a configured library or audiobook root that +// may itself be a symlink (/books -> /mnt/storage/books, or a container +// volume path that is a link). filepath.Walk Lstats its root, so a linked +// root is reported once as a link and never entered, and a scan of it found +// nothing. walkRoot resolves the root first and walks the target, then hands +// fn every path rewritten under the configured root, so what a caller +// reports, compares or stores is in the same form as the book_files rows +// imports write and the root the serving containment checks resolve. +// +// Only the root is resolved. Entries inside it are still Lstat'ed and a +// linked folder inside the library is reported, not entered, exactly as +// filepath.Walk does. A root that cannot be resolved (missing, unreadable) +// is walked as given, so fn sees the same error it always did. +func walkRoot(root string, fn filepath.WalkFunc) error { + resolved, err := filepath.EvalSymlinks(root) + if err != nil || resolved == filepath.Clean(root) { + return filepath.Walk(root, fn) + } + return filepath.Walk(resolved, func(path string, info os.FileInfo, err error) error { + if rel, relErr := filepath.Rel(resolved, path); relErr == nil { + path = filepath.Join(root, rel) + } + return fn(path, info, err) + }) +} + // scanLibrary walks the library directory (and the separate audiobook directory // when configured) for book files not yet tracked in the database and reconciles // found files with existing "wanted" book records. Callers must hold the @@ -3291,7 +3317,9 @@ func (s *Scanner) scanLibrary(ctx context.Context) { walked := make(map[string]walkedFile) walkDir := func(root string) []string { var files []string - if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + // walkRoot enters a root that is itself a symlink and reports paths + // under the configured root; links inside it are still not followed. + if err := walkRoot(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil }