Skip to content
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
129 changes: 129 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import time
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
116 changes: 116 additions & 0 deletions static/history.js
Original file line number Diff line number Diff line change
@@ -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 = '&nbsp;'; }
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();
})();
1 change: 1 addition & 0 deletions templates/contact.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
</button>
<div class="nav-links" id="navLinks">
<a href="/">Application</a>
<a href="/history">History</a>
<a href="/faq">FAQ</a>
<a href="/contact">Contact</a>
<a class="github-button" href="https://github.com/lkmeta/txtify"
Expand Down
1 change: 1 addition & 0 deletions templates/faq.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
</button>
<div class="nav-links" id="navLinks">
<a href="/">Application</a>
<a href="/history">History</a>
<a href="/faq">FAQ</a>
<a href="/contact">Contact</a>
<a class="github-button" href="https://github.com/lkmeta/txtify"
Expand Down
Loading
Loading