From f40f9748a40a5a469e5e15b54b61978ab52ac7e6 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 24 Oct 2025 12:31:41 -0400 Subject: [PATCH] jellyfin-qbittorrent-monitor: improve testing infra --- services/jellyfin-qbittorrent-monitor.py | 12 +- tests/jellyfin-qbittorrent-monitor.nix | 177 ++++++++++++++++++----- 2 files changed, 151 insertions(+), 38 deletions(-) diff --git a/services/jellyfin-qbittorrent-monitor.py b/services/jellyfin-qbittorrent-monitor.py index 27bc167..6d19c61 100644 --- a/services/jellyfin-qbittorrent-monitor.py +++ b/services/jellyfin-qbittorrent-monitor.py @@ -21,6 +21,8 @@ class JellyfinQBittorrentMonitor: qbittorrent_url="http://localhost:8080", check_interval=30, jellyfin_api_key=None, + streaming_start_delay=10, + streaming_stop_delay=60, ): self.jellyfin_url = jellyfin_url self.qbittorrent_url = qbittorrent_url @@ -33,8 +35,8 @@ class JellyfinQBittorrentMonitor: self.last_active_streams = [] # Hysteresis settings to prevent rapid switching - self.streaming_start_delay = 10 - self.streaming_stop_delay = 60 + self.streaming_start_delay = streaming_start_delay + self.streaming_stop_delay = streaming_stop_delay self.last_state_change = 0 # Local network ranges (RFC 1918 private networks + localhost) @@ -80,7 +82,7 @@ class JellyfinQBittorrentMonitor: for session in sessions: if ( "NowPlayingItem" in session - and session.get("PlayState", {}).get("IsPaused", True) == False + and not session.get("PlayState", {}).get("IsPaused", True) ): # Check if session is from external network remote_endpoint = session.get("RemoteEndPoint", "") @@ -249,12 +251,16 @@ if __name__ == "__main__": 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") + streaming_start_delay = int(os.getenv("STREAMING_START_DELAY", "10")) + streaming_stop_delay = int(os.getenv("STREAMING_STOP_DELAY", "60")) monitor = JellyfinQBittorrentMonitor( jellyfin_url=jellyfin_url, qbittorrent_url=qbittorrent_url, check_interval=check_interval, jellyfin_api_key=jellyfin_api_key, + streaming_start_delay=streaming_start_delay, + streaming_stop_delay=streaming_stop_delay, ) monitor.run() diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 3def72a..c2c09d1 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -69,10 +69,9 @@ pkgs.testers.runNixOSTest { serviceConfig = { Type = "simple"; ExecStart = lib.getExe ( - pkgs.writers.writePython3Bin "mock-jellyfin" { } '' + pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } '' import http.server import socketserver - import os import json @@ -83,24 +82,42 @@ pkgs.testers.runNixOSTest { self.send_header('Content-type', 'application/json') self.end_headers() - # Check if we should simulate streaming - streaming_file = '/tmp/jellyfin_streaming' - if os.path.exists(streaming_file): - # Simulate external streaming session + state = getattr(self.server, 'test_state', { + 'streaming': False, + 'paused': False, + 'local': False, + 'media_type': 'Movie' + }) + + if state['streaming']: + if state['local']: + remote_ip = '192.168.1.100' + else: + remote_ip = '203.0.113.42' + + # Map media types to names + type_names = { + 'Movie': 'Test Movie', + 'Episode': 'Test Episode S01E01', + 'Video': 'Test Video', + 'Audio': 'Test Song' + } + sessions = [{ 'Id': 'test-session-1', 'UserName': 'ExternalUser', - 'RemoteEndPoint': '203.0.113.42', # External IP + 'RemoteEndPoint': remote_ip, 'NowPlayingItem': { - 'Name': 'Test Movie', - 'Type': 'Movie' + 'Name': type_names.get( + state['media_type'], 'Test Content' + ), + 'Type': state['media_type'] }, 'PlayState': { - 'IsPaused': False + 'IsPaused': state['paused'] } }] else: - # No streaming sessions sessions = [] self.wfile.write(json.dumps(sessions).encode()) @@ -108,6 +125,52 @@ pkgs.testers.runNixOSTest { self.send_response(404) self.end_headers() + def do_POST(self): + if self.path.startswith('/control/'): + try: + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + data = json.loads(post_data.decode()) if post_data else {} + + if not hasattr(self.server, 'test_state'): + self.server.test_state = { + 'streaming': False, + 'paused': False, + 'local': False, + 'media_type': 'Movie' + } + + if self.path == '/control/state': + # Set complete state + self.server.test_state.update(data) + self.send_response(200) + self.end_headers() + self.wfile.write(b'OK') + state_str = str(self.server.test_state) + print(f'Jellyfin Mock: State updated to {state_str}') + elif self.path == '/control/reset': + # Reset to default state + self.server.test_state = { + 'streaming': False, + 'paused': False, + 'local': False, + 'media_type': 'Movie' + } + self.send_response(200) + self.end_headers() + self.wfile.write(b'OK') + print('Jellyfin Mock: State reset') + else: + self.send_response(404) + self.end_headers() + except Exception as e: + print(f'Jellyfin Mock: Control error: {e}') + self.send_response(500) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + def log_message(self, format, *args): print(f'Jellyfin Mock: {format % args}') @@ -147,18 +210,33 @@ pkgs.testers.runNixOSTest { server.wait_for_open_port(8080) # Mock qBittorrent import time + import json + time.sleep(5) - # TEST 1: Verify initial state - no streaming, normal speed limits - qbt_result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") - assert qbt_result.strip() == "0", "qBittorrent should start with normal speed limits" + # Helper function to set mock server state + def set_jellyfin_state(streaming=False, paused=False, local=False, media_type="Movie"): + state = { + "streaming": streaming, + "paused": paused, + "local": local, + "media_type": media_type + } + server.succeed(f"curl -s -X POST -H 'Content-Type: application/json' -d '{json.dumps(state)}' http://localhost:8096/control/state") + + # Helper function to get current qBittorrent throttling state + def get_throttling_state(): + result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") + return result.strip() == "1" + + print("\\nTesting initial state...") + assert not get_throttling_state(), "qBittorrent should start with normal speed limits" - # Verify no streaming sessions initially sessions_result = server.succeed("curl -s http://localhost:8096/Sessions") print(f"Initial Jellyfin sessions: {sessions_result}") assert "[]" in sessions_result, "Should be no streaming sessions initially" - # Start the monitor + # Start the monitor with fast delays for testing python_path = "${pkgs.python3.withPackages (ps: with ps; [ requests ])}/bin/python" monitor_path = "${../services/jellyfin-qbittorrent-monitor.py}" server.succeed(f""" @@ -166,36 +244,65 @@ pkgs.testers.runNixOSTest { --setenv=JELLYFIN_URL=http://localhost:8096 \\ --setenv=QBITTORRENT_URL=http://localhost:8080 \\ --setenv=CHECK_INTERVAL=2 \\ + --setenv=STREAMING_START_DELAY=2 \\ + --setenv=STREAMING_STOP_DELAY=3 \\ {python_path} {monitor_path} """) - # Wait for monitor to start time.sleep(3) - # TEST 2: Verify monitor runs and keeps normal limits with no streaming - qbt_result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") - assert qbt_result.strip() == "0", "Should maintain normal speed limits with no streaming" + # Define test scenarios + media_types = ["Movie", "Episode", "Video", "Audio"] + playback_states = [False, True] # False=playing, True=paused + network_locations = [False, True] # False=external, True=local - # TEST 3: Simulate external streaming and verify throttling - print("\\nSimulating external streaming session...") - server.succeed("touch /tmp/jellyfin_streaming") # Signal mock to return streaming session + test_count = 0 + for media_type in media_types: + for is_paused in playback_states: + for is_local in network_locations: + test_count += 1 + print(f"\\nTest {test_count}: {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}") - # Wait for monitor to detect streaming and apply throttling (includes hysteresis delay) - time.sleep(15) + # Set streaming state + set_jellyfin_state(streaming=True, paused=is_paused, local=is_local, media_type=media_type) + time.sleep(6) # Wait for monitor to detect and apply changes - qbt_result_streaming = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") + throttling_active = get_throttling_state() - # Check if throttling was enabled - assert qbt_result_streaming.strip() == "1", "External streaming should enable qBittorrent throttling" + # Determine expected behavior: + # Throttling should be active only if: + # - Not paused AND + # - Not local AND + # - Media type is video (Movie, Episode, Video) - NOT Audio + should_throttle = ( + not is_paused and + not is_local and + media_type in ["Movie", "Episode", "Video"] + ) - # TEST 4: Stop streaming and verify throttling is removed - print("\\nStopping streaming session...") - server.succeed("rm -f /tmp/jellyfin_streaming") # Signal mock to return no sessions + if should_throttle: + assert throttling_active, f"Expected throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}" + else: + assert not throttling_active, f"Expected no throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}" - # Wait for monitor to detect no streaming and remove throttling (includes hysteresis delay) - time.sleep(65) + set_jellyfin_state(streaming=False) + time.sleep(7) # Wait for stop delay - qbt_result_no_streaming = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode") - assert qbt_result_no_streaming.strip() == "0", "No streaming should disable qBittorrent throttling" + assert not get_throttling_state(), "No streaming should disable throttling" + + # Start with throttling-enabled state + set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie") + time.sleep(6) + assert get_throttling_state(), "Should enable throttling for external Movie" + + # Switch to paused (should disable throttling) + set_jellyfin_state(streaming=True, paused=True, local=False, media_type="Movie") + time.sleep(6) + assert not get_throttling_state(), "Should disable throttling when paused" + + # Switch back to playing (should re-enable throttling) + set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie") + time.sleep(6) + assert get_throttling_state(), "Should re-enable throttling when unpaused" ''; }