app.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. #!/usr/bin/env python3
  2. """
  3. WeBrake — self-hosted HandBrake web UI for Alpine LXC.
  4. Repo: https://gogs.av2x.dev/av2x/WeBrake
  5. Serves a single-page UI, accepts media uploads, runs HandBrakeCLI jobs with
  6. live JSON progress, exposes every HandBrake flag (structured groups + raw
  7. passthrough), detects GPU/hardware encoders, and serves finished files back
  8. for download.
  9. Secrets live in token.json (generated by install.sh, never committed).
  10. """
  11. import json
  12. import os
  13. import re
  14. import secrets
  15. import shlex
  16. import shutil
  17. import subprocess
  18. import threading
  19. import time
  20. import uuid
  21. from functools import wraps
  22. from pathlib import Path
  23. from flask import (Flask, Response, abort, jsonify, request,
  24. send_file, send_from_directory)
  25. # --------------------------------------------------------------------------
  26. # Paths & config
  27. # --------------------------------------------------------------------------
  28. APP_DIR = Path(__file__).resolve().parent
  29. STATIC_DIR = APP_DIR / "static"
  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. JOBS_DIR = DATA_DIR / "jobs"
  34. TOKEN_FILE = Path(os.environ.get("WEBRAKE_TOKENS", str(APP_DIR / "token.json")))
  35. for d in (UPLOAD_DIR, OUTPUT_DIR, JOBS_DIR):
  36. d.mkdir(parents=True, exist_ok=True)
  37. HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
  38. VERSION = "1.0.2"
  39. def load_tokens():
  40. """token.json is created by install.sh and is never part of the repo."""
  41. if TOKEN_FILE.exists():
  42. try:
  43. return json.loads(TOKEN_FILE.read_text())
  44. except Exception:
  45. pass
  46. # First run without installer: generate one locally so the app still boots.
  47. tok = {
  48. "api_token": secrets.token_urlsafe(32),
  49. "secret_key": secrets.token_urlsafe(32),
  50. "require_auth": False,
  51. "bots": {},
  52. }
  53. try:
  54. TOKEN_FILE.write_text(json.dumps(tok, indent=2))
  55. os.chmod(TOKEN_FILE, 0o600)
  56. except Exception:
  57. pass
  58. return tok
  59. TOKENS = load_tokens()
  60. app = Flask(__name__, static_folder=None)
  61. app.secret_key = TOKENS.get("secret_key", secrets.token_urlsafe(32))
  62. app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 * 1024 # 512 GB ceiling
  63. def auth_required(fn):
  64. """If require_auth is on in token.json, every API call must carry a token."""
  65. @wraps(fn)
  66. def wrapper(*args, **kwargs):
  67. if TOKENS.get("require_auth"):
  68. supplied = (request.headers.get("X-API-Token")
  69. or request.args.get("token", ""))
  70. valid = {TOKENS.get("api_token")} | set(
  71. (TOKENS.get("bots") or {}).values())
  72. if supplied not in valid or not supplied:
  73. abort(401, description="Missing or invalid API token")
  74. return fn(*args, **kwargs)
  75. return wrapper
  76. # --------------------------------------------------------------------------
  77. # Capabilities / GPU detection
  78. # --------------------------------------------------------------------------
  79. _caps_cache = None
  80. def detect_capabilities():
  81. """Probe HandBrakeCLI + /dev for hardware encode paths (LXC GPU passthru)."""
  82. global _caps_cache
  83. if _caps_cache:
  84. return _caps_cache
  85. caps = {
  86. "handbrake_found": os.path.exists(HANDBRAKE) or shutil.which("HandBrakeCLI") is not None,
  87. "handbrake_version": None,
  88. "encoders": [],
  89. "hw_encoders": [],
  90. "devices": {"dri": [], "nvidia": []},
  91. "vaapi": False, "qsv": False, "nvenc": False, "vce": False,
  92. }
  93. dri = Path("/dev/dri")
  94. if dri.exists():
  95. caps["devices"]["dri"] = sorted(p.name for p in dri.iterdir())
  96. for n in ("/dev/nvidia0", "/dev/nvidiactl"):
  97. if os.path.exists(n):
  98. caps["devices"]["nvidia"].append(os.path.basename(n))
  99. if caps["handbrake_found"]:
  100. try:
  101. out = subprocess.run([HANDBRAKE, "--version"], capture_output=True,
  102. text=True, timeout=20)
  103. m = re.search(r"HandBrake\s+([\w.\-]+)", out.stdout + out.stderr)
  104. caps["handbrake_version"] = m.group(1) if m else "unknown"
  105. except Exception:
  106. pass
  107. try:
  108. out = subprocess.run([HANDBRAKE, "--help"], capture_output=True,
  109. text=True, timeout=30)
  110. help_txt = out.stdout + out.stderr
  111. # Encoder ids appear indented in the -e/--encoder section
  112. enc = set(re.findall(
  113. r"^\s{6,}((?:x26[45]|mpeg[24]|VP[89]|svt_av1|theora|ffv1|"
  114. r"qsv_\w+|nvenc_\w+|vce_\w+|vaapi_\w+|mf_\w+)[\w]*)\s*$",
  115. help_txt, re.M))
  116. caps["encoders"] = sorted(enc)
  117. caps["hw_encoders"] = sorted(e for e in enc if re.match(
  118. r"^(qsv|nvenc|vce|vaapi|mf)_", e))
  119. caps["qsv"] = any(e.startswith("qsv_") for e in enc)
  120. caps["nvenc"] = any(e.startswith("nvenc_") for e in enc)
  121. caps["vce"] = any(e.startswith("vce_") for e in enc)
  122. caps["vaapi"] = any(e.startswith("vaapi_") for e in enc) or (
  123. bool(caps["devices"]["dri"]))
  124. except Exception:
  125. pass
  126. _caps_cache = caps
  127. return caps
  128. # --------------------------------------------------------------------------
  129. # Job model
  130. # --------------------------------------------------------------------------
  131. JOBS: dict = {}
  132. JOBS_LOCK = threading.Lock()
  133. QUEUE_EVENT = threading.Event()
  134. def persist_job(job):
  135. slim = {k: v for k, v in job.items() if k != "proc"}
  136. try:
  137. (JOBS_DIR / f"{job['id']}.json").write_text(json.dumps(slim, indent=2))
  138. except Exception:
  139. pass
  140. def load_persisted_jobs():
  141. for f in sorted(JOBS_DIR.glob("*.json")):
  142. try:
  143. j = json.loads(f.read_text())
  144. if j.get("status") in ("queued", "running", "scanning"):
  145. j["status"] = "failed"
  146. j["error"] = "Interrupted by server restart"
  147. JOBS[j["id"]] = j
  148. except Exception:
  149. continue
  150. load_persisted_jobs()
  151. def new_job(kind, filename, src_path, options):
  152. job = {
  153. "id": uuid.uuid4().hex[:12],
  154. "kind": kind, # "encode"
  155. "filename": filename,
  156. "src": str(src_path),
  157. "out": None,
  158. "options": options,
  159. "status": "queued", # queued|running|done|failed|cancelled
  160. "progress": 0.0,
  161. "fps": None, "fps_avg": None, "eta": None, "pass": None,
  162. "log_tail": [],
  163. "error": None,
  164. "created": time.time(),
  165. "started": None, "finished": None,
  166. "cmd": None,
  167. "proc": None,
  168. }
  169. with JOBS_LOCK:
  170. JOBS[job["id"]] = job
  171. persist_job(job)
  172. QUEUE_EVENT.set()
  173. return job
  174. # --------------------------------------------------------------------------
  175. # HandBrake command construction — full flag surface
  176. # --------------------------------------------------------------------------
  177. # Structured options map 1:1 onto HandBrakeCLI flags. Anything not covered
  178. # structurally can be supplied verbatim through options["extra_args"], so the
  179. # complete HandBrake feature set is reachable from the UI.
  180. FLAG_MAP = {
  181. # General / container
  182. "preset": ("--preset", str),
  183. "preset_import_file": ("--preset-import-file", str),
  184. "format": ("--format", str),
  185. "optimize": ("--optimize", bool),
  186. "align_av": ("--align-av", bool),
  187. "inline_parameter_sets": ("--inline-parameter-sets", bool),
  188. "markers": ("--markers", bool),
  189. "no_markers": ("--no-markers", bool),
  190. # Source
  191. "title": ("--title", str),
  192. "min_duration": ("--min-duration", str),
  193. "main_feature": ("--main-feature", bool),
  194. "chapters": ("--chapters", str),
  195. "angle": ("--angle", str),
  196. "previews": ("--previews", str),
  197. "start_at_preview": ("--start-at-preview", str),
  198. "start_at": ("--start-at", str),
  199. "stop_at": ("--stop-at", str),
  200. # Video
  201. "encoder": ("--encoder", str),
  202. "encoder_preset": ("--encoder-preset", str),
  203. "encoder_tune": ("--encoder-tune", str),
  204. "encoder_profile": ("--encoder-profile", str),
  205. "encoder_level": ("--encoder-level", str),
  206. "quality": ("--quality", str),
  207. "vb": ("--vb", str),
  208. "two_pass": ("--two-pass", bool),
  209. "turbo": ("--turbo", bool),
  210. "rate": ("--rate", str),
  211. "cfr": ("--cfr", bool),
  212. "vfr": ("--vfr", bool),
  213. "pfr": ("--pfr", bool),
  214. "encopts": ("--encopts", str),
  215. "enable_hw_decoding": ("--enable-hw-decoding", str),
  216. "disable_hw_decoding": ("--disable-hw-decoding", bool),
  217. # Audio
  218. "audio_lang_list": ("--audio-lang-list", str),
  219. "all_audio": ("--all-audio", bool),
  220. "first_audio": ("--first-audio", bool),
  221. "audio": ("--audio", str),
  222. "aencoder": ("--aencoder", str),
  223. "audio_copy_mask": ("--audio-copy-mask", str),
  224. "audio_fallback": ("--audio-fallback", str),
  225. "ab": ("--ab", str),
  226. "aq": ("--aq", str),
  227. "ac": ("--ac", str),
  228. "mixdown": ("--mixdown", str),
  229. "normalize_mix": ("--normalize-mix", str),
  230. "arate": ("--arate", str),
  231. "drc": ("--drc", str),
  232. "gain": ("--gain", str),
  233. "adither": ("--adither", str),
  234. "aname": ("--aname", str),
  235. # Picture
  236. "width": ("--width", str),
  237. "height": ("--height", str),
  238. "crop": ("--crop", str),
  239. "crop_mode": ("--crop-mode", str),
  240. "maxWidth": ("--maxWidth", str),
  241. "maxHeight": ("--maxHeight", str),
  242. "non_anamorphic": ("--non-anamorphic", bool),
  243. "auto_anamorphic": ("--auto-anamorphic", bool),
  244. "loose_anamorphic": ("--loose-anamorphic", bool),
  245. "custom_anamorphic": ("--custom-anamorphic", bool),
  246. "display_width": ("--display-width", str),
  247. "keep_display_aspect": ("--keep-display-aspect", bool),
  248. "pixel_aspect": ("--pixel-aspect", str),
  249. "modulus": ("--modulus", str),
  250. "color_matrix": ("--color-matrix", str),
  251. # Filters
  252. "comb_detect": ("--comb-detect", "optval"),
  253. "deinterlace": ("--deinterlace", "optval"),
  254. "decomb": ("--decomb", "optval"),
  255. "detelecine": ("--detelecine", "optval"),
  256. "hqdn3d": ("--hqdn3d", "optval"),
  257. "nlmeans": ("--nlmeans", "optval"),
  258. "nlmeans_tune": ("--nlmeans-tune", str),
  259. "chroma_smooth": ("--chroma-smooth", "optval"),
  260. "chroma_smooth_tune": ("--chroma-smooth-tune", str),
  261. "unsharp": ("--unsharp", "optval"),
  262. "unsharp_tune": ("--unsharp-tune", str),
  263. "lapsharp": ("--lapsharp", "optval"),
  264. "lapsharp_tune": ("--lapsharp-tune", str),
  265. "deblock": ("--deblock", "optval"),
  266. "deblock_tune": ("--deblock-tune", str),
  267. "rotate": ("--rotate", "optval"),
  268. "grayscale": ("--grayscale", bool),
  269. "pad": ("--pad", str),
  270. "colorspace": ("--colorspace", str),
  271. # Subtitles
  272. "subtitle_lang_list": ("--subtitle-lang-list", str),
  273. "all_subtitles": ("--all-subtitles", bool),
  274. "first_subtitle": ("--first-subtitle", bool),
  275. "subtitle": ("--subtitle", str),
  276. "subtitle_forced": ("--subtitle-forced", "optval"),
  277. "subtitle_burned": ("--subtitle-burned", "optval"),
  278. "subtitle_default": ("--subtitle-default", "optval"),
  279. "subname": ("--subname", str),
  280. "native_language": ("--native-language", str),
  281. "native_dub": ("--native-dub", bool),
  282. "srt_file": ("--srt-file", str),
  283. "srt_codeset": ("--srt-codeset", str),
  284. "srt_offset": ("--srt-offset", str),
  285. "srt_lang": ("--srt-lang", str),
  286. "srt_default": ("--srt-default", "optval"),
  287. "srt_burn": ("--srt-burn", "optval"),
  288. "ssa_file": ("--ssa-file", str),
  289. "ssa_offset": ("--ssa-offset", str),
  290. "ssa_lang": ("--ssa-lang", str),
  291. "ssa_default": ("--ssa-default", "optval"),
  292. "ssa_burn": ("--ssa-burn", "optval"),
  293. }
  294. EXT_FOR_FORMAT = {"av_mp4": ".mp4", "av_mkv": ".mkv", "av_webm": ".webm"}
  295. def build_cmd(job):
  296. o = job["options"] or {}
  297. src = Path(job["src"])
  298. fmt = o.get("format") or "av_mkv"
  299. ext = EXT_FOR_FORMAT.get(fmt, ".mkv")
  300. default_stem = re.sub(r"^[0-9a-f]{8}_", "", src.stem) # drop upload prefix
  301. out_name = (o.get("output_name") or default_stem) + ext
  302. out_name = re.sub(r"[^\w.\- ()\[\]]", "_", out_name)
  303. out_path = OUTPUT_DIR / f"{job['id']}_{out_name}"
  304. job["out"] = str(out_path)
  305. cmd = [HANDBRAKE, "--json", "-i", str(src), "-o", str(out_path)]
  306. for key, (flag, typ) in FLAG_MAP.items():
  307. if key not in o:
  308. continue
  309. val = o[key]
  310. if typ is bool:
  311. if val:
  312. cmd.append(flag)
  313. elif typ == "optval":
  314. # Filter-style flags: True enables with defaults, a string passes settings
  315. if val is True or val == "":
  316. cmd.append(flag)
  317. elif val:
  318. cmd.append(f"{flag}={val}")
  319. else:
  320. if val not in (None, ""):
  321. cmd += [flag, str(val)]
  322. extra = (o.get("extra_args") or "").strip()
  323. if extra:
  324. cmd += shlex.split(extra)
  325. return cmd
  326. # --------------------------------------------------------------------------
  327. # Worker — runs one HandBrakeCLI job at a time, parses --json progress
  328. # --------------------------------------------------------------------------
  329. def parse_progress_blocks(job, stream):
  330. """HandBrakeCLI --json emits 'Progress: { ... }' blocks; brace-balance them."""
  331. buf, depth, capturing = [], 0, False
  332. for raw in iter(stream.readline, ""):
  333. line = raw.rstrip("\n")
  334. job["log_tail"].append(line)
  335. if len(job["log_tail"]) > 400:
  336. del job["log_tail"][:200]
  337. if not capturing:
  338. if line.strip().startswith("Progress:"):
  339. capturing = True
  340. brace_part = line.split("Progress:", 1)[1]
  341. buf = [brace_part]
  342. depth = brace_part.count("{") - brace_part.count("}")
  343. if depth == 0 and "{" in brace_part:
  344. _apply_progress(job, "".join(buf))
  345. capturing = False
  346. continue
  347. buf.append(line)
  348. depth += line.count("{") - line.count("}")
  349. if depth <= 0 and any("{" in b for b in buf):
  350. _apply_progress(job, "\n".join(buf))
  351. capturing, buf, depth = False, [], 0
  352. def _apply_progress(job, text):
  353. try:
  354. data = json.loads(text)
  355. except Exception:
  356. return
  357. state = data.get("State")
  358. if state == "WORKING":
  359. w = data.get("Working", {})
  360. job["progress"] = round(float(w.get("Progress", 0)) * 100, 2)
  361. job["fps"] = round(float(w.get("Rate", 0)), 2)
  362. job["fps_avg"] = round(float(w.get("RateAvg", 0)), 2)
  363. job["eta"] = int(w.get("ETASeconds", 0)) or None
  364. job["pass"] = w.get("Pass")
  365. job["status"] = "running"
  366. elif state == "SCANNING":
  367. s = data.get("Scanning", {})
  368. job["progress"] = round(float(s.get("Progress", 0)) * 100, 2)
  369. job["status"] = "scanning"
  370. elif state == "MUXING":
  371. job["status"] = "running"
  372. job["progress"] = max(job["progress"], 99.0)
  373. elif state == "WORKDONE":
  374. err = data.get("WorkDone", {}).get("Error", 0)
  375. if err not in (0, "0", None):
  376. job["error"] = f"HandBrake error code {err}"
  377. def worker_loop():
  378. while True:
  379. QUEUE_EVENT.wait(timeout=5)
  380. QUEUE_EVENT.clear()
  381. while True:
  382. with JOBS_LOCK:
  383. pending = [j for j in JOBS.values() if j["status"] == "queued"]
  384. pending.sort(key=lambda j: j["created"])
  385. job = pending[0] if pending else None
  386. if job:
  387. job["status"] = "scanning"
  388. job["started"] = time.time()
  389. if not job:
  390. break
  391. run_job(job)
  392. def run_job(job):
  393. try:
  394. cmd = build_cmd(job)
  395. job["cmd"] = " ".join(shlex.quote(c) for c in cmd)
  396. persist_job(job)
  397. env = dict(os.environ)
  398. proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
  399. stderr=subprocess.STDOUT, text=True,
  400. bufsize=1, env=env)
  401. job["proc"] = proc
  402. parse_progress_blocks(job, proc.stdout)
  403. rc = proc.wait()
  404. job["proc"] = None
  405. if job["status"] == "cancelled":
  406. pass
  407. elif rc == 0 and not job["error"] and Path(job["out"]).exists():
  408. job["status"] = "done"
  409. job["progress"] = 100.0
  410. else:
  411. job["status"] = "failed"
  412. job["error"] = job["error"] or f"HandBrakeCLI exited with code {rc}"
  413. except FileNotFoundError:
  414. job["status"] = "failed"
  415. job["error"] = "HandBrakeCLI not found — is the handbrake package installed?"
  416. except Exception as e:
  417. job["status"] = "failed"
  418. job["error"] = str(e)
  419. finally:
  420. job["finished"] = time.time()
  421. persist_job(job)
  422. threading.Thread(target=worker_loop, daemon=True).start()
  423. # --------------------------------------------------------------------------
  424. # Routes
  425. # --------------------------------------------------------------------------
  426. @app.route("/")
  427. def index():
  428. page = STATIC_DIR / "index.html"
  429. if not page.is_file() or page.stat().st_size == 0:
  430. return jsonify({
  431. "error": "WeBrake backend is running, but the UI file is missing.",
  432. "expected": str(page),
  433. "fix": "Run /opt/webrake/update.sh to pull static/index.html from the repo, "
  434. "or place the file there manually, then reload this page.",
  435. }), 503
  436. return send_from_directory(STATIC_DIR, "index.html")
  437. @app.route("/<path:anything>")
  438. def catch_all(anything):
  439. if anything.startswith("api/"):
  440. abort(404, description=f"Unknown API endpoint: /{anything}")
  441. return jsonify({
  442. "error": f"No such page: /{anything}",
  443. "hint": "The UI lives at / and the API under /api/ "
  444. "(version, capabilities, presets, upload, uploads, scan, jobs, download).",
  445. }), 404
  446. @app.route("/api/version")
  447. def api_version():
  448. # Intentionally unauthenticated: the UI probes this to learn whether a
  449. # token is required before it can show the token panel.
  450. caps = detect_capabilities()
  451. return jsonify({"webrake": VERSION,
  452. "handbrake": caps.get("handbrake_version"),
  453. "auth_required": bool(TOKENS.get("require_auth"))})
  454. @app.route("/api/capabilities")
  455. @auth_required
  456. def api_capabilities():
  457. return jsonify(detect_capabilities())
  458. @app.route("/api/presets")
  459. @auth_required
  460. def api_presets():
  461. try:
  462. out = subprocess.run([HANDBRAKE, "--preset-list"], capture_output=True,
  463. text=True, timeout=30)
  464. presets, category = [], None
  465. for line in (out.stdout + out.stderr).splitlines():
  466. m_cat = re.match(r"^([A-Z][\w /&\-]+)/\s*$", line.strip())
  467. if m_cat:
  468. category = m_cat.group(1)
  469. continue
  470. m_p = re.match(r"^\s{4}(\S.*\S|\S)\s*$", line)
  471. if m_p and category and not line.strip().startswith(("+", "-")):
  472. name = m_p.group(1)
  473. if not name.endswith(":") and len(line) - len(line.lstrip()) == 4:
  474. presets.append({"category": category, "name": name})
  475. return jsonify({"presets": presets})
  476. except Exception as e:
  477. return jsonify({"presets": [], "error": str(e)})
  478. @app.route("/api/upload", methods=["POST"])
  479. @auth_required
  480. def api_upload():
  481. f = request.files.get("file")
  482. if not f or not f.filename:
  483. abort(400, description="No file supplied")
  484. safe = re.sub(r"[^\w.\- ()\[\]]", "_", os.path.basename(f.filename))
  485. dest = UPLOAD_DIR / f"{uuid.uuid4().hex[:8]}_{safe}"
  486. f.save(dest)
  487. return jsonify({"upload_id": dest.name, "filename": safe,
  488. "size": dest.stat().st_size})
  489. @app.route("/api/uploads")
  490. @auth_required
  491. def api_uploads():
  492. items = []
  493. for p in sorted(UPLOAD_DIR.iterdir()):
  494. if p.is_file():
  495. items.append({"upload_id": p.name,
  496. "filename": p.name.split("_", 1)[-1],
  497. "size": p.stat().st_size,
  498. "mtime": p.stat().st_mtime})
  499. return jsonify({"uploads": items})
  500. @app.route("/api/uploads/<upload_id>", methods=["DELETE"])
  501. @auth_required
  502. def api_delete_upload(upload_id):
  503. p = UPLOAD_DIR / os.path.basename(upload_id)
  504. if p.exists():
  505. p.unlink()
  506. return jsonify({"deleted": True})
  507. abort(404)
  508. @app.route("/api/scan/<upload_id>")
  509. @auth_required
  510. def api_scan(upload_id):
  511. """Title/track discovery via HandBrakeCLI --scan --json."""
  512. p = UPLOAD_DIR / os.path.basename(upload_id)
  513. if not p.exists():
  514. abort(404, description="Upload not found")
  515. try:
  516. out = subprocess.run(
  517. [HANDBRAKE, "--json", "-i", str(p), "--scan", "--title", "0"],
  518. capture_output=True, text=True, timeout=300)
  519. text = out.stdout
  520. m = re.search(r"JSON Title Set:\s*(\{.*)", text, re.S)
  521. if not m:
  522. return jsonify({"error": "Scan produced no title data",
  523. "log": text[-2000:]}), 500
  524. blob, depth, end = m.group(1), 0, 0
  525. for i, ch in enumerate(blob):
  526. if ch == "{": depth += 1
  527. elif ch == "}":
  528. depth -= 1
  529. if depth == 0:
  530. end = i + 1
  531. break
  532. return jsonify(json.loads(blob[:end]))
  533. except subprocess.TimeoutExpired:
  534. return jsonify({"error": "Scan timed out"}), 504
  535. except Exception as e:
  536. return jsonify({"error": str(e)}), 500
  537. @app.route("/api/jobs", methods=["GET"])
  538. @auth_required
  539. def api_jobs():
  540. with JOBS_LOCK:
  541. jobs = [{k: v for k, v in j.items() if k not in ("proc", "log_tail")}
  542. for j in JOBS.values()]
  543. jobs.sort(key=lambda j: j["created"], reverse=True)
  544. return jsonify({"jobs": jobs})
  545. @app.route("/api/jobs", methods=["POST"])
  546. @auth_required
  547. def api_create_job():
  548. body = request.get_json(force=True, silent=True) or {}
  549. upload_id = body.get("upload_id")
  550. if not upload_id:
  551. abort(400, description="upload_id required")
  552. src = UPLOAD_DIR / os.path.basename(upload_id)
  553. if not src.exists():
  554. abort(404, description="Upload not found")
  555. options = body.get("options") or {}
  556. job = new_job("encode", src.name.split("_", 1)[-1], src, options)
  557. return jsonify({"job_id": job["id"]}), 201
  558. @app.route("/api/jobs/<job_id>")
  559. @auth_required
  560. def api_job(job_id):
  561. job = JOBS.get(job_id)
  562. if not job:
  563. abort(404)
  564. slim = {k: v for k, v in job.items() if k != "proc"}
  565. return jsonify(slim)
  566. @app.route("/api/jobs/<job_id>/log")
  567. @auth_required
  568. def api_job_log(job_id):
  569. job = JOBS.get(job_id)
  570. if not job:
  571. abort(404)
  572. return Response("\n".join(job.get("log_tail", [])), mimetype="text/plain")
  573. @app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
  574. @auth_required
  575. def api_cancel(job_id):
  576. job = JOBS.get(job_id)
  577. if not job:
  578. abort(404)
  579. if job["status"] in ("queued", "running", "scanning"):
  580. job["status"] = "cancelled"
  581. proc = job.get("proc")
  582. if proc:
  583. try:
  584. proc.terminate()
  585. except Exception:
  586. pass
  587. persist_job(job)
  588. return jsonify({"status": job["status"]})
  589. @app.route("/api/jobs/<job_id>", methods=["DELETE"])
  590. @auth_required
  591. def api_delete_job(job_id):
  592. job = JOBS.get(job_id)
  593. if not job:
  594. abort(404)
  595. if job["status"] in ("running", "scanning"):
  596. abort(409, description="Cancel the job before deleting it")
  597. with JOBS_LOCK:
  598. JOBS.pop(job_id, None)
  599. try:
  600. (JOBS_DIR / f"{job_id}.json").unlink(missing_ok=True)
  601. if job.get("out") and Path(job["out"]).exists():
  602. Path(job["out"]).unlink()
  603. except Exception:
  604. pass
  605. return jsonify({"deleted": True})
  606. @app.route("/api/download/<job_id>")
  607. @auth_required
  608. def api_download(job_id):
  609. job = JOBS.get(job_id)
  610. if not job or job["status"] != "done" or not job.get("out"):
  611. abort(404, description="No finished output for this job")
  612. out = Path(job["out"])
  613. if not out.exists():
  614. abort(410, description="Output file no longer exists")
  615. name = out.name.split("_", 1)[-1]
  616. return send_file(out, as_attachment=True, download_name=name)
  617. @app.errorhandler(400)
  618. @app.errorhandler(401)
  619. @app.errorhandler(404)
  620. @app.errorhandler(409)
  621. @app.errorhandler(410)
  622. def json_error(err):
  623. return jsonify({"error": getattr(err, "description", str(err))}), err.code
  624. if __name__ == "__main__":
  625. host = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
  626. port = int(os.environ.get("WEBRAKE_PORT", "8090"))
  627. detect_capabilities()
  628. app.run(host=host, port=port, threaded=True)