Phase 4: CBZ comics module and reader

- comics: reads pages straight out of the archive on demand. No extraction
  pass, so a 200-page CBZ is not materialised for a reader who views three
  pages. Pages are read into memory and served via http.ServeContent, which
  gives Range for free.
- natural page ordering: page2 sorts before page10. Lexical ordering shuffles
  a numbered archive, which is the difference between a readable book and a
  scrambled one.
- skips __MACOSX and dotfiles; rejects archives with no images.
- cover is the first page.
- media.Item gained ModTime (from the backing file) so page responses carry a
  Last-Modified and support conditional GETs.
- web: ComicReader with a bounded prefetch window (two pages each way), page
  jump, keyboard nav, and progress stored as a page number.

Verified: 3-page CBZ served in natural order, out-of-range 404, cover from
page 1, progress round-trip. svelte-check 0/0.
This commit is contained in:
mithral 2026-10-09 21:07:59 -04:00
parent 7a5584a83f
commit c3b7db7114
10 changed files with 594 additions and 5 deletions

View file

@ -17,6 +17,7 @@ import (
"github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/media" "github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/modules/comics"
"github.com/mithral/vellum/internal/modules/ebooks" "github.com/mithral/vellum/internal/modules/ebooks"
"github.com/mithral/vellum/migrations" "github.com/mithral/vellum/migrations"
) )
@ -75,6 +76,7 @@ func run() error {
library.NewHandlers(db, log, authenticator).Register(api.Mux()) library.NewHandlers(db, log, authenticator).Register(api.Mux())
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux()) media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux()) ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,

View file

@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"time"
) )
// ErrItemNotFound means no item matched. // ErrItemNotFound means no item matched.
@ -26,6 +27,10 @@ type Item struct {
// RootPath is the absolute library root, so AbsPath can be computed. // RootPath is the absolute library root, so AbsPath can be computed.
RootPath string RootPath string
// ModTime is the backing file's modification time, used for conditional
// GETs. Zero when unknown.
ModTime time.Time
} }
// AbsPath returns the absolute filesystem path of the item's file. // AbsPath returns the absolute filesystem path of the item's file.
@ -39,21 +44,42 @@ func (i Item) AbsPath() string {
// LoadItem loads an item and its library root. // LoadItem loads an item and its library root.
func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) { func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
var it Item var it Item
var mtime string
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path,
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '')
FROM media_items i FROM media_items i
JOIN libraries l ON l.id = i.library_id JOIN libraries l ON l.id = i.library_id
WHERE i.id = ?`, id, WHERE i.id = ?`, id,
).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath) ).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath, &mtime)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrItemNotFound return Item{}, ErrItemNotFound
} }
if err != nil { if err != nil {
return Item{}, fmt.Errorf("media: load item: %w", err) return Item{}, fmt.Errorf("media: load item: %w", err)
} }
if nanos := parseUnixNano(mtime); nanos > 0 {
it.ModTime = time.Unix(0, nanos).UTC()
}
return it, nil return it, nil
} }
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
// that is not all digits.
func parseUnixNano(s string) int64 {
if s == "" {
return 0
}
var n int64
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0
}
n = n*10 + int64(s[i]-'0')
}
return n
}
// FileEntry is one physical file backing an item. // FileEntry is one physical file backing an item.
type FileEntry struct { type FileEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`

View file

@ -0,0 +1,139 @@
// 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"
)
// 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 naturalLess(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 compares two strings treating digit runs as numbers.
func naturalLess(a, b string) bool {
ai, bi := 0, 0
for ai < len(a) && bi < len(b) {
ad, bd := isDigit(a[ai]), isDigit(b[bi])
if ad && bd {
// Compare the full digit runs numerically.
as, bs := ai, bi
for ai < len(a) && isDigit(a[ai]) {
ai++
}
for bi < len(b) && isDigit(b[bi]) {
bi++
}
an := strings.TrimLeft(a[as:ai], "0")
bn := strings.TrimLeft(b[bs:bi], "0")
if len(an) != len(bn) {
return len(an) < len(bn)
}
if an != bn {
return an < bn
}
continue
}
if a[ai] != b[bi] {
return a[ai] < b[bi]
}
ai++
bi++
}
return len(a)-ai < len(b)-bi
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }

View file

@ -0,0 +1,102 @@
package comics
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func writeCBZ(t *testing.T, entries map[string]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "test.cbz")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
for name, body := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return p
}
func TestListPagesNaturalOrder(t *testing.T) {
p := writeCBZ(t, map[string]string{
"page10.jpg": "ten",
"page2.jpg": "two",
"page1.jpg": "one",
"notes.txt": "not an image",
"__MACOSX/._page1.jpg": "junk",
})
pages, err := ListPages(p)
if err != nil {
t.Fatalf("ListPages: %v", err)
}
want := []string{"page1.jpg", "page2.jpg", "page10.jpg"}
if len(pages) != len(want) {
t.Fatalf("got %d pages, want %d: %+v", len(pages), len(want), pages)
}
for i, name := range want {
if pages[i].Name != name {
t.Errorf("page %d = %q, want %q", i+1, pages[i].Name, name)
}
if pages[i].Index != i+1 {
t.Errorf("page %d index = %d, want %d", i, pages[i].Index, i+1)
}
}
}
func TestReadPage(t *testing.T) {
p := writeCBZ(t, map[string]string{"page1.jpg": "hello-page"})
data, err := ReadPage(p, "page1.jpg")
if err != nil {
t.Fatalf("ReadPage: %v", err)
}
if string(data) != "hello-page" {
t.Errorf("page content = %q", data)
}
if _, err := ReadPage(p, "missing.jpg"); err == nil {
t.Error("ReadPage succeeded for a missing entry")
}
}
func TestListPagesRejectsArchiveWithoutImages(t *testing.T) {
p := writeCBZ(t, map[string]string{"readme.txt": "nothing to see"})
if _, err := ListPages(p); err == nil {
t.Error("ListPages succeeded on an archive with no images")
}
}
func TestNaturalLess(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"page1.jpg", "page2.jpg", true},
{"page2.jpg", "page10.jpg", true},
{"page10.jpg", "page2.jpg", false},
{"page01.jpg", "page1.jpg", false}, // equal numerically
{"page1.jpg", "page01.jpg", false},
{"a.jpg", "b.jpg", true},
}
for _, c := range cases {
if got := naturalLess(c.a, c.b); got != c.want {
t.Errorf("naturalLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}

View file

@ -0,0 +1,52 @@
package comics
import (
"context"
"database/sql"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"github.com/mithral/vellum/internal/media"
)
func init() {
media.RegisterCoverExtractor(Kind, ExtractCover)
}
// ExtractCover writes the first page of the archive into the cache and returns
// its path. A comic's first page is its cover by convention.
func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
pages, err := ListPages(absPath)
if err != nil {
return "", err
}
dir, err := media.CoverCacheDir(cacheDir, Kind)
if err != nil {
return "", err
}
ext := strings.ToLower(path.Ext(pages[0].Name))
if ext == "" {
ext = ".jpg"
}
outPath := filepath.Join(dir, fmt.Sprintf("%d%s", item.ID, ext))
data, err := ReadPage(absPath, pages[0].Name)
if err != nil {
return "", err
}
tmp := outPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o640); err != nil {
return "", fmt.Errorf("comics: write cover: %w", err)
}
if err := os.Rename(tmp, outPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return outPath, nil
}

View file

@ -0,0 +1,115 @@
package comics
import (
"bytes"
"database/sql"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/stream"
)
// Handlers serves comic-specific endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the comic HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/items/{id}/pages", h.auth.Require(h.handleListPages))
mux.Handle("GET /api/v1/items/{id}/pages/{page}", h.auth.Require(h.handlePage))
}
// handleListPages returns the archive's page list in reading order.
func (h *Handlers) handleListPages(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
pages, err := ListPages(it.AbsPath())
if err != nil {
h.log.Warn("list pages", "item", it.ID, "err", err)
httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive")
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"pages": pages, "count": len(pages)})
}
// handlePage streams one page image.
func (h *Handlers) handlePage(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
idx, err := strconv.Atoi(r.PathValue("page"))
if err != nil || idx < 1 {
httpx.WriteError(w, http.StatusBadRequest, "invalid page number")
return
}
pages, err := ListPages(it.AbsPath())
if err != nil {
httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive")
return
}
if idx > len(pages) {
httpx.WriteError(w, http.StatusNotFound, "page out of range")
return
}
data, err := ReadPage(it.AbsPath(), pages[idx-1].Name)
if err != nil {
h.log.Error("read page", "item", it.ID, "page", idx, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "could not read the page")
return
}
// Pages are immutable; let the browser keep them.
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Content-Type", stream.ContentType(pages[idx-1].Name))
http.ServeContent(w, r, pages[idx-1].Name, it.ModTime, bytes.NewReader(data))
}
// authorized loads an item, confirms access, and checks it is a comic.
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
return media.Item{}, false
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if err != nil {
h.log.Error("load item", "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
return media.Item{}, false
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if it.Kind != Kind {
httpx.WriteError(w, http.StatusBadRequest, "item is not a comic")
return media.Item{}, false
}
return it, true
}

3
web/dist/.gitkeep vendored
View file

@ -1,3 +0,0 @@
The Vite build output goes here. This file keeps the directory present so the
Go embed directive in ../embed.go is always valid, even before the frontend has
been built. Run `pnpm build` (or `make build` from the repo root) to populate it.

View file

@ -130,6 +130,11 @@ export const api = {
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`), ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
pages: (id: number) =>
request<{ pages: { index: number; name: string }[]; count: number }>('GET', `/api/v1/items/${id}/pages`),
pageUrl: (id: number, page: number) => `/api/v1/items/${id}/pages/${page}`,
coverUrl: (id: number) => `/api/v1/items/${id}/cover`, coverUrl: (id: number) => `/api/v1/items/${id}/cover`,
fileUrl: (id: number) => `/api/v1/items/${id}/file`, fileUrl: (id: number) => `/api/v1/items/${id}/file`,

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError } from '../api'
let { itemId }: { itemId: number } = $props()
let count = $state(0)
let current = $state(1)
let loading = $state(true)
let error = $state<string | null>(null)
let pageInput = $state('1')
let saveTimer: number | undefined
const preloaded = new Set<string>()
onMount(async () => {
try {
const [list, prog] = await Promise.all([api.pages(itemId), api.progress(itemId)])
count = list.count
if (prog.progress && prog.progress.position > 0 && prog.progress.position <= count) {
current = prog.progress.position
}
pageInput = String(current)
window.addEventListener('keydown', onKey)
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this comic'
} finally {
loading = false
}
})
onDestroy(() => {
window.removeEventListener('keydown', onKey)
if (saveTimer) clearTimeout(saveTimer)
flush()
})
// Prefetch a window ahead and behind so a fast reader never sees a blank
// page. Bounded to two in each direction to keep memory flat on weak clients.
$effect(() => {
if (count === 0) return
for (const offset of [1, 2, -1, -2]) {
const n = current + offset
if (n < 1 || n > count) continue
const url = api.pageUrl(itemId, n)
if (preloaded.has(url)) continue
preloaded.add(url)
const img = new Image()
img.src = url
}
})
function goTo(n: number) {
const next = Math.min(Math.max(n, 1), count)
if (next === current) return
current = next
pageInput = String(next)
scheduleSave()
}
function onKey(event: KeyboardEvent) {
if (event.key === 'ArrowRight' || event.key === ' ') {
event.preventDefault()
goTo(current + 1)
}
if (event.key === 'ArrowLeft') goTo(current - 1)
if (event.key === 'Home') goTo(1)
if (event.key === 'End') goTo(count)
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(flush, 900)
}
function flush() {
if (count === 0) return
void api.putProgress(itemId, { position: current, percent: current / count }).catch(() => {})
}
function submitJump(event: SubmitEvent) {
event.preventDefault()
const n = parseInt(pageInput, 10)
if (!Number.isNaN(n)) goTo(n)
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if loading}
<div class="stage center"><div class="spinner"></div></div>
{:else}
<div class="stage">
<img class="page" src={api.pageUrl(itemId, current)} alt={`Page ${current}`} />
</div>
<footer class="bar">
<button class="btn btn-ghost" onclick={() => goTo(current - 1)} disabled={current <= 1}>←</button>
<form class="jump" onsubmit={submitJump}>
<input class="input page-input" bind:value={pageInput} aria-label="Page number" />
<span class="muted mono">/ {count}</span>
</form>
<span class="pct mono">{Math.round((current / count) * 100)}%</span>
<button class="btn btn-ghost" onclick={() => goTo(current + 1)} disabled={current >= count}>→</button>
</footer>
{/if}
<style>
.stage {
height: calc(100vh - 52px - 96px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: #000;
overflow: auto;
display: grid;
}
.center {
place-items: center;
}
.page {
display: block;
margin: 0 auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.bar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 56px;
}
.jump {
display: flex;
align-items: center;
gap: var(--space-2);
}
.page-input {
width: 64px;
text-align: center;
}
.pct {
color: var(--text-faint);
min-width: 40px;
text-align: right;
}
</style>

View file

@ -2,6 +2,7 @@
import ePub from 'epubjs' import ePub from 'epubjs'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type EbookMetadata } from '../api' import { api, ApiError, type Item, type EbookMetadata } from '../api'
import ComicReader from './ComicReader.svelte'
let { itemId }: { itemId: number } = $props() let { itemId }: { itemId: number } = $props()
@ -89,6 +90,8 @@
{#if error} {#if error}
<div class="alert">{error}</div> <div class="alert">{error}</div>
{:else if item && item.kind === 'comics'}
<ComicReader {itemId} />
{:else if item && item.kind !== 'ebooks'} {:else if item && item.kind !== 'ebooks'}
<div class="empty"> <div class="empty">
<h3>{item.title}</h3> <h3>{item.title}</h3>