Files
audio2text/app/views/home_html.py
audio2text dev 7635e5e766 feat: 设置页调 batch_size + 删除任务 + /docs 去认证 + 离线模式
设置页(/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)
2026-07-11 11:33:34 +08:00

211 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""主页:上传入口 + 最近 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,
)