initial release

This commit is contained in:
2026-02-25 11:44:52 +02:00
parent 36fe80a7bb
commit 84a3152bd4
2 changed files with 401 additions and 0 deletions

155
README.md Normal file
View File

@@ -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

246
tdarr-autoscaler.py Normal file
View File

@@ -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()