Files
bookhoard/internal/services/podcast_worker.go
T
John O'Keefe b574df346a chore(podcasts): drop the dead QueueFeedDownloads wrapper
All callers moved to QueueFeedDownloadsAs in the item-5 commit; the
no-override shim had no remaining callers.
2026-10-30 10:29:45 -04:00

841 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Podcast download worker (plan §7 / D6 / D8 / D19; pool since §0 item
// 4): a download-manager-style pool driving the episode state machine
//
// pending → queued → downloading → downloaded
// ↘ failed (+ download_error)
//
// queued rows are the DB-backed worklist — the state is persisted so
// the episode list ALWAYS renders truth (no in-memory queue to lose).
// Each queue ticket (download_queue_pos, a SEQUENCE nextval at
// enqueue) makes the enqueue loop's ordering the download order.
//
// Concurrency: up to podcast_download_concurrency episodes in flight
// server-wide (default 4, admin-tunable live 1–16). Fair share: the
// claim prefers the feed least-recently-served, so several books
// download in parallel while chapters within a book land in ticket
// order (start listening while the rest catch up). Bandwidth: an
// optional shared token bucket (podcast_download_speed_mbps, 0 =
// unlimited) paces all workers together.
//
// Idempotency: a download request for an episode whose file already
// exists and probes cleanly re-marks it downloaded without re-fetching
// (crash-between-move-and-update reconciliation); a downloaded episode
// re-request is a full no-op.
//
// Retry semantics: manual retry (same endpoint as download) resets
// download_attempts; auto-download policies re-queue failed episodes on
// later polls up to maxAutoDownloadAttempts, then stay failed until a
// human retries.
//
// RLS: the worker runs detached — every statement executes under the
// first-admin identity (invariant 12), same contract as the fetcher.
package services
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// maxAutoDownloadAttempts caps POLICY-driven re-queues of a failing
// episode; a human retry always resets the budget.
const maxAutoDownloadAttempts = 5
// PodcastDownloadWorker processes queued episode downloads.
type PodcastDownloadWorker struct {
db *database.Queries
svc *PodcastService
settings *database.SettingsRegistry
wake chan struct{}
// Fair-share bookkeeping (§0 item 4): feed UUID string → last
// time the pool served it. Claim prefers the smallest value;
// never-served feeds (missing key) go first. Guarded by fairMu.
fairMu sync.Mutex
lastServed map[string]time.Time
// Shared bandwidth bucket across all pool workers (§0 item 4).
limiter bandwidthLimiter
// Live byte-progress slots for in-flight downloads (issue #1).
// Keyed by episode UUID string; guarded by progMu.
progMu sync.Mutex
progress map[string]*episodeProgress
}
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
return &PodcastDownloadWorker{
db: db,
svc: svc,
wake: make(chan struct{}, 1),
lastServed: make(map[string]time.Time),
progress: make(map[string]*episodeProgress),
}
}
// SetSettings wires the live settings registry (admin tunables:
// concurrency + bandwidth cap). Without it the worker runs on the
// registered defaults (4 in flight, unlimited speed) — the test
// harness constructs workers without settings.
func (w *PodcastDownloadWorker) SetSettings(r *database.SettingsRegistry) {
w.settings = r
}
// concurrency re-reads the live setting each drain cycle.
func (w *PodcastDownloadWorker) concurrency() int {
if w.settings == nil {
return 4 // registered default
}
if n := w.settings.PodcastDownloadConcurrency(); n >= 1 {
return n
}
return 4
}
// refillLimiter re-syncs the shared bucket's rate (bytes/sec) from the
// live setting; 0 = unlimited.
func (w *PodcastDownloadWorker) refillLimiter() {
if w.settings == nil {
return
}
w.limiter.setRate(int64(w.settings.PodcastDownloadSpeedMbps() * 125000)) // Mbps → bytes/sec
}
// Wake nudges the loop (new work queued).
func (w *PodcastDownloadWorker) Wake() {
select {
case w.wake <- struct{}{}:
default:
}
}
// Start launches the loop; it exits when ctx is cancelled. Called once
// from route registration (scanner background-tasks precedent).
func (w *PodcastDownloadWorker) Start(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-w.wake:
case <-time.After(5 * time.Second):
// Poll fallback: a queued row written by another process
// (or a crashed worker's leftovers) still gets picked up.
}
w.drain(ctx)
}
}()
}
// DrainNow runs one bounded pool round synchronously — the test hook
// and the future admin "retry now" seam (normally the wake channel
// drives rounds).
func (w *PodcastDownloadWorker) DrainNow(ctx context.Context) {
w.drain(ctx)
}
// drain runs one pool round: up to concurrency() claim-loop goroutines
// each claim (fair-share) → download → settle, until the queue is
// empty. The cap setting is re-read every round — live-tunable.
func (w *PodcastDownloadWorker) drain(ctx context.Context) {
if ctx.Err() != nil {
return
}
w.refillLimiter()
limit := w.concurrency()
var wg sync.WaitGroup
for i := 0; i < limit; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
if ctx.Err() != nil {
return
}
ep, ok := w.claimNext(ctx)
if !ok {
return // queue drained (or nothing claimable)
}
w.runOne(ctx, ep)
}
}()
}
wg.Wait()
}
// claimNext atomically claims the next queued episode, preferring the
// feed least-recently-served (fair share — books proceed in parallel)
// and the earliest queue ticket within a feed (chapters land in
// enqueue order). Returns ok=false when nothing is claimable.
func (w *PodcastDownloadWorker) claimNext(ctx context.Context) (database.MediaItems, bool) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return database.MediaItems{}, false
}
cands, err := w.db.ListQueuedPodcastEpisodes(actx)
if err != nil || len(cands) == 0 {
return database.MediaItems{}, false
}
// Group per feed preserving ticket order (the SQL scan is ordered).
perFeed := make(map[pgtype.UUID][]pgtype.UUID, 8)
for _, c := range cands {
perFeed[c.SourceFeedID] = append(perFeed[c.SourceFeedID], c.ID)
}
w.fairMu.Lock()
defer w.fairMu.Unlock()
pickFeed := pickFeedToServe(perFeed, w.lastServed)
if !pickFeed.Valid {
return database.MediaItems{}, false
}
// Claim the head ticket of the chosen feed; if another claimant
// won it meanwhile (retry raced), fall through the feed's tickets.
for _, id := range perFeed[pickFeed] {
ep, err := w.db.ClaimQueuedPodcastEpisode(actx, id)
if err != nil {
continue // claimed elsewhere / state moved on
}
w.lastServed[pickFeed.String()] = time.Now()
return ep, true
}
return database.MediaItems{}, false
}
// pickFeedToServe chooses the feed least-recently-served (never-served
// first); among never-served feeds the first encountered wins (any is
// equally fair). Unit-testable seam for the fair-share rule.
func pickFeedToServe(perFeed map[pgtype.UUID][]pgtype.UUID, lastServed map[string]time.Time) pgtype.UUID {
var pick pgtype.UUID
var best time.Time
haveNever := false
for feed := range perFeed {
last, seen := lastServed[feed.String()]
if !seen {
if !haveNever {
pick, haveNever = feed, true
}
continue
}
if haveNever {
continue // a never-served feed already won
}
if !pick.Valid || last.Before(best) {
pick, best = feed, last
}
}
return pick
}
// runOne downloads a claimed episode and settles the row either way
// (failed/rejected: state + reason persist; metadata/file facts
// untouched — invariant 7, write-after-success only).
func (w *PodcastDownloadWorker) runOne(ctx context.Context, ep database.MediaItems) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return
}
if err := w.runPipeline(actx, ep); err != nil {
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
ID: ep.ID,
DownloadError: pgtypeText(err.Error()),
Column3: false, // auto failure: never resets the attempt budget
})
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
}
}
// AdminCtx exposes the worker's first-admin identity (tests use it for
// worker-scope DB statements).
func (w *PodcastDownloadWorker) AdminCtx(ctx context.Context) (context.Context, error) {
return w.svc.adminCtx(ctx)
}
// QueueEpisode marks one episode queued (manual download / retry path).
// Idempotent by design: downloaded → no-op. The per-feed lock
// serializes against polls rewriting the same row.
func (w *PodcastDownloadWorker) QueueEpisode(ctx context.Context, episodeID, feedID pgtype.UUID) error {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return err
}
lock := w.svc.feedLock(feedID.String())
lock.Lock()
defer lock.Unlock()
ep, err := w.db.GetPodcastEpisode(actx, episodeID)
if err != nil {
return errNotFound("episode not found")
}
if ep.DownloadState == "downloaded" {
return nil // idempotent no-op
}
// Manual path resets the attempt budget (SetPodcastEpisodePending)
// then queues — a human retry always overrides auto-retry capping.
if err := w.db.SetPodcastEpisodePending(actx, episodeID); err != nil {
return err
}
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, episodeID); err != nil {
return err
}
w.Wake()
return nil
}
// QueueFeedDownloadsAs is Download All honoring the CALLER's listening
// order (§0 item 5b): episodes queue in the feed's effective order (the
// caller's override, feed-native fallback). Tickets (§0 item 4) make
// that queue order the download order — "getting the last chapter
// first doesn't help me". userID zero = no override (feed order).
func (w *PodcastDownloadWorker) QueueFeedDownloadsAs(ctx context.Context, userID, feedID pgtype.UUID) (int, error) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return 0, err
}
lock := w.svc.feedLock(feedID.String())
lock.Lock()
defer lock.Unlock()
eps, err := w.db.ListUndownloadedEpisodes(actx, feedID)
if err != nil {
return 0, err
}
order := "native"
if userID.Valid {
order = w.orderOverrideFor(actx, userID, feedID)
}
// THE order helper (§0 item 5a) — same semantics as the sequence,
// reader chain, and page list; no per-surface sort drift.
OrderEpisodes(eps, order,
func(ep database.MediaItems) int32 { return ep.FeedPosition.Int32 },
func(ep database.MediaItems) string {
if ep.DatePublished.Valid {
return ep.DatePublished.Time.Format("2006-01-02")
}
return ""
},
)
queued := 0
for _, ep := range eps {
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
queued++
}
}
w.Wake()
return queued, nil
}
// orderOverrideFor reads a user's podcast_order override via the
// reader_settings KV (the D5 storage; native when absent/invalid).
func (w *PodcastDownloadWorker) orderOverrideFor(ctx context.Context, userID, feedID pgtype.UUID) string {
row, err := w.db.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
UserID: userID, SettingKey: "podcast_order:" + feedID.String(),
})
if err != nil {
return "native"
}
var v struct {
Order string `json:"order"`
}
if json.Unmarshal(row, &v) != nil || !ValidPodcastEpisodeOrder(v.Order) {
return "native"
}
return v.Order
}
// ApplyAutoPolicies unions subscriber policies for a feed (D6: most
// aggressive wins for what ARRIVES — deletion stays admin-only) and
// queues the target set, respecting the auto-retry attempt cap.
func (w *PodcastDownloadWorker) ApplyAutoPolicies(ctx context.Context, feedID pgtype.UUID) (int, error) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return 0, err
}
subs, err := w.db.ListFeedSubscribers(actx, feedID)
if err != nil {
return 0, err
}
wantAll := false
newestN := 0
for _, sub := range subs {
switch sub.AutoDownload {
case "all":
wantAll = true
case "newest":
if n := int(sub.AutoDownloadCount.Int32); n > newestN {
newestN = n
}
}
}
if !wantAll && newestN == 0 {
return 0, nil // nobody's policy asks for anything
}
lock := w.svc.feedLock(feedID.String())
lock.Lock()
defer lock.Unlock()
eps, err := w.db.ListUndownloadedEpisodes(actx, feedID)
if err != nil {
return 0, err
}
// ListUndownloadedEpisodes is native-order (oldest first); "newest
// N" takes the TAIL (highest positions = latest releases).
targets := eps
if !wantAll && newestN > 0 && len(eps) > newestN {
targets = eps[len(eps)-newestN:]
}
queued := 0
for _, ep := range targets {
// Auto-retry budget: only re-queue within the cap. A failed
// episode past the cap waits for a human (its row renders the
// reason + retry button — D8/D19).
if ep.DownloadState == "failed" && int(ep.DownloadAttempts) >= maxAutoDownloadAttempts {
continue
}
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
queued++
}
}
if queued > 0 {
w.Wake()
}
return queued, nil
}
// runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/
// size validation → ffprobe reconcile → move into place → SUCCESS-only
// row update.
func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.MediaItems) error {
if !ep.EnclosureUrl.Valid || ep.EnclosureUrl.String == "" {
return fmt.Errorf("episode has no enclosure URL")
}
if !ep.SourceFeedID.Valid {
return fmt.Errorf("episode has no feed reference")
}
feed, err := w.db.GetPodcastFeed(ctx, ep.SourceFeedID)
if err != nil {
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
}
root, err := w.svc.downloadRootAbs(ctx, &feed)
if err != nil {
return err
}
// Declared-size pre-check: reject hopeless downloads before
// transferring a byte (validation rejections render like failures,
// with their reason — D8).
if ep.FileSize.Valid && ep.FileSize.Int64 > maxEnclosureCapBytes {
return fmt.Errorf("rejected: enclosure declares %d bytes (cap %d)", ep.FileSize.Int64, maxEnclosureCapBytes)
}
feedDir := filepath.Join(root, filepath.Dir(ep.FilePath))
finalPath := filepath.Join(root, ep.FilePath)
if err := os.MkdirAll(feedDir, 0o755); err != nil {
return fmt.Errorf("download dir: %w", err)
}
// Idempotency reconciliation: file already on disk and probes?
if _, err := os.Stat(finalPath); err == nil {
if payload := probeEpisodeAudio(finalPath); payload != nil {
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
}
// Unprobeable stray: fall through and re-fetch over it.
}
// ---- fetch to temp (streamed with byte-progress tracking) ----
// Enclosures stream straight to disk through a counting loop — no
// whole-body buffering — so the episode list can render a real
// progress bar while the worker runs (issue #1).
tmpPath := finalPath + ".part-" + randomSuffix()
cleanup := func() { _ = os.Remove(tmpPath) }
received, total, fetchErr := w.streamEnclosure(ctx, ep, tmpPath)
w.endProgress(ep.ID) // terminal either way; the row state is the truth
if fetchErr != nil {
cleanup()
return fetchErr
}
_, _ = received, total
// ---- validation ----
info, err := os.Stat(tmpPath)
if err != nil || info.Size() == 0 {
cleanup()
return fmt.Errorf("rejected: empty download")
}
if info.Size() > maxEnclosureCapBytes {
cleanup()
return fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
}
if !looksLikeAudioFile(tmpPath) {
cleanup()
return fmt.Errorf("rejected: downloaded file does not look like audio media")
}
// ---- ffprobe reconcile + embedded cover (scanner machinery reuse) ----
payload := probeEpisodeAudio(tmpPath)
// ---- move into place (crash-safe: rename is atomic on-same-fs) ----
if err := os.Rename(tmpPath, finalPath); err != nil {
cleanup()
return fmt.Errorf("finalize: %w", err)
}
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
}
// ---- byte-progress tracking (issue #1) ----
// In-memory, deliberately NOT persisted: a downloading row renders a
// bar from this snapshot while the worker holds it; the DB row's state
// remains the durable truth (queued/downloading/downloaded).
type episodeProgress struct {
Received int64
Total int64 // 0 = unknown (no Content-Length, no declared length)
}
// beginProgress registers a download slot (total 0 = unknown until the
// response's Content-Length arrives).
func (w *PodcastDownloadWorker) beginProgress(episodeID pgtype.UUID, total int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
w.progress[episodeID.String()] = &episodeProgress{Total: total}
}
// addProgress accumulates streamed bytes.
func (w *PodcastDownloadWorker) addProgress(episodeID pgtype.UUID, n int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
if p, ok := w.progress[episodeID.String()]; ok {
p.Received += n
if p.Total == 0 {
// Total unknown at start; keep received-only (bar renders
// indeterminate).
}
}
}
// setProgressTotal records the authoritative total once known.
func (w *PodcastDownloadWorker) setProgressTotal(episodeID pgtype.UUID, total int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
if p, ok := w.progress[episodeID.String()]; ok && total > 0 {
p.Total = total
}
}
// endProgress drops the slot (terminal state reached — the row itself
// now tells the story).
func (w *PodcastDownloadWorker) endProgress(episodeID pgtype.UUID) {
w.progMu.Lock()
defer w.progMu.Unlock()
delete(w.progress, episodeID.String())
}
// EpisodeProgress reports the live byte counts for a downloading
// episode (ok=false when nothing is in flight for it).
func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received, total int64, ok bool) {
w.progMu.Lock()
defer w.progMu.Unlock()
p, ok := w.progress[episodeID.String()]
if !ok {
return 0, 0, false
}
return p.Received, p.Total, true
}
// ---- shared bandwidth bucket (§0 item 4) ----
// One leaky-bucket-with-reservations limiter across ALL pool workers:
// each caller reserves the next slot of bandwidth and sleeps outside
// the lock — reservation order = transmission order, and N workers
// share one cap instead of N caps.
type bandwidthLimiter struct {
mu sync.Mutex
rate int64 // bytes per second; <=0 = unlimited
next time.Time // when the currently-reserved budget runs out
}
func (l *bandwidthLimiter) setRate(bytesPerSec int64) {
l.mu.Lock()
defer l.mu.Unlock()
l.rate = bytesPerSec
}
// take reserves n bytes of bandwidth and returns how long the caller
// must wait before sending them (0 when free flow).
func (l *bandwidthLimiter) take(n int64) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
if l.rate <= 0 {
return 0
}
now := time.Now()
if l.next.Before(now) {
l.next = now
}
d := time.Duration(float64(n) / float64(l.rate) * float64(time.Second))
start := l.next // my window begins when earlier reservations end
l.next = start.Add(d)
return start.Sub(now)
}
// streamEnclosure performs the SSRF-guarded enclosure download straight
// to tmpPath while updating the progress slot. Returns bytes received
// and the authoritative total (0 when unknown). Scheme and size guards
// match guardFetch; redirects re-dial through the same client so the
// IP guard re-applies per hop.
func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database.MediaItems, tmpPath string) (int64, int64, error) {
u, err := url.Parse(ep.EnclosureUrl.String)
if err != nil {
return 0, 0, fmt.Errorf("download failed: invalid enclosure URL: %w", err)
}
if err := guardURLScheme(u); err != nil {
return 0, 0, fmt.Errorf("download failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureUrl.String, nil)
if err != nil {
return 0, 0, fmt.Errorf("download failed: %w", err)
}
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
// Declared total (feed's enclosure length) seeds the bar until the
// response's Content-Length replaces it.
declared := int64(0)
if ep.FileSize.Valid {
declared = ep.FileSize.Int64
}
w.beginProgress(ep.ID, declared)
resp, err := podcastHTTPClientFactory().Do(req)
if err != nil {
return 0, declared, fmt.Errorf("download failed: %w", classifyFetchError(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 0, declared, fmt.Errorf("download failed: HTTP %d for enclosure", resp.StatusCode)
}
total := resp.ContentLength // -1 when unknown
if total < 0 {
total = declared
}
w.setProgressTotal(ep.ID, total)
out, err := os.Create(tmpPath)
if err != nil {
return 0, total, fmt.Errorf("write: %w", err)
}
defer func() { _ = out.Close() }()
buf := make([]byte, 64<<10)
var received int64
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
received += int64(n)
if received > maxEnclosureCapBytes {
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
}
// Shared bandwidth cap: pace against the server-wide
// bucket (0 rate = unlimited, take returns 0).
if wait := w.limiter.take(int64(n)); wait > 0 {
select {
case <-ctx.Done():
return received, total, fmt.Errorf("download failed: cancelled")
case <-time.After(wait):
}
}
w.addProgress(ep.ID, int64(n))
if _, werr := out.Write(buf[:n]); werr != nil {
return received, total, fmt.Errorf("write: %w", werr)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return received, total, fmt.Errorf("download failed: %w", readErr)
}
}
return received, total, nil
}
// markDownloaded is the SUCCESS-ONLY row write (invariant 7): the file
// is on disk and (when ffprobe is available) probed before this runs.
func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.MediaItems, feed *database.PodcastFeeds, absPath string, payload *AudioMetadataPayload) error {
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("post-move stat: %w", err)
}
// Embedded cover extraction (optional enrichment; scanner precedent).
coverRel := pgtype.Text{}
base := strings.TrimSuffix(absPath, filepath.Ext(absPath))
if coverAbs := extractEmbeddedAudioCover(absPath, base); coverAbs != "" {
if root, err := w.svc.downloadRootAbs(ctx, feed); err == nil {
if rel, err := filepath.Rel(root, coverAbs); err == nil {
coverRel = pgtypeText(rel)
}
}
}
var chapterCount pgtype.Int4
if payload != nil && len(payload.Chapters) > 0 {
chapterCount = pgtypeInt4(int32(len(payload.Chapters)))
}
_, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{
ID: ep.ID,
FilePath: ep.FilePath,
FileSize: pgtypeInt8(info.Size()),
MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))),
CoverImagePath: coverRel,
AudioMetadata: audioPayloadJSONB(payload),
ChapterCount: chapterCount,
})
if err != nil {
return fmt.Errorf("mark downloaded: %w", err)
}
log.Printf("[PODCAST-DL] downloaded %q (%s) for feed %s", ep.Title, ep.FilePath, feed.Title)
return nil
}
// probeEpisodeAudio shells out to ffprobe and builds the audio_metadata
// payload (scanner machinery reuse; nil when ffprobe is absent — the
// episode still downloads, facts degrade to declared values).
func probeEpisodeAudio(path string) *AudioMetadataPayload {
report := runFFProbe(path)
if report == nil {
return nil
}
payload := &AudioMetadataPayload{}
if report.Format.Duration > 0 {
payload.DurationSeconds = float64(report.Format.Duration)
}
if report.Format.BitRate > 0 {
payload.BitRate = int64(report.Format.BitRate)
}
if len(report.Streams) > 0 {
profile := report.Streams[0].CodecName
if p := report.Streams[0].Profile; p != "" && p != "unknown" {
profile += " / " + p
}
payload.CodecProfile = profile
}
for _, ch := range report.Chapters {
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: ch.ID,
Title: ch.Tags.Title,
Start: float64(ch.StartTime),
End: float64(ch.EndTime),
})
}
return payload
}
// looksLikeAudioFile sniffs content: an audio container starts with
// known magic bytes, or (ID3v1/v2, ADTS) has recognizable heads. HTML
// error pages and JSON never pass.
func looksLikeAudioFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer func() { _ = f.Close() }()
head := make([]byte, 16)
n, _ := io.ReadFull(f, head)
if n < 4 {
return false
}
h := head[:n]
switch {
// MP3: ID3 tag or MPEG frame sync.
case string(h[:3]) == "ID3":
return true
case h[0] == 0xFF && h[1]&0xE0 == 0xE0:
return true
// MP4/M4A/M4B: ftyp box.
case string(h[4:8]) == "ftyp":
return true
// FLAC
case string(h[:4]) == "fLaC":
return true
// OGG/Opus
case string(h[:4]) == "OggS":
return true
// WAV/RIFF
case string(h[:4]) == "RIFF":
return true
// AAC ADTS (sync word)
case h[0] == 0xFF && (h[1]&0xF6) == 0xF0:
return true
}
return false
}
// pickMimeType prefers the feed-declared type; falls back by extension.
func pickMimeType(ep database.MediaItems, absPath string) string {
if ep.MimeType.Valid && strings.HasPrefix(ep.MimeType.String, "audio/") {
return ep.MimeType.String
}
switch strings.ToLower(filepath.Ext(absPath)) {
case ".m4a", ".m4b":
return "audio/mp4"
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".ogg", ".oga", ".opus":
return "audio/ogg"
case ".wav":
return "audio/wav"
case ".aac":
return "audio/aac"
}
return "audio/mpeg"
}
// audioPayloadJSONB marshals the probed payload for the JSONB column
// (nil payload → NULL: keep the column honest, never an empty object).
func audioPayloadJSONB(p *AudioMetadataPayload) []byte {
if p == nil {
return nil
}
b, err := json.Marshal(p)
if err != nil {
return nil
}
return b
}
// randomSuffix disambiguates concurrent temp files (single worker, but
// a crashed worker's stale .part files must never collide).
func randomSuffix() string {
sum := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano) + fmt.Sprint(os.Getpid())))
return hex.EncodeToString(sum[:4])
}