app.py 28 KB

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