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'