设置页(/settings):
- 新增 Setting DB 模型(键值存储,持久化运行时覆盖)
- config.py 加 _apply_overrides:get_settings() 合并 DB 覆盖值
(_applying_overrides 标志防递归:DB 初始化回调 get_settings 时不重入)
- save_setting() 写 DB + 清 lru_cache,后续任务读到新值
- settings_router.py: GET/PUT /api/settings
- settings_html.py: 设置页表单(batch_size/beam_size/sort_by_length)
+ 设备只读信息 + 保存后 reload 确认
- 验证:改 asr_batch_size=16 beam_size=1 -> 任务 ASR 日志确认生效
- 验证:重启容器后设置从 DB 恢复(持久化)
删除任务:
- task_router.py: DELETE /api/tasks/{id},仅 done/failed 可删
删字幕/音频/视频产物 + UploadSession + Task DB 记录
- home_html.py + history_html.py: done/failed 任务显示删除按钮
+ confirm 确认 + 调用 DELETE API + 刷新列表
- 验证:删除 task 15 成功,删除进行中 task 返回 409
/docs 去认证:
- 移除 require_docs_auth 依赖,/docs /redoc /openapi.json 直接公开
离线模式:
- Dockerfile dev/final 加 HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
(模型已缓存在 ./models volume,无需联网验证)
- 验证:离线模式完整跑通 ASR+翻译(task 15 done)
211 lines
7.4 KiB
Python
211 lines
7.4 KiB
Python
"""主页:上传入口 + 最近 10 个任务的实时进度。
|
||
|
||
共享 _shared.py 的 BASE_CSS / SHARED_JS / 上传协议 JS。
|
||
页面专属:refreshList(拉取最近任务)、makeTaskCard、pollTask(轮询进行中任务)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from ._shared import POLL_INTERVAL, render_page, render_upload_js
|
||
|
||
RECENT_LIMIT = 10
|
||
LIST_REFRESH_INTERVAL = 5000
|
||
|
||
_PAGE_JS = f"""
|
||
const POLL_INTERVAL = {POLL_INTERVAL};
|
||
const LIST_REFRESH_INTERVAL = {LIST_REFRESH_INTERVAL};
|
||
const RECENT_LIMIT = {RECENT_LIMIT};
|
||
|
||
const tasksEl = document.getElementById("tasks");
|
||
const emptyEl = document.getElementById("empty");
|
||
let pollingIds = new Set();
|
||
|
||
// 初始化上传区
|
||
initUpload(document.getElementById("drop"), document.getElementById("file-input"), tasksEl);
|
||
|
||
// ==================== 最近任务列表 ====================
|
||
|
||
async function refreshList() {{
|
||
try {{
|
||
const r = await fetch("/api/tasks?limit=" + RECENT_LIMIT);
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
renderTasks(data.tasks);
|
||
}} catch (e) {{}}
|
||
}}
|
||
|
||
function renderTasks(tasks) {{
|
||
tasksEl.querySelectorAll(".server-task").forEach(el => el.remove());
|
||
const hasUploadCards = tasksEl.querySelector(".upload-task") !== null;
|
||
emptyEl.style.display = (tasks.length === 0 && !hasUploadCards) ? "block" : "none";
|
||
|
||
for (const task of tasks) {{
|
||
tasksEl.appendChild(makeTaskCard(task));
|
||
}}
|
||
// 对进行中的任务启动轮询(上传会话用 upload_id 去重,Task 用 id 去重)
|
||
for (const task of tasks) {{
|
||
if (!ACTIVE_STATES.includes(task.status)) continue;
|
||
const pollKey = task.is_upload ? task.upload_id : String(task.id);
|
||
if (pollingIds.has(pollKey)) continue;
|
||
pollingIds.add(pollKey);
|
||
pollTask(task);
|
||
}}
|
||
}}
|
||
|
||
function downloadLinks(taskId) {{
|
||
return `<a href="/api/tasks/${{taskId}}/subtitle?type=bilingual" class="dl">双语 SRT</a>`
|
||
+ ` <a href="/api/tasks/${{taskId}}/subtitle?type=en" class="dl">英</a>`
|
||
+ ` <a href="/api/tasks/${{taskId}}/subtitle?type=zh" class="dl">中</a>`;
|
||
}}
|
||
|
||
function makeTaskCard(task) {{
|
||
const el = document.createElement("div");
|
||
el.className = "card task server-task";
|
||
el.dataset.taskId = task.id;
|
||
if (task.is_upload) el.dataset.uploadId = task.upload_id;
|
||
el.innerHTML = renderTaskInner(task);
|
||
return el;
|
||
}}
|
||
|
||
function renderTaskInner(task) {{
|
||
const label = STATUS_LABEL[task.status] || task.status;
|
||
const stateClass = task.status === "done" ? "state-done"
|
||
: task.status === "failed" ? "state-fail" : "state-running";
|
||
|
||
// 上传中:created_at 可能是 naive UTC,前端按 UTC 解析
|
||
let created;
|
||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||
catch (e) {{ created = task.created_at; }}
|
||
|
||
// 删除按钮:仅 done/failed 且非上传中任务显示
|
||
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
|
||
const delBtn = canDelete
|
||
? `<button class="btn-sm del-btn" onclick="deleteTask(${{task.id}}, this)">删除</button>`
|
||
: "";
|
||
|
||
let body;
|
||
if (task.status === "done") {{
|
||
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
|
||
}} else if (task.status === "failed") {{
|
||
body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`;
|
||
}} else {{
|
||
const pct = (task.progress == null) ? 0 : task.progress;
|
||
const sizeInfo = task.size_bytes ? ` · ${{fmtBytes(task.size_bytes)}}` : "";
|
||
body = `<div class="bar"><div class="fill" style="width:${{pct}}%"></div><span class="pct">${{pct.toFixed(0)}}%${{sizeInfo}}</span></div>`;
|
||
}}
|
||
return `
|
||
<div class="task-head">
|
||
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
||
<span class="fstate ${{stateClass}}">${{label}}</span>
|
||
${{delBtn}}
|
||
</div>
|
||
${{body}}
|
||
<div class="task-time">${{created}}</div>`;
|
||
}}
|
||
|
||
async function deleteTask(taskId, btn) {{
|
||
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
|
||
btn.disabled = true;
|
||
try {{
|
||
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
|
||
if (!r.ok) {{
|
||
const err = await r.text();
|
||
alert("删除失败:" + err);
|
||
btn.disabled = false;
|
||
return;
|
||
}}
|
||
refreshList();
|
||
}} catch (e) {{
|
||
alert("删除失败:" + e);
|
||
btn.disabled = false;
|
||
}}
|
||
}}
|
||
|
||
async function pollTask(task) {{
|
||
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
||
const isUpload = task.is_upload === true && task.upload_id;
|
||
const url = isUpload
|
||
? "/api/tasks/chunk-uploads/" + task.upload_id + "/status"
|
||
: "/api/tasks/" + task.id;
|
||
let backoff = POLL_INTERVAL;
|
||
const tick = async () => {{
|
||
try {{
|
||
const r = await fetch(url);
|
||
if (!r.ok) {{ pollingIds.delete(task.id); pollingIds.delete(task.upload_id); return; }}
|
||
const data = await r.json();
|
||
|
||
if (isUpload) {{
|
||
// 上传会话:complete 后 task_id 出现,切换为 Task 轮询
|
||
if (data.completed && data.task_id) {{
|
||
pollingIds.delete(task.upload_id);
|
||
pollingIds.add(data.task_id);
|
||
pollTask({{ id: data.task_id, is_upload: false }});
|
||
// 刷新列表让新 Task 卡片出现
|
||
refreshList();
|
||
return;
|
||
}}
|
||
// 更新上传进度
|
||
const card = tasksEl.querySelector('.server-task[data-upload-id="' + task.upload_id + '"]');
|
||
if (!card) {{ pollingIds.delete(task.upload_id); return; }}
|
||
const uploaded = (data.uploaded_chunks || []).length;
|
||
const total = data.total_chunks || 1;
|
||
const pct = Math.min(4, uploaded / total * 4);
|
||
const fakeTask = {{
|
||
status: "uploading", progress: pct, is_upload: true,
|
||
upload_id: task.upload_id, filename: data.filename,
|
||
size_bytes: data.size_bytes, created_at: task.created_at,
|
||
}};
|
||
card.innerHTML = renderTaskInner(fakeTask);
|
||
backoff = POLL_INTERVAL;
|
||
setTimeout(tick, backoff);
|
||
}} else {{
|
||
// Task 轮询
|
||
const card = tasksEl.querySelector('.server-task[data-task-id="' + task.id + '"]');
|
||
if (!card) {{ pollingIds.delete(task.id); return; }}
|
||
card.innerHTML = renderTaskInner(data);
|
||
if (data.status === "done" || data.status === "failed") {{
|
||
pollingIds.delete(task.id);
|
||
return;
|
||
}}
|
||
backoff = POLL_INTERVAL;
|
||
setTimeout(tick, backoff);
|
||
}}
|
||
}} catch (e) {{
|
||
// 网络错误:指数退避,上限 30s
|
||
backoff = Math.min(backoff * 1.6, 30000);
|
||
setTimeout(tick, backoff);
|
||
}}
|
||
}};
|
||
tick();
|
||
}}
|
||
|
||
// 启动
|
||
refreshList();
|
||
setInterval(refreshList, LIST_REFRESH_INTERVAL);
|
||
"""
|
||
|
||
_BODY = """
|
||
<h1>音频转字幕</h1>
|
||
<p class="sub">上传视频或音频文件,自动生成双语(英/中)SRT 字幕。支持大文件分片上传与断点续传。</p>
|
||
|
||
<div id="drop" class="drop">
|
||
<p class="drop-hint">把文件拖到这里,或</p>
|
||
<label class="btn">选择文件<input id="file-input" type="file" multiple hidden></label>
|
||
</div>
|
||
|
||
<h2>最近任务</h2>
|
||
<div id="tasks" class="tasks"></div>
|
||
<div id="empty" class="empty" style="display:none">暂无任务,上传文件后这里会显示进度。</div>
|
||
"""
|
||
|
||
|
||
def render() -> str:
|
||
# 上传完成后刷新任务列表,让新任务出现在卡片中
|
||
upload_js = render_upload_js(on_complete="refreshList();")
|
||
return render_page(
|
||
title="audio2text — 音频转字幕",
|
||
nav_active="home",
|
||
body=_BODY,
|
||
page_js=upload_js + _PAGE_JS,
|
||
)
|