From b8da9fd92ef2e686733beb37df06df2eb74fbd45 Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 00:22:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(cbz):=20adapt=20reader=20to=20real=20archi?= =?UTF-8?q?ve=20structure=20=E2=80=94=20PageIndex=20skips=20macOS=20junk?= =?UTF-8?q?=20(=5F=5FMACOSX/=20and=20.=5F=20AppleDouble=20were=20polluting?= =?UTF-8?q?=20pages=20with=20163-byte=20blacks),=20pages=20endpoint=20deri?= =?UTF-8?q?ves=20chapters[{title,start}]=20from=20folder=20layout,=20image?= =?UTF-8?q?less=20zip=20now=20lands=20state=3Derror=20instead=20of=20an=20?= =?UTF-8?q?empty=20reader,=20CbzReader=20gains=20=E7=9B=AE=E5=BD=95=20side?= =?UTF-8?q?bar=20+=20prev/next=20chapter=20(TextReader=20pattern,=20rd-*?= =?UTF-8?q?=20classes);=20pagesidx=20cache=20key=20bumped=20for=20one-time?= =?UTF-8?q?=20invalidation;=20e2e=20on=20311MB=20=E8=AA=BF=E6=95=99?= =?UTF-8?q?=E9=96=8B=E9=97=9C=EF=BC=9A=E7=AC=AC=E4=BA=8C=E5=AD=A3.zip:=201?= =?UTF-8?q?5048=E2=86=927524=20real=20pages,=2052=20chapters,=20all=20JPEG?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/webui/handlers/content.go | 46 +++++++++++++- backend/cmd/webui/handlers/content_test.go | 72 ++++++++++++++++++++++ backend/internal/bookfile/zip.go | 11 ++++ backend/internal/bookfile/zip_test.go | 17 +++++ backend/internal/scanner/scanner.go | 7 +++ docs/CHANGELOG.md | 6 ++ docs/CHANGELOG_web.md | 3 + frontend/src/api/client.ts | 3 +- frontend/src/readers/CbzReader.tsx | 54 ++++++++++++++++ 9 files changed, 216 insertions(+), 3 deletions(-) diff --git a/backend/cmd/webui/handlers/content.go b/backend/cmd/webui/handlers/content.go index 29ec4f0..f8e03c5 100644 --- a/backend/cmd/webui/handlers/content.go +++ b/backend/cmd/webui/handlers/content.go @@ -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) { diff --git a/backend/cmd/webui/handlers/content_test.go b/backend/cmd/webui/handlers/content_test.go index c863495..93f5436 100644 --- a/backend/cmd/webui/handlers/content_test.go +++ b/backend/cmd/webui/handlers/content_test.go @@ -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) diff --git a/backend/internal/bookfile/zip.go b/backend/internal/bookfile/zip.go index 7514104..25ffca5 100644 --- a/backend/internal/bookfile/zip.go +++ b/backend/internal/bookfile/zip.go @@ -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) } diff --git a/backend/internal/bookfile/zip_test.go b/backend/internal/bookfile/zip_test.go index 22c4b16..72811c6 100644 --- a/backend/internal/bookfile/zip_test.go +++ b/backend/internal/bookfile/zip_test.go @@ -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) diff --git a/backend/internal/scanner/scanner.go b/backend/internal/scanner/scanner.go index 926f096..e794c52 100644 --- a/backend/internal/scanner/scanner.go +++ b/backend/internal/scanner/scanner.go @@ -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) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aa62fe0..90b5b33 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,11 +10,17 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 +- API: CBZ page indexing now skips macOS packaging junk (`__MACOSX/…` and `._*` AppleDouble files), which used to land in the page list as ~163-byte black "pages"; `GET /api/books/:id/pages` additionally returns `chapters:[{title,start}]` derived from the archive's folder structure (e.g. 第1話…), so per-folder comics expose their real organization. +- API:CBZ 页索引现会跳过 macOS 打包垃圾(`__MACOSX/…` 与 `._*` 资源叉文件),此前它们以 ~163 字节黑页混入页列表;`GET /api/books/:id/pages` 新增 `chapters:[{title,start}]`,按压缩包内目录结构(如 第1話…)给出真实章节。 + - API: resumable chunked upload protocol for large files — `POST /api/libraries/:id/upload/init` (fingerprint-derived deterministic `uploadId`, rejects totals over `UPLOAD_MAX_MB` with `413 too_large`), `PUT /api/uploads/:uid/parts/:index` (parts ≤ 32MB), `GET /api/uploads/:uid` (received parts, for resume), `POST /api/uploads/:uid/complete` (assemble + atomic land, same path contract as single-POST upload). Sessions persist under `BOOKS_DIR/.uploads/` with 24h opportunistic sweep. `UPLOAD_MAX_MB` is now wired through both compose stacks/`.env`; `.env.example` sets 2048 and drops `NGINX_CLIENT_MAX_BODY_SIZE` to 32m (nginx only ever sees one chunk). - API: 新增大文件可续传分片上传协议——`POST /api/libraries/:id/upload/init`(按指纹派生确定性 `uploadId`,总量超 `UPLOAD_MAX_MB` 返回 `413 too_large`)、`PUT /api/uploads/:uid/parts/:index`(单片 ≤32MB)、`GET /api/uploads/:uid`(查询已传分片以续传)、`POST /api/uploads/:uid/complete`(拼接后原子落盘,返回与单发上传一致的 `path`)。会话存于 `BOOKS_DIR/.uploads/`,超 24h 顺手清理。`UPLOAD_MAX_MB` 已接入两份 compose/`.env`;`.env.example` 调至 2048 并将 `NGINX_CLIENT_MAX_BODY_SIZE` 降为 32m(nginx 只见单个分片)。 ### Changed / 变更 +- Scanner: an image-list-less archive (`.zip`/`.cbz` with no page images — video packs, document dumps) is now recorded as `state=error` ("no images in archive") instead of registering as an empty CBZ with a blank reader. +- 扫描器:不含任何图片条目的 `.zip`/`.cbz`(视频包、文档包)现记录为 `state=error`("no images in archive"),不再注册成空 CBZ 留下一个白板阅读器。 + - API: upload over `UPLOAD_MAX_MB` now returns `413 too_large` with the limit in the message; previously the size abort was misreported as `400 bad_request "multipart field 'file' required"`. - API:超过 `UPLOAD_MAX_MB` 的上传现在返回 `413 too_large` 并在消息中带上限额;此前体积超限被误报为 `400 bad_request "multipart field 'file' required"`。 - API: `POST /api/libraries` now takes only `{name}`; `root_path` is generated server-side as `BOOKS_DIR/` (no client-supplied paths, validated at creation). diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 15334c9..8403326 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 +- Folder-structured comics gain a 目录 sidebar in the CBZ reader: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI. +- 按目录组织的漫画在 CBZ 阅读器新增「目录」侧栏:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。 + - Library uploads over 16MB now auto-switch to the resumable chunked protocol (8MB parts): failed parts retry in place up to 3 times and already-sent parts are skipped, so a flaky upload continues instead of starting over; small files keep the original single request. - 书库上传超过 16MB 自动切换为可续传分片协议(每片 8MB):失败的分片原地重试至多 3 次,已传片自动跳过,中断后继续而不是从头再来;小文件仍走原单次上传。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ad04b08..59d63e5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -172,7 +172,8 @@ export const api = { }, getBook: (id: number) => apiFetch(`/books/${id}`), deleteBook: (id: number) => apiFetch(`/books/${id}`, { method: "DELETE" }), - pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl), + pageCount: (pagesUrl: string) => + apiFetch<{ count: number; chapters?: { title: string; start: number }[] | null }>(pagesUrl), putProgress: (id: number, locator: Record, percent: number, keepalive = false) => apiFetch(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }), diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 2a8b558..9ead599 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -84,6 +84,9 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) retry: 0, }); const count = countQ.data?.count ?? 0; + const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷) + const [toc, setToc] = useState(false); + const activeRow = useRef(null); const saver = useProgressSaver(book.id); const boxRef = useRef(null); const [width, setWidth] = useState(480); @@ -168,6 +171,10 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) } const cur = ph.pageAt(top + vh / 2); + const ci = chapters ? chapters.reduce((acc, c, i) => (c.start <= cur ? i : acc), 0) : 0; + useEffect(() => { + if (toc) activeRow.current?.scrollIntoView({ block: "center" }); + }, [toc]); function jump(i: number, smooth = true) { const el = boxRef.current; @@ -284,6 +291,26 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) > {MODE_LABEL[mode]} + {chapters && ( + <> + + + {ci + 1}/{chapters.length} + + + + + )} ) : ( @@ -294,6 +321,33 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% )} + {toc && chapters && ( + <> + ); }