refactor(scanner): merge add/update into ingest, use shared utilities

- add/update consolidated into ingest(isNew) — persist branch differs,
  error handling and cover path fully shared (Task 24)
- zipIndex/cover use bookfile.OpenReaderAt instead of hand-rolled
  open+stat pairs
- root containment check uses bookfile.Contains (local inside removed)
- cover write goes through media.WriteAtomic (now exported, log-free —
  callers own context); media no longer logs inside the atomic helper
This commit is contained in:
2026-09-14 22:12:30 +08:00
parent fbd5cd243d
commit b9b7022212
2 changed files with 38 additions and 86 deletions
+6 -7
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"log"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -54,7 +53,7 @@ func (m *M) EnsureCover(ctx context.Context, bookID int64, format string, size,
return err return err
} }
dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS))) 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. // 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 { if err != nil {
return "", err 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 "", err
} }
return dst, nil return dst, nil
} }
// writeAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on failure. // WriteAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on
func writeAtomic(dir, name string, data []byte) error { // 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 { if err := os.MkdirAll(dir, 0o755); err != nil {
return err return err
} }
tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano())) tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
if err := os.WriteFile(tmp, data, 0o644); err != nil { if err := os.WriteFile(tmp, data, 0o644); err != nil {
log.Printf("media: write %s: %v", name, err)
os.Remove(tmp) os.Remove(tmp)
return err return err
} }
dst := filepath.Join(dir, name) dst := filepath.Join(dir, name)
if err := os.Rename(tmp, dst); err != nil { if err := os.Rename(tmp, dst); err != nil {
log.Printf("media: rename %s: %v", name, err)
os.Remove(tmp) os.Remove(tmp)
return err return err
} }
+28 -75
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -15,6 +14,7 @@ import (
"booklib/internal/bookfile" "booklib/internal/bookfile"
"booklib/internal/config" "booklib/internal/config"
"booklib/internal/media"
"booklib/internal/redispkg" "booklib/internal/redispkg"
"booklib/internal/store" "booklib/internal/store"
) )
@@ -97,7 +97,7 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
defer unlock() defer unlock()
root, err := filepath.EvalSymlinks(filepath.Clean(lib.RootPath)) 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) log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath)
return return
} }
@@ -116,9 +116,9 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
delete(dbMeta, rel) delete(dbMeta, rel)
switch { switch {
case !exists: 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: 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 { // 只剩被删的文件 for rel := range dbMeta { // 只剩被删的文件
@@ -131,11 +131,6 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
type diskStat struct{ size, modTS int64 } 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) { func walk(root string) (map[string]diskStat, error) {
out := map[string]diskStat{} out := map[string]diskStat{}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { 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)), "_", " ")) 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)) format := bookfile.FormatFromExt(filepath.Base(rel))
pageCount := 0 pageCount := 0
var idxErr error 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") idxErr = errors.New("no images in archive")
} }
} }
if isNew {
id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount) id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount)
if err != nil { if err != nil {
log.Printf("scan: insert %s: %v", rel, err) log.Printf("scan: insert %s: %v", rel, err)
return return
} }
if idxErr != nil { bookID = id
// B10: log SetBookState errors instead of discarding. } else if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
if e := s.st.SetBookState(ctx, id, "error", idxErr.Error()); e != nil {
log.Printf("scan: SetBookState error for %s: %v", rel, e)
}
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 {
log.Printf("scan: update %s: %v", rel, err) log.Printf("scan: update %s: %v", rel, err)
return return
} }
if idxErr != nil { if idxErr != nil {
// B10: log SetBookState errors instead of discarding.
if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil { 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 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) { 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 { if err != nil {
return nil, err return nil, err
} }
defer f.Close() defer f.Close()
st, err := f.Stat() return bookfile.PageIndex(f, size)
if err != nil {
return nil, err
}
return bookfile.PageIndex(f, st.Size())
} }
// cover writes the cover image to the cache dir. B11: all write errors are // cover writes the cover image to the cache dir via media.WriteAtomic.
// checked and logged; orphan .tmp files are cleaned only on failure path. // 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) { func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) {
var img []byte var fn func(io.ReaderAt, int64) ([]byte, string, error)
var ext string
var err error
switch format { switch format {
case "cbz": case "cbz":
img, ext, err = s.readCover(root, rel, bookfile.CBZCover) fn = bookfile.CBZCover
case "epub": case "epub":
img, ext, err = s.readCover(root, rel, bookfile.EPUBCover) fn = bookfile.EPUBCover
default: default:
return // pdf/txt/md 用占位 SVG,不落盘 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 { if err != nil {
log.Printf("scan: cover %s: %v", rel, err) log.Printf("scan: cover %s: %v", rel, err)
return return
} }
dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS))) dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS)))
if e := os.MkdirAll(dir, 0o755); e != nil { if e := media.WriteAtomic(dir, "cover"+ext, img); e != nil {
log.Printf("scan: coverdir %s: %v", rel, e) log.Printf("scan: cover write %s: %v", rel, e)
return
} }
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) { func (s *Scanner) sweepCache(ctx context.Context) {