feat(longform): job library — finished books/stories in Projects (PR 7/8) (#417)

Surfaces finished Audiobook + Story renders so they're re-downloadable from the
Projects view — closing the resume/history loop of the convergence.

Backend (new, no migration — reads existing job_store rows):
- routers/longform_jobs.py: GET /longform/jobs lists finished audiobook/story
  jobs newest-first, recovering output/chapters/duration from each job's
  persisted 'done' SSE event. Pure build_longform_library() over the job_store
  callables; defensive (skips unparseable jobs, never 500s). Registered in main.py.

Frontend:
- Projects.jsx: new "Audiobooks" category fed by /longform/jobs; each row opens
  the rendered file (/audio/<output>) with type/chapters/duration. Offline-safe
  (empty on fetch failure). en.json keys added.

Built via parallel worktree agent; backend tests/test_longform_jobs.py (9) green;
334 frontend tests + build clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-13 16:17:28 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 00f400e4c7
commit 36e7fb12fc
5 changed files with 372 additions and 2 deletions
+158
View File
@@ -0,0 +1,158 @@
"""Longform Job Library (PR 7).
``GET /longform/jobs`` — list finished Audiobook + Story renders so the user can
re-download them from the Projects view. The render itself (the m4b/mp3) already
landed in ``OUTPUTS_DIR`` and is served at ``/audio/<output>``; here we just
recover, from each finished job's persisted SSE tail, the output filename plus
the chapter count and duration the ``done`` event carried.
Pure recovery, no synthesis. Defensive by construction: a job whose ``done``
event is missing or unparseable is skipped, never surfaced and never a 500.
The work lives in :func:`build_longform_library`, a pure function over the
job-store callables, so it's unit-testable without importing ``main`` (and the
torch graph behind it).
"""
from __future__ import annotations
import json
import logging
from typing import Callable, Optional
from fastapi import APIRouter, Query
logger = logging.getLogger("omnivoice.longform_jobs")
router = APIRouter()
#: Job types this library surfaces. Both flow through the shared longform
#: renderer (``_render_longform_sse``) and emit the same ``done`` event shape.
_LONGFORM_TYPES = ("audiobook", "story")
def _done_payload_from_events(events: list[dict]) -> Optional[dict]:
"""Recover the final ``{"type": "done", ...}`` payload from a job's SSE tail.
Each row's ``payload`` is the JSON the renderer stored via
``job_store.append_event(job_id, json.dumps(payload))``. We scan newest-first
and return the first parseable ``done`` event. Anything malformed is skipped
— this never raises.
"""
for ev in reversed(events):
raw = ev.get("payload") if isinstance(ev, dict) else None
if not raw or not isinstance(raw, str):
continue
try:
obj = json.loads(raw)
except (ValueError, TypeError):
continue
if isinstance(obj, dict) and obj.get("type") == "done":
return obj
return None
def _coerce_int(value, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _coerce_float(value, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def build_longform_library(
list_jobs: Callable[..., list[dict]],
events_since: Callable[..., list[dict]],
*,
limit: int = 50,
) -> list[dict]:
"""Build the newest-first list of finished longform renders.
Pure over the two job-store callables so tests can pass them directly:
* ``list_jobs(status="done", limit=...)`` → all done jobs, newest-first.
* ``events_since(job_id)`` → that job's persisted SSE events.
Returns ``[{job_id, type, title?, output, duration_s, chapters,
created_at}]``. Jobs that aren't a longform type, or whose ``done`` event /
output filename can't be recovered, are silently skipped — the library only
ever lists things the user can actually re-download.
"""
limit = max(1, min(_coerce_int(limit, 50), 500))
try:
# Over-fetch: non-longform done jobs (dub, etc.) get filtered out below,
# so ask for more rows than the caller's limit to still fill the page.
rows = list_jobs(status="done", limit=limit * 4)
except Exception:
logger.warning("longform library: list_jobs failed", exc_info=True)
return []
out: list[dict] = []
for row in rows or []:
if len(out) >= limit:
break
try:
job_type = row.get("type")
job_id = row.get("id")
if job_type not in _LONGFORM_TYPES or not job_id:
continue
try:
events = events_since(job_id)
except Exception:
logger.warning("longform library: events_since failed for %s",
job_id, exc_info=True)
continue
done = _done_payload_from_events(events or [])
if not done:
continue
output = done.get("output")
if not output or not isinstance(output, str):
continue # nothing to re-download → not worth listing
item = {
"job_id": job_id,
"type": job_type,
"output": output,
"duration_s": round(_coerce_float(done.get("duration_s")), 2),
"chapters": _coerce_int(done.get("chapters")),
"created_at": row.get("created_at"),
}
# Title is optional — prefer the done event, fall back to job meta.
title = done.get("title")
if not title:
meta_raw = row.get("meta_json")
if isinstance(meta_raw, str) and meta_raw:
try:
meta = json.loads(meta_raw)
if isinstance(meta, dict):
title = meta.get("title")
except (ValueError, TypeError):
title = None
if title:
item["title"] = title
out.append(item)
except Exception:
# Per-row isolation: one bad row never sinks the whole list.
logger.warning("longform library: skipping unparseable job row",
exc_info=True)
continue
return out
@router.get("/longform/jobs")
def longform_jobs(limit: int = Query(50, ge=1, le=500)) -> dict:
"""Finished Audiobook + Story renders, newest-first, ready to re-download.
Each item's ``output`` is served at ``/audio/<output>``. Never 500s — on any
backend hiccup it returns an empty list rather than an error.
"""
from core import job_store
jobs = build_longform_library(
job_store.list_jobs, job_store.events_since, limit=limit,
)
return {"jobs": jobs}
+2
View File
@@ -330,6 +330,7 @@ from api.routers import (
marketplace,
sonitranslate,
audiobook,
longform_jobs,
settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state
)
from utils import hf_progress
@@ -766,6 +767,7 @@ app.include_router(tts_stream.router)
app.include_router(marketplace.router)
app.include_router(sonitranslate.router)
app.include_router(audiobook.router)
app.include_router(longform_jobs.router)
app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings
+3
View File
@@ -810,6 +810,9 @@
"dub_projects": "Dub Projects",
"voice_profiles": "Voice Profiles",
"transcripts": "Transcripts",
"audiobooks": "Audiobooks",
"audiobook": "Audiobook",
"story": "Story",
"history": "History",
"exports": "Exports",
"search_placeholder": "Search dubs, clones, transcripts, exports…",
+33 -2
View File
@@ -3,8 +3,10 @@ import { copyText } from "../utils/copyText";
import { useTranslation } from 'react-i18next';
import {
Search, FolderOpen, Film, Fingerprint, Wand2, Music, Download,
LayoutGrid, List as ListIcon, Clock, FileText, Mic,
LayoutGrid, List as ListIcon, Clock, FileText, Mic, BookMarked,
} from 'lucide-react';
import { apiFetch } from '../api/client';
import { audioUrl } from '../api/generate';
import './Projects.css';
/**
@@ -88,10 +90,25 @@ export default function Projects({
{ id: 'dubs', label: t('projects.dub_projects'), Icon: Film },
{ id: 'profiles', label: t('projects.voice_profiles'), Icon: Fingerprint },
{ id: 'transcripts', label: t('projects.transcripts'), Icon: Mic },
{ id: 'audiobooks', label: t('projects.audiobooks'), Icon: BookMarked },
{ id: 'history', label: t('projects.history'), Icon: Music },
{ id: 'exports', label: t('projects.exports'), Icon: Download },
];
// Finished Audiobook + Story renders (server-side longform library).
const [longformJobs, setLongformJobs] = useState([]);
React.useEffect(() => {
let alive = true;
(async () => {
try {
const res = await apiFetch('/longform/jobs');
const data = await res.json();
if (alive) setLongformJobs(data.jobs || []);
} catch { /* offline / no backend — leave empty */ }
})();
return () => { alive = false; };
}, []);
// Load transcriptions from localStorage (same source as TranscriptionsPage)
const [transcriptions, setTranscriptions] = useState(() => {
try { return JSON.parse(localStorage.getItem('omni_transcriptions') || '[]'); }
@@ -160,6 +177,20 @@ export default function Projects({
onClick: () => e.path && onRevealExport?.(e.path),
});
}
for (const j of longformJobs) {
const mins = j.duration_s ? `${Math.round(j.duration_s / 60)} min` : '';
list.push({
type: 'audiobooks',
id: j.job_id,
title: j.title || j.output,
subtitle: [j.type === 'story' ? t('projects.story') : t('projects.audiobook'),
j.chapters ? `${j.chapters} ch` : '', mins].filter(Boolean).join(' · '),
ts: (j.created_at || 0) * 1000,
accent: '#d3869b',
Icon: BookMarked,
onClick: () => j.output && window.open(audioUrl(j.output), '_blank'),
});
}
for (const tr of transcriptions) {
list.push({
type: 'transcripts',
@@ -176,7 +207,7 @@ export default function Projects({
}
list.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return list;
}, [studioProjects, profiles, history, exportHistory, transcriptions, onOpenDub, onOpenProfile, onRevealExport]);
}, [studioProjects, profiles, history, exportHistory, transcriptions, longformJobs, onOpenDub, onOpenProfile, onRevealExport, t]);
const counts = useMemo(() => {
const c = { all: items.length };
+176
View File
@@ -0,0 +1,176 @@
"""PR 7 — Longform Job Library. Tests the recovery logic in
``api.routers.longform_jobs.build_longform_library`` against a seeded job_store.
We call the pure builder (and the route handler) directly — no ``main``/torch
import — seeding the real job_store over its temp DB.
"""
import json
import os
import time
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
from core import job_store
from core.db import init_db
from api.routers.longform_jobs import (
build_longform_library,
_done_payload_from_events,
longform_jobs,
)
@pytest.fixture(autouse=True)
def _init_db_once():
init_db()
yield
def _uid(prefix: str) -> str:
return f"{prefix}_{int(time.time()*1e6)}_{os.getpid()}"
def _seed_done(job_id: str, *, type: str, done_payload: dict | None,
meta: dict | None = None, extra_events: list[dict] | None = None):
"""Create a job, append some progress events + a final done event, mark done."""
job_store.create(job_id, type=type, meta=meta)
job_store.mark_running(job_id)
for ev in (extra_events or []):
job_store.append_event(job_id, json.dumps(ev))
if done_payload is not None:
job_store.append_event(job_id, json.dumps(done_payload))
job_store.mark_done(job_id)
# ── _done_payload_from_events ────────────────────────────────────────────────
def test_done_payload_recovers_last_done():
events = [
{"payload": json.dumps({"type": "started", "chapters": 2})},
{"payload": json.dumps({"type": "chapter", "index": 0})},
{"payload": json.dumps({"type": "done", "output": "story_x.m4b",
"chapters": 2, "duration_s": 12.5})},
]
done = _done_payload_from_events(events)
assert done is not None
assert done["output"] == "story_x.m4b"
def test_done_payload_skips_malformed_json():
events = [
{"payload": "not json at all"},
{"payload": json.dumps({"type": "done", "output": "ok.m4b"})},
]
assert _done_payload_from_events(events)["output"] == "ok.m4b"
def test_done_payload_none_when_absent():
events = [{"payload": json.dumps({"type": "chapter"})}]
assert _done_payload_from_events(events) is None
# ── build_longform_library ───────────────────────────────────────────────────
def test_library_lists_only_finished_longform():
ab = _uid("ab")
st = _uid("st")
failed = _uid("fail")
dub = _uid("dub")
_seed_done(ab, type="audiobook", meta={"title": "My Book"},
done_payload={"type": "done", "output": f"{ab}.m4b",
"chapters": 3, "duration_s": 100.0})
_seed_done(st, type="story",
done_payload={"type": "done", "output": f"{st}.m4b",
"chapters": 1, "duration_s": 42.0, "title": "A Tale"})
# A failed audiobook job — has progress events but never a done event.
job_store.create(failed, type="audiobook")
job_store.mark_running(failed)
job_store.append_event(failed, json.dumps({"type": "chapter", "index": 0}))
job_store.mark_failed(failed, "boom")
# A finished dub job — done, but not a longform type → excluded.
_seed_done(dub, type="dub_generate",
done_payload={"type": "done", "output": f"{dub}.wav"})
lib = build_longform_library(job_store.list_jobs, job_store.events_since, limit=50)
by_id = {it["job_id"]: it for it in lib}
assert ab in by_id
assert st in by_id
assert failed not in by_id # never finished
assert dub not in by_id # wrong type
assert by_id[ab]["type"] == "audiobook"
assert by_id[ab]["output"] == f"{ab}.m4b"
assert by_id[ab]["chapters"] == 3
assert by_id[ab]["duration_s"] == 100.0
assert by_id[ab]["title"] == "My Book" # from job meta
assert by_id[ab]["created_at"] is not None
assert by_id[st]["title"] == "A Tale" # from done event
assert by_id[st]["chapters"] == 1
def test_library_skips_done_job_without_output():
bad = _uid("noout")
_seed_done(bad, type="story",
done_payload={"type": "done", "chapters": 2}) # no "output"
lib = build_longform_library(job_store.list_jobs, job_store.events_since, limit=50)
assert bad not in {it["job_id"] for it in lib}
def test_library_newest_first():
older = _uid("old")
newer = _uid("new")
_seed_done(older, type="audiobook",
done_payload={"type": "done", "output": f"{older}.m4b", "chapters": 1})
time.sleep(0.01)
_seed_done(newer, type="audiobook",
done_payload={"type": "done", "output": f"{newer}.m4b", "chapters": 1})
lib = build_longform_library(job_store.list_jobs, job_store.events_since, limit=50)
ids = [it["job_id"] for it in lib if it["job_id"] in (older, newer)]
assert ids.index(newer) < ids.index(older)
def test_library_respects_limit():
seeded = []
for _ in range(5):
jid = _uid("lim")
_seed_done(jid, type="story",
done_payload={"type": "done", "output": f"{jid}.m4b", "chapters": 1})
seeded.append(jid)
lib = build_longform_library(job_store.list_jobs, job_store.events_since, limit=2)
assert len(lib) == 2
def test_library_never_raises_on_bad_callables():
def boom(*a, **k):
raise RuntimeError("db down")
# list_jobs failing → empty list, no exception.
assert build_longform_library(boom, job_store.events_since, limit=10) == []
# events_since failing for a row → that row skipped, no exception.
jid = _uid("ev")
_seed_done(jid, type="audiobook",
done_payload={"type": "done", "output": f"{jid}.m4b", "chapters": 1})
def evboom(*a, **k):
raise RuntimeError("events down")
lib = build_longform_library(job_store.list_jobs, evboom, limit=10)
assert jid not in {it["job_id"] for it in lib}
# ── route handler ────────────────────────────────────────────────────────────
def test_route_handler_returns_jobs_envelope():
jid = _uid("route")
_seed_done(jid, type="audiobook",
done_payload={"type": "done", "output": f"{jid}.m4b",
"chapters": 2, "duration_s": 9.0})
resp = longform_jobs(limit=50)
assert "jobs" in resp
assert jid in {it["job_id"] for it in resp["jobs"]}