Vellum/internal/media/covers.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

82 lines
2.6 KiB
Go

package media
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
)
// CoverExtractor pulls cover art out of a media file and writes it under
// cacheDir, returning the path to the written image.
//
// Modules register one per media kind. It runs lazily on first request and the
// result is recorded in the assets table, so the cost is paid once.
type CoverExtractor func(ctx context.Context, db *sql.DB, item Item, absPath, cacheDir string) (string, error)
var (
coverMu sync.RWMutex
coverExtractors = map[string]CoverExtractor{}
)
// RegisterCoverExtractor installs the extractor for a media kind. A later
// registration replaces an earlier one.
func RegisterCoverExtractor(kind string, fn CoverExtractor) {
coverMu.Lock()
defer coverMu.Unlock()
coverExtractors[kind] = fn
}
// CoverPath returns the cached cover path for an item, extracting it if
// necessary. The bool is false when no cover could be produced — which is a
// normal outcome, not an error, so callers return a placeholder rather than a
// 500.
func CoverPath(ctx context.Context, db *sql.DB, item Item, cacheDir string) (string, bool, error) {
// Already extracted and recorded?
var path string
err := db.QueryRowContext(ctx, `
SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, item.ID).Scan(&path)
if err == nil {
if _, statErr := os.Stat(path); statErr == nil {
return path, true, nil
}
// Recorded but gone: fall through and re-extract.
} else if err != sql.ErrNoRows {
return "", false, fmt.Errorf("media: lookup cover: %w", err)
}
coverMu.RLock()
extract, ok := coverExtractors[item.Kind]
coverMu.RUnlock()
if !ok {
return "", false, nil
}
outPath, err := extract(ctx, db, item, item.AbsPath(), cacheDir)
if err != nil {
return "", false, fmt.Errorf("media: extract cover: %w", err)
}
if outPath == "" {
return "", false, nil
}
if _, err := db.ExecContext(ctx, `
INSERT INTO assets (item_id, role, path) VALUES (?, 'cover', ?)
ON CONFLICT (item_id, role, path) DO NOTHING`, item.ID, outPath); err != nil {
return "", false, fmt.Errorf("media: record cover: %w", err)
}
return outPath, true, nil
}
// ── helpers for extractors ─────────────────────────────────────────────
// CoverCacheDir returns (and creates) the per-kind cover cache directory.
func CoverCacheDir(cacheDir, kind string) (string, error) {
dir := filepath.Join(cacheDir, "covers", kind)
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
return dir, nil
}