jellyfin-qbittorrent-monitor: dynamic bandwidth management

This commit is contained in:
2026-02-15 23:33:45 -05:00
parent cf3e876f27
commit 9a9ecc6556
3 changed files with 370 additions and 23 deletions

View File

@@ -46,6 +46,11 @@
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.torrent}";
CHECK_INTERVAL = "30";
# Bandwidth budget configuration
TOTAL_BANDWIDTH_BUDGET = "30000000"; # 30 Mbps in bits per second
SERVICE_BUFFER = "5000000"; # 5 Mbps reserved for other services (bps)
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
};
};
}

View File

@@ -29,13 +29,23 @@ class JellyfinQBittorrentMonitor:
jellyfin_api_key=None,
streaming_start_delay=10,
streaming_stop_delay=60,
total_bandwidth_budget=30000000,
service_buffer=5000000,
default_stream_bitrate=10000000,
min_torrent_speed=100,
):
self.jellyfin_url = jellyfin_url
self.qbittorrent_url = qbittorrent_url
self.check_interval = check_interval
self.jellyfin_api_key = jellyfin_api_key
self.total_bandwidth_budget = total_bandwidth_budget
self.service_buffer = service_buffer
self.default_stream_bitrate = default_stream_bitrate
self.min_torrent_speed = min_torrent_speed
self.last_streaming_state = None
self.throttle_active = False
self.current_state = "unlimited"
self.torrents_paused = False
self.last_alt_limits = None
self.running = True
self.session = requests.Session() # Use session for cookies
self.last_active_streams = []
@@ -70,7 +80,7 @@ class JellyfinQBittorrentMonitor:
self.restore_normal_limits()
sys.exit(0)
def check_jellyfin_sessions(self) -> list[str]:
def check_jellyfin_sessions(self) -> list[dict]:
headers = (
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
)
@@ -101,7 +111,20 @@ class JellyfinQBittorrentMonitor:
item_type = item.get("Type", "").lower()
if item_type in ["movie", "episode", "video"]:
user = session.get("UserName", "Unknown")
active_streams.append(f"{user}: {item.get('Name', 'Unknown')}")
stream_name = f"{user}: {item.get('Name', 'Unknown')}"
if session.get("TranscodingInfo") and session[
"TranscodingInfo"
].get("Bitrate"):
bitrate = session["TranscodingInfo"]["Bitrate"]
elif item.get("Bitrate"):
bitrate = item["Bitrate"]
elif item.get("MediaSources", [{}])[0].get("Bitrate"):
bitrate = item["MediaSources"][0]["Bitrate"]
else:
bitrate = self.default_stream_bitrate
bitrate = min(int(bitrate), 100_000_000)
active_streams.append({"name": stream_name, "bitrate_bps": bitrate})
return active_streams
@@ -139,9 +162,6 @@ class JellyfinQBittorrentMonitor:
timeout=10,
)
response.raise_for_status()
self.throttle_active = enable
new_state = self.check_qbittorrent_alternate_limits()
if new_state == enable:
logger.info(f"Alternate speed limits {action}")
@@ -157,19 +177,76 @@ class JellyfinQBittorrentMonitor:
except requests.exceptions.RequestException as e:
logger.error(f"Failed to {action} alternate speed limits: {e}")
def pause_all_torrents(self) -> None:
try:
response = self.session.post(
f"{self.qbittorrent_url}/api/v2/torrents/stop",
data={"hashes": "all"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(f"Failed to pause torrents: {e}")
def resume_all_torrents(self) -> None:
try:
response = self.session.post(
f"{self.qbittorrent_url}/api/v2/torrents/start",
data={"hashes": "all"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(f"Failed to resume torrents: {e}")
def set_alt_speed_limits(self, dl_kbs: float, ul_kbs: float) -> None:
try:
payload = {
"alt_dl_limit": int(dl_kbs * 1024),
"alt_up_limit": int(ul_kbs * 1024),
}
response = self.session.post(
f"{self.qbittorrent_url}/api/v2/app/setPreferences",
data={"json": json.dumps(payload)},
timeout=10,
)
response.raise_for_status()
self.last_alt_limits = (dl_kbs, ul_kbs)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to set alternate speed limits: {e}")
def restore_normal_limits(self) -> None:
if self.throttle_active:
if self.torrents_paused:
logger.info("Resuming all torrents before shutdown...")
self.resume_all_torrents()
self.torrents_paused = False
if self.current_state != "unlimited":
logger.info("Restoring normal speed limits before shutdown...")
self.use_alt_limits(False)
self.use_alt_limits(False)
self.current_state = "unlimited"
def sync_qbittorrent_state(self) -> None:
try:
actual_state = self.check_qbittorrent_alternate_limits()
if actual_state != self.throttle_active:
logger.warning(
f"qBittorrent state mismatch detected: expected {self.throttle_active}, got {actual_state}. Re-syncing..."
)
self.use_alt_limits(self.throttle_active)
if self.current_state == "unlimited":
actual_state = self.check_qbittorrent_alternate_limits()
if actual_state:
logger.warning(
"qBittorrent state mismatch detected: expected alt speed OFF, got ON. Re-syncing..."
)
self.use_alt_limits(False)
elif self.current_state == "throttled":
if self.last_alt_limits:
self.set_alt_speed_limits(*self.last_alt_limits)
actual_state = self.check_qbittorrent_alternate_limits()
if not actual_state:
logger.warning(
"qBittorrent state mismatch detected: expected alt speed ON, got OFF. Re-syncing..."
)
self.use_alt_limits(True)
elif self.current_state == "paused":
self.pause_all_torrents()
self.torrents_paused = True
except ServiceUnavailable:
pass
@@ -182,7 +259,6 @@ class JellyfinQBittorrentMonitor:
time_since_change = now - self.last_state_change
# 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
@@ -190,10 +266,9 @@ class JellyfinQBittorrentMonitor:
else:
remaining = self.streaming_start_delay - time_since_change
logger.info(
f"Streaming started - waiting {remaining:.1f}s before enabling throttling"
f"Streaming started - waiting {remaining:.1f}s before enforcing limits"
)
# 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
@@ -201,7 +276,7 @@ class JellyfinQBittorrentMonitor:
else:
remaining = self.streaming_stop_delay - time_since_change
logger.info(
f"Streaming stopped - waiting {remaining:.1f}s before disabling throttling"
f"Streaming stopped - waiting {remaining:.1f}s before restoring unlimited mode"
)
return False
@@ -211,6 +286,12 @@ class JellyfinQBittorrentMonitor:
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")
logger.info(f"Streaming start delay: {self.streaming_start_delay}s")
logger.info(f"Streaming stop delay: {self.streaming_stop_delay}s")
logger.info(f"Total bandwidth budget: {self.total_bandwidth_budget} bps")
logger.info(f"Service buffer: {self.service_buffer} bps")
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
@@ -222,26 +303,91 @@ class JellyfinQBittorrentMonitor:
try:
active_streams = self.check_jellyfin_sessions()
except ServiceUnavailable:
logger.warning(
"Jellyfin unavailable, maintaining current throttle state"
)
logger.warning("Jellyfin unavailable, maintaining current state")
time.sleep(self.check_interval)
continue
streaming_active = len(active_streams) > 0
if active_streams:
for stream in active_streams:
logger.debug(
f"Active stream: {stream['name']} ({stream['bitrate_bps']} bps)"
)
if active_streams != self.last_active_streams:
if streaming_active:
stream_names = ", ".join(
stream["name"] for stream in active_streams
)
logger.info(
f"Active streams ({len(active_streams)}): {', '.join(active_streams)}"
f"Active streams ({len(active_streams)}): {stream_names}"
)
elif len(active_streams) == 0 and self.last_streaming_state:
logger.info("No active streaming sessions")
if self.should_change_state(streaming_active):
self.last_streaming_state = streaming_active
self.use_alt_limits(streaming_active)
streaming_state = bool(self.last_streaming_state)
total_streaming_bps = sum(
stream["bitrate_bps"] for stream in active_streams
)
remaining_bps = (
self.total_bandwidth_budget
- self.service_buffer
- total_streaming_bps
)
remaining_kbs = max(0, remaining_bps) / 8 / 1024
if not streaming_state:
desired_state = "unlimited"
elif streaming_active:
if remaining_kbs >= self.min_torrent_speed:
desired_state = "throttled"
else:
desired_state = "paused"
else:
desired_state = self.current_state
if desired_state != self.current_state:
if desired_state == "unlimited":
action = "resume torrents, disable alt speed"
elif desired_state == "throttled":
action = (
"set alt limits "
f"dl={int(remaining_kbs)}KB/s ul=1KB/s, enable alt speed"
)
else:
action = "pause torrents"
logger.info(
"State change %s -> %s | streams=%d total_bps=%d remaining_bps=%d action=%s",
self.current_state,
desired_state,
len(active_streams),
total_streaming_bps,
remaining_bps,
action,
)
if desired_state == "unlimited":
if self.torrents_paused:
self.resume_all_torrents()
self.torrents_paused = False
self.use_alt_limits(False)
elif desired_state == "throttled":
if self.torrents_paused:
self.resume_all_torrents()
self.torrents_paused = False
self.set_alt_speed_limits(remaining_kbs, 1)
self.use_alt_limits(True)
else:
if not self.torrents_paused:
self.pause_all_torrents()
self.torrents_paused = True
self.current_state = desired_state
self.last_active_streams = active_streams
time.sleep(self.check_interval)
@@ -265,6 +411,10 @@ if __name__ == "__main__":
jellyfin_api_key = os.getenv("JELLYFIN_API_KEY")
streaming_start_delay = int(os.getenv("STREAMING_START_DELAY", "10"))
streaming_stop_delay = int(os.getenv("STREAMING_STOP_DELAY", "60"))
total_bandwidth_budget = int(os.getenv("TOTAL_BANDWIDTH_BUDGET", "30000000"))
service_buffer = int(os.getenv("SERVICE_BUFFER", "5000000"))
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
monitor = JellyfinQBittorrentMonitor(
jellyfin_url=jellyfin_url,
@@ -273,6 +423,10 @@ if __name__ == "__main__":
jellyfin_api_key=jellyfin_api_key,
streaming_start_delay=streaming_start_delay,
streaming_stop_delay=streaming_stop_delay,
total_bandwidth_budget=total_bandwidth_budget,
service_buffer=service_buffer,
default_stream_bitrate=default_stream_bitrate,
min_torrent_speed=min_torrent_speed,
)
monitor.run()