Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/scan-symlinked-library-root.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions docs/User-Guide-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/importer/library_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
169 changes: 169 additions & 0 deletions internal/importer/scan_symlinked_root_test.go
Original file line number Diff line number Diff line change
@@ -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 -> <elsewhere>/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")
}
}
30 changes: 29 additions & 1 deletion internal/importer/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading