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:
@@ -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/
|
||||
|
||||
@@ -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&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>⤓ 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}'">⤓ 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">→</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;
|
||||
|
||||
Reference in New Issue
Block a user