ArtyomV2X 4 týždňov pred
commit
ab1e2d77a1
6 zmenil súbory, kde vykonal 1631 pridanie a 0 odobranie
  1. 94 0
      README.md
  2. 696 0
      app.py
  3. 3 0
      gitignore
  4. 676 0
      index.html
  5. 117 0
      install.sh
  6. 45 0
      update.sh

+ 94 - 0
README.md

@@ -0,0 +1,94 @@
+# WeBrake ☕
+
+A self-hosted web UI for [HandBrake CLI](https://handbrake.fr/features.php), built to run inside an **Alpine Linux LXC** on Proxmox. Upload media from any PC on your network, remux or re-encode it with the full range of HandBrake options (hardware encoders included when a GPU is passed through), watch live progress bars, and download the results straight back to the uploading machine.
+
+Cream-and-gold by day, dark-roast by night.
+
+## One-liner install
+
+Run this **inside an existing Alpine LXC** (as root). It never runs on, or installs anything from, the Proxmox host:
+
+```sh
+wget -qO- https://gogs.av2x.dev/av2x/WeBrake/raw/main/install.sh | ash
+```
+
+The installer will:
+
+1. Enable the Alpine `community` repo and `apk add` Python 3, Flask, HandBrakeCLI, and VA-API driver packages.
+2. Pull `app.py`, `index.html`, and `update.sh` from this repository into `/opt/webrake`.
+3. **Generate a local `token.json`** containing the API token and Flask secret. This file is created on the container at install time — it is never pulled from the repo and must never be committed to it (`.gitignore` enforces this).
+4. Register and start an OpenRC service (`rc-service webrake start`, enabled at boot).
+
+When it finishes it prints the URL (default port `8090`) and the API token. Paste the token into the UI once; it is remembered by your browser.
+
+## Updating
+
+```sh
+/opt/webrake/update.sh
+```
+
+The updater pulls fresh `app.py` and `index.html` (and itself) directly from the repo, keeps a `.bak` of the previous versions, leaves `token.json` untouched, and restarts the service. Nothing changes unless the repo copies differ.
+
+## GPU passthrough (Proxmox host → LXC)
+
+WeBrake itself installs nothing on the host, but hardware encoding requires the host to expose the GPU device nodes to the container. On the **Proxmox host**, edit `/etc/pve/lxc/<CTID>.conf`:
+
+**Intel / AMD (VA-API, QSV):**
+
+```
+dev0: /dev/dri/renderD128,gid=44
+```
+
+(or on older Proxmox versions:)
+
+```
+lxc.cgroup2.devices.allow: c 226:* rwm
+lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir
+```
+
+**NVIDIA (NVENC):**
+
+```
+lxc.cgroup2.devices.allow: c 195:* rwm
+lxc.mount.entry: /dev/nvidia0 dev/nvidia0 none bind,optional,create=file
+lxc.mount.entry: /dev/nvidiactl dev/nvidiactl none bind,optional,create=file
+lxc.mount.entry: /dev/nvidia-uvm dev/nvidia-uvm none bind,optional,create=file
+```
+
+Restart the container afterwards. WeBrake auto-detects `/dev/dri` and `/dev/nvidia*`, shows the result in the header badge, and marks hardware encoders (⚡ `nvenc_*`, `qsv_*`, `vce_*`) in the encoder list. If no GPU is visible it falls back to software encoders and tells you so.
+
+## Using it
+
+1. Open `http://<container-ip>:8090` and enter the API token.
+2. Drag a video into the drop zone — the upload has its own progress bar and lands in `/var/lib/webrake/uploads`.
+3. Optionally **Scan** the source to list its resolution, audio, and subtitle tracks.
+4. Configure the encode across the tabs — General (container, chapters, ranges), Video (encoder, RF/bitrate, presets/tune/profile/level, framerate), Dimensions (resize, crop, anamorphic, rotate, pad), Filters (deinterlace, decomb, detelecine, denoise, sharpen, deblock, colorspace, grayscale), Audio (tracks, encoders, mixdown, DRC, gain, passthru masks), and Subtitles (selection, burn-in, forced, external SRT).
+5. Anything not covered by a control is still available: the **Advanced** tab appends raw `HandBrakeCLI` arguments verbatim, so every flag in the [CLI reference](https://handbrake.fr/docs/en/latest/cli/command-line-reference.html) works.
+6. Queue the encode. Jobs run one at a time with a live gold progress bar, fps, average fps, ETA, pass counter, full log viewer, and the exact command line used.
+7. When a job is **done**, hit **Download** to pull the file back to the PC you're browsing from. Cancel and delete work as expected; deleting a job also removes its output and log.
+
+## Files & layout
+
+| Path | Purpose |
+|---|---|
+| `/opt/webrake/app.py` | Flask backend (pulled from repo) |
+| `/opt/webrake/index.html` | Web UI (pulled from repo) |
+| `/opt/webrake/update.sh` | Updater (pulled from repo) |
+| `/opt/webrake/token.json` | **Local secrets — generated at install, never in the repo** |
+| `/var/lib/webrake/uploads` | Uploaded sources |
+| `/var/lib/webrake/output` | Finished encodes |
+| `/var/lib/webrake/logs` | Per-job HandBrake logs |
+| `/etc/init.d/webrake` | OpenRC service |
+
+Environment overrides: `WEBRAKE_PORT` (default `8090`), `WEBRAKE_DATA`, `WEBRAKE_REPO` (alternate raw-file base URL for install/update).
+
+## Notes & limits
+
+- Uploads are capped at 64 GiB per file.
+- One encode runs at a time; the rest wait in the queue (HandBrake saturates the machine anyway).
+- The API token gates every endpoint. If you expose WeBrake beyond your LAN, put it behind HTTPS (reverse proxy) — the token travels in a header.
+- "Remuxing" without re-encoding: set audio encoder to `copy`, add `--audio-copy-mask` for the codecs to pass through, and pick a fast video path — or drive it entirely from the Advanced tab.
+
+## License / repo
+
+Source of truth: <https://gogs.av2x.dev/av2x/WeBrake>

+ 696 - 0
app.py

@@ -0,0 +1,696 @@
+#!/usr/bin/env python3
+"""
+WeBrake — HandBrake CLI web frontend for Alpine LXC containers.
+
+Serves a single-page UI, accepts media uploads, queues HandBrakeCLI jobs
+with full flag coverage (including a raw-arguments passthrough), reports
+live progress, and serves finished files back for download.
+
+Secrets live in token.json beside this file. That file is generated at
+install time (or on first run if missing) and must NEVER be committed
+to the repository.
+"""
+
+import json
+import os
+import re
+import secrets
+import shlex
+import shutil
+import signal
+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 & configuration
+# --------------------------------------------------------------------------
+APP_DIR = Path(__file__).resolve().parent
+DATA_DIR = Path(os.environ.get("WEBRAKE_DATA", "/var/lib/webrake"))
+UPLOAD_DIR = DATA_DIR / "uploads"
+OUTPUT_DIR = DATA_DIR / "output"
+LOG_DIR = DATA_DIR / "logs"
+STATE_FILE = DATA_DIR / "jobs.json"
+TOKEN_FILE = APP_DIR / "token.json"
+
+HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
+HOST = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
+PORT = int(os.environ.get("WEBRAKE_PORT", "8090"))
+
+for d in (UPLOAD_DIR, OUTPUT_DIR, LOG_DIR):
+    d.mkdir(parents=True, exist_ok=True)
+
+# --------------------------------------------------------------------------
+# token.json — generated at install, never stored in the repo
+# --------------------------------------------------------------------------
+def load_tokens() -> dict:
+    if not TOKEN_FILE.exists():
+        tokens = {
+            "api_token": secrets.token_urlsafe(32),
+            "secret_key": secrets.token_urlsafe(32),
+            "note": "Generated locally by WeBrake. Do not commit this file.",
+        }
+        TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
+        os.chmod(TOKEN_FILE, 0o600)
+        return tokens
+    return json.loads(TOKEN_FILE.read_text())
+
+
+TOKENS = load_tokens()
+
+app = Flask(__name__)
+app.secret_key = TOKENS["secret_key"]
+app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 * 64  # 64 GiB
+
+
+def require_token(fn):
+    @wraps(fn)
+    def wrapper(*args, **kwargs):
+        supplied = (request.headers.get("X-API-Token")
+                    or request.args.get("token", ""))
+        if not secrets.compare_digest(supplied, TOKENS["api_token"]):
+            return jsonify({"error": "Invalid or missing API token."}), 401
+        return fn(*args, **kwargs)
+    return wrapper
+
+
+# --------------------------------------------------------------------------
+# Job model & persistence
+# --------------------------------------------------------------------------
+JOBS: dict = {}
+JOB_LOCK = threading.Lock()
+QUEUE_EVENT = threading.Event()
+PROCS: dict = {}  # job_id -> Popen
+
+
+def save_state():
+    with JOB_LOCK:
+        snapshot = {jid: {k: v for k, v in j.items()} for jid, j in JOBS.items()}
+    STATE_FILE.write_text(json.dumps(snapshot, indent=2))
+
+
+def load_state():
+    if STATE_FILE.exists():
+        try:
+            data = json.loads(STATE_FILE.read_text())
+            for jid, job in data.items():
+                if job.get("status") in ("queued", "running", "scanning"):
+                    job["status"] = "failed"
+                    job["message"] = "Interrupted by server restart."
+                JOBS[jid] = job
+        except Exception:
+            pass
+
+
+# --------------------------------------------------------------------------
+# GPU / capability detection
+# --------------------------------------------------------------------------
+def detect_gpu() -> dict:
+    info = {"dri_devices": [], "nvidia": False, "vaapi": False, "notes": []}
+    dri = Path("/dev/dri")
+    if dri.exists():
+        info["dri_devices"] = sorted(p.name for p in dri.iterdir())
+        info["vaapi"] = any(n.startswith("renderD") for n in info["dri_devices"])
+    if Path("/dev/nvidia0").exists() or Path("/dev/nvidiactl").exists():
+        info["nvidia"] = True
+    if not info["dri_devices"] and not info["nvidia"]:
+        info["notes"].append(
+            "No GPU devices visible in this container. Software encoders will be used. "
+            "Pass /dev/dri (Intel/AMD) or /dev/nvidia* (NVIDIA) into the LXC to enable "
+            "hardware encoding.")
+    return info
+
+
+def hb_help_text() -> str:
+    try:
+        out = subprocess.run([HANDBRAKE, "--help"], capture_output=True,
+                             text=True, timeout=30)
+        return out.stdout + out.stderr
+    except Exception:
+        return ""
+
+
+def detect_encoders() -> list:
+    """Parse the encoder list out of HandBrakeCLI --help."""
+    text = hb_help_text()
+    encoders = []
+    m = re.search(r"--encoder\b.*?Select video encoder:(.*?)(?:--|\Z)",
+                  text, re.S)
+    block = m.group(1) if m else text
+    for token in re.findall(r"^\s{6,}([a-z0-9_]+)\s*$", block, re.M):
+        encoders.append(token)
+    if not encoders:
+        # Sensible fallback list; UI marks unavailable ones after a scan fails.
+        encoders = ["svt_av1", "x264", "x264_10bit", "x265", "x265_10bit",
+                    "x265_12bit", "mpeg4", "mpeg2", "VP8", "VP9", "theora",
+                    "nvenc_h264", "nvenc_h265", "nvenc_av1",
+                    "qsv_h264", "qsv_h265", "qsv_av1",
+                    "vce_h264", "vce_h265"]
+    return encoders
+
+
+def detect_presets() -> list:
+    try:
+        out = subprocess.run([HANDBRAKE, "--preset-list"], capture_output=True,
+                             text=True, timeout=60)
+        text = out.stdout + out.stderr
+        presets, category = [], ""
+        for line in text.splitlines():
+            cat = re.match(r"^([A-Za-z].*)/$", line.strip())
+            if cat:
+                category = cat.group(1)
+                continue
+            item = re.match(r"^\s{4}(\S.*)$", line)
+            if item and category and not line.strip().startswith("+"):
+                name = item.group(1).strip()
+                if name and not name.startswith("-"):
+                    presets.append({"category": category, "name": name})
+        return presets
+    except Exception:
+        return []
+
+
+CAPS_CACHE: dict = {}
+
+
+def capabilities() -> dict:
+    if not CAPS_CACHE:
+        version = ""
+        try:
+            v = subprocess.run([HANDBRAKE, "--version"], capture_output=True,
+                               text=True, timeout=30)
+            version = (v.stdout + v.stderr).strip().splitlines()[0] if (v.stdout or v.stderr) else ""
+        except Exception:
+            version = "HandBrakeCLI not found — install it inside the container."
+        CAPS_CACHE.update({
+            "handbrake": version,
+            "binary": HANDBRAKE,
+            "encoders": detect_encoders(),
+            "presets": detect_presets(),
+            "gpu": detect_gpu(),
+        })
+    return CAPS_CACHE
+
+
+# --------------------------------------------------------------------------
+# HandBrake command builder — maps UI options onto CLI flags
+# --------------------------------------------------------------------------
+SAFE_NAME = re.compile(r"[^A-Za-z0-9._ ()\[\]-]")
+
+
+def sanitize_name(name: str) -> str:
+    return SAFE_NAME.sub("_", Path(name).name)[:200] or "media"
+
+
+def add(cmd: list, flag: str, value=None):
+    if value is None:
+        cmd.append(flag)
+    else:
+        cmd.extend([flag, str(value)])
+
+
+def build_command(job: dict) -> list:
+    o = job["options"]
+    src = UPLOAD_DIR / job["source"]
+    dst = OUTPUT_DIR / job["output"]
+    cmd = [HANDBRAKE, "-i", str(src), "-o", str(dst)]
+
+    # ---- General / source -------------------------------------------------
+    if o.get("preset"):
+        add(cmd, "--preset", o["preset"])
+    if o.get("format"):
+        add(cmd, "--format", o["format"])
+    if o.get("title"):
+        add(cmd, "--title", o["title"])
+    if o.get("chapters"):
+        add(cmd, "--chapters", o["chapters"])
+    if o.get("start_at"):
+        add(cmd, "--start-at", o["start_at"])
+    if o.get("stop_at"):
+        add(cmd, "--stop-at", o["stop_at"])
+    if o.get("angle"):
+        add(cmd, "--angle", o["angle"])
+    if o.get("markers"):
+        add(cmd, "--markers")
+    if o.get("optimize"):
+        add(cmd, "--optimize")
+    if o.get("ipod_atom"):
+        add(cmd, "--ipod-atom")
+    if o.get("align_av"):
+        add(cmd, "--align-av")
+    if o.get("inline_parameter_sets"):
+        add(cmd, "--inline-parameter-sets")
+
+    # ---- Video ------------------------------------------------------------
+    if o.get("encoder"):
+        add(cmd, "--encoder", o["encoder"])
+    rc = o.get("rate_control", "quality")
+    if rc == "quality" and o.get("quality") not in (None, ""):
+        add(cmd, "--quality", o["quality"])
+    elif rc == "bitrate" and o.get("vb"):
+        add(cmd, "--vb", o["vb"])
+        if o.get("two_pass"):
+            add(cmd, "--multi-pass")
+            if o.get("turbo"):
+                add(cmd, "--turbo")
+    if o.get("encoder_preset"):
+        add(cmd, "--encoder-preset", o["encoder_preset"])
+    if o.get("encoder_tune"):
+        add(cmd, "--encoder-tune", o["encoder_tune"])
+    if o.get("encoder_profile"):
+        add(cmd, "--encoder-profile", o["encoder_profile"])
+    if o.get("encoder_level"):
+        add(cmd, "--encoder-level", o["encoder_level"])
+    if o.get("encopts"):
+        add(cmd, "--encopts", o["encopts"])
+    if o.get("framerate"):
+        add(cmd, "--rate", o["framerate"])
+    fr_mode = o.get("framerate_mode")
+    if fr_mode == "cfr":
+        add(cmd, "--cfr")
+    elif fr_mode == "vfr":
+        add(cmd, "--vfr")
+    elif fr_mode == "pfr":
+        add(cmd, "--pfr")
+
+    # ---- Dimensions ---------------------------------------------------------
+    if o.get("width"):
+        add(cmd, "--width", o["width"])
+    if o.get("height"):
+        add(cmd, "--height", o["height"])
+    if o.get("max_width"):
+        add(cmd, "--maxWidth", o["max_width"])
+    if o.get("max_height"):
+        add(cmd, "--maxHeight", o["max_height"])
+    if o.get("crop"):
+        add(cmd, "--crop", o["crop"])
+    if o.get("crop_mode"):
+        add(cmd, "--crop-mode", o["crop_mode"])
+    anam = o.get("anamorphic")
+    if anam in ("auto", "loose", "custom", "non"):
+        add(cmd, f"--{'non-' if anam == 'non' else ''}anamorphic"
+            if anam == "non" else f"--{anam}-anamorphic")
+    if o.get("display_width"):
+        add(cmd, "--display-width", o["display_width"])
+    if o.get("pixel_aspect"):
+        add(cmd, "--pixel-aspect", o["pixel_aspect"])
+    if o.get("modulus"):
+        add(cmd, "--modulus", o["modulus"])
+    if o.get("color_matrix"):
+        add(cmd, "--color-matrix", o["color_matrix"])
+
+    # ---- Filters ------------------------------------------------------------
+    def filt(key, flag):
+        v = o.get(key)
+        if v is True or v == "default":
+            add(cmd, flag)
+        elif v:
+            add(cmd, flag, v)
+
+    filt("comb_detect", "--comb-detect")
+    filt("deinterlace", "--deinterlace")
+    filt("decomb", "--decomb")
+    filt("detelecine", "--detelecine")
+    if o.get("denoise_filter") == "hqdn3d":
+        filt("denoise", "--hqdn3d")
+    elif o.get("denoise_filter") == "nlmeans":
+        filt("denoise", "--nlmeans")
+        if o.get("nlmeans_tune"):
+            add(cmd, "--nlmeans-tune", o["nlmeans_tune"])
+    filt("chroma_smooth", "--chroma-smooth")
+    if o.get("chroma_smooth_tune"):
+        add(cmd, "--chroma-smooth-tune", o["chroma_smooth_tune"])
+    if o.get("sharpen_filter") == "unsharp":
+        filt("sharpen", "--unsharp")
+        if o.get("sharpen_tune"):
+            add(cmd, "--unsharp-tune", o["sharpen_tune"])
+    elif o.get("sharpen_filter") == "lapsharp":
+        filt("sharpen", "--lapsharp")
+        if o.get("sharpen_tune"):
+            add(cmd, "--lapsharp-tune", o["sharpen_tune"])
+    filt("deblock", "--deblock")
+    if o.get("deblock_tune"):
+        add(cmd, "--deblock-tune", o["deblock_tune"])
+    if o.get("rotate"):
+        add(cmd, "--rotate", o["rotate"])
+    if o.get("pad"):
+        add(cmd, "--pad", o["pad"])
+    if o.get("colorspace"):
+        add(cmd, "--colorspace", o["colorspace"])
+    if o.get("grayscale"):
+        add(cmd, "--grayscale")
+    if o.get("no_dvdnav"):
+        add(cmd, "--no-dvdnav")
+
+    # ---- Audio --------------------------------------------------------------
+    if o.get("all_audio"):
+        add(cmd, "--all-audio")
+    elif o.get("audio_tracks"):
+        add(cmd, "--audio", o["audio_tracks"])
+    if o.get("audio_encoder"):
+        add(cmd, "--aencoder", o["audio_encoder"])
+    if o.get("audio_bitrate"):
+        add(cmd, "--ab", o["audio_bitrate"])
+    if o.get("audio_quality"):
+        add(cmd, "--aq", o["audio_quality"])
+    if o.get("mixdown"):
+        add(cmd, "--mixdown", o["mixdown"])
+    if o.get("samplerate"):
+        add(cmd, "--arate", o["samplerate"])
+    if o.get("drc"):
+        add(cmd, "--drc", o["drc"])
+    if o.get("gain"):
+        add(cmd, "--gain", o["gain"])
+    if o.get("audio_names"):
+        add(cmd, "--aname", o["audio_names"])
+    if o.get("audio_copy_mask"):
+        add(cmd, "--audio-copy-mask", o["audio_copy_mask"])
+    if o.get("audio_fallback"):
+        add(cmd, "--audio-fallback", o["audio_fallback"])
+    if o.get("normalize_mix"):
+        add(cmd, "--normalize-mix", o["normalize_mix"])
+
+    # ---- Subtitles ------------------------------------------------------------
+    if o.get("all_subtitles"):
+        add(cmd, "--all-subtitles")
+    elif o.get("subtitle_tracks"):
+        add(cmd, "--subtitle", o["subtitle_tracks"])
+    if o.get("subtitle_burned"):
+        add(cmd, "--subtitle-burned", o["subtitle_burned"])
+    if o.get("subtitle_default"):
+        add(cmd, "--subtitle-default", o["subtitle_default"])
+    if o.get("subtitle_forced"):
+        add(cmd, "--subtitle-forced", o["subtitle_forced"])
+    if o.get("native_language"):
+        add(cmd, "--native-language", o["native_language"])
+    if o.get("srt_file"):
+        add(cmd, "--srt-file", str(UPLOAD_DIR / sanitize_name(o["srt_file"])))
+        if o.get("srt_codeset"):
+            add(cmd, "--srt-codeset", o["srt_codeset"])
+        if o.get("srt_lang"):
+            add(cmd, "--srt-lang", o["srt_lang"])
+        if o.get("srt_burn"):
+            add(cmd, "--srt-burn")
+
+    # ---- Raw passthrough: guarantees EVERY HandBrake flag is reachable -------
+    if o.get("raw_args"):
+        cmd.extend(shlex.split(o["raw_args"]))
+
+    return cmd
+
+
+# --------------------------------------------------------------------------
+# Worker: runs queued jobs one at a time, parses live progress
+# --------------------------------------------------------------------------
+PROGRESS_RE = re.compile(
+    r"Encoding:.*?(\d+\.\d+)\s?%"
+    r"(?:.*?(\d+\.\d+)\s?fps"
+    r".*?avg\s+(\d+\.\d+)\s?fps"
+    r".*?ETA\s+(\d+h\d+m\d+s))?", re.S)
+TASK_RE = re.compile(r"task (\d+) of (\d+)")
+
+
+def run_job(job_id: str):
+    with JOB_LOCK:
+        job = JOBS[job_id]
+        job["status"] = "running"
+        job["started"] = time.time()
+    save_state()
+
+    cmd = job["command"]
+    log_path = LOG_DIR / f"{job_id}.log"
+    try:
+        with open(log_path, "w") as log:
+            log.write("$ " + " ".join(shlex.quote(c) for c in cmd) + "\n\n")
+            log.flush()
+            proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
+                                    stderr=subprocess.STDOUT, text=True,
+                                    bufsize=1, errors="replace",
+                                    preexec_fn=os.setsid)
+            PROCS[job_id] = proc
+            buf = ""
+            while True:
+                chunk = proc.stdout.read(256)
+                if not chunk:
+                    break
+                log.write(chunk)
+                log.flush()
+                buf = (buf + chunk)[-2000:]
+                m = None
+                for m in PROGRESS_RE.finditer(buf):
+                    pass
+                if m:
+                    with JOB_LOCK:
+                        job["progress"] = float(m.group(1))
+                        if m.group(2):
+                            job["fps"] = float(m.group(2))
+                            job["avg_fps"] = float(m.group(3))
+                            job["eta"] = m.group(4)
+                    t = None
+                    for t in TASK_RE.finditer(buf):
+                        pass
+                    if t:
+                        with JOB_LOCK:
+                            job["task"] = f"{t.group(1)}/{t.group(2)}"
+            proc.wait()
+        PROCS.pop(job_id, None)
+        out_file = OUTPUT_DIR / job["output"]
+        with JOB_LOCK:
+            if job.get("status") == "cancelled":
+                pass
+            elif proc.returncode == 0 and out_file.exists():
+                job["status"] = "done"
+                job["progress"] = 100.0
+                job["size"] = out_file.stat().st_size
+                job["message"] = "Ready to download."
+            else:
+                job["status"] = "failed"
+                job["message"] = f"HandBrakeCLI exited with code {proc.returncode}. See log."
+            job["finished"] = time.time()
+    except Exception as exc:
+        PROCS.pop(job_id, None)
+        with JOB_LOCK:
+            job["status"] = "failed"
+            job["message"] = f"{type(exc).__name__}: {exc}"
+            job["finished"] = time.time()
+    save_state()
+
+
+def worker_loop():
+    while True:
+        QUEUE_EVENT.wait(timeout=2)
+        QUEUE_EVENT.clear()
+        while True:
+            with JOB_LOCK:
+                pending = [j for j in JOBS.values() if j["status"] == "queued"]
+                pending.sort(key=lambda j: j["created"])
+                nxt = pending[0]["id"] if pending else None
+            if not nxt:
+                break
+            run_job(nxt)
+
+
+threading.Thread(target=worker_loop, daemon=True).start()
+
+
+# --------------------------------------------------------------------------
+# Routes
+# --------------------------------------------------------------------------
+@app.route("/")
+def index():
+    return send_from_directory(APP_DIR, "index.html")
+
+
+@app.route("/api/ping")
+def ping():
+    supplied = request.headers.get("X-API-Token", "")
+    ok = secrets.compare_digest(supplied, TOKENS["api_token"])
+    return jsonify({"ok": ok})
+
+
+@app.route("/api/capabilities")
+@require_token
+def api_capabilities():
+    return jsonify(capabilities())
+
+
+@app.route("/api/upload", methods=["POST"])
+@require_token
+def api_upload():
+    f = request.files.get("file")
+    if not f or not f.filename:
+        return jsonify({"error": "No file supplied."}), 400
+    name = sanitize_name(f.filename)
+    dest = UPLOAD_DIR / name
+    stem, suffix, n = dest.stem, dest.suffix, 1
+    while dest.exists():
+        dest = UPLOAD_DIR / f"{stem}({n}){suffix}"
+        n += 1
+    f.save(dest)
+    return jsonify({"filename": dest.name, "size": dest.stat().st_size})
+
+
+@app.route("/api/sources")
+@require_token
+def api_sources():
+    files = []
+    for p in sorted(UPLOAD_DIR.iterdir()):
+        if p.is_file():
+            files.append({"name": p.name, "size": p.stat().st_size,
+                          "mtime": p.stat().st_mtime})
+    return jsonify(files)
+
+
+@app.route("/api/sources/<path:name>", methods=["DELETE"])
+@require_token
+def api_delete_source(name):
+    p = UPLOAD_DIR / sanitize_name(name)
+    if p.exists():
+        p.unlink()
+    return jsonify({"ok": True})
+
+
+@app.route("/api/scan", methods=["POST"])
+@require_token
+def api_scan():
+    """Scan a source with HandBrakeCLI to enumerate titles/tracks."""
+    name = sanitize_name(request.json.get("filename", ""))
+    src = UPLOAD_DIR / name
+    if not src.exists():
+        return jsonify({"error": "Source not found."}), 404
+    try:
+        out = subprocess.run(
+            [HANDBRAKE, "-i", str(src), "--scan", "--title", "0", "--json"],
+            capture_output=True, text=True, timeout=600)
+        text = out.stdout
+        m = re.search(r"JSON Title Set:\s*(\{.*)", text, re.S)
+        titles = []
+        if m:
+            data = json.loads(m.group(1)[:m.group(1).rfind("}") + 1])
+            for t in data.get("TitleList", []):
+                titles.append({
+                    "index": t.get("Index"),
+                    "duration": t.get("Duration"),
+                    "geometry": t.get("Geometry"),
+                    "framerate": t.get("FrameRate"),
+                    "audio": [{"track": i + 1,
+                               "description": a.get("Description", ""),
+                               "language": a.get("Language", "")}
+                              for i, a in enumerate(t.get("AudioList", []))],
+                    "subtitles": [{"track": i + 1,
+                                   "name": s.get("Name") or s.get("Language", ""),
+                                   "format": s.get("SourceName", "")}
+                                  for i, s in enumerate(t.get("SubtitleList", []))],
+                })
+        return jsonify({"titles": titles})
+    except subprocess.TimeoutExpired:
+        return jsonify({"error": "Scan timed out."}), 500
+    except Exception as exc:
+        return jsonify({"error": str(exc)}), 500
+
+
+@app.route("/api/jobs", methods=["GET"])
+@require_token
+def api_jobs():
+    with JOB_LOCK:
+        jobs = sorted(JOBS.values(), key=lambda j: j["created"], reverse=True)
+    return jsonify(jobs)
+
+
+@app.route("/api/jobs", methods=["POST"])
+@require_token
+def api_create_job():
+    body = request.json or {}
+    source = sanitize_name(body.get("source", ""))
+    if not (UPLOAD_DIR / source).exists():
+        return jsonify({"error": "Source file not found — upload it first."}), 400
+    options = body.get("options", {})
+    fmt = options.get("format", "av_mkv")
+    ext = {"av_mp4": ".mp4", "av_mkv": ".mkv", "av_webm": ".webm"}.get(fmt, ".mkv")
+    out_name = body.get("output") or (Path(source).stem + ".webrake" + ext)
+    out_name = sanitize_name(out_name)
+
+    job_id = uuid.uuid4().hex[:12]
+    job = {
+        "id": job_id,
+        "source": source,
+        "output": out_name,
+        "options": options,
+        "status": "queued",
+        "progress": 0.0,
+        "created": time.time(),
+        "message": "Waiting in queue.",
+    }
+    job["command"] = build_command(job)
+    job["command_preview"] = " ".join(shlex.quote(c) for c in job["command"])
+    with JOB_LOCK:
+        JOBS[job_id] = job
+    save_state()
+    QUEUE_EVENT.set()
+    return jsonify(job), 201
+
+
+@app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
+@require_token
+def api_cancel(job_id):
+    with JOB_LOCK:
+        job = JOBS.get(job_id)
+        if not job:
+            abort(404)
+        job["status"] = "cancelled"
+        job["message"] = "Cancelled by user."
+    proc = PROCS.get(job_id)
+    if proc:
+        try:
+            os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+        except Exception:
+            pass
+    save_state()
+    return jsonify({"ok": True})
+
+
+@app.route("/api/jobs/<job_id>", methods=["DELETE"])
+@require_token
+def api_delete_job(job_id):
+    with JOB_LOCK:
+        job = JOBS.pop(job_id, None)
+    if job:
+        for p in (OUTPUT_DIR / job["output"], LOG_DIR / f"{job_id}.log"):
+            if p.exists():
+                p.unlink()
+    save_state()
+    return jsonify({"ok": True})
+
+
+@app.route("/api/jobs/<job_id>/log")
+@require_token
+def api_log(job_id):
+    p = LOG_DIR / f"{job_id}.log"
+    if not p.exists():
+        abort(404)
+    tail = p.read_text(errors="replace")[-20000:]
+    return Response(tail, mimetype="text/plain")
+
+
+@app.route("/api/download/<job_id>")
+@require_token
+def api_download(job_id):
+    with JOB_LOCK:
+        job = JOBS.get(job_id)
+    if not job or job["status"] != "done":
+        abort(404)
+    return send_file(OUTPUT_DIR / job["output"], as_attachment=True,
+                     download_name=job["output"])
+
+
+if __name__ == "__main__":
+    load_state()
+    print(f"WeBrake listening on http://{HOST}:{PORT}")
+    print(f"API token: {TOKENS['api_token']}")
+    app.run(host=HOST, port=PORT, threaded=True)

+ 3 - 0
gitignore

@@ -0,0 +1,3 @@
+token.json
+*.bak
+__pycache__/

+ 676 - 0
index.html

@@ -0,0 +1,676 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>WeBrake · HandBrake, poured over the web</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+:root{
+  --bg:#F6F0E2;            /* cream */
+  --bg-raise:#FDFAF2;
+  --bg-sunken:#EFE7D3;
+  --ink:#33271B;
+  --ink-soft:#6E5E49;
+  --line:#E2D7BE;
+  --gold:#B98A1E;          /* gold accent */
+  --gold-bright:#D9A82E;
+  --gold-soft:#F0E3C0;
+  --ok:#5E7C3A; --err:#A8442F; --warn:#B0761D;
+  --shadow:0 1px 2px rgba(51,39,27,.06),0 8px 24px rgba(51,39,27,.07);
+  --radius:14px;
+}
+[data-theme="dark"]{
+  --bg:#221610;            /* dark roast */
+  --bg-raise:#2C1E15;
+  --bg-sunken:#1A100B;
+  --ink:#F0E4D0;
+  --ink-soft:#B39B7E;
+  --line:#3E2C1E;
+  --gold:#D4A937;
+  --gold-bright:#E8C158;
+  --gold-soft:#4A3617;
+  --ok:#8FB061; --err:#D3745E; --warn:#D9A54B;
+  --shadow:0 1px 2px rgba(0,0,0,.35),0 10px 28px rgba(0,0,0,.35);
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{color-scheme:light}
+[data-theme="dark"] html,[data-theme="dark"]{color-scheme:dark}
+body{
+  font-family:'Inter',system-ui,sans-serif;font-size:14.5px;line-height:1.5;
+  background:var(--bg);color:var(--ink);
+  transition:background .3s,color .3s;min-height:100vh;
+}
+h1,h2,h3{font-family:'Fraunces',serif;font-weight:700;letter-spacing:-.01em}
+code,.mono,textarea.mono,input.mono{font-family:'JetBrains Mono',monospace;font-size:12.5px}
+a{color:var(--gold)}
+button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
+
+.wrap{max-width:1100px;margin:0 auto;padding:0 20px 80px}
+
+/* ---------- header ---------- */
+header{display:flex;align-items:center;gap:14px;padding:26px 0 20px;flex-wrap:wrap}
+.logo{display:flex;align-items:center;gap:12px}
+.logo svg{display:block}
+.logo h1{font-size:26px}
+.logo .tag{font-size:12px;color:var(--ink-soft);margin-top:-2px}
+.chips{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto;align-items:center}
+.chip{font-size:11.5px;font-weight:600;padding:4px 10px;border-radius:999px;
+  border:1px solid var(--line);background:var(--bg-raise);color:var(--ink-soft)}
+.chip.hw{border-color:var(--gold);color:var(--gold);background:transparent}
+.theme-btn{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);
+  background:var(--bg-raise);display:grid;place-items:center;transition:transform .2s}
+.theme-btn:hover{transform:rotate(20deg)}
+
+/* ---------- cards ---------- */
+.card{background:var(--bg-raise);border:1px solid var(--line);border-radius:var(--radius);
+  box-shadow:var(--shadow);padding:22px;margin-bottom:22px}
+.card h2{font-size:19px;margin-bottom:4px}
+.card .sub{color:var(--ink-soft);font-size:13px;margin-bottom:16px}
+
+/* ---------- dropzone ---------- */
+.drop{border:2px dashed var(--line);border-radius:var(--radius);padding:34px;
+  text-align:center;color:var(--ink-soft);transition:.2s;cursor:pointer;background:var(--bg-sunken)}
+.drop.hover{border-color:var(--gold);color:var(--gold);background:var(--gold-soft)}
+.drop strong{color:var(--ink)}
+.upbar{margin-top:14px;display:none}
+.upbar.show{display:block}
+
+/* ---------- progress (signature: gold pour) ---------- */
+.bar{height:10px;border-radius:999px;background:var(--bg-sunken);overflow:hidden;
+  border:1px solid var(--line);position:relative}
+.bar>i{display:block;height:100%;width:0;border-radius:999px;
+  background:linear-gradient(90deg,var(--gold) 0%,var(--gold-bright) 50%,var(--gold) 100%);
+  background-size:200% 100%;animation:pour 1.6s linear infinite;transition:width .4s ease}
+.bar.static>i{animation:none}
+@keyframes pour{from{background-position:0 0}to{background-position:-200% 0}}
+@media (prefers-reduced-motion:reduce){.bar>i{animation:none}}
+
+/* ---------- sources ---------- */
+.src-list{display:flex;flex-direction:column;gap:8px;margin-top:14px}
+.src{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--line);
+  border-radius:10px;background:var(--bg-sunken);cursor:pointer;transition:.15s}
+.src:hover{border-color:var(--gold)}
+.src.sel{border-color:var(--gold);background:var(--gold-soft);box-shadow:inset 0 0 0 1px var(--gold)}
+.src .name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.src .meta{margin-left:auto;color:var(--ink-soft);font-size:12px;white-space:nowrap}
+.src .del{color:var(--ink-soft);padding:2px 6px;border-radius:6px}
+.src .del:hover{color:var(--err)}
+
+/* ---------- tabs & fields ---------- */
+.tabs{display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin:6px 0 18px}
+.tab{padding:9px 14px;font-weight:600;font-size:13px;color:var(--ink-soft);
+  border-radius:8px 8px 0 0;border-bottom:2px solid transparent;margin-bottom:-1px}
+.tab.on{color:var(--gold);border-bottom-color:var(--gold)}
+.pane{display:none}.pane.on{display:block}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:14px 16px}
+.field label{display:block;font-size:12px;font-weight:600;color:var(--ink-soft);
+  margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em}
+.field input[type=text],.field input[type=number],.field select,.field textarea{
+  width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;
+  background:var(--bg-sunken);color:var(--ink);font:inherit;font-size:13.5px}
+.field input:focus,.field select:focus,.field textarea:focus{
+  outline:2px solid var(--gold);outline-offset:1px;border-color:var(--gold)}
+.field.wide{grid-column:1/-1}
+.check{display:flex;align-items:center;gap:8px;font-size:13.5px;padding:6px 0}
+.check input{accent-color:var(--gold);width:16px;height:16px}
+.hint{font-size:11.5px;color:var(--ink-soft);margin-top:4px}
+textarea{resize:vertical;min-height:70px}
+
+/* ---------- buttons ---------- */
+.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:10px;
+  font-weight:600;font-size:14px;border:1px solid var(--line);background:var(--bg-sunken);
+  color:var(--ink);transition:.15s}
+.btn:hover{border-color:var(--gold)}
+.btn.gold{background:var(--gold);border-color:var(--gold);color:#fff}
+[data-theme="dark"] .btn.gold{color:#221610}
+.btn.gold:hover{background:var(--gold-bright);border-color:var(--gold-bright)}
+.btn.sm{padding:6px 12px;font-size:12.5px;border-radius:8px}
+.btn.danger:hover{border-color:var(--err);color:var(--err)}
+.btn:disabled{opacity:.45;cursor:not-allowed}
+.actions{display:flex;gap:10px;align-items:center;margin-top:18px;flex-wrap:wrap}
+
+/* ---------- jobs ---------- */
+.job{border:1px solid var(--line);border-radius:12px;padding:16px;margin-top:12px;background:var(--bg-sunken)}
+.job .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.job .fname{font-weight:600}
+.status{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
+  padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--ink-soft)}
+.status.running,.status.scanning{color:var(--gold);border-color:var(--gold)}
+.status.done{color:var(--ok);border-color:var(--ok)}
+.status.failed{color:var(--err);border-color:var(--err)}
+.status.cancelled{color:var(--warn);border-color:var(--warn)}
+.job .stats{margin-left:auto;color:var(--ink-soft);font-size:12.5px;display:flex;gap:14px}
+.job .bar{margin-top:10px}
+.job .btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
+.job .errline{color:var(--err);font-size:12.5px;margin-top:8px}
+.logbox{display:none;margin-top:10px;background:var(--bg);border:1px solid var(--line);
+  border-radius:8px;padding:10px;max-height:260px;overflow:auto;white-space:pre-wrap;font-size:11.5px}
+.logbox.show{display:block}
+.empty{color:var(--ink-soft);text-align:center;padding:26px;font-size:13.5px}
+
+.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);
+  background:var(--ink);color:var(--bg);padding:10px 20px;border-radius:10px;font-size:13.5px;
+  opacity:0;pointer-events:none;transition:.25s;z-index:50;box-shadow:var(--shadow)}
+.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
+@media(max-width:640px){.job .stats{margin-left:0;width:100%}}
+</style>
+</head>
+<body>
+<div class="wrap">
+
+<header>
+  <div class="logo">
+    <!-- coffee-ring / reel mark -->
+    <svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden="true">
+      <circle cx="21" cy="21" r="17" stroke="var(--gold)" stroke-width="3.5"/>
+      <circle cx="21" cy="21" r="9" stroke="var(--gold)" stroke-width="2" stroke-dasharray="4 5"/>
+      <circle cx="21" cy="21" r="3" fill="var(--gold)"/>
+    </svg>
+    <div>
+      <h1>WeBrake</h1>
+      <div class="tag">HandBrake, poured over the web</div>
+    </div>
+  </div>
+  <div class="chips" id="chips">
+    <span class="chip" id="chip-hb">HandBrake …</span>
+    <button class="theme-btn" id="themeBtn" title="Toggle dark roast" aria-label="Toggle theme">
+      <svg id="themeIcon" width="18" height="18" viewBox="0 0 24 24" fill="none"
+        stroke="currentColor" stroke-width="2" stroke-linecap="round"></svg>
+    </button>
+  </div>
+</header>
+
+<!-- ============ upload ============ -->
+<section class="card">
+  <h2>1 · Pour in your media</h2>
+  <div class="sub">Drop a video file to upload it to the container for remuxing or re-encoding.</div>
+  <div class="drop" id="drop" tabindex="0" role="button" aria-label="Upload a video file">
+    <strong>Drop a file here</strong> or click to browse
+    <div class="hint">MKV, MP4, AVI, TS, MOV, WebM — anything HandBrake can scan.</div>
+  </div>
+  <input type="file" id="fileInput" hidden>
+  <div class="upbar" id="upbar">
+    <div class="bar"><i id="upfill"></i></div>
+    <div class="hint" id="uptext">Uploading…</div>
+  </div>
+  <div class="src-list" id="srcList"></div>
+</section>
+
+<!-- ============ options ============ -->
+<section class="card">
+  <h2>2 · Dial in the brew</h2>
+  <div class="sub">Every HandBrake feature is here — pick a preset, fine-tune the groups, or drop raw flags in Advanced.</div>
+
+  <div class="grid" style="margin-bottom:4px">
+    <div class="field">
+      <label for="preset">Preset</label>
+      <select id="preset"><option value="">(none — manual settings)</option></select>
+    </div>
+    <div class="field">
+      <label for="o_format">Container</label>
+      <select id="o_format">
+        <option value="av_mkv">MKV (av_mkv)</option>
+        <option value="av_mp4">MP4 (av_mp4)</option>
+        <option value="av_webm">WebM (av_webm)</option>
+      </select>
+    </div>
+    <div class="field">
+      <label for="o_output_name">Output name (no extension)</label>
+      <input type="text" id="o_output_name" placeholder="defaults to source name">
+    </div>
+  </div>
+
+  <div class="tabs" id="tabs">
+    <button class="tab on" data-pane="p-src">Source</button>
+    <button class="tab" data-pane="p-vid">Video</button>
+    <button class="tab" data-pane="p-aud">Audio</button>
+    <button class="tab" data-pane="p-sub">Subtitles</button>
+    <button class="tab" data-pane="p-pic">Picture</button>
+    <button class="tab" data-pane="p-fil">Filters</button>
+    <button class="tab" data-pane="p-adv">Advanced</button>
+  </div>
+
+  <!-- Source -->
+  <div class="pane on" id="p-src"><div class="grid">
+    <div class="field"><label>Title #</label><input type="number" id="o_title" min="0" placeholder="auto"></div>
+    <div class="field"><label>Chapters</label><input type="text" id="o_chapters" placeholder="e.g. 1-3"></div>
+    <div class="field"><label>Angle</label><input type="number" id="o_angle" min="1" placeholder=""></div>
+    <div class="field"><label>Min duration (s)</label><input type="number" id="o_min_duration" placeholder=""></div>
+    <div class="field"><label>Start at</label><input type="text" id="o_start_at" placeholder="seconds:30 / frames:100 / duration:00:01:00"></div>
+    <div class="field"><label>Stop at</label><input type="text" id="o_stop_at" placeholder="same units as start"></div>
+    <div class="field"><label>Previews</label><input type="text" id="o_previews" placeholder="e.g. 30:1"></div>
+    <div class="field"><label>Start at preview #</label><input type="number" id="o_start_at_preview" placeholder=""></div>
+    <div class="field wide">
+      <label class="check"><input type="checkbox" id="o_main_feature"> Main feature title only (--main-feature)</label>
+      <label class="check"><input type="checkbox" id="o_markers" checked> Chapter markers (--markers)</label>
+      <label class="check"><input type="checkbox" id="o_optimize"> Web optimized / fast start (--optimize)</label>
+      <label class="check"><input type="checkbox" id="o_align_av"> Align A/V start (--align-av)</label>
+      <label class="check"><input type="checkbox" id="o_inline_parameter_sets"> Inline parameter sets (--inline-parameter-sets)</label>
+    </div>
+    <div class="field wide" id="scanBox">
+      <button class="btn sm" id="scanBtn" type="button">Scan source for titles &amp; tracks</button>
+      <div class="hint mono" id="scanOut"></div>
+    </div>
+  </div></div>
+
+  <!-- Video -->
+  <div class="pane" id="p-vid"><div class="grid">
+    <div class="field"><label>Encoder</label><select id="o_encoder"><option value="">(preset default)</option></select>
+      <div class="hint" id="hwHint"></div></div>
+    <div class="field"><label>Encoder preset</label><input type="text" id="o_encoder_preset" placeholder="e.g. medium / p5 / quality"></div>
+    <div class="field"><label>Encoder tune</label><input type="text" id="o_encoder_tune" placeholder="e.g. film, animation"></div>
+    <div class="field"><label>Profile</label><input type="text" id="o_encoder_profile" placeholder="e.g. main, high, main10"></div>
+    <div class="field"><label>Level</label><input type="text" id="o_encoder_level" placeholder="e.g. 4.1"></div>
+    <div class="field"><label>Quality (CQ/RF)</label><input type="number" id="o_quality" step="0.25" placeholder="e.g. 22"></div>
+    <div class="field"><label>Bitrate kb/s (--vb)</label><input type="number" id="o_vb" placeholder="overrides quality"></div>
+    <div class="field"><label>Framerate (--rate)</label><input type="text" id="o_rate" placeholder="e.g. 23.976, 30"></div>
+    <div class="field"><label>Framerate mode</label>
+      <select id="o_fr_mode"><option value="">source default</option>
+        <option value="cfr">Constant (--cfr)</option>
+        <option value="vfr">Variable (--vfr)</option>
+        <option value="pfr">Peak (--pfr)</option></select></div>
+    <div class="field wide"><label>Extra encoder options (--encopts)</label>
+      <input type="text" class="mono" id="o_encopts" placeholder="key=value:key=value"></div>
+    <div class="field wide">
+      <label class="check"><input type="checkbox" id="o_two_pass"> Two-pass encode (--two-pass)</label>
+      <label class="check"><input type="checkbox" id="o_turbo"> Turbo first pass (--turbo)</label>
+      <label class="check"><input type="checkbox" id="o_hwdec"> Hardware decoding (--enable-hw-decoding)</label>
+    </div>
+  </div></div>
+
+  <!-- Audio -->
+  <div class="pane" id="p-aud"><div class="grid">
+    <div class="field"><label>Tracks (--audio)</label><input type="text" id="o_audio" placeholder="e.g. 1,2 or leave blank"></div>
+    <div class="field"><label>Language list</label><input type="text" id="o_audio_lang_list" placeholder="e.g. eng,jpn"></div>
+    <div class="field"><label>Encoder(s) (--aencoder)</label><input type="text" id="o_aencoder" placeholder="e.g. copy, av_aac, opus, flac"></div>
+    <div class="field"><label>Copy mask</label><input type="text" id="o_audio_copy_mask" placeholder="e.g. aac,ac3,dtshd,truehd"></div>
+    <div class="field"><label>Fallback encoder</label><input type="text" id="o_audio_fallback" placeholder="e.g. av_aac"></div>
+    <div class="field"><label>Bitrate(s) kb/s (--ab)</label><input type="text" id="o_ab" placeholder="e.g. 160,256"></div>
+    <div class="field"><label>Quality(ies) (--aq)</label><input type="text" id="o_aq" placeholder=""></div>
+    <div class="field"><label>Compression (--ac)</label><input type="text" id="o_ac" placeholder=""></div>
+    <div class="field"><label>Mixdown</label><input type="text" id="o_mixdown" placeholder="e.g. stereo, 5point1"></div>
+    <div class="field"><label>Normalize mix</label><input type="text" id="o_normalize_mix" placeholder="0 or 1"></div>
+    <div class="field"><label>Samplerate kHz (--arate)</label><input type="text" id="o_arate" placeholder="e.g. 48"></div>
+    <div class="field"><label>DRC (--drc)</label><input type="text" id="o_drc" placeholder="1.0–4.0"></div>
+    <div class="field"><label>Gain dB</label><input type="text" id="o_gain" placeholder="e.g. 2"></div>
+    <div class="field"><label>Dither (--adither)</label><input type="text" id="o_adither" placeholder="auto / none / …"></div>
+    <div class="field wide"><label>Track name(s) (--aname)</label><input type="text" id="o_aname" placeholder="comma-separated"></div>
+    <div class="field wide">
+      <label class="check"><input type="checkbox" id="o_all_audio"> Include all audio tracks (--all-audio)</label>
+      <label class="check"><input type="checkbox" id="o_first_audio"> First audio track only (--first-audio)</label>
+    </div>
+  </div></div>
+
+  <!-- Subtitles -->
+  <div class="pane" id="p-sub"><div class="grid">
+    <div class="field"><label>Tracks (--subtitle)</label><input type="text" id="o_subtitle" placeholder="e.g. 1,2 / scan"></div>
+    <div class="field"><label>Language list</label><input type="text" id="o_subtitle_lang_list" placeholder="e.g. eng"></div>
+    <div class="field"><label>Forced only (--subtitle-forced)</label><input type="text" id="o_subtitle_forced" placeholder="track #s or blank"></div>
+    <div class="field"><label>Burn in (--subtitle-burned)</label><input type="text" id="o_subtitle_burned" placeholder="track # / native / none"></div>
+    <div class="field"><label>Default track</label><input type="text" id="o_subtitle_default" placeholder="track # / none"></div>
+    <div class="field"><label>Track name(s) (--subname)</label><input type="text" id="o_subname" placeholder=""></div>
+    <div class="field"><label>Native language</label><input type="text" id="o_native_language" placeholder="e.g. eng"></div>
+    <div class="field wide"><label>External SRT file path (--srt-file)</label>
+      <input type="text" class="mono" id="o_srt_file" placeholder="/var/lib/webrake/uploads/…"></div>
+    <div class="field"><label>SRT codeset</label><input type="text" id="o_srt_codeset" placeholder="UTF-8"></div>
+    <div class="field"><label>SRT offset ms</label><input type="text" id="o_srt_offset" placeholder=""></div>
+    <div class="field"><label>SRT language</label><input type="text" id="o_srt_lang" placeholder="e.g. eng"></div>
+    <div class="field"><label>SRT burn (track #)</label><input type="text" id="o_srt_burn" placeholder=""></div>
+    <div class="field wide">
+      <label class="check"><input type="checkbox" id="o_all_subtitles"> Include all subtitles (--all-subtitles)</label>
+      <label class="check"><input type="checkbox" id="o_first_subtitle"> First subtitle only (--first-subtitle)</label>
+      <label class="check"><input type="checkbox" id="o_native_dub"> Prefer native-language dub (--native-dub)</label>
+    </div>
+  </div></div>
+
+  <!-- Picture -->
+  <div class="pane" id="p-pic"><div class="grid">
+    <div class="field"><label>Width</label><input type="number" id="o_width" placeholder=""></div>
+    <div class="field"><label>Height</label><input type="number" id="o_height" placeholder=""></div>
+    <div class="field"><label>Max width</label><input type="number" id="o_maxWidth" placeholder=""></div>
+    <div class="field"><label>Max height</label><input type="number" id="o_maxHeight" placeholder=""></div>
+    <div class="field"><label>Crop T:B:L:R</label><input type="text" id="o_crop" placeholder="e.g. 0:0:0:0"></div>
+    <div class="field"><label>Crop mode</label>
+      <select id="o_crop_mode"><option value="">default</option>
+        <option>auto</option><option>conservative</option><option>none</option></select></div>
+    <div class="field"><label>Anamorphic</label>
+      <select id="o_anamorphic"><option value="">default</option>
+        <option value="auto_anamorphic">auto</option>
+        <option value="loose_anamorphic">loose</option>
+        <option value="custom_anamorphic">custom</option>
+        <option value="non_anamorphic">none</option></select></div>
+    <div class="field"><label>Display width</label><input type="number" id="o_display_width" placeholder=""></div>
+    <div class="field"><label>Pixel aspect X:Y</label><input type="text" id="o_pixel_aspect" placeholder="e.g. 1:1"></div>
+    <div class="field"><label>Modulus</label><input type="number" id="o_modulus" placeholder="2"></div>
+    <div class="field"><label>Color matrix</label><input type="text" id="o_color_matrix" placeholder="709 / 601 / 2020 / …"></div>
+    <div class="field wide">
+      <label class="check"><input type="checkbox" id="o_keep_display_aspect"> Keep display aspect (--keep-display-aspect)</label>
+    </div>
+  </div></div>
+
+  <!-- Filters -->
+  <div class="pane" id="p-fil"><div class="grid">
+    <div class="field"><label>Comb detect</label><input type="text" id="o_comb_detect" placeholder="blank=off · 'default' or settings"></div>
+    <div class="field"><label>Deinterlace</label><input type="text" id="o_deinterlace" placeholder="'default' or preset/settings"></div>
+    <div class="field"><label>Decomb</label><input type="text" id="o_decomb" placeholder="'default' or settings"></div>
+    <div class="field"><label>Detelecine</label><input type="text" id="o_detelecine" placeholder="'default' or settings"></div>
+    <div class="field"><label>Denoise hqdn3d</label><input type="text" id="o_hqdn3d" placeholder="light / medium / strong / custom"></div>
+    <div class="field"><label>Denoise NLMeans</label><input type="text" id="o_nlmeans" placeholder="ultralight … strong"></div>
+    <div class="field"><label>NLMeans tune</label><input type="text" id="o_nlmeans_tune" placeholder="film / grain / …"></div>
+    <div class="field"><label>Chroma smooth</label><input type="text" id="o_chroma_smooth" placeholder=""></div>
+    <div class="field"><label>Unsharp</label><input type="text" id="o_unsharp" placeholder="light / medium / strong"></div>
+    <div class="field"><label>Lapsharp</label><input type="text" id="o_lapsharp" placeholder=""></div>
+    <div class="field"><label>Deblock</label><input type="text" id="o_deblock" placeholder=""></div>
+    <div class="field"><label>Rotate</label><input type="text" id="o_rotate" placeholder="angle=90:hflip=0"></div>
+    <div class="field"><label>Pad</label><input type="text" id="o_pad" placeholder="W:H:color:X:Y"></div>
+    <div class="field"><label>Colorspace</label><input type="text" id="o_colorspace" placeholder="bt709 / bt2020 / …"></div>
+    <div class="field wide">
+      <label class="check"><input type="checkbox" id="o_grayscale"> Grayscale (--grayscale)</label>
+    </div>
+    <div class="hint wide field">Filter fields map to HandBrake's optional-value flags: enter <code>default</code> to enable with defaults, or paste full settings strings.</div>
+  </div></div>
+
+  <!-- Advanced -->
+  <div class="pane" id="p-adv"><div class="grid">
+    <div class="field wide"><label>Raw HandBrakeCLI flags — everything else lives here</label>
+      <textarea id="o_extra_args" class="mono" placeholder="--preset-import-file /path/custom.json --subtitle-lang-list eng --queue-import-file …"></textarea>
+      <div class="hint">Appended verbatim to the command line, so the entire HandBrake flag surface is available. See <code>HandBrakeCLI --help</code>.</div>
+    </div>
+    <div class="field wide"><label>Command preview</label>
+      <textarea id="cmdPreview" class="mono" readonly rows="3"></textarea></div>
+  </div></div>
+
+  <div class="actions">
+    <button class="btn gold" id="startBtn">Start encode</button>
+    <span class="hint" id="startHint">Select a source above first.</span>
+  </div>
+</section>
+
+<!-- ============ queue ============ -->
+<section class="card">
+  <h2>3 · The counter</h2>
+  <div class="sub">Live queue — watch progress, then download the finished cup back to this PC.</div>
+  <div id="jobs"><div class="empty">Nothing brewing yet.</div></div>
+</section>
+
+<footer>WeBrake · <span id="verline"></span> · <a href="https://gogs.av2x.dev/av2x/WeBrake">source</a></footer>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script>
+"use strict";
+/* ---------------- helpers ---------------- */
+const $ = s => document.querySelector(s);
+const $$ = s => [...document.querySelectorAll(s)];
+let API_TOKEN = localStorage.getItem("webrake_token") || "";
+function headers(extra){ const h = extra||{}; if(API_TOKEN) h["X-API-Token"]=API_TOKEN; return h; }
+async function api(path, opts={}){
+  opts.headers = headers(opts.headers);
+  const r = await fetch(path, opts);
+  if(r.status===401){
+    API_TOKEN = prompt("This WeBrake requires an API token (see token.json in /opt/webrake):")||"";
+    localStorage.setItem("webrake_token", API_TOKEN);
+    if(API_TOKEN) return api(path, opts);
+  }
+  if(!r.ok){ let e={}; try{e=await r.json()}catch{} throw new Error(e.error||r.statusText); }
+  return r;
+}
+function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show");
+  clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("show"),2600); }
+function fmtSize(b){ if(b>1e9)return (b/1e9).toFixed(2)+" GB"; if(b>1e6)return (b/1e6).toFixed(1)+" MB";
+  return (b/1e3).toFixed(0)+" KB"; }
+function fmtEta(s){ if(!s)return ""; const h=~~(s/3600),m=~~(s%3600/60),ss=~~(s%60);
+  return (h?h+"h ":"")+(m?m+"m ":"")+ss+"s"; }
+function esc(x){ const d=document.createElement("div"); d.textContent=x??""; return d.innerHTML; }
+
+/* ---------------- theme ---------------- */
+const sunPath = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>';
+const moonPath = '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>';
+function setTheme(t){
+  document.documentElement.dataset.theme=t;
+  localStorage.setItem("webrake_theme",t);
+  $("#themeIcon").innerHTML = t==="dark" ? sunPath : moonPath;
+}
+setTheme(localStorage.getItem("webrake_theme") ||
+  (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
+$("#themeBtn").onclick = () =>
+  setTheme(document.documentElement.dataset.theme==="dark" ? "light" : "dark");
+
+/* ---------------- tabs ---------------- */
+$$("#tabs .tab").forEach(t => t.onclick = () => {
+  $$("#tabs .tab").forEach(x=>x.classList.remove("on"));
+  $$(".pane").forEach(x=>x.classList.remove("on"));
+  t.classList.add("on"); $("#"+t.dataset.pane).classList.add("on");
+});
+
+/* ---------------- capabilities / presets ---------------- */
+let CAPS = {};
+async function loadMeta(){
+  try{
+    const v = await (await api("/api/version")).json();
+    $("#verline").textContent = "v"+v.webrake+" · HandBrake "+(v.handbrake||"not found");
+    CAPS = await (await api("/api/capabilities")).json();
+    $("#chip-hb").textContent = CAPS.handbrake_found
+      ? "HandBrake "+(CAPS.handbrake_version||"") : "HandBrake missing!";
+    const chips = $("#chips"), btn = $("#themeBtn");
+    for(const [k,label] of [["qsv","Intel QSV"],["nvenc","NVENC"],["vce","AMD VCE"],["vaapi","VAAPI"]]){
+      if(CAPS[k]){ const c=document.createElement("span"); c.className="chip hw";
+        c.textContent="⚡ "+label; chips.insertBefore(c,btn); }
+    }
+    const sel = $("#o_encoder");
+    (CAPS.encoders||[]).forEach(e=>{
+      const o=document.createElement("option"); o.value=e;
+      o.textContent=(CAPS.hw_encoders||[]).includes(e) ? "⚡ "+e+" (GPU)" : e;
+      sel.appendChild(o);
+    });
+    const hw=(CAPS.hw_encoders||[]);
+    $("#hwHint").textContent = hw.length
+      ? "GPU passthru detected — ⚡ encoders run on hardware."
+      : "No hardware encoders detected; software encoders only.";
+    const p = await (await api("/api/presets")).json();
+    let lastCat=null, group=null, ps=$("#preset");
+    (p.presets||[]).forEach(pr=>{
+      if(pr.category!==lastCat){ group=document.createElement("optgroup");
+        group.label=pr.category; ps.appendChild(group); lastCat=pr.category; }
+      const o=document.createElement("option"); o.value=pr.name; o.textContent=pr.name;
+      group.appendChild(o);
+    });
+  }catch(e){ toast("Backend unreachable: "+e.message); }
+}
+
+/* ---------------- uploads ---------------- */
+let SELECTED = null;
+const drop=$("#drop"), fi=$("#fileInput");
+drop.onclick=()=>fi.click();
+drop.onkeydown=e=>{ if(e.key==="Enter"||e.key===" ") fi.click(); };
+["dragover","dragenter"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hover");}));
+["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hover");}));
+drop.addEventListener("drop",e=>{ if(e.dataTransfer.files.length) upload(e.dataTransfer.files[0]); });
+fi.onchange=()=>{ if(fi.files.length) upload(fi.files[0]); fi.value=""; };
+
+function upload(file){
+  const xhr=new XMLHttpRequest(), fd=new FormData(); fd.append("file",file);
+  $("#upbar").classList.add("show");
+  const fill=$("#upfill"), txt=$("#uptext");
+  xhr.upload.onprogress=e=>{
+    if(e.lengthComputable){
+      const pct=(e.loaded/e.total*100);
+      fill.style.width=pct.toFixed(1)+"%";
+      txt.textContent=`Uploading ${file.name} — ${pct.toFixed(1)}% of ${fmtSize(e.total)}`;
+    }};
+  xhr.onload=()=>{
+    $("#upbar").classList.remove("show"); fill.style.width="0";
+    if(xhr.status===200){ toast("Uploaded "+file.name); refreshUploads(); }
+    else toast("Upload failed ("+xhr.status+")");
+  };
+  xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed"); };
+  xhr.open("POST","/api/upload");
+  if(API_TOKEN) xhr.setRequestHeader("X-API-Token",API_TOKEN);
+  xhr.send(fd);
+}
+
+async function refreshUploads(){
+  try{
+    const d = await (await api("/api/uploads")).json();
+    const list=$("#srcList"); list.innerHTML="";
+    d.uploads.forEach(u=>{
+      const row=document.createElement("div");
+      row.className="src"+(SELECTED===u.upload_id?" sel":"");
+      row.innerHTML=`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z" fill="var(--gold)"/></svg>
+        <span class="name">${esc(u.filename)}</span>
+        <span class="meta">${fmtSize(u.size)}</span>
+        <button class="del" title="Delete upload" aria-label="Delete upload">✕</button>`;
+      row.onclick=e=>{
+        if(e.target.classList.contains("del")) return;
+        SELECTED=u.upload_id; refreshUploads(); updateStartState(); previewCmd();
+      };
+      row.querySelector(".del").onclick=async ()=>{
+        await api("/api/uploads/"+encodeURIComponent(u.upload_id),{method:"DELETE"});
+        if(SELECTED===u.upload_id){SELECTED=null;updateStartState();}
+        refreshUploads(); toast("Upload deleted");
+      };
+      list.appendChild(row);
+    });
+    if(!d.uploads.length) list.innerHTML='<div class="empty">No sources uploaded yet.</div>';
+  }catch(e){/* backend down; header toast already covers it */}
+}
+function updateStartState(){
+  $("#startBtn").disabled=!SELECTED;
+  $("#startHint").textContent=SELECTED?"":"Select a source above first.";
+}
+
+/* ---------------- scan ---------------- */
+$("#scanBtn").onclick=async ()=>{
+  if(!SELECTED){ toast("Select a source first"); return; }
+  $("#scanOut").textContent="Scanning…";
+  try{
+    const d = await (await api("/api/scan/"+encodeURIComponent(SELECTED))).json();
+    const lines=(d.TitleList||[]).map(t=>
+      `Title ${t.Index}: ${t.Duration?`${t.Duration.Hours}:${String(t.Duration.Minutes).padStart(2,"0")}:${String(t.Duration.Seconds).padStart(2,"0")}`:""} · `+
+      `${t.Geometry?t.Geometry.Width+"×"+t.Geometry.Height:""} · `+
+      `${(t.AudioList||[]).length} audio · ${(t.SubtitleList||[]).length} subs`);
+    $("#scanOut").textContent=lines.join("\n")||"No titles found.";
+  }catch(e){ $("#scanOut").textContent="Scan failed: "+e.message; }
+};
+
+/* ---------------- options collection ---------------- */
+const TEXT_KEYS=["title","chapters","angle","min_duration","start_at","stop_at","previews",
+ "start_at_preview","encoder","encoder_preset","encoder_tune","encoder_profile","encoder_level",
+ "quality","vb","rate","encopts","audio","audio_lang_list","aencoder","audio_copy_mask",
+ "audio_fallback","ab","aq","ac","mixdown","normalize_mix","arate","drc","gain","adither","aname",
+ "subtitle","subtitle_lang_list","subtitle_forced","subtitle_burned","subtitle_default","subname",
+ "native_language","srt_file","srt_codeset","srt_offset","srt_lang","srt_burn",
+ "width","height","maxWidth","maxHeight","crop","crop_mode","display_width","pixel_aspect",
+ "modulus","color_matrix","comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
+ "nlmeans_tune","chroma_smooth","unsharp","lapsharp","deblock","rotate","pad","colorspace",
+ "output_name","extra_args","preset_import_file"];
+const BOOL_KEYS=["main_feature","markers","optimize","align_av","inline_parameter_sets",
+ "two_pass","turbo","all_audio","first_audio","all_subtitles","first_subtitle","native_dub",
+ "keep_display_aspect","grayscale"];
+const FILTER_KEYS=new Set(["comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
+ "chroma_smooth","unsharp","lapsharp","deblock","rotate","subtitle_forced","subtitle_burned",
+ "subtitle_default","srt_burn"]);
+
+function collectOptions(){
+  const o={};
+  const preset=$("#preset").value; if(preset) o.preset=preset;
+  o.format=$("#o_format").value;
+  for(const k of TEXT_KEYS){
+    const el=document.getElementById("o_"+k); if(!el) continue;
+    let v=el.value.trim(); if(v==="") continue;
+    if(FILTER_KEYS.has(k) && v.toLowerCase()==="default") v=true;
+    o[k]=v;
+  }
+  for(const k of BOOL_KEYS){
+    const el=document.getElementById("o_"+k);
+    if(el && el.checked) o[k]=true;
+  }
+  const fr=$("#o_fr_mode").value; if(fr) o[fr]=true;
+  const an=$("#o_anamorphic").value; if(an) o[an]=true;
+  if($("#o_hwdec").checked) o.enable_hw_decoding =
+    CAPS.nvenc ? "nvdec" : (CAPS.qsv ? "qsv" : "videotoolbox");
+  return o;
+}
+function previewCmd(){
+  const o=collectOptions(), parts=["HandBrakeCLI --json -i <source> -o <output>"];
+  for(const [k,v] of Object.entries(o)){
+    if(k==="output_name") continue;
+    const flag="--"+k.replace(/_/g,"-");
+    if(v===true) parts.push(flag);
+    else if(k==="extra_args") parts.push(v);
+    else parts.push(flag+" "+v);
+  }
+  $("#cmdPreview").value=parts.join(" ");
+}
+document.addEventListener("input",previewCmd);
+document.addEventListener("change",previewCmd);
+
+/* ---------------- jobs ---------------- */
+$("#startBtn").onclick=async ()=>{
+  if(!SELECTED) return;
+  try{
+    await api("/api/jobs",{method:"POST",
+      headers:{"Content-Type":"application/json"},
+      body:JSON.stringify({upload_id:SELECTED,options:collectOptions()})});
+    toast("Job queued"); refreshJobs();
+  }catch(e){ toast("Could not queue job: "+e.message); }
+};
+
+const openLogs=new Set();
+async function refreshJobs(){
+  try{
+    const d=await (await api("/api/jobs")).json();
+    const box=$("#jobs");
+    if(!d.jobs.length){ box.innerHTML='<div class="empty">Nothing brewing yet.</div>'; return; }
+    box.innerHTML="";
+    d.jobs.forEach(j=>{
+      const el=document.createElement("div"); el.className="job"; el.dataset.id=j.id;
+      const running=["running","scanning","queued"].includes(j.status);
+      const stats=[];
+      if(j.fps) stats.push(j.fps+" fps");
+      if(j.eta) stats.push("ETA "+fmtEta(j.eta));
+      if(j.pass&&j.pass>0) stats.push("pass "+j.pass);
+      el.innerHTML=`
+        <div class="row">
+          <span class="fname">${esc(j.filename)}</span>
+          <span class="status ${j.status}">${j.status}</span>
+          <span class="stats">${stats.map(esc).join(" · ")}</span>
+        </div>
+        <div class="bar ${running?"":"static"}"><i style="width:${j.progress||0}%"></i></div>
+        ${j.error?`<div class="errline">${esc(j.error)}</div>`:""}
+        <div class="btns">
+          ${j.status==="done"?`<a class="btn sm gold" href="/api/download/${j.id}${API_TOKEN?"?token="+encodeURIComponent(API_TOKEN):""}" download>Download</a>`:""}
+          ${running?`<button class="btn sm danger" data-act="cancel">Cancel</button>`:""}
+          <button class="btn sm" data-act="log">${openLogs.has(j.id)?"Hide log":"Log"}</button>
+          ${!running?`<button class="btn sm danger" data-act="del">Delete</button>`:""}
+        </div>
+        <pre class="logbox ${openLogs.has(j.id)?"show":""}"></pre>`;
+      el.querySelectorAll("[data-act]").forEach(b=>b.onclick=async ()=>{
+        const act=b.dataset.act;
+        if(act==="cancel"){ await api(`/api/jobs/${j.id}/cancel`,{method:"POST"}); toast("Cancelling…"); }
+        if(act==="del"){ await api(`/api/jobs/${j.id}`,{method:"DELETE"}); toast("Job deleted"); }
+        if(act==="log"){ openLogs.has(j.id)?openLogs.delete(j.id):openLogs.add(j.id); }
+        refreshJobs();
+      });
+      if(openLogs.has(j.id)){
+        api(`/api/jobs/${j.id}/log`).then(r=>r.text()).then(t=>{
+          const lb=el.querySelector(".logbox"); lb.textContent=t; lb.scrollTop=lb.scrollHeight;
+        }).catch(()=>{});
+      }
+      box.appendChild(el);
+    });
+  }catch(e){/* transient */}
+}
+
+/* ---------------- boot ---------------- */
+loadMeta(); refreshUploads(); refreshJobs(); updateStartState(); previewCmd();
+setInterval(refreshJobs,2000);
+setInterval(refreshUploads,15000);
+</script>
+</body>
+</html>

+ 117 - 0
install.sh

@@ -0,0 +1,117 @@
+#!/bin/sh
+# ---------------------------------------------------------------------------
+# WeBrake installer — run INSIDE an existing Alpine Linux LXC container.
+#
+#   wget -qO- https://gogs.av2x.dev/av2x/WeBrake/raw/main/install.sh | ash
+#
+# This script never touches the Proxmox host. It:
+#   1. Installs Python, HandBrakeCLI and VA-API drivers via apk
+#   2. Pulls app.py / index.html / update.sh from the repo
+#   3. Generates a local token.json (never fetched from, or pushed to, the repo)
+#   4. Registers and starts an OpenRC service
+# ---------------------------------------------------------------------------
+set -eu
+
+REPO_RAW="${WEBRAKE_REPO:-https://gogs.av2x.dev/av2x/WeBrake/raw/main}"
+APP_DIR="/opt/webrake"
+DATA_DIR="/var/lib/webrake"
+PORT="${WEBRAKE_PORT:-8090}"
+
+say()  { printf '\033[1;33m[WeBrake]\033[0m %s\n' "$*"; }
+die()  { printf '\033[1;31m[WeBrake]\033[0m %s\n' "$*" >&2; exit 1; }
+
+# --- sanity: Alpine, root, inside a container -------------------------------
+[ "$(id -u)" = "0" ] || die "Run as root inside the Alpine container."
+[ -f /etc/alpine-release ] || die "This installer only supports Alpine Linux (run it inside the LXC, not on the Proxmox host)."
+if [ -r /proc/1/environ ] && grep -qa 'container=' /proc/1/environ 2>/dev/null; then :; fi
+
+say "Installing packages via apk..."
+# HandBrake lives in the community repository — make sure it is enabled.
+if ! grep -Eq '^[^#].*community' /etc/apk/repositories; then
+    ALPINE_VER=$(cut -d. -f1,2 /etc/alpine-release)
+    echo "https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VER}/community" >> /etc/apk/repositories
+    say "Enabled the Alpine community repository."
+fi
+apk update
+apk add --no-cache python3 py3-flask wget ca-certificates
+
+if ! apk add --no-cache handbrake >/dev/null 2>&1; then
+    say "handbrake not in this release's community repo — trying edge/community..."
+    apk add --no-cache handbrake \
+        --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \
+        --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \
+        || die "Could not install HandBrakeCLI via apk. Install it manually, then re-run."
+fi
+
+# VA-API / QSV userspace drivers (harmless if no GPU is passed through)
+apk add --no-cache libva libva-utils mesa-va-gallium intel-media-driver 2>/dev/null || \
+    say "GPU driver packages unavailable on this release — software encoding will still work."
+
+command -v HandBrakeCLI >/dev/null || die "HandBrakeCLI is not on PATH after install."
+say "HandBrake: $(HandBrakeCLI --version 2>&1 | head -n1)"
+
+# --- fetch application files from the repo ----------------------------------
+say "Pulling application files from ${REPO_RAW} ..."
+mkdir -p "$APP_DIR" "$DATA_DIR"
+for f in app.py index.html update.sh; do
+    wget -q -O "$APP_DIR/$f.new" "$REPO_RAW/$f" || die "Failed to fetch $f from the repo."
+    mv "$APP_DIR/$f.new" "$APP_DIR/$f"
+done
+chmod +x "$APP_DIR/update.sh"
+
+# --- generate token.json locally (NEVER pulled from or pushed to the repo) --
+if [ ! -f "$APP_DIR/token.json" ]; then
+    say "Generating local token.json ..."
+    python3 - "$APP_DIR/token.json" <<'PY'
+import json, secrets, sys, os
+path = sys.argv[1]
+with open(path, "w") as f:
+    json.dump({
+        "api_token": secrets.token_urlsafe(32),
+        "secret_key": secrets.token_urlsafe(32),
+        "note": "Generated locally by WeBrake. Do not commit this file."
+    }, f, indent=2)
+os.chmod(path, 0o600)
+PY
+else
+    say "Existing token.json found — keeping it."
+fi
+
+# --- OpenRC service ----------------------------------------------------------
+say "Registering OpenRC service..."
+cat > /etc/init.d/webrake <<EOF
+#!/sbin/openrc-run
+name="WeBrake"
+description="WeBrake HandBrake web UI"
+command="/usr/bin/python3"
+command_args="$APP_DIR/app.py"
+command_background="yes"
+directory="$APP_DIR"
+pidfile="/run/webrake.pid"
+output_log="/var/log/webrake.log"
+error_log="/var/log/webrake.log"
+export WEBRAKE_PORT="$PORT"
+
+depend() {
+    need net
+}
+EOF
+chmod +x /etc/init.d/webrake
+rc-update add webrake default >/dev/null 2>&1 || true
+rc-service webrake restart >/dev/null 2>&1 || rc-service webrake start
+
+IP=$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 | head -n1)
+TOKEN=$(python3 -c "import json;print(json.load(open('$APP_DIR/token.json'))['api_token'])")
+
+say "----------------------------------------------------------------"
+say "WeBrake is up."
+say "  URL:       http://${IP:-<container-ip>}:${PORT}"
+say "  API token: ${TOKEN}"
+say "  Token file: $APP_DIR/token.json  (local only — never in the repo)"
+say "  Update:    $APP_DIR/update.sh"
+if [ ! -e /dev/dri ] && [ ! -e /dev/nvidia0 ]; then
+say "  GPU:       no /dev/dri or /dev/nvidia* visible. To enable hardware"
+say "             encoding, add a device passthrough to this container's"
+say "             config on the Proxmox host (see README), then restart it."
+fi
+say "----------------------------------------------------------------"

+ 45 - 0
update.sh

@@ -0,0 +1,45 @@
+#!/bin/sh
+# ---------------------------------------------------------------------------
+# WeBrake updater — pulls the latest app.py and index.html (and this script)
+# directly from the repo and restarts the service.
+#
+# token.json is local-only: it is never fetched from the repo and never
+# overwritten by this script.
+#
+#   /opt/webrake/update.sh
+# ---------------------------------------------------------------------------
+set -eu
+
+REPO_RAW="${WEBRAKE_REPO:-https://gogs.av2x.dev/av2x/WeBrake/raw/main}"
+APP_DIR="/opt/webrake"
+
+say() { printf '\033[1;33m[WeBrake]\033[0m %s\n' "$*"; }
+die() { printf '\033[1;31m[WeBrake]\033[0m %s\n' "$*" >&2; exit 1; }
+
+[ "$(id -u)" = "0" ] || die "Run as root inside the Alpine container."
+[ -d "$APP_DIR" ] || die "WeBrake is not installed at $APP_DIR — run install.sh first."
+
+changed=0
+for f in app.py index.html update.sh; do
+    say "Fetching $f ..."
+    wget -q -O "$APP_DIR/$f.new" "$REPO_RAW/$f" || die "Failed to fetch $f from the repo."
+    if [ -f "$APP_DIR/$f" ] && cmp -s "$APP_DIR/$f" "$APP_DIR/$f.new"; then
+        rm -f "$APP_DIR/$f.new"
+        say "  $f unchanged."
+    else
+        # Keep one backup of the previous version.
+        [ -f "$APP_DIR/$f" ] && cp "$APP_DIR/$f" "$APP_DIR/$f.bak"
+        mv "$APP_DIR/$f.new" "$APP_DIR/$f"
+        say "  $f updated (previous copy at $f.bak)."
+        changed=1
+    fi
+done
+chmod +x "$APP_DIR/update.sh"
+
+if [ "$changed" = "1" ]; then
+    say "Restarting WeBrake service..."
+    rc-service webrake restart || say "Could not restart via OpenRC — restart manually."
+    say "Update complete. token.json was left untouched."
+else
+    say "Already up to date."
+fi