feat(backend): directory scanner — diff, error state, covers, cache sweep, redis lock

This commit is contained in:
2026-09-05 00:31:32 +08:00
parent 4e84a8aeb7
commit 193d7612b5
7 changed files with 410 additions and 7 deletions
+142
View File
@@ -0,0 +1,142 @@
package scanner
import (
"archive/zip"
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"booklib/internal/bookfile"
"booklib/internal/config"
"booklib/internal/db"
"booklib/internal/redispkg"
"booklib/internal/store"
)
// 返回 scanner、library、解析过符号链接的 root、以及一个满足 progress FK 的 uid
func setupLib(t *testing.T) (*Scanner, store.Library, string, int64) {
t.Helper()
url := os.Getenv("DATABASE_URL")
if url == "" {
t.Skip("DATABASE_URL not set")
}
ctx := context.Background()
p, _ := db.Connect(ctx, url)
db.Migrate(ctx, p)
st := store.New(p)
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
books := t.TempDir()
cache := t.TempDir()
resolved := mustResolve(t, books)
libID, err := st.CreateLibrary(ctx, "t", filepath.Join(resolved, "lib"))
if err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser(ctx, "scantest", "h", "member")
if err != nil {
t.Fatal(err)
}
root := filepath.Join(resolved, "lib")
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
cfg := &config.Config{BooksDir: resolved, CacheDir: cache, ScanInterval: time.Minute}
lib, _ := st.GetLibrary(ctx, libID)
return New(st, cfg, redispkg.New(os.Getenv("REDIS_URL"))), lib, root, uid
}
func mustResolve(t *testing.T, p string) string {
r, err := filepath.EvalSymlinks(p) // macOS 上 t.TempDir 是 /var→/private 符号链接
if err != nil {
t.Fatal(err)
}
return r
}
func writeCBZ(t *testing.T, path string, pages int) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for i := 1; i <= pages; i++ {
w, _ := zw.Create(fmt.Sprintf("%02d.jpg", i))
w.Write(bytes.Repeat([]byte("JPG"), 100))
}
zw.Close()
os.WriteFile(path, buf.Bytes(), 0o644)
}
func TestScanFullLifecycle(t *testing.T) {
sc, lib, root, uid := setupLib(t)
ctx := context.Background()
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 3)
os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hi"), 0o644)
sc.ScanLibrary(ctx, lib)
meta, _ := sc.st.ListBookMeta(ctx, lib.ID)
if len(meta) != 2 {
t.Fatalf("want 2 books got %v", meta)
}
b, err := sc.st.GetBook(ctx, meta["series-a/vol_01.cbz"].ID)
if err != nil {
t.Fatal(err)
}
if b.Format != "cbz" || b.PageCount != 3 || b.Title != "vol 01" {
t.Fatalf("bad book %+v", b)
}
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
covers, _ := os.ReadDir(bookfile.CoverDir(sc.cfg.CacheDir, key))
if len(covers) == 0 {
t.Fatal("cover not built")
}
// 二次扫描:无变化 → 不动
before := b.AddedAt
sc.ScanLibrary(ctx, lib)
b2, _ := sc.st.GetBook(ctx, b.ID)
if !b2.AddedAt.Equal(before) || b2.FileSize != b.FileSize {
t.Fatal("unchanged file must be untouched")
}
// 修改:page_count 变、hash 变、旧缓存被扫尾清掉
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 5)
os.Chtimes(filepath.Join(root, "series-a", "vol_01.cbz"), time.Now(), time.Now())
sc.ScanLibrary(ctx, lib)
b3, _ := sc.st.GetBook(ctx, b.ID)
if b3.PageCount != 5 {
t.Fatalf("not updated: %+v", b3)
}
if _, err := os.Stat(bookfile.CoverDir(sc.cfg.CacheDir, key)); !os.IsNotExist(err) {
t.Fatal("stale cover cache remains")
}
// 删除:行没了,进度按 path 还在(spec §4)
if err := sc.st.UpsertProgress(ctx, uid, lib.ID, "notes.txt", []byte("{}"), 0.5); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(root, "notes.txt")); err != nil {
t.Fatal(err)
}
sc.ScanLibrary(ctx, lib)
if _, err := sc.st.GetBook(ctx, b.ID); err != nil {
t.Fatal("cbz vanished wrongly")
}
meta2, _ := sc.st.ListBookMeta(ctx, lib.ID)
if _, ok := meta2["notes.txt"]; ok {
t.Fatal("deleted file still in db")
}
if pr, err := sc.st.GetProgress(ctx, uid, lib.ID, "notes.txt"); err != nil || pr.Percent != 0.5 {
t.Fatalf("progress must survive book removal: %+v %v", pr, err)
}
}
func TestScanBrokenCBZStateError(t *testing.T) {
sc, lib, root, _ := setupLib(t)
ctx := context.Background()
os.WriteFile(filepath.Join(root, "bad.cbz"), []byte("not a zip"), 0o644)
sc.ScanLibrary(ctx, lib)
meta, _ := sc.st.ListBookMeta(ctx, lib.ID)
b, _ := sc.st.GetBook(ctx, meta["bad.cbz"].ID)
if b.State != "error" || b.ErrMsg == "" {
t.Fatalf("want error state, got %+v", b)
}
}