Vellum/internal/modules/comics/cover.go
mithral c3b7db7114 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.
2026-10-09 21:07:59 -04:00

52 lines
1.1 KiB
Go

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
}