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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user