feat(ui): resume STT job after refresh via ?job= and localStorage

- Sync job_id to URL and localStorage; tryResumeJob on load
- Clear sync on terminal state, clear button, new upload, poll errors
- README: note server thread vs UI reattach and JOB_TTL

Made-with: Cursor
This commit is contained in:
dosangyoon
2026-03-23 17:46:38 +09:00
parent e74805f2fd
commit 14df6d8805
2 changed files with 126 additions and 3 deletions

View File

@@ -320,7 +320,8 @@
<div class="hint">
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm<br />
- 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.<br />
- 화자 분리 켜짐: <span class="mono">./models/pyannote-diarization-3.1</span> 및 gated HF 모델 동의(README 참고).
- 화자 분리 켜짐: <span class="mono">./models/pyannote-diarization-3.1</span> 및 gated HF 모델 동의(README 참고).<br />
- 전사는 서버에서 계속 실행됩니다. 새로고침·다른 탭 후에도 주소의 <span class="mono">?job=…</span> 또는 이 브라우저 저장값으로 진행 상황을 다시 붙입니다.
</div>
<div class="progress">
@@ -432,6 +433,9 @@
const timingEl = $("timing");
const allowedExt = [".mp3", ".m4a", ".wav", ".mp4", ".aac", ".ogg", ".flac", ".webm"];
const STORAGE_ACTIVE_JOB = "stt_active_job_id";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
let currentJobId = null;
let pollTimer = null;
let startedAt = null;
@@ -439,6 +443,107 @@
let lastSegCount = 0;
let lastTextLen = 0;
/** 서버 작업 ID를 브라우저에 남겨 새로고침 후에도 폴링 복구 */
function syncActiveJobToBrowser(jobId) {
try {
if (jobId) {
localStorage.setItem(STORAGE_ACTIVE_JOB, jobId);
const u = new URL(window.location.href);
u.searchParams.set("job", jobId);
history.replaceState(null, "", u.pathname + u.search + u.hash);
} else {
localStorage.removeItem(STORAGE_ACTIVE_JOB);
const u = new URL(window.location.href);
u.searchParams.delete("job");
history.replaceState(null, "", u.pathname + u.search + u.hash);
}
} catch (_) {}
}
async function tryResumeJob() {
let jobId = null;
try {
const u = new URL(window.location.href);
jobId = u.searchParams.get("job") || localStorage.getItem(STORAGE_ACTIVE_JOB);
} catch (_) {
return;
}
if (!jobId || !UUID_RE.test(jobId.trim())) {
if (jobId) syncActiveJobToBrowser(null);
return;
}
jobId = jobId.trim();
const r = await fetch(`api/jobs/${encodeURIComponent(jobId)}`);
if (!r.ok) {
syncActiveJobToBrowser(null);
return;
}
const body = await r.json().catch(() => ({}));
const st = body.status;
if (!st) {
syncActiveJobToBrowser(null);
return;
}
currentJobId = jobId;
lastSegCount = 0;
lastTextLen = 0;
const text = body.text || "";
outEl.value = text;
lastTextLen = text.length;
const segs = Array.isArray(body.segments) ? body.segments : [];
segEl.value = segs.length ? JSON.stringify(segs, null, 2) : "";
lastSegCount = segs.length;
setProgress(typeof body.progress === "number" ? body.progress : null);
const lang = body.detected_language ? `${body.detected_language}` : "-";
const prob = typeof body.language_probability === "number" ? body.language_probability.toFixed(3) : "-";
const dur = typeof body.duration_sec === "number" ? `${body.duration_sec.toFixed(1)}s` : "-";
metaEl.textContent = `감지 언어: ${lang} (p=${prob}), 오디오 길이: ${dur}`;
if (st === "completed" || st === "failed" || st === "cancelled") {
syncActiveJobToBrowser(null);
currentJobId = null;
stopPolling();
if (st === "completed") {
setStatus("완료(재접속 시 결과 표시)");
setProgress(1);
setError("");
} else if (st === "failed") {
setStatus("실패");
setError(body.error || "실패");
} else {
setStatus("취소됨");
setError("");
}
setIdle();
downloadEl.disabled = !outEl.value?.trim();
return;
}
syncActiveJobToBrowser(jobId);
const t0 = typeof body.created_at === "number" ? body.created_at : Date.now() / 1000;
startedAt = performance.now() - (Date.now() / 1000 - t0) * 1000;
setStatus(st === "queued" ? "대기 중 — 서버에서 처리 예정(재접속됨)" : "전사(STT) 처리 중 — 서버에서 실행 중(재접속됨)");
metaEl.textContent = `${metaEl.textContent} · 새로고침해도 서버 작업은 계속됩니다`;
setRunning();
await pollJobOnce();
stopPolling();
pollTimer = setInterval(() => {
pollJobOnce().catch((e) => {
stopPolling();
setError(String(e?.message || e));
setStatus("실패");
currentJobId = null;
syncActiveJobToBrowser(null);
setIdle();
});
}, 700);
}
// Tabs
function setTab(which) {
const isAdmin = which === "admin";
@@ -537,7 +642,17 @@
});
clearEl.addEventListener("click", () => {
cancelCurrent();
stopPolling();
if (uploadController) {
try {
uploadController.abort();
} catch (_) {}
uploadController = null;
} else if (currentJobId) {
fetch(`api/jobs/${encodeURIComponent(currentJobId)}/cancel`, { method: "POST" }).catch(() => {});
}
syncActiveJobToBrowser(null);
currentJobId = null;
fileEl.value = "";
outEl.value = "";
segEl.value = "";
@@ -549,6 +664,7 @@
goEl.disabled = true;
cancelEl.disabled = true;
downloadEl.disabled = true;
setIdle();
});
downloadEl.addEventListener("click", () => {
@@ -632,6 +748,7 @@
setProgress(1);
currentJobId = null;
uploadController = null;
syncActiveJobToBrowser(null);
setIdle();
downloadEl.disabled = !outEl.value?.trim();
return;
@@ -641,6 +758,7 @@
setStatus("취소됨");
currentJobId = null;
uploadController = null;
syncActiveJobToBrowser(null);
setIdle();
return;
}
@@ -650,6 +768,7 @@
setError(body?.error || "실패");
currentJobId = null;
uploadController = null;
syncActiveJobToBrowser(null);
setIdle();
return;
}
@@ -663,6 +782,7 @@
if (!f) return;
stopPolling();
syncActiveJobToBrowser(null);
currentJobId = null;
lastSegCount = 0;
lastTextLen = 0;
@@ -695,6 +815,7 @@
}
currentJobId = body.job_id;
syncActiveJobToBrowser(currentJobId);
uploadController = null;
setStatus("전사(STT) 처리 중…");
setRunning();
@@ -707,6 +828,7 @@
setStatus("실패");
currentJobId = null;
uploadController = null;
syncActiveJobToBrowser(null);
setIdle();
});
}, 700);
@@ -729,6 +851,7 @@
checkHealth();
setInterval(checkHealth, 5000);
tryResumeJob().catch(() => {});
// Admin (DB Records)
const adminQEl = $("admin-q");