Parcourir la source

Resolve load queue status failures

ArtyomV2X il y a 3 semaines
Parent
commit
a6bff263b4
3 fichiers modifiés avec 655 ajouts et 682 suppressions
  1. 1 94
      README.md
  2. 539 572
      app.py
  3. 115 16
      index.html

+ 1 - 94
README.md

@@ -1,94 +1 @@
-# 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/master/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>
+wget -qO- https://gogs.av2x.dev/av2x/WeBrake/raw/master/install.sh | ash

+ 539 - 572
app.py

@@ -1,14 +1,14 @@
 #!/usr/bin/env python3
 """
-WeBrake — HandBrake CLI web frontend for Alpine LXC containers.
+WeBrake — self-hosted HandBrake web UI for Alpine LXC.
+Repo: https://gogs.av2x.dev/av2x/WeBrake
 
-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.
+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 beside this file. That file is generated at
-install time (or on first run if missing) and must NEVER be committed
-to the repository.
+Secrets live in token.json (generated by install.sh, never committed).
 """
 
 import json
@@ -17,7 +17,6 @@ import re
 import secrets
 import shlex
 import shutil
-import signal
 import subprocess
 import threading
 import time
@@ -29,668 +28,636 @@ from flask import (Flask, Response, abort, jsonify, request,
                    send_file, send_from_directory)
 
 # --------------------------------------------------------------------------
-# Paths & configuration
+# Paths & config
 # --------------------------------------------------------------------------
-APP_DIR = Path(__file__).resolve().parent
-DATA_DIR = Path(os.environ.get("WEBRAKE_DATA", "/var/lib/webrake"))
+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"
-LOG_DIR = DATA_DIR / "logs"
-STATE_FILE = DATA_DIR / "jobs.json"
-TOKEN_FILE = APP_DIR / "token.json"
+JOBS_DIR   = DATA_DIR / "jobs"
+TOKEN_FILE = Path(os.environ.get("WEBRAKE_TOKENS", str(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):
+for d in (UPLOAD_DIR, OUTPUT_DIR, JOBS_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())
+HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
+VERSION   = "1.0.1"
 
+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__)
-app.secret_key = TOKENS["secret_key"]
-app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 * 64  # 64 GiB
+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 require_token(fn):
+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):
-        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
+        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
 
-
 # --------------------------------------------------------------------------
-# Job model & persistence
+# Capabilities / GPU detection
 # --------------------------------------------------------------------------
-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))
+_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))
 
-def load_state():
-    if STATE_FILE.exists():
+    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:
-            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
+            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
 
 # --------------------------------------------------------------------------
-# GPU / capability detection
+# Job model
 # --------------------------------------------------------------------------
-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:
+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:
-        out = subprocess.run([HANDBRAKE, "--help"], capture_output=True,
-                             text=True, timeout=30)
-        return out.stdout + out.stderr
+        (JOBS_DIR / f"{job['id']}.json").write_text(json.dumps(slim, indent=2))
     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 = {}
-
+        pass
 
-def capabilities() -> dict:
-    if not CAPS_CACHE:
-        version = ""
+def load_persisted_jobs():
+    for f in sorted(JOBS_DIR.glob("*.json")):
         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 ""
+            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:
-            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
+            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 builder — maps UI options onto CLI flags
+# HandBrake command construction — full flag surface
 # --------------------------------------------------------------------------
-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"]))
-
+# 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 queued jobs one at a time, parses live progress
+# Worker — runs one HandBrakeCLI job at a time, parses --json 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"
+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:
-        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()
-
+        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=2)
+        QUEUE_EVENT.wait(timeout=5)
         QUEUE_EVENT.clear()
         while True:
-            with JOB_LOCK:
+            with JOBS_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:
+                job = pending[0] if pending else None
+                if job:
+                    job["status"] = "scanning"
+                    job["started"] = time.time()
+            if not job:
                 break
-            run_job(nxt)
+            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():
-    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})
+    return send_from_directory(STATIC_DIR, "index.html")
 
+@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")
-@require_token
+@auth_required
 def api_capabilities():
-    return jsonify(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"])
-@require_token
+@auth_required
 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
+        abort(400, description="No file supplied")
+    safe = re.sub(r"[^\w.\- ()\[\]]", "_", os.path.basename(f.filename))
+    dest = UPLOAD_DIR / f"{uuid.uuid4().hex[:8]}_{safe}"
     f.save(dest)
-    return jsonify({"filename": dest.name, "size": dest.stat().st_size})
-
+    return jsonify({"upload_id": dest.name, "filename": safe,
+                    "size": dest.stat().st_size})
 
-@app.route("/api/sources")
-@require_token
-def api_sources():
-    files = []
+@app.route("/api/uploads")
+@auth_required
+def api_uploads():
+    items = []
     for p in sorted(UPLOAD_DIR.iterdir()):
         if p.is_file():
-            files.append({"name": p.name, "size": p.stat().st_size,
+            items.append({"upload_id": p.name,
+                          "filename": p.name.split("_", 1)[-1],
+                          "size": p.stat().st_size,
                           "mtime": p.stat().st_mtime})
-    return jsonify(files)
-
+    return jsonify({"uploads": items})
 
-@app.route("/api/sources/<path:name>", methods=["DELETE"])
-@require_token
-def api_delete_source(name):
-    p = UPLOAD_DIR / sanitize_name(name)
+@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({"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
+        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, "-i", str(src), "--scan", "--title", "0", "--json"],
-            capture_output=True, text=True, timeout=600)
+            [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)
-        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})
+        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."}), 500
-    except Exception as exc:
-        return jsonify({"error": str(exc)}), 500
-
+        return jsonify({"error": "Scan timed out"}), 504
+    except Exception as e:
+        return jsonify({"error": str(e)}), 500
 
 @app.route("/api/jobs", methods=["GET"])
-@require_token
+@auth_required
 def api_jobs():
-    with JOB_LOCK:
-        jobs = sorted(JOBS.values(), key=lambda j: j["created"], reverse=True)
-    return jsonify(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"])
-@require_token
+@auth_required
 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
+    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"])
-@require_token
+@auth_required
 def api_cancel(job_id):
-    with JOB_LOCK:
-        job = JOBS.get(job_id)
-        if not job:
-            abort(404)
+    job = JOBS.get(job_id)
+    if not job:
+        abort(404)
+    if job["status"] in ("queued", "running", "scanning"):
         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})
-
+        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"])
-@require_token
+@auth_required
 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():
+    job = JOBS.get(job_id)
+    if not job:
         abort(404)
-    tail = p.read_text(errors="replace")[-20000:]
-    return Response(tail, mimetype="text/plain")
-
+    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>")
-@require_token
+@auth_required
 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"])
-
+    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
 
 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)
+    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)

+ 115 - 16
index.html

@@ -156,6 +156,14 @@ textarea{resize:vertical;min-height:70px}
   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}
+.token-panel{border-color:var(--gold)}
+.theme-btn.attn{border-color:var(--gold);color:var(--gold);
+  box-shadow:0 0 0 3px var(--gold-soft);animation:knock 1.4s ease-in-out infinite}
+@keyframes knock{0%,100%{transform:none}50%{transform:translateY(-2px)}}
+@media (prefers-reduced-motion:reduce){.theme-btn.attn{animation:none}}
+.srcerr{color:var(--err);text-align:center;padding:18px;font-size:13px}
+.active-src{font-size:13px;color:var(--ink-soft)}
+.active-src b{color:var(--gold)}
 @media(max-width:640px){.job .stats{margin-left:0;width:100%}}
 </style>
 </head>
@@ -177,6 +185,12 @@ footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
   </div>
   <div class="chips" id="chips">
     <span class="chip" id="chip-hb">HandBrake …</span>
+    <button class="theme-btn" id="tokenBtn" title="API token" aria-label="API token settings">
+      <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+        stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M16 5l3 3M13 8l3 3"/>
+      </svg>
+    </button>
     <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>
@@ -184,6 +198,24 @@ footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
   </div>
 </header>
 
+<!-- ============ token panel ============ -->
+<section class="card token-panel" id="tokenPanel" hidden>
+  <h2>API token</h2>
+  <div class="sub">Paste the <code>api_token</code> from <code>/opt/webrake/token.json</code>, or upload the file itself — it's read locally in your browser and never sent anywhere except as a request header.</div>
+  <div class="grid">
+    <div class="field wide"><label for="tokenInput">Token</label>
+      <input type="password" id="tokenInput" class="mono" placeholder="paste api_token or a bot token" autocomplete="off">
+    </div>
+  </div>
+  <div class="actions">
+    <button class="btn gold" id="tokenSave">Save token</button>
+    <button class="btn" id="tokenFileBtn">Upload token.json…</button>
+    <input type="file" id="tokenFile" accept=".json,application/json" hidden>
+    <button class="btn danger" id="tokenClear">Clear</button>
+    <span class="hint" id="tokenStatus"></span>
+  </div>
+</section>
+
 <!-- ============ upload ============ -->
 <section class="card">
   <h2>1 · Pour in your media</h2>
@@ -408,15 +440,18 @@ footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
 const $ = s => document.querySelector(s);
 const $$ = s => [...document.querySelectorAll(s)];
 let API_TOKEN = localStorage.getItem("webrake_token") || "";
+let AUTH_NEEDED = false;
 function headers(extra){ const h = extra||{}; if(API_TOKEN) h["X-API-Token"]=API_TOKEN; return h; }
+function markAuthNeeded(){
+  AUTH_NEEDED = true;
+  $("#tokenBtn").classList.add("attn");
+  $("#tokenStatus").textContent = "A token is required — paste it or upload token.json.";
+  if($("#tokenPanel").hidden){ $("#tokenPanel").hidden = false; }
+}
 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.status===401){ markAuthNeeded(); throw new Error("API token required"); }
   if(!r.ok){ let e={}; try{e=await r.json()}catch{} throw new Error(e.error||r.statusText); }
   return r;
 }
@@ -441,6 +476,46 @@ setTheme(localStorage.getItem("webrake_theme") ||
 $("#themeBtn").onclick = () =>
   setTheme(document.documentElement.dataset.theme==="dark" ? "light" : "dark");
 
+/* ---------------- token panel ---------------- */
+function tokenStatusLine(){
+  $("#tokenStatus").textContent = API_TOKEN
+    ? "Token saved in this browser." : (AUTH_NEEDED
+    ? "A token is required — paste it or upload token.json." : "No token set (not required unless auth is enabled).");
+  $("#tokenInput").value = API_TOKEN;
+}
+$("#tokenBtn").onclick = () => {
+  const p = $("#tokenPanel"); p.hidden = !p.hidden;
+  if(!p.hidden){ tokenStatusLine(); $("#tokenInput").focus(); }
+};
+function saveToken(tok){
+  API_TOKEN = (tok||"").trim();
+  localStorage.setItem("webrake_token", API_TOKEN);
+  AUTH_NEEDED = false;
+  $("#tokenBtn").classList.remove("attn");
+  tokenStatusLine();
+  toast(API_TOKEN ? "Token saved" : "Token cleared");
+  bootData(); // re-pull everything with the new credentials
+}
+$("#tokenSave").onclick = () => saveToken($("#tokenInput").value);
+$("#tokenInput").addEventListener("keydown", e => { if(e.key==="Enter") saveToken(e.target.value); });
+$("#tokenClear").onclick = () => saveToken("");
+$("#tokenFileBtn").onclick = () => $("#tokenFile").click();
+$("#tokenFile").onchange = () => {
+  const f = $("#tokenFile").files[0]; if(!f) return;
+  const rd = new FileReader();
+  rd.onload = () => {
+    try{
+      const j = JSON.parse(rd.result);
+      const tok = j.api_token || Object.values(j.bots||{})[0];
+      if(!tok) throw new Error("no api_token or bot token found");
+      saveToken(tok);
+      $("#tokenPanel").hidden = true;
+    }catch(e){ $("#tokenStatus").textContent = "Couldn't read that file: "+e.message; }
+  };
+  rd.readAsText(f);
+  $("#tokenFile").value = "";
+};
+
 /* ---------------- tabs ---------------- */
 $$("#tabs .tab").forEach(t => t.onclick = () => {
   $$("#tabs .tab").forEach(x=>x.classList.remove("on"));
@@ -449,12 +524,15 @@ $$("#tabs .tab").forEach(t => t.onclick = () => {
 });
 
 /* ---------------- capabilities / presets ---------------- */
-let CAPS = {};
+let CAPS = {}, metaLoaded = false;
 async function loadMeta(){
   try{
-    const v = await (await api("/api/version")).json();
+    const v = await (await fetch("/api/version")).json();
     $("#verline").textContent = "v"+v.webrake+" · HandBrake "+(v.handbrake||"not found");
+    if(v.auth_required && !API_TOKEN){ markAuthNeeded(); return; }
+    if(metaLoaded) return;
     CAPS = await (await api("/api/capabilities")).json();
+    metaLoaded = true;
     $("#chip-hb").textContent = CAPS.handbrake_found
       ? "HandBrake "+(CAPS.handbrake_version||"") : "HandBrake missing!";
     const chips = $("#chips"), btn = $("#themeBtn");
@@ -505,8 +583,17 @@ function upload(file){
     }};
   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+")");
+    if(xhr.status===200){
+      let res={}; try{ res=JSON.parse(xhr.responseText) }catch{}
+      SELECTED = res.upload_id || SELECTED;      // freshly poured = active source
+      toast("Uploaded "+file.name+" — selected as source");
+      refreshUploads(); updateStartState(); previewCmd();
+    }
+    else if(xhr.status===401){ markAuthNeeded(); toast("Upload needs an API token"); }
+    else{
+      let msg=""; try{ msg=JSON.parse(xhr.responseText).error }catch{}
+      toast("Upload failed ("+xhr.status+")"+(msg?": "+msg:""));
+    }
   };
   xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed"); };
   xhr.open("POST","/api/upload");
@@ -537,11 +624,19 @@ async function refreshUploads(){
       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 */}
+    if(SELECTED && !d.uploads.some(u=>u.upload_id===SELECTED)){
+      SELECTED=null; updateStartState();   // file vanished server-side — don't pretend it's active
+    }
+  }catch(e){
+    $("#srcList").innerHTML =
+      `<div class="srcerr">Can't list sources: ${esc(e.message)}${AUTH_NEEDED?" — set your token via the key button above.":""}</div>`;
+  }
 }
 function updateStartState(){
   $("#startBtn").disabled=!SELECTED;
-  $("#startHint").textContent=SELECTED?"":"Select a source above first.";
+  $("#startHint").innerHTML = SELECTED
+    ? `<span class="active-src">Active source: <b>${esc(SELECTED.split("_").slice(1).join("_")||SELECTED)}</b></span>`
+    : "Select a source above first.";
 }
 
 /* ---------------- scan ---------------- */
@@ -664,13 +759,17 @@ async function refreshJobs(){
       }
       box.appendChild(el);
     });
-  }catch(e){/* transient */}
+  }catch(e){
+    if(AUTH_NEEDED) $("#jobs").innerHTML =
+      '<div class="srcerr">Token required to view the queue — set it via the key button above.</div>';
+  }
 }
 
 /* ---------------- boot ---------------- */
-loadMeta(); refreshUploads(); refreshJobs(); updateStartState(); previewCmd();
-setInterval(refreshJobs,2000);
-setInterval(refreshUploads,15000);
+function bootData(){ loadMeta(); refreshUploads(); refreshJobs(); }
+bootData(); updateStartState(); previewCmd();
+setInterval(()=>{ if(!AUTH_NEEDED) refreshJobs(); },2000);
+setInterval(()=>{ if(!AUTH_NEEDED) refreshUploads(); },15000);
 </script>
 </body>
-</html>
+</html>