|
@@ -1,14 +1,14 @@
|
|
|
#!/usr/bin/env python3
|
|
#!/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
|
|
import json
|
|
@@ -17,7 +17,6 @@ import re
|
|
|
import secrets
|
|
import secrets
|
|
|
import shlex
|
|
import shlex
|
|
|
import shutil
|
|
import shutil
|
|
|
-import signal
|
|
|
|
|
import subprocess
|
|
import subprocess
|
|
|
import threading
|
|
import threading
|
|
|
import time
|
|
import time
|
|
@@ -29,668 +28,636 @@ from flask import (Flask, Response, abort, jsonify, request,
|
|
|
send_file, send_from_directory)
|
|
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"
|
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
|
|
OUTPUT_DIR = DATA_DIR / "output"
|
|
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)
|
|
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()
|
|
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)
|
|
@wraps(fn)
|
|
|
def wrapper(*args, **kwargs):
|
|
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 fn(*args, **kwargs)
|
|
|
return wrapper
|
|
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:
|
|
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:
|
|
except Exception:
|
|
|
pass
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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():
|
|
def worker_loop():
|
|
|
while True:
|
|
while True:
|
|
|
- QUEUE_EVENT.wait(timeout=2)
|
|
|
|
|
|
|
+ QUEUE_EVENT.wait(timeout=5)
|
|
|
QUEUE_EVENT.clear()
|
|
QUEUE_EVENT.clear()
|
|
|
while True:
|
|
while True:
|
|
|
- with JOB_LOCK:
|
|
|
|
|
|
|
+ with JOBS_LOCK:
|
|
|
pending = [j for j in JOBS.values() if j["status"] == "queued"]
|
|
pending = [j for j in JOBS.values() if j["status"] == "queued"]
|
|
|
pending.sort(key=lambda j: j["created"])
|
|
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
|
|
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()
|
|
threading.Thread(target=worker_loop, daemon=True).start()
|
|
|
|
|
|
|
|
-
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# --------------------------------------------------------------------------
|
|
|
# Routes
|
|
# Routes
|
|
|
# --------------------------------------------------------------------------
|
|
# --------------------------------------------------------------------------
|
|
|
@app.route("/")
|
|
@app.route("/")
|
|
|
def index():
|
|
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")
|
|
@app.route("/api/capabilities")
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_capabilities():
|
|
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"])
|
|
@app.route("/api/upload", methods=["POST"])
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_upload():
|
|
def api_upload():
|
|
|
f = request.files.get("file")
|
|
f = request.files.get("file")
|
|
|
if not f or not f.filename:
|
|
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)
|
|
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()):
|
|
for p in sorted(UPLOAD_DIR.iterdir()):
|
|
|
if p.is_file():
|
|
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})
|
|
"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():
|
|
if p.exists():
|
|
|
p.unlink()
|
|
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:
|
|
try:
|
|
|
out = subprocess.run(
|
|
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
|
|
text = out.stdout
|
|
|
m = re.search(r"JSON Title Set:\s*(\{.*)", text, re.S)
|
|
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:
|
|
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"])
|
|
@app.route("/api/jobs", methods=["GET"])
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_jobs():
|
|
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"])
|
|
@app.route("/api/jobs", methods=["POST"])
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_create_job():
|
|
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"])
|
|
@app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_cancel(job_id):
|
|
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["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"])
|
|
@app.route("/api/jobs/<job_id>", methods=["DELETE"])
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_delete_job(job_id):
|
|
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)
|
|
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>")
|
|
@app.route("/api/download/<job_id>")
|
|
|
-@require_token
|
|
|
|
|
|
|
+@auth_required
|
|
|
def api_download(job_id):
|
|
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__":
|
|
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)
|