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..53ea7e5 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 @@ -32,6 +33,7 @@ from deepl_languages import SOURCE_LANGUAGES, TARGET_LANGUAGES from utils import ( MAX_UPLOAD_SIZE_MB, + RETENTION_DAYS, cleanup_files, handle_transcription, is_valid_media_file, @@ -111,6 +113,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 +160,130 @@ 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" + ) + except (TypeError, ValueError): + return "—" + + +def _duration_secs(created_at: str, completed_at: str): + try: + return round(float(completed_at) - float(created_at), 1) + except (TypeError, ValueError): + return None + + +def _status_class(status: str, progress: int) -> str: + """Category used for the status badge color on the history page.""" + if job_status.is_canceled(status): + return "canceled" + if job_status.is_error(status): + return "error" + if progress >= 100: + return "success" + return "progress" + + +@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(limit=1000): + # 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"]) + secs = _duration_secs(row["created_at"], row["completed_at"]) + try: + created_ts = float(row["created_at"]) + except (TypeError, ValueError): + created_ts = 0.0 + yt = row["youtube_url"] + translating = (row["translation"] or "").lower() not in ("", "none") + lang_display = ( + f'{row["language"]} → {row["language_translation"]}' + if translating + else row["language"] + ) + jobs.append( + { + "id": row["id"], + "created": _fmt_time(row["created_at"]), + "created_ts": created_ts, # raw, for sorting + "source_full": yt or row["media_path"] or "—", + "source_is_url": bool(yt), + "duration": f"{secs}s" if secs is not None else "—", + "duration_secs": secs if secs is not None else -1, # raw, for sorting + "model": MODEL_LABELS.get(row["model"], row["model"]), + "lang": lang_display, + "status": row["status"], + "status_class": _status_class(row["status"], row["progress"]), + "progress": row["progress"], + "downloadable": downloadable, + } + ) + return templates.TemplateResponse( + request, "history.html", {"jobs": jobs, "retention_days": RETENTION_DAYS} + ) + + +def _is_in_flight(row) -> bool: + """A job whose worker may still be writing files — never delete under it.""" + return row["progress"] < 100 and not job_status.is_locked(row["status"]) + + +@app.post("/history/delete", response_class=JSONResponse) +async def history_delete(pid: int): + """Delete one job's files and DB row. Refuses if it's still running.""" + if not ENABLE_HISTORY: + raise HTTPException(status_code=404, detail="History is disabled.") + row = DB.get_transcription(pid) + if not row: + return JSONResponse(content={"message": "Job not found."}, status_code=404) + if _is_in_flight(row): + return JSONResponse( + content={"message": "Job is still running — cancel it first."}, + status_code=409, + ) + cleanup_files(pid) + DB.delete_transcription(pid) + return JSONResponse(content={"status": "success"}) + + +@app.post("/history/clear", response_class=JSONResponse) +async def history_clear(): + """Delete every finished job's files and DB row. Leaves running jobs alone.""" + if not ENABLE_HISTORY: + raise HTTPException(status_code=404, detail="History is disabled.") + removed = 0 + skipped = 0 + for row in DB.list_jobs(limit=100000): + if _is_in_flight(row): + skipped += 1 + continue + cleanup_files(row["id"]) + DB.delete_transcription(row["id"]) + removed += 1 + return JSONResponse( + content={"status": "success", "removed": removed, "skipped": skipped} + ) + + @app.post("/submit_contact") async def submit_contact( name: str = Form(None), diff --git a/static/history.js b/static/history.js new file mode 100644 index 0000000..a4a0c1c --- /dev/null +++ b/static/history.js @@ -0,0 +1,116 @@ +// History table: client-side stats/filter + sort + pagination + delete. All +// rows are already on the page (retention keeps the count bounded), so this is +// cheap DOM work. +(function () { + const table = document.getElementById('historyTable'); + if (!table) return; + const tbody = table.querySelector('tbody'); + const PER_PAGE = 10; + const colCount = table.querySelectorAll('thead th').length; + let master = Array.from(tbody.querySelectorAll('tr')); // every row, unfiltered + let filter = 'all'; + let page = 1; + let sort = { index: 1, type: 'number', asc: false }; // default: When, newest first + + const byId = id => document.getElementById(id); + const cellValue = cell => cell.dataset.sortValue !== undefined ? cell.dataset.sortValue : cell.textContent.trim(); + + function visibleRows() { + const rows = filter === 'all' ? master.slice() : master.filter(r => r.dataset.status === filter); + const { index, type, asc } = sort; + rows.sort((a, b) => { + let av = cellValue(a.children[index]); + let bv = cellValue(b.children[index]); + if (type === 'number') { + av = parseFloat(av) || 0; + bv = parseFloat(bv) || 0; + return asc ? av - bv : bv - av; + } + return asc ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + }); + return rows; + } + + function fillerRow(text) { + const tr = document.createElement('tr'); + tr.className = 'filler-row'; + const td = document.createElement('td'); + td.colSpan = colCount; + if (text) { td.textContent = text; td.style.textAlign = 'center'; td.style.opacity = '.7'; } + else { td.innerHTML = ' '; } + tr.appendChild(td); + return tr; + } + + function render() { + const rows = visibleRows(); + const pages = Math.max(1, Math.ceil(rows.length / PER_PAGE)); + page = Math.min(Math.max(1, page), pages); + + // Always render exactly PER_PAGE row-slots so the table's height never + // changes — short pages/filters are padded with empty rows. + const nodes = rows.length === 0 + ? [fillerRow('No jobs match this filter.')] + : rows.slice((page - 1) * PER_PAGE, page * PER_PAGE); + while (nodes.length < PER_PAGE) nodes.push(fillerRow()); + tbody.replaceChildren(...nodes); + + // Stats always reflect the full set, not the current filter. + const count = c => master.filter(r => r.dataset.status === c).length; + byId('statTotal').textContent = master.length; + byId('statSuccess').textContent = count('success'); + byId('statError').textContent = count('error'); + byId('statCanceled').textContent = count('canceled'); + + byId('pageInfo').textContent = `Page ${page} of ${pages}`; + byId('prevPage').disabled = page <= 1; + byId('nextPage').disabled = page >= pages; + } + + // Sorting + table.querySelectorAll('th.sortable').forEach((th, i, all) => { + th.addEventListener('click', () => { + const index = Array.from(th.parentNode.children).indexOf(th); + // toggle direction on the same column; new column starts ascending + sort = { index, type: th.dataset.type || 'text', asc: sort.index === index ? !sort.asc : true }; + all.forEach(h => h.classList.remove('sort-asc', 'sort-desc')); + th.classList.add(sort.asc ? 'sort-asc' : 'sort-desc'); + page = 1; + render(); + }); + }); + + // Filtering via the stat cards + document.querySelectorAll('.stat').forEach(card => { + card.addEventListener('click', () => { + filter = card.dataset.filter; + document.querySelectorAll('.stat').forEach(c => c.classList.toggle('active', c === card)); + page = 1; + render(); + }); + }); + + // Pagination + byId('prevPage').addEventListener('click', () => { page--; render(); }); + byId('nextPage').addEventListener('click', () => { page++; render(); }); + + // Delete + window.deleteJob = function (id) { + if (!confirm(`Delete job #${id} and its files? This cannot be undone.`)) return; + fetch(`/history/delete?pid=${id}`, { method: 'POST' }).then(r => { + if (r.ok) { + master = master.filter(row => row.dataset.jobId != id); + render(); + } else { + r.json().then(d => alert(d.message || 'Could not delete the job.')).catch(() => alert('Could not delete the job.')); + } + }).catch(() => alert('Could not delete the job.')); + }; + + window.clearHistory = function () { + if (!confirm('Delete ALL finished jobs and their files? This cannot be undone. (Running jobs are kept.)')) return; + fetch('/history/clear', { method: 'POST' }).then(() => location.reload()).catch(() => alert('Could not clear history.')); + }; + + render(); +})(); 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 @@