| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696 |
- #!/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)
|