| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740 |
- #!/usr/bin/env python3
- """
- WeBrake — self-hosted HandBrake web UI for Alpine LXC.
- Repo: https://gogs.av2x.dev/av2x/WeBrake
- Serves a single-page UI, accepts media uploads, runs HandBrakeCLI jobs with
- live JSON progress, exposes every HandBrake flag (structured groups + raw
- passthrough), detects GPU/hardware encoders, and serves finished files back
- for download.
- Secrets live in token.json (generated by install.sh, never committed).
- """
- import json
- import os
- import re
- import secrets
- import shlex
- import shutil
- import subprocess
- import threading
- import time
- import uuid
- from functools import wraps
- from pathlib import Path
- from flask import (Flask, Response, abort, jsonify, request,
- send_file, send_from_directory)
- # --------------------------------------------------------------------------
- # Paths & config
- # --------------------------------------------------------------------------
- APP_DIR = Path(__file__).resolve().parent
- STATIC_DIR = APP_DIR / "static"
- DATA_DIR = Path(os.environ.get("WEBRAKE_DATA", "/var/lib/webrake"))
- UPLOAD_DIR = DATA_DIR / "uploads"
- OUTPUT_DIR = DATA_DIR / "output"
- JOBS_DIR = DATA_DIR / "jobs"
- TOKEN_FILE = Path(os.environ.get("WEBRAKE_TOKENS", str(APP_DIR / "token.json")))
- for d in (UPLOAD_DIR, OUTPUT_DIR, JOBS_DIR):
- d.mkdir(parents=True, exist_ok=True)
- # Spool any temporary files onto the data volume, never /tmp — on Alpine /tmp
- # can be a small RAM-backed tmpfs, which silently caps upload sizes.
- import tempfile
- SCRATCH = DATA_DIR / "tmp"
- SCRATCH.mkdir(parents=True, exist_ok=True)
- os.environ["TMPDIR"] = str(SCRATCH)
- tempfile.tempdir = str(SCRATCH)
- def storage_info():
- u = shutil.disk_usage(DATA_DIR)
- return {"total": u.total, "used": u.used, "free": u.free}
- FREE_SPACE_MARGIN = 256 * 1024 * 1024 # keep 256 MB headroom for outputs/logs
- HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
- VERSION = "1.0.4"
- def load_tokens():
- """token.json is created by install.sh and is never part of the repo."""
- if TOKEN_FILE.exists():
- try:
- return json.loads(TOKEN_FILE.read_text())
- except Exception:
- pass
- # First run without installer: generate one locally so the app still boots.
- tok = {
- "api_token": secrets.token_urlsafe(32),
- "secret_key": secrets.token_urlsafe(32),
- "require_auth": False,
- "bots": {},
- }
- try:
- TOKEN_FILE.write_text(json.dumps(tok, indent=2))
- os.chmod(TOKEN_FILE, 0o600)
- except Exception:
- pass
- return tok
- TOKENS = load_tokens()
- app = Flask(__name__, static_folder=None)
- app.secret_key = TOKENS.get("secret_key", secrets.token_urlsafe(32))
- app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 * 1024 # 512 GB ceiling
- def auth_required(fn):
- """If require_auth is on in token.json, every API call must carry a token."""
- @wraps(fn)
- def wrapper(*args, **kwargs):
- if TOKENS.get("require_auth"):
- supplied = (request.headers.get("X-API-Token")
- or request.args.get("token", ""))
- valid = {TOKENS.get("api_token")} | set(
- (TOKENS.get("bots") or {}).values())
- if supplied not in valid or not supplied:
- abort(401, description="Missing or invalid API token")
- return fn(*args, **kwargs)
- return wrapper
- # --------------------------------------------------------------------------
- # Capabilities / GPU detection
- # --------------------------------------------------------------------------
- _caps_cache = None
- def detect_capabilities():
- """Probe HandBrakeCLI + /dev for hardware encode paths (LXC GPU passthru)."""
- global _caps_cache
- if _caps_cache:
- return _caps_cache
- caps = {
- "handbrake_found": os.path.exists(HANDBRAKE) or shutil.which("HandBrakeCLI") is not None,
- "handbrake_version": None,
- "encoders": [],
- "hw_encoders": [],
- "devices": {"dri": [], "nvidia": []},
- "vaapi": False, "qsv": False, "nvenc": False, "vce": False,
- }
- dri = Path("/dev/dri")
- if dri.exists():
- caps["devices"]["dri"] = sorted(p.name for p in dri.iterdir())
- for n in ("/dev/nvidia0", "/dev/nvidiactl"):
- if os.path.exists(n):
- caps["devices"]["nvidia"].append(os.path.basename(n))
- if caps["handbrake_found"]:
- try:
- out = subprocess.run([HANDBRAKE, "--version"], capture_output=True,
- text=True, timeout=20)
- m = re.search(r"HandBrake\s+([\w.\-]+)", out.stdout + out.stderr)
- caps["handbrake_version"] = m.group(1) if m else "unknown"
- except Exception:
- pass
- try:
- out = subprocess.run([HANDBRAKE, "--help"], capture_output=True,
- text=True, timeout=30)
- help_txt = out.stdout + out.stderr
- # Encoder ids appear indented in the -e/--encoder section
- enc = set(re.findall(
- r"^\s{6,}((?:x26[45]|mpeg[24]|VP[89]|svt_av1|theora|ffv1|"
- r"qsv_\w+|nvenc_\w+|vce_\w+|vaapi_\w+|mf_\w+)[\w]*)\s*$",
- help_txt, re.M))
- caps["encoders"] = sorted(enc)
- caps["hw_encoders"] = sorted(e for e in enc if re.match(
- r"^(qsv|nvenc|vce|vaapi|mf)_", e))
- caps["qsv"] = any(e.startswith("qsv_") for e in enc)
- caps["nvenc"] = any(e.startswith("nvenc_") for e in enc)
- caps["vce"] = any(e.startswith("vce_") for e in enc)
- caps["vaapi"] = any(e.startswith("vaapi_") for e in enc) or (
- bool(caps["devices"]["dri"]))
- except Exception:
- pass
- _caps_cache = caps
- return caps
- # --------------------------------------------------------------------------
- # Job model
- # --------------------------------------------------------------------------
- JOBS: dict = {}
- JOBS_LOCK = threading.Lock()
- QUEUE_EVENT = threading.Event()
- def persist_job(job):
- slim = {k: v for k, v in job.items() if k != "proc"}
- try:
- (JOBS_DIR / f"{job['id']}.json").write_text(json.dumps(slim, indent=2))
- except Exception:
- pass
- def load_persisted_jobs():
- for f in sorted(JOBS_DIR.glob("*.json")):
- try:
- j = json.loads(f.read_text())
- if j.get("status") in ("queued", "running", "scanning"):
- j["status"] = "failed"
- j["error"] = "Interrupted by server restart"
- JOBS[j["id"]] = j
- except Exception:
- continue
- load_persisted_jobs()
- def new_job(kind, filename, src_path, options):
- job = {
- "id": uuid.uuid4().hex[:12],
- "kind": kind, # "encode"
- "filename": filename,
- "src": str(src_path),
- "out": None,
- "options": options,
- "status": "queued", # queued|running|done|failed|cancelled
- "progress": 0.0,
- "fps": None, "fps_avg": None, "eta": None, "pass": None,
- "log_tail": [],
- "error": None,
- "created": time.time(),
- "started": None, "finished": None,
- "cmd": None,
- "proc": None,
- }
- with JOBS_LOCK:
- JOBS[job["id"]] = job
- persist_job(job)
- QUEUE_EVENT.set()
- return job
- # --------------------------------------------------------------------------
- # HandBrake command construction — full flag surface
- # --------------------------------------------------------------------------
- # Structured options map 1:1 onto HandBrakeCLI flags. Anything not covered
- # structurally can be supplied verbatim through options["extra_args"], so the
- # complete HandBrake feature set is reachable from the UI.
- FLAG_MAP = {
- # General / container
- "preset": ("--preset", str),
- "preset_import_file": ("--preset-import-file", str),
- "format": ("--format", str),
- "optimize": ("--optimize", bool),
- "align_av": ("--align-av", bool),
- "inline_parameter_sets": ("--inline-parameter-sets", bool),
- "markers": ("--markers", bool),
- "no_markers": ("--no-markers", bool),
- # Source
- "title": ("--title", str),
- "min_duration": ("--min-duration", str),
- "main_feature": ("--main-feature", bool),
- "chapters": ("--chapters", str),
- "angle": ("--angle", str),
- "previews": ("--previews", str),
- "start_at_preview": ("--start-at-preview", str),
- "start_at": ("--start-at", str),
- "stop_at": ("--stop-at", str),
- # Video
- "encoder": ("--encoder", str),
- "encoder_preset": ("--encoder-preset", str),
- "encoder_tune": ("--encoder-tune", str),
- "encoder_profile": ("--encoder-profile", str),
- "encoder_level": ("--encoder-level", str),
- "quality": ("--quality", str),
- "vb": ("--vb", str),
- "two_pass": ("--two-pass", bool),
- "turbo": ("--turbo", bool),
- "rate": ("--rate", str),
- "cfr": ("--cfr", bool),
- "vfr": ("--vfr", bool),
- "pfr": ("--pfr", bool),
- "encopts": ("--encopts", str),
- "enable_hw_decoding": ("--enable-hw-decoding", str),
- "disable_hw_decoding": ("--disable-hw-decoding", bool),
- # Audio
- "audio_lang_list": ("--audio-lang-list", str),
- "all_audio": ("--all-audio", bool),
- "first_audio": ("--first-audio", bool),
- "audio": ("--audio", str),
- "aencoder": ("--aencoder", str),
- "audio_copy_mask": ("--audio-copy-mask", str),
- "audio_fallback": ("--audio-fallback", str),
- "ab": ("--ab", str),
- "aq": ("--aq", str),
- "ac": ("--ac", str),
- "mixdown": ("--mixdown", str),
- "normalize_mix": ("--normalize-mix", str),
- "arate": ("--arate", str),
- "drc": ("--drc", str),
- "gain": ("--gain", str),
- "adither": ("--adither", str),
- "aname": ("--aname", str),
- # Picture
- "width": ("--width", str),
- "height": ("--height", str),
- "crop": ("--crop", str),
- "crop_mode": ("--crop-mode", str),
- "maxWidth": ("--maxWidth", str),
- "maxHeight": ("--maxHeight", str),
- "non_anamorphic": ("--non-anamorphic", bool),
- "auto_anamorphic": ("--auto-anamorphic", bool),
- "loose_anamorphic": ("--loose-anamorphic", bool),
- "custom_anamorphic": ("--custom-anamorphic", bool),
- "display_width": ("--display-width", str),
- "keep_display_aspect": ("--keep-display-aspect", bool),
- "pixel_aspect": ("--pixel-aspect", str),
- "modulus": ("--modulus", str),
- "color_matrix": ("--color-matrix", str),
- # Filters
- "comb_detect": ("--comb-detect", "optval"),
- "deinterlace": ("--deinterlace", "optval"),
- "decomb": ("--decomb", "optval"),
- "detelecine": ("--detelecine", "optval"),
- "hqdn3d": ("--hqdn3d", "optval"),
- "nlmeans": ("--nlmeans", "optval"),
- "nlmeans_tune": ("--nlmeans-tune", str),
- "chroma_smooth": ("--chroma-smooth", "optval"),
- "chroma_smooth_tune": ("--chroma-smooth-tune", str),
- "unsharp": ("--unsharp", "optval"),
- "unsharp_tune": ("--unsharp-tune", str),
- "lapsharp": ("--lapsharp", "optval"),
- "lapsharp_tune": ("--lapsharp-tune", str),
- "deblock": ("--deblock", "optval"),
- "deblock_tune": ("--deblock-tune", str),
- "rotate": ("--rotate", "optval"),
- "grayscale": ("--grayscale", bool),
- "pad": ("--pad", str),
- "colorspace": ("--colorspace", str),
- # Subtitles
- "subtitle_lang_list": ("--subtitle-lang-list", str),
- "all_subtitles": ("--all-subtitles", bool),
- "first_subtitle": ("--first-subtitle", bool),
- "subtitle": ("--subtitle", str),
- "subtitle_forced": ("--subtitle-forced", "optval"),
- "subtitle_burned": ("--subtitle-burned", "optval"),
- "subtitle_default": ("--subtitle-default", "optval"),
- "subname": ("--subname", str),
- "native_language": ("--native-language", str),
- "native_dub": ("--native-dub", bool),
- "srt_file": ("--srt-file", str),
- "srt_codeset": ("--srt-codeset", str),
- "srt_offset": ("--srt-offset", str),
- "srt_lang": ("--srt-lang", str),
- "srt_default": ("--srt-default", "optval"),
- "srt_burn": ("--srt-burn", "optval"),
- "ssa_file": ("--ssa-file", str),
- "ssa_offset": ("--ssa-offset", str),
- "ssa_lang": ("--ssa-lang", str),
- "ssa_default": ("--ssa-default", "optval"),
- "ssa_burn": ("--ssa-burn", "optval"),
- }
- EXT_FOR_FORMAT = {"av_mp4": ".mp4", "av_mkv": ".mkv", "av_webm": ".webm"}
- def build_cmd(job):
- o = job["options"] or {}
- src = Path(job["src"])
- fmt = o.get("format") or "av_mkv"
- ext = EXT_FOR_FORMAT.get(fmt, ".mkv")
- default_stem = re.sub(r"^[0-9a-f]{8}_", "", src.stem) # drop upload prefix
- out_name = (o.get("output_name") or default_stem) + ext
- out_name = re.sub(r"[^\w.\- ()\[\]]", "_", out_name)
- out_path = OUTPUT_DIR / f"{job['id']}_{out_name}"
- job["out"] = str(out_path)
- cmd = [HANDBRAKE, "--json", "-i", str(src), "-o", str(out_path)]
- for key, (flag, typ) in FLAG_MAP.items():
- if key not in o:
- continue
- val = o[key]
- if typ is bool:
- if val:
- cmd.append(flag)
- elif typ == "optval":
- # Filter-style flags: True enables with defaults, a string passes settings
- if val is True or val == "":
- cmd.append(flag)
- elif val:
- cmd.append(f"{flag}={val}")
- else:
- if val not in (None, ""):
- cmd += [flag, str(val)]
- extra = (o.get("extra_args") or "").strip()
- if extra:
- cmd += shlex.split(extra)
- return cmd
- # --------------------------------------------------------------------------
- # Worker — runs one HandBrakeCLI job at a time, parses --json progress
- # --------------------------------------------------------------------------
- def parse_progress_blocks(job, stream):
- """HandBrakeCLI --json emits 'Progress: { ... }' blocks; brace-balance them."""
- buf, depth, capturing = [], 0, False
- for raw in iter(stream.readline, ""):
- line = raw.rstrip("\n")
- job["log_tail"].append(line)
- if len(job["log_tail"]) > 400:
- del job["log_tail"][:200]
- if not capturing:
- if line.strip().startswith("Progress:"):
- capturing = True
- brace_part = line.split("Progress:", 1)[1]
- buf = [brace_part]
- depth = brace_part.count("{") - brace_part.count("}")
- if depth == 0 and "{" in brace_part:
- _apply_progress(job, "".join(buf))
- capturing = False
- continue
- buf.append(line)
- depth += line.count("{") - line.count("}")
- if depth <= 0 and any("{" in b for b in buf):
- _apply_progress(job, "\n".join(buf))
- capturing, buf, depth = False, [], 0
- def _apply_progress(job, text):
- try:
- data = json.loads(text)
- except Exception:
- return
- state = data.get("State")
- if state == "WORKING":
- w = data.get("Working", {})
- job["progress"] = round(float(w.get("Progress", 0)) * 100, 2)
- job["fps"] = round(float(w.get("Rate", 0)), 2)
- job["fps_avg"] = round(float(w.get("RateAvg", 0)), 2)
- job["eta"] = int(w.get("ETASeconds", 0)) or None
- job["pass"] = w.get("Pass")
- job["status"] = "running"
- elif state == "SCANNING":
- s = data.get("Scanning", {})
- job["progress"] = round(float(s.get("Progress", 0)) * 100, 2)
- job["status"] = "scanning"
- elif state == "MUXING":
- job["status"] = "running"
- job["progress"] = max(job["progress"], 99.0)
- elif state == "WORKDONE":
- err = data.get("WorkDone", {}).get("Error", 0)
- if err not in (0, "0", None):
- job["error"] = f"HandBrake error code {err}"
- def worker_loop():
- while True:
- QUEUE_EVENT.wait(timeout=5)
- QUEUE_EVENT.clear()
- while True:
- with JOBS_LOCK:
- pending = [j for j in JOBS.values() if j["status"] == "queued"]
- pending.sort(key=lambda j: j["created"])
- job = pending[0] if pending else None
- if job:
- job["status"] = "scanning"
- job["started"] = time.time()
- if not job:
- break
- run_job(job)
- def run_job(job):
- try:
- cmd = build_cmd(job)
- job["cmd"] = " ".join(shlex.quote(c) for c in cmd)
- persist_job(job)
- env = dict(os.environ)
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT, text=True,
- bufsize=1, env=env)
- job["proc"] = proc
- parse_progress_blocks(job, proc.stdout)
- rc = proc.wait()
- job["proc"] = None
- if job["status"] == "cancelled":
- pass
- elif rc == 0 and not job["error"] and Path(job["out"]).exists():
- job["status"] = "done"
- job["progress"] = 100.0
- else:
- job["status"] = "failed"
- job["error"] = job["error"] or f"HandBrakeCLI exited with code {rc}"
- except FileNotFoundError:
- job["status"] = "failed"
- job["error"] = "HandBrakeCLI not found — is the handbrake package installed?"
- except Exception as e:
- job["status"] = "failed"
- job["error"] = str(e)
- finally:
- job["finished"] = time.time()
- persist_job(job)
- threading.Thread(target=worker_loop, daemon=True).start()
- # --------------------------------------------------------------------------
- # Routes
- # --------------------------------------------------------------------------
- @app.route("/")
- def index():
- # Accept either layout: static/index.html (canonical) or a flat
- # /opt/webrake/index.html (as older updaters installed it).
- for candidate in (STATIC_DIR / "index.html", APP_DIR / "index.html"):
- if candidate.is_file() and candidate.stat().st_size > 0:
- return send_from_directory(candidate.parent, candidate.name)
- return jsonify({
- "error": "WeBrake backend is running, but the UI file is missing.",
- "looked_in": [str(STATIC_DIR / "index.html"), str(APP_DIR / "index.html")],
- "fix": "Run /opt/webrake/update.sh to pull index.html from the repo, "
- "or place the file at either path, then reload this page.",
- }), 503
- @app.route("/<path:anything>")
- def catch_all(anything):
- if anything.startswith("api/"):
- abort(404, description=f"Unknown API endpoint: /{anything}")
- return jsonify({
- "error": f"No such page: /{anything}",
- "hint": "The UI lives at / and the API under /api/ "
- "(version, capabilities, presets, upload, uploads, scan, jobs, download).",
- }), 404
- @app.route("/api/version")
- def api_version():
- # Intentionally unauthenticated: the UI probes this to learn whether a
- # token is required before it can show the token panel.
- caps = detect_capabilities()
- return jsonify({"webrake": VERSION,
- "handbrake": caps.get("handbrake_version"),
- "auth_required": bool(TOKENS.get("require_auth"))})
- @app.route("/api/capabilities")
- @auth_required
- def api_capabilities():
- return jsonify(detect_capabilities())
- @app.route("/api/presets")
- @auth_required
- def api_presets():
- try:
- out = subprocess.run([HANDBRAKE, "--preset-list"], capture_output=True,
- text=True, timeout=30)
- presets, category = [], None
- for line in (out.stdout + out.stderr).splitlines():
- m_cat = re.match(r"^([A-Z][\w /&\-]+)/\s*$", line.strip())
- if m_cat:
- category = m_cat.group(1)
- continue
- m_p = re.match(r"^\s{4}(\S.*\S|\S)\s*$", line)
- if m_p and category and not line.strip().startswith(("+", "-")):
- name = m_p.group(1)
- if not name.endswith(":") and len(line) - len(line.lstrip()) == 4:
- presets.append({"category": category, "name": name})
- return jsonify({"presets": presets})
- except Exception as e:
- return jsonify({"presets": [], "error": str(e)})
- @app.route("/api/upload", methods=["POST", "PUT"])
- @auth_required
- def api_upload():
- # Preferred path: raw body streaming (?filename=… or X-Filename header) —
- # constant memory, no multipart spooling. Multipart 'file' still accepted
- # for curl/bot compatibility.
- raw_name = request.args.get("filename") or request.headers.get("X-Filename")
- mp = None if raw_name else request.files.get("file")
- if not raw_name and (not mp or not mp.filename):
- abort(400, description="No file supplied — send raw body with "
- "?filename=… or multipart field 'file'")
- name = os.path.basename(raw_name or mp.filename)
- safe = re.sub(r"[^\w.\- ()\[\]]", "_", name) or "upload.bin"
- # Preflight: refuse uploads that cannot fit, with a number instead of a 500.
- length = request.content_length or 0
- free = storage_info()["free"]
- if length and length + FREE_SPACE_MARGIN > free:
- return jsonify({"error": "Not enough space on the container volume: "
- f"upload is {length/1e9:.2f} GB but only "
- f"{max(free - FREE_SPACE_MARGIN, 0)/1e9:.2f} GB is usable. "
- "Delete old sources/outputs or grow the LXC disk."}), 507
- dest = UPLOAD_DIR / f"{uuid.uuid4().hex[:8]}_{safe}"
- try:
- if raw_name:
- with open(dest, "wb") as out:
- while True:
- chunk = request.stream.read(4 * 1024 * 1024)
- if not chunk:
- break
- out.write(chunk)
- else:
- mp.save(dest)
- except OSError as e:
- dest.unlink(missing_ok=True) # never leave truncated sources behind
- import errno
- if e.errno == errno.ENOSPC:
- return jsonify({"error": "Disk filled up mid-upload — the partial "
- "file was removed. Free space and retry."}), 507
- return jsonify({"error": f"Could not store upload: {e}"}), 500
- if raw_name and length and dest.stat().st_size != length:
- # Only meaningful for raw uploads: multipart Content-Length includes
- # boundary/header overhead and will never equal the file size.
- dest.unlink(missing_ok=True)
- return jsonify({"error": "Upload was truncated in transit — removed. "
- "Please retry."}), 500
- return jsonify({"upload_id": dest.name, "filename": safe,
- "size": dest.stat().st_size})
- @app.route("/api/uploads")
- @auth_required
- def api_uploads():
- items = []
- for p in sorted(UPLOAD_DIR.iterdir()):
- if p.is_file():
- items.append({"upload_id": p.name,
- "filename": p.name.split("_", 1)[-1],
- "size": p.stat().st_size,
- "mtime": p.stat().st_mtime})
- return jsonify({"uploads": items, "storage": storage_info()})
- @app.route("/api/uploads/<upload_id>", methods=["DELETE"])
- @auth_required
- def api_delete_upload(upload_id):
- p = UPLOAD_DIR / os.path.basename(upload_id)
- if p.exists():
- p.unlink()
- return jsonify({"deleted": True})
- abort(404)
- @app.route("/api/scan/<upload_id>")
- @auth_required
- def api_scan(upload_id):
- """Title/track discovery via HandBrakeCLI --scan --json."""
- p = UPLOAD_DIR / os.path.basename(upload_id)
- if not p.exists():
- abort(404, description="Upload not found")
- try:
- out = subprocess.run(
- [HANDBRAKE, "--json", "-i", str(p), "--scan", "--title", "0"],
- capture_output=True, text=True, timeout=300)
- text = out.stdout
- m = re.search(r"JSON Title Set:\s*(\{.*)", text, re.S)
- if not m:
- return jsonify({"error": "Scan produced no title data",
- "log": text[-2000:]}), 500
- blob, depth, end = m.group(1), 0, 0
- for i, ch in enumerate(blob):
- if ch == "{": depth += 1
- elif ch == "}":
- depth -= 1
- if depth == 0:
- end = i + 1
- break
- return jsonify(json.loads(blob[:end]))
- except subprocess.TimeoutExpired:
- return jsonify({"error": "Scan timed out"}), 504
- except Exception as e:
- return jsonify({"error": str(e)}), 500
- @app.route("/api/jobs", methods=["GET"])
- @auth_required
- def api_jobs():
- with JOBS_LOCK:
- jobs = [{k: v for k, v in j.items() if k not in ("proc", "log_tail")}
- for j in JOBS.values()]
- jobs.sort(key=lambda j: j["created"], reverse=True)
- return jsonify({"jobs": jobs})
- @app.route("/api/jobs", methods=["POST"])
- @auth_required
- def api_create_job():
- body = request.get_json(force=True, silent=True) or {}
- upload_id = body.get("upload_id")
- if not upload_id:
- abort(400, description="upload_id required")
- src = UPLOAD_DIR / os.path.basename(upload_id)
- if not src.exists():
- abort(404, description="Upload not found")
- options = body.get("options") or {}
- job = new_job("encode", src.name.split("_", 1)[-1], src, options)
- return jsonify({"job_id": job["id"]}), 201
- @app.route("/api/jobs/<job_id>")
- @auth_required
- def api_job(job_id):
- job = JOBS.get(job_id)
- if not job:
- abort(404)
- slim = {k: v for k, v in job.items() if k != "proc"}
- return jsonify(slim)
- @app.route("/api/jobs/<job_id>/log")
- @auth_required
- def api_job_log(job_id):
- job = JOBS.get(job_id)
- if not job:
- abort(404)
- return Response("\n".join(job.get("log_tail", [])), mimetype="text/plain")
- @app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
- @auth_required
- def api_cancel(job_id):
- job = JOBS.get(job_id)
- if not job:
- abort(404)
- if job["status"] in ("queued", "running", "scanning"):
- job["status"] = "cancelled"
- proc = job.get("proc")
- if proc:
- try:
- proc.terminate()
- except Exception:
- pass
- persist_job(job)
- return jsonify({"status": job["status"]})
- @app.route("/api/jobs/<job_id>", methods=["DELETE"])
- @auth_required
- def api_delete_job(job_id):
- job = JOBS.get(job_id)
- if not job:
- abort(404)
- if job["status"] in ("running", "scanning"):
- abort(409, description="Cancel the job before deleting it")
- with JOBS_LOCK:
- JOBS.pop(job_id, None)
- try:
- (JOBS_DIR / f"{job_id}.json").unlink(missing_ok=True)
- if job.get("out") and Path(job["out"]).exists():
- Path(job["out"]).unlink()
- except Exception:
- pass
- return jsonify({"deleted": True})
- @app.route("/api/download/<job_id>")
- @auth_required
- def api_download(job_id):
- job = JOBS.get(job_id)
- if not job or job["status"] != "done" or not job.get("out"):
- abort(404, description="No finished output for this job")
- out = Path(job["out"])
- if not out.exists():
- abort(410, description="Output file no longer exists")
- name = out.name.split("_", 1)[-1]
- return send_file(out, as_attachment=True, download_name=name)
- @app.errorhandler(400)
- @app.errorhandler(401)
- @app.errorhandler(404)
- @app.errorhandler(409)
- @app.errorhandler(410)
- def json_error(err):
- return jsonify({"error": getattr(err, "description", str(err))}), err.code
- @app.errorhandler(500)
- def json_500(err):
- orig = getattr(err, "original_exception", None)
- return jsonify({"error": f"Server error: {orig or err}"}), 500
- if __name__ == "__main__":
- host = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
- port = int(os.environ.get("WEBRAKE_PORT", "8090"))
- detect_capabilities()
- app.run(host=host, port=port, threaded=True)
|