feat(cbz): adapt reader to real archive structure — PageIndex skips macOS junk (__MACOSX/ and ._ AppleDouble were polluting pages with 163-byte blacks), pages endpoint derives chapters[{title,start}] from folder layout, imageless zip now lands state=error instead of an empty reader, CbzReader gains 目录 sidebar + prev/next chapter (TextReader pattern, rd-* classes); pagesidx cache key bumped for one-time invalidation; e2e on 311MB 調教開關:第二季.zip: 15048→7524 real pages, 52 chapters, all JPEGs

This commit is contained in:
2026-09-08 00:22:13 +08:00
parent 7d650107d2
commit b8da9fd92e
9 changed files with 216 additions and 3 deletions
@@ -1,6 +1,8 @@
package handlers_test
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
@@ -103,6 +105,76 @@ func TestPages(t *testing.T) {
}
}
func zipTo(t *testing.T, path string, kv map[string]string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for n, v := range kv {
w, err := zw.Create(n)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(v))
}
zw.Close()
os.WriteFile(path, buf.Bytes(), 0o644)
}
func TestPagesChaptersAndNoImageZip(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
lib, root := newLibrary(t, st, h, tok, booksDir, "ch")
zipTo(t, filepath.Join(root, "show.cbz"), map[string]string{
"第2季/第2話/0001.jpg": "IMG2",
"第2季/第1話/0001.jpg": "IMG1a",
"第2季/第1話/._0001.jpg": "junk",
"__MACOSX/第2季/._0001.jpg": "junk",
})
zipTo(t, filepath.Join(root, "videos.zip"), map[string]string{"ep/01.mkv": "x"})
scanNow(t, sc, lib)
bookID := func(q string) string {
w := do(h, "GET", "/api/books?q="+q, tok, nil)
var bs []map[string]any
json.Unmarshal(w.Body.Bytes(), &bs)
if len(bs) != 1 {
t.Fatalf("q=%s books: %s", q, w.Body)
}
return itoa(bs[0]["id"])
}
cbzID := bookID("show")
w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil)
var pg struct {
Count int `json:"count"`
Chapters []struct {
Title string `json:"title"`
Start int `json:"start"`
} `json:"chapters"`
}
json.Unmarshal(w.Body.Bytes(), &pg)
if pg.Count != 2 || len(pg.Chapters) != 2 {
t.Fatalf("pages want 2/2ch got %s", w.Body)
}
if pg.Chapters[0].Title != "第1話" || pg.Chapters[0].Start != 0 || pg.Chapters[1].Start != 1 {
t.Fatalf("chapters: %s", w.Body)
}
ww := do(h, "GET", "/api/books/"+cbzID+"/pages/0", tok, nil)
if ww.Code != 200 || ww.Body.String() != "IMG1a" {
t.Fatalf("page0 must be real image (junk filtered): %d %s", ww.Code, ww.Body)
}
if ww := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil); ww.Code != 404 {
t.Fatalf("junk-indexed page must be gone: %d", ww.Code)
}
// 无图 zip → state=error,而不是空白 reader
w = do(h, "GET", "/api/books/"+bookID("videos"), tok, nil)
var bk map[string]any
json.Unmarshal(w.Body.Bytes(), &bk)
if bk["state"] != "error" || !strings.Contains(bk["error"].(string), "no images") {
t.Fatalf("empty-image zip want error, got %s", w.Body)
}
}
func TestBrokenCBZ(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
atok := adminToken(t, h)