Плеер на странице разбора и хранение записей

Записи складываются в папку records и удаляются вместе с задачей по тому же
сроку. На странице разбора появился плеер, а щелчок по времени слева от
реплики перематывает запись - можно послушать спорное место, не таская
ползунок.

Настройка keep_records в config.toml. Если запись не сохранена, вместо
пустого плеера показывается объяснение.

Черновик с подсветкой расхождений пока остаётся отдельной командой:
build/make_draft.py. Ложные расхождения по написанию больше не подсвечиваются.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-18 15:11:23 +05:00
co-authored by Claude Opus 5
parent 7b4f4721d0
commit 96c2a27c4c
8 changed files with 188 additions and 16 deletions
+33 -2
View File
@@ -111,7 +111,7 @@ def render_index(jobs: list[dict], token: str) -> str:
</div></body></html>"""
def render_job(job: dict) -> str:
def render_job(job: dict, token: str = "") -> str:
"""Собирает самодостаточную страницу: без внешних файлов и запросов."""
result = job.get("result") or job
turns = result.get("turns") or []
@@ -132,7 +132,8 @@ def render_job(job: dict) -> str:
who = f"Г{speaker}" if speaker else "Г0"
rows.append(
f'<div class="turn sp{speaker % 3}">'
f'<span class="tc">{timecode(turn["start"])}</span>'
f'<span class="tc" data-at="{turn["start"]:.1f}">'
f'{timecode(turn["start"])}</span>'
f'<span class="who">{who}</span>'
f'<span class="db">{volume}</span>'
f'<span class="text">{html.escape(turn["text"])}{mark}</span>'
@@ -154,6 +155,18 @@ def render_job(job: dict) -> str:
'запись обработана версией сервиса старше 0.14.0. Отправьте её заново, '
'чтобы собрать диалог по ролям.</div>')
# Плеер появляется, только если запись сохранена: иначе он показывал бы
# пустую полосу и выглядел бы поломкой.
if job.get("has_record"):
suffix = f"?token={quote(token)}" if token else ""
player = (f'<audio id="player" controls preload="none" '
f'src="/v1/jobs/{html.escape(str(job.get("job_id", "")))}/audio{suffix}">'
'</audio>'
'<div class="hint">Щелчок по времени слева от реплики перематывает запись.</div>')
else:
player = ('<div class="hint">Запись не сохранена: включите keep_records '
'в config.toml, чтобы слушать разговор прямо здесь.</div>')
payload = json.dumps(
[{"n": s["n"], "text": s["text"], "start": s["start"]} for s in sentences],
ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028")
@@ -167,6 +180,9 @@ def render_job(job: dict) -> str:
.turn {{ display:grid; grid-template-columns:52px 34px 58px 1fr; gap:8px;
padding:5px 0; border-bottom:1px solid var(--line); }}
.tc, .db {{ color:var(--dim); font-size:12px; font-variant-numeric:tabular-nums; }}
.tc[data-at] {{ cursor:pointer; text-decoration:underline dotted; }}
.tc[data-at]:hover {{ color:var(--fg); }}
audio {{ width:100%; margin:4px 0 2px; }}
.who {{ font-weight:600; font-size:13px; }}
.sp1 .who {{ color:var(--a); }} .sp2 .who {{ color:var(--b); }}
.gap {{ color:var(--warn); font-size:12px; padding:5px 0 5px 94px; }}
@@ -179,6 +195,10 @@ def render_job(job: dict) -> str:
.roles .line {{ display:grid; grid-template-columns:76px 1fr; gap:10px;
padding:4px 0; border-bottom:1px solid var(--line); }}
.who2 {{ font-weight:600; font-size:13px; }}
a.button {{ display:inline-block; font-size:13px; color:var(--fg);
border:1px solid var(--line); border-radius:4px; padding:5px 12px;
text-decoration:none; }}
a.button:hover {{ border-color:var(--dim); }}
button.copy {{ font:13px inherit; color:var(--fg); background:transparent;
border:1px solid var(--line); border-radius:4px; padding:3px 10px;
margin-left:10px; cursor:pointer; font-weight:400; }}
@@ -187,6 +207,7 @@ def render_job(job: dict) -> str:
<h1>Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}</h1>
<div class="stats">{_stats_row(job)}</div>
{player}
<h2>Что отдал сервис <button class="copy" data-src="plain">скопировать для Word</button></h2>
<div class="hint">Реплики так, как их нарезал сервис. Г0 значит, что разделение
по голосам выключено и говорящий неизвестен - это норма.</div>
@@ -263,4 +284,14 @@ async function copyAs(src, button) {{
}}
document.querySelectorAll('button.copy').forEach(b =>
b.addEventListener('click', () => copyAs(b.dataset.src, b)));
// Перемотка по щелчку на времени: слушать нужное место, не таская ползунок.
const player = document.getElementById('player');
if (player) {{
document.querySelectorAll('.tc[data-at]').forEach(el =>
el.addEventListener('click', () => {{
player.currentTime = parseFloat(el.dataset.at);
player.play();
}}));
}}
</script></body></html>"""