Frontend pass on the two embedded HTML templates in the FastAPI server. No backend / Python logic changed; only template strings, CSS, and inline JS. Index page: full CSS custom-property theme (light, #c39733 accent), responsive viewport meta, search input with embedded SVG magnifier and focus ring, control bar reorganised into divider-separated groups with the browse-mode toggle rendered via :has() selector, hit cards with hover-lift + arrow indicator and focus-visible outline, restyled Q/A badges and score/topic chips, animated loading dots. Episode page: sticky audio player and sticky aside (top: 130px, max-height calc'd against viewport). New active-Q&A highlight builds a sorted index of QA blocks at load time, computes each block's end as the next block's start (capped at +180s), and on timeupdate/pause toggles .active on both the body QA block and its aside list item; a "NOW PLAYING" pill is revealed on .qa.active. Intro-marker also gets .active. Audio preload bumped from none to metadata so #qa-<id> deep links can seek without a prior user gesture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1477 lines
56 KiB
Python
1477 lines
56 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">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Computer Guru Radio Archive</title>
|
|
<style>
|
|
:root {
|
|
--fg: #1a1a1a;
|
|
--fg-muted: #666;
|
|
--fg-subtle: #999;
|
|
--bg: #fefefe;
|
|
--bg-soft: #f6f5f1;
|
|
--bg-card-hover: #fbf8ec;
|
|
--border: #e7e5dd;
|
|
--border-soft: #efede5;
|
|
--link: #1853a0;
|
|
--link-hover: #0e3f7f;
|
|
--accent: #c39733;
|
|
--accent-soft: #faf1d4;
|
|
--accent-line: #e3c378;
|
|
--s5: #2a8f43; --s4: #5aa54b; --s3: #999; --s2: #c08a3a; --s1: #b85a4a;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; }
|
|
body { font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
max-width: 940px; margin: 1.5em auto; padding: 0 1em;
|
|
color: var(--fg); background: var(--bg); }
|
|
h1 { font-size: 22px; font-weight: 600; letter-spacing: -.01em; margin: 0 0 .15em; }
|
|
h3 { font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: .07em; color: var(--fg-muted); margin: 0 0 .5em; }
|
|
a { color: var(--link); text-decoration: none; }
|
|
a:hover { color: var(--link-hover); text-decoration: underline; }
|
|
|
|
.topline { display: flex; align-items: baseline; gap: .85em;
|
|
margin: 0 0 .9em; flex-wrap: wrap; }
|
|
.sub { font-size: 12.5px; color: var(--fg-muted); }
|
|
|
|
.search-wrap { position: relative; margin-bottom: .35em; }
|
|
input[type=search] { width: 100%; padding: .65em 1em .65em 2.4em;
|
|
font-size: 16px; font-family: inherit; line-height: 1.4;
|
|
border: 1px solid var(--border); border-radius: 7px;
|
|
background: var(--bg) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>") .8em center/16px no-repeat;
|
|
color: var(--fg);
|
|
transition: border-color .15s, box-shadow .15s; }
|
|
input[type=search]:focus { outline: none; border-color: var(--link);
|
|
box-shadow: 0 0 0 3px rgba(24,83,160,.13); }
|
|
input[type=search]:disabled { background-color: var(--bg-soft);
|
|
color: var(--fg-subtle); cursor: not-allowed; }
|
|
|
|
.controls { display: flex; gap: .25em .55em; align-items: center;
|
|
margin: .5em 0 1em; flex-wrap: wrap; font-size: 13px;
|
|
color: var(--fg-muted); }
|
|
.controls .group { display: flex; gap: .55em; align-items: center;
|
|
padding: 0 .85em; border-left: 1px solid var(--border); }
|
|
.controls .group:first-child { padding-left: 0; border-left: none; }
|
|
.controls label { display: inline-flex; gap: .3em; align-items: center;
|
|
cursor: pointer; user-select: none; }
|
|
.controls select { font: inherit; padding: 1px 4px; border: 1px solid var(--border);
|
|
border-radius: 3px; background: var(--bg); color: var(--fg);
|
|
cursor: pointer; }
|
|
.controls input[type=checkbox], .controls input[type=radio] { accent-color: var(--link); }
|
|
|
|
.browse-toggle { padding: 4px 10px; border: 1px solid var(--border);
|
|
border-radius: 4px; background: var(--bg); cursor: pointer;
|
|
transition: background .12s, border-color .12s, color .12s; }
|
|
.browse-toggle:has(input:checked) { background: var(--accent-soft);
|
|
border-color: var(--accent-line);
|
|
color: #6f5320; font-weight: 500; }
|
|
.browse-toggle input { display: none; }
|
|
|
|
.group-hits { padding: .5em 0 .85em; border-top: 1px solid var(--border-soft); }
|
|
.group-hits:first-of-type { border-top: none; padding-top: .25em; }
|
|
|
|
a.hit-link { display: block; padding: .65em .8em; margin: .3em -.8em;
|
|
border: 1px solid transparent; border-radius: 7px;
|
|
color: inherit; text-decoration: none;
|
|
transition: background .12s, border-color .12s; }
|
|
a.hit-link:hover, a.hit-link:focus-visible {
|
|
background: var(--bg-card-hover); border-color: var(--accent-line);
|
|
text-decoration: none;
|
|
}
|
|
a.hit-link:focus-visible { outline: 2px solid var(--link); outline-offset: 1px; }
|
|
a.hit-link:hover .arrow, a.hit-link:focus-visible .arrow {
|
|
opacity: 1; transform: translateX(2px);
|
|
}
|
|
a.hit-link.dim { opacity: .55; }
|
|
a.hit-link.dim:hover { opacity: .85; }
|
|
|
|
.hit-head { display: flex; gap: .55em; align-items: baseline;
|
|
flex-wrap: wrap; margin-bottom: .35em;
|
|
font-size: 12px; color: var(--fg-muted); }
|
|
.hit-id { display: inline-flex; gap: .35em; align-items: center;
|
|
flex-wrap: wrap; }
|
|
.caller { color: var(--fg); font-weight: 600; }
|
|
.caller-none { font-style: italic; color: var(--fg-subtle); }
|
|
.hit-where { flex: 1 1 auto; min-width: 0; }
|
|
.ep-title { color: var(--fg); }
|
|
.ep-time { font-variant-numeric: tabular-nums; }
|
|
.arrow { color: var(--link); opacity: .25; padding-left: .25em;
|
|
font-size: 14px; transition: opacity .12s, transform .12s; }
|
|
|
|
.hit-q, .hit-a { margin: .15em 0; line-height: 1.5; color: var(--fg); }
|
|
.hit-q .label, .hit-a .label { display: inline-block; min-width: 1.4em;
|
|
padding: 0 .3em; margin-right: .25em;
|
|
font-size: 10px; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: .05em;
|
|
vertical-align: 1px; border-radius: 2px;
|
|
text-align: center; }
|
|
.hit-q .label { background: var(--accent); color: #fff; }
|
|
.hit-a .label { background: #ddd; color: #555; }
|
|
|
|
.seg-hit { padding: .35em 0; }
|
|
.seg-hit .meta { font-size: 12px; color: var(--fg-muted); }
|
|
|
|
.badge { display: inline-block; min-width: 1.4em; padding: 0 .35em;
|
|
font-size: 10px; font-weight: 700; text-align: center;
|
|
border-radius: 3px; color: #fff; background: var(--s3);
|
|
vertical-align: 1px; line-height: 1.5; }
|
|
.badge.s5 { background: var(--s5); }
|
|
.badge.s4 { background: var(--s4); }
|
|
.badge.s3 { background: var(--s3); }
|
|
.badge.s2 { background: var(--s2); }
|
|
.badge.s1 { background: var(--s1); }
|
|
|
|
.topic { font-size: 10px; color: var(--fg-muted); padding: 1px .4em;
|
|
border-radius: 3px; background: var(--bg-soft);
|
|
border: 1px solid var(--border-soft);
|
|
text-transform: lowercase; letter-spacing: .02em; }
|
|
|
|
mark { background: #fff2c0; padding: 0 .15em; border-radius: 2px; }
|
|
|
|
.browse-bar { display: flex; align-items: center; justify-content: space-between;
|
|
padding: .5em .85em; background: var(--accent-soft);
|
|
border: 1px solid var(--accent-line); border-radius: 6px;
|
|
margin: .5em 0 1em; font-size: 13px; color: #6f5320; }
|
|
|
|
button.more, button.clear {
|
|
padding: .45em 1em; font: inherit; font-size: 13px; cursor: pointer;
|
|
background: var(--bg-soft); border: 1px solid var(--border);
|
|
border-radius: 4px; color: var(--fg);
|
|
transition: background .12s, border-color .12s;
|
|
}
|
|
button.more:hover, button.clear:hover {
|
|
background: var(--bg-card-hover); border-color: var(--accent-line);
|
|
}
|
|
button.more { display: block; margin: 1em auto; min-width: 140px; }
|
|
button.more:disabled { color: var(--fg-subtle); cursor: default; }
|
|
|
|
.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;
|
|
display: inline-block; width: 1em; text-align: left; }
|
|
@keyframes dots { 0%,20% { content: '.'; } 40% { content: '..'; }
|
|
60%,100% { content: '...'; } }
|
|
|
|
.empty { padding: 2.5em 0; text-align: center; color: var(--fg-muted); }
|
|
.empty p { margin: .35em 0; }
|
|
.empty .suggest { font-size: 13px; color: var(--fg-subtle); }
|
|
.empty button.clear { margin-top: .65em; }
|
|
|
|
.stats { font-size: 11px; color: var(--fg-subtle); margin-top: 3em;
|
|
padding-top: 1em; border-top: 1px solid var(--border-soft); }
|
|
</style>
|
|
<header class="topline">
|
|
<h1>Computer Guru Radio Archive</h1>
|
|
<span class="sub" id="sub">loading...</span>
|
|
</header>
|
|
<div class="search-wrap">
|
|
<input type="search" id="q" autofocus
|
|
placeholder="search transcripts and Q&A — wireless, virus, BIOS...">
|
|
</div>
|
|
<div class="controls">
|
|
<span class="group">
|
|
<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>
|
|
<span class="group">
|
|
<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>topic
|
|
<select id="topic_class">
|
|
<option value="">any</option>
|
|
<option value="computer-help">computer-help</option>
|
|
<option value="banter">banter</option>
|
|
<option value="off-topic">off-topic</option>
|
|
<option value="promo">promo</option>
|
|
<option value="unclear">unclear</option>
|
|
</select>
|
|
</label>
|
|
<label><input type="checkbox" id="exclude_banter"> hide banter</label>
|
|
</span>
|
|
<span class="group">
|
|
<label class="browse-toggle">
|
|
<input type="checkbox" id="browse_all"> browse all Q&A
|
|
</label>
|
|
</span>
|
|
</div>
|
|
<div class="controls" id="browse_controls" style="display:none">
|
|
<span class="group">
|
|
<label>year
|
|
<select id="browse_year"><option value="">any</option></select>
|
|
</label>
|
|
<label>sort
|
|
<select id="browse_order">
|
|
<option value="air_date_desc">newest first</option>
|
|
<option value="air_date_asc">oldest first</option>
|
|
<option value="score_desc">usefulness score</option>
|
|
</select>
|
|
</label>
|
|
</span>
|
|
</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');
|
|
const topicEl = document.getElementById('topic_class');
|
|
|
|
let browseOffset = 0;
|
|
const BROWSE_LIMIT = 50;
|
|
let browseTotal = 0;
|
|
let restoring = false;
|
|
|
|
// === Stats + year list ===
|
|
fetch('/api/stats').then(r => r.json()).then(s => {
|
|
const c = s.counts;
|
|
sub.textContent = `${c.episodes} episodes · ${c.qa_pairs.toLocaleString()} Q&A pairs · ${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);
|
|
}
|
|
restoreFromUrl();
|
|
});
|
|
|
|
// === Format helpers ===
|
|
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]));
|
|
}
|
|
|
|
// === Hit rendering ===
|
|
function qaHitHtml(h) {
|
|
const score = h.usefulness_score;
|
|
const topic = h.topic_class;
|
|
const banter = h.is_banter === 1;
|
|
const dim = (score != null && score <= 2) || banter ? ' dim' : '';
|
|
|
|
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 callerHtml = h.caller_name
|
|
? `<span class="caller">${escapeHtml(h.caller_name)}</span>`
|
|
: `<span class="caller-none">unknown caller</span>`;
|
|
|
|
const epTitle = escapeHtml(h.title || '');
|
|
const epDate = h.air_date ? ` · ${escapeHtml(h.air_date)}` : '';
|
|
const time = fmtTime(h.question_start_sec);
|
|
|
|
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="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>
|
|
<span class="arrow">→</span>
|
|
</div>
|
|
<div class="hit-q"><span class="label">Q</span>${qBody}</div>
|
|
<div class="hit-a"><span class="label">A</span>${aBody}</div>
|
|
</a>`;
|
|
}
|
|
|
|
function segmentHitHtml(h) {
|
|
const ad = h.air_date ? ` · ${escapeHtml(h.air_date)}` : '';
|
|
return `<div class="seg-hit">
|
|
<div class="meta">${h.year} · ${escapeHtml(h.title)}${ad} · @ ${fmtTime(h.start_sec)}</div>
|
|
<div>${h.snippet}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// === Filter helpers ===
|
|
function filtersActive() {
|
|
return minScoreEl.value !== '0'
|
|
|| excludeBanterEl.checked
|
|
|| topicEl.value !== ''
|
|
|| (browseToggle.checked && browseYear.value !== '');
|
|
}
|
|
function clearFilters() {
|
|
minScoreEl.value = browseToggle.checked ? '3' : '0';
|
|
excludeBanterEl.checked = false;
|
|
topicEl.value = '';
|
|
if (browseToggle.checked) browseYear.value = '';
|
|
refresh();
|
|
}
|
|
function emptyHtml(headline) {
|
|
const fActive = filtersActive();
|
|
const tip = fActive
|
|
? `<p class="suggest">Filters are active.</p>
|
|
<button class="clear" id="clear_filters">clear filters</button>`
|
|
: (browseToggle.checked
|
|
? `<p class="suggest">No Q&A pairs match. Try a different year or sort.</p>`
|
|
: `<p class="suggest">Try a different term, or browse all.</p>
|
|
<button class="clear" id="open_browse">browse all Q&A</button>`);
|
|
return `<div class="empty"><p>${headline}</p>${tip}</div>`;
|
|
}
|
|
function wireEmptyButtons() {
|
|
const cf = document.getElementById('clear_filters');
|
|
if (cf) cf.onclick = clearFilters;
|
|
const ob = document.getElementById('open_browse');
|
|
if (ob) ob.onclick = () => browseToggle.click();
|
|
}
|
|
|
|
// === Search runner ===
|
|
async function runSearch() {
|
|
const term = q.value.trim();
|
|
if (term.length < 2) { results.innerHTML = ''; syncUrl(); return; }
|
|
syncUrl();
|
|
results.innerHTML = '<div class="loading">searching</div>';
|
|
|
|
const kind = document.querySelector('input[name=kind]:checked').value;
|
|
const params = new URLSearchParams({ q: term, kind, limit: '40' });
|
|
if (minScoreEl.value !== '0') params.set('min_score', minScoreEl.value);
|
|
if (excludeBanterEl.checked) params.set('exclude_banter', 'true');
|
|
|
|
let j;
|
|
try {
|
|
const r = await fetch(`/api/search?${params}`);
|
|
j = await r.json();
|
|
} catch (e) {
|
|
results.innerHTML = `<div class="empty"><p>Search failed.</p><p class="suggest">${escapeHtml(e.message || String(e))}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
// Client-side topic filter (the search API doesn't filter by topic_class
|
|
// currently; cheap to apply here since results are capped at 40).
|
|
let qa = j.qa || [];
|
|
if (topicEl.value) qa = qa.filter(h => h.topic_class === topicEl.value);
|
|
|
|
let html = '';
|
|
if (qa.length) {
|
|
html += '<section class="group-hits"><h3>Q&A pairs</h3>';
|
|
for (const h of qa) html += qaHitHtml(h);
|
|
html += '</section>';
|
|
}
|
|
if ((j.segments || []).length) {
|
|
html += '<section class="group-hits"><h3>Transcript segments</h3>';
|
|
for (const h of j.segments) html += segmentHitHtml(h);
|
|
html += '</section>';
|
|
}
|
|
if (!html) html = emptyHtml(`No hits for “${escapeHtml(term)}”`);
|
|
results.innerHTML = html;
|
|
wireEmptyButtons();
|
|
}
|
|
|
|
// === Browse runner ===
|
|
async function runBrowse(reset) {
|
|
if (reset) browseOffset = 0;
|
|
syncUrl();
|
|
|
|
const params = new URLSearchParams({
|
|
limit: String(BROWSE_LIMIT),
|
|
offset: String(browseOffset),
|
|
order: browseOrder.value,
|
|
});
|
|
if (browseYear.value) params.set('year', browseYear.value);
|
|
if (minScoreEl.value !== '0') params.set('min_score', minScoreEl.value);
|
|
if (excludeBanterEl.checked) params.set('exclude_banter', 'true');
|
|
if (topicEl.value) params.set('topic_class', topicEl.value);
|
|
|
|
if (reset) results.innerHTML = '<div class="loading">loading Q&A pairs</div>';
|
|
|
|
let j;
|
|
try {
|
|
const r = await fetch(`/api/qa?${params}`);
|
|
j = await r.json();
|
|
} catch (e) {
|
|
results.innerHTML = `<div class="empty"><p>Load failed.</p><p class="suggest">${escapeHtml(e.message || String(e))}</p></div>`;
|
|
return;
|
|
}
|
|
browseTotal = j.total;
|
|
const items = j.items || [];
|
|
|
|
const newRows = items.map(qaHitHtml).join('');
|
|
const shown = Math.min(browseOffset + items.length, browseTotal);
|
|
const header = `<div class="browse-bar"><span>Showing <b>${shown.toLocaleString()}</b> of <b>${browseTotal.toLocaleString()}</b> Q&A pairs</span><span>${browseOrder.options[browseOrder.selectedIndex].text}</span></div>`;
|
|
const moreEnabled = browseOffset + items.length < browseTotal;
|
|
|
|
if (reset) {
|
|
if (items.length === 0) {
|
|
results.innerHTML = emptyHtml('No Q&A pairs match these filters.');
|
|
wireEmptyButtons();
|
|
return;
|
|
}
|
|
results.innerHTML =
|
|
`${header}<section class="group-hits" id="qa_browse"><h3>Q&A pairs</h3>${newRows}</section>` +
|
|
(moreEnabled ? '<button class="more" id="load_more">load more</button>' : '');
|
|
} else {
|
|
const list = document.getElementById('qa_browse');
|
|
list.insertAdjacentHTML('beforeend', newRows);
|
|
const bar = results.querySelector('.browse-bar');
|
|
if (bar) bar.outerHTML = header;
|
|
const btn = document.getElementById('load_more');
|
|
if (btn && !moreEnabled) btn.remove();
|
|
}
|
|
browseOffset += items.length;
|
|
const btn = document.getElementById('load_more');
|
|
if (btn) btn.onclick = () => runBrowse(false);
|
|
}
|
|
|
|
// === URL state ===
|
|
function syncUrl() {
|
|
if (restoring) return;
|
|
const url = new URL(window.location);
|
|
url.search = '';
|
|
const sp = url.searchParams;
|
|
if (browseToggle.checked) sp.set('browse', '1');
|
|
else if (q.value.trim().length >= 2) sp.set('q', q.value.trim());
|
|
const kind = document.querySelector('input[name=kind]:checked').value;
|
|
if (kind !== 'both') sp.set('kind', kind);
|
|
if (minScoreEl.value !== '0') sp.set('min_score', minScoreEl.value);
|
|
if (excludeBanterEl.checked) sp.set('exclude_banter', '1');
|
|
if (topicEl.value) sp.set('topic', topicEl.value);
|
|
if (browseToggle.checked) {
|
|
if (browseYear.value) sp.set('year', browseYear.value);
|
|
if (browseOrder.value !== 'air_date_desc') sp.set('order', browseOrder.value);
|
|
}
|
|
history.replaceState(null, '', url);
|
|
}
|
|
function restoreFromUrl() {
|
|
const sp = new URLSearchParams(window.location.search);
|
|
if (!sp.toString()) return;
|
|
restoring = true;
|
|
if (sp.has('min_score')) minScoreEl.value = sp.get('min_score');
|
|
if (sp.has('topic')) topicEl.value = sp.get('topic');
|
|
if (sp.has('exclude_banter')) excludeBanterEl.checked = sp.get('exclude_banter') === '1';
|
|
if (sp.has('kind')) {
|
|
const r = document.querySelector(`input[name=kind][value=${sp.get('kind')}]`);
|
|
if (r) r.checked = true;
|
|
}
|
|
if (sp.has('browse') || sp.has('year') || sp.has('order')) {
|
|
browseToggle.checked = true;
|
|
browseControls.style.display = 'flex';
|
|
q.disabled = true;
|
|
if (sp.has('year')) browseYear.value = sp.get('year');
|
|
if (sp.has('order')) browseOrder.value = sp.get('order');
|
|
if (minScoreEl.value === '0') minScoreEl.value = '3';
|
|
restoring = false;
|
|
runBrowse(true);
|
|
return;
|
|
}
|
|
if (sp.has('q')) {
|
|
q.value = sp.get('q');
|
|
restoring = false;
|
|
runSearch();
|
|
return;
|
|
}
|
|
restoring = false;
|
|
}
|
|
|
|
// === Wiring ===
|
|
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);
|
|
topicEl.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 = '';
|
|
syncUrl();
|
|
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();
|
|
else syncUrl();
|
|
}
|
|
</script>
|
|
</html>
|
|
"""
|
|
|
|
|
|
# Single-episode transcript view.
|
|
EPISODE_HTML = """<!doctype html>
|
|
<html lang="en">
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{title} · Computer Guru Radio Archive</title>
|
|
<style>
|
|
:root {{
|
|
--fg: #1a1a1a;
|
|
--fg-muted: #666;
|
|
--fg-subtle: #999;
|
|
--bg: #fefefe;
|
|
--bg-soft: #f6f5f1;
|
|
--bg-stuck: rgba(254,254,254,.96);
|
|
--border: #e7e5dd;
|
|
--border-soft: #efede5;
|
|
--link: #1853a0;
|
|
--link-hover: #0e3f7f;
|
|
--accent: #c39733;
|
|
--accent-soft: #fbf3da;
|
|
--accent-line: #d8c97a;
|
|
--accent-active: #f7e2a3;
|
|
--intro-soft: #f6f9fc;
|
|
--intro-line: #b9d2ec;
|
|
--s5: #2a8f43; --s4: #5aa54b; --s3: #999; --s2: #c08a3a; --s1: #b85a4a;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
html {{ scroll-behavior: smooth; scroll-padding-top: 110px; }}
|
|
body {{ font: 14.5px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
max-width: 1100px; margin: 1.25em auto; padding: 0 1em;
|
|
color: var(--fg); background: var(--bg); }}
|
|
a {{ color: var(--link); text-decoration: none; }}
|
|
a:hover {{ color: var(--link-hover); text-decoration: underline; }}
|
|
|
|
.topbar {{ position: sticky; top: 0; z-index: 20; padding: .75em 0 .65em;
|
|
background: var(--bg-stuck); backdrop-filter: blur(6px);
|
|
border-bottom: 1px solid var(--border-soft); margin-bottom: 1em; }}
|
|
.topbar h1 {{ margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -.005em; }}
|
|
.topbar .meta {{ color: var(--fg-muted); font-size: 12px; margin-top: 2px; }}
|
|
.topbar code {{ font-size: 11px; color: var(--fg-subtle);
|
|
background: var(--bg-soft); padding: 1px 4px; border-radius: 2px; }}
|
|
audio {{ width: 100%; margin-top: .55em; }}
|
|
.audio-missing {{ background: var(--accent-soft); border: 1px solid var(--accent-line);
|
|
padding: .5em .75em; border-radius: 4px; font-size: 13px;
|
|
color: #6f5320; margin-top: .5em; }}
|
|
|
|
.layout {{ display: grid; grid-template-columns: 1fr 280px; gap: 2em;
|
|
align-items: start; }}
|
|
@media (max-width: 820px) {{
|
|
.layout {{ grid-template-columns: 1fr; }}
|
|
aside {{ position: static !important; }}
|
|
}}
|
|
|
|
.body p.seg {{ margin: .2em 0; line-height: 1.55; }}
|
|
|
|
.ts {{ display: inline-block; min-width: 3.4em; color: var(--link);
|
|
font-variant-numeric: tabular-nums; font-size: 12px;
|
|
text-decoration: none; cursor: pointer; }}
|
|
.ts:hover {{ text-decoration: underline; }}
|
|
.ts.inline {{ min-width: 0; margin-right: .25em; }}
|
|
|
|
.qa {{ background: var(--accent-soft); border-left: 3px solid var(--accent-line);
|
|
padding: .65em .85em; margin: 1.1em 0; border-radius: 4px;
|
|
transition: background .25s, border-color .25s, box-shadow .25s; }}
|
|
.qa.dim {{ opacity: .55; }}
|
|
.qa.dim:hover {{ opacity: .9; }}
|
|
.qa.active {{ background: var(--accent-active);
|
|
border-left-color: var(--accent);
|
|
box-shadow: 0 0 0 1px var(--accent-line); }}
|
|
.qa.active .now-playing {{ display: inline-block; }}
|
|
.now-playing {{ display: none; font-size: 10px; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: .07em;
|
|
color: var(--accent); margin-left: .5em;
|
|
vertical-align: 1px; }}
|
|
|
|
.qa-head {{ font-size: 12px; color: var(--fg-muted); margin-bottom: .4em;
|
|
display: flex; gap: .35em; align-items: center; flex-wrap: wrap; }}
|
|
.qa-q, .qa-a {{ margin: .35em 0; line-height: 1.55; color: var(--fg); }}
|
|
.qa-q b, .qa-a b {{ display: inline-block; min-width: 1.4em; padding: 0 .3em;
|
|
margin-right: .25em; font-size: 10px; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: .05em;
|
|
vertical-align: 1px; border-radius: 2px;
|
|
text-align: center; }}
|
|
.qa-q b {{ background: var(--accent); color: #fff; }}
|
|
.qa-a b {{ background: #ddd; color: #555; }}
|
|
|
|
.intro-marker {{ border-left: 3px solid var(--intro-line);
|
|
background: var(--intro-soft);
|
|
padding: .35em .65em; margin: .85em 0;
|
|
font-size: 13px; border-radius: 4px;
|
|
transition: background .25s; }}
|
|
.intro-marker.active {{ background: #e7eff8;
|
|
border-left-color: #6ba0d6; }}
|
|
|
|
.badge {{ display: inline-block; min-width: 1.4em; padding: 0 .35em;
|
|
font-size: 10px; font-weight: 700; text-align: center;
|
|
border-radius: 3px; color: #fff; background: var(--s3);
|
|
vertical-align: 1px; line-height: 1.5; }}
|
|
.badge.s5 {{ background: var(--s5); }}
|
|
.badge.s4 {{ background: var(--s4); }}
|
|
.badge.s3 {{ background: var(--s3); }}
|
|
.badge.s2 {{ background: var(--s2); }}
|
|
.badge.s1 {{ background: var(--s1); }}
|
|
|
|
.topic {{ font-size: 10px; color: var(--fg-muted); padding: 1px .4em;
|
|
border-radius: 3px; background: var(--bg-soft);
|
|
border: 1px solid var(--border-soft); }}
|
|
.muted {{ color: var(--fg-subtle); }}
|
|
|
|
button.play {{ font-size: 11px; margin-left: auto; padding: 2px 8px;
|
|
border: 1px solid var(--border); border-radius: 3px;
|
|
background: var(--bg); color: var(--link); cursor: pointer;
|
|
transition: background .12s; font-family: inherit; }}
|
|
button.play:hover {{ background: var(--bg-soft); }}
|
|
|
|
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;
|
|
color: var(--fg-muted); margin: 1em 0 .4em; font-weight: 600; }}
|
|
aside h3:first-child {{ margin-top: 0; }}
|
|
aside ul {{ list-style: none; margin: 0; padding: 0; font-size: 12px; }}
|
|
aside li {{ padding: .25em .35em; line-height: 1.35; border-radius: 3px;
|
|
transition: background .15s; }}
|
|
aside li:hover {{ background: var(--bg-soft); }}
|
|
aside li.active {{ background: var(--accent-soft); }}
|
|
aside a {{ color: var(--link); text-decoration: none;
|
|
display: block; }}
|
|
aside a:hover {{ text-decoration: none; }}
|
|
|
|
.back-link {{ font-size: 12px; color: var(--fg-muted); }}
|
|
</style>
|
|
<div class="topbar">
|
|
<h1>{title}</h1>
|
|
<div class="meta">
|
|
{year} · {air} · {duration_min} min ·
|
|
{qa_count} Q&A · {intro_count} intros ·
|
|
<a class="back-link" href="/">« back to search</a>
|
|
</div>
|
|
<div class="meta"><code>{rel_path}</code></div>
|
|
<audio id="player" controls preload="metadata" src="/api/audio/{episode_id}"></audio>
|
|
<div id="audio_missing" class="audio-missing" style="display:none">
|
|
Audio file isn't on this server. Browse-only.
|
|
</div>
|
|
</div>
|
|
<div class="layout">
|
|
<div class="body">
|
|
{body}
|
|
</div>
|
|
<aside>
|
|
<h3>Q&A pairs ({qa_count})</h3>
|
|
<ul id="qa_index">{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');
|
|
|
|
// Hide audio + show notice if the file is unavailable on this server
|
|
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 data-seek
|
|
document.body.addEventListener('click', (ev) => {{
|
|
const el = ev.target.closest('[data-seek]');
|
|
if (!el) return;
|
|
const tag = el.tagName.toLowerCase();
|
|
if (tag === 'a' && el.getAttribute('href') && !el.getAttribute('href').startsWith('#')) return;
|
|
ev.preventDefault();
|
|
seek(el.getAttribute('data-seek'));
|
|
const href = el.getAttribute('href') || '';
|
|
if (tag === 'a' && (href.startsWith('#qa-') || href.startsWith('#intro-'))) {{
|
|
const target = document.getElementById(href.slice(1));
|
|
if (target) target.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
|
|
}}
|
|
}});
|
|
|
|
// On hash load, jump + seek
|
|
function handleHash() {{
|
|
const h = window.location.hash;
|
|
if (!h) return;
|
|
const target = document.getElementById(h.slice(1));
|
|
if (!target) return;
|
|
target.scrollIntoView({{ block: 'start' }});
|
|
const seekEl = target.querySelector('[data-seek]');
|
|
if (seekEl) {{
|
|
const t = seekEl.getAttribute('data-seek');
|
|
if (player.readyState >= 1) seek(t);
|
|
else player.addEventListener('loadedmetadata', () => seek(t), {{ once: true }});
|
|
}}
|
|
}}
|
|
handleHash();
|
|
window.addEventListener('hashchange', handleHash);
|
|
|
|
// Active-Q&A highlighting following audio playhead.
|
|
// Build a sorted index of {{ start, end, el, indexLi }} once.
|
|
const qaBlocks = Array.from(document.querySelectorAll('.qa[id^="qa-"]')).map(el => {{
|
|
const startEl = el.querySelector('[data-seek]');
|
|
const start = startEl ? parseFloat(startEl.getAttribute('data-seek')) : 0;
|
|
// The corresponding aside list item
|
|
const id = el.id;
|
|
const indexLi = document.querySelector(`#qa_index a[href="#${{id}}"]`);
|
|
return {{ el, start, indexLi: indexLi ? indexLi.parentElement : null }};
|
|
}}).sort((a, b) => a.start - b.start);
|
|
|
|
// Compute end as next start (capped at +180s for safety).
|
|
for (let i = 0; i < qaBlocks.length; i++) {{
|
|
const next = qaBlocks[i+1];
|
|
qaBlocks[i].end = next ? Math.min(next.start, qaBlocks[i].start + 180) : qaBlocks[i].start + 180;
|
|
}}
|
|
|
|
let currentActive = null;
|
|
function updateActive() {{
|
|
const t = player.currentTime;
|
|
if (player.paused || isNaN(t) || t === 0) {{
|
|
if (currentActive) {{
|
|
currentActive.el.classList.remove('active');
|
|
if (currentActive.indexLi) currentActive.indexLi.classList.remove('active');
|
|
currentActive = null;
|
|
}}
|
|
return;
|
|
}}
|
|
const found = qaBlocks.find(b => t >= b.start && t < b.end);
|
|
if (found === currentActive) return;
|
|
if (currentActive) {{
|
|
currentActive.el.classList.remove('active');
|
|
if (currentActive.indexLi) currentActive.indexLi.classList.remove('active');
|
|
}}
|
|
if (found) {{
|
|
found.el.classList.add('active');
|
|
if (found.indexLi) found.indexLi.classList.add('active');
|
|
}}
|
|
currentActive = found || null;
|
|
}}
|
|
player.addEventListener('timeupdate', updateActive);
|
|
player.addEventListener('pause', updateActive);
|
|
}})();
|
|
</script>
|
|
</html>
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|