""" app/web_server.py ────────────────── FastAPI Web Application & API Server for Cloud-Native Auto Dubbing Studio. Accessible from any mobile phone, tablet, or browser anywhere. """ import os import sys import json import time import shutil import asyncio import uuid import base64 from pathlib import Path from typing import Dict, Any, Optional, List # Add project root to sys.path project_root = Path(__file__).resolve().parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from fastapi import FastAPI, UploadFile, File, Form, WebSocket, WebSocketDisconnect, BackgroundTasks, HTTPException, Body from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware import uvicorn from app.core.cloud_pipeline import CloudPipeline from app.api_pool import ApiPool from app.core.job_manager import JobManager app = FastAPI(title="TrungSangViet Auto Dubbing Cloud Studio", version="2.5.0") # Enable CORS for remote access app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) BASE_DIR = project_root STATIC_DIR = BASE_DIR / "app" / "static" UPLOAD_DIR = BASE_DIR / "temp" / "uploads" OUTPUT_DIR = BASE_DIR / "output" TEMP_DIR = BASE_DIR / "temp" STATIC_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) TEMP_DIR.mkdir(parents=True, exist_ok=True) # Mount static assets app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # WebSocket connection manager for live logs class ConnectionManager: def __init__(self): self.active_connections: Dict[str, list[WebSocket]] = {} async def connect(self, job_id: str, websocket: WebSocket): await websocket.accept() if job_id not in self.active_connections: self.active_connections[job_id] = [] self.active_connections[job_id].append(websocket) def disconnect(self, job_id: str, websocket: WebSocket): if job_id in self.active_connections: if websocket in self.active_connections[job_id]: self.active_connections[job_id].remove(websocket) async def broadcast(self, job_id: str, message: dict): if job_id in self.active_connections: for connection in self.active_connections[job_id]: try: await connection.send_json(message) except Exception: pass manager = ConnectionManager() # In-memory jobs cache & database sync jobs_db: Dict[str, Dict[str, Any]] = {} job_manager = JobManager.instance() _JOBS_SNAPSHOT = BASE_DIR / "temp" / "jobs_snapshot.json" def _save_jobs_snapshot(): # FIX: jobs_db thuần in-memory -> restart server là mất hết job/log. # Snapshot JSON để khởi động lại vẫn còn trạng thái DONE trước đó. try: serializable = {} for k, v in jobs_db.items(): serializable[k] = {kk: vv for kk, vv in v.items() if kk != "subtitles"} _JOBS_SNAPSHOT.write_text(json.dumps(serializable, ensure_ascii=False), encoding="utf-8") except Exception: pass def _load_jobs_snapshot(): try: if _JOBS_SNAPSHOT.exists(): data = json.loads(_JOBS_SNAPSHOT.read_text(encoding="utf-8")) if isinstance(data, dict): jobs_db.update(data) except Exception: pass _load_jobs_snapshot() @app.get("/", response_class=HTMLResponse) async def serve_index(): index_file = STATIC_DIR / "index.html" if index_file.exists(): return HTMLResponse(content=index_file.read_text(encoding="utf-8")) return HTMLResponse(content="

TrungSangViet Auto Dubbing Cloud Studio is running!

") @app.get("/api/system-status") async def get_system_status(): pool = ApiPool.instance() pool.reload() return { "status": "online", "healthy_slots": pool.count_healthy(), "total_slots": pool.count_slots(), "report": pool.status_report() } @app.post("/api/upload-logo") async def upload_logo(file: UploadFile = File(...)): """Upload logo image/video (PNG/JPG/MP4) for overlay. Returns saved_path for preview/render.""" ext = Path(file.filename).suffix.lower() or ".png" if ext not in (".png", ".jpg", ".jpeg", ".webp", ".mp4", ".mov", ".webm"): ext = ".png" saved_name = f"logo_{uuid.uuid4().hex[:8]}{ext}" saved_path = UPLOAD_DIR / saved_name with open(saved_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) return {"status": "ok", "logo_path": str(saved_path), "filename": saved_name} @app.post("/api/upload-cta") async def upload_cta(file: UploadFile = File(...)): """Upload CTA video (MP4/MOV) for overlay. Returns saved_path.""" ext = Path(file.filename).suffix.lower() or ".mp4" if ext not in (".mp4", ".mov", ".webm", ".avi"): ext = ".mp4" saved_name = f"cta_{uuid.uuid4().hex[:8]}{ext}" saved_path = UPLOAD_DIR / saved_name with open(saved_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) return {"status": "ok", "cta_path": str(saved_path), "filename": saved_name} @app.post("/api/upload") async def upload_video(file: UploadFile = File(...)): job_id = str(uuid.uuid4())[:8] ext = Path(file.filename).suffix or ".mp4" saved_name = f"{job_id}_{file.filename}" saved_path = UPLOAD_DIR / saved_name with open(saved_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) return { "job_id": job_id, "filename": file.filename, "saved_path": str(saved_path) } def _url_host_is_private(url: str) -> bool: # Chặn SSRF: không cho request tới IP nội bộ/loopback/link-local. try: import ipaddress import socket from urllib.parse import urlparse host = urlparse(url).hostname or "" if host in ("localhost",): return True try: ip = ipaddress.ip_address(host) return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved except ValueError: pass try: resolved = socket.getaddrinfo(host, None, family=socket.AF_UNSPEC) for fam, _, _, _, sockaddr in resolved: ip = ipaddress.ip_address(sockaddr[0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return True except Exception: return True return False except Exception: return True @app.post("/api/download-url") async def download_video_url(url: str = Form(...)): from urllib.parse import urlparse scheme = urlparse(url).scheme.lower() if scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="URL phải bắt đầu bằng http:// hoặc https://") if _url_host_is_private(url): raise HTTPException(status_code=400, detail="URL nội bộ không được phép (chống SSRF).") job_id = str(uuid.uuid4())[:8] saved_name = f"{job_id}_video.mp4" saved_path = UPLOAD_DIR / saved_name part_path = UPLOAD_DIR / f"{saved_name}.part" # Try downloading with yt-dlp (atomic .part + rename) try: import subprocess cmd = ["yt-dlp", "-f", "best[ext=mp4]/best", "-o", str(part_path), url] res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90) if res.returncode == 0 and part_path.exists() and part_path.stat().st_size > 5000: part_path.replace(saved_path) return {"job_id": job_id, "saved_path": str(saved_path), "filename": saved_name} except Exception: pass finally: try: if part_path.exists() and not saved_path.exists(): part_path.unlink() except Exception: pass # Fallback direct download (chỉ nhận content-type video, giới hạn 500MB) import requests try: r = requests.get(url, stream=True, timeout=30, headers={"User-Agent": "Mozilla/5.0"}) if r.status_code == 200: ctype = (r.headers.get("Content-Type", "") or "").lower() if "video" not in ctype and "octet-stream" not in ctype: raise HTTPException(status_code=400, detail=f"URL không trỏ tới file video (Content-Type: {ctype or 'unknown'}).") total = 0 with open(part_path, "wb") as f: for chunk in r.iter_content(chunk_size=1024*1024): if chunk: total += len(chunk) if total > 500 * 1024 * 1024: raise HTTPException(status_code=400, detail="File video vượt quá 500MB.") f.write(chunk) if part_path.exists() and part_path.stat().st_size > 5000: part_path.replace(saved_path) return {"job_id": job_id, "saved_path": str(saved_path), "filename": saved_name} except HTTPException: raise except Exception: pass finally: try: if part_path.exists() and not saved_path.exists(): part_path.unlink() except Exception: pass raise HTTPException(status_code=400, detail="Không thể tải video từ đường link này. Vui lòng kiểm tra lại URL.") @app.post("/api/preview-frame") async def get_preview_frame( video_path: str = Form(...), sec: float = Form(2.0), region_preset: str = Form("custom"), custom_y_pct: float = Form(75.0), custom_h_pct: float = Form(20.0), custom_x_pct: float = Form(0.0), custom_w_pct: float = Form(100.0), logo_enabled: bool = Form(False), logo_path: str = Form(""), logo_preset: str = Form("bottom_right"), logo_scale: float = Form(18.0), logo_opacity: float = Form(1.0), logo_x_pct: float = Form(80.0), logo_y_pct: float = Form(80.0), logo_chromakey: bool = Form(True), cta_enabled: bool = Form(False), cta_path: str = Form(""), cta_preset: str = Form("bottom_center"), cta_scale: float = Form(48.0), cta_opacity: float = Form(1.0), cta_x_pct: float = Form(50.0), cta_y_pct: float = Form(82.0), cta_interval: float = Form(5.0), cta_duration: float = Form(2.0), cta_chromakey: bool = Form(True) ): if not Path(video_path).exists(): raise HTTPException(status_code=404, detail="File video không tồn tại.") # Fallback to default bundled assets if enabled but no path supplied if logo_enabled and not logo_path: default_logo = BASE_DIR / "assets" / "logo_ins_drippy4.png" if default_logo.exists(): logo_path = str(default_logo) if cta_enabled and not cta_path: default_cta = BASE_DIR / "assets" / "cta_ins_drippy4.mp4" if default_cta.exists(): cta_path = str(default_cta) pipeline = CloudPipeline(base_dir=BASE_DIR) b64_img = pipeline.generate_preview_frame( video_path=video_path, region_preset=region_preset, custom_y_pct=custom_y_pct, custom_h_pct=custom_h_pct, custom_x_pct=custom_x_pct, custom_w_pct=custom_w_pct, sec=sec, return_type="base64", logo_enabled=logo_enabled, logo_path=logo_path, logo_preset=logo_preset, logo_scale=logo_scale, logo_opacity=logo_opacity, logo_x_pct=logo_x_pct, logo_y_pct=logo_y_pct, logo_chromakey=logo_chromakey, cta_enabled=cta_enabled, cta_path=cta_path, cta_preset=cta_preset, cta_scale=cta_scale, cta_opacity=cta_opacity, cta_x_pct=cta_x_pct, cta_y_pct=cta_y_pct, cta_interval=cta_interval, cta_duration=cta_duration, cta_chromakey=cta_chromakey ) if b64_img: return {"status": "ok", "image_base64": f"data:image/jpeg;base64,{b64_img}"} raise HTTPException(status_code=500, detail="Không thể trích xuất khung hình từ video.") @app.post("/api/tts-preview") async def tts_preview( text: str = Form("Xin chào, đây là giọng đọc thử nghiệm của Trung Sáng Việt Studio."), voice: str = Form("vi-VN-NamMinhNeural"), speed: float = Form(1.0), pitch: int = Form(0), volume: int = Form(100) ): """Generates an instant TTS audio preview as base64 MP3.""" import edge_tts import tempfile rate_str = f"{int(round((speed - 1.0) * 100)):+d}%" pitch_str = f"{int(round(pitch)):+d}Hz" vol_str = f"{int(round(volume - 100)):+d}%" temp_mp3 = Path(tempfile.gettempdir()) / f"tts_sample_{int(time.time()*1000)}.mp3" try: communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_str, pitch=pitch_str, volume=vol_str) # FIX: hàm đã là async — await trực tiếp thay vì tạo event loop mới (leak + lỗi loop). await communicate.save(str(temp_mp3)) if temp_mp3.exists() and temp_mp3.stat().st_size > 100: audio_bytes = temp_mp3.read_bytes() b64_audio = base64.b64encode(audio_bytes).decode('utf-8') temp_mp3.unlink() return {"status": "ok", "audio_base64": f"data:audio/mp3;base64,{b64_audio}"} except Exception as e: if temp_mp3.exists(): temp_mp3.unlink() raise HTTPException(status_code=500, detail=f"Lỗi tạo giọng đọc: {str(e)}") raise HTTPException(status_code=500, detail="Không thể tạo file âm thanh mẫu.") @app.post("/api/start-pipeline") async def start_pipeline( background_tasks: BackgroundTasks, job_id: str = Form(...), video_path: str = Form(...), mode: str = Form("asr"), source_lang: str = Form("zh"), voice: str = Form("vi-VN-NamMinhNeural"), speed: float = Form(1.0), pitch: int = Form(0), volume: int = Form(100), region_preset: str = Form("custom"), sub_mask_mode: str = Form("box"), sub_style: str = Form("motion_drip"), custom_y_pct: float = Form(75.0), custom_h_pct: float = Form(20.0), custom_x_pct: float = Form(0.0), custom_w_pct: float = Form(100.0), ducking_ratio: float = Form(0.18), enable_sfx: bool = Form(True), mute_original_audio: bool = Form(False), logo_enabled: bool = Form(False), logo_path: str = Form(""), logo_preset: str = Form("bottom_right"), logo_scale: float = Form(18.0), logo_opacity: float = Form(1.0), logo_x_pct: float = Form(80.0), logo_y_pct: float = Form(80.0), logo_chromakey: bool = Form(True), cta_enabled: bool = Form(False), cta_path: str = Form(""), cta_preset: str = Form("bottom_center"), cta_scale: float = Form(48.0), cta_opacity: float = Form(1.0), cta_x_pct: float = Form(50.0), cta_y_pct: float = Form(82.0), cta_interval: float = Form(5.0), cta_duration: float = Form(2.5), cta_chromakey: bool = Form(True) ): if not Path(video_path).exists(): raise HTTPException(status_code=404, detail="File video không tồn tại.") jobs_db[job_id] = { "job_id": job_id, "video_path": video_path, "filename": Path(video_path).name, "status": "PROCESSING", "progress": 0, "stage": "STAGE_0_PREPARE", "output_path": None, "error": None, "created_at": time.time() } _save_jobs_snapshot() def run_job(): # NOTE: loop thừa (tạo mà không run/close -> leak) đã xóa. # Callbacks dùng asyncio.run (loop riêng) vì chạy trong worker thread của BackgroundTasks. def log_cb(msg: str): asyncio.run(manager.broadcast(job_id, {"type": "log", "text": msg, "time": time.strftime("%H:%M:%S")})) def progress_cb(pct: int, stage_name: str): jobs_db[job_id]["progress"] = pct jobs_db[job_id]["stage"] = stage_name asyncio.run(manager.broadcast(job_id, {"type": "progress", "percent": pct, "stage": stage_name})) pipeline = CloudPipeline( base_dir=BASE_DIR, log_callback=log_cb, progress_callback=progress_cb ) # Default logo/cta fallback if logo_enabled and not logo_path: _dl = BASE_DIR / "assets" / "logo_ins_drippy4.png" if _dl.exists(): logo_path = str(_dl) if cta_enabled and not cta_path: _dc = BASE_DIR / "assets" / "cta_ins_drippy4.mp4" if _dc.exists(): cta_path = str(_dc) out_path = pipeline.run_video( video_path=video_path, mode=mode, source_lang=source_lang, voice=voice, speed=speed, pitch=pitch, volume=volume, region_preset=region_preset, sub_mask_mode=sub_mask_mode, sub_style=sub_style, custom_y_pct=custom_y_pct, custom_h_pct=custom_h_pct, custom_x_pct=custom_x_pct, custom_w_pct=custom_w_pct, ducking_ratio=ducking_ratio, enable_sfx=enable_sfx, mute_original_audio=mute_original_audio, logo_enabled=logo_enabled, logo_path=logo_path, logo_preset=logo_preset, logo_scale=logo_scale, logo_opacity=logo_opacity, logo_x_pct=logo_x_pct, logo_y_pct=logo_y_pct, logo_chromakey=logo_chromakey, cta_enabled=cta_enabled, cta_path=cta_path, cta_preset=cta_preset, cta_scale=cta_scale, cta_opacity=cta_opacity, cta_x_pct=cta_x_pct, cta_y_pct=cta_y_pct, cta_interval=cta_interval, cta_duration=cta_duration, cta_chromakey=cta_chromakey ) if out_path: jobs_db[job_id]["status"] = "DONE" jobs_db[job_id]["output_path"] = out_path _save_jobs_snapshot() asyncio.run(manager.broadcast(job_id, { "type": "done", "output_url": f"/api/download-result/{Path(out_path).name}" })) else: jobs_db[job_id]["status"] = "FAILED" _save_jobs_snapshot() asyncio.run(manager.broadcast(job_id, {"type": "error", "message": "Pipeline execution failed."})) background_tasks.add_task(run_job) return {"status": "started", "job_id": job_id} @app.post("/api/start-translation-only") async def start_translation_only( background_tasks: BackgroundTasks, job_id: str = Form(...), video_path: str = Form(...), mode: str = Form("asr"), source_lang: str = Form("zh"), region_preset: str = Form("custom"), custom_y_pct: float = Form(75.0), custom_h_pct: float = Form(20.0), custom_x_pct: float = Form(0.0), custom_w_pct: float = Form(100.0) ): """Runs extraction & translation only, then stops for user review in Subtitle Editor.""" if not Path(video_path).exists(): raise HTTPException(status_code=404, detail="File video không tồn tại.") jobs_db[job_id] = { "job_id": job_id, "video_path": video_path, "filename": Path(video_path).name, "status": "WAITING_REVIEW", "progress": 50, "stage": "STAGE_C_TRANSLATION", "subtitles": [], "output_path": None, "created_at": time.time() } _save_jobs_snapshot() def run_trans(): def log_cb(msg: str): asyncio.run(manager.broadcast(job_id, {"type": "log", "text": msg, "time": time.strftime("%H:%M:%S")})) def progress_cb(pct: int, stage_name: str): jobs_db[job_id]["progress"] = pct jobs_db[job_id]["stage"] = stage_name asyncio.run(manager.broadcast(job_id, {"type": "progress", "percent": pct, "stage": stage_name})) pipeline = CloudPipeline(base_dir=BASE_DIR, log_callback=log_cb, progress_callback=progress_cb) res = pipeline.extract_subtitles_only( video_path=video_path, mode=mode, source_lang=source_lang, region_preset=region_preset, custom_y_pct=custom_y_pct, custom_h_pct=custom_h_pct, custom_x_pct=custom_x_pct, custom_w_pct=custom_w_pct ) if res and "blocks" in res: jobs_db[job_id]["status"] = "WAITING_REVIEW" jobs_db[job_id]["subtitles"] = res["blocks"] _save_jobs_snapshot() asyncio.run(manager.broadcast(job_id, { "type": "subtitles_ready", "blocks": res["blocks"] })) else: jobs_db[job_id]["status"] = "FAILED" _save_jobs_snapshot() asyncio.run(manager.broadcast(job_id, {"type": "error", "message": "Bóc tách & dịch thuật thất bại."})) background_tasks.add_task(run_trans) return {"status": "started", "job_id": job_id} @app.get("/api/jobs/{job_id}/subtitles") async def get_job_subtitles(job_id: str): if job_id in jobs_db and "subtitles" in jobs_db[job_id]: return {"status": "ok", "blocks": jobs_db[job_id]["subtitles"]} raise HTTPException(status_code=404, detail="Subtitles not found for this job") @app.post("/api/jobs/{job_id}/continue-render") async def continue_render( background_tasks: BackgroundTasks, job_id: str, payload: Dict[str, Any] = Body(...) ): """Takes reviewed subtitles and renders final video.""" if job_id not in jobs_db: raise HTTPException(status_code=404, detail="Job not found") video_path = jobs_db[job_id]["video_path"] subtitles = payload.get("subtitles", jobs_db[job_id].get("subtitles", [])) voice = payload.get("voice", "vi-VN-NamMinhNeural") speed = float(payload.get("speed", 1.0)) pitch = int(payload.get("pitch", 0)) volume = int(payload.get("volume", 100)) region_preset = payload.get("region_preset", "custom") sub_mask_mode = payload.get("sub_mask_mode", "box") sub_style = payload.get("sub_style", "motion_drip") custom_y_pct = float(payload.get("custom_y_pct", 75.0)) custom_h_pct = float(payload.get("custom_h_pct", 20.0)) custom_x_pct = float(payload.get("custom_x_pct", 0.0)) custom_w_pct = float(payload.get("custom_w_pct", 100.0)) ducking_ratio = float(payload.get("ducking_ratio", 0.18)) enable_sfx = bool(payload.get("enable_sfx", True)) mute_original_audio = bool(payload.get("mute_original_audio", False)) logo_enabled = bool(payload.get("logo_enabled", False)) logo_path = str(payload.get("logo_path", "") or "") logo_preset = str(payload.get("logo_preset", "bottom_right")) logo_scale = float(payload.get("logo_scale", 18.0)) logo_opacity = float(payload.get("logo_opacity", 1.0)) logo_x_pct = float(payload.get("logo_x_pct", 80.0)) logo_y_pct = float(payload.get("logo_y_pct", 80.0)) logo_chromakey = bool(payload.get("logo_chromakey", True)) cta_enabled = bool(payload.get("cta_enabled", False)) cta_path = str(payload.get("cta_path", "") or "") cta_preset = str(payload.get("cta_preset", "bottom_center")) cta_scale = float(payload.get("cta_scale", 48.0)) cta_opacity = float(payload.get("cta_opacity", 1.0)) cta_x_pct = float(payload.get("cta_x_pct", 50.0)) cta_y_pct = float(payload.get("cta_y_pct", 82.0)) cta_interval = float(payload.get("cta_interval", 5.0)) cta_duration = float(payload.get("cta_duration", 2.0)) cta_chromakey = bool(payload.get("cta_chromakey", True)) if logo_enabled and not logo_path: _dl = BASE_DIR / "assets" / "logo_ins_drippy4.png" if _dl.exists(): logo_path = str(_dl) if cta_enabled and not cta_path: _dc = BASE_DIR / "assets" / "cta_ins_drippy4.mp4" if _dc.exists(): cta_path = str(_dc) jobs_db[job_id]["status"] = "PROCESSING" _save_jobs_snapshot() def run_render(): def log_cb(msg: str): asyncio.run(manager.broadcast(job_id, {"type": "log", "text": msg, "time": time.strftime("%H:%M:%S")})) def progress_cb(pct: int, stage_name: str): jobs_db[job_id]["progress"] = pct jobs_db[job_id]["stage"] = stage_name asyncio.run(manager.broadcast(job_id, {"type": "progress", "percent": pct, "stage": stage_name})) pipeline = CloudPipeline(base_dir=BASE_DIR, log_callback=log_cb, progress_callback=progress_cb) out_path = pipeline.render_from_subtitles( video_path=video_path, subtitles_data=subtitles, voice=voice, speed=speed, pitch=pitch, volume=volume, region_preset=region_preset, sub_mask_mode=sub_mask_mode, sub_style=sub_style, custom_y_pct=custom_y_pct, custom_h_pct=custom_h_pct, custom_x_pct=custom_x_pct, custom_w_pct=custom_w_pct, ducking_ratio=ducking_ratio, enable_sfx=enable_sfx, mute_original_audio=mute_original_audio, logo_enabled=logo_enabled, logo_path=logo_path, logo_preset=logo_preset, logo_scale=logo_scale, logo_opacity=logo_opacity, logo_x_pct=logo_x_pct, logo_y_pct=logo_y_pct, logo_chromakey=logo_chromakey, cta_enabled=cta_enabled, cta_path=cta_path, cta_preset=cta_preset, cta_scale=cta_scale, cta_opacity=cta_opacity, cta_x_pct=cta_x_pct, cta_y_pct=cta_y_pct, cta_interval=cta_interval, cta_duration=cta_duration, cta_chromakey=cta_chromakey ) if out_path: jobs_db[job_id]["status"] = "DONE" jobs_db[job_id]["output_path"] = out_path _save_jobs_snapshot() asyncio.run(manager.broadcast(job_id, { "type": "done", "output_url": f"/api/download-result/{Path(out_path).name}" })) else: jobs_db[job_id]["status"] = "FAILED" _save_jobs_snapshot() asyncio.run(manager.broadcast(job_id, {"type": "error", "message": "Render video thất bại."})) background_tasks.add_task(run_render) return {"status": "started", "job_id": job_id} @app.get("/api/download-result/{filename}") async def download_result(filename: str): # FIX path traversal: filename chứa ../ hoặc separator -> 404 ngay. if not filename or ".." in filename or "/" in filename or "\\" in filename: raise HTTPException(status_code=404, detail="File không tồn tại.") file_path = OUTPUT_DIR / filename if file_path.exists(): return FileResponse(path=file_path, filename=filename, media_type="video/mp4") raise HTTPException(status_code=404, detail="File không tồn tại.") @app.get("/api/jobs") async def list_jobs(): return list(jobs_db.values()) @app.get("/api/jobs/{job_id}") async def get_job_status(job_id: str): if job_id in jobs_db: return jobs_db[job_id] raise HTTPException(status_code=404, detail="Job not found") @app.get("/api/glossary") async def get_glossary(): glossary_path = BASE_DIR / "glossary.json" if glossary_path.exists(): return json.loads(glossary_path.read_text(encoding="utf-8")) return {"glossary": {}} @app.post("/api/glossary") async def update_glossary(data: Dict[str, Any] = Body(...)): glossary_path = BASE_DIR / "glossary.json" glossary_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") return {"status": "ok", "count": len(data.get("glossary", {}))} @app.websocket("/ws/logs/{job_id}") async def websocket_logs(websocket: WebSocket, job_id: str): await manager.connect(job_id, websocket) try: while True: await websocket.receive_text() except WebSocketDisconnect: manager.disconnect(job_id, websocket) def get_all_ips(): import socket ips = {"lan": "127.0.0.1", "tailscale": None} try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ips["lan"] = s.getsockname()[0] s.close() except Exception: pass try: hostname = socket.gethostname() for ip in socket.gethostbyname_ex(hostname)[2]: if ip.startswith("100."): ips["tailscale"] = ip break except Exception: pass return ips if __name__ == "__main__": port = int(os.environ.get("PORT", 7860)) ip_info = get_all_ips() lan_ip = ip_info.get("lan", "127.0.0.1") ts_ip = ip_info.get("tailscale") print("\n" + "=" * 65) print(" 🎬 TRUNG SÁNG VIỆT - AUTO DUBBING CLOUD STUDIO (v2.5)") print("=" * 65) print(f" 💻 Trên máy tính này : http://localhost:{port} hoặc http://127.0.0.1:{port}") print(f" 📱 Trên ĐIỆN THOẠI : http://{lan_ip}:{port} (Bắt buộc chung Wi-Fi)") if ts_ip: print(f" 🌐 Qua Tailscale VPN : http://{ts_ip}:{port}") print("=" * 65 + "\n") uvicorn.run(app, host="0.0.0.0", port=port)