Make the radio archive Q&A pairs actually browseable end to end:
- /api/qa list endpoint (year, min_score, exclude_banter, topic_class,
pagination, sort by air_date or score). Returns the same column shape as
/api/search Q&A hits.
- /api/audio/{episode_id} streams the MP3 with HTTP Range support so the
browser <audio> can seek. 206 + Content-Range when ranged, 200 when
full-file. Returns 404 cleanly when episodes/ tree is absent (Jupiter).
- /episode/{id} HTML transcript view: chronological segments with clickable
timestamps, Q&A blocks spliced inline (anchor #qa-<id>), intros marked
inline, right-rail summary. Hash-anchor on load auto-seeks the audio.
- New question_excerpt / answer_excerpt fields on /api/search Q&A hits and
on /api/qa items: trim leading run-on chatter, take ~300 chars, end on a
sentence boundary or word boundary with ellipsis.
- Index UI: each Q&A hit now links to /episode/{id}#qa-{qa_id}; new
"Browse all Q&A" toggle (year selector, sort, append-load 50 per page,
defaults to min_score=3); FTS snippet replaced with the plain excerpt
when available.
No new dependencies, no schema changes, no LLM calls. Uses
EPISODES_DIR env (default /data/episodes) — Jupiter compose still only
mounts /data so audio degrades gracefully to 404 there until episodes
are uploaded.
1083 lines
40 KiB
Python
1083 lines
40 KiB
Python
"""
|
|
Radio archive query server. Read-only FastAPI over the SQLite archive.db.
|
|
|
|
Endpoints:
|
|
GET / Landing page with search UI
|
|
GET /api/episodes List all episodes (year, title, duration)
|
|
GET /api/episodes/{id} Episode detail: intros + qa_pairs
|
|
GET /api/episodes/{id}/transcript Chronologically merged segments + turns
|
|
GET /api/search?q=...&kind=... FTS over segments and/or qa_pairs
|
|
GET /api/qa List Q&A pairs (no search query, filterable)
|
|
GET /api/audio/{id} Stream the episode MP3 (HTTP Range supported)
|
|
GET /api/callers Top recurring caller_names
|
|
GET /episode/{id} HTML transcript view with audio player
|
|
|
|
Config via env:
|
|
ARCHIVE_DB path to archive.db (default /data/archive.db)
|
|
EPISODES_DIR path to mp3 tree (default /data/episodes)
|
|
PORT listen port (default 8765)
|
|
"""
|
|
import html as _html
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
|
|
from fastapi import FastAPI, HTTPException, Query, Request
|
|
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
|
|
|
|
DB_PATH = os.environ.get("ARCHIVE_DB", "/data/archive.db")
|
|
EPISODES_DIR = os.environ.get("EPISODES_DIR", "/data/episodes")
|
|
PORT = int(os.environ.get("PORT", "8765"))
|
|
|
|
|
|
def _connect() -> sqlite3.Connection:
|
|
if not Path(DB_PATH).exists():
|
|
raise RuntimeError(f"Archive DB not found at {DB_PATH}")
|
|
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
app.state.db = _connect()
|
|
yield
|
|
app.state.db.close()
|
|
|
|
|
|
app = FastAPI(title="Computer Guru Radio Archive", lifespan=lifespan)
|
|
|
|
|
|
def fts_escape(q: str) -> str:
|
|
"""Wrap each term in double quotes so FTS5 treats reserved chars literally."""
|
|
return " ".join(f'"{tok}"' for tok in q.split() if tok)
|
|
|
|
|
|
# Excerpt extraction for Q&A texts.
|
|
#
|
|
# Whisper transcripts often start with disfluent run-on chatter that's a
|
|
# leftover from the previous turn. We trim that prefix, take the first 300
|
|
# chars, and try to end on a sentence boundary so the excerpt reads cleanly.
|
|
_EXCERPT_BODY = 300 # primary character budget
|
|
_EXCERPT_LOOKAHEAD = 80 # extra chars allowed to find a sentence end
|
|
_EXCERPT_LEAD_SCAN = 30 # window to look for a leading capital letter
|
|
|
|
|
|
def _excerpt(text: str | None) -> str:
|
|
"""Return a short, readable excerpt suitable for browsing.
|
|
|
|
Rules (intentionally simple — see spec):
|
|
1. Walk the leading prefix and skip to the first capital letter, but
|
|
only within the first ~30 chars; otherwise keep the original start.
|
|
2. Take the first 300 chars.
|
|
3. If that cut lands mid-sentence, look up to 80 more chars ahead for
|
|
the next .!? and end there.
|
|
4. Otherwise back up to the last word boundary and append "..." so we
|
|
never display half a word.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
s = text.strip()
|
|
if not s:
|
|
return ""
|
|
|
|
# 1. trim disfluent leading run-on to the first capital letter
|
|
lead_window = s[:_EXCERPT_LEAD_SCAN]
|
|
cap_match = re.search(r"[A-Z]", lead_window)
|
|
if cap_match and cap_match.start() > 0:
|
|
s = s[cap_match.start():]
|
|
|
|
if len(s) <= _EXCERPT_BODY:
|
|
return s
|
|
|
|
body = s[:_EXCERPT_BODY]
|
|
# 3. if the body ends mid-sentence, look ahead for a terminator
|
|
if body and body[-1] not in ".!?":
|
|
ahead = s[_EXCERPT_BODY:_EXCERPT_BODY + _EXCERPT_LOOKAHEAD]
|
|
m = re.search(r"[.!?]", ahead)
|
|
if m:
|
|
return body + ahead[: m.end()]
|
|
# 4. back up to last whitespace and ellipsize
|
|
cut = body.rfind(" ")
|
|
if cut > 0:
|
|
return body[:cut].rstrip(",;:- ") + "..."
|
|
return body + "..."
|
|
|
|
return body
|
|
|
|
|
|
def _qa_search_excerpts(row: dict) -> dict:
|
|
"""Augment a search/qa row with question/answer excerpts.
|
|
|
|
Excerpts are computed from the (un-highlighted) full text that lives
|
|
next to the snippet in the row. This keeps the existing q_snippet/
|
|
a_snippet (with <mark> highlighting) working for back-compat and adds
|
|
plain-text excerpts the UI can prefer.
|
|
"""
|
|
row["question_excerpt"] = _excerpt(row.pop("_question_text", None))
|
|
row["answer_excerpt"] = _excerpt(row.pop("_answer_text", None))
|
|
return row
|
|
|
|
|
|
@app.get("/api/episodes")
|
|
def list_episodes(year: int | None = None, limit: int = 1000):
|
|
db: sqlite3.Connection = app.state.db
|
|
sql = """
|
|
SELECT id, year, title, air_date, ROUND(duration_sec/60.0,1) AS minutes,
|
|
(SELECT COUNT(*) FROM qa_pairs q WHERE q.episode_id = e.id) AS qa_count,
|
|
(SELECT COUNT(*) FROM intros i WHERE i.episode_id = e.id) AS intro_count
|
|
FROM episodes e
|
|
"""
|
|
params: list = []
|
|
if year is not None:
|
|
sql += " WHERE year = ?"
|
|
params.append(year)
|
|
sql += " ORDER BY COALESCE(air_date, '9999') ASC, title ASC LIMIT ?"
|
|
params.append(limit)
|
|
rows = db.execute(sql, params).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@app.get("/api/episodes/{episode_id}")
|
|
def episode_detail(episode_id: int):
|
|
db: sqlite3.Connection = app.state.db
|
|
ep = db.execute("SELECT * FROM episodes WHERE id = ?", (episode_id,)).fetchone()
|
|
if not ep:
|
|
raise HTTPException(404, "episode not found")
|
|
intros = db.execute(
|
|
"SELECT name, role_hint, intro_time_sec, affiliation, fillin_for, source_text "
|
|
"FROM intros WHERE episode_id = ? ORDER BY intro_time_sec",
|
|
(episode_id,),
|
|
).fetchall()
|
|
qa = db.execute(
|
|
"SELECT id, question_start_sec, question_end_sec, "
|
|
"answer_start_sec, answer_end_sec, "
|
|
"question_text, answer_text, caller_name, caller_role, topic, topic_tags, "
|
|
"usefulness_score, topic_class, is_banter "
|
|
"FROM qa_pairs WHERE episode_id = ? ORDER BY question_start_sec",
|
|
(episode_id,),
|
|
).fetchall()
|
|
return {
|
|
"episode": dict(ep),
|
|
"intros": [dict(r) for r in intros],
|
|
"qa_pairs": [
|
|
{**dict(r), "topic_tags": json.loads(r["topic_tags"] or "[]")} for r in qa
|
|
],
|
|
}
|
|
|
|
|
|
@app.get("/api/episodes/{episode_id}/transcript")
|
|
def episode_transcript(episode_id: int):
|
|
db: sqlite3.Connection = app.state.db
|
|
ep = db.execute("SELECT id, title, year FROM episodes WHERE id = ?", (episode_id,)).fetchone()
|
|
if not ep:
|
|
raise HTTPException(404, "episode not found")
|
|
segments = db.execute(
|
|
"SELECT seg_idx, start_sec, end_sec, text FROM segments "
|
|
"WHERE episode_id = ? ORDER BY seg_idx",
|
|
(episode_id,),
|
|
).fetchall()
|
|
turns = db.execute(
|
|
"SELECT speaker, start_sec, end_sec, confidence FROM turns "
|
|
"WHERE episode_id = ? ORDER BY start_sec",
|
|
(episode_id,),
|
|
).fetchall()
|
|
return {
|
|
"episode": dict(ep),
|
|
"segments": [dict(r) for r in segments],
|
|
"turns": [dict(r) for r in turns],
|
|
}
|
|
|
|
|
|
@app.get("/api/search")
|
|
def search(
|
|
q: str = Query(..., min_length=2),
|
|
kind: str = Query("both", pattern="^(both|segments|qa)$"),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
min_score: int = Query(0, ge=0, le=5,
|
|
description="Minimum usefulness_score for Q&A hits (0=no filter)"),
|
|
exclude_banter: bool = Query(False,
|
|
description="Drop Q&A rows where is_banter=1"),
|
|
):
|
|
db: sqlite3.Connection = app.state.db
|
|
fts_q = fts_escape(q)
|
|
if not fts_q:
|
|
return {"q": q, "segments": [], "qa": []}
|
|
|
|
seg_results = []
|
|
qa_results = []
|
|
|
|
if kind in ("both", "segments"):
|
|
seg_results = [
|
|
dict(r) for r in db.execute(
|
|
"""
|
|
SELECT e.id AS episode_id, e.year, e.title, e.air_date,
|
|
s.start_sec, s.end_sec,
|
|
snippet(segments_fts, 0, '<mark>', '</mark>', '...', 16) AS snippet,
|
|
bm25(segments_fts) AS rank
|
|
FROM segments_fts
|
|
JOIN segments s ON s.id = segments_fts.rowid
|
|
JOIN episodes e ON e.id = s.episode_id
|
|
WHERE segments_fts MATCH ?
|
|
ORDER BY rank LIMIT ?
|
|
""",
|
|
(fts_q, limit),
|
|
).fetchall()
|
|
]
|
|
|
|
if kind in ("both", "qa"):
|
|
# NULL is treated as "unscored, include" so unprocessed rows still
|
|
# appear and old saved URLs keep working as the classifier rolls out.
|
|
# Filters are applied as additional WHERE clauses on top of the FTS
|
|
# MATCH; SQLite's planner can use idx_qa_usefulness once it's helpful.
|
|
qa_clauses = ["qa_fts MATCH :q"]
|
|
qa_params: dict[str, object] = {"q": fts_q, "limit": limit}
|
|
if min_score > 0:
|
|
qa_clauses.append(
|
|
"(p.usefulness_score IS NULL OR p.usefulness_score >= :min_score)"
|
|
)
|
|
qa_params["min_score"] = min_score
|
|
if exclude_banter:
|
|
qa_clauses.append("(p.is_banter IS NULL OR p.is_banter = 0)")
|
|
|
|
qa_sql = f"""
|
|
SELECT e.id AS episode_id, e.year, e.title, e.air_date,
|
|
p.id AS qa_id, p.caller_name,
|
|
p.question_start_sec, p.answer_start_sec,
|
|
p.usefulness_score, p.topic_class, p.is_banter,
|
|
p.question_text AS _question_text,
|
|
p.answer_text AS _answer_text,
|
|
snippet(qa_fts, 0, '<mark>', '</mark>', '...', 16) AS q_snippet,
|
|
snippet(qa_fts, 1, '<mark>', '</mark>', '...', 16) AS a_snippet,
|
|
bm25(qa_fts) AS rank
|
|
FROM qa_fts
|
|
JOIN qa_pairs p ON p.id = qa_fts.rowid
|
|
JOIN episodes e ON e.id = p.episode_id
|
|
WHERE {' AND '.join(qa_clauses)}
|
|
ORDER BY rank LIMIT :limit
|
|
"""
|
|
qa_results = [
|
|
_qa_search_excerpts(dict(r))
|
|
for r in db.execute(qa_sql, qa_params).fetchall()
|
|
]
|
|
|
|
return {"q": q, "segments": seg_results, "qa": qa_results}
|
|
|
|
|
|
# Sort key whitelist so we can pass user input straight into ORDER BY.
|
|
_QA_SORT_ORDERS: dict[str, str] = {
|
|
"air_date_desc": "COALESCE(e.air_date, '0000') DESC, p.question_start_sec ASC",
|
|
"air_date_asc": "COALESCE(e.air_date, '9999') ASC, p.question_start_sec ASC",
|
|
"score_desc": "COALESCE(p.usefulness_score, 0) DESC, "
|
|
"COALESCE(e.air_date, '0000') DESC, p.question_start_sec ASC",
|
|
}
|
|
|
|
|
|
@app.get("/api/qa")
|
|
def list_qa(
|
|
year: int | None = None,
|
|
min_score: int = Query(0, ge=0, le=5),
|
|
exclude_banter: bool = Query(False),
|
|
topic_class: str | None = None,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
order: str = Query("air_date_desc"),
|
|
):
|
|
"""Browseable Q&A list — same column shape as /api/search Q&A hits."""
|
|
db: sqlite3.Connection = app.state.db
|
|
if order not in _QA_SORT_ORDERS:
|
|
raise HTTPException(400, f"unknown order: {order}")
|
|
order_sql = _QA_SORT_ORDERS[order]
|
|
|
|
where = ["1=1"]
|
|
params: dict[str, object] = {}
|
|
if year is not None:
|
|
where.append("e.year = :year")
|
|
params["year"] = year
|
|
if min_score > 0:
|
|
where.append("(p.usefulness_score IS NULL OR p.usefulness_score >= :min_score)")
|
|
params["min_score"] = min_score
|
|
if exclude_banter:
|
|
where.append("(p.is_banter IS NULL OR p.is_banter = 0)")
|
|
if topic_class:
|
|
where.append("p.topic_class = :topic_class")
|
|
params["topic_class"] = topic_class
|
|
|
|
where_sql = " AND ".join(where)
|
|
|
|
total = db.execute(
|
|
f"""SELECT COUNT(*) FROM qa_pairs p
|
|
JOIN episodes e ON e.id = p.episode_id
|
|
WHERE {where_sql}""",
|
|
params,
|
|
).fetchone()[0]
|
|
|
|
params_pl = dict(params, limit=limit, offset=offset)
|
|
rows = db.execute(
|
|
f"""SELECT e.id AS episode_id, e.year, e.title, e.air_date,
|
|
p.id AS qa_id, p.caller_name,
|
|
p.question_start_sec, p.answer_start_sec,
|
|
p.usefulness_score, p.topic_class, p.is_banter,
|
|
p.question_text AS _question_text,
|
|
p.answer_text AS _answer_text
|
|
FROM qa_pairs p
|
|
JOIN episodes e ON e.id = p.episode_id
|
|
WHERE {where_sql}
|
|
ORDER BY {order_sql}
|
|
LIMIT :limit OFFSET :offset""",
|
|
params_pl,
|
|
).fetchall()
|
|
|
|
items = [_qa_search_excerpts(dict(r)) for r in rows]
|
|
return {"total": total, "items": items}
|
|
|
|
|
|
# --- Audio streaming with HTTP Range support ----------------------------
|
|
|
|
_AUDIO_CHUNK = 64 * 1024
|
|
|
|
|
|
def _resolve_audio_path(rel_path: str) -> Path | None:
|
|
"""Return the absolute Path to the MP3 if it exists, else None.
|
|
|
|
rel_path is the value stored in episodes.rel_path (e.g.
|
|
"2010/10 - October/10-02-10 HR 1.mp3"). We refuse anything that escapes
|
|
the episodes root via .. so a malicious DB row cannot read arbitrary
|
|
files.
|
|
"""
|
|
if not rel_path:
|
|
return None
|
|
base = Path(EPISODES_DIR).resolve()
|
|
candidate = (base / rel_path).resolve()
|
|
try:
|
|
candidate.relative_to(base)
|
|
except ValueError:
|
|
return None
|
|
if not candidate.is_file():
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def _parse_range(header: str, file_size: int) -> tuple[int, int] | None:
|
|
"""Parse a single-range "bytes=START-END" header. Returns None if invalid."""
|
|
if not header or not header.startswith("bytes="):
|
|
return None
|
|
spec = header[len("bytes="):].strip()
|
|
if "," in spec:
|
|
# Multi-range — fall back to no-range (full file) for simplicity
|
|
return None
|
|
if "-" not in spec:
|
|
return None
|
|
start_s, end_s = spec.split("-", 1)
|
|
try:
|
|
if start_s == "":
|
|
# suffix range: "-N" -> last N bytes
|
|
length = int(end_s)
|
|
if length <= 0:
|
|
return None
|
|
start = max(0, file_size - length)
|
|
end = file_size - 1
|
|
else:
|
|
start = int(start_s)
|
|
end = int(end_s) if end_s else file_size - 1
|
|
except ValueError:
|
|
return None
|
|
if start < 0 or end < start or start >= file_size:
|
|
return None
|
|
end = min(end, file_size - 1)
|
|
return start, end
|
|
|
|
|
|
def _file_iter(path: Path, start: int, length: int,
|
|
chunk: int = _AUDIO_CHUNK) -> Iterator[bytes]:
|
|
remaining = length
|
|
with open(path, "rb") as f:
|
|
f.seek(start)
|
|
while remaining > 0:
|
|
data = f.read(min(chunk, remaining))
|
|
if not data:
|
|
break
|
|
remaining -= len(data)
|
|
yield data
|
|
|
|
|
|
@app.get("/api/audio/{episode_id}")
|
|
def stream_audio(episode_id: int, request: Request):
|
|
"""Stream the episode's MP3 with HTTP Range support.
|
|
|
|
Returns 404 if the episode doesn't exist or the file isn't on disk
|
|
(Jupiter currently has no episodes/ tree — that's a clean 404). The
|
|
audio element on the transcript page checks the response and hides
|
|
itself on 404.
|
|
"""
|
|
db: sqlite3.Connection = app.state.db
|
|
ep = db.execute("SELECT rel_path FROM episodes WHERE id = ?", (episode_id,)).fetchone()
|
|
if not ep:
|
|
raise HTTPException(404, "episode not found")
|
|
path = _resolve_audio_path(ep["rel_path"])
|
|
if path is None:
|
|
raise HTTPException(404, "audio file missing")
|
|
|
|
file_size = path.stat().st_size
|
|
range_header = request.headers.get("range") or request.headers.get("Range")
|
|
rng = _parse_range(range_header, file_size) if range_header else None
|
|
|
|
headers = {
|
|
"Accept-Ranges": "bytes",
|
|
"Cache-Control": "public, max-age=86400",
|
|
"Content-Type": "audio/mpeg",
|
|
}
|
|
|
|
if rng is None:
|
|
# Full content
|
|
headers["Content-Length"] = str(file_size)
|
|
return StreamingResponse(
|
|
_file_iter(path, 0, file_size),
|
|
status_code=200,
|
|
headers=headers,
|
|
media_type="audio/mpeg",
|
|
)
|
|
|
|
start, end = rng
|
|
length = end - start + 1
|
|
headers["Content-Length"] = str(length)
|
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
|
return StreamingResponse(
|
|
_file_iter(path, start, length),
|
|
status_code=206,
|
|
headers=headers,
|
|
media_type="audio/mpeg",
|
|
)
|
|
|
|
|
|
@app.get("/api/callers")
|
|
def top_callers(limit: int = 50):
|
|
db: sqlite3.Connection = app.state.db
|
|
rows = db.execute(
|
|
"SELECT caller_name, COUNT(*) AS pairs FROM qa_pairs "
|
|
"WHERE caller_name IS NOT NULL "
|
|
"GROUP BY caller_name ORDER BY pairs DESC LIMIT ?",
|
|
(limit,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@app.get("/api/stats")
|
|
def stats():
|
|
db: sqlite3.Connection = app.state.db
|
|
counts = {
|
|
t: db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
|
|
for t in ("episodes", "segments", "turns", "intros", "qa_pairs")
|
|
}
|
|
by_year = [
|
|
dict(r) for r in db.execute(
|
|
"SELECT year, COUNT(*) AS episodes, "
|
|
"ROUND(SUM(duration_sec)/3600.0, 1) AS hours "
|
|
"FROM episodes GROUP BY year ORDER BY year"
|
|
).fetchall()
|
|
]
|
|
return {"counts": counts, "by_year": by_year}
|
|
|
|
|
|
@app.get("/api/db.sqlite")
|
|
def download_db():
|
|
"""Stream the read-only archive.db for offline laptop sync.
|
|
|
|
Anyone who can reach /api/search can already read every transcript,
|
|
so exposing the underlying SQLite file adds no meaningful disclosure.
|
|
Sync side: curl -o archive.db <host>:<port>/api/db.sqlite
|
|
"""
|
|
if not Path(DB_PATH).exists():
|
|
raise HTTPException(404, "archive db not present")
|
|
return FileResponse(
|
|
DB_PATH,
|
|
media_type="application/vnd.sqlite3",
|
|
filename="archive.db",
|
|
)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index():
|
|
return INDEX_HTML
|
|
|
|
|
|
# --- Single-episode HTML transcript view --------------------------------
|
|
|
|
|
|
def _fmt_time(sec: float | None) -> str:
|
|
if sec is None:
|
|
return ""
|
|
s = int(sec)
|
|
return f"{s // 60}:{s % 60:02d}"
|
|
|
|
|
|
def _episode_html(episode_id: int) -> str:
|
|
db: sqlite3.Connection = app.state.db
|
|
ep = db.execute("SELECT * FROM episodes WHERE id = ?", (episode_id,)).fetchone()
|
|
if not ep:
|
|
raise HTTPException(404, "episode not found")
|
|
intros = db.execute(
|
|
"SELECT id, name, role_hint, intro_time_sec FROM intros "
|
|
"WHERE episode_id = ? ORDER BY intro_time_sec",
|
|
(episode_id,),
|
|
).fetchall()
|
|
qa = db.execute(
|
|
"SELECT id, question_start_sec, question_end_sec, answer_start_sec, "
|
|
" answer_end_sec, question_text, answer_text, caller_name, "
|
|
" caller_role, usefulness_score, topic_class, is_banter "
|
|
"FROM qa_pairs WHERE episode_id = ? ORDER BY question_start_sec",
|
|
(episode_id,),
|
|
).fetchall()
|
|
segments = db.execute(
|
|
"SELECT seg_idx, start_sec, end_sec, text FROM segments "
|
|
"WHERE episode_id = ? ORDER BY seg_idx",
|
|
(episode_id,),
|
|
).fetchall()
|
|
|
|
esc = _html.escape
|
|
title = esc(ep["title"] or f"Episode {episode_id}")
|
|
air = esc(ep["air_date"] or "")
|
|
year = ep["year"]
|
|
duration_min = round((ep["duration_sec"] or 0) / 60.0, 1)
|
|
rel_path = esc(ep["rel_path"] or "")
|
|
|
|
# Build qa lookup keyed by question_start so we can splice them into
|
|
# the segment stream chronologically.
|
|
qa_rows = [dict(r) for r in qa]
|
|
qa_starts = sorted(
|
|
((r["question_start_sec"] or 0.0), r) for r in qa_rows
|
|
)
|
|
|
|
# Right rail summary lists
|
|
intro_items = []
|
|
for r in intros:
|
|
t = _fmt_time(r["intro_time_sec"])
|
|
name = esc(r["name"] or "?")
|
|
role = esc(r["role_hint"] or "")
|
|
role_html = f' <span class="muted">({role})</span>' if role else ""
|
|
intro_items.append(
|
|
f'<li><a href="#intro-{r["id"]}" data-seek="{r["intro_time_sec"] or 0}">'
|
|
f'{t}</a> · {name}{role_html}</li>'
|
|
)
|
|
intros_html = "\n".join(intro_items) or '<li class="muted">none</li>'
|
|
|
|
qa_items = []
|
|
for r in qa_rows:
|
|
t = _fmt_time(r["question_start_sec"])
|
|
score = r["usefulness_score"]
|
|
badge = (
|
|
f'<span class="badge s{score}" title="usefulness {score}/5">{score}</span>'
|
|
if score is not None else ""
|
|
)
|
|
topic = esc(r["topic_class"] or "")
|
|
topic_html = f'<span class="topic">{topic}</span> ' if topic else ""
|
|
caller = esc(r["caller_name"] or "")
|
|
caller_html = f' · {caller}' if caller else ""
|
|
first_q = _excerpt(r["question_text"] or "")[:80]
|
|
teaser = esc(first_q)
|
|
qa_items.append(
|
|
f'<li><a href="#qa-{r["id"]}" data-seek="{r["question_start_sec"] or 0}">'
|
|
f'{t}</a> {badge}{topic_html}<span class="muted">{teaser}</span>{caller_html}</li>'
|
|
)
|
|
qa_summary_html = "\n".join(qa_items) or '<li class="muted">none</li>'
|
|
|
|
# Build the chronological transcript body. We walk segments and, before
|
|
# any segment whose start_sec >= a Q&A's question_start, we emit the
|
|
# Q&A block. (Q&A blocks contain the full question/answer text already,
|
|
# so segment text becomes context around them.)
|
|
body_parts: list[str] = []
|
|
qa_iter = iter(qa_starts)
|
|
next_qa: tuple[float, dict] | None = next(qa_iter, None)
|
|
|
|
# Intros also get inline anchors so the right-rail jump links work
|
|
intro_by_time = sorted(
|
|
((r["intro_time_sec"] or 0.0), r) for r in intros
|
|
)
|
|
intro_iter = iter(intro_by_time)
|
|
next_intro = next(intro_iter, None)
|
|
|
|
def _flush_inline_at(t_seg: float) -> None:
|
|
nonlocal next_intro, next_qa
|
|
while next_intro and next_intro[0] <= t_seg:
|
|
ir = next_intro[1]
|
|
tlbl = _fmt_time(ir["intro_time_sec"])
|
|
name = esc(ir["name"] or "?")
|
|
role = esc(ir["role_hint"] or "")
|
|
role_html = f' <span class="muted">({role})</span>' if role else ""
|
|
body_parts.append(
|
|
f'<div class="intro-marker" id="intro-{ir["id"]}">'
|
|
f'<a class="ts" href="#" data-seek="{ir["intro_time_sec"] or 0}">'
|
|
f'{tlbl}</a> intro: <b>{name}</b>{role_html}'
|
|
f'</div>'
|
|
)
|
|
next_intro = next(intro_iter, None)
|
|
while next_qa and next_qa[0] <= t_seg:
|
|
qr = next_qa[1]
|
|
qstart = qr["question_start_sec"] or 0.0
|
|
astart = qr["answer_start_sec"] or qstart
|
|
score = qr["usefulness_score"]
|
|
badge = (
|
|
f'<span class="badge s{score}" title="usefulness {score}/5">{score}</span>'
|
|
if score is not None else ""
|
|
)
|
|
topic = esc(qr["topic_class"] or "")
|
|
topic_html = f'<span class="topic">{topic}</span> ' if topic else ""
|
|
caller = esc(qr["caller_name"] or "")
|
|
caller_html = f' · <i>{caller}</i>' if caller else ""
|
|
qbody = esc(qr["question_text"] or "")
|
|
abody = esc(qr["answer_text"] or "")
|
|
dim = " dim" if (score is not None and score <= 2) or qr["is_banter"] == 1 else ""
|
|
body_parts.append(
|
|
f'<div class="qa{dim}" id="qa-{qr["id"]}">'
|
|
f'<div class="qa-head">{badge}{topic_html}'
|
|
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'</div>'
|
|
f'<div class="qa-q"><b>Q:</b> {qbody}</div>'
|
|
f'<div class="qa-a">'
|
|
f'<a class="ts inline" href="#" data-seek="{astart}">{_fmt_time(astart)}</a>'
|
|
f' <b>A:</b> {abody}</div>'
|
|
f'</div>'
|
|
)
|
|
next_qa = next(qa_iter, None)
|
|
|
|
for s in segments:
|
|
t_seg = s["start_sec"] or 0.0
|
|
_flush_inline_at(t_seg)
|
|
seg_text = esc(s["text"] or "").strip()
|
|
if not seg_text:
|
|
continue
|
|
body_parts.append(
|
|
f'<p class="seg">'
|
|
f'<a class="ts" href="#" data-seek="{t_seg}">{_fmt_time(t_seg)}</a> '
|
|
f'{seg_text}</p>'
|
|
)
|
|
# Flush any tail intros / Q&As after final segment
|
|
_flush_inline_at(float("inf"))
|
|
|
|
body_html = "\n".join(body_parts) or '<p class="muted">no transcript</p>'
|
|
|
|
qa_count = len(qa_rows)
|
|
intro_count = len(intros)
|
|
|
|
return EPISODE_HTML.format(
|
|
title=title,
|
|
episode_id=episode_id,
|
|
year=year,
|
|
air=air,
|
|
duration_min=duration_min,
|
|
rel_path=rel_path,
|
|
qa_count=qa_count,
|
|
intro_count=intro_count,
|
|
intros_summary=intros_html,
|
|
qa_summary=qa_summary_html,
|
|
body=body_html,
|
|
)
|
|
|
|
|
|
@app.get("/episode/{episode_id}", response_class=HTMLResponse)
|
|
def episode_page(episode_id: int):
|
|
return _episode_html(episode_id)
|
|
|
|
|
|
INDEX_HTML = """<!doctype html>
|
|
<html lang=en>
|
|
<meta charset=utf-8>
|
|
<title>Computer Guru Radio Archive</title>
|
|
<style>
|
|
body { font: 14px/1.45 ui-sans-serif, system-ui; max-width: 920px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
h1 { margin: 0 0 .25em; }
|
|
.sub { color:#666; margin-bottom: 1.5em; }
|
|
input[type=search] { width: 100%; padding: .6em .8em; font-size: 16px; box-sizing: border-box; }
|
|
input[type=search]:disabled { background:#f4f4f4; color:#999; }
|
|
.controls { display:flex; gap:.5em; align-items:center; margin: .5em 0 1em; flex-wrap: wrap; }
|
|
.controls label { font-size: 13px; color:#555; }
|
|
.group { border-bottom: 1px solid #eee; padding: 1em 0; }
|
|
.group h3 { margin: 0 0 .25em; font-size: 13px; color:#666; text-transform: uppercase; letter-spacing: .04em; }
|
|
.hit { padding: .5em 0; }
|
|
.hit .meta { font-size: 12px; color: #888; }
|
|
.hit a { color: #06c; text-decoration: none; }
|
|
.hit a:hover { text-decoration: underline; }
|
|
a.hit-link { display:block; color:inherit; text-decoration:none; padding: .5em .35em; margin: 0 -.35em; border-radius: 4px; }
|
|
a.hit-link:hover { background: #f6f8fa; }
|
|
a.hit-link .meta a { color:#06c; }
|
|
mark { background: #ffec99; padding: 0 .15em; }
|
|
.stats { font-size: 12px; color:#666; margin-top: 2em; }
|
|
.empty { color:#999; padding: 1em 0; }
|
|
.controls select, .controls input[type=checkbox] { margin-right: .35em; }
|
|
.badge { display: inline-block; min-width: 1.6em; padding: 0 .35em; margin-right: .35em;
|
|
font-size: 11px; font-weight: 600; text-align: center; border-radius: 3px;
|
|
color: #fff; background: #999; vertical-align: 1px; }
|
|
.badge.s5 { background: #2a8f43; }
|
|
.badge.s4 { background: #5aa54b; }
|
|
.badge.s3 { background: #999; }
|
|
.badge.s2 { background: #c08a3a; }
|
|
.badge.s1 { background: #b85a4a; }
|
|
.topic { font-size: 11px; color: #888; padding: 0 .35em; border-radius: 3px;
|
|
background: #f0f0f0; }
|
|
.hit.dim, a.hit-link.dim { opacity: .55; }
|
|
button.more { margin: 1em 0; padding: .5em 1em; font-size: 13px; cursor: pointer;
|
|
background: #f4f4f4; border: 1px solid #ccc; border-radius: 3px; }
|
|
button.more:hover { background: #ececec; }
|
|
button.more:disabled { color: #999; cursor: default; }
|
|
.browse-bar { background:#f8f8f4; border:1px solid #ece8d8; padding:.4em .6em;
|
|
border-radius: 4px; margin: 0 0 1em; font-size: 13px; color:#665; }
|
|
</style>
|
|
<h1>Computer Guru Radio Archive</h1>
|
|
<div class=sub id=sub>...</div>
|
|
<input type=search id=q autofocus placeholder="search transcripts and Q&A — e.g. wireless, virus, BIOS">
|
|
<div class=controls>
|
|
<label><input type=radio name=kind value=both checked> both</label>
|
|
<label><input type=radio name=kind value=qa> Q&A only</label>
|
|
<label><input type=radio name=kind value=segments> transcript only</label>
|
|
<span style="border-left:1px solid #ddd; padding-left:.6em">
|
|
<label>min score
|
|
<select id=min_score>
|
|
<option value=0>any</option>
|
|
<option value=2>2+</option>
|
|
<option value=3>3+</option>
|
|
<option value=4>4+</option>
|
|
<option value=5>5</option>
|
|
</select>
|
|
</label>
|
|
<label><input type=checkbox id=exclude_banter> hide banter</label>
|
|
</span>
|
|
<span style="border-left:1px solid #ddd; padding-left:.6em">
|
|
<label><input type=checkbox id=browse_all> Browse all Q&A</label>
|
|
</span>
|
|
</div>
|
|
<div class=controls id=browse_controls style="display:none">
|
|
<label>year
|
|
<select id=browse_year><option value="">any</option></select>
|
|
</label>
|
|
<label>sort
|
|
<select id=browse_order>
|
|
<option value=air_date_desc>air date (newest first)</option>
|
|
<option value=air_date_asc>air date (oldest first)</option>
|
|
<option value=score_desc>usefulness score</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div id=results></div>
|
|
<div class=stats id=stats></div>
|
|
<script>
|
|
const q = document.getElementById('q');
|
|
const results = document.getElementById('results');
|
|
const sub = document.getElementById('sub');
|
|
const browseToggle = document.getElementById('browse_all');
|
|
const browseControls = document.getElementById('browse_controls');
|
|
const browseYear = document.getElementById('browse_year');
|
|
const browseOrder = document.getElementById('browse_order');
|
|
const minScoreEl = document.getElementById('min_score');
|
|
const excludeBanterEl = document.getElementById('exclude_banter');
|
|
|
|
let browseOffset = 0;
|
|
const BROWSE_LIMIT = 50;
|
|
let browseTotal = 0;
|
|
|
|
fetch('/api/stats').then(r => r.json()).then(s => {
|
|
const c = s.counts;
|
|
sub.textContent = `${c.episodes} episodes / ${c.qa_pairs} Q&A pairs / ${c.intros} intros / ${c.segments.toLocaleString()} segments`;
|
|
const yrs = (s.by_year || []).map(x => x.year).sort((a,b)=>b-a);
|
|
for (const y of yrs) {
|
|
const o = document.createElement('option');
|
|
o.value = y; o.textContent = y;
|
|
browseYear.appendChild(o);
|
|
}
|
|
});
|
|
|
|
let timer;
|
|
q.addEventListener('input', () => {
|
|
if (browseToggle.checked) return;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(runSearch, 250);
|
|
});
|
|
document.querySelectorAll('input[name=kind]').forEach(el => el.addEventListener('change', () => { if (!browseToggle.checked) runSearch(); }));
|
|
minScoreEl.addEventListener('change', refresh);
|
|
excludeBanterEl.addEventListener('change', refresh);
|
|
browseToggle.addEventListener('change', () => {
|
|
const on = browseToggle.checked;
|
|
browseControls.style.display = on ? 'flex' : 'none';
|
|
q.disabled = on;
|
|
if (on) {
|
|
if (minScoreEl.value === '0') minScoreEl.value = '3';
|
|
runBrowse(true);
|
|
} else {
|
|
results.innerHTML = '';
|
|
if (q.value.trim().length >= 2) runSearch();
|
|
}
|
|
});
|
|
browseYear.addEventListener('change', () => runBrowse(true));
|
|
browseOrder.addEventListener('change', () => runBrowse(true));
|
|
|
|
function refresh() {
|
|
if (browseToggle.checked) runBrowse(true);
|
|
else if (q.value.trim().length >= 2) runSearch();
|
|
}
|
|
|
|
function fmtTime(s) {
|
|
if (s == null) return '';
|
|
const m = Math.floor(s/60), sec = Math.floor(s%60);
|
|
return `${m}:${sec.toString().padStart(2,'0')}`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return (s ?? '').replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
|
|
function qaHitHtml(h, opts) {
|
|
const ad = h.air_date ? ` (${h.air_date})` : '';
|
|
const cn = h.caller_name ? ` — ${escapeHtml(h.caller_name)}` : '';
|
|
const score = h.usefulness_score;
|
|
const topic = h.topic_class;
|
|
const banter = h.is_banter === 1;
|
|
const badge = score != null
|
|
? `<span class="badge s${score}" title="usefulness ${score}/5">${score}</span>`
|
|
: '';
|
|
const topicTag = topic
|
|
? `<span class=topic>${escapeHtml(topic)}</span> `
|
|
: '';
|
|
const dim = (score != null && score <= 2) || banter ? ' dim' : '';
|
|
// Prefer plain-text excerpt, fall back to FTS snippet (which may include <mark>).
|
|
const qBody = h.question_excerpt
|
|
? escapeHtml(h.question_excerpt)
|
|
: (h.q_snippet || '');
|
|
const aBody = h.answer_excerpt
|
|
? escapeHtml(h.answer_excerpt)
|
|
: (h.a_snippet || '');
|
|
const href = `/episode/${h.episode_id}#qa-${h.qa_id}`;
|
|
return `<a class="hit-link${dim}" href="${href}">
|
|
<div class=meta>${badge}${topicTag}${h.year} · ${escapeHtml(h.title)}${ad}${cn} · @ ${fmtTime(h.question_start_sec)}</div>
|
|
<div><b>Q:</b> ${qBody}</div>
|
|
<div><b>A:</b> ${aBody}</div>
|
|
</a>`;
|
|
}
|
|
|
|
async function runSearch() {
|
|
const term = q.value.trim();
|
|
if (term.length < 2) { results.innerHTML = ''; return; }
|
|
const kind = document.querySelector('input[name=kind]:checked').value;
|
|
const minScore = minScoreEl.value;
|
|
const excludeBanter = excludeBanterEl.checked;
|
|
const params = new URLSearchParams({ q: term, kind, limit: '40' });
|
|
if (minScore !== '0') params.set('min_score', minScore);
|
|
if (excludeBanter) params.set('exclude_banter', 'true');
|
|
const r = await fetch(`/api/search?${params}`);
|
|
const j = await r.json();
|
|
let html = '';
|
|
if (j.qa.length) {
|
|
html += '<div class=group><h3>Q&A Pairs</h3>';
|
|
for (const h of j.qa) html += qaHitHtml(h);
|
|
html += '</div>';
|
|
}
|
|
if (j.segments.length) {
|
|
html += '<div class=group><h3>Transcript Segments</h3>';
|
|
for (const h of j.segments) {
|
|
const ad = h.air_date ? ` (${h.air_date})` : '';
|
|
html += `<div class=hit>
|
|
<div class=meta>${h.year} · ${escapeHtml(h.title)}${ad} · @ ${fmtTime(h.start_sec)}</div>
|
|
<div>${h.snippet}</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
if (!j.qa.length && !j.segments.length) {
|
|
html = '<div class=empty>no hits</div>';
|
|
}
|
|
results.innerHTML = html;
|
|
}
|
|
|
|
async function runBrowse(reset) {
|
|
if (reset) browseOffset = 0;
|
|
const params = new URLSearchParams({
|
|
limit: String(BROWSE_LIMIT),
|
|
offset: String(browseOffset),
|
|
order: browseOrder.value,
|
|
});
|
|
if (browseYear.value) params.set('year', browseYear.value);
|
|
const ms = minScoreEl.value;
|
|
if (ms !== '0') params.set('min_score', ms);
|
|
if (excludeBanterEl.checked) params.set('exclude_banter', 'true');
|
|
|
|
const r = await fetch(`/api/qa?${params}`);
|
|
const j = await r.json();
|
|
browseTotal = j.total;
|
|
|
|
const newRows = (j.items || []).map(h => qaHitHtml(h)).join('');
|
|
const header = `<div class=browse-bar>Showing ${Math.min(browseOffset + (j.items || []).length, browseTotal)} of ${browseTotal} Q&A pairs</div>`;
|
|
const moreEnabled = browseOffset + (j.items || []).length < browseTotal;
|
|
|
|
if (reset) {
|
|
results.innerHTML = `${header}<div class=group id=qa_browse><h3>Q&A Pairs</h3>${newRows}</div>` +
|
|
(moreEnabled ? '<button class=more id=load_more>load more</button>' : '');
|
|
} else {
|
|
const list = document.getElementById('qa_browse');
|
|
list.insertAdjacentHTML('beforeend', newRows);
|
|
// refresh the count bar
|
|
const bar = results.querySelector('.browse-bar');
|
|
if (bar) bar.outerHTML = header;
|
|
const btn = document.getElementById('load_more');
|
|
if (btn && !moreEnabled) btn.remove();
|
|
}
|
|
browseOffset += (j.items || []).length;
|
|
const btn = document.getElementById('load_more');
|
|
if (btn) btn.onclick = () => runBrowse(false);
|
|
if ((j.items || []).length === 0 && reset) {
|
|
results.innerHTML = '<div class=empty>no Q&A pairs match these filters</div>';
|
|
}
|
|
}
|
|
</script>
|
|
</html>
|
|
"""
|
|
|
|
|
|
# Single-episode transcript view.
|
|
EPISODE_HTML = """<!doctype html>
|
|
<html lang=en>
|
|
<meta charset=utf-8>
|
|
<title>{title} · Computer Guru Radio Archive</title>
|
|
<style>
|
|
body {{ font: 14px/1.5 ui-sans-serif, system-ui; max-width: 1080px; margin: 1.5em auto;
|
|
padding: 0 1em; color: #222; }}
|
|
h1 {{ margin: 0; font-size: 20px; }}
|
|
.topbar {{ display: flex; gap: 1em; align-items: baseline; flex-wrap: wrap;
|
|
border-bottom: 1px solid #eee; padding-bottom: .75em; margin-bottom: 1em; }}
|
|
.topbar .meta {{ color: #666; font-size: 12px; }}
|
|
.topbar a {{ color: #06c; text-decoration: none; }}
|
|
.topbar a:hover {{ text-decoration: underline; }}
|
|
audio {{ width: 100%; margin: .5em 0 1em; }}
|
|
.layout {{ display: grid; grid-template-columns: 1fr 280px; gap: 2em; }}
|
|
@media (max-width: 760px) {{ .layout {{ grid-template-columns: 1fr; }} }}
|
|
.body p.seg {{ margin: .25em 0; }}
|
|
.ts {{ display: inline-block; min-width: 3.5em; color: #06c; text-decoration: none;
|
|
font-variant-numeric: tabular-nums; font-size: 12px; }}
|
|
.ts:hover {{ text-decoration: underline; }}
|
|
.ts.inline {{ min-width: 0; margin-right: .25em; }}
|
|
.qa {{ background: #fbf9f1; border-left: 3px solid #d8c97a;
|
|
padding: .5em .75em; margin: 1em 0; border-radius: 3px; }}
|
|
.qa.dim {{ opacity: .55; }}
|
|
.qa-head {{ font-size: 12px; color: #555; margin-bottom: .35em; }}
|
|
.qa-q {{ margin: .35em 0; }}
|
|
.qa-a {{ margin: .35em 0; }}
|
|
.intro-marker {{ border-left: 3px solid #b9d2ec; background: #f6f9fc;
|
|
padding: .35em .6em; margin: .75em 0; font-size: 13px; }}
|
|
.badge {{ display: inline-block; min-width: 1.6em; padding: 0 .35em; margin-right: .35em;
|
|
font-size: 11px; font-weight: 600; text-align: center; border-radius: 3px;
|
|
color: #fff; background: #999; vertical-align: 1px; }}
|
|
.badge.s5 {{ background: #2a8f43; }}
|
|
.badge.s4 {{ background: #5aa54b; }}
|
|
.badge.s3 {{ background: #999; }}
|
|
.badge.s2 {{ background: #c08a3a; }}
|
|
.badge.s1 {{ background: #b85a4a; }}
|
|
.topic {{ font-size: 11px; color: #888; padding: 0 .35em; border-radius: 3px;
|
|
background: #f0f0f0; }}
|
|
.muted {{ color: #999; }}
|
|
button.play {{ font-size: 11px; margin-left: .5em; padding: 1px 6px;
|
|
border: 1px solid #ccc; border-radius: 3px; background: #fff;
|
|
color: #06c; cursor: pointer; }}
|
|
button.play:hover {{ background: #f6f8fa; }}
|
|
aside h3 {{ font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
|
color: #666; margin: 1em 0 .35em; }}
|
|
aside ul {{ list-style: none; margin: 0; padding: 0; font-size: 12px; }}
|
|
aside li {{ padding: .15em 0; line-height: 1.35; }}
|
|
aside a {{ color: #06c; text-decoration: none; }}
|
|
aside a:hover {{ text-decoration: underline; }}
|
|
.audio-missing {{ background: #fff7e6; border: 1px solid #f0d9a8; padding: .5em .75em;
|
|
border-radius: 3px; font-size: 13px; color: #8a6f2a; }}
|
|
</style>
|
|
<div class=topbar>
|
|
<div>
|
|
<h1>{title}</h1>
|
|
<div class=meta>{year} · {air} · {duration_min} min · {qa_count} Q&A · {intro_count} intros · <a href="/">« back to search</a></div>
|
|
<div class=meta><code>{rel_path}</code></div>
|
|
</div>
|
|
</div>
|
|
<audio id=player controls preload=none src="/api/audio/{episode_id}"></audio>
|
|
<div id=audio_missing class=audio-missing style="display:none">
|
|
Audio file is not available on this server.
|
|
</div>
|
|
<div class=layout>
|
|
<div class=body>
|
|
{body}
|
|
</div>
|
|
<aside>
|
|
<h3>Q&A pairs ({qa_count})</h3>
|
|
<ul>{qa_summary}</ul>
|
|
<h3>Intros ({intro_count})</h3>
|
|
<ul>{intros_summary}</ul>
|
|
</aside>
|
|
</div>
|
|
<script>
|
|
(function() {{
|
|
const player = document.getElementById('player');
|
|
const missing = document.getElementById('audio_missing');
|
|
|
|
// If the audio element fails to load, hide it and show a notice.
|
|
player.addEventListener('error', () => {{
|
|
player.style.display = 'none';
|
|
missing.style.display = '';
|
|
}});
|
|
|
|
function seek(t) {{
|
|
const sec = parseFloat(t);
|
|
if (isNaN(sec)) return;
|
|
if (player.style.display === 'none') return;
|
|
try {{
|
|
player.currentTime = sec;
|
|
player.play().catch(() => {{}});
|
|
}} catch (e) {{}}
|
|
}}
|
|
|
|
// Click handler for any element with a data-seek attribute.
|
|
document.body.addEventListener('click', (ev) => {{
|
|
const el = ev.target.closest('[data-seek]');
|
|
if (!el) return;
|
|
// Only intercept if it's a # anchor or button/click — let normal navigation work otherwise.
|
|
const tag = el.tagName.toLowerCase();
|
|
if (tag === 'a' && el.getAttribute('href') && !el.getAttribute('href').startsWith('#')) return;
|
|
ev.preventDefault();
|
|
const t = el.getAttribute('data-seek');
|
|
seek(t);
|
|
const href = el.getAttribute('href') || '';
|
|
if (tag === 'a' && (href.startsWith('#qa-') || href.startsWith('#intro-'))) {{
|
|
// also scroll the anchor into view
|
|
const target = document.getElementById(href.slice(1));
|
|
if (target) target.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
|
|
}}
|
|
}});
|
|
|
|
// On page load, if the URL contains #qa-<id>, seek the audio to that Q&A's start.
|
|
function handleHash() {{
|
|
const h = window.location.hash;
|
|
if (!h) return;
|
|
const target = document.getElementById(h.slice(1));
|
|
if (!target) return;
|
|
target.scrollIntoView({{ block: 'start' }});
|
|
// find nearest data-seek descendant for the start time
|
|
const seekEl = target.querySelector('[data-seek]');
|
|
if (seekEl) {{
|
|
const t = seekEl.getAttribute('data-seek');
|
|
// wait for metadata before seeking so currentTime sticks
|
|
if (player.readyState >= 1) seek(t);
|
|
else player.addEventListener('loadedmetadata', () => seek(t), {{ once: true }});
|
|
}}
|
|
}}
|
|
handleHash();
|
|
window.addEventListener('hashchange', handleHash);
|
|
}})();
|
|
</script>
|
|
</html>
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|