From 7b971696f3c0f0ea5c0db4e61359f337b1caf64d Mon Sep 17 00:00:00 2001 From: lkmeta Date: Sat, 15 Aug 2026 12:26:29 +0300 Subject: [PATCH] feat: retry a job from history with the same parameters Add a Retry action on failed/canceled history rows (and POST /history/retry) that re-runs a past job as a NEW job with the same settings. YouTube re-runs from the stored URL; an upload reuses its source file if it's still on disk (handle_transcription gains a source_file branch that copies it under the new job id), otherwise the user is told to re-upload (410). Refuses in-flight jobs and honors the concurrency cap. Verified: pytest 74 (youtube retry starts a new job; refuses running; upload reuses source; missing source -> 410; 404 when history disabled); against the container a completed fixture job retried and re-ran to "Completed successfully!"; docker_e2e.sh PASS. --- src/main.py | 60 ++++++++++++++++++++++++++++++++++++++++++ src/utils.py | 10 +++++++ static/history.js | 11 ++++++++ templates/history.html | 6 +++++ tests/test_api.py | 40 ++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+) diff --git a/src/main.py b/src/main.py index 53ea7e5..f516203 100644 --- a/src/main.py +++ b/src/main.py @@ -284,6 +284,66 @@ async def history_clear(): ) +@app.post("/history/retry", response_class=JSONResponse) +async def history_retry(pid: int): + """ + Re-run a past job with the same parameters as a NEW job. YouTube re-downloads + from the stored URL; an upload reuses its source file if still on disk. + """ + if not ENABLE_HISTORY: + raise HTTPException(status_code=404, detail="History is disabled.") + old = DB.get_transcription(pid) + if not old: + return JSONResponse(content={"message": "Job not found."}, status_code=404) + if _is_in_flight(old): + return JSONResponse( + content={"message": "Job is still running."}, status_code=409 + ) + if _count_active_jobs() >= MAX_CONCURRENT_JOBS: + return JSONResponse( + content={"message": "Server busy — too many jobs running. Try again shortly."}, + status_code=429, + ) + + youtube_url = old["youtube_url"] or "" + source_file = None + if not youtube_url: + # Reuse the previous upload's source if it hasn't been cleaned up. + candidates = [ + f for f in OUTPUT_DIR.glob(f"{pid}_*") + if f.is_file() and not f.name.endswith("_logs.txt") + ] + mp3s = [f for f in candidates if f.suffix == ".mp3"] + chosen = mp3s or candidates + if not chosen: + return JSONResponse( + content={"message": "The uploaded file is no longer available — please re-upload it to run again."}, + status_code=410, + ) + source_file = str(chosen[0]) + + new_id = DB.insert_transcription( + youtube_url, old["media_path"], old["language"], old["model"], + old["translation"], old["language_translation"], old["file_export"], + job_status.PROCESSING, str(time.time()), + ) + DB.update_transcription_status(job_status.PROCESSING, "", 10, new_id) + started = await run_in_threadpool( + handle_transcription, new_id, youtube_url or None, None, + old["language"], old["model"], old["translation"], + old["language_translation"], old["file_export"], source_file, + ) + if not started: + row = DB.get_transcription(new_id) + msg = ( + row["status"].removeprefix(f"{job_status.ERROR_PREFIX} ") + if row and row["status"].startswith(job_status.ERROR_PREFIX) + else "Retry failed to start." + ) + return JSONResponse(content={"message": msg, "pid": new_id}, status_code=400) + return JSONResponse(content={"status": "success", "pid": new_id}) + + @app.post("/submit_contact") async def submit_contact( name: str = Form(None), diff --git a/src/utils.py b/src/utils.py index 449ff64..218e808 100644 --- a/src/utils.py +++ b/src/utils.py @@ -107,6 +107,7 @@ def handle_transcription( translation: str, language_translation: str, file_export: str, + source_file: str = None, ) -> bool: """ Handle the transcription process: download YouTube or handle uploaded media, @@ -196,6 +197,15 @@ def handle_transcription( raise output_file = convert_to_mp3(media_file_path) + elif source_file: + # Retry of a previous upload: reuse the source still on disk, copied + # under this job's id so the two jobs own independent files. + src = Path(source_file) + dest = OUTPUT_DIR / f"{job_id}_{src.name}" + if src.resolve() != dest.resolve(): + shutil.copy(src, dest) + output_file = convert_to_mp3(dest) + logger.info(f"Transcription started for: {output_file}") # The download/convert above can take minutes; the user may have diff --git a/static/history.js b/static/history.js index a4a0c1c..2b7bbc5 100644 --- a/static/history.js +++ b/static/history.js @@ -107,6 +107,17 @@ }).catch(() => alert('Could not delete the job.')); }; + window.retryJob = function (id) { + if (!confirm(`Re-run job #${id} with the same settings?`)) return; + fetch(`/history/retry?pid=${id}`, { method: 'POST' }).then(r => { + if (r.ok) { + location.reload(); // the new job shows up (Processing) + } else { + r.json().then(d => alert(d.message || 'Could not retry the job.')).catch(() => alert('Could not retry the job.')); + } + }).catch(() => alert('Could not retry 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.')); diff --git a/templates/history.html b/templates/history.html index f101d81..3c819f7 100644 --- a/templates/history.html +++ b/templates/history.html @@ -90,7 +90,10 @@ .act-dl:hover { background: rgba(35, 134, 54, .24); } .act-del { color: #e5484d; background: rgba(229, 72, 77, .12); border-color: rgba(229, 72, 77, .4); } .act-del:hover { background: rgba(229, 72, 77, .22); } + .act-retry { color: #2a6fdb; background: rgba(56, 132, 222, .12); border-color: rgba(56, 132, 222, .4); } + .act-retry:hover { background: rgba(56, 132, 222, .22); } body.dark-theme .act-dl { color: #4ac26b; } + body.dark-theme .act-retry { color: #6aa8ff; } .history-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 1.1rem; gap: 1rem; flex-wrap: wrap; } .pager { display: flex; gap: 1rem; align-items: center; } @@ -194,6 +197,9 @@

Transcription History

{% if job.downloadable %} Download {% endif %} + {% if job.status_class in ['error', 'canceled'] %} + + {% endif %} diff --git a/tests/test_api.py b/tests/test_api.py index ed63404..e826aa0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -32,6 +32,46 @@ def test_history_can_be_disabled(client, monkeypatch): assert client.get("/history").status_code == 404 assert client.post("/history/delete?pid=1").status_code == 404 assert client.post("/history/clear").status_code == 404 + assert client.post("/history/retry?pid=1").status_code == 404 + + +def _seed(status="Error", youtube_url="", media_path=""): + return main.DB.insert_transcription( + youtube_url, media_path, "en", "whisper_tiny", "none", "en", + "all", status, "1.0", + ) + + +def test_history_retry_youtube_starts_new_job(client, monkeypatch): + monkeypatch.setattr(main, "handle_transcription", lambda *a, **k: True) + old = _seed(youtube_url="https://youtu.be/abc") + r = client.post(f"/history/retry?pid={old}") + assert r.status_code == 200 + new_pid = r.json()["pid"] + assert new_pid != old + assert main.DB.get_transcription(new_pid)["youtube_url"] == "https://youtu.be/abc" + + +def test_history_retry_refuses_running(client, monkeypatch): + monkeypatch.setattr(main, "handle_transcription", lambda *a, **k: True) + job = _seed(status="Processing request...", youtube_url="https://youtu.be/abc") + main.DB.update_transcription_status("Transcribing...", "", 40, job) # in-flight + assert client.post(f"/history/retry?pid={job}").status_code == 409 + + +def test_history_retry_upload_reuses_source(client, monkeypatch, tmp_path): + monkeypatch.setattr(main, "OUTPUT_DIR", tmp_path) + monkeypatch.setattr(main, "handle_transcription", lambda *a, **k: True) + job = _seed(media_path="clip.mp3") + (tmp_path / f"{job}_clip.mp3").write_bytes(b"fake") # source still on disk + assert client.post(f"/history/retry?pid={job}").status_code == 200 + + +def test_history_retry_upload_missing_source_is_410(client, monkeypatch, tmp_path): + monkeypatch.setattr(main, "OUTPUT_DIR", tmp_path) + monkeypatch.setattr(main, "handle_transcription", lambda *a, **k: True) + job = _seed(media_path="clip.mp3") # no file on disk + assert client.post(f"/history/retry?pid={job}").status_code == 410 def test_history_delete_removes_job_and_files(client, monkeypatch, tmp_path):