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 | 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>