- 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.
82 lines
2.6 KiB
Go
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
|
|
}
|