diff --git a/projects/radio-show/audio-processor/server/Dockerfile b/projects/radio-show/audio-processor/server/Dockerfile index 290419f..df5f632 100644 --- a/projects/radio-show/audio-processor/server/Dockerfile +++ b/projects/radio-show/audio-processor/server/Dockerfile @@ -1,5 +1,9 @@ FROM python:3.12-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY requirements.txt /app/ diff --git a/projects/radio-show/audio-processor/server/main.py b/projects/radio-show/audio-processor/server/main.py index 1f1494a..d99c162 100644 --- a/projects/radio-show/audio-processor/server/main.py +++ b/projects/radio-show/audio-processor/server/main.py @@ -22,6 +22,7 @@ import json import os import re import sqlite3 +import subprocess from contextlib import asynccontextmanager from pathlib import Path from typing import Iterator @@ -454,6 +455,79 @@ def stream_audio(episode_id: int, request: Request): ) +@app.get("/api/clip/{qa_id}") +def download_clip(qa_id: int): + """Extract and stream a Q&A pair as a stand-alone MP3. + + Runs ffmpeg with 1-second padding and 200ms fade in/out on each side. + Streams directly from ffmpeg stdout — no temp file on disk. + """ + db: sqlite3.Connection = app.state.db + row = db.execute( + """SELECT p.question_start_sec, p.answer_end_sec, + p.topic_class, p.caller_name, + e.rel_path, e.air_date + FROM qa_pairs p JOIN episodes e ON e.id = p.episode_id + WHERE p.id = ?""", + (qa_id,), + ).fetchone() + if not row: + raise HTTPException(404, "Q&A pair not found") + + path = _resolve_audio_path(row["rel_path"]) + if path is None: + raise HTTPException(404, "audio file not on this server") + + a_end = row["answer_end_sec"] + if a_end is None: + raise HTTPException(422, "Q&A pair has no end timestamp") + + q_start = row["question_start_sec"] or 0.0 + start = max(0.0, q_start - 1.0) + end = a_end + 1.0 + duration = end - start + fade_s = 0.2 + + date_part = (row["air_date"] or "unknown").replace("/", "-") + name_parts = [date_part] + if row["caller_name"]: + name_parts.append(re.sub(r"[^\w\s-]", "", row["caller_name"]).strip()[:30]) + if row["topic_class"]: + name_parts.append(row["topic_class"]) + filename = "-".join(name_parts).replace(" ", "-") + ".mp3" + + def _stream(): + cmd = [ + "ffmpeg", "-y", + "-ss", f"{start:.3f}", + "-i", str(path), + "-t", f"{duration:.3f}", + "-af", ( + f"afade=t=in:st=0:d={fade_s}," + f"afade=t=out:st={max(0.0, duration - fade_s):.3f}:d={fade_s}" + ), + "-q:a", "2", + "-f", "mp3", + "pipe:1", + ] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + try: + while True: + chunk = proc.stdout.read(65536) + if not chunk: + break + yield chunk + finally: + proc.stdout.close() + proc.wait() + + return StreamingResponse( + _stream(), + media_type="audio/mpeg", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @app.get("/api/callers") def top_callers(limit: int = 50): db: sqlite3.Connection = app.state.db @@ -639,6 +713,7 @@ def _episode_html(episode_id: int) -> str: f'{_fmt_time(qstart)}' f' Q&A{caller_html}' f'' + f'⤓ mp3' f'' f'
Q: {qbody}
' f'
' @@ -835,6 +910,15 @@ INDEX_HTML = """ button.more { display: block; margin: 1em auto; min-width: 140px; } button.more:disabled { color: var(--fg-subtle); cursor: default; } + button.btn-dl { + font: inherit; font-size: 11px; padding: 1px 7px; cursor: pointer; + border: 1px solid var(--border); border-radius: 3px; + background: var(--bg); color: var(--fg-muted); + transition: background .12s, color .12s; + flex-shrink: 0; + } + button.btn-dl:hover { background: var(--bg-soft); color: var(--fg); } + .loading { padding: 1.25em 0; text-align: center; color: var(--fg-subtle); font-style: italic; font-size: 13px; } .loading::after { content: '...'; animation: dots 1.4s steps(4, end) infinite; @@ -979,11 +1063,14 @@ function qaHitHtml(h) { : (h.a_snippet || ''); const href = `/episode/${h.episode_id}#qa-${h.qa_id}`; + const dlBtn = ``; return `
${badge}${topicTag}${callerHtml} ${epTitle}${epDate} · @ ${time} + ${dlBtn}
Q${qBody}
@@ -1332,6 +1419,14 @@ EPISODE_HTML = """ transition: background .12s; font-family: inherit; }} button.play:hover {{ background: var(--bg-soft); }} + a.btn-dl {{ font-size: 11px; padding: 2px 8px; + border: 1px solid var(--border); border-radius: 3px; + background: var(--bg); color: var(--fg-muted); + text-decoration: none; + transition: background .12s, color .12s; }} + a.btn-dl:hover {{ background: var(--bg-soft); color: var(--fg); + text-decoration: none; }} + aside {{ position: sticky; top: 130px; max-height: calc(100vh - 150px); overflow-y: auto; padding-right: 4px; }} aside h3 {{ font-size: 11px; text-transform: uppercase; letter-spacing: .07em;