From df1d983b630221fc361d91e159527d2c38573615 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Tue, 13 Jan 2026 13:41:23 -0500 Subject: [PATCH] rework qbittorrent jellyfin monitor test --- tests/jellyfin-qbittorrent-monitor.nix | 433 +++++++++---------------- 1 file changed, 157 insertions(+), 276 deletions(-) diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 830fa25..4868d83 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -1,305 +1,186 @@ { - config, lib, pkgs, ... }: +let + mockQBittorrent = pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } '' + import http.server + import socketserver + + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + print(f"qbt: {fmt % args}") + + def do_GET(self): + if self.path == "/api/v2/transfer/speedLimitsMode": + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(("1" if getattr(self.server, "alt_speed", False) else "0").encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/api/v2/transfer/toggleSpeedLimitsMode": + self.server.alt_speed = not getattr(self.server, "alt_speed", False) + self.send_response(200) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + + with socketserver.TCPServer(("127.0.0.1", 8080), Handler) as s: + print("Mock qBittorrent on port 8080") + s.serve_forever() + ''; + + mockJellyfin = pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } '' + import http.server + import socketserver + import json + + DEFAULT = {"streaming": False, "paused": False, "local": False, "media_type": "Movie"} + + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + print(f"jellyfin: {fmt % args}") + + def do_GET(self): + if self.path == "/Sessions": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + state = getattr(self.server, "state", DEFAULT.copy()) + sessions = [] + if state["streaming"]: + sessions = [{ + "Id": "test-1", + "UserName": "User", + "RemoteEndPoint": "192.168.1.100" if state["local"] else "203.0.113.42", + "NowPlayingItem": {"Name": f"Test {state['media_type']}", "Type": state["media_type"]}, + "PlayState": {"IsPaused": state["paused"]} + }] + self.wfile.write(json.dumps(sessions).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/control/state": + data = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))).decode() or "{}") + if not hasattr(self.server, "state"): + self.server.state = DEFAULT.copy() + self.server.state.update(data) + self.send_response(200) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + + with socketserver.TCPServer(("127.0.0.1", 8096), Handler) as s: + print("Mock Jellyfin on port 8096") + s.serve_forever() + ''; +in pkgs.testers.runNixOSTest { name = "jellyfin-qbittorrent-monitor"; - nodes = { - server = - { pkgs, config, ... }: - { - # Mock qBittorrent service - systemd.services.mock-qbittorrent = { - description = "Mock qBittorrent API server"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "simple"; - ExecStart = lib.getExe ( - pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } '' - import http.server - import socketserver - - - class MockQBittorrentHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path == '/api/v2/transfer/speedLimitsMode': - self.send_response(200) - self.send_header('Content-type', 'text/plain') - self.end_headers() - response = '1' if getattr(self.server, 'speed_limits_mode', False) else '0' - self.wfile.write(response.encode()) - else: - self.send_response(404) - self.end_headers() - - def do_POST(self): - if self.path == '/api/v2/transfer/toggleSpeedLimitsMode': - self.server.speed_limits_mode = not getattr(self.server, 'speed_limits_mode', False) - self.send_response(200) - self.end_headers() - print(f'MONITOR_TEST: Speed limits toggled to {self.server.speed_limits_mode}') - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format, *args): - print(f'qBittorrent Mock: {format % args}') - - - with socketserver.TCPServer(('127.0.0.1', 8080), MockQBittorrentHandler) as httpd: - httpd.speed_limits_mode = False - print('Mock qBittorrent server started on port 8080') - httpd.serve_forever() - '' - ); - Restart = "always"; - RestartSec = "5s"; - }; - }; - - # Mock Jellyfin service with controllable streaming state - systemd.services.mock-jellyfin = { - description = "Mock Jellyfin API server"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "simple"; - ExecStart = lib.getExe ( - pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } '' - import http.server - import socketserver - import json - - - class MockJellyfinHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path == '/Sessions': - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.end_headers() - - 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': remote_ip, - 'NowPlayingItem': { - 'Name': type_names.get( - state['media_type'], 'Test Content' - ), - 'Type': state['media_type'] - }, - 'PlayState': { - 'IsPaused': state['paused'] - } - }] - else: - sessions = [] - - self.wfile.write(json.dumps(sessions).encode()) - else: - 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}') - - - with socketserver.TCPServer(('127.0.0.1', 8096), MockJellyfinHandler) as httpd: - print('Mock Jellyfin server started on port 8096') - httpd.serve_forever() - '' - ); - Restart = "always"; - RestartSec = "5s"; - }; - }; - - environment.systemPackages = with pkgs; [ - curl - python3 - ]; - - networking.firewall.allowedTCPPorts = [ - 8096 - 8080 - ]; - }; + nodes.server = { + systemd.services.mock-qbittorrent = { + wantedBy = [ "multi-user.target" ]; + serviceConfig.ExecStart = lib.getExe mockQBittorrent; + }; + systemd.services.mock-jellyfin = { + wantedBy = [ "multi-user.target" ]; + serviceConfig.ExecStart = lib.getExe mockJellyfin; + }; + environment.systemPackages = [ pkgs.curl ]; + networking.firewall.allowedTCPPorts = [ + 8096 + 8080 + ]; }; testScript = '' + import time, json + start_all() - - # Wait for services to start server.wait_for_unit("multi-user.target") - server.wait_for_unit("mock-jellyfin.service") - server.wait_for_unit("mock-qbittorrent.service") + for svc in ["mock-jellyfin", "mock-qbittorrent"]: + server.wait_for_unit(f"{svc}.service") + for port in [8096, 8080]: + server.wait_for_open_port(port) + time.sleep(2) - # Wait for services to be accessible - server.wait_for_open_port(8096) # Mock Jellyfin - server.wait_for_open_port(8080) # Mock qBittorrent + def set_state(**kwargs): + server.succeed(f"curl -sX POST -H 'Content-Type: application/json' -d '{json.dumps(kwargs)}' http://localhost:8096/control/state") - import time - import json + def is_throttled(): + return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1" - time.sleep(5) + # Verify initial state + assert not is_throttled(), "Should start unthrottled" + assert "[]" in server.succeed("curl -s http://localhost:8096/Sessions"), "No initial sessions" - # 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" - - 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 with fast delays for testing - python_path = "${pkgs.python3.withPackages (ps: with ps; [ requests ])}/bin/python" - monitor_path = "${../services/jellyfin-qbittorrent-monitor.py}" + # Start monitor with fast intervals + python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python" + monitor = "${../services/jellyfin-qbittorrent-monitor.py}" server.succeed(f""" - systemd-run --unit=jellyfin-qbittorrent-monitor-test \\ - --setenv=JELLYFIN_URL=http://localhost:8096 \\ - --setenv=QBITTORRENT_URL=http://localhost:8080 \\ - --setenv=CHECK_INTERVAL=1 \\ - --setenv=STREAMING_START_DELAY=1 \\ - --setenv=STREAMING_STOP_DELAY=1 \\ - {python_path} {monitor_path} + systemd-run --unit=monitor-test \ + --setenv=JELLYFIN_URL=http://localhost:8096 \ + --setenv=QBITTORRENT_URL=http://localhost:8080 \ + --setenv=CHECK_INTERVAL=1 \ + --setenv=STREAMING_START_DELAY=1 \ + --setenv=STREAMING_STOP_DELAY=1 \ + {python} {monitor} """) + time.sleep(2) - time.sleep(3) + # Test cases: (streaming, media_type, paused, local, expected_throttle) + # Throttle only when: external + playing + video content (not audio) + test_cases: list[tuple[bool, str, bool, bool, bool]] = [ + # Video types, external, playing -> THROTTLE + (True, "Movie", False, False, True), + (True, "Episode", False, False, True), + (True, "Video", False, False, True), + # Audio external playing -> NO throttle + (True, "Audio", False, False, False), + # Paused -> NO throttle + (True, "Movie", True, False, False), + # Local -> NO throttle + (True, "Movie", False, True, False), + # Local + paused -> NO throttle + (True, "Movie", True, True, False), + # No streaming -> NO throttle + (False, "Movie", False, False, False), + ] - # 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 + for i, (streaming, media_type, paused, local, expected) in enumerate(test_cases, 1): + desc = f"Test {i}: streaming={streaming}, type={media_type}, paused={paused}, local={local}" + print(f"\n{desc}") + set_state(streaming=streaming, media_type=media_type, paused=paused, local=local) + time.sleep(1.5) + actual = is_throttled() + assert actual == expected, f"FAIL {desc}: got {actual}, expected {expected}" - 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'}") - - # Set streaming state - set_jellyfin_state(streaming=True, paused=is_paused, local=is_local, media_type=media_type) - time.sleep(1.5) # Wait for monitor to detect and apply changes - - throttling_active = get_throttling_state() - - # 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"] - ) - - assert throttling_active == should_throttle, f"Expected {"no " if not should_throttle else ""} throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}" - - set_jellyfin_state(streaming=False) - time.sleep(1.5) # Wait for stop delay - - 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") + # Transition tests: pause/unpause while streaming + print("\nTransition: external movie playing -> paused -> playing") + set_state(streaming=True, media_type="Movie", paused=False, local=False) time.sleep(1.5) - assert get_throttling_state(), "Should enable throttling for external Movie" + assert is_throttled(), "Should throttle external movie" - # Switch to paused (should disable throttling) - set_jellyfin_state(streaming=True, paused=True, local=False, media_type="Movie") + set_state(streaming=True, media_type="Movie", paused=True, local=False) time.sleep(1.5) - assert not get_throttling_state(), "Should disable throttling when paused" + assert not is_throttled(), "Should unthrottle when paused" - # Switch back to playing (should re-enable throttling) - set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie") + set_state(streaming=True, media_type="Movie", paused=False, local=False) time.sleep(1.5) - assert get_throttling_state(), "Should re-enable throttling when unpaused" + assert is_throttled(), "Should re-throttle when unpaused" ''; }