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 7a4cc598fc
commit d0dbc3bbcf
2 changed files with 99 additions and 0 deletions

View File

@@ -1,5 +1,9 @@
FROM python:3.12-slim 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 WORKDIR /app
COPY requirements.txt /app/ COPY requirements.txt /app/

View File

@@ -22,6 +22,7 @@ import json
import os import os
import re import re
import sqlite3 import sqlite3
import subprocess
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Iterator 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") @app.get("/api/callers")
def top_callers(limit: int = 50): def top_callers(limit: int = 50):
db: sqlite3.Connection = app.state.db 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'<a class="ts" href="#" data-seek="{qstart}">{_fmt_time(qstart)}</a>'
f' Q&amp;A{caller_html}' f' Q&amp;A{caller_html}'
f'<button class="play" data-seek="{qstart}">play from here</button>' 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>'
f'<div class="qa-q"><b>Q:</b> {qbody}</div>' f'<div class="qa-q"><b>Q:</b> {qbody}</div>'
f'<div class="qa-a">' 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 { display: block; margin: 1em auto; min-width: 140px; }
button.more:disabled { color: var(--fg-subtle); cursor: default; } 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); .loading { padding: 1.25em 0; text-align: center; color: var(--fg-subtle);
font-style: italic; font-size: 13px; } font-style: italic; font-size: 13px; }
.loading::after { content: '...'; animation: dots 1.4s steps(4, end) infinite; .loading::after { content: '...'; animation: dots 1.4s steps(4, end) infinite;
@@ -979,11 +1063,14 @@ function qaHitHtml(h) {
: (h.a_snippet || ''); : (h.a_snippet || '');
const href = `/episode/${h.episode_id}#qa-${h.qa_id}`; 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}"> return `<a class="hit-link${dim}" href="${href}">
<div class="hit-head"> <div class="hit-head">
<span class="hit-id">${badge}${topicTag}${callerHtml}</span> <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> <span class="hit-where"><span class="ep-title">${epTitle}</span>${epDate} · <span class="ep-time">@ ${time}</span></span>
${dlBtn}
<span class="arrow">&rarr;</span> <span class="arrow">&rarr;</span>
</div> </div>
<div class="hit-q"><span class="label">Q</span>${qBody}</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; }} transition: background .12s; font-family: inherit; }}
button.play:hover {{ background: var(--bg-soft); }} 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); aside {{ position: sticky; top: 130px; max-height: calc(100vh - 150px);
overflow-y: auto; padding-right: 4px; }} overflow-y: auto; padding-right: 4px; }}
aside h3 {{ font-size: 11px; text-transform: uppercase; letter-spacing: .07em; aside h3 {{ font-size: 11px; text-transform: uppercase; letter-spacing: .07em;