Spaces:
Running
fix(dub): dọn phiên quá hạn xoá trúng phiên đang chạy -> job chết, trả video gốc
Browse filesLỗi người dùng gặp:
Không tổng hợp giọng được nên trả video gốc: ffmpeg ... anullsrc ...
line_0000.wav returned non-zero exit status 254
và last_error trên Space:
FileNotFoundError: '/data/tnt_work/dub_.../work/tts/line_0000.wav.mp3'
Cả engine chính, Edge đọc đỡ, lẫn bước tạo file lặng đều không ghi nổi file
-> thư mục làm việc đã BIẾN MẤT giữa job. Thủ phạm là cleanup_old: nó xét
tuổi bằng mtime của THƯ MỤC PHIÊN, mà mtime thư mục chỉ đổi khi thêm/bớt mục
con TRỰC TIẾP. Job ghi sâu bên trong (out/<video>/work/tts/...) không hề chạm
tới mtime đó, nên phiên đang xử lý vẫn bị tính là quá hạn và bị rmtree ngay
giữa chừng (sweep chạy ké mỗi lần có người mở phiên mới).
- Đếm job đang chạy theo phiên (session_in_use); cleanup_old bỏ qua phiên bận.
- cleanup_old cũng thôi đụng thư mục dùng chung (voice_previews) — không phải
phiên thì không có "hạn".
- Mọi bước ghi file TTS tự dựng lại thư mục nếu mất, để một sự cố tương tự chỉ
làm chậm chứ không giết cả job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- backend/app/api/dub.py +21 -7
- backend/app/core/files.py +47 -1
- backend/app/services/dubber/tts.py +14 -0
- backend/tests/test_files.py +42 -0
|
@@ -18,7 +18,7 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
|
| 18 |
from fastapi.responses import FileResponse
|
| 19 |
from pydantic import BaseModel
|
| 20 |
|
| 21 |
-
from ..core.files import new_session, rel_path, save_upload, save_upload_stream, session_dir, sub_dir
|
| 22 |
from ..core.jobs import jobs
|
| 23 |
from ..config import settings
|
| 24 |
from ..services.dubber import pipeline
|
|
@@ -31,6 +31,20 @@ router = APIRouter(prefix="/api/dub", tags=["dub"])
|
|
| 31 |
LANGS = {"en", "vi", "fil"}
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
@router.get("/voices")
|
| 35 |
def voices(lang: str = "vi"):
|
| 36 |
"""Danh sách giọng + engine SẼ THẬT SỰ CHẠY cho ngôn ngữ đó.
|
|
@@ -299,7 +313,7 @@ def start(req: DubReq):
|
|
| 299 |
|
| 300 |
return {"session": req.session, "outputs": outputs}
|
| 301 |
|
| 302 |
-
|
| 303 |
return {"job_id": job.id, "session": req.session, "videos": len(req.files)}
|
| 304 |
|
| 305 |
|
|
@@ -412,7 +426,7 @@ def prepare(req: DubReq):
|
|
| 412 |
h.set_progress((idx + 1) / max(1, n))
|
| 413 |
return {"session": req.session, "outputs": outputs}
|
| 414 |
|
| 415 |
-
|
| 416 |
return {"job_id": job.id, "session": req.session, "videos": len(req.files)}
|
| 417 |
|
| 418 |
|
|
@@ -496,7 +510,7 @@ def synthesize(req: SynthReq):
|
|
| 496 |
_auto_save_dub(_username, req.session, outputs)
|
| 497 |
return {"session": req.session, "outputs": outputs}
|
| 498 |
|
| 499 |
-
|
| 500 |
return {"job_id": job.id, "session": req.session, "videos": len(req.items)}
|
| 501 |
|
| 502 |
|
|
@@ -590,7 +604,7 @@ def tts_from_text(req: TtsReq):
|
|
| 590 |
h.item_status(it, "error", error=str(e))
|
| 591 |
return {"session": sid, "error": str(e)}
|
| 592 |
|
| 593 |
-
|
| 594 |
return {"job_id": job.id, "session": sid}
|
| 595 |
|
| 596 |
|
|
@@ -640,7 +654,7 @@ def subtitle_transcribe(req: SubTranscribeReq):
|
|
| 640 |
h.item_status(it, "error", error=str(e))
|
| 641 |
return {"session": req.session, "error": str(e)}
|
| 642 |
|
| 643 |
-
|
| 644 |
return {"job_id": job.id, "session": req.session}
|
| 645 |
|
| 646 |
|
|
@@ -721,7 +735,7 @@ def subtitle_burn(req: SubBurnReq):
|
|
| 721 |
h.item_status(it, "error", error=str(e))
|
| 722 |
return {"session": req.session, "error": str(e)}
|
| 723 |
|
| 724 |
-
|
| 725 |
return {"job_id": job.id, "session": req.session}
|
| 726 |
|
| 727 |
|
|
|
|
| 18 |
from fastapi.responses import FileResponse
|
| 19 |
from pydantic import BaseModel
|
| 20 |
|
| 21 |
+
from ..core.files import session_in_use, new_session, rel_path, save_upload, save_upload_stream, session_dir, sub_dir
|
| 22 |
from ..core.jobs import jobs
|
| 23 |
from ..config import settings
|
| 24 |
from ..services.dubber import pipeline
|
|
|
|
| 31 |
LANGS = {"en", "vi", "fil"}
|
| 32 |
|
| 33 |
|
| 34 |
+
def _submit_guarded(job, work, sid: str):
|
| 35 |
+
"""Chạy job và GIỮ CHỖ phiên trong suốt lúc chạy.
|
| 36 |
+
|
| 37 |
+
Không giữ thì bộ dọn phiên quá hạn (chạy ké mỗi lần có người mở phiên mới)
|
| 38 |
+
có thể xoá đúng thư mục job đang ghi -> TTS FileNotFoundError, ffmpeg exit
|
| 39 |
+
254, job chết và chỉ trả lại video gốc.
|
| 40 |
+
"""
|
| 41 |
+
def guarded(h):
|
| 42 |
+
with session_in_use(sid):
|
| 43 |
+
return work(h)
|
| 44 |
+
|
| 45 |
+
jobs.submit(job, guarded)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
@router.get("/voices")
|
| 49 |
def voices(lang: str = "vi"):
|
| 50 |
"""Danh sách giọng + engine SẼ THẬT SỰ CHẠY cho ngôn ngữ đó.
|
|
|
|
| 313 |
|
| 314 |
return {"session": req.session, "outputs": outputs}
|
| 315 |
|
| 316 |
+
_submit_guarded(job, work, req.session)
|
| 317 |
return {"job_id": job.id, "session": req.session, "videos": len(req.files)}
|
| 318 |
|
| 319 |
|
|
|
|
| 426 |
h.set_progress((idx + 1) / max(1, n))
|
| 427 |
return {"session": req.session, "outputs": outputs}
|
| 428 |
|
| 429 |
+
_submit_guarded(job, work, req.session)
|
| 430 |
return {"job_id": job.id, "session": req.session, "videos": len(req.files)}
|
| 431 |
|
| 432 |
|
|
|
|
| 510 |
_auto_save_dub(_username, req.session, outputs)
|
| 511 |
return {"session": req.session, "outputs": outputs}
|
| 512 |
|
| 513 |
+
_submit_guarded(job, work, req.session)
|
| 514 |
return {"job_id": job.id, "session": req.session, "videos": len(req.items)}
|
| 515 |
|
| 516 |
|
|
|
|
| 604 |
h.item_status(it, "error", error=str(e))
|
| 605 |
return {"session": sid, "error": str(e)}
|
| 606 |
|
| 607 |
+
_submit_guarded(job, work, sid)
|
| 608 |
return {"job_id": job.id, "session": sid}
|
| 609 |
|
| 610 |
|
|
|
|
| 654 |
h.item_status(it, "error", error=str(e))
|
| 655 |
return {"session": req.session, "error": str(e)}
|
| 656 |
|
| 657 |
+
_submit_guarded(job, work, req.session)
|
| 658 |
return {"job_id": job.id, "session": req.session}
|
| 659 |
|
| 660 |
|
|
|
|
| 735 |
h.item_status(it, "error", error=str(e))
|
| 736 |
return {"session": req.session, "error": str(e)}
|
| 737 |
|
| 738 |
+
_submit_guarded(job, work, req.session)
|
| 739 |
return {"job_id": job.id, "session": req.session}
|
| 740 |
|
| 741 |
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import os
|
| 6 |
import shutil
|
|
|
|
|
|
|
| 7 |
import time
|
| 8 |
import uuid
|
| 9 |
import zipfile
|
|
@@ -123,13 +125,57 @@ def delete_session(sid: str) -> bool:
|
|
| 123 |
return False
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
def cleanup_old(max_age_hours: float = 6):
|
| 127 |
-
"""Dọn các phiên tạm quá hạn (mặc định 6 giờ).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
now = time.time()
|
| 129 |
root = settings.STORAGE_DIR
|
| 130 |
if not os.path.isdir(root):
|
| 131 |
return
|
| 132 |
for name in os.listdir(root):
|
|
|
|
|
|
|
| 133 |
p = os.path.join(root, name)
|
| 134 |
try:
|
| 135 |
if now - os.path.getmtime(p) > max_age_hours * 3600:
|
|
|
|
| 4 |
|
| 5 |
import os
|
| 6 |
import shutil
|
| 7 |
+
import threading
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
import time
|
| 10 |
import uuid
|
| 11 |
import zipfile
|
|
|
|
| 125 |
return False
|
| 126 |
|
| 127 |
|
| 128 |
+
# ── Phiên ĐANG CHẠY job — cấm dọn ────────────────────────────────
|
| 129 |
+
# cleanup_old xét tuổi bằng mtime của THƯ MỤC PHIÊN, mà mtime thư mục chỉ đổi
|
| 130 |
+
# khi thêm/bớt mục CON TRỰC TIẾP. Job ghi file sâu bên trong (out/<video>/work/
|
| 131 |
+
# tts/...) không hề chạm vào mtime đó -> một phiên đang xử lý vẫn bị coi là
|
| 132 |
+
# "quá hạn" và bị xoá NGAY GIỮA JOB. Hậu quả thấy trên Space: TTS ghi file thì
|
| 133 |
+
# FileNotFoundError, cả ffmpeg tạo file lặng cũng exit 254, job chết trả về
|
| 134 |
+
# video gốc. Vì thế đếm job đang chạy theo phiên và bỏ qua khi dọn.
|
| 135 |
+
_ACTIVE: dict[str, int] = {}
|
| 136 |
+
_ACTIVE_LOCK = threading.Lock()
|
| 137 |
+
|
| 138 |
+
# Thư mục trong STORAGE_DIR KHÔNG phải phiên -> đừng dọn theo tuổi phiên.
|
| 139 |
+
_NOT_SESSIONS = {"voice_previews"}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@contextmanager
|
| 143 |
+
def session_in_use(sid: str):
|
| 144 |
+
"""Đánh dấu phiên đang có job chạy (lồng nhau được, đếm tham chiếu)."""
|
| 145 |
+
if not sid:
|
| 146 |
+
yield
|
| 147 |
+
return
|
| 148 |
+
with _ACTIVE_LOCK:
|
| 149 |
+
_ACTIVE[sid] = _ACTIVE.get(sid, 0) + 1
|
| 150 |
+
try:
|
| 151 |
+
yield
|
| 152 |
+
finally:
|
| 153 |
+
with _ACTIVE_LOCK:
|
| 154 |
+
n = _ACTIVE.get(sid, 1) - 1
|
| 155 |
+
if n > 0:
|
| 156 |
+
_ACTIVE[sid] = n
|
| 157 |
+
else:
|
| 158 |
+
_ACTIVE.pop(sid, None)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def session_is_busy(sid: str) -> bool:
|
| 162 |
+
with _ACTIVE_LOCK:
|
| 163 |
+
return _ACTIVE.get(sid, 0) > 0
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def cleanup_old(max_age_hours: float = 6):
|
| 167 |
+
"""Dọn các phiên tạm quá hạn (mặc định 6 giờ).
|
| 168 |
+
|
| 169 |
+
KHÔNG đụng vào phiên đang chạy job, và không đụng thư mục dùng chung
|
| 170 |
+
(vd voice_previews) — chúng không phải phiên nên không có "hạn".
|
| 171 |
+
"""
|
| 172 |
now = time.time()
|
| 173 |
root = settings.STORAGE_DIR
|
| 174 |
if not os.path.isdir(root):
|
| 175 |
return
|
| 176 |
for name in os.listdir(root):
|
| 177 |
+
if name in _NOT_SESSIONS or session_is_busy(name):
|
| 178 |
+
continue
|
| 179 |
p = os.path.join(root, name)
|
| 180 |
try:
|
| 181 |
if now - os.path.getmtime(p) > max_age_hours * 3600:
|
|
@@ -515,7 +515,20 @@ def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
|
|
| 515 |
for (label, vid) in _static_voices(lang, name)]
|
| 516 |
|
| 517 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
def _run_engine(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
|
|
|
|
| 519 |
engine.synth(text, out_wav, cfg)
|
| 520 |
# Một số wrapper trả về không lỗi nhưng KHÔNG ghi file -> verify để bước sau
|
| 521 |
# (fit_to_slot) không FileNotFound làm vỡ pipeline.
|
|
@@ -574,6 +587,7 @@ def is_silent_wav(path: str) -> bool:
|
|
| 574 |
|
| 575 |
|
| 576 |
def _silence(out_wav: str, dur: float):
|
|
|
|
| 577 |
subprocess.run(
|
| 578 |
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 579 |
"-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
|
|
|
|
| 515 |
for (label, vid) in _static_voices(lang, name)]
|
| 516 |
|
| 517 |
|
| 518 |
+
def _ensure_dir(path: str) -> None:
|
| 519 |
+
"""Dựng lại thư mục chứa file sắp ghi nếu nó không còn.
|
| 520 |
+
|
| 521 |
+
Thư mục làm việc có thể BIẾN MẤT giữa chừng (dọn phiên quá hạn quét trúng
|
| 522 |
+
phiên đang chạy). Không dựng lại thì cả engine lẫn bước tạo file lặng đều
|
| 523 |
+
ném FileNotFoundError -> chết nguyên job, người dùng chỉ nhận lại video gốc.
|
| 524 |
+
"""
|
| 525 |
+
d = os.path.dirname(os.path.abspath(path))
|
| 526 |
+
if d:
|
| 527 |
+
os.makedirs(d, exist_ok=True)
|
| 528 |
+
|
| 529 |
+
|
| 530 |
def _run_engine(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
|
| 531 |
+
_ensure_dir(out_wav)
|
| 532 |
engine.synth(text, out_wav, cfg)
|
| 533 |
# Một số wrapper trả về không lỗi nhưng KHÔNG ghi file -> verify để bước sau
|
| 534 |
# (fit_to_slot) không FileNotFound làm vỡ pipeline.
|
|
|
|
| 587 |
|
| 588 |
|
| 589 |
def _silence(out_wav: str, dur: float):
|
| 590 |
+
_ensure_dir(out_wav)
|
| 591 |
subprocess.run(
|
| 592 |
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 593 |
"-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
|
|
@@ -90,3 +90,45 @@ def test_cleanup_old_removes_aged():
|
|
| 90 |
fresh = files.new_session("download") # phiên mới phải còn nguyên
|
| 91 |
files.cleanup_old(max_age_hours=6)
|
| 92 |
assert os.path.isdir(files.session_dir(fresh))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
fresh = files.new_session("download") # phiên mới phải còn nguyên
|
| 91 |
files.cleanup_old(max_age_hours=6)
|
| 92 |
assert os.path.isdir(files.session_dir(fresh))
|
| 93 |
+
|
| 94 |
+
def test_cleanup_spares_busy_session_and_shared_dirs():
|
| 95 |
+
"""Dọn phiên quá hạn KHÔNG được đụng phiên đang chạy job.
|
| 96 |
+
|
| 97 |
+
Lỗi thật trên Space: mtime của thư mục phiên chỉ đổi khi thêm/bớt mục con
|
| 98 |
+
TRỰC TIẾP, mà job ghi file sâu bên trong (out/<video>/work/tts/...) nên
|
| 99 |
+
phiên đang xử lý vẫn bị coi là quá hạn -> xoá giữa chừng, TTS ném
|
| 100 |
+
FileNotFoundError và ffmpeg tạo file lặng cũng exit 254.
|
| 101 |
+
"""
|
| 102 |
+
import os
|
| 103 |
+
import time
|
| 104 |
+
from app.core import files as F
|
| 105 |
+
|
| 106 |
+
root = settings.STORAGE_DIR
|
| 107 |
+
busy = os.path.join(root, "dub_busy_000000")
|
| 108 |
+
idle = os.path.join(root, "dub_idle_000000")
|
| 109 |
+
shared = os.path.join(root, "voice_previews")
|
| 110 |
+
for d in (busy, idle, shared):
|
| 111 |
+
os.makedirs(os.path.join(d, "out", "v", "work", "tts"), exist_ok=True)
|
| 112 |
+
old = time.time() - 48 * 3600
|
| 113 |
+
os.utime(d, (old, old)) # cả ba đều "quá hạn"
|
| 114 |
+
|
| 115 |
+
with F.session_in_use("dub_busy_000000"):
|
| 116 |
+
F.cleanup_old(6)
|
| 117 |
+
assert os.path.isdir(busy), "phiên đang chạy job bị xoá mất"
|
| 118 |
+
assert os.path.isdir(shared), "thư mục dùng chung không phải phiên, đừng xoá"
|
| 119 |
+
assert not os.path.isdir(idle), "phiên rảnh quá hạn thì vẫn phải dọn"
|
| 120 |
+
|
| 121 |
+
F.cleanup_old(6) # hết job -> mới được dọn
|
| 122 |
+
assert not os.path.isdir(busy)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_session_in_use_nested():
|
| 126 |
+
"""Hai job cùng phiên -> chỉ hết bận khi job cuối xong."""
|
| 127 |
+
from app.core import files as F
|
| 128 |
+
|
| 129 |
+
with F.session_in_use("s1"):
|
| 130 |
+
with F.session_in_use("s1"):
|
| 131 |
+
assert F.session_is_busy("s1")
|
| 132 |
+
assert F.session_is_busy("s1") # job ngoài vẫn chạy
|
| 133 |
+
assert not F.session_is_busy("s1")
|
| 134 |
+
|