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.
109 lines
2.8 KiB
Go
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.
|