Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions static/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.'));
Expand Down
6 changes: 6 additions & 0 deletions templates/history.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -194,6 +197,9 @@ <h1 style="text-align:center;">Transcription History</h1>
{% if job.downloadable %}
<a class="act act-dl" href="/download?pid={{ job.id }}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
{% endif %}
{% if job.status_class in ['error', 'canceled'] %}
<button class="act act-retry" onclick="retryJob({{ job.id }})"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Retry</button>
{% endif %}
<button class="act act-del" onclick="deleteJob({{ job.id }})"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>
</div>
</td>
Expand Down
40 changes: 40 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading