commit b96bf0f91682cf2c75efb7e01c5898902722b471
equwal <truex@equwal.com>
2026-09-20 17:29:04 -0700
The site converts in the browser; the server keeps the books
Dropping the two files no longer uploads anything. The book is read and its
language detected in the tab, the job runs there (engine/), and only the
finished .srt and its metadata are sent back, so history still follows the
account to another device.
The server's part is now entitlement and record-keeping:
POST /api/local/jobs may this start? (free window / credit)
POST /api/local/jobs/{id}/finish keep the subtitles
POST /api/local/jobs/{id}/fail hand back what the job held
POST /api/convert mobi/azw3 -> epub, the one format family
that needs a real parser
Reopening the same book resumes under the job already paid for; a tab closed
for good releases its slot or credit after 48 hours.
Videos are made on request in the tab, in seconds. Paying to unlock one opens
checkout in a new tab, since the finished job only exists in this one.
backend/api.py | 175 +++++++++++++++++++
backend/db.py | 3 +
backend/main.py | 5 +-
frontend/app.js | 390 +++++++++++++++++++++++++++----------------
frontend/engine/languages.js | 20 +++
frontend/index.html | 30 +++-
frontend/style.css | 9 +
tests/test_local_jobs.py | 122 ++++++++++++++
8 files changed, 607 insertions(+), 147 deletions(-)
diff --git a/backend/api.py b/backend/api.py
index 7220f1e..437e55e 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -145,6 +145,7 @@ class JobOut(BaseModel):
audio_duration_seconds: float | None
error: str | None
created_at: str
+ local: bool = False
tier: str = billing.FREE
artifacts: list[ArtifactOut] = Field(default_factory=list)
@@ -189,6 +190,28 @@ class AccountOut(BaseModel):
queue_depth: int
+class LocalJobIn(BaseModel):
+ """A conversion about to run in the visitor's browser."""
+ audio_filename: str = Field(max_length=512)
+ audio_parts: int = Field(default=1, ge=1, le=2000)
+ audio_bytes: int = Field(default=0, ge=0)
+ audio_duration_seconds: float | None = None
+ text_filename: str = Field(max_length=512)
+ language: str = Field(max_length=16)
+ tier: Literal["free", "youtube"] = "free"
+
+
+class LocalFinishIn(BaseModel):
+ # A twenty-hour book is a few megabytes of subtitles.
+ srt: str = Field(max_length=8_000_000)
+ filename: str = Field(max_length=512)
+ metadata: dict = Field(default_factory=dict)
+
+
+class LocalFailIn(BaseModel):
+ error: str = Field(default="", max_length=4000)
+
+
class EmailIn(BaseModel):
email: str
@@ -222,6 +245,7 @@ def _job_out(job: Job, arts: list[Artifact]) -> JobOut:
audio_duration_seconds=job.audio_duration_seconds,
error=job.error,
created_at=job.created_at.isoformat(),
+ local=bool(job.local),
tier=job.tier or billing.FREE,
artifacts=[
ArtifactOut(
@@ -646,6 +670,157 @@ def start_job(
return _job_out(job, [])
+# --------------------------------------------------------------------------
+# jobs that run in the browser
+# --------------------------------------------------------------------------
+#
+# The audio never reaches us. The server's part is to say whether the job may
+# start (the free window, or a credit), and to keep the finished subtitles so
+# they are still there on another device. Nothing here can be enforced against
+# someone who edits the page's JavaScript, and that is accepted: see billing.py.
+
+_LOCAL_STALE_HOURS = 48
+
+
+@router.post("/local/jobs", response_model=JobOut)
+def start_local_job(
+ body: LocalJobIn,
+ account: Annotated[Account, Depends(get_account)],
+ session: Annotated[Session, Depends(get_session)],
+):
+ # The same book started again - a closed tab, a reload - carries on under
+ # the job it already paid for rather than being charged a second time.
+ running = (
+ session.query(Job)
+ .filter(
+ Job.account_id == account.id, Job.local == 1,
+ Job.status == JobStatus.running,
+ Job.audio_filename == body.audio_filename,
+ Job.audio_bytes == body.audio_bytes,
+ )
+ .order_by(Job.created_at.desc())
+ .first()
+ )
+ if running is not None:
+ if body.tier == billing.YOUTUBE and running.tier != billing.YOUTUBE:
+ try:
+ billing.unlock(session, account, running)
+ except billing.PaymentRequired as exc:
+ session.rollback()
+ raise HTTPException(402, str(exc)) from exc
+ running.language = body.language
+ session.commit()
+ return _job_out(running, [])
+
+ job = Job(
+ account_id=account.id, status=JobStatus.running, local=1,
+ language=body.language, splitter="browser", model="whisper-tiny",
+ audio_filename=body.audio_filename, text_filename=body.text_filename,
+ audio_parts=body.audio_parts, audio_bytes=body.audio_bytes,
+ audio_duration_seconds=body.audio_duration_seconds,
+ stage="Running in your browser", started_at=utcnow(),
+ )
+ session.add(job)
+ try:
+ billing.authorize_start(session, account, job, body.tier)
+ except billing.PaymentRequired as exc:
+ session.rollback()
+ raise HTTPException(402, str(exc)) from exc
+ session.commit()
+ return _job_out(job, [])
+
+
+@router.post("/local/jobs/{job_id}/finish", response_model=JobOut)
+def finish_local_job(
+ job_id: str,
+ body: LocalFinishIn,
+ account: Annotated[Account, Depends(get_account)],
+ session: Annotated[Session, Depends(get_session)],
+):
+ job = _load(session, account, job_id)
+ if not job.local or job.status != JobStatus.running:
+ raise HTTPException(409, f"Job is already {job.status.value}.")
+
+ import json
+ import tempfile
+
+ name = Path(body.filename).name or "subtitles.srt"
+ with tempfile.TemporaryDirectory() as tmp:
+ for kind, filename, content in (
+ ("srt", name, body.srt),
+ ("metadata", "metadata.json",
+ json.dumps({"job_id": job.id, **body.metadata}, ensure_ascii=False, indent=2)),
+ ):
+ src = Path(tmp) / filename
+ # Bytes, not text: on Windows write_text would turn every line ending into CRLF.
+ src.write_bytes(content.encode("utf-8"))
+ key = f"{job.id}/{filename}"
+ size = storage.put_file(key, src)
+ session.add(Artifact(job_id=job.id, kind=kind, filename=filename,
+ storage_key=key, size_bytes=size))
+
+ job.status = JobStatus.succeeded
+ job.stage, job.progress, job.finished_at = "Done", 1.0, utcnow()
+ session.commit()
+ return _job_out(job, _artifacts(session, job.id))
+
+
+@router.post("/local/jobs/{job_id}/fail", response_model=JobOut)
+def fail_local_job(
+ job_id: str,
+ body: LocalFailIn,
+ account: Annotated[Account, Depends(get_account)],
+ session: Annotated[Session, Depends(get_session)],
+):
+ job = _load(session, account, job_id)
+ if not job.local or job.status != JobStatus.running:
+ raise HTTPException(409, f"Job is already {job.status.value}.")
+ job.status = JobStatus.failed
+ job.stage, job.error, job.finished_at = "Failed", body.error or "Failed in the browser.", utcnow()
+ # Whatever went wrong, they got nothing: the free slot or the credit goes back.
+ billing.refund(session, job)
+ session.commit()
+ return _job_out(job, [])
+
+
+def expire_stale_local_jobs(session: Session) -> int:
+ """A tab that was closed for good never reports back. Release what it held."""
+ from datetime import timedelta
+
+ stale = (
+ session.query(Job)
+ .filter(Job.local == 1, Job.status == JobStatus.running,
+ Job.created_at < utcnow() - timedelta(hours=_LOCAL_STALE_HOURS))
+ .all()
+ )
+ for job in stale:
+ job.status, job.stage, job.finished_at = JobStatus.canceled, "Abandoned", utcnow()
+ billing.refund(session, job)
+ session.commit()
+ return len(stale)
+
+
+@router.post("/convert")
+async def convert_book(file: Annotated[UploadFile, File()]):
+ """mobi / azw3 to epub. The one thing the browser cannot do for itself:
+ those formats need a real parser, and a book is small enough to send."""
+ name = Path(file.filename or "book").name
+ if not convert.needs_conversion(name):
+ raise HTTPException(400, "That format does not need converting.")
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ src = Path(tmp) / name
+ await _save(file, src)
+ try:
+ out = convert.to_readable(src, Path(tmp) / "converted")
+ except convert.ConversionError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ data = out.read_bytes()
+ return Response(data, media_type="application/epub+zip",
+ headers={"X-Filename": Path(name).stem + ".epub"})
+
+
@router.get("/jobs", response_model=list[JobOut])
def list_jobs(
account: Annotated[Account, Depends(get_account)],
diff --git a/backend/db.py b/backend/db.py
index 864bde4..245db7b 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -143,6 +143,9 @@ class Job(Base):
tier: Mapped[str] = mapped_column(
String(16), default="free", server_default=text("'free'")
)
+ # 1 when the work happens in the visitor's browser and the server only
+ # keeps the books: nothing to queue, nothing to resume after a restart.
+ local: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
# 1 if a credit was spent on this job, so a failed run can hand it back.
credit_spent: Mapped[int] = mapped_column(
Integer, default=0, server_default=text("0")
diff --git a/backend/main.py b/backend/main.py
index 3fd7bc7..484ace4 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -46,7 +46,7 @@ def _requeue_interrupted() -> None:
with SessionLocal() as s:
stale = (
s.query(Job)
- .filter(Job.status.in_([JobStatus.running, JobStatus.queued]))
+ .filter(Job.status.in_([JobStatus.running, JobStatus.queued]), Job.local == 0)
.all()
)
for job in stale:
@@ -73,6 +73,9 @@ async def lifespan(_: FastAPI):
"on" if settings.billing_enabled else "off",
)
_requeue_interrupted()
+ with SessionLocal() as s:
+ from .api import expire_stale_local_jobs
+ expire_stale_local_jobs(s)
yield
queue.shutdown()
diff --git a/frontend/app.js b/frontend/app.js
index 3905319..a8b4f96 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -22,6 +22,9 @@ const el = {
signinNote: $('signin-note'),
pricingDialog: $('pricing-dialog'), pricingWhy: $('pricing-why'),
plans: $('plans'), toast: $('toast'),
+ working: $('working'), workTitle: $('work-title'), workBar: $('work-bar'),
+ workMeta: $('work-meta'), stop: $('stop'), results: $('results'),
+ resultFiles: $('result-files'), another: $('another'),
};
let languages = [];
@@ -73,7 +76,8 @@ async function api(path, opts = {}) {
/* ---------------- setup ---------------- */
async function loadLanguages() {
- languages = await api('/api/languages');
+ // Whisper's own list: the speech model is the only thing that cares.
+ ({ LANGUAGES: languages } = await import('/engine/languages.js'));
el.language.innerHTML = '';
for (const l of languages) {
const o = document.createElement('option');
@@ -95,7 +99,6 @@ function setLanguage(code) {
el.language.appendChild(o);
el.language.value = code;
}
- updateSplitNote();
}
async function refreshAccount() {
@@ -227,7 +230,7 @@ function addFiles(files) {
renderStaged();
if (!audio.length && !text.length) return; // an image alone is not a job
if (staged.audio.length && staged.text) {
- uploadFiles([...staged.audio, staged.text]);
+ prepare();
}
}
@@ -281,65 +284,47 @@ document.querySelectorAll('.slot-x').forEach((btn) => {
});
});
-function uploadFiles(files) {
- clearError();
- const list = [...files];
+/* Nothing is uploaded. Once both halves are here the book is read in this tab,
+ its language guessed, and the visitor asked to confirm before hours of work. */
+const NEEDS_CONVERTING = /\.(mobi|azw|azw3|prc)$/i;
+const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
+async function prepare() {
+ clearError();
el.dropzone.hidden = true;
el.staged.hidden = true;
el.confirm.hidden = true;
el.uploading.hidden = false;
- el.upbar.style.width = '0%';
- el.uptext.textContent = 'Starting…';
-
- const form = new FormData();
- for (const f of list) form.append('files', f, f.name);
- if (staged.cover && signedIn) form.append('files', staged.cover, staged.cover.name);
-
- // XHR rather than fetch: it reports upload progress, and these files are big.
- const xhr = new XMLHttpRequest();
- xhr.open('POST', '/api/uploads');
- xhr.withCredentials = true;
-
- xhr.upload.onprogress = (e) => {
- if (!e.lengthComputable) return;
- const pct = Math.round((e.loaded / e.total) * 100);
- el.upbar.style.width = `${pct}%`;
- el.uptext.textContent = pct < 100
- ? `${pct}% — ${fmtBytes(e.loaded)} of ${fmtBytes(e.total)}`
- : 'Analysing the book…';
- };
+ el.upbar.style.width = '100%';
+ el.uptext.textContent = 'Reading the book…';
- xhr.onload = () => {
- el.uploading.hidden = true;
- if (xhr.status >= 200 && xhr.status < 300) {
- try {
- const body = JSON.parse(xhr.responseText);
- if (body.cover_requires_sign_in) {
- showError('Your cover image was not used — that needs an account. ' +
- 'Everything else ran normally.');
- }
- showConfirm(body);
- }
- catch { showError('Server sent an unreadable response.'); resetToDrop(); }
- } else {
- let msg = `Upload failed (${xhr.status}).`;
- try {
- const b = JSON.parse(xhr.responseText);
- if (b.detail) msg = typeof b.detail === 'string' ? b.detail : msg;
- } catch { /* keep the generic message */ }
- showError(msg);
- resetToDrop();
+ try {
+ const { readBook, detectLanguage } = await import('/engine/book.js');
+ let book = staged.text;
+ if (NEEDS_CONVERTING.test(book.name)) {
+ // The one thing the browser cannot do itself: Kindle formats need a real
+ // parser. A book is small; the audio still never leaves this machine.
+ el.uptext.textContent = 'Converting the book to epub…';
+ const form = new FormData();
+ form.append('file', book, book.name);
+ const res = await fetch('/api/convert', { method: 'POST', body: form, credentials: 'same-origin' });
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Could not convert that book.');
+ book = new File([await res.blob()], res.headers.get('X-Filename') || 'book.epub');
}
- };
-
- xhr.onerror = () => {
+ const parsed = await readBook(book);
+ if (!parsed.paragraphs.length) {
+ throw new Error(`No text could be read from ${book.name}. A scanned, image-only book cannot be aligned.`);
+ }
+ // 9.mp3 before 10.mp3: playback order is what the timeline is built from.
+ const audio = staged.audio.slice().sort((a, b) => natural.compare(a.name, b.name));
+ draft = { audio, book, cover: staged.cover, detected: detectLanguage(parsed.paragraphs) };
+ el.uploading.hidden = true;
+ showConfirm();
+ } catch (e) {
el.uploading.hidden = true;
- showError('Upload failed: lost connection to the server.');
+ showError(e.message);
resetToDrop();
- };
-
- xhr.send(form);
+ }
}
function resetToDrop() {
@@ -348,117 +333,219 @@ function resetToDrop() {
el.dropzone.hidden = false;
el.confirm.hidden = true;
el.uploading.hidden = true;
+ el.working.hidden = true;
el.picker.value = '';
}
/* ---------------- confirm ---------------- */
-async function showConfirm(payload) {
- // The options must exist before we can select the detected language.
- try { await languagesReady; } catch { /* handled at boot */ }
-
- draft = payload.job;
- const d = payload.detected;
-
- const dur = fmtDuration(draft.audio_duration_seconds);
- const bits = [
- draft.audio_parts > 1 ? `${draft.audio_parts} parts` : null,
- fmtBytes(draft.audio_bytes),
- dur,
- ].filter(Boolean);
- el.cAudio.innerHTML =
- `${escapeHtml(draft.audio_filename)} <span class="meta">${bits.join(' · ')}</span>`;
- el.cText.textContent = draft.text_filename;
-
- setLanguage(draft.language);
-
- if (d.code && d.supported) {
- const pct = Math.round(d.confidence * 100);
- el.detected.textContent = `detected ${d.name} · ${pct}%`;
- el.detected.classList.toggle('low', d.confidence < 0.7);
- el.detected.hidden = false;
- } else if (d.code) {
- el.detected.textContent = `detected ${d.name} — not supported, pick one`;
- el.detected.classList.add('low');
- el.detected.hidden = false;
+async function showConfirm() {
+ const bytes = draft.audio.reduce((n, f) => n + f.size, 0);
+ const name = draft.audio.length > 1
+ ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name;
+ el.cAudio.innerHTML = `${escapeHtml(name)} <span class="meta">${fmtBytes(bytes)}</span>`;
+ el.cText.textContent = draft.book.name;
+
+ if (draft.detected) {
+ setLanguage(draft.detected);
+ el.detected.textContent = 'detected from the book';
+ el.detected.classList.remove('low');
} else {
- el.detected.hidden = true;
+ el.detected.textContent = 'could not tell — please choose';
+ el.detected.classList.add('low');
}
-
- renderMatch(payload.match);
-
- el.eta.textContent = draft.audio_duration_seconds
- ? `Alignment usually takes a fraction of the book's length, but on CPU it can approach it. ${dur} of audio — expect a long run.`
- : '';
-
+ el.detected.hidden = false;
+ el.match.hidden = true;
+
+ // Only the speech model decides how long this takes, and only the GPU
+ // decides how fast the speech model is. Say which it will be.
+ const { hasWebGpu } = await import('/engine/asr.js');
+ const gpu = await hasWebGpu();
+ const { savedProgress } = await import('/engine/job.js');
+ const saved = await savedProgress(draft.audio, el.language.value);
+ el.eta.textContent =
+ (saved?.complete ? 'This audio was transcribed here before, so this will take seconds. '
+ : saved?.doneUntil ? `Picks up where it stopped, ${fmtDuration(saved.doneUntil)} in. ` : '') +
+ 'Everything runs in this tab: nothing is uploaded, and it has to stay open. ' +
+ (saved?.complete ? '' : gpu ? 'Expect roughly a quarter of the book\'s length.'
+ : 'This browser has no WebGPU, so expect about the book\'s own length — Chrome or Edge on a computer with a graphics card is several times faster.');
+
+ el.start.textContent = saved?.doneUntil && !saved.complete ? 'Continue' : 'Start';
el.confirm.hidden = false;
el.start.disabled = false;
el.confirm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
-/* The preflight score, using the backend's own matching rule. A poor score is
- shown but never blocks: the check samples the audio, so it can be wrong, and
- it is the user's book. */
-function renderMatch(m) {
- if (!m || m.skipped || m.verdict === 'unknown') {
- el.match.hidden = true;
- el.start.textContent = 'Start alignment';
+el.discard.addEventListener('click', () => { clearError(); resetToDrop(); });
+
+/* ---------------- the job, in this tab ---------------- */
+
+let running = null; // { job, serverId }
+let wakeLock = null;
+
+el.start.addEventListener('click', async () => {
+ if (!draft || running) return;
+ el.start.disabled = true;
+ clearError();
+ const language = el.language.value;
+ const tier = chosenTier();
+
+ // Ask first: the free window and credits are the server's to say.
+ let registered;
+ try {
+ registered = await api('/api/local/jobs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ audio_filename: draft.audio.length > 1 ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name,
+ audio_parts: draft.audio.length,
+ audio_bytes: draft.audio.reduce((n, f) => n + f.size, 0),
+ text_filename: draft.book.name, language, tier,
+ }),
+ });
+ } catch (e) {
+ // 402 is not an error to apologise for; it is the price list's cue.
+ if (e.status === 402) openPricing(e.message); else showError(e.message);
+ el.start.disabled = false;
return;
}
- el.match.hidden = false;
- el.match.className = `match ${m.verdict}`;
- el.matchBadge.textContent =
- { good: 'match', marginal: 'weak match', poor: 'no match' }[m.verdict] || m.verdict;
- el.matchSummary.textContent = m.summary;
-
- el.matchWarnings.innerHTML = '';
- for (const w of m.warnings || []) {
- const li = document.createElement('li');
- li.textContent = w;
- el.matchWarnings.appendChild(li);
+ const { Job, Cancelled } = await import('/engine/job.js');
+ const job = new Job({ audio: draft.audio, book: draft.book, language, onStatus: renderWorking });
+ running = { job, serverId: registered.id, tier: registered.tier, cover: draft.cover };
+ el.confirm.hidden = true;
+ el.working.hidden = false;
+ el.results.hidden = true;
+ el.stop.hidden = false;
+ renderWorking({ phase: 'preparing', detail: 'Starting', fraction: 0 });
+ refreshAccount();
+ try { wakeLock = await navigator.wakeLock?.request('screen'); } catch { /* not granted: fine */ }
+
+ try {
+ const result = await job.run();
+ await api(`/api/local/jobs/${registered.id}/finish`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ srt: result.srt, filename: result.srtName,
+ metadata: {
+ source: { audio_parts: draft.audio.length, text_filename: draft.book.name,
+ audio_duration_seconds: result.duration },
+ alignment: { where: 'in the browser', device: job.device ?? 'cached transcript', model: 'whisper-tiny',
+ language, mode: 'forced alignment against supplied text' },
+ output: { filename: result.srtName, cue_count: result.cues.length,
+ match_rate: result.matchRate, paragraphs_dropped: result.paragraphsDropped },
+ },
+ }),
+ }).catch(() => { /* the subtitles are still here to download; only the history entry is missing */ });
+ showResults(result);
+ } catch (e) {
+ const stopped = e instanceof Cancelled;
+ await api(`/api/local/jobs/${registered.id}/fail`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ error: stopped ? 'Stopped.' : String(e.message || e) }),
+ }).catch(() => {});
+ job.close();
+ running = null;
+ el.working.hidden = true;
+ if (stopped) { toast('Stopped. What was transcribed is kept — the same files will carry on from there.'); el.confirm.hidden = false; el.start.disabled = false; }
+ else { showError(e.message || String(e)); resetToDrop(); }
+ } finally {
+ wakeLock?.release?.().catch(() => {});
+ refreshAccount();
+ refreshJobs();
}
+});
- // Make the user's choice explicit when we expect this to fail.
- el.start.textContent =
- m.verdict === 'poor' ? 'Start anyway' : 'Start alignment';
+el.stop.addEventListener('click', () => { running?.job.cancel(); el.stop.disabled = true; });
+
+function renderWorking(st) {
+ el.workTitle.textContent = st.detail;
+ el.workBar.style.width = `${Math.round((st.fraction || 0) * 100)}%`;
+ el.workBar.parentElement.classList.toggle('indeterminate', !!st.indeterminate);
+ el.workMeta.textContent = [
+ `${Math.round((st.fraction || 0) * 100)}%`,
+ st.eta != null ? `about ${fmtDuration(st.eta)} left` : null,
+ st.speed ? `${st.speed.toFixed(1)}× real time` : null,
+ ].filter(Boolean).join(' · ');
}
-function updateSplitNote() {
- // The note comes from the server, so the UI carries no knowledge of which
- // alignment backend is running or how it splits sentences.
- const lang = languages.find((l) => l.code === el.language.value);
- el.splitnote.textContent = (lang && lang.note) || '';
+function showResults(r) {
+ el.stop.hidden = true;
+ el.stop.disabled = false;
+ el.results.hidden = false;
+ const pct = Math.round(r.matchRate * 100);
+ el.workTitle.textContent = `Done — ${r.cues.length} lines, ${pct}% found in the book`;
+ el.workBar.style.width = '100%';
+ el.workMeta.textContent =
+ (pct < 80 ? 'That is low: usually a different edition or translation, or the wrong language. ' : '') +
+ (r.paragraphsDropped ? `${r.paragraphsDropped} paragraphs of the book were never narrated (front matter, notes) and were left out.` : '');
+ renderResultButtons();
}
-el.language.addEventListener('change', updateSplitNote);
-
-el.discard.addEventListener('click', async () => {
- if (draft) { try { await api(`/api/jobs/${draft.id}`, { method: 'DELETE' }); } catch {} }
- clearError();
- resetToDrop();
- refreshJobs();
-});
+function renderResultButtons() {
+ const r = running.job.result;
+ const paid = !account?.billing_enabled || running.tier === 'youtube';
+ el.resultFiles.innerHTML = '';
+ const button = (label, hint, cls, onClick) => {
+ const b = document.createElement('button');
+ b.type = 'button'; b.className = `dl ${cls}`; b.title = hint; b.textContent = label;
+ b.addEventListener('click', () => onClick(b));
+ el.resultFiles.appendChild(b);
+ };
+ button('⬇ Subtitles (.srt)', 'For Hoshi Reader, or to upload alongside the YouTube video', '',
+ () => save(new File([r.srt], r.srtName, { type: 'application/x-subrip' })));
+ button('⬇ Video with subs built in (.mkv)', 'Subtitles inside the file, for MPV or VLC', '',
+ (b) => makeVideo('mkv', b));
+ button(paid ? '⬇ Video for YouTube (.mp4)' : '🔒 Unlock the YouTube video (.mp4)',
+ paid ? 'No subtitles baked in — add the .srt in YouTube Studio'
+ : 'Part of the YouTube tier — one credit, or the unlimited plan',
+ paid ? '' : 'locked', (b) => (paid ? makeVideo('mp4', b) : unlockRunning(b)));
+}
-el.start.addEventListener('click', async () => {
- if (!draft) return;
- el.start.disabled = true;
- clearError();
+async function makeVideo(kind, btn) {
+ const label = btn.textContent;
+ btn.disabled = true;
try {
- await api(`/api/jobs/${draft.id}/start`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ language: el.language.value, tier: chosenTier() }),
+ const file = await running.job.video(kind, {
+ coverFile: signedIn ? running.cover : null,
+ onProgress: (f) => { btn.textContent = `Making the ${kind}… ${Math.round(f * 100)}%`; },
});
- resetToDrop();
- refreshJobs();
+ save(file);
+ } catch (e) { showError(`Could not make the ${kind}: ${e.message}`); }
+ btn.textContent = label;
+ btn.disabled = false;
+}
+
+async function unlockRunning(btn) {
+ btn.disabled = true;
+ try {
+ const j = await api(`/api/jobs/${running.serverId}/unlock`, { method: 'POST' });
+ running.tier = j.tier;
+ toast('Unlocked.');
+ renderResultButtons();
refreshAccount();
} catch (e) {
- // 402 is not an error to apologise for; it is the price list's cue.
- if (e.status === 402) openPricing(e.message);
- else showError(e.message);
- el.start.disabled = false;
+ if (e.status === 402) { unlockJobId = running.serverId; openPricing(e.message); } else showError(e.message);
+ btn.disabled = false;
}
+}
+
+function save(file) {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(file);
+ a.download = file.name;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 60_000);
+}
+
+el.another.addEventListener('click', () => { running?.job.close(); running = null; resetToDrop(); });
+
+// Hours of work live in this tab. Make closing it a decision, not an accident.
+window.addEventListener('beforeunload', (e) => {
+ if (running && !running.job.result) { e.preventDefault(); e.returnValue = ''; }
});
/* ---------------- jobs ---------------- */
@@ -473,7 +560,9 @@ function jobCard(j) {
const li = document.createElement('li');
li.className = 'job';
- const active = j.status === 'running' || j.status === 'queued';
+ // A browser-run job reports no progress to the server; it is either ours
+ // (shown above, in the working panel) or running in some other tab.
+ const active = !j.local && (j.status === 'running' || j.status === 'queued');
const pct = Math.round(j.progress * 100);
const meta = [
@@ -498,6 +587,9 @@ function jobCard(j) {
<div class="job-stage"><span>${escapeHtml(j.stage)}</span><span>${pct}%</span></div>`;
}
+ if (j.local && j.status === 'running') {
+ html += '<div class="job-stage"><span>Running in a browser tab</span></div>';
+ }
if (j.error) html += `<div class="job-err">${escapeHtml(j.error)}</div>`;
const files = j.artifacts || [];
@@ -564,12 +656,12 @@ async function refreshJobs() {
catch { return; }
// Drafts live in the confirm panel, not the list.
- const visible = jobs.filter((j) => j.status !== 'draft');
+ const visible = jobs.filter((j) => j.status !== 'draft' && j.id !== running?.serverId);
el.jobsSection.hidden = visible.length === 0;
el.jobs.innerHTML = '';
for (const j of visible) el.jobs.appendChild(jobCard(j));
- const anyActive = visible.some((j) => j.status === 'running' || j.status === 'queued');
+ const anyActive = visible.some((j) => !j.local && (j.status === 'running' || j.status === 'queued'));
clearTimeout(pollTimer);
if (anyActive) pollTimer = setTimeout(refreshJobs, 1500);
}
@@ -723,6 +815,14 @@ async function checkout(planId, btn) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_id: planId, job_id: unlockJobId }),
});
+ if (running) {
+ // The finished job only exists in this tab. Pay in another one.
+ window.open(url, '_blank', 'noopener');
+ el.pricingDialog.close();
+ toast('Checkout opened in a new tab. Come back here when you have paid.', 12000);
+ btn.disabled = false;
+ return;
+ }
window.location.href = url; // Stripe's page; we come back via /api/billing/return
} catch (e) {
el.pricingDialog.close();
@@ -805,6 +905,16 @@ async function handleArrival() {
}
}
+// Back from paying in the other tab: the purchase unlocked this job server-side.
+window.addEventListener('focus', async () => {
+ if (!running?.job.result || running.tier === 'youtube') return;
+ try {
+ const j = await api(`/api/jobs/${running.serverId}`);
+ if (j.tier === 'youtube') { running.tier = j.tier; renderResultButtons(); toast('Unlocked.'); }
+ refreshAccount();
+ } catch { /* try again on the next focus */ }
+});
+
/* ---------------- boot ---------------- */
(async function init() {
diff --git a/frontend/engine/languages.js b/frontend/engine/languages.js
new file mode 100644
index 0000000..69ad7fb
--- /dev/null
+++ b/frontend/engine/languages.js
@@ -0,0 +1,20 @@
+/* The languages Whisper transcribes, by its own codes. */
+export const LANGUAGES = Object.entries({
+ af: 'Afrikaans', sq: 'Albanian', am: 'Amharic', ar: 'Arabic', hy: 'Armenian', as: 'Assamese',
+ az: 'Azerbaijani', ba: 'Bashkir', eu: 'Basque', be: 'Belarusian', bn: 'Bengali', bs: 'Bosnian',
+ br: 'Breton', bg: 'Bulgarian', my: 'Burmese', yue: 'Cantonese', ca: 'Catalan', zh: 'Chinese',
+ hr: 'Croatian', cs: 'Czech', da: 'Danish', nl: 'Dutch', en: 'English', et: 'Estonian',
+ fo: 'Faroese', fi: 'Finnish', fr: 'French', gl: 'Galician', ka: 'Georgian', de: 'German',
+ el: 'Greek', gu: 'Gujarati', ht: 'Haitian Creole', ha: 'Hausa', haw: 'Hawaiian', he: 'Hebrew',
+ hi: 'Hindi', hu: 'Hungarian', is: 'Icelandic', id: 'Indonesian', it: 'Italian', ja: 'Japanese',
+ jw: 'Javanese', kn: 'Kannada', kk: 'Kazakh', km: 'Khmer', ko: 'Korean', lo: 'Lao', la: 'Latin',
+ lv: 'Latvian', ln: 'Lingala', lt: 'Lithuanian', lb: 'Luxembourgish', mk: 'Macedonian',
+ mg: 'Malagasy', ms: 'Malay', ml: 'Malayalam', mt: 'Maltese', mi: 'Maori', mr: 'Marathi',
+ mn: 'Mongolian', ne: 'Nepali', no: 'Norwegian', nn: 'Nynorsk', oc: 'Occitan', ps: 'Pashto',
+ fa: 'Persian', pl: 'Polish', pt: 'Portuguese', pa: 'Punjabi', ro: 'Romanian', ru: 'Russian',
+ sa: 'Sanskrit', sr: 'Serbian', sn: 'Shona', sd: 'Sindhi', si: 'Sinhala', sk: 'Slovak',
+ sl: 'Slovenian', so: 'Somali', es: 'Spanish', su: 'Sundanese', sw: 'Swahili', sv: 'Swedish',
+ tl: 'Tagalog', tg: 'Tajik', ta: 'Tamil', tt: 'Tatar', te: 'Telugu', th: 'Thai', bo: 'Tibetan',
+ tr: 'Turkish', tk: 'Turkmen', uk: 'Ukrainian', ur: 'Urdu', uz: 'Uzbek', vi: 'Vietnamese',
+ cy: 'Welsh', yi: 'Yiddish', yo: 'Yoruba',
+}).map(([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name));
diff --git a/frontend/index.html b/frontend/index.html
index e39d819..9fe5dfe 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SubRead — read along with your audiobook</title>
-<link rel="stylesheet" href="/style.css?v=9">
+<link rel="stylesheet" href="/style.css?v=10">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>">
</head>
<body>
@@ -31,7 +31,8 @@
<p>
Drop in an audiobook and the ebook it was read from. You get back subtitles
timed to the narration, with the wording taken from your own book — not
- from a machine's guess at what it heard.
+ from a machine's guess at what it heard. It all happens in this tab:
+ <strong>your files are never uploaded</strong>.
</p>
<ul class="uses">
<li><strong>HoshiReader read-along</strong> — the .srt timing file, to match
@@ -99,7 +100,7 @@
<div id="uploading" class="panel uploading" hidden>
<div class="spinner" aria-hidden="true"></div>
<div class="up-body">
- <p class="up-title">Uploading & analysing…</p>
+ <p class="up-title">Getting ready…</p>
<div class="bar"><div id="upbar" class="bar-fill"></div></div>
<p id="uptext" class="muted small">0%</p>
</div>
@@ -159,7 +160,23 @@
<p class="muted small" id="eta"></p>
</section>
- <!-- step 3: jobs -->
+ <!-- step 3: the job, running in this tab -->
+ <section id="working" class="panel working" hidden>
+ <p class="up-title" id="work-title">Starting</p>
+ <div class="bar"><div id="work-bar" class="bar-fill"></div></div>
+ <p id="work-meta" class="muted small"></p>
+ <div class="actions">
+ <button id="stop" class="ghost">Stop (progress is kept)</button>
+ </div>
+ <div id="results" hidden>
+ <div id="result-files" class="job-files"></div>
+ <p class="muted small">Videos are made here when you ask for them, in seconds.
+ They are not kept anywhere: make them before closing this tab.</p>
+ <div class="actions"><button id="another" class="ghost">Convert another book</button></div>
+ </div>
+ </section>
+
+ <!-- step 4: history -->
<section id="jobs-section" hidden>
<h2 class="section-title">Your conversions</h2>
<ul id="jobs" class="jobs"></ul>
@@ -168,7 +185,8 @@
<footer>
<p class="muted small">
- Alignment by <a href="https://github.com/kanjieater/SubPlz" target="_blank" rel="noopener">SubPlz</a>.
+ Alignment method from <a href="https://github.com/kanjieater/SubPlz" target="_blank" rel="noopener">SubPlz</a>;
+ speech model <a href="https://github.com/openai/whisper" target="_blank" rel="noopener">Whisper</a>.
Every line of text comes from the book you upload, so names and spelling stay right.
</p>
</footer>
@@ -199,6 +217,6 @@
<div id="toast" class="toast" hidden role="status"></div>
-<script src="/app.js?v=9"></script>
+<script type="module" src="/app.js?v=10"></script>
</body>
</html>
diff --git a/frontend/style.css b/frontend/style.css
index 831fe42..7f362c2 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -277,6 +277,15 @@ button.ghost:hover { color: var(--ink); border-color: var(--muted); }
.dl.secondary { background: transparent; color: var(--muted); border-color: var(--line); }
.dl.secondary:hover { color: var(--ink); border-color: var(--muted); }
+/* ---------- the running job ---------- */
+.working .up-title { margin: 0 0 10px; font-weight: 600; }
+.working .job-files { margin-top: 16px; }
+button.dl { font: inherit; font-weight: 500; cursor: pointer; border: 1px solid transparent; border-radius: 9px; }
+button.dl:disabled { opacity: .6; cursor: progress; }
+.bar.indeterminate .bar-fill { width: 35% !important; animation: slide 1.4s ease-in-out infinite; }
+@keyframes slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
+@media (prefers-reduced-motion: reduce) { .bar.indeterminate .bar-fill { animation: none; margin-left: 0; } }
+
/* ---------- account bar ---------- */
.account { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.who { color: var(--muted); font-size: 13px; max-width: 220px;
diff --git a/tests/test_local_jobs.py b/tests/test_local_jobs.py
new file mode 100644
index 0000000..b5b3dae
--- /dev/null
+++ b/tests/test_local_jobs.py
@@ -0,0 +1,122 @@
+"""Jobs that run in the visitor's browser: the server only keeps the books."""
+
+from __future__ import annotations
+
+import time
+from datetime import timedelta
+
+from backend import api
+from backend.db import Job, JobStatus, SessionLocal, utcnow
+
+from .conftest import account_id, checkout_event, get_job_row, post_webhook
+
+BOOK = {
+ "audio_filename": "book.m4b", "audio_bytes": 123456, "audio_duration_seconds": 3600.0,
+ "text_filename": "book.epub", "language": "ja",
+}
+SRT = "1\n00:00:00,000 --> 00:00:01,000\nこんにちは\n\n"
+
+
+def begin(client, **over):
+ return client.post("/api/local/jobs", json={**BOOK, **over})
+
+
+def buy(client, plan="single", email="local@example.com"):
+ sid = f"cs_{time.time_ns()}"
+ assert post_webhook(client, checkout_event(sid, account_id(client), plan, email=email)).status_code == 200
+
+
+def test_free_job_runs_finishes_and_keeps_its_subtitles(client):
+ r = begin(client)
+ assert r.status_code == 200
+ job = r.json()
+ assert job["local"] is True and job["status"] == "running" and job["tier"] == "free"
+
+ done = client.post(f"/api/local/jobs/{job['id']}/finish",
+ json={"srt": SRT, "filename": "book.ja.srt", "metadata": {"cues": 1}})
+ assert done.status_code == 200 and done.json()["status"] == "succeeded"
+ kinds = {a["kind"] for a in done.json()["artifacts"]}
+ assert kinds == {"srt", "metadata"}
+
+ got = client.get(f"/api/jobs/{job['id']}/files/srt")
+ assert got.status_code == 200 and got.content.decode("utf-8") == SRT
+ assert job["id"] in {j["id"] for j in client.get("/api/jobs").json()}
+
+
+def test_free_window_applies_to_browser_jobs_too(client):
+ assert begin(client).status_code == 200
+ second = begin(client, audio_filename="another.m4b", audio_bytes=999)
+ assert second.status_code == 402
+
+
+def test_reopening_the_same_book_does_not_charge_twice(client):
+ first = begin(client).json()
+ again = begin(client) # closed tab, same files
+ assert again.status_code == 200 and again.json()["id"] == first["id"]
+ with SessionLocal() as s:
+ assert s.query(Job).filter(Job.account_id == account_id(client)).count() == 1
+
+
+def test_youtube_tier_costs_a_credit_and_a_failure_returns_it(client):
+ assert begin(client, tier="youtube").status_code == 402
+
+ buy(client)
+ r = begin(client, tier="youtube")
+ assert r.status_code == 200 and r.json()["tier"] == "youtube"
+ assert client.get("/api/account").json()["credits"] == 0
+
+ failed = client.post(f"/api/local/jobs/{r.json()['id']}/fail", json={"error": "GPU lost"})
+ assert failed.status_code == 200 and failed.json()["status"] == "failed"
+ assert client.get("/api/account").json()["credits"] == 1
+ # And the free book was never touched.
+ assert client.get("/api/account").json()["free_remaining"] == 1
+
+
+def test_upgrading_a_running_free_job_to_youtube(client):
+ job = begin(client).json()
+ assert begin(client, tier="youtube").status_code == 402 # no credit yet
+ buy(client, email="upgrade@example.com")
+ up = begin(client, tier="youtube")
+ assert up.status_code == 200 and up.json()["id"] == job["id"] and up.json()["tier"] == "youtube"
+ assert client.get("/api/account").json()["credits"] == 0
+
+
+def test_finish_is_once_only_and_owner_only(client, second_client):
+ job = begin(client).json()
+ body = {"srt": SRT, "filename": "x.srt"}
+ assert second_client.post(f"/api/local/jobs/{job['id']}/finish", json=body).status_code == 404
+ assert client.post(f"/api/local/jobs/{job['id']}/finish", json=body).status_code == 200
+ assert client.post(f"/api/local/jobs/{job['id']}/finish", json=body).status_code == 409
+ assert client.post(f"/api/local/jobs/{job['id']}/fail", json={}).status_code == 409
+
+
+def test_filename_cannot_escape_the_job_directory(client):
+ job = begin(client).json()
+ r = client.post(f"/api/local/jobs/{job['id']}/finish",
+ json={"srt": SRT, "filename": "../../../etc/passwd"})
+ assert r.status_code == 200
+ srt = next(a for a in r.json()["artifacts"] if a["kind"] == "srt")
+ assert srt["filename"] == "passwd"
+
+
+def test_abandoned_jobs_release_what_they_held(client):
+ job = begin(client).json()
+ assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 402
+ with SessionLocal() as s:
+ row = s.get(Job, job["id"])
+ row.created_at = utcnow() - timedelta(hours=72)
+ s.commit()
+ assert api.expire_stale_local_jobs(s) >= 1
+ assert get_job_row(job["id"]).status == JobStatus.canceled
+ assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 200
+
+
+def test_restart_does_not_queue_browser_jobs(client, monkeypatch):
+ from backend import main
+
+ job = begin(client).json()
+ queued = []
+ monkeypatch.setattr(main.queue, "enqueue", queued.append)
+ main._requeue_interrupted()
+ assert job["id"] not in queued
+ assert get_job_row(job["id"]).status == JobStatus.running