claude'd jellyfin auto limit qbt upload
This commit is contained in:
parent
36a00bedc5
commit
7274b86ec1
BIN
secrets/jellyfin-api-key
Normal file
BIN
secrets/jellyfin-api-key
Normal file
Binary file not shown.
326
services/jellyfin-qbittorrent-monitor.py
Normal file
326
services/jellyfin-qbittorrent-monitor.py
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
import json
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinQBittorrentMonitor:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
jellyfin_url="http://localhost:8096",
|
||||||
|
qbittorrent_url="http://localhost:8080",
|
||||||
|
check_interval=30,
|
||||||
|
jellyfin_api_key=None,
|
||||||
|
):
|
||||||
|
self.jellyfin_url = jellyfin_url
|
||||||
|
self.qbittorrent_url = qbittorrent_url
|
||||||
|
self.check_interval = check_interval
|
||||||
|
self.jellyfin_api_key = jellyfin_api_key
|
||||||
|
self.last_streaming_state = None
|
||||||
|
self.throttle_active = False
|
||||||
|
self.running = True
|
||||||
|
self.session = requests.Session() # Use session for cookies
|
||||||
|
|
||||||
|
# Hysteresis settings to prevent rapid switching
|
||||||
|
self.streaming_start_delay = 10 # seconds to wait before throttling
|
||||||
|
self.streaming_stop_delay = 60 # seconds to wait before removing throttle
|
||||||
|
self.last_state_change = 0
|
||||||
|
|
||||||
|
# Try to authenticate with qBittorrent
|
||||||
|
self.authenticate_qbittorrent()
|
||||||
|
|
||||||
|
def signal_handler(self, signum, frame):
|
||||||
|
logger.info("Received shutdown signal, cleaning up...")
|
||||||
|
self.running = False
|
||||||
|
self.restore_normal_limits()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def authenticate_qbittorrent(self):
|
||||||
|
"""Try to authenticate with qBittorrent using empty credentials (for whitelist)"""
|
||||||
|
logger.info("Attempting to authenticate with qBittorrent...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First, try to access a simple endpoint to see if auth is needed
|
||||||
|
test_response = self.session.get(
|
||||||
|
f"{self.qbittorrent_url}/api/v2/app/version", timeout=5
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Version endpoint test: HTTP {test_response.status_code}, Response: {test_response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if test_response.status_code == 200:
|
||||||
|
logger.info(
|
||||||
|
"qBittorrent accessible without explicit login - subnet whitelist working"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Version endpoint failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try login with empty credentials (should work with subnet whitelist)
|
||||||
|
login_data = {"username": "", "password": ""}
|
||||||
|
headers = {
|
||||||
|
"Referer": self.qbittorrent_url,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Attempting login to {self.qbittorrent_url}/login")
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.qbittorrent_url}/login",
|
||||||
|
data=login_data,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Login response: HTTP {response.status_code}, Response: '{response.text}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
if "Ok." in response.text or response.text.strip() == "Ok.":
|
||||||
|
logger.info("Successfully authenticated with qBittorrent")
|
||||||
|
return True
|
||||||
|
elif "Fails." in response.text:
|
||||||
|
logger.warning(
|
||||||
|
"qBittorrent login failed - authentication may be required"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"Unexpected login response: '{response.text}'")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Login request failed with HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not authenticate with qBittorrent: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_jellyfin_sessions(self):
|
||||||
|
"""Check if anyone is actively streaming from Jellyfin"""
|
||||||
|
try:
|
||||||
|
headers = {}
|
||||||
|
if self.jellyfin_api_key:
|
||||||
|
headers["X-Emby-Token"] = self.jellyfin_api_key
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
f"{self.jellyfin_url}/Sessions", headers=headers, timeout=10
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
sessions = response.json()
|
||||||
|
|
||||||
|
# Count active streaming sessions
|
||||||
|
active_streams = []
|
||||||
|
for session in sessions:
|
||||||
|
if (
|
||||||
|
"NowPlayingItem" in session
|
||||||
|
and session.get("PlayState", {}).get("IsPaused", True) == False
|
||||||
|
):
|
||||||
|
item = session["NowPlayingItem"]
|
||||||
|
user = session.get("UserName", "Unknown")
|
||||||
|
active_streams.append(f"{user}: {item.get('Name', 'Unknown')}")
|
||||||
|
|
||||||
|
return active_streams
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to check Jellyfin sessions: {e}")
|
||||||
|
return []
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse Jellyfin response: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def check_qbittorrent_alternate_limits(self):
|
||||||
|
"""Check if alternate speed limits are currently enabled"""
|
||||||
|
# For qBittorrent v5.1.0, use API v2 with GET requests
|
||||||
|
try:
|
||||||
|
# Try the transfer info endpoint first (more reliable)
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.qbittorrent_url}/api/v2/transfer/info", timeout=10
|
||||||
|
)
|
||||||
|
logger.info(f"Transfer info endpoint: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(f"Transfer info keys: {list(data.keys())}")
|
||||||
|
|
||||||
|
# Check for alternative speed limit status in the response
|
||||||
|
if "use_alt_speed_limits" in data:
|
||||||
|
is_enabled = data["use_alt_speed_limits"]
|
||||||
|
logger.info(f"Alternative speed limits enabled: {is_enabled}")
|
||||||
|
return is_enabled
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Transfer info endpoint failed: {e}")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse transfer info JSON: {e}")
|
||||||
|
|
||||||
|
# Fallback: try app preferences endpoint
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.qbittorrent_url}/api/v2/app/preferences", timeout=10
|
||||||
|
)
|
||||||
|
logger.info(f"Preferences endpoint: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
# Look for alternative speed settings
|
||||||
|
if "alt_up_limit" in data or "scheduler_enabled" in data:
|
||||||
|
# Check if alternative speeds are currently active
|
||||||
|
# This is a bit indirect but should work
|
||||||
|
logger.info(
|
||||||
|
"Found preferences data, assuming alt speeds not active by default"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Preferences endpoint failed: {e}")
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"Failed to check qBittorrent alternate limits status: all endpoints failed"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def toggle_qbittorrent_limits(self, enable_throttle):
|
||||||
|
"""Toggle qBittorrent alternate speed limits"""
|
||||||
|
try:
|
||||||
|
# Check current state
|
||||||
|
current_throttle = self.check_qbittorrent_alternate_limits()
|
||||||
|
|
||||||
|
if enable_throttle and not current_throttle:
|
||||||
|
try:
|
||||||
|
# Use API v2 POST endpoint to toggle alternative speed limits
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Toggle enable response: HTTP {response.status_code}, {response.text[:100]}"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
self.throttle_active = True
|
||||||
|
logger.info("✓ Enabled alternate speed limits (throttling)")
|
||||||
|
return
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to enable alternate speed limits: {e}")
|
||||||
|
|
||||||
|
elif not enable_throttle and current_throttle:
|
||||||
|
try:
|
||||||
|
# Use API v2 POST endpoint to toggle alternative speed limits
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Toggle disable response: HTTP {response.status_code}, {response.text[:100]}"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
self.throttle_active = False
|
||||||
|
logger.info("✓ Disabled alternate speed limits (normal)")
|
||||||
|
return
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to disable alternate speed limits: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to toggle qBittorrent limits: {e}")
|
||||||
|
|
||||||
|
def restore_normal_limits(self):
|
||||||
|
"""Ensure normal speed limits are restored on shutdown"""
|
||||||
|
if self.throttle_active:
|
||||||
|
logger.info("Restoring normal speed limits before shutdown...")
|
||||||
|
self.toggle_qbittorrent_limits(False)
|
||||||
|
|
||||||
|
def should_change_state(self, new_streaming_state):
|
||||||
|
"""Apply hysteresis to prevent rapid state changes"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# If state hasn't changed, no action needed
|
||||||
|
if new_streaming_state == self.last_streaming_state:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Calculate time since last state change
|
||||||
|
time_since_change = now - self.last_state_change
|
||||||
|
|
||||||
|
# If we want to start throttling (streaming started)
|
||||||
|
if new_streaming_state and not self.last_streaming_state:
|
||||||
|
if time_since_change >= self.streaming_start_delay:
|
||||||
|
self.last_state_change = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If we want to stop throttling (streaming stopped)
|
||||||
|
elif not new_streaming_state and self.last_streaming_state:
|
||||||
|
if time_since_change >= self.streaming_stop_delay:
|
||||||
|
self.last_state_change = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main monitoring loop"""
|
||||||
|
logger.info("Starting Jellyfin-qBittorrent monitor")
|
||||||
|
logger.info(f"Jellyfin URL: {self.jellyfin_url}")
|
||||||
|
logger.info(f"qBittorrent URL: {self.qbittorrent_url}")
|
||||||
|
logger.info(f"Check interval: {self.check_interval}s")
|
||||||
|
|
||||||
|
# Set up signal handlers
|
||||||
|
signal.signal(signal.SIGINT, self.signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
# Check for active streaming
|
||||||
|
active_streams = self.check_jellyfin_sessions()
|
||||||
|
streaming_active = len(active_streams) > 0
|
||||||
|
|
||||||
|
# Log current status
|
||||||
|
if streaming_active:
|
||||||
|
logger.info(
|
||||||
|
f"Active streams ({len(active_streams)}): {', '.join(active_streams)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("No active streaming sessions")
|
||||||
|
|
||||||
|
# Apply hysteresis and change state if needed
|
||||||
|
if self.should_change_state(streaming_active):
|
||||||
|
self.last_streaming_state = streaming_active
|
||||||
|
self.toggle_qbittorrent_limits(streaming_active)
|
||||||
|
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in monitoring loop: {e}")
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
self.restore_normal_limits()
|
||||||
|
logger.info("Monitor stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Configuration from environment variables
|
||||||
|
jellyfin_url = os.getenv("JELLYFIN_URL", "http://localhost:8096")
|
||||||
|
qbittorrent_url = os.getenv("QBITTORRENT_URL", "http://localhost:8080")
|
||||||
|
check_interval = int(os.getenv("CHECK_INTERVAL", "30"))
|
||||||
|
jellyfin_api_key = os.getenv("JELLYFIN_API_KEY")
|
||||||
|
|
||||||
|
monitor = JellyfinQBittorrentMonitor(
|
||||||
|
jellyfin_url=jellyfin_url,
|
||||||
|
qbittorrent_url=qbittorrent_url,
|
||||||
|
check_interval=check_interval,
|
||||||
|
jellyfin_api_key=jellyfin_api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
monitor.run()
|
||||||
@ -56,6 +56,10 @@
|
|||||||
|
|
||||||
GlobalUPSpeedLimit = 1000;
|
GlobalUPSpeedLimit = 1000;
|
||||||
GlobalDLSpeedLimit = 1000;
|
GlobalDLSpeedLimit = 1000;
|
||||||
|
|
||||||
|
# Alternate speed limits for when Jellyfin is streaming
|
||||||
|
AlternativeGlobalUPSpeedLimit = 500; # 500 KB/s when throttled
|
||||||
|
AlternativeGlobalDLSpeedLimit = 800; # 800 KB/s when throttled
|
||||||
IncludeOverheadInLimits = true;
|
IncludeOverheadInLimits = true;
|
||||||
|
|
||||||
GlobalMaxRatio = 6.0;
|
GlobalMaxRatio = 6.0;
|
||||||
|
|||||||
@ -19,51 +19,44 @@
|
|||||||
nload
|
nload
|
||||||
];
|
];
|
||||||
|
|
||||||
networking.firewall.extraCommands = ''
|
systemd.services."jellyfin-qbittorrent-monitor" = {
|
||||||
# Exempt local traffic from marking
|
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
||||||
iptables -t mangle -A POSTROUTING -s ${service_configs.https.wg_ip}/24 -d 192.168.1.0/24 -j RETURN
|
after = [
|
||||||
|
"network.target"
|
||||||
|
"jellyfin.service"
|
||||||
|
"qbittorrent.service"
|
||||||
|
];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
# Mark all other traffic from the VPN namespace
|
serviceConfig = {
|
||||||
iptables -t mangle -A POSTROUTING -s ${service_configs.https.wg_ip}/24 -j MARK --set-mark 1
|
Type = "simple";
|
||||||
'';
|
ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" ''
|
||||||
|
export JELLYFIN_API_KEY=$(cat ${../secrets/jellyfin-api-key})
|
||||||
|
exec ${
|
||||||
|
pkgs.python3.withPackages (ps: with ps; [ requests ])
|
||||||
|
}/bin/python ${./jellyfin-qbittorrent-monitor.py}
|
||||||
|
'';
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = "10s";
|
||||||
|
|
||||||
systemd.services."traffic-shaping" =
|
# Security hardening
|
||||||
let
|
DynamicUser = true;
|
||||||
upload_pipe = 44;
|
NoNewPrivileges = true;
|
||||||
high_prio = 40;
|
ProtectSystem = "strict";
|
||||||
low_prio = 4;
|
ProtectHome = true;
|
||||||
in
|
ProtectKernelTunables = true;
|
||||||
{
|
ProtectKernelModules = true;
|
||||||
description = "Apply QoS to prioritize non-VPN traffic";
|
ProtectControlGroups = true;
|
||||||
after = [
|
MemoryDenyWriteExecute = true;
|
||||||
"network.target"
|
RestrictRealtime = true;
|
||||||
"vpn-wg.service"
|
RestrictSUIDSGID = true;
|
||||||
];
|
RemoveIPC = true;
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
ExecStart = pkgs.writeShellScript "tc-setup" ''
|
|
||||||
# Add HTB qdisc to physical interface
|
|
||||||
${pkgs.iproute2}/bin/tc qdisc add dev ${eth_interface} root handle 1: htb default 10
|
|
||||||
|
|
||||||
# Define classes:
|
|
||||||
# - Class 1:10 (high priority, unmarked)
|
|
||||||
# - Class 1:20 (low priority, marked VPN traffic)
|
|
||||||
${pkgs.iproute2}/bin/tc class add dev ${eth_interface} parent 1: classid 1:1 htb rate ${builtins.toString upload_pipe}mbit ceil ${builtins.toString upload_pipe}mbit
|
|
||||||
${pkgs.iproute2}/bin/tc class add dev ${eth_interface} parent 1:1 classid 1:10 htb rate ${builtins.toString high_prio}mbit ceil ${builtins.toString upload_pipe}mbit prio 1
|
|
||||||
${pkgs.iproute2}/bin/tc class add dev ${eth_interface} parent 1:1 classid 1:20 htb rate ${builtins.toString low_prio}mbit ceil ${builtins.toString upload_pipe}mbit prio 2
|
|
||||||
|
|
||||||
# Direct marked packets to low-priority class
|
|
||||||
${pkgs.iproute2}/bin/tc filter add dev ${eth_interface} parent 1: protocol ip prio 1 handle 1 fw flowid 1:20
|
|
||||||
'';
|
|
||||||
|
|
||||||
ExecStop = pkgs.writeShellScript "tc-stop" ''
|
|
||||||
${pkgs.iproute2}/bin/tc filter del dev ${eth_interface} parent 1:
|
|
||||||
${pkgs.iproute2}/bin/tc class del dev ${eth_interface} parent 1: classid 1:20
|
|
||||||
${pkgs.iproute2}/bin/tc class del dev ${eth_interface} parent 1: classid 1:10
|
|
||||||
${pkgs.iproute2}/bin/tc class del dev ${eth_interface} parent 1: classid 1:1
|
|
||||||
${pkgs.iproute2}/bin/tc qdisc del dev ${eth_interface} root
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
|
||||||
|
QBITTORRENT_URL = "http://${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.torrent}";
|
||||||
|
CHECK_INTERVAL = "30";
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user