app.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. #!/usr/bin/env python3
  2. """
  3. WeBrake — HandBrake CLI web frontend for Alpine LXC containers.
  4. Serves a single-page UI, accepts media uploads, queues HandBrakeCLI jobs
  5. with full flag coverage (including a raw-arguments passthrough), reports
  6. live progress, and serves finished files back for download.
  7. Secrets live in token.json beside this file. That file is generated at
  8. install time (or on first run if missing) and must NEVER be committed
  9. to the repository.
  10. """
  11. import json
  12. import os
  13. import re
  14. import secrets
  15. import shlex
  16. import shutil
  17. import signal
  18. import subprocess
  19. import threading
  20. import time
  21. import uuid
  22. from functools import wraps
  23. from pathlib import Path
  24. from flask import (Flask, Response, abort, jsonify, request,
  25. send_file, send_from_directory)
  26. # --------------------------------------------------------------------------
  27. # Paths & configuration
  28. # --------------------------------------------------------------------------
  29. APP_DIR = Path(__file__).resolve().parent
  30. DATA_DIR = Path(os.environ.get("WEBRAKE_DATA", "/var/lib/webrake"))
  31. UPLOAD_DIR = DATA_DIR / "uploads"
  32. OUTPUT_DIR = DATA_DIR / "output"
  33. LOG_DIR = DATA_DIR / "logs"
  34. STATE_FILE = DATA_DIR / "jobs.json"
  35. TOKEN_FILE = APP_DIR / "token.json"
  36. HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
  37. HOST = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
  38. PORT = int(os.environ.get("WEBRAKE_PORT", "8090"))
  39. for d in (UPLOAD_DIR, OUTPUT_DIR, LOG_DIR):
  40. d.mkdir(parents=True, exist_ok=True)
  41. # --------------------------------------------------------------------------
  42. # token.json — generated at install, never stored in the repo
  43. # --------------------------------------------------------------------------
  44. def load_tokens() -> dict:
  45. if not TOKEN_FILE.exists():
  46. tokens = {
  47. "api_token": secrets.token_urlsafe(32),
  48. "secret_key": secrets.token_urlsafe(32),
  49. "note": "Generated locally by WeBrake. Do not commit this file.",
  50. }
  51. TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
  52. os.chmod(TOKEN_FILE, 0o600)
  53. return tokens
  54. return json.loads(TOKEN_FILE.read_text())
  55. TOKENS = load_tokens()
  56. app = Flask(__name__)
  57. app.secret_key = TOKENS["secret_key"]
  58. app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 * 64 # 64 GiB
  59. def require_token(fn):
  60. @wraps(fn)
  61. def wrapper(*args, **kwargs):
  62. supplied = (request.headers.get("X-API-Token")
  63. or request.args.get("token", ""))
  64. if not secrets.compare_digest(supplied, TOKENS["api_token"]):
  65. return jsonify({"error": "Invalid or missing API token."}), 401
  66. return fn(*args, **kwargs)
  67. return wrapper
  68. # --------------------------------------------------------------------------
  69. # Job model & persistence
  70. # --------------------------------------------------------------------------
  71. JOBS: dict = {}
  72. JOB_LOCK = threading.Lock()
  73. QUEUE_EVENT = threading.Event()
  74. PROCS: dict = {} # job_id -> Popen
  75. def save_state():
  76. with JOB_LOCK:
  77. snapshot = {jid: {k: v for k, v in j.items()} for jid, j in JOBS.items()}
  78. STATE_FILE.write_text(json.dumps(snapshot, indent=2))
  79. def load_state():
  80. if STATE_FILE.exists():
  81. try:
  82. data = json.loads(STATE_FILE.read_text())
  83. for jid, job in data.items():
  84. if job.get("status") in ("queued", "running", "scanning"):
  85. job["status"] = "failed"
  86. job["message"] = "Interrupted by server restart."
  87. JOBS[jid] = job
  88. except Exception:
  89. pass
  90. # --------------------------------------------------------------------------
  91. # GPU / capability detection
  92. # --------------------------------------------------------------------------
  93. def detect_gpu() -> dict:
  94. info = {"dri_devices": [], "nvidia": False, "vaapi": False, "notes": []}
  95. dri = Path("/dev/dri")
  96. if dri.exists():
  97. info["dri_devices"] = sorted(p.name for p in dri.iterdir())
  98. info["vaapi"] = any(n.startswith("renderD") for n in info["dri_devices"])
  99. if Path("/dev/nvidia0").exists() or Path("/dev/nvidiactl").exists():
  100. info["nvidia"] = True
  101. if not info["dri_devices"] and not info["nvidia"]:
  102. info["notes"].append(
  103. "No GPU devices visible in this container. Software encoders will be used. "
  104. "Pass /dev/dri (Intel/AMD) or /dev/nvidia* (NVIDIA) into the LXC to enable "
  105. "hardware encoding.")
  106. return info
  107. def hb_help_text() -> str:
  108. try:
  109. out = subprocess.run([HANDBRAKE, "--help"], capture_output=True,
  110. text=True, timeout=30)
  111. return out.stdout + out.stderr
  112. except Exception:
  113. return ""
  114. def detect_encoders() -> list:
  115. """Parse the encoder list out of HandBrakeCLI --help."""
  116. text = hb_help_text()
  117. encoders = []
  118. m = re.search(r"--encoder\b.*?Select video encoder:(.*?)(?:--|\Z)",
  119. text, re.S)
  120. block = m.group(1) if m else text
  121. for token in re.findall(r"^\s{6,}([a-z0-9_]+)\s*$", block, re.M):
  122. encoders.append(token)
  123. if not encoders:
  124. # Sensible fallback list; UI marks unavailable ones after a scan fails.
  125. encoders = ["svt_av1", "x264", "x264_10bit", "x265", "x265_10bit",
  126. "x265_12bit", "mpeg4", "mpeg2", "VP8", "VP9", "theora",
  127. "nvenc_h264", "nvenc_h265", "nvenc_av1",
  128. "qsv_h264", "qsv_h265", "qsv_av1",
  129. "vce_h264", "vce_h265"]
  130. return encoders
  131. def detect_presets() -> list:
  132. try:
  133. out = subprocess.run([HANDBRAKE, "--preset-list"], capture_output=True,
  134. text=True, timeout=60)
  135. text = out.stdout + out.stderr
  136. presets, category = [], ""
  137. for line in text.splitlines():
  138. cat = re.match(r"^([A-Za-z].*)/$", line.strip())
  139. if cat:
  140. category = cat.group(1)
  141. continue
  142. item = re.match(r"^\s{4}(\S.*)$", line)
  143. if item and category and not line.strip().startswith("+"):
  144. name = item.group(1).strip()
  145. if name and not name.startswith("-"):
  146. presets.append({"category": category, "name": name})
  147. return presets
  148. except Exception:
  149. return []
  150. CAPS_CACHE: dict = {}
  151. def capabilities() -> dict:
  152. if not CAPS_CACHE:
  153. version = ""
  154. try:
  155. v = subprocess.run([HANDBRAKE, "--version"], capture_output=True,
  156. text=True, timeout=30)
  157. version = (v.stdout + v.stderr).strip().splitlines()[0] if (v.stdout or v.stderr) else ""
  158. except Exception:
  159. version = "HandBrakeCLI not found — install it inside the container."
  160. CAPS_CACHE.update({
  161. "handbrake": version,
  162. "binary": HANDBRAKE,
  163. "encoders": detect_encoders(),
  164. "presets": detect_presets(),
  165. "gpu": detect_gpu(),
  166. })
  167. return CAPS_CACHE
  168. # --------------------------------------------------------------------------
  169. # HandBrake command builder — maps UI options onto CLI flags
  170. # --------------------------------------------------------------------------
  171. SAFE_NAME = re.compile(r"[^A-Za-z0-9._ ()\[\]-]")
  172. def sanitize_name(name: str) -> str:
  173. return SAFE_NAME.sub("_", Path(name).name)[:200] or "media"
  174. def add(cmd: list, flag: str, value=None):
  175. if value is None:
  176. cmd.append(flag)
  177. else:
  178. cmd.extend([flag, str(value)])
  179. def build_command(job: dict) -> list:
  180. o = job["options"]
  181. src = UPLOAD_DIR / job["source"]
  182. dst = OUTPUT_DIR / job["output"]
  183. cmd = [HANDBRAKE, "-i", str(src), "-o", str(dst)]
  184. # ---- General / source -------------------------------------------------
  185. if o.get("preset"):
  186. add(cmd, "--preset", o["preset"])
  187. if o.get("format"):
  188. add(cmd, "--format", o["format"])
  189. if o.get("title"):
  190. add(cmd, "--title", o["title"])
  191. if o.get("chapters"):
  192. add(cmd, "--chapters", o["chapters"])
  193. if o.get("start_at"):
  194. add(cmd, "--start-at", o["start_at"])
  195. if o.get("stop_at"):
  196. add(cmd, "--stop-at", o["stop_at"])
  197. if o.get("angle"):
  198. add(cmd, "--angle", o["angle"])
  199. if o.get("markers"):
  200. add(cmd, "--markers")
  201. if o.get("optimize"):
  202. add(cmd, "--optimize")
  203. if o.get("ipod_atom"):
  204. add(cmd, "--ipod-atom")
  205. if o.get("align_av"):
  206. add(cmd, "--align-av")
  207. if o.get("inline_parameter_sets"):
  208. add(cmd, "--inline-parameter-sets")
  209. # ---- Video ------------------------------------------------------------
  210. if o.get("encoder"):
  211. add(cmd, "--encoder", o["encoder"])
  212. rc = o.get("rate_control", "quality")
  213. if rc == "quality" and o.get("quality") not in (None, ""):
  214. add(cmd, "--quality", o["quality"])
  215. elif rc == "bitrate" and o.get("vb"):
  216. add(cmd, "--vb", o["vb"])
  217. if o.get("two_pass"):
  218. add(cmd, "--multi-pass")
  219. if o.get("turbo"):
  220. add(cmd, "--turbo")
  221. if o.get("encoder_preset"):
  222. add(cmd, "--encoder-preset", o["encoder_preset"])
  223. if o.get("encoder_tune"):
  224. add(cmd, "--encoder-tune", o["encoder_tune"])
  225. if o.get("encoder_profile"):
  226. add(cmd, "--encoder-profile", o["encoder_profile"])
  227. if o.get("encoder_level"):
  228. add(cmd, "--encoder-level", o["encoder_level"])
  229. if o.get("encopts"):
  230. add(cmd, "--encopts", o["encopts"])
  231. if o.get("framerate"):
  232. add(cmd, "--rate", o["framerate"])
  233. fr_mode = o.get("framerate_mode")
  234. if fr_mode == "cfr":
  235. add(cmd, "--cfr")
  236. elif fr_mode == "vfr":
  237. add(cmd, "--vfr")
  238. elif fr_mode == "pfr":
  239. add(cmd, "--pfr")
  240. # ---- Dimensions ---------------------------------------------------------
  241. if o.get("width"):
  242. add(cmd, "--width", o["width"])
  243. if o.get("height"):
  244. add(cmd, "--height", o["height"])
  245. if o.get("max_width"):
  246. add(cmd, "--maxWidth", o["max_width"])
  247. if o.get("max_height"):
  248. add(cmd, "--maxHeight", o["max_height"])
  249. if o.get("crop"):
  250. add(cmd, "--crop", o["crop"])
  251. if o.get("crop_mode"):
  252. add(cmd, "--crop-mode", o["crop_mode"])
  253. anam = o.get("anamorphic")
  254. if anam in ("auto", "loose", "custom", "non"):
  255. add(cmd, f"--{'non-' if anam == 'non' else ''}anamorphic"
  256. if anam == "non" else f"--{anam}-anamorphic")
  257. if o.get("display_width"):
  258. add(cmd, "--display-width", o["display_width"])
  259. if o.get("pixel_aspect"):
  260. add(cmd, "--pixel-aspect", o["pixel_aspect"])
  261. if o.get("modulus"):
  262. add(cmd, "--modulus", o["modulus"])
  263. if o.get("color_matrix"):
  264. add(cmd, "--color-matrix", o["color_matrix"])
  265. # ---- Filters ------------------------------------------------------------
  266. def filt(key, flag):
  267. v = o.get(key)
  268. if v is True or v == "default":
  269. add(cmd, flag)
  270. elif v:
  271. add(cmd, flag, v)
  272. filt("comb_detect", "--comb-detect")
  273. filt("deinterlace", "--deinterlace")
  274. filt("decomb", "--decomb")
  275. filt("detelecine", "--detelecine")
  276. if o.get("denoise_filter") == "hqdn3d":
  277. filt("denoise", "--hqdn3d")
  278. elif o.get("denoise_filter") == "nlmeans":
  279. filt("denoise", "--nlmeans")
  280. if o.get("nlmeans_tune"):
  281. add(cmd, "--nlmeans-tune", o["nlmeans_tune"])
  282. filt("chroma_smooth", "--chroma-smooth")
  283. if o.get("chroma_smooth_tune"):
  284. add(cmd, "--chroma-smooth-tune", o["chroma_smooth_tune"])
  285. if o.get("sharpen_filter") == "unsharp":
  286. filt("sharpen", "--unsharp")
  287. if o.get("sharpen_tune"):
  288. add(cmd, "--unsharp-tune", o["sharpen_tune"])
  289. elif o.get("sharpen_filter") == "lapsharp":
  290. filt("sharpen", "--lapsharp")
  291. if o.get("sharpen_tune"):
  292. add(cmd, "--lapsharp-tune", o["sharpen_tune"])
  293. filt("deblock", "--deblock")
  294. if o.get("deblock_tune"):
  295. add(cmd, "--deblock-tune", o["deblock_tune"])
  296. if o.get("rotate"):
  297. add(cmd, "--rotate", o["rotate"])
  298. if o.get("pad"):
  299. add(cmd, "--pad", o["pad"])
  300. if o.get("colorspace"):
  301. add(cmd, "--colorspace", o["colorspace"])
  302. if o.get("grayscale"):
  303. add(cmd, "--grayscale")
  304. if o.get("no_dvdnav"):
  305. add(cmd, "--no-dvdnav")
  306. # ---- Audio --------------------------------------------------------------
  307. if o.get("all_audio"):
  308. add(cmd, "--all-audio")
  309. elif o.get("audio_tracks"):
  310. add(cmd, "--audio", o["audio_tracks"])
  311. if o.get("audio_encoder"):
  312. add(cmd, "--aencoder", o["audio_encoder"])
  313. if o.get("audio_bitrate"):
  314. add(cmd, "--ab", o["audio_bitrate"])
  315. if o.get("audio_quality"):
  316. add(cmd, "--aq", o["audio_quality"])
  317. if o.get("mixdown"):
  318. add(cmd, "--mixdown", o["mixdown"])
  319. if o.get("samplerate"):
  320. add(cmd, "--arate", o["samplerate"])
  321. if o.get("drc"):
  322. add(cmd, "--drc", o["drc"])
  323. if o.get("gain"):
  324. add(cmd, "--gain", o["gain"])
  325. if o.get("audio_names"):
  326. add(cmd, "--aname", o["audio_names"])
  327. if o.get("audio_copy_mask"):
  328. add(cmd, "--audio-copy-mask", o["audio_copy_mask"])
  329. if o.get("audio_fallback"):
  330. add(cmd, "--audio-fallback", o["audio_fallback"])
  331. if o.get("normalize_mix"):
  332. add(cmd, "--normalize-mix", o["normalize_mix"])
  333. # ---- Subtitles ------------------------------------------------------------
  334. if o.get("all_subtitles"):
  335. add(cmd, "--all-subtitles")
  336. elif o.get("subtitle_tracks"):
  337. add(cmd, "--subtitle", o["subtitle_tracks"])
  338. if o.get("subtitle_burned"):
  339. add(cmd, "--subtitle-burned", o["subtitle_burned"])
  340. if o.get("subtitle_default"):
  341. add(cmd, "--subtitle-default", o["subtitle_default"])
  342. if o.get("subtitle_forced"):
  343. add(cmd, "--subtitle-forced", o["subtitle_forced"])
  344. if o.get("native_language"):
  345. add(cmd, "--native-language", o["native_language"])
  346. if o.get("srt_file"):
  347. add(cmd, "--srt-file", str(UPLOAD_DIR / sanitize_name(o["srt_file"])))
  348. if o.get("srt_codeset"):
  349. add(cmd, "--srt-codeset", o["srt_codeset"])
  350. if o.get("srt_lang"):
  351. add(cmd, "--srt-lang", o["srt_lang"])
  352. if o.get("srt_burn"):
  353. add(cmd, "--srt-burn")
  354. # ---- Raw passthrough: guarantees EVERY HandBrake flag is reachable -------
  355. if o.get("raw_args"):
  356. cmd.extend(shlex.split(o["raw_args"]))
  357. return cmd
  358. # --------------------------------------------------------------------------
  359. # Worker: runs queued jobs one at a time, parses live progress
  360. # --------------------------------------------------------------------------
  361. PROGRESS_RE = re.compile(
  362. r"Encoding:.*?(\d+\.\d+)\s?%"
  363. r"(?:.*?(\d+\.\d+)\s?fps"
  364. r".*?avg\s+(\d+\.\d+)\s?fps"
  365. r".*?ETA\s+(\d+h\d+m\d+s))?", re.S)
  366. TASK_RE = re.compile(r"task (\d+) of (\d+)")
  367. def run_job(job_id: str):
  368. with JOB_LOCK:
  369. job = JOBS[job_id]
  370. job["status"] = "running"
  371. job["started"] = time.time()
  372. save_state()
  373. cmd = job["command"]
  374. log_path = LOG_DIR / f"{job_id}.log"
  375. try:
  376. with open(log_path, "w") as log:
  377. log.write("$ " + " ".join(shlex.quote(c) for c in cmd) + "\n\n")
  378. log.flush()
  379. proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
  380. stderr=subprocess.STDOUT, text=True,
  381. bufsize=1, errors="replace",
  382. preexec_fn=os.setsid)
  383. PROCS[job_id] = proc
  384. buf = ""
  385. while True:
  386. chunk = proc.stdout.read(256)
  387. if not chunk:
  388. break
  389. log.write(chunk)
  390. log.flush()
  391. buf = (buf + chunk)[-2000:]
  392. m = None
  393. for m in PROGRESS_RE.finditer(buf):
  394. pass
  395. if m:
  396. with JOB_LOCK:
  397. job["progress"] = float(m.group(1))
  398. if m.group(2):
  399. job["fps"] = float(m.group(2))
  400. job["avg_fps"] = float(m.group(3))
  401. job["eta"] = m.group(4)
  402. t = None
  403. for t in TASK_RE.finditer(buf):
  404. pass
  405. if t:
  406. with JOB_LOCK:
  407. job["task"] = f"{t.group(1)}/{t.group(2)}"
  408. proc.wait()
  409. PROCS.pop(job_id, None)
  410. out_file = OUTPUT_DIR / job["output"]
  411. with JOB_LOCK:
  412. if job.get("status") == "cancelled":
  413. pass
  414. elif proc.returncode == 0 and out_file.exists():
  415. job["status"] = "done"
  416. job["progress"] = 100.0
  417. job["size"] = out_file.stat().st_size
  418. job["message"] = "Ready to download."
  419. else:
  420. job["status"] = "failed"
  421. job["message"] = f"HandBrakeCLI exited with code {proc.returncode}. See log."
  422. job["finished"] = time.time()
  423. except Exception as exc:
  424. PROCS.pop(job_id, None)
  425. with JOB_LOCK:
  426. job["status"] = "failed"
  427. job["message"] = f"{type(exc).__name__}: {exc}"
  428. job["finished"] = time.time()
  429. save_state()
  430. def worker_loop():
  431. while True:
  432. QUEUE_EVENT.wait(timeout=2)
  433. QUEUE_EVENT.clear()
  434. while True:
  435. with JOB_LOCK:
  436. pending = [j for j in JOBS.values() if j["status"] == "queued"]
  437. pending.sort(key=lambda j: j["created"])
  438. nxt = pending[0]["id"] if pending else None
  439. if not nxt:
  440. break
  441. run_job(nxt)
  442. threading.Thread(target=worker_loop, daemon=True).start()
  443. # --------------------------------------------------------------------------
  444. # Routes
  445. # --------------------------------------------------------------------------
  446. @app.route("/")
  447. def index():
  448. return send_from_directory(APP_DIR, "index.html")
  449. @app.route("/api/ping")
  450. def ping():
  451. supplied = request.headers.get("X-API-Token", "")
  452. ok = secrets.compare_digest(supplied, TOKENS["api_token"])
  453. return jsonify({"ok": ok})
  454. @app.route("/api/capabilities")
  455. @require_token
  456. def api_capabilities():
  457. return jsonify(capabilities())
  458. @app.route("/api/upload", methods=["POST"])
  459. @require_token
  460. def api_upload():
  461. f = request.files.get("file")
  462. if not f or not f.filename:
  463. return jsonify({"error": "No file supplied."}), 400
  464. name = sanitize_name(f.filename)
  465. dest = UPLOAD_DIR / name
  466. stem, suffix, n = dest.stem, dest.suffix, 1
  467. while dest.exists():
  468. dest = UPLOAD_DIR / f"{stem}({n}){suffix}"
  469. n += 1
  470. f.save(dest)
  471. return jsonify({"filename": dest.name, "size": dest.stat().st_size})
  472. @app.route("/api/sources")
  473. @require_token
  474. def api_sources():
  475. files = []
  476. for p in sorted(UPLOAD_DIR.iterdir()):
  477. if p.is_file():
  478. files.append({"name": p.name, "size": p.stat().st_size,
  479. "mtime": p.stat().st_mtime})
  480. return jsonify(files)
  481. @app.route("/api/sources/<path:name>", methods=["DELETE"])
  482. @require_token
  483. def api_delete_source(name):
  484. p = UPLOAD_DIR / sanitize_name(name)
  485. if p.exists():
  486. p.unlink()
  487. return jsonify({"ok": True})
  488. @app.route("/api/scan", methods=["POST"])
  489. @require_token
  490. def api_scan():
  491. """Scan a source with HandBrakeCLI to enumerate titles/tracks."""
  492. name = sanitize_name(request.json.get("filename", ""))
  493. src = UPLOAD_DIR / name
  494. if not src.exists():
  495. return jsonify({"error": "Source not found."}), 404
  496. try:
  497. out = subprocess.run(
  498. [HANDBRAKE, "-i", str(src), "--scan", "--title", "0", "--json"],
  499. capture_output=True, text=True, timeout=600)
  500. text = out.stdout
  501. m = re.search(r"JSON Title Set:\s*(\{.*)", text, re.S)
  502. titles = []
  503. if m:
  504. data = json.loads(m.group(1)[:m.group(1).rfind("}") + 1])
  505. for t in data.get("TitleList", []):
  506. titles.append({
  507. "index": t.get("Index"),
  508. "duration": t.get("Duration"),
  509. "geometry": t.get("Geometry"),
  510. "framerate": t.get("FrameRate"),
  511. "audio": [{"track": i + 1,
  512. "description": a.get("Description", ""),
  513. "language": a.get("Language", "")}
  514. for i, a in enumerate(t.get("AudioList", []))],
  515. "subtitles": [{"track": i + 1,
  516. "name": s.get("Name") or s.get("Language", ""),
  517. "format": s.get("SourceName", "")}
  518. for i, s in enumerate(t.get("SubtitleList", []))],
  519. })
  520. return jsonify({"titles": titles})
  521. except subprocess.TimeoutExpired:
  522. return jsonify({"error": "Scan timed out."}), 500
  523. except Exception as exc:
  524. return jsonify({"error": str(exc)}), 500
  525. @app.route("/api/jobs", methods=["GET"])
  526. @require_token
  527. def api_jobs():
  528. with JOB_LOCK:
  529. jobs = sorted(JOBS.values(), key=lambda j: j["created"], reverse=True)
  530. return jsonify(jobs)
  531. @app.route("/api/jobs", methods=["POST"])
  532. @require_token
  533. def api_create_job():
  534. body = request.json or {}
  535. source = sanitize_name(body.get("source", ""))
  536. if not (UPLOAD_DIR / source).exists():
  537. return jsonify({"error": "Source file not found — upload it first."}), 400
  538. options = body.get("options", {})
  539. fmt = options.get("format", "av_mkv")
  540. ext = {"av_mp4": ".mp4", "av_mkv": ".mkv", "av_webm": ".webm"}.get(fmt, ".mkv")
  541. out_name = body.get("output") or (Path(source).stem + ".webrake" + ext)
  542. out_name = sanitize_name(out_name)
  543. job_id = uuid.uuid4().hex[:12]
  544. job = {
  545. "id": job_id,
  546. "source": source,
  547. "output": out_name,
  548. "options": options,
  549. "status": "queued",
  550. "progress": 0.0,
  551. "created": time.time(),
  552. "message": "Waiting in queue.",
  553. }
  554. job["command"] = build_command(job)
  555. job["command_preview"] = " ".join(shlex.quote(c) for c in job["command"])
  556. with JOB_LOCK:
  557. JOBS[job_id] = job
  558. save_state()
  559. QUEUE_EVENT.set()
  560. return jsonify(job), 201
  561. @app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
  562. @require_token
  563. def api_cancel(job_id):
  564. with JOB_LOCK:
  565. job = JOBS.get(job_id)
  566. if not job:
  567. abort(404)
  568. job["status"] = "cancelled"
  569. job["message"] = "Cancelled by user."
  570. proc = PROCS.get(job_id)
  571. if proc:
  572. try:
  573. os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
  574. except Exception:
  575. pass
  576. save_state()
  577. return jsonify({"ok": True})
  578. @app.route("/api/jobs/<job_id>", methods=["DELETE"])
  579. @require_token
  580. def api_delete_job(job_id):
  581. with JOB_LOCK:
  582. job = JOBS.pop(job_id, None)
  583. if job:
  584. for p in (OUTPUT_DIR / job["output"], LOG_DIR / f"{job_id}.log"):
  585. if p.exists():
  586. p.unlink()
  587. save_state()
  588. return jsonify({"ok": True})
  589. @app.route("/api/jobs/<job_id>/log")
  590. @require_token
  591. def api_log(job_id):
  592. p = LOG_DIR / f"{job_id}.log"
  593. if not p.exists():
  594. abort(404)
  595. tail = p.read_text(errors="replace")[-20000:]
  596. return Response(tail, mimetype="text/plain")
  597. @app.route("/api/download/<job_id>")
  598. @require_token
  599. def api_download(job_id):
  600. with JOB_LOCK:
  601. job = JOBS.get(job_id)
  602. if not job or job["status"] != "done":
  603. abort(404)
  604. return send_file(OUTPUT_DIR / job["output"], as_attachment=True,
  605. download_name=job["output"])
  606. if __name__ == "__main__":
  607. load_state()
  608. print(f"WeBrake listening on http://{HOST}:{PORT}")
  609. print(f"API token: {TOKENS['api_token']}")
  610. app.run(host=HOST, port=PORT, threaded=True)