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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user