Pārlūkot izejas kodu

Resolve update failures on existing installs

ArtyomV2X 3 nedēļas atpakaļ
vecāks
revīzija
5480e0890a
4 mainītis faili ar 288 papildinājumiem un 108 dzēšanām
  1. 130 1
      README.md
  2. 19 1
      app.py
  3. 90 74
      install.sh
  4. 49 32
      update.sh

+ 130 - 1
README.md

@@ -1 +1,130 @@
-wget -qO- https://gogs.av2x.dev/av2x/WeBrake/raw/master/install.sh | ash
+# ☕ WeBrake
+
+**HandBrake, poured over the web.** A self-hosted web UI for [HandBrake CLI](https://handbrake.fr/features.php), built to live inside an **Alpine Linux LXC on Proxmox**. Upload media from any PC on your network, remux or re-encode it with the *entire* HandBrake flag surface, watch live progress bars, and download the result — GPU-accelerated when the container has passthru.
+
+Repo: `https://gogs.av2x.dev/av2x/WeBrake`
+
+---
+
+## One-liner install
+
+Run this **inside** your existing Alpine LXC (as root). It refuses to run on a Proxmox host by design — nothing is ever installed from the host:
+
+```sh
+wget -qO- https://gogs.av2x.dev/av2x/WeBrake/raw/master/install.sh | ash
+```
+
+Then open `http://<container-ip>:8090`.
+
+The installer:
+
+1. Verifies it's running on Alpine (and **not** on a Proxmox host — it aborts if `pveversion` exists).
+2. Installs `python3`, `py3-flask`, `handbrake` (community repo, with edge fallback), and VA-API userspace drivers.
+3. Pulls `app.py`, `static/index.html`, and `update.sh` **from this repo** — trying branches `master` then `main`, accepting `index.html` at the repo root as a fallback, and validating every download so a Gogs error/login page can never be installed as the app.
+4. Generates a fresh **`token.json`** locally — secrets are created on your box, never fetched from nor committed to the repo (it's `.gitignore`d).
+5. Installs and starts an OpenRC service (`rc-service webrake start`, enabled at boot).
+
+Custom port: `WEBRAKE_PORT=9000 wget -qO- .../install.sh | ash`
+
+## Updating
+
+```sh
+/opt/webrake/update.sh
+```
+
+Pulls the newest `app.py` and `index.html` **directly from the repo** (same `master`→`main` branch and path fallbacks as the installer), validates the downloads, backs up the old files to `/opt/webrake/.backup/`, restarts the service — and never touches `token.json`.
+
+---
+
+## Features
+
+- **Full HandBrake flag coverage** — structured controls for General/Container, Source (titles, chapters, angles, start/stop), Video (all detected encoders, presets/tune/profile/level, CQ/bitrate, 2-pass, framerate modes, `--encopts`), Audio (tracks, encoders, copy masks, mixdown, DRC, gain…), Subtitles (incl. SRT/SSA import, burn-in, forced), Picture (crop, scale, anamorphic, color matrix), and Filters (deinterlace, decomb, detelecine, denoise, sharpen, deblock, rotate, pad, colorspace, grayscale) — plus a **raw flags** box in *Advanced* that passes anything else verbatim to `HandBrakeCLI`, so every current and future flag is reachable.
+- **Presets** — the container's `HandBrakeCLI --preset-list` is loaded into a categorized dropdown; custom preset JSON works via `--preset-import-file` in the raw flags box.
+- **Upload → track → download** — drag-and-drop upload with a live progress bar, a job queue with per-job status bars (scan %, encode %, fps, ETA, pass number, tail of the encode log), cancel/delete controls, and one-click download of finished files back to the uploading PC.
+- **GPU passthru aware** — the backend probes `/dev/dri` + `/dev/nvidia*` and asks HandBrake which hardware encoders (QSV / NVENC / AMD VCE / VAAPI) actually work; those show up with a ⚡ badge and hardware decoding can be toggled on.
+- **Source scanning** — one click runs `HandBrakeCLI --scan --json` and lists titles, resolutions, and audio/subtitle track counts.
+- **Modern UI** — cream primary with gold accents; dark mode steeps everything in a dark-roast coffee palette with the same gold. Responsive, keyboard-accessible, reduced-motion friendly.
+
+## GPU passthru (host configuration — done once, manually)
+
+WeBrake never installs anything on the Proxmox host, but the host must *lend* the GPU to the container. On the **host**, edit `/etc/pve/lxc/<CTID>.conf`:
+
+**Intel / AMD (VA-API & QuickSync):**
+
+```
+lxc.cgroup2.devices.allow: c 226:* rwm
+lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir
+```
+
+**NVIDIA (NVENC)** — host needs the NVIDIA driver; then:
+
+```
+lxc.cgroup2.devices.allow: c 195:* rwm
+lxc.cgroup2.devices.allow: c 509:* 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. The header of the UI will show ⚡ chips for whatever hardware paths HandBrake can actually use. (Note: Alpine's `handbrake` package supports QSV/VAAPI paths depending on build; NVENC requires a HandBrake build with NVENC enabled.)
+
+## `token.json`
+
+Created at `/opt/webrake/token.json` during install, mode `0600`:
+
+```json
+{
+  "api_token": "<generated>",
+  "secret_key": "<generated>",
+  "require_auth": false,
+  "bots": { "my-bot": "<add-your-own-bot-token>" }
+}
+```
+
+- **Never in the repo** — it's listed in `.gitignore`, generated locally, and the updater never overwrites it.
+- Set `"require_auth": true` and restart (`rc-service webrake restart`) to require a token for **all** API access. The web UI detects this and highlights the **key button** in the header — paste the token there, or simply **upload your `token.json`** into the panel (it's parsed locally in the browser and only ever leaves as the `X-API-Token` request header). The token is remembered per-browser.
+- Bots/automation authenticate with header `X-API-Token: <token>` (any value from `api_token` or the `bots` map), e.g.:
+
+```sh
+curl -H "X-API-Token: $TOKEN" -F file=@movie.mkv http://ct:8090/api/upload
+curl -H "X-API-Token: $TOKEN" -H 'Content-Type: application/json' \
+     -d '{"upload_id":"<id>","options":{"format":"av_mkv","aencoder":"copy","extra_args":"--all-audio --all-subtitles"}}' \
+     http://ct:8090/api/jobs
+```
+
+## API overview
+
+| Method | Path | Purpose |
+|---|---|---|
+| `POST` | `/api/upload` | multipart upload (`file`) |
+| `GET` | `/api/uploads` | list sources |
+| `DELETE` | `/api/uploads/<id>` | remove a source |
+| `GET` | `/api/scan/<id>` | HandBrake JSON title scan |
+| `GET` | `/api/presets` | preset list from the CLI |
+| `GET` | `/api/capabilities` | encoders + GPU device probe |
+| `POST` | `/api/jobs` | queue an encode (`upload_id`, `options`) |
+| `GET` | `/api/jobs` / `/api/jobs/<id>` | queue & live progress |
+| `GET` | `/api/jobs/<id>/log` | encode log tail |
+| `POST` | `/api/jobs/<id>/cancel` | cancel |
+| `DELETE` | `/api/jobs/<id>` | delete job + output |
+| `GET` | `/api/download/<id>` | download finished file |
+
+`options` keys mirror HandBrake flags (`encoder`, `quality`, `two_pass`, `hqdn3d`, …) and `extra_args` is appended verbatim — see `FLAG_MAP` in `app.py`.
+
+## Paths
+
+| Path | What |
+|---|---|
+| `/opt/webrake/` | app, UI, updater, `token.json` |
+| `/var/lib/webrake/uploads` | uploaded sources |
+| `/var/lib/webrake/output` | finished encodes |
+| `/var/lib/webrake/jobs` | job state (survives restarts) |
+| `/var/log/webrake.log` | service log |
+
+## Remux quick-tip
+
+For a pure remux (no re-encode), set **Container** to your target, put `copy` in the audio encoder, and add `--all-audio --all-subtitles` in Advanced — HandBrake passes streams through where the container allows.
+
+---
+
+*Not affiliated with the HandBrake project. HandBrake is © the HandBrake Team, GPLv2.*

+ 19 - 1
app.py

@@ -42,7 +42,7 @@ for d in (UPLOAD_DIR, OUTPUT_DIR, JOBS_DIR):
     d.mkdir(parents=True, exist_ok=True)
 
 HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
-VERSION   = "1.0.1"
+VERSION   = "1.0.2"
 
 def load_tokens():
     """token.json is created by install.sh and is never part of the repo."""
@@ -461,8 +461,26 @@ threading.Thread(target=worker_loop, daemon=True).start()
 # --------------------------------------------------------------------------
 @app.route("/")
 def index():
+    page = STATIC_DIR / "index.html"
+    if not page.is_file() or page.stat().st_size == 0:
+        return jsonify({
+            "error": "WeBrake backend is running, but the UI file is missing.",
+            "expected": str(page),
+            "fix": "Run /opt/webrake/update.sh to pull static/index.html from the repo, "
+                   "or place the file there manually, then reload this page.",
+        }), 503
     return send_from_directory(STATIC_DIR, "index.html")
 
+@app.route("/<path:anything>")
+def catch_all(anything):
+    if anything.startswith("api/"):
+        abort(404, description=f"Unknown API endpoint: /{anything}")
+    return jsonify({
+        "error": f"No such page: /{anything}",
+        "hint": "The UI lives at / and the API under /api/ "
+                "(version, capabilities, presets, upload, uploads, scan, jobs, download).",
+    }), 404
+
 @app.route("/api/version")
 def api_version():
     # Intentionally unauthenticated: the UI probes this to learn whether a

+ 90 - 74
install.sh

@@ -1,18 +1,17 @@
 #!/bin/sh
-# ---------------------------------------------------------------------------
-# WeBrake installer — run INSIDE an existing Alpine Linux LXC container.
-#
+# ==========================================================================
+# WeBrake installer — run this INSIDE an existing Alpine Linux LXC.
 #   wget -qO- https://gogs.av2x.dev/av2x/WeBrake/raw/master/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
-# ---------------------------------------------------------------------------
+# It will refuse to run on a Proxmox host. Nothing is ever installed from
+# the host side; GPU passthru is host *configuration* only (see README).
+# Files are pulled from the repo. token.json is generated locally and is
+# never fetched from, nor pushed to, the repository.
+# ==========================================================================
 set -eu
 
-REPO_RAW="${WEBRAKE_REPO:-https://gogs.av2x.dev/av2x/WeBrake/raw/master}"
+REPO_BASE="${WEBRAKE_REPO_BASE:-https://gogs.av2x.dev/av2x/WeBrake/raw}"
+BRANCHES="${WEBRAKE_BRANCH:-master main}"
 APP_DIR="/opt/webrake"
 DATA_DIR="/var/lib/webrake"
 PORT="${WEBRAKE_PORT:-8090}"
@@ -20,77 +19,106 @@ 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 -------------------------------
+# fetch <dest> <validator-grep> <path> [alt-path…] — tries every branch/path
+# combo and validates the payload so a Gogs error/login page is never installed.
+fetch() {
+    dest="$1"; check="$2"; shift 2
+    for br in $BRANCHES; do
+        for p in "$@"; do
+            url="$REPO_BASE/$br/$p"
+            if wget -q -O "$dest.tmp" "$url" && [ -s "$dest.tmp" ] \
+               && head -c 4096 "$dest.tmp" | grep -q "$check"; then
+                mv "$dest.tmp" "$dest"
+                say "  fetched $p (branch: $br)"
+                return 0
+            fi
+        done
+    done
+    rm -f "$dest.tmp"
+    return 1
+}
+
+# ---- guard rails ---------------------------------------------------------
 [ "$(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
+command -v pveversion >/dev/null 2>&1 && \
+  die "This looks like a Proxmox HOST. Run the installer inside the Alpine LXC instead."
+[ -f /etc/alpine-release ] || \
+  die "This installer targets Alpine Linux. /etc/alpine-release not found."
 
-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
+say "Installing on Alpine $(cat /etc/alpine-release)"
 
-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
+# ---- packages ------------------------------------------------------------
+say "Installing packages (python3, flask, handbrake, VA-API drivers)…"
+apk update >/dev/null
+apk add --no-cache python3 py3-flask wget ca-certificates >/dev/null
 
-# 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."
+if ! command -v HandBrakeCLI >/dev/null 2>&1; then
+    if ! apk add --no-cache handbrake >/dev/null 2>&1; then
+        say "handbrake not in enabled repos — trying community/testing…"
+        REL="$(cut -d. -f1,2 /etc/alpine-release)"
+        apk add --no-cache handbrake \
+            --repository="https://dl-cdn.alpinelinux.org/alpine/v${REL}/community" \
+          >/dev/null 2>&1 || \
+        apk add --no-cache handbrake \
+            --repository="https://dl-cdn.alpinelinux.org/alpine/edge/community" \
+            --repository="https://dl-cdn.alpinelinux.org/alpine/edge/main" \
+          >/dev/null 2>&1 || \
+        die "Could not install the 'handbrake' package. Enable the community repository in /etc/apk/repositories and re-run."
+    fi
+fi
+say "HandBrakeCLI: $(HandBrakeCLI --version 2>/dev/null | head -n1 || echo installed)"
 
-command -v HandBrakeCLI >/dev/null || die "HandBrakeCLI is not on PATH after install."
-say "HandBrake: $(HandBrakeCLI --version 2>&1 | head -n1)"
+# GPU userspace drivers (harmless if no GPU is passed through)
+apk add --no-cache libva libva-utils mesa-va-gallium intel-media-driver libdrm >/dev/null 2>&1 || \
+  say "VA-API driver packages unavailable on this release — skipping (software encode still works)."
 
-# --- 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
+# ---- app files from repo ---------------------------------------------------
+say "Pulling application files from ${REPO_BASE} (branches tried: ${BRANCHES})…"
+mkdir -p "$APP_DIR/static" "$DATA_DIR/uploads" "$DATA_DIR/output" "$DATA_DIR/jobs"
+fetch "$APP_DIR/app.py" "^#!/usr/bin/env python3" "app.py" \
+    || die "Failed to fetch a valid app.py — check the repo URL, branch name, and that the repo is public."
+fetch "$APP_DIR/static/index.html" "^<!DOCTYPE html>" "static/index.html" "index.html" \
+    || die "Failed to fetch a valid index.html — expected at static/index.html (or repo root) on branch master or main."
+fetch "$APP_DIR/update.sh" "^#!/bin/sh" "update.sh" \
+    || die "Failed to fetch a valid update.sh from the repo."
 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'
+# ---- token.json (generated locally, NEVER from the repo) -------------------
+if [ -f "$APP_DIR/token.json" ]; then
+    say "token.json already exists — keeping existing secrets."
+else
+    say "Generating token.json (local secrets — not stored in the repo)…"
+    python3 - "$APP_DIR/token.json" <<'PYEOF'
 import json, secrets, sys, os
 path = sys.argv[1]
+tok = {
+    "api_token": secrets.token_urlsafe(32),
+    "secret_key": secrets.token_urlsafe(32),
+    "require_auth": False,
+    "bots": {}
+}
 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)
+    json.dump(tok, f, indent=2)
 os.chmod(path, 0o600)
-PY
-else
-    say "Existing token.json found — keeping it."
+print("  api_token:", tok["api_token"])
+PYEOF
+    say "Set \"require_auth\": true in $APP_DIR/token.json to require this token for all API/bot access."
 fi
 
-# --- OpenRC service ----------------------------------------------------------
-say "Registering OpenRC service..."
+# ---- OpenRC service ---------------------------------------------------------
+say "Installing OpenRC service…"
 cat > /etc/init.d/webrake <<EOF
 #!/sbin/openrc-run
-name="WeBrake"
-description="WeBrake HandBrake web UI"
+name="webrake"
+description="WeBrake HandBrake web UI"
 command="/usr/bin/python3"
 command_args="$APP_DIR/app.py"
-command_background="yes"
-directory="$APP_DIR"
+command_background=true
 pidfile="/run/webrake.pid"
 output_log="/var/log/webrake.log"
 error_log="/var/log/webrake.log"
 export WEBRAKE_PORT="$PORT"
+export WEBRAKE_DATA="$DATA_DIR"
 
 depend() {
     need net
@@ -100,18 +128,6 @@ 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 "----------------------------------------------------------------"
+IP="$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 | head -n1)"
+say "Done. WeBrake is running at: http://${IP:-<container-ip>}:${PORT}"
+say "Update any time with: $APP_DIR/update.sh"

+ 49 - 32
update.sh

@@ -1,17 +1,20 @@
 #!/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.
+# ==========================================================================
+# WeBrake updater — pulls the latest app.py and static/index.html straight
+# from the repository and restarts the service.
 #
 #   /opt/webrake/update.sh
-# ---------------------------------------------------------------------------
+#
+# Tries branches master and main, and both static/index.html and a root
+# index.html, validating every payload before anything is swapped in.
+# token.json is never touched: secrets stay local, exactly as installed.
+# ==========================================================================
 set -eu
 
-REPO_RAW="${WEBRAKE_REPO:-https://gogs.av2x.dev/av2x/WeBrake/raw/master}"
+REPO_BASE="${WEBRAKE_REPO_BASE:-https://gogs.av2x.dev/av2x/WeBrake/raw}"
+BRANCHES="${WEBRAKE_BRANCH:-master main}"
 APP_DIR="/opt/webrake"
+BACKUP_DIR="$APP_DIR/.backup/$(date +%Y%m%d-%H%M%S)"
 
 say() { printf '\033[1;33m[WeBrake]\033[0m %s\n' "$*"; }
 die() { printf '\033[1;31m[WeBrake]\033[0m %s\n' "$*" >&2; exit 1; }
@@ -19,27 +22,41 @@ 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
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+# fetch <dest> <validator-grep> <path> [alt-path…]
+fetch() {
+    dest="$1"; check="$2"; shift 2
+    for br in $BRANCHES; do
+        for p in "$@"; do
+            if wget -q -O "$dest" "$REPO_BASE/$br/$p" && [ -s "$dest" ] \
+               && head -c 4096 "$dest" | grep -q "$check"; then
+                say "  fetched $p (branch: $br)"
+                return 0
+            fi
+        done
+    done
+    return 1
+}
+
+say "Fetching latest files from ${REPO_BASE} (branches tried: ${BRANCHES})…"
+fetch "$TMP/app.py" "^#!/usr/bin/env python3" "app.py" \
+    || die "No valid app.py found in the repo — nothing changed."
+fetch "$TMP/index.html" "^<!DOCTYPE html>" "static/index.html" "index.html" \
+    || die "No valid index.html found (looked at static/index.html and index.html) — nothing changed."
+fetch "$TMP/update.sh" "^#!/bin/sh" "update.sh" \
+    || say "Could not refresh update.sh (keeping current copy)."
+
+say "Backing up current files to $BACKUP_DIR…"
+mkdir -p "$BACKUP_DIR" "$APP_DIR/static"
+cp -a "$APP_DIR/app.py" "$APP_DIR/static/index.html" "$BACKUP_DIR/" 2>/dev/null || true
+
+install -m 0755 "$TMP/app.py"     "$APP_DIR/app.py"
+install -m 0644 "$TMP/index.html" "$APP_DIR/static/index.html"
+[ -s "$TMP/update.sh" ] && install -m 0755 "$TMP/update.sh" "$APP_DIR/update.sh" || true
+
+say "token.json untouched — local secrets preserved."
+say "Restarting service…"
+rc-service webrake restart >/dev/null 2>&1 || rc-service webrake start
+say "WeBrake updated."