radio-archive: add /api/clip endpoint + download buttons + ffmpeg in Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 08:44:46 -07:00
parent b1fac9ba16
commit 8539f62462
2 changed files with 99 additions and 0 deletions

View File

@@ -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/

View File

@@ -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'<a class="ts" href="#" data-seek="{qstart}">{_fmt_time(qstart)}</a>'
f' Q&amp;A{caller_html}'
f'<button class="play" data-seek="{qstart}">play from here</button>'
f'<a class="btn-dl" href="/api/clip/{qr["id"]}" download>&#x2913; mp3</a>'
f'</div>'
f'<div class="qa-q"><b>Q:</b> {qbody}</div>'
f'<div class="qa-a">'
@@ -835,6 +910,15 @@ INDEX_HTML = """<!doctype 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 = `<button class="btn-dl" title="Download clip as MP3"
onclick="event.preventDefault();event.stopPropagation();location.href='/api/clip/${h.qa_id}'">&#x2913; mp3</button>`;
return `<a class="hit-link${dim}" href="${href}">
<div class="hit-head">
<span class="hit-id">${badge}${topicTag}${callerHtml}</span>
<span class="hit-where"><span class="ep-title">${epTitle}</span>${epDate} · <span class="ep-time">@ ${time}</span></span>
${dlBtn}
<span class="arrow">&rarr;</span>
</div>
<div class="hit-q"><span class="label">Q</span>${qBody}</div>
@@ -1332,6 +1419,14 @@ EPISODE_HTML = """<!doctype 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;