diff --git a/backend/internal/media/media.go b/backend/internal/media/media.go index 9531eb4..daf4f28 100644 --- a/backend/internal/media/media.go +++ b/backend/internal/media/media.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log" "os" "path" "path/filepath" @@ -54,7 +53,7 @@ func (m *M) EnsureCover(ctx context.Context, bookID int64, format string, size, return err } dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS))) - return writeAtomic(dir, "cover"+ext, img) + return WriteAtomic(dir, "cover"+ext, img) } // PageIndex returns the page list for a CBZ, using redis cache when available. @@ -141,26 +140,26 @@ func (m *M) EnsurePage(bookID int64, size, modTS int64, root, rel string, n int, if err != nil { return "", err } - if err := writeAtomic(dir, strconv.Itoa(n)+ext, data); err != nil { + if err := WriteAtomic(dir, strconv.Itoa(n)+ext, data); err != nil { return "", err } return dst, nil } -// writeAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on failure. -func writeAtomic(dir, name string, data []byte) error { +// WriteAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on +// failure, so a crash mid-write never leaves a readable half-written file. +// It does not log — callers own the context (which book, which page). +func WriteAtomic(dir, name string, data []byte) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano())) if err := os.WriteFile(tmp, data, 0o644); err != nil { - log.Printf("media: write %s: %v", name, err) os.Remove(tmp) return err } dst := filepath.Join(dir, name) if err := os.Rename(tmp, dst); err != nil { - log.Printf("media: rename %s: %v", name, err) os.Remove(tmp) return err } diff --git a/backend/internal/scanner/scanner.go b/backend/internal/scanner/scanner.go index 3703af8..41bea9b 100644 --- a/backend/internal/scanner/scanner.go +++ b/backend/internal/scanner/scanner.go @@ -7,7 +7,6 @@ import ( "io" "io/fs" "log" - "os" "path/filepath" "strings" "sync" @@ -15,6 +14,7 @@ import ( "booklib/internal/bookfile" "booklib/internal/config" + "booklib/internal/media" "booklib/internal/redispkg" "booklib/internal/store" ) @@ -97,7 +97,7 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) { defer unlock() root, err := filepath.EvalSymlinks(filepath.Clean(lib.RootPath)) - if err != nil || !inside(s.cfg.BooksDir, root) { + if err != nil || !bookfile.Contains(s.cfg.BooksDir, root) { log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath) return } @@ -116,9 +116,9 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) { delete(dbMeta, rel) switch { case !exists: - s.add(ctx, lib.ID, root, rel, ds) + s.ingest(ctx, lib.ID, 0, root, rel, ds, true) case old.Size != ds.size || old.ModTS != ds.modTS: - s.update(ctx, lib.ID, old.ID, root, rel, ds) + s.ingest(ctx, lib.ID, old.ID, root, rel, ds, false) } } for rel := range dbMeta { // 只剩被删的文件 @@ -131,11 +131,6 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) { type diskStat struct{ size, modTS int64 } -func inside(booksDir, root string) bool { - b := filepath.Clean(booksDir) - return root == b || strings.HasPrefix(root, b+string(os.PathSeparator)) -} - func walk(root string) (map[string]diskStat, error) { out := map[string]diskStat{} err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { @@ -168,7 +163,9 @@ func titleOf(rel string) string { return strings.TrimSpace(strings.ReplaceAll(strings.TrimSuffix(base, filepath.Ext(base)), "_", " ")) } -func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds diskStat) { +// ingest 是 add/update 的合一实现(Task 24):isNew 决定走 Insert 还是 UpdateBookFile, +// 之后的错误处理与封面生成完全共享。bookID 仅在 isNew=false 时有意义。 +func (s *Scanner) ingest(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat, isNew bool) { format := bookfile.FormatFromExt(filepath.Base(rel)) pageCount := 0 var idxErr error @@ -180,40 +177,21 @@ func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds dis idxErr = errors.New("no images in archive") } } - id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount) - if err != nil { - log.Printf("scan: insert %s: %v", rel, err) - return - } - if idxErr != nil { - // B10: log SetBookState errors instead of discarding. - if e := s.st.SetBookState(ctx, id, "error", idxErr.Error()); e != nil { - log.Printf("scan: SetBookState error for %s: %v", rel, e) + if isNew { + id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount) + if err != nil { + log.Printf("scan: insert %s: %v", rel, err) + return } - return - } - s.cover(ctx, id, root, rel, format, ds) -} - -func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat) { - format := bookfile.FormatFromExt(filepath.Base(rel)) - pageCount := 0 - var idxErr error - if format == "cbz" { - idx, err := s.zipIndex(root, rel) - pageCount = len(idx) - idxErr = err - if idxErr == nil && pageCount == 0 { - idxErr = errors.New("no images in archive") - } - } - if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil { + bookID = id + } else if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil { log.Printf("scan: update %s: %v", rel, err) return } if idxErr != nil { + // B10: log SetBookState errors instead of discarding. if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil { - log.Printf("scan: SetBookState error for %s: %v", rel, e) + log.Printf("scan: SetBookState %s: %v", rel, e) } return } @@ -221,66 +199,41 @@ func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel str } func (s *Scanner) zipIndex(root, rel string) ([]string, error) { - f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel))) + f, size, err := bookfile.OpenReaderAt(root, rel) if err != nil { return nil, err } defer f.Close() - st, err := f.Stat() - if err != nil { - return nil, err - } - return bookfile.PageIndex(f, st.Size()) + return bookfile.PageIndex(f, size) } -// cover writes the cover image to the cache dir. B11: all write errors are -// checked and logged; orphan .tmp files are cleaned only on failure path. +// cover writes the cover image to the cache dir via media.WriteAtomic. +// B11: write failures are logged; orphan .tmp files are cleaned only on failure. func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) { - var img []byte - var ext string - var err error + var fn func(io.ReaderAt, int64) ([]byte, string, error) switch format { case "cbz": - img, ext, err = s.readCover(root, rel, bookfile.CBZCover) + fn = bookfile.CBZCover case "epub": - img, ext, err = s.readCover(root, rel, bookfile.EPUBCover) + fn = bookfile.EPUBCover default: return // pdf/txt/md 用占位 SVG,不落盘 } + f, size, err := bookfile.OpenReaderAt(root, rel) + if err != nil { + log.Printf("scan: cover %s: %v", rel, err) + return + } + defer f.Close() + img, ext, err := fn(f, size) if err != nil { log.Printf("scan: cover %s: %v", rel, err) return } dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS))) - if e := os.MkdirAll(dir, 0o755); e != nil { - log.Printf("scan: coverdir %s: %v", rel, e) - return + if e := media.WriteAtomic(dir, "cover"+ext, img); e != nil { + log.Printf("scan: cover write %s: %v", rel, e) } - tmp := filepath.Join(dir, "cover"+ext+".tmp") - dst := filepath.Join(dir, "cover"+ext) - if e := os.WriteFile(tmp, img, 0o644); e != nil { - log.Printf("scan: write cover tmp %s: %v", rel, e) - os.Remove(tmp) - return - } - if e := os.Rename(tmp, dst); e != nil { - log.Printf("scan: rename cover %s: %v", rel, e) - os.Remove(tmp) - return - } -} - -func (s *Scanner) readCover(root, rel string, fn func(io.ReaderAt, int64) ([]byte, string, error)) ([]byte, string, error) { - f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel))) - if err != nil { - return nil, "", err - } - defer f.Close() - st, err := f.Stat() - if err != nil { - return nil, "", err - } - return fn(f, st.Size()) } func (s *Scanner) sweepCache(ctx context.Context) {