Vellum/internal/modules/ebooks/cover.go
mithral 5c63932419 Phase 3: EPUB module, file streaming, per-user progress
- stream: serving goes through http.ServeContent, so Range/If-Range/
  If-Modified-Since are correct for every media type by construction
- progress: the polymorphic store. One row per user/item/file; the PK includes
  the file so multi-track audiobooks resume per track later. Finished is
  inferred when position reaches duration.
- media: generic item endpoints (detail, file, cover, progress, recent). Every
  one routes through auth.CanAccessLibrary; an unauthorised item 404s.
- covers: a per-kind extractor registry. Extraction is lazy and recorded in
  the assets table. A missing cover is a placeholder, never a 500.
- modules/ebooks: EPUB parsing (container.xml -> OPF -> metadata, spine, cover
  with three fallback strategies), cover extraction to /config, and lazy
  metadata parsing so parser fixes need no rescan.

Verified against a generated EPUB: title/2 authors/language/publisher/spine,
cover served as PNG, range request returned 206 with correct Content-Range,
progress round-tripped.
2026-10-09 16:36:27 -04:00

73 lines
1.6 KiB
Go

package ebooks
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/mithral/vellum/internal/media"
)
// Kind is the media kind this module owns.
const Kind = "ebooks"
func init() {
media.RegisterCoverExtractor(Kind, ExtractCover)
}
// ExtractCover writes the EPUB's cover image into the cache and returns its
// path. A missing cover is not an error: it returns ("", nil) and the caller
// serves a placeholder.
func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
md, err := Parse(absPath)
if err != nil {
return "", err
}
if md.CoverHref == "" {
return "", nil
}
dir, err := media.CoverCacheDir(cacheDir, Kind)
if err != nil {
return "", err
}
ext := strings.ToLower(path.Ext(md.CoverHref))
if ext == "" {
ext = ".jpg"
}
outPath := filepath.Join(dir, fmt.Sprintf("%d%s", item.ID, ext))
rc, _, err := OpenEntry(absPath, md.CoverHref)
if err != nil {
// Cover declared but not present in the archive: not fatal.
return "", nil
}
defer rc.Close()
tmp := outPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return "", fmt.Errorf("ebooks: create cover: %w", err)
}
if _, err := io.Copy(f, io.LimitReader(rc, 32<<20)); err != nil {
f.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("ebooks: write cover: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return "", err
}
// Atomic replace so a concurrent reader never sees a half-written image.
if err := os.Rename(tmp, outPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return outPath, nil
}