Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -241,6 +242,34 @@ Here you can edit settings and check your log.

***

<a id="webhook-on-remux"></a>
## 🔔 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`.

***

<a id="supported-file-formats"></a>
## 📄 Supported File Formats
Always Processed:
Expand Down
25 changes: 25 additions & 0 deletions uldas/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion uldas/constants.py
Original file line number Diff line number Diff line change
@@ -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] = {
Expand Down
16 changes: 15 additions & 1 deletion uldas/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
142 changes: 142 additions & 0 deletions uldas/webhook.py
Original file line number Diff line number Diff line change
@@ -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()
113 changes: 113 additions & 0 deletions uldas/webui/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]


Expand Down
Loading
Loading