{ config, lib, pkgs, ... }: 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 ]; }; }; testScript = '' 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") # Wait for services to be accessible server.wait_for_open_port(8096) # Mock Jellyfin server.wait_for_open_port(8080) # Mock qBittorrent import time import json time.sleep(5) # 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}" 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} """) time.sleep(3) # 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_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"] ) 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'}" 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") time.sleep(1.5) 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(1.5) 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(1.5) assert get_throttling_state(), "Should re-enable throttling when unpaused" ''; }