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 }