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:
@@ -161,7 +161,7 @@ uvicorn app.main:app --reload --host 127.0.0.1 --port 8025
|
|||||||
|
|
||||||
브라우저에서 `http://127.0.0.1:8025` 접속.
|
브라우저에서 `http://127.0.0.1:8025` 접속.
|
||||||
|
|
||||||
웹 UI는 **faster-whisper** 전사와(옵션) **pyannote 화자 분리**를 지원합니다. OpenAI Whisper 기반 CLI는 **`whisper_stt.py`** 를 사용하세요.
|
웹 UI는 **faster-whisper** 전사와(옵션) **pyannote 화자 분리**를 지원합니다. OpenAI Whisper 기반 CLI는 **`whisper_stt.py`** 를 사용하세요. 비동기 작업(`POST /api/jobs`)은 **서버 스레드에서 계속** 돌아가며, UI는 **`?job=<uuid>`·`localStorage`** 로 새로고침 후에도 같은 작업을 다시 조회·폴링합니다(메모리 TTL `APP_JOB_TTL_SEC` 초과 시 job 은 사라짐).
|
||||||
|
|
||||||
### HTTPS 도메인(`https://ncue.net/stt/…`)에서 503 (Service Unavailable)
|
### HTTPS 도메인(`https://ncue.net/stt/…`)에서 503 (Service Unavailable)
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,8 @@
|
|||||||
<div class="hint">
|
<div class="hint">
|
||||||
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm<br />
|
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm<br />
|
||||||
- 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.<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>
|
||||||
|
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
@@ -432,6 +433,9 @@
|
|||||||
const timingEl = $("timing");
|
const timingEl = $("timing");
|
||||||
|
|
||||||
const allowedExt = [".mp3", ".m4a", ".wav", ".mp4", ".aac", ".ogg", ".flac", ".webm"];
|
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 currentJobId = null;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let startedAt = null;
|
let startedAt = null;
|
||||||
@@ -439,6 +443,107 @@
|
|||||||
let lastSegCount = 0;
|
let lastSegCount = 0;
|
||||||
let lastTextLen = 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
|
// Tabs
|
||||||
function setTab(which) {
|
function setTab(which) {
|
||||||
const isAdmin = which === "admin";
|
const isAdmin = which === "admin";
|
||||||
@@ -537,7 +642,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearEl.addEventListener("click", () => {
|
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 = "";
|
fileEl.value = "";
|
||||||
outEl.value = "";
|
outEl.value = "";
|
||||||
segEl.value = "";
|
segEl.value = "";
|
||||||
@@ -549,6 +664,7 @@
|
|||||||
goEl.disabled = true;
|
goEl.disabled = true;
|
||||||
cancelEl.disabled = true;
|
cancelEl.disabled = true;
|
||||||
downloadEl.disabled = true;
|
downloadEl.disabled = true;
|
||||||
|
setIdle();
|
||||||
});
|
});
|
||||||
|
|
||||||
downloadEl.addEventListener("click", () => {
|
downloadEl.addEventListener("click", () => {
|
||||||
@@ -632,6 +748,7 @@
|
|||||||
setProgress(1);
|
setProgress(1);
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
downloadEl.disabled = !outEl.value?.trim();
|
downloadEl.disabled = !outEl.value?.trim();
|
||||||
return;
|
return;
|
||||||
@@ -641,6 +758,7 @@
|
|||||||
setStatus("취소됨");
|
setStatus("취소됨");
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -650,6 +768,7 @@
|
|||||||
setError(body?.error || "실패");
|
setError(body?.error || "실패");
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -663,6 +782,7 @@
|
|||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
lastSegCount = 0;
|
lastSegCount = 0;
|
||||||
lastTextLen = 0;
|
lastTextLen = 0;
|
||||||
@@ -695,6 +815,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentJobId = body.job_id;
|
currentJobId = body.job_id;
|
||||||
|
syncActiveJobToBrowser(currentJobId);
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
setStatus("전사(STT) 처리 중…");
|
setStatus("전사(STT) 처리 중…");
|
||||||
setRunning();
|
setRunning();
|
||||||
@@ -707,6 +828,7 @@
|
|||||||
setStatus("실패");
|
setStatus("실패");
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
});
|
});
|
||||||
}, 700);
|
}, 700);
|
||||||
@@ -729,6 +851,7 @@
|
|||||||
|
|
||||||
checkHealth();
|
checkHealth();
|
||||||
setInterval(checkHealth, 5000);
|
setInterval(checkHealth, 5000);
|
||||||
|
tryResumeJob().catch(() => {});
|
||||||
|
|
||||||
// Admin (DB Records)
|
// Admin (DB Records)
|
||||||
const adminQEl = $("admin-q");
|
const adminQEl = $("admin-q");
|
||||||
|
|||||||
Reference in New Issue
Block a user