titusgiap Claude Opus 5 commited on
Commit
20e69c0
·
1 Parent(s): cc970b0

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 files

Lỗ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 CHANGED
@@ -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
- jobs.submit(job, work)
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
- jobs.submit(job, work)
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
- jobs.submit(job, work)
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
- jobs.submit(job, work)
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
- jobs.submit(job, work)
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
- jobs.submit(job, work)
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
 
backend/app/core/files.py CHANGED
@@ -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:
backend/app/services/dubber/tts.py CHANGED
@@ -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",
backend/tests/test_files.py CHANGED
@@ -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
+