From 9a9ecc65561dfe2b534516522e1608f334fe42ed Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sun, 15 Feb 2026 23:33:45 -0500 Subject: [PATCH] jellyfin-qbittorrent-monitor: dynamic bandwidth management --- services/jellyfin-qbittorrent-monitor.nix | 5 + services/jellyfin-qbittorrent-monitor.py | 200 +++++++++++++++++++--- tests/jellyfin-qbittorrent-monitor.nix | 188 ++++++++++++++++++++ 3 files changed, 370 insertions(+), 23 deletions(-) diff --git a/services/jellyfin-qbittorrent-monitor.nix b/services/jellyfin-qbittorrent-monitor.nix index c1f89cc..68ab406 100644 --- a/services/jellyfin-qbittorrent-monitor.nix +++ b/services/jellyfin-qbittorrent-monitor.nix @@ -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 }; }; } diff --git a/services/jellyfin-qbittorrent-monitor.py b/services/jellyfin-qbittorrent-monitor.py index 39de95b..99f3205 100644 --- a/services/jellyfin-qbittorrent-monitor.py +++ b/services/jellyfin-qbittorrent-monitor.py @@ -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() diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 45dee94..eb5a474 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -123,6 +123,20 @@ pkgs.testers.runNixOSTest { def is_throttled(): return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1" + def get_alt_dl_limit(): + prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences")) + return prefs["alt_dl_limit"] + + def get_alt_up_limit(): + prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences")) + return prefs["alt_up_limit"] + + def are_torrents_paused(): + torrents = json.loads(server.succeed("curl -s 'http://localhost:8080/api/v2/torrents/info'")) + if not torrents: + return False + return all(t["state"].startswith("stopped") for t in torrents) + movie_id: str = "" media_source_id: str = "" @@ -186,12 +200,17 @@ pkgs.testers.runNixOSTest { --setenv=CHECK_INTERVAL=1 \ --setenv=STREAMING_START_DELAY=1 \ --setenv=STREAMING_STOP_DELAY=1 \ + --setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \ + --setenv=SERVICE_BUFFER=2000000 \ + --setenv=DEFAULT_STREAM_BITRATE=10000000 \ + --setenv=MIN_TORRENT_SPEED=100 \ {python} {monitor} """) time.sleep(2) assert not is_throttled(), "Should start unthrottled" client_auth = 'MediaBrowser Client="External Client", DeviceId="external-9999", Device="ExternalDevice", Version="1.0"' + client_auth2 = 'MediaBrowser Client="External Client 2", DeviceId="external-8888", Device="ExternalDevice2", Version="1.0"' server_ip = "192.168.1.1" with subtest("Client authenticates from external network"): @@ -199,6 +218,11 @@ pkgs.testers.runNixOSTest { client_auth_result = json.loads(client.succeed(auth_cmd)) client_token = client_auth_result["AccessToken"] + with subtest("Second client authenticates from external network"): + auth_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" + client_auth_result2 = json.loads(client.succeed(auth_cmd2)) + client_token2 = client_auth_result2["AccessToken"] + with subtest("External video playback triggers throttling"): playback_start = { "ItemId": movie_id, @@ -248,6 +272,162 @@ pkgs.testers.runNixOSTest { assert not is_throttled(), "Should unthrottle when playback stops" + with subtest("Single stream sets proportional alt speed limits"): + playback_start = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-proportional", + "CanSeek": True, + "IsPaused": False, + } + start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(start_cmd) + time.sleep(3) + + assert is_throttled(), "Should be in alt speed mode during streaming" + dl_limit = get_alt_dl_limit() + ul_limit = get_alt_up_limit() + # Upload should be minimal (1 KB/s = 1024 bytes/s) + assert ul_limit == 1024, f"Upload limit should be 1024 bytes/s, got {ul_limit}" + # Download limit should be > 0 (budget not exhausted for a single stream) + assert dl_limit > 0, f"Download limit should be > 0, got {dl_limit}" + + # Stop playback + playback_stop = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-proportional", + "PositionTicks": 50000000, + } + stop_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(playback_stop)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(stop_cmd) + time.sleep(3) + + with subtest("Multiple streams reduce available bandwidth"): + # Start first stream + playback1 = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-multi-1", + "CanSeek": True, + "IsPaused": False, + } + start_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(start_cmd1) + time.sleep(3) + + single_dl_limit = get_alt_dl_limit() + + # Start second stream with different client identity + playback2 = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-multi-2", + "CanSeek": True, + "IsPaused": False, + } + start_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'" + client.succeed(start_cmd2) + time.sleep(3) + + dual_dl_limit = get_alt_dl_limit() + # Two streams should leave less bandwidth than one stream + assert dual_dl_limit < single_dl_limit, f"Two streams ({dual_dl_limit}) should have lower limit than one ({single_dl_limit})" + + # Stop both streams + stop1 = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-multi-1", + "PositionTicks": 50000000, + } + stop_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(stop_cmd1) + + stop2 = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-multi-2", + "PositionTicks": 50000000, + } + stop_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'" + client.succeed(stop_cmd2) + time.sleep(3) + + with subtest("Budget exhaustion pauses all torrents"): + # Stop current monitor + server.succeed("systemctl stop monitor-test || true") + time.sleep(1) + + # Add a dummy torrent so we can check pause state + server.succeed("curl -sf -X POST 'http://localhost:8080/api/v2/torrents/add' -d 'urls=magnet:?xt=urn:btih:0000000000000000000000000000000000000001%26dn=test-torrent'") + time.sleep(2) + + # Start monitor with impossibly low budget + server.succeed(f""" + systemd-run --unit=monitor-exhaust \ + --setenv=JELLYFIN_URL=http://localhost:8096 \ + --setenv=JELLYFIN_API_KEY={token} \ + --setenv=QBITTORRENT_URL=http://localhost:8080 \ + --setenv=CHECK_INTERVAL=1 \ + --setenv=STREAMING_START_DELAY=1 \ + --setenv=STREAMING_STOP_DELAY=1 \ + --setenv=TOTAL_BANDWIDTH_BUDGET=1000 \ + --setenv=SERVICE_BUFFER=500 \ + --setenv=DEFAULT_STREAM_BITRATE=10000000 \ + --setenv=MIN_TORRENT_SPEED=100 \ + {python} {monitor} + """) + time.sleep(2) + + # Start a stream - this will exceed the tiny budget + playback_start = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-exhaust", + "CanSeek": True, + "IsPaused": False, + } + start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(start_cmd) + time.sleep(3) + + assert are_torrents_paused(), "Torrents should be paused when budget is exhausted" + + with subtest("Recovery from pause restores unlimited"): + # Stop the stream + playback_stop = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-exhaust", + "PositionTicks": 50000000, + } + stop_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(playback_stop)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(stop_cmd) + time.sleep(3) + + assert not is_throttled(), "Should return to unlimited after streams stop" + assert not are_torrents_paused(), "Torrents should be resumed after streams stop" + + # Clean up: stop exhaust monitor, restart normal monitor + server.succeed("systemctl stop monitor-exhaust || true") + time.sleep(1) + server.succeed(f""" + systemd-run --unit=monitor-test \ + --setenv=JELLYFIN_URL=http://localhost:8096 \ + --setenv=JELLYFIN_API_KEY={token} \ + --setenv=QBITTORRENT_URL=http://localhost:8080 \ + --setenv=CHECK_INTERVAL=1 \ + --setenv=STREAMING_START_DELAY=1 \ + --setenv=STREAMING_STOP_DELAY=1 \ + --setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \ + --setenv=SERVICE_BUFFER=2000000 \ + --setenv=DEFAULT_STREAM_BITRATE=10000000 \ + --setenv=MIN_TORRENT_SPEED=100 \ + {python} {monitor} + """) + time.sleep(2) + with subtest("Local playback does NOT trigger throttling"): local_auth = 'MediaBrowser Client="Local Client", DeviceId="local-1111", Device="LocalDevice", Version="1.0"' local_auth_result = json.loads(server.succeed( @@ -351,6 +531,10 @@ pkgs.testers.runNixOSTest { f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" )) client_token = client_auth_result["AccessToken"] + client_auth_result2 = json.loads(client.succeed( + f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" + )) + client_token2 = client_auth_result2["AccessToken"] # No active streams after Jellyfin restart, should eventually unthrottle time.sleep(3) @@ -362,6 +546,10 @@ pkgs.testers.runNixOSTest { f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" )) client_token = client_auth_result["AccessToken"] + client_auth_result2 = json.loads(client.succeed( + f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" + )) + client_token2 = client_auth_result2["AccessToken"] # Start playback playback_start = {