Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


commit a33e06b36de74348b1493d6b4c8eaa79a0f81eb9
equwal <truex@equwal.com>
2026-09-22 18:18:34 -0700

Keep a copy of the input of browser jobs, and tell users so

The owner wants the files of each conversion on the server for debugging
during the beta, including the conversions that run in the browser.

- During the beta (SUBPLZ_WEB_BROWSER_COPY, on by default), the page
  sends a copy of the audio and the book of a browser job to POST
  /api/local/jobs/{id}/files. The copy goes to data/work/<job id>/input/,
  and the finished subtitles go next to it. The copy costs no credit.
  Only the owner of the job can send it, a file name cannot leave the
  folder of the job, and a server job refuses it.
- The page footer says that user data is kept as the privacy and user
  data policy says, and links to the new section "Input data kept for
  debugging (beta)" of /terms.html.
- The page no longer says that files are never uploaded. The note under
  the switch says that a copy goes to the server during the beta.

 README.md                  |  8 +++++
 backend/api.py             | 46 +++++++++++++++++++++++++
 backend/settings.py        |  5 +++
 frontend/app.js            | 17 +++++++--
 frontend/index.html        |  9 +++--
 frontend/terms.html        | 17 +++++++--
 tests/test_browser_copy.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_page.py         | 13 +++++++
 8 files changed, 194 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index aaed18c..bf1dd17 100644
--- a/README.md
+++ b/README.md
@@ -396,6 +396,14 @@ The server keeps each upload and each result, for debugging. Nothing deletes
 them automatically: the operator deletes them by hand, and on request.
 `/terms.html` says so to the visitor, and shows `SUBPLZ_WEB_CONTACT_EMAIL`.
 
+During the beta (`SUBPLZ_WEB_BROWSER_COPY`, on by default), a job in the
+browser also sends a copy of its audio and its book to
+`POST /api/local/jobs/{id}/files`. The copy lands in `data/work/<job id>/input/`,
+and the finished subtitles go next to it, so the files of every job are
+together. The copy costs no credit. The page footer and the section "Input
+data kept for debugging" of `/terms.html` say so to the visitor. Set the
+variable to false after the beta, and change that policy and the page with it.
+
 `SUBPLZ_WEB_CLOUD_ENABLED` says that fast conversion is on offer. While it is
 false the page shows no way to buy credits, whatever `SUBPLZ_WEB_BILLING_ENABLED`
 says: credits that buy nothing must not be for sale.
diff --git a/backend/api.py b/backend/api.py
index 5211e66..8ae2ed2 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -13,6 +13,7 @@ checkout, its return trip and its webhook).
 
 from __future__ import annotations
 
+import re
 from pathlib import Path
 from typing import Annotated, Literal
 
@@ -192,6 +193,9 @@ class AccountOut(BaseModel):
     subscription_ends: str | None
     cloud_allowed: bool
     queue_depth: int
+    # During the beta, the page sends a copy of the files of a job in the
+    # browser to the server (settings.browser_copy).
+    browser_copy: bool = False
 
 
 class LocalJobIn(BaseModel):
@@ -324,6 +328,7 @@ def _account_out(session: Session, account: Account) -> AccountOut:
         cloud_available=settings.cloud_enabled,
         cloud_allowed=ent.cloud_allowed or not settings.billing_enabled,
         queue_depth=queue.depth(),
+        browser_copy=settings.browser_copy,
     )
 
 
@@ -762,12 +767,53 @@ def finish_local_job(
             session.add(Artifact(job_id=job.id, kind=kind, filename=filename,
                                  storage_key=key, size_bytes=size))
 
+    if settings.browser_copy:
+        # The subtitles go next to the copy of the audio and the book.
+        root = Paths.for_job(job.id).root
+        root.mkdir(parents=True, exist_ok=True)
+        (root / (_plain_name(body.filename) or "subtitles.srt")).write_bytes(
+            body.srt.encode("utf-8"))
+
     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))
 
 
+def _plain_name(raw: str) -> str:
+    """The last part of a file name from a browser, with no folder in it."""
+    name = re.split(r"[\\/]", raw or "")[-1].strip()
+    return "" if name in ("", ".", "..") else name
+
+
+@router.post("/local/jobs/{job_id}/files")
+async def copy_local_job_files(
+    job_id: str,
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+    files: Annotated[list[UploadFile], File()],
+):
+    """Keep a copy of the audio and the book of a job in the browser.
+
+    Only during the beta (settings.browser_copy), for debugging, as the
+    privacy and user data policy says. The copy goes with the other files of
+    the job, and it costs no credit.
+    """
+    if not settings.browser_copy:
+        raise HTTPException(404, "Not found")
+    job = _load(session, account, job_id)
+    if not job.local:
+        raise HTTPException(409, "A server job has its files already.")
+    inp = Paths.for_job(job.id).inp
+    saved = 0
+    for upload in files:
+        name = _plain_name(upload.filename or "")
+        if name:
+            await _save(upload, inp / name)
+            saved += 1
+    return {"saved": saved}
+
+
 @router.post("/local/jobs/{job_id}/fail", response_model=JobOut)
 def fail_local_job(
     job_id: str,
diff --git a/backend/settings.py b/backend/settings.py
index 217a270..173da8e 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -150,6 +150,11 @@ class Settings(BaseSettings):
 
     # --- uploads -----------------------------------------------------------
     max_upload_bytes: int = 2 * 1024 * 1024 * 1024  # 2 GiB
+    # During the beta, a job in the browser sends a copy of its audio and its
+    # book to the server, for debugging, as the privacy and user data policy
+    # says (terms.html, "Input data kept for debugging"). Set false after the
+    # beta, and change that policy and the page at the same time.
+    browser_copy: bool = True
 
     @property
     def payments_configured(self) -> bool:
diff --git a/frontend/app.js b/frontend/app.js
index 01c149a..4cdc055 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -340,7 +340,7 @@ async function showConfirm() {
   browserPlan = {
     note: (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. ' +
+      'Everything runs in this tab, 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.'),
     label: saved?.doneUntil && !saved.complete ? 'Continue' : 'Start',
@@ -392,7 +392,9 @@ function renderMode() {
   el.inBrowser.disabled = !offered;
   if (!offered) el.inBrowser.checked = true;
   if (inBrowser()) {
-    el.modeNote.textContent = 'Uses no credit, and has no limit. Your files are not uploaded.' +
+    const copy = account?.browser_copy
+      ? ' During the beta, a copy of your files goes to our server, for debugging.' : '';
+    el.modeNote.textContent = 'Uses no credit, and has no limit.' + copy +
       (offered ? ` Turn this off to have our server do the work instead${serverCost()}.` : '');
     el.start.textContent = browserPlan.label;
     el.eta.textContent = browserPlan.note +
@@ -493,6 +495,7 @@ el.start.addEventListener('click', async () => {
       text_filename: draft.book.name, language,
     }),
   }).catch(() => null);
+  if (registered && account?.browser_copy) sendCopy(registered.id, draft.audio, draft.book);
   const report = (path, body) => (registered
     ? api(`/api/local/jobs/${registered.id}/${path}`, {
       method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
@@ -542,6 +545,16 @@ el.start.addEventListener('click', async () => {
 
 el.stop.addEventListener('click', () => { running?.job.cancel(); el.stop.disabled = true; });
 
+/* During the beta, a copy of the audio and the book goes to the server, for
+   debugging, as the privacy and user data policy says. It runs beside the job
+   and costs no credit. If it fails, nothing changes for the visitor. */
+function sendCopy(jobId, audio, book) {
+  const form = new FormData();
+  for (const f of [...audio, book]) form.append('files', f, f.name);
+  fetch(`/api/local/jobs/${jobId}/files`, { method: 'POST', body: form, credentials: 'same-origin' })
+    .catch(() => { /* a copy for debugging only */ });
+}
+
 function renderWorking(st) {
   el.workTitle.textContent = st.detail;
   el.workBar.style.width = `${Math.round((st.fraction || 0) * 100)}%`;
diff --git a/frontend/index.html b/frontend/index.html
index 0eecd9d..90f28a4 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -40,8 +40,7 @@
     <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. It all happens in this tab:
-      <strong>your files are never uploaded</strong>.
+      from a machine's guess at what it heard. The work happens in this tab, on your computer.
     </p>
     <ul class="uses">
       <li><strong>HoshiReader read-along</strong> โ€” the .srt timing file, to match
@@ -64,7 +63,7 @@
       <span><strong>Convert in this browser (JavaScript)</strong>
         <span class="tag free">free</span></span>
     </label>
-    <p class="muted small" id="mode-note">Free, without limit, and your files are not uploaded.</p>
+    <p class="muted small" id="mode-note">Uses no credit, and has no limit.</p>
   </section>
 
   <!-- step 1: drop -->
@@ -197,6 +196,10 @@
     <a href="https://github.com/equwal/subread-android/releases/latest" target="_blank" rel="noopener">Android app</a> ยท
     <a href="https://ko-fi.com/truex" target="_blank" rel="noopener">Support on Ko-fi</a>
   </p>
+  <p class="muted small" id="data-notice">
+    We keep user data as our <a href="/terms.html#debug-data">privacy and user data policy</a> says.
+    During the beta, that includes the audio and the book of each conversion, for debugging.
+  </p>
 </footer>
 
 <dialog id="signin-dialog" class="modal">
diff --git a/frontend/terms.html b/frontend/terms.html
index 8dca898..c1a5e90 100644
--- a/frontend/terms.html
+++ b/frontend/terms.html
@@ -21,8 +21,9 @@
   <p>SubRead lines an audiobook up with its ebook and gives you timed subtitles, videos and a
     read-along book. All of the code is public, and you may run it yourself.</p>
   <ul>
-    <li><strong>In your browser (free):</strong> the work is done on your machine. Your audio and
-      your book are not uploaded. The server gets the names and sizes of the files and the
+    <li><strong>In your browser (free):</strong> the work is done on your machine, and it uses no
+      credit. During the beta, the page also sends a copy of your audio and your book to our server
+      (see <a href="#debug-data">Input data kept for debugging</a>). The server also gets the
       finished subtitles, so that this page can list your conversions.</li>
     <li><strong>On our server (credits):</strong> you upload the audio and the book, and the server
       does the work. One credit converts one book. New visitors get some free credits to try it.</li>
@@ -40,6 +41,18 @@
       and they are not used to train anything. There is no public library of uploads.</li>
   </ul>
 
+  <h2 id="debug-data">Input data kept for debugging (beta)</h2>
+  <ul>
+    <li>During the beta, we keep the input data of each conversion on our server: the audio and the
+      book. This is also true for a conversion in your browser: while it converts, the page sends a
+      copy of your audio and your book to our server.</li>
+    <li>We keep the input data together with the results of the conversion, so that we can find and
+      fix a fault that you report to us. We use it for nothing else. We do not share it, and we do
+      not use it to train anything.</li>
+    <li>Nothing deletes the input data automatically: we delete it by hand. Write to us to have your
+      data deleted.</li>
+  </ul>
+
   <h2>Payments and refunds</h2>
   <ul>
     <li>Payments are taken by Stripe. We never see your card.</li>
diff --git a/tests/test_browser_copy.py b/tests/test_browser_copy.py
new file mode 100644
index 0000000..e476950
--- /dev/null
+++ b/tests/test_browser_copy.py
@@ -0,0 +1,86 @@
+"""During the beta, a job in the browser also sends a copy of its audio and its
+book to the server, as the privacy and user data policy says (terms.html,
+"Input data kept for debugging"). The server keeps the copy with the
+subtitles, in the folder of the job. A job in the browser never spends a
+credit, with or without the copy.
+"""
+
+from __future__ import annotations
+
+from backend.runner import Paths
+from backend.settings import settings
+
+from .conftest import make_job
+
+BOOK = {
+    "audio_filename": "copy.m4b", "audio_bytes": 11,
+    "text_filename": "copy.epub", "language": "en",
+}
+SRT = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n"
+
+
+def begin(client) -> dict:
+    r = client.post("/api/local/jobs", json=BOOK)
+    assert r.status_code == 200
+    return r.json()
+
+
+def send_copy(client, job_id: str, files: list[tuple[str, bytes]]):
+    return client.post(
+        f"/api/local/jobs/{job_id}/files",
+        files=[("files", (name, data, "application/octet-stream")) for name, data in files],
+    )
+
+
+def test_the_page_is_told_to_send_a_copy(client):
+    assert client.get("/api/account").json()["browser_copy"] is True
+
+
+def test_a_browser_job_keeps_audio_book_and_subtitles_together(client):
+    job = begin(client)
+    r = send_copy(client, job["id"], [("copy.m4b", b"audio"), ("copy.epub", b"book")])
+    assert r.status_code == 200 and r.json() == {"saved": 2}
+
+    done = client.post(f"/api/local/jobs/{job['id']}/finish",
+                       json={"srt": SRT, "filename": "copy.en.srt"})
+    assert done.status_code == 200
+
+    root = Paths.for_job(job["id"]).root
+    assert (root / "input" / "copy.m4b").read_bytes() == b"audio"
+    assert (root / "input" / "copy.epub").read_bytes() == b"book"
+    assert (root / "copy.en.srt").read_bytes() == SRT.encode("utf-8")
+
+
+def test_a_browser_job_and_its_copy_spend_no_credit(client, monkeypatch):
+    monkeypatch.setattr(settings, "cloud_enabled", True)  # the free credit exists
+    before = client.get("/api/account").json()["credits"]
+    job = begin(client)
+    assert send_copy(client, job["id"], [("copy.m4b", b"audio")]).status_code == 200
+    assert client.get("/api/account").json()["credits"] == before == 1
+
+
+def test_only_the_owner_can_send_a_copy(client, second_client):
+    job = begin(client)
+    assert send_copy(second_client, job["id"], [("copy.m4b", b"x")]).status_code == 404
+
+
+def test_a_file_name_cannot_leave_the_folder_of_the_job(client):
+    job = begin(client)
+    r = send_copy(client, job["id"], [("../../escape.m4b", b"x"), ("..\\..\\win.epub", b"y")])
+    assert r.status_code == 200
+    inp = Paths.for_job(job["id"]).inp
+    assert (inp / "escape.m4b").read_bytes() == b"x"
+    assert (inp / "win.epub").read_bytes() == b"y"
+    assert not (inp.parent.parent / "escape.m4b").exists()
+
+
+def test_no_copy_when_the_beta_setting_is_off(client, monkeypatch):
+    monkeypatch.setattr(settings, "browser_copy", False)
+    assert client.get("/api/account").json()["browser_copy"] is False
+    job = begin(client)
+    assert send_copy(client, job["id"], [("copy.m4b", b"x")]).status_code == 404
+
+
+def test_a_server_job_takes_no_copy(client):
+    job_id = make_job(client)  # a server job: its files came with the upload
+    assert send_copy(client, job_id, [("copy.m4b", b"x")]).status_code == 409
diff --git a/tests/test_page.py b/tests/test_page.py
index 809e944..20c560c 100644
--- a/tests/test_page.py
+++ b/tests/test_page.py
@@ -27,3 +27,16 @@ def test_the_browser_mode_says_that_it_uses_no_credit(client):
     app = client.get("/app.js").text
     assert "'Uses no credit, and has no limit." in app
     assert "do the work.' + serverPrice()" not in app
+
+
+def test_the_page_tells_how_it_keeps_user_data(client):
+    # During the beta a job in the browser sends a copy of its files to the
+    # server (settings.browser_copy). The page must say so, point to the
+    # policy, and make no promise that the copy breaks.
+    page = client.get("/").text
+    app = client.get("/app.js").text
+    notice = re.search(r'<p[^>]*id="data-notice"[^>]*>(.*?)</p>', page, re.S)
+    assert notice and 'href="/terms.html#debug-data"' in notice.group(1)
+    assert 'id="debug-data"' in client.get("/terms.html").text
+    for promise in ("never uploaded", "not uploaded", "nothing is uploaded"):
+        assert promise not in page and promise not in app, promise