Files
video-subtitle-remover/web_app.py
T
liuchenandClaude Haiku 4.5 693d394f55
Docker Build and Push / check-secrets (push) Successful in 30s
Docker Build and Push / build-and-push (cuda, 12.6) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.8) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 11.8) (push) Has been skipped
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
Add GPU support and multi-threaded subtitle detection
- Add gpu_id parameter to SubtitleDetect for GPU acceleration
- Implement multi-threaded frame scanning with thread-safe CUDA context
- Add smart_detect module and web application components
- Update .gitignore to exclude web cache

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-27 13:27:26 +08:00

769 lines
29 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""
Web interface for video-subtitle-remover.
Usage (from project root):
python web_app.py
# then open http://localhost:8000
Or from any directory:
VSR_PATH=/path/to/video-subtitle-remover python web_app.py
"""
import asyncio
import cv2
import hashlib
import io
import json
import os
import queue as q_module
import sqlite3
import sys
import threading
import time
import traceback
import uuid
from pathlib import Path
from typing import Optional
import httpx
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
# ── project path ────────────────────────────────────────────────────────────
# Default: the directory containing this file (run from project root)
VSR_PATH = os.environ.get("VSR_PATH", str(Path(__file__).parent))
if VSR_PATH not in sys.path:
sys.path.insert(0, VSR_PATH)
try:
from backend.config import config, tr
from backend.tools.constant import InpaintMode
from backend.main import SubtitleRemover
import configparser as _cp
# Force Chinese interface for web app
_ch_ini = os.path.join(VSR_PATH, 'backend', 'interface', 'ch.ini')
tr.read(_ch_ini, encoding='utf-8')
VSR_AVAILABLE = True
_import_err_msg = ""
except ImportError as _import_err:
VSR_AVAILABLE = False
_import_err_msg = str(_import_err)
# ── storage dirs ────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
CACHE_DIR = BASE_DIR / "web_cache" / "downloads"
OUTPUT_DIR = BASE_DIR / "web_cache" / "outputs"
DB_PATH = BASE_DIR / "web_cache" / "tasks.db"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# ── database ─────────────────────────────────────────────────────────────
def init_db():
conn = sqlite3.connect(str(DB_PATH))
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
url TEXT,
is_cache_file BOOLEAN,
cache_path TEXT,
inpaint_mode TEXT,
subtitle_areas TEXT,
preview_mode BOOLEAN DEFAULT 0,
detect_mode TEXT DEFAULT 'text',
status TEXT,
progress INTEGER,
download_progress INTEGER,
cached BOOLEAN,
output_path TEXT,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Try to add new columns if they don't exist
try:
conn.execute("ALTER TABLE tasks ADD COLUMN preview_mode BOOLEAN DEFAULT 0")
except:
pass
try:
conn.execute("ALTER TABLE tasks ADD COLUMN detect_mode TEXT DEFAULT 'text'")
except:
pass
conn.commit()
conn.close()
init_db()
INPAINT_MODE_MAP = {
"sttn-auto": lambda: InpaintMode.STTN_AUTO,
"sttn-det": lambda: InpaintMode.STTN_DET,
"lama": lambda: InpaintMode.LAMA,
"propainter": lambda: InpaintMode.PROPAINTER,
"opencv": lambda: InpaintMode.OPENCV,
}
# ── state ────────────────────────────────────────────────────────────────────
tasks: dict[str, dict] = {}
task_lock = threading.Lock()
task_queue: q_module.Queue = q_module.Queue()
ws_connections: list[WebSocket] = []
_loop: Optional[asyncio.AbstractEventLoop] = None
# ── helpers ──────────────────────────────────────────────────────────────────
def url_to_cache_path(url: str) -> Path:
h = hashlib.md5(url.encode()).hexdigest()[:12]
raw_name = url.rstrip("/").split("/")[-1].split("?")[0][:80]
name = raw_name if raw_name and "." in raw_name else f"{h}.mp4"
return CACHE_DIR / f"{h}_{name}"
def _task_snapshot(task: dict) -> dict:
return {k: v for k, v in task.items() if k != "logs"}
def _push(coro):
if _loop and not _loop.is_closed():
asyncio.run_coroutine_threadsafe(coro, _loop)
async def _broadcast(msg: dict):
dead = []
text = json.dumps(msg)
for ws in ws_connections:
try:
await ws.send_text(text)
except Exception:
dead.append(ws)
for ws in dead:
if ws in ws_connections:
ws_connections.remove(ws)
def update_task(task_id: str, **kwargs):
with task_lock:
if task_id not in tasks:
return
tasks[task_id].update(kwargs)
snap = _task_snapshot(tasks[task_id])
_save_task_to_db(tasks[task_id])
_push(_broadcast({"type": "task_update", "task": snap}))
def _save_task_to_db(task: dict):
"""Save task to database"""
try:
conn = sqlite3.connect(str(DB_PATH))
conn.execute("""
INSERT OR REPLACE INTO tasks
(id, url, is_cache_file, cache_path, inpaint_mode, subtitle_areas,
preview_mode, detect_mode, status, progress, download_progress, cached, output_path, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
task["id"],
task["url"],
task.get("is_cache_file", False),
task.get("cache_path", ""),
task["inpaint_mode"],
json.dumps(task["subtitle_areas"]),
task.get("preview_mode", False),
task.get("detect_mode", "text"),
task["status"],
task["progress"],
task["download_progress"],
task["cached"],
task["output_path"],
task["error"],
))
conn.commit()
conn.close()
except Exception as e:
print(f"Failed to save task to DB: {e}")
def _load_tasks_from_db():
"""Load tasks from database on startup"""
try:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row # Use column names
cursor = conn.execute(
"SELECT * FROM tasks ORDER BY created_at DESC LIMIT 100"
)
for row in cursor.fetchall():
task = {
"id": row["id"],
"url": row["url"],
"is_cache_file": row["is_cache_file"],
"cache_path": row["cache_path"],
"inpaint_mode": row["inpaint_mode"],
"subtitle_areas": json.loads(row["subtitle_areas"]),
"preview_mode": row["preview_mode"] if "preview_mode" in row.keys() else False,
"detect_mode": row["detect_mode"] if "detect_mode" in row.keys() else "text",
"status": row["status"],
"progress": row["progress"],
"download_progress": row["download_progress"],
"cached": row["cached"],
"output_path": row["output_path"],
"error": row["error"],
"phase": "pending",
"detect_pct": 0,
"process_pct": 0,
"merge_pct": 0,
"logs": [],
}
with task_lock:
tasks[task["id"]] = task
# Re-queue pending/downloading/processing tasks (processing might have crashed)
if task["status"] in ("pending", "downloading", "processing"):
task_queue.put(task["id"])
conn.close()
except Exception as e:
print(f"Failed to load tasks from DB: {e}")
def push_log(task_id: str, message: str):
with task_lock:
if task_id in tasks:
tasks[task_id]["logs"].append(message)
_push(_broadcast({"type": "log", "task_id": task_id, "message": message}))
# ── worker ───────────────────────────────────────────────────────────────────
def _download(task_id: str, url: str, dest: Path) -> bool:
update_task(task_id, status="downloading", download_progress=0)
push_log(task_id, f"下载中: {url}")
last_pct = -1
try:
with httpx.stream("GET", url, follow_redirects=True, timeout=7200) as r:
r.raise_for_status()
total = int(r.headers.get("content-length", 0))
done = 0
with open(dest, "wb") as f:
for chunk in r.iter_bytes(chunk_size=1024 * 512):
f.write(chunk)
done += len(chunk)
if total:
pct = int(done / total * 100)
if pct >= last_pct + 2:
last_pct = pct
mb_done = done // 1048576
mb_total = total // 1048576
update_task(task_id, download_progress=pct)
push_log(task_id, f"下载 {pct}%({mb_done} MB / {mb_total} MB)")
push_log(task_id, f"下载完成 → {dest.name}")
return True
except Exception as e:
push_log(task_id, f"下载失败: {e}")
dest.unlink(missing_ok=True)
return False
def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_areas: list, preview_mode: bool = False, detect_mode: str = "text"):
if not VSR_AVAILABLE:
push_log(task_id, f"错误:无法加载后端模块。{_import_err_msg}")
update_task(task_id, status="error", error="VSR backend not available")
return
modeLabels = {'sttn-auto':'STTN 自动','sttn-det':'STTN 检测','lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV'}
push_log(task_id, f"启动 — 算法:{modeLabels.get(inpaint_mode_str, inpaint_mode_str)}")
update_task(task_id, status="processing", progress=0, started_at=time.time())
class _WebRemover(SubtitleRemover):
_static_boxes = [] # SAM2检测到的静态区域,格式 (xmin, xmax, ymin, ymax) 像素
def append_output(self_, *args):
msg = " ".join(str(a) for a in args)
print(msg, flush=True)
push_log(task_id, msg)
def update_preview_with_comp(self_, frame_ori, frame_comp):
pass # no-op: no GUI
def run(self_):
if self_._static_boxes:
from backend.tools.subtitle_detect import SubtitleDetect as _SD
_orig = _SD.find_subtitle_frame_no
_boxes = list(self_._static_boxes)
def _patched(sd_self, sub_remover=None):
result = _orig(sd_self, sub_remover=sub_remover)
ocr_frames = len(result)
if ocr_frames > 0:
push_log(task_id, f"OCR 在 {ocr_frames} 帧中检测到文字")
cap = cv2.VideoCapture(sd_self.video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
for frame_no in range(1, total + 1):
if frame_no not in result:
result[frame_no] = []
result[frame_no] = list(result[frame_no]) + _boxes
push_log(task_id, f"SAM2 静态区域已注入全部 {total} 帧")
return result
_SD.find_subtitle_frame_no = _patched
try:
super().run()
finally:
_SD.find_subtitle_frame_no = _orig
else:
super().run()
def merge_audio_to_video(self_):
push_log(task_id, "正在合并音频...")
update_task(task_id, detect_pct=100, process_pct=100, merge_pct=50, phase="merging")
super().merge_audio_to_video()
update_task(task_id, detect_pct=100, process_pct=100, merge_pct=100, phase="merging")
push_log(task_id, "音频合并完成")
# Preview mode: cut first 10 seconds using FFmpeg BEFORE processing
preview_clip_path = None
if preview_mode:
try:
from backend.tools.ffmpeg_cli import FFmpegCLI
preview_clip_path = str(Path(video_path).parent / f"_preview_{task_id}.mp4")
cut_cmd = [
FFmpegCLI.instance().ffmpeg_path,
"-y", "-i", video_path,
"-t", "10",
"-c", "copy",
"-loglevel", "error",
preview_clip_path
]
push_log(task_id, "预览模式:截取前10秒...")
import subprocess
subprocess.run(cut_cmd, check=True, timeout=60)
push_log(task_id, "截取完成,开始处理预览片段")
video_path = preview_clip_path # process the short clip
except Exception as e:
push_log(task_id, f"截取失败,改为处理完整视频: {e}")
preview_clip_path = None
try:
sr = _WebRemover(video_path, gui_mode=False)
if preview_mode:
push_log(task_id, f"预览片段:{sr.frame_count} 帧 / {sr.fps:.1f}fps")
mode_factory = INPAINT_MODE_MAP.get(inpaint_mode_str, INPAINT_MODE_MAP["sttn-auto"])
config.set(config.inpaintMode, mode_factory())
# Auto-detect regions if not provided
if not subtitle_areas:
combined_areas = []
import concurrent.futures
def _run_ocr():
from backend.tools.subtitle_detect import SubtitleDetect
detector = SubtitleDetect(video_path, [])
cap = cv2.VideoCapture(video_path)
ret, frame = cap.read()
cap.release()
if ret:
return detector.detect_subtitle(frame)
return []
def _run_sam2():
print(f"[DEBUG] _run_sam2 called, detect_mode={detect_mode}", flush=True)
if detect_mode != "smart":
print(f"[DEBUG] detect_mode != smart, skipping SAM2", flush=True)
return []
from backend.tools.smart_detect import smart_detect_watermark
return smart_detect_watermark(video_path, sample_frames=30)
push_log(task_id, "并发检测中(OCR + SAM2)..." if detect_mode == "smart" else "文字检测(OCR)中...")
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
f_ocr = ex.submit(_run_ocr)
f_sam2 = ex.submit(_run_sam2)
ocr_result = f_ocr.result()
sam2_result = f_sam2.result()
if ocr_result:
push_log(task_id, f"OCR 检测到 {len(ocr_result)} 个文字区域")
combined_areas += [
(
int(y1 / sr.frame_height * 100),
int(y2 / sr.frame_height * 100),
int(x1 / sr.frame_width * 100),
int(x2 / sr.frame_width * 100),
)
for x1, x2, y1, y2 in ocr_result
]
else:
push_log(task_id, "OCR 未检测到文字区域")
if sam2_result:
push_log(task_id, f"SAM2 检测到 {len(sam2_result)} 个区域,将强制每帧都处理")
for i, (x1, y1, x2, y2) in enumerate(sam2_result):
push_log(task_id, f" 区域{i+1}: 左上({x1},{y1}) 右下({x2},{y2}) 大小{x2-x1}x{y2-y1}px")
sr._static_boxes = [(x1, x2, y1, y2) for x1, y1, x2, y2 in sam2_result]
elif detect_mode == "smart":
push_log(task_id, "SAM2 未检测到静态水印区域")
# 单帧初探仅用于日志,不作为全扫描的空间约束
# 若把 21 个 15px 小框设为 sub_areas,全扫描几乎找不到任何匹配帧 → 报错
# SubtitleRemover.find_subtitle_frame_no 会自行全帧扫描
if not combined_areas and not sr._static_boxes:
push_log(task_id, "初探未检测到区域,全扫描将覆盖全屏")
elif combined_areas:
push_log(task_id, f"初探检测到 {len(combined_areas)} 个文字区域(全扫描不受此约束)")
if sr._static_boxes:
push_log(task_id, f"SAM2 静态区域已注入全部帧,无需区域约束")
# subtitle_areas 保持为空,sub_areas 不设置 → 全帧无约束扫描
if subtitle_areas:
pixel_areas = [
(
int(a[0] / 100 * sr.frame_height),
int(a[1] / 100 * sr.frame_height),
int(a[2] / 100 * sr.frame_width),
int(a[3] / 100 * sr.frame_width),
)
for a in subtitle_areas
]
if detect_mode == "smart":
# 水印模式:用户手动指定区域 + 智能检测 → 强制每帧处理该区域
# 适合位置变化的动态水印(OCR 无法识别装饰性字体)
force_boxes = [
(int(a[2]/100*sr.frame_width), int(a[3]/100*sr.frame_width),
int(a[0]/100*sr.frame_height), int(a[1]/100*sr.frame_height))
for a in subtitle_areas
]
if sr._static_boxes:
sr._static_boxes = sr._static_boxes + force_boxes
else:
sr._static_boxes = force_boxes
push_log(task_id, f"手动指定 {len(force_boxes)} 个水印区域 → 强制每帧去除")
print(f"[DEBUG] force_boxes={force_boxes}", flush=True)
else:
# 字幕模式:限制 OCR 搜索范围
sr.sub_areas = pixel_areas
out_stem = Path(video_path).stem
sr.video_out_path = str(OUTPUT_DIR / f"{out_stem}_{task_id[:8]}_no_sub.mp4")
# 检测阶段:progress_total = (100*frame/total)//2 → 0-50
# 处理阶段:update_progress → 0-100(从0重新开始)
# 判断切换:raw>50 OR 前值≥40后值<15(跌落 = 阶段重置)
_state = {"phase": "detecting", "processing": False, "prev_raw": 0}
def _on_progress(pct, finished):
try:
raw = int(pct)
prev = _state["prev_raw"]
_state["prev_raw"] = raw
# 跌落检测:从检测末尾(≥40)掉回处理起始(<15)
if not _state["processing"] and prev >= 40 and raw < 15:
_state["processing"] = True
if _state["processing"] or raw > 50:
_state["processing"] = True
_state["phase"] = "processing"
process_pct = min(raw, 98)
update_task(task_id, detect_pct=100, process_pct=process_pct, merge_pct=0, phase="processing")
else:
detect_pct = min(raw * 2, 99) # 0-50 → 0-99
update_task(task_id, detect_pct=detect_pct, process_pct=0, merge_pct=0, phase="detecting")
print(f"[PROGRESS] {task_id}: phase={_state['phase']} raw={raw}%", flush=True)
except Exception as e:
print(f"[ERROR] Progress callback failed: {e}", flush=True)
push_log(task_id, f"检测模式:{'智能(SAM2)' if detect_mode == 'smart' else '文字(OCR)'}")
print(f"[DEBUG] Adding progress listener for {task_id}", flush=True)
sr.add_progress_listener(_on_progress)
print(f"[DEBUG] Progress listeners count: {len(sr.progress_listeners)}", flush=True)
print(f"[DEBUG] Starting sr.run()", flush=True)
sr.run()
print(f"[DEBUG] sr.run() completed", flush=True)
# Preview mode: skip audio merge, directly use temp file
if preview_mode and hasattr(sr, 'video_temp_file') and sr.video_temp_file:
push_log(task_id, "预览模式:跳过音频合并,直接输出")
import shutil
try:
shutil.copy2(sr.video_temp_file.name, sr.video_out_path)
push_log(task_id, f"预览完成 → {sr.video_out_path}")
except Exception as e:
push_log(task_id, f"复制文件失败: {e}")
update_task(task_id, status="done", progress=100, output_path=sr.video_out_path, completed_at=time.time())
push_log(task_id, f"完成 → {sr.video_out_path}")
except Exception:
err = traceback.format_exc()
push_log(task_id, f"错误:\n{err}")
update_task(task_id, status="error", error=err.splitlines()[-1])
finally:
# Clean up preview clip
if preview_clip_path and Path(preview_clip_path).exists():
try:
Path(preview_clip_path).unlink()
except Exception:
pass
def _run_task(task_id: str):
with task_lock:
task = tasks.get(task_id)
if not task:
return
# Handle cache file directly
if task.get("is_cache_file"):
cache_path = Path(task["cache_path"])
if cache_path.exists():
push_log(task_id, f"使用本地缓存: {cache_path.name}")
update_task(task_id, cached=True)
else:
update_task(task_id, status="error", error="缓存文件不存在")
return
else:
# Handle URL download
url = task["url"]
cache_path = url_to_cache_path(url)
if cache_path.exists():
push_log(task_id, f"缓存命中: {cache_path.name}")
update_task(task_id, cached=True)
else:
ok = _download(task_id, url, cache_path)
if not ok:
update_task(task_id, status="error", error="下载失败")
return
_process(task_id, str(cache_path), task["inpaint_mode"], task["subtitle_areas"],
task.get("preview_mode", False), task.get("detect_mode", "text"))
def _worker():
while True:
task_id = task_queue.get()
if task_id is None:
break
try:
_run_task(task_id)
except Exception:
traceback.print_exc()
finally:
task_queue.task_done()
# ── FastAPI ───────────────────────────────────────────────────────────────────
app = FastAPI(title="VSR Web")
_worker_thread = threading.Thread(target=_worker, daemon=True, name="vsr-worker")
_worker_thread.start()
@app.on_event("startup")
async def _startup():
global _loop
_loop = asyncio.get_event_loop()
_load_tasks_from_db()
@app.get("/")
async def index():
html = (BASE_DIR / "web" / "index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
class TaskCreate(BaseModel):
url: str
inpaint_mode: str = "sttn-auto"
subtitle_areas: list = []
preview_mode: bool = False
detect_mode: str = "text"
@app.post("/api/tasks")
async def create_task(body: TaskCreate):
task_id = uuid.uuid4().hex[:8]
# Check if it's a cache file name (from the recent list)
is_cache_file = body.url.startswith("cache_")
if is_cache_file:
# Remove "cache_" prefix
filename = body.url[6:] # strip "cache_"
cache_path = CACHE_DIR / filename
cached = cache_path.exists()
else:
cache_path = url_to_cache_path(body.url)
cached = cache_path.exists()
with task_lock:
now = time.time()
tasks[task_id] = {
"id": task_id,
"url": body.url,
"is_cache_file": is_cache_file,
"cache_path": str(cache_path),
"inpaint_mode": body.inpaint_mode,
"subtitle_areas": body.subtitle_areas,
"preview_mode": body.preview_mode,
"detect_mode": body.detect_mode,
"status": "pending",
"progress": 0,
"phase": "pending",
"detect_pct": 0,
"process_pct": 0,
"merge_pct": 0,
"download_progress": 0,
"cached": cached,
"output_path": None,
"error": None,
"logs": [],
"created_at": now,
"started_at": None,
"completed_at": None,
}
task_queue.put(task_id)
return {"task_id": task_id}
@app.get("/api/tasks")
async def list_tasks():
with task_lock:
return [_task_snapshot(t) for t in tasks.values()]
@app.get("/api/tasks/{task_id}")
async def get_task(task_id: str):
with task_lock:
task = tasks.get(task_id)
if not task:
raise HTTPException(404, "Not found")
return task
@app.delete("/api/tasks/{task_id}")
async def delete_task(task_id: str):
with task_lock:
tasks.pop(task_id, None)
# Also delete from database
try:
conn = sqlite3.connect(str(DB_PATH))
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
conn.commit()
conn.close()
except Exception as e:
print(f"Failed to delete task from DB: {e}")
return {"ok": True}
@app.get("/api/tasks/{task_id}/download")
async def download_result(task_id: str):
with task_lock:
task = tasks.get(task_id)
if not task or task["status"] != "done" or not task.get("output_path"):
raise HTTPException(404, "Result not ready")
out = Path(task["output_path"])
if not out.exists():
raise HTTPException(404, "Output file missing")
return FileResponse(str(out), filename=out.name, media_type="video/mp4")
@app.get("/api/tasks/{task_id}/stream")
async def stream_result(task_id: str, request: Request):
with task_lock:
task = tasks.get(task_id)
if not task or task["status"] != "done" or not task.get("output_path"):
raise HTTPException(404, "Result not ready")
out = Path(task["output_path"])
if not out.exists():
raise HTTPException(404, "Output file missing")
file_size = out.stat().st_size
range_header = request.headers.get("range")
if range_header:
# Parse Range header: "bytes=start-end"
try:
range_value = range_header.replace("bytes=", "")
start, end = range_value.split("-")
start = int(start) if start else 0
end = int(end) if end else file_size - 1
if start >= file_size or end >= file_size or start > end:
raise HTTPException(416, "Range Not Satisfiable")
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
}
return FileResponse(
str(out),
media_type="video/mp4",
headers=headers,
status_code=206,
)
except (ValueError, IndexError):
pass
return FileResponse(
str(out),
filename=out.name,
media_type="video/mp4",
headers={"Accept-Ranges": "bytes"},
)
@app.get("/api/cache-check")
async def cache_check(url: str):
return {"cached": url_to_cache_path(url).exists()}
@app.get("/api/cache-list")
async def cache_list():
files = []
if CACHE_DIR.exists():
for f in sorted(CACHE_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if f.is_file():
files.append({
"name": f.name,
"size": f.stat().st_size,
"mtime": f.stat().st_mtime,
})
return {"files": files}
@app.get("/api/status")
async def server_status():
return {
"vsr_available": VSR_AVAILABLE,
"vsr_path": VSR_PATH,
"queue_size": task_queue.qsize(),
}
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
ws_connections.append(ws)
try:
with task_lock:
init_tasks = [_task_snapshot(t) for t in tasks.values()]
await ws.send_text(json.dumps({"type": "init", "tasks": init_tasks}))
while True:
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
if ws in ws_connections:
ws_connections.remove(ws)
# ── entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", "8001"))
print(f"VSR_PATH = {VSR_PATH}")
print(f"VSR backend available: {VSR_AVAILABLE}")
if not VSR_AVAILABLE:
print(f" Import error: {_import_err_msg}")
print(f" Set VSR_PATH to the project root and ensure dependencies are installed.")
print(f"Starting on http://{host}:{port}")
uvicorn.run(app, host=host, port=port)