app.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  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.1"
  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. return send_from_directory(STATIC_DIR, "index.html")
  429. @app.route("/api/version")
  430. def api_version():
  431. # Intentionally unauthenticated: the UI probes this to learn whether a
  432. # token is required before it can show the token panel.
  433. caps = detect_capabilities()
  434. return jsonify({"webrake": VERSION,
  435. "handbrake": caps.get("handbrake_version"),
  436. "auth_required": bool(TOKENS.get("require_auth"))})
  437. @app.route("/api/capabilities")
  438. @auth_required
  439. def api_capabilities():
  440. return jsonify(detect_capabilities())
  441. @app.route("/api/presets")
  442. @auth_required
  443. def api_presets():
  444. try:
  445. out = subprocess.run([HANDBRAKE, "--preset-list"], capture_output=True,
  446. text=True, timeout=30)
  447. presets, category = [], None
  448. for line in (out.stdout + out.stderr).splitlines():
  449. m_cat = re.match(r"^([A-Z][\w /&\-]+)/\s*$", line.strip())
  450. if m_cat:
  451. category = m_cat.group(1)
  452. continue
  453. m_p = re.match(r"^\s{4}(\S.*\S|\S)\s*$", line)
  454. if m_p and category and not line.strip().startswith(("+", "-")):
  455. name = m_p.group(1)
  456. if not name.endswith(":") and len(line) - len(line.lstrip()) == 4:
  457. presets.append({"category": category, "name": name})
  458. return jsonify({"presets": presets})
  459. except Exception as e:
  460. return jsonify({"presets": [], "error": str(e)})
  461. @app.route("/api/upload", methods=["POST"])
  462. @auth_required
  463. def api_upload():
  464. f = request.files.get("file")
  465. if not f or not f.filename:
  466. abort(400, description="No file supplied")
  467. safe = re.sub(r"[^\w.\- ()\[\]]", "_", os.path.basename(f.filename))
  468. dest = UPLOAD_DIR / f"{uuid.uuid4().hex[:8]}_{safe}"
  469. f.save(dest)
  470. return jsonify({"upload_id": dest.name, "filename": safe,
  471. "size": dest.stat().st_size})
  472. @app.route("/api/uploads")
  473. @auth_required
  474. def api_uploads():
  475. items = []
  476. for p in sorted(UPLOAD_DIR.iterdir()):
  477. if p.is_file():
  478. items.append({"upload_id": p.name,
  479. "filename": p.name.split("_", 1)[-1],
  480. "size": p.stat().st_size,
  481. "mtime": p.stat().st_mtime})
  482. return jsonify({"uploads": items})
  483. @app.route("/api/uploads/<upload_id>", methods=["DELETE"])
  484. @auth_required
  485. def api_delete_upload(upload_id):
  486. p = UPLOAD_DIR / os.path.basename(upload_id)
  487. if p.exists():
  488. p.unlink()
  489. return jsonify({"deleted": True})
  490. abort(404)
  491. @app.route("/api/scan/<upload_id>")
  492. @auth_required
  493. def api_scan(upload_id):
  494. """Title/track discovery via HandBrakeCLI --scan --json."""
  495. p = UPLOAD_DIR / os.path.basename(upload_id)
  496. if not p.exists():
  497. abort(404, description="Upload not found")
  498. try:
  499. out = subprocess.run(
  500. [HANDBRAKE, "--json", "-i", str(p), "--scan", "--title", "0"],
  501. capture_output=True, text=True, timeout=300)
  502. text = out.stdout
  503. m = re.search(r"JSON Title Set:\s*(\{.*)", text, re.S)
  504. if not m:
  505. return jsonify({"error": "Scan produced no title data",
  506. "log": text[-2000:]}), 500
  507. blob, depth, end = m.group(1), 0, 0
  508. for i, ch in enumerate(blob):
  509. if ch == "{": depth += 1
  510. elif ch == "}":
  511. depth -= 1
  512. if depth == 0:
  513. end = i + 1
  514. break
  515. return jsonify(json.loads(blob[:end]))
  516. except subprocess.TimeoutExpired:
  517. return jsonify({"error": "Scan timed out"}), 504
  518. except Exception as e:
  519. return jsonify({"error": str(e)}), 500
  520. @app.route("/api/jobs", methods=["GET"])
  521. @auth_required
  522. def api_jobs():
  523. with JOBS_LOCK:
  524. jobs = [{k: v for k, v in j.items() if k not in ("proc", "log_tail")}
  525. for j in JOBS.values()]
  526. jobs.sort(key=lambda j: j["created"], reverse=True)
  527. return jsonify({"jobs": jobs})
  528. @app.route("/api/jobs", methods=["POST"])
  529. @auth_required
  530. def api_create_job():
  531. body = request.get_json(force=True, silent=True) or {}
  532. upload_id = body.get("upload_id")
  533. if not upload_id:
  534. abort(400, description="upload_id required")
  535. src = UPLOAD_DIR / os.path.basename(upload_id)
  536. if not src.exists():
  537. abort(404, description="Upload not found")
  538. options = body.get("options") or {}
  539. job = new_job("encode", src.name.split("_", 1)[-1], src, options)
  540. return jsonify({"job_id": job["id"]}), 201
  541. @app.route("/api/jobs/<job_id>")
  542. @auth_required
  543. def api_job(job_id):
  544. job = JOBS.get(job_id)
  545. if not job:
  546. abort(404)
  547. slim = {k: v for k, v in job.items() if k != "proc"}
  548. return jsonify(slim)
  549. @app.route("/api/jobs/<job_id>/log")
  550. @auth_required
  551. def api_job_log(job_id):
  552. job = JOBS.get(job_id)
  553. if not job:
  554. abort(404)
  555. return Response("\n".join(job.get("log_tail", [])), mimetype="text/plain")
  556. @app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
  557. @auth_required
  558. def api_cancel(job_id):
  559. job = JOBS.get(job_id)
  560. if not job:
  561. abort(404)
  562. if job["status"] in ("queued", "running", "scanning"):
  563. job["status"] = "cancelled"
  564. proc = job.get("proc")
  565. if proc:
  566. try:
  567. proc.terminate()
  568. except Exception:
  569. pass
  570. persist_job(job)
  571. return jsonify({"status": job["status"]})
  572. @app.route("/api/jobs/<job_id>", methods=["DELETE"])
  573. @auth_required
  574. def api_delete_job(job_id):
  575. job = JOBS.get(job_id)
  576. if not job:
  577. abort(404)
  578. if job["status"] in ("running", "scanning"):
  579. abort(409, description="Cancel the job before deleting it")
  580. with JOBS_LOCK:
  581. JOBS.pop(job_id, None)
  582. try:
  583. (JOBS_DIR / f"{job_id}.json").unlink(missing_ok=True)
  584. if job.get("out") and Path(job["out"]).exists():
  585. Path(job["out"]).unlink()
  586. except Exception:
  587. pass
  588. return jsonify({"deleted": True})
  589. @app.route("/api/download/<job_id>")
  590. @auth_required
  591. def api_download(job_id):
  592. job = JOBS.get(job_id)
  593. if not job or job["status"] != "done" or not job.get("out"):
  594. abort(404, description="No finished output for this job")
  595. out = Path(job["out"])
  596. if not out.exists():
  597. abort(410, description="Output file no longer exists")
  598. name = out.name.split("_", 1)[-1]
  599. return send_file(out, as_attachment=True, download_name=name)
  600. @app.errorhandler(400)
  601. @app.errorhandler(401)
  602. @app.errorhandler(404)
  603. @app.errorhandler(409)
  604. @app.errorhandler(410)
  605. def json_error(err):
  606. return jsonify({"error": getattr(err, "description", str(err))}), err.code
  607. if __name__ == "__main__":
  608. host = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
  609. port = int(os.environ.get("WEBRAKE_PORT", "8090"))
  610. detect_capabilities()
  611. app.run(host=host, port=port, threaded=True)