Recently Written · git

subplz-web

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

Log | Files | Refs


commit 3298922ae53795654adf908ff46691bdb55c8a3d
equwal <truex@equwal.com>
2026-09-20 15:38:12 -0700

Custom cover image for the video, gated behind sign-in

Drop a jpg/png alongside the audiobook and it becomes the video background
instead of the epub's own cover or a plain card.

Signed-in only, and said so before anyone picks a file rather than after:
the drop zone carries "sign in required", the client refuses to attach the
image and explains why, and the server repeats the refusal in
cover_requires_sign_in as a backstop. A visitor who is not signed in still
gets their free book - only the cover is withheld - and nobody waits through
a render to discover the feature was not available.

Account.signed_in is email-based, so it flips on as soon as real auth exists.

 backend/api.py      | 28 ++++++++++++++++++++++++++--
 backend/db.py       | 13 +++++++++++++
 backend/detect.py   | 11 ++++++++++-
 backend/runner.py   | 20 +++++++++++++++++++-
 frontend/app.js     | 44 +++++++++++++++++++++++++++++++++++++++++---
 frontend/index.html | 16 +++++++++++++---
 6 files changed, 122 insertions(+), 10 deletions(-)
diff --git a/backend/api.py b/backend/api.py
index c0347e3..59ca4ea 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -27,8 +27,8 @@ from .aligner import aligner
 from .db import Account, Artifact, Job, JobStatus, SessionLocal, new_id, utcnow
 from .queue import queue
 from .runner import (
-    Paths, input_prefix, probe_duration, staged_audio_path, staged_part_path,
-    staged_text_path,
+    Paths, input_prefix, probe_duration, staged_audio_path, staged_cover_path,
+    staged_part_path, staged_text_path,
 )
 from .settings import settings
 from .storage import LocalStorage, storage
@@ -118,6 +118,7 @@ class JobOut(BaseModel):
     model: str
     audio_filename: str
     audio_parts: int
+    cover_filename: str | None
     text_filename: str
     audio_bytes: int
     audio_duration_seconds: float | None
@@ -129,6 +130,8 @@ class JobOut(BaseModel):
 class UploadOut(BaseModel):
     job: JobOut
     detected: DetectionOut
+    # Set when an image was dropped but the visitor is not signed in.
+    cover_requires_sign_in: bool = False
     # How well the book scores against a sample of the audio, using the
     # backend's own rule. None when the check is disabled.
     match: dict | None = None
@@ -141,6 +144,8 @@ class StartIn(BaseModel):
 
 class AccountOut(BaseModel):
     id: str
+    signed_in: bool
+    email: str | None
     billing_enabled: bool
     free_allowance: int
     free_window_hours: int
@@ -167,6 +172,7 @@ def _job_out(job: Job, arts: list[Artifact]) -> JobOut:
         model=job.model,
         audio_filename=job.audio_filename,
         audio_parts=job.audio_parts or 1,
+        cover_filename=job.cover_filename,
         text_filename=job.text_filename,
         audio_bytes=job.audio_bytes,
         audio_duration_seconds=job.audio_duration_seconds,
@@ -221,6 +227,8 @@ def get_account_info(
     ent = billing.check(session, account)
     return AccountOut(
         id=account.id,
+        signed_in=account.signed_in,
+        email=account.email,
         billing_enabled=settings.billing_enabled,
         free_allowance=ent.free_allowance,
         free_window_hours=ent.window_hours,
@@ -304,6 +312,20 @@ async def create_upload(
         if not audio_paths:
             raise HTTPException(400, "Upload did not include any audio.")
 
+        # Custom cover art is a signed-in feature. Say so plainly rather than
+        # accepting the file and quietly ignoring it: the UI labels the drop
+        # zone before anyone picks an image, and this is the backstop.
+        cover_name = None
+        cover_blocked = False
+        if pairing.cover_name:
+            if account.signed_in:
+                upload = by_name.get(pairing.cover_name)
+                if upload is not None:
+                    await _save(upload, staged_cover_path(job_id, pairing.cover_name))
+                    cover_name = pairing.cover_name
+            else:
+                cover_blocked = True
+
         detection = detect.detect_language(detect.extract_text_sample(text_path))
         # Parts are merged at run time, so total the durations here.
         durations = [probe_duration(p) for p in audio_paths]
@@ -331,6 +353,7 @@ async def create_upload(
             audio_filename=pairing.display_name,
             text_filename=pairing.text_name,
             audio_parts=len(audio_paths),
+            cover_filename=cover_name,
             audio_bytes=sum(p.stat().st_size for p in audio_paths),
             audio_duration_seconds=duration,
             stage="Ready to start",
@@ -356,6 +379,7 @@ async def create_upload(
             supported=detection.supported,
         ),
         match=match.as_dict(),
+        cover_requires_sign_in=cover_blocked,
     )
 
 
diff --git a/backend/db.py b/backend/db.py
index da6f9fe..6167d4d 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -74,6 +74,16 @@ class Account(Base):
 
     jobs: Mapped[list["Job"]] = relationship(back_populates="account")
 
+    @property
+    def signed_in(self) -> bool:
+        """Whether this is a real account rather than an anonymous cookie.
+
+        Gates the features that cost us money or need someone to bill. An
+        anonymous visitor still gets their free book; they just cannot attach
+        a custom cover.
+        """
+        return bool(self.email)
+
 
 class Job(Base):
     __tablename__ = "jobs"
@@ -96,6 +106,9 @@ class Job(Base):
     # 1 for a single file; higher when the book arrived as per-chapter parts
     # that get merged before alignment.
     audio_parts: Mapped[int] = mapped_column(Integer, default=1)
+    # Custom cover art for the rendered video. Requires a signed-in account;
+    # anonymous jobs fall back to the epub's own cover.
+    cover_filename: Mapped[str | None] = mapped_column(String(512), nullable=True)
     audio_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
     audio_duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
 
diff --git a/backend/detect.py b/backend/detect.py
index 152221c..d548d14 100644
--- a/backend/detect.py
+++ b/backend/detect.py
@@ -27,12 +27,18 @@ class DetectionError(ValueError):
     pass
 
 
+# Cover art for the rendered video. Optional, and gated behind sign-in.
+IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
+
+
 @dataclass(frozen=True)
 class Pairing:
     # One entry for a single-file audiobook, many for a per-chapter set, in
     # playback order.
     audio_names: list[str]
     text_name: str
+    # A picture to put behind the video, if one was dropped in.
+    cover_name: str | None = None
 
     @property
     def is_multipart(self) -> bool:
@@ -56,9 +62,10 @@ def natural_key(name: str) -> tuple:
 
 
 def classify(filenames: list[str]) -> Pairing:
-    """Split dropped filenames into the audio part(s) and the one text file."""
+    """Split dropped filenames into the audio part(s), the text, and any cover."""
     audio = [n for n in filenames if Path(n).suffix.lower() in AUDIO_SUFFIXES]
     text = [n for n in filenames if Path(n).suffix.lower() in TEXT_SUFFIXES]
+    images = [n for n in filenames if Path(n).suffix.lower() in IMAGE_SUFFIXES]
 
     if not audio:
         raise DetectionError(
@@ -84,6 +91,8 @@ def classify(filenames: list[str]) -> Pairing:
     return Pairing(
         audio_names=sorted(audio, key=natural_key),
         text_name=text[0],
+        # Last one wins, so re-dropping an image replaces the previous choice.
+        cover_name=images[-1] if images else None,
     )
 
 
diff --git a/backend/runner.py b/backend/runner.py
index 871a48a..2114220 100644
--- a/backend/runner.py
+++ b/backend/runner.py
@@ -109,6 +109,23 @@ def staged_text_path(job_id: str, original_name: str) -> Path:
     return Paths.for_job(job_id).inp / f"{STAGE_STEM}{text_suffix(original_name)}"
 
 
+def staged_cover_path(job_id: str, original_name: str) -> Path:
+    """Where a user-supplied cover image is staged.
+
+    Kept out of the `input/` root alongside audio and text so the aligner's
+    directory scan cannot mistake a picture for content.
+    """
+    inp = Paths.for_job(job_id).inp
+    return inp / "cover" / f"cover{Path(original_name).suffix.lower()}"
+
+
+def find_cover(job_id: str) -> Path | None:
+    folder = Paths.for_job(job_id).inp / "cover"
+    if not folder.is_dir():
+        return None
+    return next((p for p in sorted(folder.iterdir()) if p.is_file()), None)
+
+
 def staged_part_path(job_id: str, index: int, original_name: str) -> Path:
     """Where chapter file `index` (1-based) of a multi-part audiobook is staged.
 
@@ -504,7 +521,8 @@ def _collect_artifacts(job_id: str, paths: Paths, log_path: Path,
         try:
             _set(job_id, stage="Rendering video", progress=0.94)
             scratch = paths.root / "video"
-            cover = render.extract_cover(request.text, scratch)
+            # A cover the user supplied wins over the one inside the epub.
+            cover = find_cover(job_id) or render.extract_cover(request.text, scratch)
             canvas = render.build_canvas(cover, scratch / "canvas.png")
 
             video_name = f"{stem}.{language}.mp4"
diff --git a/frontend/app.js b/frontend/app.js
index 1541fc1..ffe70d7 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -88,6 +88,14 @@ function setLanguage(code) {
 async function refreshQuota() {
   try {
     const a = await api('/api/account');
+    signedIn = !!a.signed_in;
+    const hint = document.getElementById('cover-hint');
+    if (hint) {
+      hint.innerHTML = signedIn
+        ? 'Optional: drop a jpg or png to use as the video background'
+        : 'Optional: drop a jpg or png to use as the video background — ' +
+          '<strong>sign in required</strong>';
+    }
     if (a.free_tier_summary && el.freetier) {
       el.freetier.textContent = a.free_tier_summary.replace(/^./, (c) => c.toUpperCase()) + '.';
     }
@@ -110,16 +118,28 @@ async function refreshQuota() {
    inside audiobook folders. */
 const AUDIO_RE = /\.(m4b|m4a|mp3|opus|ogg|oga|flac|wav|aac|wma|mka|mkv|mp4|webm|avi|mov)$/i;
 const TEXT_RE = /(\.fb2\.zip|\.(epub|txt|srt|vtt|ass|fb2|mobi|azw|azw3|prc))$/i;
+const IMAGE_RE = /\.(jpe?g|png|webp|bmp)$/i;
 
-const staged = { audio: [], text: null };
+const staged = { audio: [], text: null, cover: null };
+let signedIn = false;
 
 function addFiles(files) {
   clearError();
   const list = [...files];
   const audio = list.filter((f) => AUDIO_RE.test(f.name));
   const text = list.filter((f) => TEXT_RE.test(f.name));
+  const images = list.filter((f) => IMAGE_RE.test(f.name));
+
+  // Say up front that a cover needs an account, rather than accepting the file
+  // and springing it on them after they have waited for a render.
+  if (images.length && !signedIn) {
+    showError('Using your own cover image needs an account — sign in first, ' +
+              'and it will be applied to the video. The rest works without one.');
+  } else if (images.length) {
+    staged.cover = images[images.length - 1];
+  }
 
-  if (!audio.length && !text.length) {
+  if (!audio.length && !text.length && !images.length) {
     showError('Nothing usable in that drop — expected an audiobook or an ebook.');
     return;
   }
@@ -134,6 +154,7 @@ function addFiles(files) {
   if (text.length) staged.text = text[text.length - 1];
 
   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]);
   }
@@ -151,6 +172,13 @@ function renderStaged() {
         : staged.audio[0].name)
     : 'Waiting for an audio file';
 
+  const cv = $('slot-cover');
+  cv.hidden = !staged.cover;
+  if (staged.cover) {
+    cv.classList.add('filled');
+    cv.querySelector('.slot-value').textContent = staged.cover.name;
+  }
+
   const t = $('slot-text');
   t.classList.toggle('filled', !!staged.text);
   t.querySelector('.slot-value').textContent =
@@ -169,12 +197,14 @@ function renderStaged() {
 function clearStaged() {
   staged.audio = [];
   staged.text = null;
+  staged.cover = null;
   renderStaged();
 }
 
 document.querySelectorAll('.slot-x').forEach((btn) => {
   btn.addEventListener('click', () => {
     if (btn.dataset.slot === 'audio') staged.audio = [];
+    else if (btn.dataset.slot === 'cover') staged.cover = null;
     else staged.text = null;
     renderStaged();
   });
@@ -193,6 +223,7 @@ function uploadFiles(files) {
 
   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();
@@ -211,7 +242,14 @@ function uploadFiles(files) {
   xhr.onload = () => {
     el.uploading.hidden = true;
     if (xhr.status >= 200 && xhr.status < 300) {
-      try { showConfirm(JSON.parse(xhr.responseText)); }
+      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}).`;
diff --git a/frontend/index.html b/frontend/index.html
index 3a3bc72..1f03cdf 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>SubPlz — Audiobook to Subtitles</title>
-<link rel="stylesheet" href="/style.css?v=6">
+<link rel="stylesheet" href="/style.css?v=8">
 <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>
@@ -37,7 +37,7 @@
   <section id="dropzone" class="dropzone" tabindex="0" role="button"
            aria-label="Drop an audio file and an ebook, or click to choose files">
     <input type="file" id="filepicker" multiple hidden
-           accept=".m4b,.m4a,.mp3,.opus,.ogg,.oga,.flac,.wav,.aac,.wma,.mka,.mkv,.mp4,.webm,.avi,.mov,.epub,.txt,.srt,.vtt,.ass,.fb2,.zip,.mobi,.azw,.azw3,.prc">
+           accept=".jpg,.jpeg,.png,.webp,.bmp,.m4b,.m4a,.mp3,.opus,.ogg,.oga,.flac,.wav,.aac,.wma,.mka,.mkv,.mp4,.webm,.avi,.mov,.epub,.txt,.srt,.vtt,.ass,.fb2,.zip,.mobi,.azw,.azw3,.prc">
     <div class="dz-inner">
       <div class="dz-icon" aria-hidden="true">
         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
@@ -52,6 +52,8 @@
         Or <button type="button" id="browse" class="linkbtn">browse</button>.</p>
       <p class="dz-formats">Audio: m4b · mp3 · m4a · opus · flac · wav · mkv
         &nbsp;|&nbsp; Book: epub · fb2 · mobi · azw3 · txt</p>
+      <p class="dz-formats" id="cover-hint">Optional: drop a jpg or png to use as the
+        video background — <strong>sign in required</strong></p>
     </div>
   </section>
 
@@ -65,6 +67,14 @@
       </div>
       <button type="button" class="slot-x" data-slot="audio" title="Remove">×</button>
     </div>
+    <div class="slot" id="slot-cover" hidden>
+      <span class="slot-tick" aria-hidden="true"></span>
+      <div class="slot-body">
+        <div class="slot-label">Cover image</div>
+        <div class="slot-value">Waiting for an image</div>
+      </div>
+      <button type="button" class="slot-x" data-slot="cover" title="Remove">×</button>
+    </div>
     <div class="slot" id="slot-text">
       <span class="slot-tick" aria-hidden="true"></span>
       <div class="slot-body">
@@ -130,6 +140,6 @@
   </p>
 </footer>
 
-<script src="/app.js?v=7"></script>
+<script src="/app.js?v=8"></script>
 </body>
 </html>