From b363e464aaac71d16e9f1a65ccff3860a7e01a4c Mon Sep 17 00:00:00 2001 From: lkmeta Date: Wed, 5 Aug 2026 18:06:10 +0300 Subject: [PATCH 1/8] feat: history page listing past transcriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only /history page (linked in the nav) that lists every past job newest-first from the DB — id, time, duration, model, language, translation, status, and a .zip download for finished jobs. The data already existed; this just surfaces it, and retention keeps it bounded. db.list_jobs() returns recent rows capped at 200. ENABLE_HISTORY (default True) hides the page on shared deployments, since it lists all jobs by id and the app has no auth. Verified: pytest 65 (list_jobs order/cap, /history lists a completed job with its download link, ENABLE_HISTORY=False -> 404); rendered against the container (25 rows, download links, theme works, 0 JS errors). --- .env.example | 5 ++ src/db.py | 16 +++++++ src/main.py | 57 ++++++++++++++++++++++ templates/contact.html | 1 + templates/faq.html | 1 + templates/history.html | 104 +++++++++++++++++++++++++++++++++++++++++ templates/index.html | 1 + tests/test_api.py | 20 +++++++- tests/test_db.py | 8 ++++ 9 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 templates/history.html diff --git a/.env.example b/.env.example index 1bf4115..8071a89 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,11 @@ RUNNING_LOCALLY=True # Max transcriptions running at once (each is a full Whisper process). MAX_CONCURRENT_JOBS=2 +# ------------------- +# History page (/history) lists all past jobs by id. Fine for a single-user +# self-host; set to False to hide it on a shared deployment. +ENABLE_HISTORY=True + # ------------------- # Upload/duration limits. 0 = unlimited (the default) — it's your own machine. # Set a positive value to cap uploads (MB) / video duration (seconds) on a diff --git a/src/db.py b/src/db.py index 32d9b6f..168f172 100644 --- a/src/db.py +++ b/src/db.py @@ -248,6 +248,22 @@ def get_expired_job_ids(self, cutoff: float) -> list: ).fetchall() return [row[0] for row in rows] + def list_jobs(self, limit: int = 200): + """ + Return recent job rows, newest first, for the history page. Capped so + the page stays bounded (retention already limits how many rows exist). + + Args: + limit (int): Maximum rows to return. + + Returns: + list[sqlite3.Row]: The job rows. + """ + with closing(self._connect()) as conn, conn: + return conn.execute( + "SELECT * FROM transcriptions ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + def delete_transcription(self, job_id: int) -> None: """ Delete a transcription record by job id. diff --git a/src/main.py b/src/main.py index b95ba2d..fd302ee 100644 --- a/src/main.py +++ b/src/main.py @@ -14,6 +14,7 @@ import time import uuid import zipfile +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -111,6 +112,9 @@ async def _loop() -> None: RUNNING_LOCALLY = os.getenv("RUNNING_LOCALLY", "True").lower() == "true" # Each job is a full whisper process; uncapped concurrency OOMs the container. MAX_CONCURRENT_JOBS = int(os.getenv("MAX_CONCURRENT_JOBS", "2")) +# The history page lists every past job by numeric id — fine for a single-user +# self-host, but set ENABLE_HISTORY=False to hide it on a shared deployment. +ENABLE_HISTORY = os.getenv("ENABLE_HISTORY", "True").lower() == "true" RESEND_API_KEY = os.getenv("RESEND_API_KEY") CONTACT_EMAIL = os.getenv("CONTACT_EMAIL") if not RUNNING_LOCALLY: @@ -155,6 +159,59 @@ async def contact(request: Request): return templates.TemplateResponse(request, "contact.html") +MODEL_LABELS = { + "whisper_tiny": "Whisper Tiny", + "whisper_base": "Whisper Base", + "whisper_small": "Whisper Small", + "whisper_medium": "Whisper Medium", + "whisper_large": "Whisper Large", +} + + +def _fmt_time(created_at: str) -> str: + try: + return datetime.fromtimestamp(float(created_at), timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + except (TypeError, ValueError): + return "—" + + +def _fmt_duration(created_at: str, completed_at: str) -> str: + try: + return f"{round(float(completed_at) - float(created_at), 1)}s" + except (TypeError, ValueError): + return "—" + + +@app.get("/history", response_class=HTMLResponse) +async def history(request: Request): + """ + Read-only list of past jobs (newest first) with links to their outputs. + """ + if not ENABLE_HISTORY: + raise HTTPException(status_code=404, detail="History is disabled.") + jobs = [] + for row in DB.list_jobs(): + # Downloadable once the exports exist (progress 100) and the job is not + # canceled/errored — covers both success and "translation failed". + downloadable = row["progress"] >= 100 and not job_status.is_locked(row["status"]) + jobs.append( + { + "id": row["id"], + "created": _fmt_time(row["created_at"]), + "duration": _fmt_duration(row["created_at"], row["completed_at"]), + "model": MODEL_LABELS.get(row["model"], row["model"]), + "language": row["language"], + "translation": row["translation"], + "status": row["status"], + "progress": row["progress"], + "downloadable": downloadable, + } + ) + return templates.TemplateResponse(request, "history.html", {"jobs": jobs}) + + @app.post("/submit_contact") async def submit_contact( name: str = Form(None), diff --git a/templates/contact.html b/templates/contact.html index 7a481f7..fbf34f3 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -30,6 +30,7 @@