Vellum/internal/modules/comics/comic.go
mithral 1b11867efe Phase 6a: directory grouping, audiobooks and music playback
A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.

- registry: MediaType.Group (file | directory). Audiobooks and music group by
  directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
  their directory disappears, and keeps the file-level added/updated/missing
  accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
  and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
  verifies the file belongs to the item, so an item id cannot be paired with
  another item's file id.

No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.

Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
2026-10-09 21:20:47 -04:00

109 lines
2.8 KiB
Go

// Package comics reads CBZ archives (a zip of page images) and serves pages.
//
// Pages are read straight out of the archive on demand rather than extracted
// to disk. A page is a single image, so it is read into memory and served
// through http.ServeContent, which gives Range support for free and avoids
// materialising a 200-page archive for a reader who views three pages.
package comics
import (
"archive/zip"
"fmt"
"io"
"path"
"sort"
"strings"
"github.com/mithral/vellum/internal/natsort"
)
// Kind is the media kind this module owns.
const Kind = "comics"
// maxPageBytes caps a single page so a malformed archive cannot exhaust memory.
const maxPageBytes = 64 << 20 // 64 MiB
// imageExts are the page formats a CBZ may contain.
var imageExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
".gif": true,
".avif": true,
".bmp": true,
}
// Page is one page in reading order.
type Page struct {
Index int `json:"index"` // 1-based
Name string `json:"name"`
}
// ListPages returns the archive's image entries in reading order.
//
// Ordering is natural, not lexical: "page2.png" sorts before "page10.png",
// which lexical ordering gets wrong. Comic archives are usually already
// numbered, so this is the difference between a readable book and a shuffled
// one.
func ListPages(cbzPath string) ([]Page, error) {
zr, err := zip.OpenReader(cbzPath)
if err != nil {
return nil, fmt.Errorf("comics: open archive: %w", err)
}
defer zr.Close()
var names []string
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
name := f.Name
// Skip macOS resource forks and other hidden metadata.
if strings.HasPrefix(path.Base(name), ".") || strings.Contains(name, "__MACOSX/") {
continue
}
if imageExts[strings.ToLower(path.Ext(name))] {
names = append(names, name)
}
}
if len(names) == 0 {
return nil, fmt.Errorf("comics: archive contains no images")
}
sort.Slice(names, func(i, j int) bool { return natsort.Less(names[i], names[j]) })
pages := make([]Page, len(names))
for i, n := range names {
pages[i] = Page{Index: i + 1, Name: n}
}
return pages, nil
}
// ReadPage returns the bytes of one page by its archive name.
func ReadPage(cbzPath, name string) ([]byte, error) {
zr, err := zip.OpenReader(cbzPath)
if err != nil {
return nil, fmt.Errorf("comics: open archive: %w", err)
}
defer zr.Close()
for _, f := range zr.File {
if f.Name != name {
continue
}
if f.UncompressedSize64 > maxPageBytes {
return nil, fmt.Errorf("comics: page %q is too large", name)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(io.LimitReader(rc, maxPageBytes))
}
return nil, fmt.Errorf("comics: page %q not found", name)
}
// naturalLess moved to internal/natsort, shared with the scanner's track
// ordering.