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:
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -118,7 +119,7 @@ func (h *H) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64
|
||||
|
||||
func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
|
||||
hash := bookfile.Hash(b.FileSize, b.ModTS)
|
||||
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
|
||||
key := fmt.Sprintf("pagesidx2:%d:%s", b.ID, hash) // 前缀换代 = 索引逻辑变更时一次性作废旧缓存
|
||||
if v, ok := h.rdb.Get(c, key); ok && v != "" {
|
||||
return strings.Split(v, "\n"), nil
|
||||
}
|
||||
@@ -158,7 +159,48 @@ func (h *H) PagesCount(c *gin.Context) {
|
||||
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
|
||||
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": chaptersOf(idx)})
|
||||
}
|
||||
|
||||
type cbzChapter struct {
|
||||
Title string `json:"title"`
|
||||
Start int `json:"start"`
|
||||
}
|
||||
|
||||
// chaptersOf 页索引已自然序排列,按父目录分组:每个新目录开一章,标题取目录名;扁平包或只有一组返回 nil
|
||||
func chaptersOf(idx []string) []cbzChapter {
|
||||
type grp struct {
|
||||
dir string
|
||||
start int
|
||||
}
|
||||
var grps []grp
|
||||
last := "\x00"
|
||||
for i, n := range idx {
|
||||
d := path.Dir(n)
|
||||
if d == last {
|
||||
continue
|
||||
}
|
||||
last = d
|
||||
if d == "." { // 根目录散页不开章
|
||||
continue
|
||||
}
|
||||
grps = append(grps, grp{d, i})
|
||||
}
|
||||
if len(grps) < 2 {
|
||||
return nil
|
||||
}
|
||||
titles := make(map[string]int)
|
||||
out := make([]cbzChapter, len(grps))
|
||||
for i, g := range grps {
|
||||
out[i] = cbzChapter{Title: path.Base(g.dir), Start: g.start}
|
||||
titles[out[i].Title]++
|
||||
}
|
||||
for i, g := range grps { // 同名目录(不同父级)撞车 → 用全路径消歧
|
||||
if titles[out[i].Title] > 1 {
|
||||
out[i].Title = g.dir
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *H) Page(c *gin.Context) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,6 +26,14 @@ func isImage(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// junkEntry: macOS 打包混入的资源叉垃圾(__MACOSX/ 目录与 ._* AppleDouble),不是页
|
||||
func junkEntry(name string) bool {
|
||||
if strings.HasPrefix(strings.ToLower(name), "__macosx/") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path.Base(name), "._")
|
||||
}
|
||||
|
||||
func PageIndex(f io.ReaderAt, size int64) ([]string, error) {
|
||||
zr, err := zip.NewReader(f, size)
|
||||
if err != nil {
|
||||
@@ -36,6 +44,9 @@ func PageIndex(f io.ReaderAt, size int64) ([]string, error) {
|
||||
if unsafeEntry(zf.Name) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsafeZip, zf.Name)
|
||||
}
|
||||
if junkEntry(zf.Name) {
|
||||
continue
|
||||
}
|
||||
if isImage(zf.Name) {
|
||||
names = append(names, zf.Name)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,23 @@ func TestPageIndexSortAndFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIndexSkipsAppleDouble(t *testing.T) {
|
||||
r := zipOf(t, "第2季/第2話/0001.jpg", "第2季/第1話/._0001.jpg", "__MACOSX/第2季/._0001.jpg", "第2季/第1話/0001.jpg", "._top.jpg")
|
||||
idx, err := PageIndex(r, int64(r.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"第2季/第1話/0001.jpg", "第2季/第2話/0001.jpg"}
|
||||
if len(idx) != len(want) {
|
||||
t.Fatalf("got %v want %v", idx, want)
|
||||
}
|
||||
for i := range want {
|
||||
if idx[i] != want[i] {
|
||||
t.Fatalf("got %v want %v", idx, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIndexRejectsSlip(t *testing.T) {
|
||||
for _, bad := range []string{"../evil.jpg", "/etc/passwd.jpg", "a\\..\\b.jpg", "pag\ne.jpg", "pag\re.jpg"} {
|
||||
r := zipOf(t, bad)
|
||||
|
||||
@@ -2,6 +2,7 @@ package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -146,6 +147,9 @@ func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds dis
|
||||
idx, err := s.zipIndex(root, rel)
|
||||
pageCount = len(idx)
|
||||
idxErr = err
|
||||
if idxErr == nil && pageCount == 0 { // 视频/文档 zip 不是漫画,空白 reader 没有意义
|
||||
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 {
|
||||
@@ -167,6 +171,9 @@ func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel str
|
||||
idx, err := s.zipIndex(root, rel)
|
||||
pageCount = len(idx)
|
||||
idxErr = err
|
||||
if idxErr == nil && pageCount == 0 { // 视频/文档 zip 不是漫画,空白 reader 没有意义
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user