From e822ef7fad2ccc6ef7e21d18ed823d850689dd52 Mon Sep 17 00:00:00 2001 From: netplexflix Date: Sat, 20 Jun 2026 20:06:09 +0200 Subject: [PATCH] 2026.06.20 - Added webhook support on remux --- README.md | 29 +++++++ uldas/config.py | 25 ++++++ uldas/constants.py | 2 +- uldas/detector.py | 16 +++- uldas/webhook.py | 142 +++++++++++++++++++++++++++++++ uldas/webui/routes.py | 113 ++++++++++++++++++++++++ uldas/webui/templates/index.html | 126 ++++++++++++++++++++++++--- 7 files changed, 440 insertions(+), 13 deletions(-) create mode 100644 uldas/webhook.py diff --git a/README.md b/README.md index c621b4b..57d0af4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Requires - [Expert variables](#expert-variables) - [Model Size Guide](#model-size-guide) - [🌐 WebUI](#webui) +- [🔔 Webhook on Remux](#webhook-on-remux) - [📄 Supported File Formats](#supported-file-formats) - [⌨️ CLI Reference](#cli-reference) - [Utility Commands](#utility-commands) @@ -241,6 +242,34 @@ Here you can edit settings and check your log. *** + +## 🔔 Webhook on Remux + +ULDAS can send an HTTP request after each remux. (e.g. for triggering Autoscan/Autopulse/..) +Enable it in the WebUI under **Settings → Advanced → Webhook**, or in `config.yml`. + +| Variable | Value | +| --- | --- | +| `{path}` / `{path_enc}` | Full path of the remuxed `.mkv` (raw / URL-encoded) | +| `{dir}` / `{dir_enc}` | Parent folder of the file | +| `{filename}` | File name with extension | +| `{name_noext}` | File name without extension | +| `{old_path}` / `{old_path_enc}` | Original pre-remux file path | + +> Use the `_enc` variants whenever the value goes into a URL query string or a form body, since media paths contain spaces and special characters. + +**Extra options** + +| Key | Description | +| --- | --- | +| `webhook_headers` | Extra request headers, one `Key: Value` per line (e.g. an API token) | +| `webhook_timeout_seconds` | Request timeout (default `10`). The call is fire-and-forget — it never blocks or fails a run | +| `webhook_path_from` / `webhook_path_to` | Optional path remap, for when ULDAS's container mounts differ from your media server's. The leading `from` path is replaced with `to` before the `{path}` variables are built | + +The webhook only fires on an actual remux (the rename event), never on in-place language-tag edits, and is skipped entirely during `dry_run`. + +*** + ## 📄 Supported File Formats Always Processed: diff --git a/uldas/config.py b/uldas/config.py index 5b22f02..a12910d 100644 --- a/uldas/config.py +++ b/uldas/config.py @@ -66,6 +66,20 @@ def __init__(self): # ── Timeouts ───────────────────────────────────────────────────── self.operation_timeout_seconds: int = 600 + # ── Webhook (fire after remux) ─────────────────────────────────── + self.webhook_enabled: bool = False + self.webhook_preset: str = "custom" # custom | autoscan | autopulse (UI only) + self.webhook_url: str = "" + self.webhook_method: str = "POST" # GET | POST + self.webhook_content_type: str = "none" # form | json | none + self.webhook_body: str = "" + self.webhook_headers: list[str] = [] # "Key: Value" lines + self.webhook_auth_user: str = "" + self.webhook_auth_pass: str = "" + self.webhook_timeout_seconds: int = 10 + self.webhook_path_from: str = "" + self.webhook_path_to: str = "" + # ── Forced subtitle thresholds ─────────────────────────────────── self.forced_subtitle_low_coverage_threshold: float = 25.0 self.forced_subtitle_high_coverage_threshold: float = 50.0 @@ -127,6 +141,17 @@ def create_sample_config(self, config_path: str = "config/config.yml") -> None: "subtitle_confidence_threshold": 0.85, "reprocess_all_subtitles": False, "operation_timeout_seconds": 600, + "webhook_enabled": False, + "webhook_url": "", + "webhook_method": "POST", + "webhook_content_type": "none", + "webhook_body": "", + "webhook_headers": [], + "webhook_auth_user": "", + "webhook_auth_pass": "", + "webhook_timeout_seconds": 10, + "webhook_path_from": "", + "webhook_path_to": "", "forced_subtitle_low_density_threshold": 3.0, "forced_subtitle_high_density_threshold": 8.0, "forced_subtitle_low_coverage_threshold": 25.0, diff --git a/uldas/constants.py b/uldas/constants.py index 29d2270..a4cf3f9 100644 --- a/uldas/constants.py +++ b/uldas/constants.py @@ -1,6 +1,6 @@ #file: uldas/constants.py -VERSION = "2026.05.11" +VERSION = "2026.06.20" # ── Language name → ISO 639-2 (bibliographic) ──────────────────────────── LANGUAGE_CODES: dict[str, str] = { diff --git a/uldas/detector.py b/uldas/detector.py index 757ec72..93d9c14 100644 --- a/uldas/detector.py +++ b/uldas/detector.py @@ -1410,7 +1410,21 @@ def _process_video_files(self, video_files: List[Path]) -> List[Dict]: print(f"[{action_idx}/{action_total}] Processing: {fp.name}") cached_key = key_cache.get(str(fp)) - results.append(self.process_file(fp, _cached_key=cached_key)) + res = self.process_file(fp, _cached_key=cached_key) + results.append(res) + # Notify external scan tools once the remuxed file is fully processed (language tags already re-applied above). + if (res.get("was_remuxed") and not self.config.dry_run + and getattr(self.config, "webhook_enabled", False)): + try: + from uldas import webhook + webhook.send_remux_webhook( + self.config, + Path(res["mkv_file"]), + Path(res["original_file"]), + ) + except Exception as exc: + logger.warning("Webhook dispatch failed for %s: %s", + fp, exc) except Exception as exc: logger.error("Error processing %s: %s", fp, exc) results.append({ diff --git a/uldas/webhook.py b/uldas/webhook.py new file mode 100644 index 0000000..4f22cce --- /dev/null +++ b/uldas/webhook.py @@ -0,0 +1,142 @@ +#file: uldas/webhook.py + +import logging +import threading +from pathlib import Path +from urllib.parse import quote + +import requests + +logger = logging.getLogger(__name__) + + +def _apply_path_map(value: str, path_from: str, path_to: str) -> str: + """Normalise separators and rewrite a leading *path_from* to *path_to*. + + Media servers and scan tools (autoscan, autopulse, Plex, ...) expect + forward-slash paths, so separators are always normalised. The optional + remap is used when ULDAS sees media under a different mount point than + the media server (common with Docker volume mappings). + """ + norm = value.replace("\\", "/") + if path_from: + src = path_from.replace("\\", "/") + if norm.startswith(src): + return path_to + norm[len(src):] + return norm + + +def _build_tokens(new_path: Path, old_path: Path, + path_from: str, path_to: str) -> dict: + new_s = _apply_path_map(str(new_path), path_from, path_to) + old_s = _apply_path_map(str(old_path), path_from, path_to) + dir_s = _apply_path_map(str(new_path.parent), path_from, path_to) + return { + "{path}": new_s, + "{path_enc}": quote(new_s), + "{dir}": dir_s, + "{dir_enc}": quote(dir_s), + "{filename}": new_path.name, + "{name_noext}": new_path.stem, + "{old_path}": old_s, + "{old_path_enc}": quote(old_s), + } + + +def _substitute(template: str, tokens: dict) -> str: + if not template: + return template + out = template + for token, value in tokens.items(): + out = out.replace(token, value) + return out + + +def _parse_headers(lines) -> dict: + headers = {} + for line in lines or []: + if not line or ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip() + if key: + headers[key] = value.strip() + return headers + + +def _send(method: str, url: str, *, data, headers, auth, timeout) -> None: + try: + resp = requests.request( + method, url, data=data, headers=headers or None, + auth=auth, timeout=timeout, + ) + if resp.status_code >= 400: + logger.warning( + "Webhook returned HTTP %d for %s: %s", + resp.status_code, url, resp.text[:200], + ) + else: + logger.info("Webhook sent (%s %d): %s", + method, resp.status_code, url) + except requests.exceptions.RequestException as exc: + logger.warning("Webhook request failed for %s: %s", url, exc) + except Exception as exc: + logger.warning("Webhook error for %s: %s", url, exc) + + +def send_remux_webhook(config, new_path: Path, old_path: Path) -> None: + """Fire the configured webhook after a file has been remuxed. + + Fire-and-forget: the HTTP call runs on a daemon thread so a slow or + unreachable endpoint never stalls the processing loop, and any error + is logged rather than raised. + """ + if not getattr(config, "webhook_enabled", False): + return + + tokens = _build_tokens( + new_path, old_path, + getattr(config, "webhook_path_from", "") or "", + getattr(config, "webhook_path_to", "") or "", + ) + + url = _substitute(getattr(config, "webhook_url", "") or "", tokens).strip() + if not url: + return + + method = (getattr(config, "webhook_method", "POST") or "POST").upper() + if method not in ("GET", "POST"): + method = "POST" + + content_type = (getattr(config, "webhook_content_type", "form") + or "form").lower() + body_tmpl = getattr(config, "webhook_body", "") or "" + data = None + headers = _parse_headers(getattr(config, "webhook_headers", [])) + + if content_type != "none" and body_tmpl: + data = _substitute(body_tmpl, tokens) + # Only set a Content-Type if the user hasn't supplied one. + if not any(k.lower() == "content-type" for k in headers): + if content_type == "json": + headers["Content-Type"] = "application/json" + elif content_type == "form": + headers["Content-Type"] = "application/x-www-form-urlencoded" + + auth = None + user = getattr(config, "webhook_auth_user", "") or "" + if user: + auth = (user, getattr(config, "webhook_auth_pass", "") or "") + + try: + timeout = int(getattr(config, "webhook_timeout_seconds", 10) or 10) + except (TypeError, ValueError): + timeout = 10 + + thread = threading.Thread( + target=_send, args=(method, url), + kwargs={"data": data, "headers": headers, + "auth": auth, "timeout": timeout}, + daemon=True, + ) + thread.start() diff --git a/uldas/webui/routes.py b/uldas/webui/routes.py index de281a1..e752661 100644 --- a/uldas/webui/routes.py +++ b/uldas/webui/routes.py @@ -343,6 +343,119 @@ def _quoted_str(dumper, data): "description": "Subtitle count above this value = likely full subtitles.", "advanced": True, }, + # ── Webhook (fire after remux) ──────────────────────────────────────── + { + "key": "webhook_enabled", + "type": "bool", + "default": False, + "label": "Enabled", + "description": "Send an HTTP request after a file is remuxed (e.g. to trigger a targeted Plex/Jellyfin scan via autoscan, autopulse, etc.).", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_preset", + "type": "select", + "default": "custom", + "label": "Preset", + "description": "Pick a service to pre-fill the fields below, or 'custom' to edit them yourself. The preset only changes the form; the saved values are what get used.", + "options": ["custom", "autopulse", "autoscan (Cloudbox)", "autoscan (NiNiyas)"], + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_url", + "type": "string", + "default": "", + "label": "URL", + "description": "Target URL. Most scan tools take the file path in the query string here (e.g. ?path={path_enc} or ?dir={dir_enc}). Variables: {path}/{path_enc} (remuxed file), {dir}/{dir_enc} (folder), {filename}, {name_noext}, {old_path}/{old_path_enc}. Use the _enc variants inside a query string.", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_method", + "type": "select", + "default": "POST", + "label": "Method", + "description": "HTTP method to use.", + "options": ["POST", "GET"], + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_content_type", + "type": "select", + "default": "none", + "label": "Body Type", + "description": "'none' sends no body (the common case — the path goes in the URL query string). 'form' sends the body as form-urlencoded, 'json' as JSON.", + "options": ["none", "form", "json"], + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_body", + "type": "string", + "default": "", + "label": "Body", + "description": "Only needed for targets that read the path from the request body (e.g. NiNiyas/autoscan's form endpoint) or custom JSON webhooks. Leave empty when the path is in the URL. Same substitution variables as the URL. Ignored when Body Type is 'none'.", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_headers", + "type": "string_list", + "default": [], + "label": "Headers", + "description": "Extra request headers, one per line as 'Key: Value' (e.g. an API token).", + "placeholder": "Authorization: Bearer ...", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_auth_user", + "type": "string", + "default": "", + "label": "Basic Auth User", + "description": "Username for HTTP Basic Auth (e.g. autopulse). Leave blank to disable.", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_auth_pass", + "type": "string", + "default": "", + "label": "Basic Auth Password", + "description": "Password for HTTP Basic Auth.", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_timeout_seconds", + "type": "int", + "default": 10, + "label": "Timeout (s)", + "description": "Request timeout in seconds. The call is fire-and-forget and never blocks processing.", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_path_from", + "type": "string", + "default": "", + "label": "Path Remap From", + "description": "Optional. If ULDAS sees media under a different path than your media server, replace this leading path...", + "section": "webhook", + "advanced": True, + }, + { + "key": "webhook_path_to", + "type": "string", + "default": "", + "label": "Path Remap To", + "description": "...with this one, before building the {path} variables.", + "section": "webhook", + "advanced": True, + }, ] diff --git a/uldas/webui/templates/index.html b/uldas/webui/templates/index.html index b893be9..7cec024 100644 --- a/uldas/webui/templates/index.html +++ b/uldas/webui/templates/index.html @@ -323,6 +323,13 @@ input[type="number"] { width: 100px; } select { min-width: 120px; } +/* Highlight a field that a preset expects the user to fill (e.g. password). */ +input.input-needs-value { + border-color: var(--warning); + box-shadow: 0 0 6px var(--warning); +} +input.input-needs-value::placeholder { color: var(--warning); } + /* Path list */ .path-list { display: flex; flex-direction: column; gap: 6px; min-width: 280px; } @@ -401,12 +408,7 @@ color: var(--warning); } -/* Maintenance actions (inside Advanced) */ -.maintenance-actions { - margin-top: 24px; - padding-top: 20px; - border-top: 1px solid var(--border); -} +/* Maintenance actions (own card) */ .maintenance-actions h3 { font-size: 15px; font-weight: 600; @@ -1411,9 +1413,14 @@

Advanced

These settings affect how ULDAS processes and detects languages. Only change them if you understand their impact.
+ + +