diff --git a/README.md b/README.md new file mode 100644 index 0000000..aad65c0 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# Tdarr Autoscaler + +Dynamic Tdarr worker scaling based on Plex activity. + +Automatically scales Tdarr workers down when users are streaming on Plex +and scales them back up when Plex becomes idle.\ +Designed to prevent CPU/GPU contention between Plex transcoding and +Tdarr processing --- especially useful on Intel QSV, NVENC, or +limited-resource servers. + +Inspired by https://github.com/triw0lf/tdarr-autoscale + +------------------------------------------------------------------------ + +## Overview + +`tdarr-autoscaler.py` monitors Plex activity via Tautulli and dynamically +adjusts Tdarr worker limits using the Tdarr API. + +It can: + +- Detect active playback sessions +- Optionally count only active transcodes (ignores Direct Play / + Direct Stream) +- Apply different worker limits for: + - Idle (day) + - Active streaming (day) + - Idle (night) + - Active streaming (night) + +------------------------------------------------------------------------ + +## Requirements + +- Python 3.8+ +- `requests` library + +Install dependency: + +``` bash +pip install requests +``` + +------------------------------------------------------------------------ + +## Configuration + +Edit the `CONFIGURATION` section inside `tdarr-autoscaler.py`: + +``` python +####################### +# CONFIGURATION +####################### + +# Tdarr settings +TDARR_URL = "http://tdarr-ip:port" + +# Worker type - what does your Tdarr use for transcoding? +# Options: "GPU" (Intel QSV, NVENC, etc.), "CPU" (software encoding), or "BOTH" +WORKER_TYPE = "GPU" + +# Tautulli settings +TAUTULLI_URL = "http://tautulli-ip:port" +TAUTULLI_API_KEY = "" # Settings > Web Interface > API Key + +# Do not count Direct Play / Direct Stream since they are very easy on CPU/GPU +COUNT_TRANSCODES_ONLY = True + +# Worker limits - adjust to your hardware capability +WORKERS_IDLE = 1 # No one watching (daytime) +WORKERS_ACTIVE = 0 # Someone is streaming +WORKERS_NIGHT = 1 # No one watching (night) +WORKERS_NIGHT_ACTIVE = 0 # Streaming during night + +# Night mode hours (24h format) +NIGHT_START = 0 # Midnight +NIGHT_END = 5 # 5 AM +``` + +Adjust worker values according to your hardware capacity and desired +behavior. + +------------------------------------------------------------------------ + +## Usage + +### Option 1 --- Run as Tautulli Notification Script (Recommended) + +1. Copy `tdarr-autoscaler.py` into your **Tautulli scripts folder** + +2. Edit the configuration section + +3. In Tautulli, go to: + + Settings → Notification Agents + +4. Add a new **Script** notification agent + +5. Select `tdarr-autoscaler.py` + +6. Enable the following triggers: + + - Playback Start + - Playback Stop + - Playback Pause + - Playback Resume + - Transcode Decision Change + +7. Leave **Conditions** and **Arguments** empty + +------------------------------------------------------------------------ + +### Option 2 --- Run Manually + +``` bash +chmod +x tdarr-autoscaler.py +./tdarr-autoscaler.py +``` + +------------------------------------------------------------------------ + +### Option 3 --- Run via Cron + +Example (runs every 5 minutes): + +``` bash +*/5 * * * * /path/to/tdarr-autoscaler.py >> /path/to/tdarr-autoscaler.log 2>&1 +``` + +------------------------------------------------------------------------ + +## How It Works + +1. Queries Tautulli API for current activity +2. Counts all sessions or only transcoding sessions (if enabled) +3. Detects whether current time is within configured night window +4. Sets Tdarr worker limits via Tdarr API +5. Logs actions with timestamps + +------------------------------------------------------------------------ + +## Recommended Use Cases + +- Single-GPU home servers +- Intel iGPU (QSV) systems +- Low-power NAS setups +- Systems where Plex transcoding must always take priority +- Mixed Tdarr + Plex workloads on shared hardware + +------------------------------------------------------------------------ + +## License + +MIT + diff --git a/tdarr-autoscaler.py b/tdarr-autoscaler.py new file mode 100644 index 0000000..dead4aa --- /dev/null +++ b/tdarr-autoscaler.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +Tdarr Autoscaler - Dynamic worker scaling based on Plex activity + +Automatically scales Tdarr workers down when people are streaming and back up when Plex is idle. + +Requirements: + - Python 3.8+ + - requests + +Run as Tautulli notification script: + - Copy tdarr-autoscaler.py into Tautulli scripts folder + - Edit the CONFIGURATION section below to match your infrastructure settings + - Add a new Script notification agent in Tautulli -> Settings -> Notification Agents and select tdarr-autoscaler.py from the scripts folder + - Configure the triggers for the notification script: Playback Start, Playback Stop, Playback Pause, Playback Resume and Transcode Decision Change + - Leave empty the Conditions and Arguments config + +OR + +Run manually (or cronjob): + chmod +x tdarr-autoscaler.py + ./tdarr-autoscaler.py + + - Cron example: + */5 * * * * /path/to/tdarr-autoscaler.py >> /path/to/tdarr-autoscaler.log 2>&1 +""" + +import datetime as _dt +import json as _json +from typing import List + +import requests + + +####################### +# CONFIGURATION +####################### + +# Tdarr settings +TDARR_URL = "http://tdarr-ip:port" + +# Worker type - what does your Tdarr use for transcoding? +# Options: "GPU" (Intel QSV, NVENC, etc.), "CPU" (software encoding), or "BOTH" +WORKER_TYPE = "GPU" + +# Tautulli settings +TAUTULLI_URL = "http://tautulli-ip:port" +TAUTULLI_API_KEY = "" # Settings > Web Interface > API Key + +# Do not count Direct Play / Direct Stream since they are very easy on CPU/GPU +COUNT_TRANSCODES_ONLY = True + +# Worker limits - adjust to your hardware capability +WORKERS_IDLE = 1 # No one watching (daytime) +WORKERS_ACTIVE = 0 # Someone is streaming +WORKERS_NIGHT = 1 # No one watching (night) +WORKERS_NIGHT_ACTIVE = 0 # Streaming during night + +# Night mode hours (24h format) +NIGHT_START = 0 # Midnight +NIGHT_END = 5 # 5 AM + +####################### +# SCRIPT - no edits needed below +####################### + +def _now_stamp() -> str: + return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _error_exit(msg: str, code: int = 1) -> None: + print(f"[{_now_stamp()}] ERROR: {msg}") + raise SystemExit(code) + + +def _get_json(url: str, timeout: int = 10) -> dict: + try: + r = requests.get(url, timeout=timeout) + r.raise_for_status() + return r.json() + except Exception as e: + _error_exit(f"HTTP/JSON error fetching {url}: {e}") + + +def _post_json(url: str, payload: dict, timeout: int = 10) -> None: + try: + r = requests.post( + url, + headers={"Content-Type": "application/json"}, + data=_json.dumps(payload), + timeout=timeout, + ) + r.raise_for_status() + except Exception as e: + _error_exit(f"HTTP error POST {url}: {e}") + + +def _worker_types_from_friendly(name: str) -> List[str]: + if name == "GPU": + return ["transcodegpu"] + if name == "CPU": + return ["transcodecpu"] + if name == "BOTH": + return ["transcodegpu", "transcodecpu"] + _error_exit("Invalid WORKER_TYPE. Use 'GPU', 'CPU', or 'BOTH'") + + +def _get_first_node_id(tdarr_url: str) -> str: + nodes = _get_json(f"{tdarr_url}/api/v2/get-nodes") + if not isinstance(nodes, dict) or not nodes: + return "" + return next(iter(nodes.keys()), "") + + +def _is_night(hour: int, night_start: int, night_end: int) -> bool: + if night_start < night_end: + return (hour >= night_start) and (hour < night_end) + else: + return (hour >= night_start) or (hour < night_end) + + +def _get_streams_tautulli(tautulli_url: str, api_key: str) -> int: + data = _get_json(f"{tautulli_url}/api/v2?apikey={api_key}&cmd=get_activity") + try: + streams = ( + data.get("response", {}) + .get("data", {}) + .get("stream_count", 0) + ) + except Exception: + streams = 0 + + if streams is None: + return 0 + try: + return int(streams) + except Exception: + return 0 + +def _get_transcodes_tautulli (tautulli_url: str, api_key: str) -> int: + data = _get_json(f"{tautulli_url}/api/v2?apikey={api_key}&cmd=get_activity") + try: + sessions = ( + data.get("response", {}) + .get("data", {}) + .get("sessions", []) + ) + except Exception: + return 0 + + if not isinstance(sessions, list): + return 0 + + return sum( + 1 + for s in sessions + if isinstance(s, dict) and s.get("video_decision") == "transcode" + ) + +def _get_current_worker_limit(tdarr_url: str, node_id: str, worker_type: str) -> int: + nodes = _get_json(f"{tdarr_url}/api/v2/get-nodes") + try: + val = nodes[node_id]["workerLimits"][worker_type] + except Exception: + val = 0 + + if val is None: + return 0 + try: + return int(val) + except Exception: + return 0 + + +def _alter_worker_limit(tdarr_url: str, node_id: str, worker_type: str, process: str) -> None: + payload = {"data": {"nodeID": node_id, "workerType": worker_type, "process": process}} + _post_json(f"{tdarr_url}/api/v2/alter-worker-limit", payload) + + +def main() -> None: + worker_types = _worker_types_from_friendly(WORKER_TYPE) + + node_id = _get_first_node_id(TDARR_URL) + if not node_id or node_id == "null": + _error_exit("Could not get Tdarr node ID") + + hour = int(_dt.datetime.now().strftime("%H")) + + is_night = _is_night(hour, NIGHT_START, NIGHT_END) + time_mode = "Night" if is_night else "Day" + + if COUNT_TRANSCODES_ONLY: + streams = _get_transcodes_tautulli(TAUTULLI_URL, TAUTULLI_API_KEY) + stream_type = "Transcodes" + else: + streams = _get_streams_tautulli(TAUTULLI_URL, TAUTULLI_API_KEY) + stream_type = "Streams" + + if streams is None: + streams = 0 + try: + streams = int(streams) + except Exception: + streams = 0 + + if is_night: + if streams == 0: + target_workers = WORKERS_NIGHT + else: + target_workers = WORKERS_NIGHT_ACTIVE + else: + if streams == 0: + target_workers = WORKERS_IDLE + else: + target_workers = WORKERS_ACTIVE + + output = "" + for tdarr_worker_type in worker_types: + if tdarr_worker_type == "transcodegpu": + type_label = "GPU Workers" + else: + type_label = "CPU Workers" + + current = _get_current_worker_limit(TDARR_URL, node_id, tdarr_worker_type) + + if current != target_workers: + original = current + if current < target_workers: + while current < target_workers: + _alter_worker_limit(TDARR_URL, node_id, tdarr_worker_type, "increase") + current += 1 + diff = f"+{target_workers - original}" + else: + while current > target_workers: + _alter_worker_limit(TDARR_URL, node_id, tdarr_worker_type, "decrease") + current -= 1 + diff = f"-{original - target_workers}" + output += f" | {type_label}: {target_workers} ({diff})" + else: + output += f" | {type_label}: {target_workers} (no change)" + + print(f"[{_now_stamp()}] {stream_type}: {streams} | Mode: {time_mode}{output}") + + +if __name__ == "__main__": + main()