From a93c7892780295ce37336134b2af85c77fb74962 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Tue, 13 Jan 2026 14:08:20 -0500 Subject: [PATCH] jellyfin-qbittorrent-monitor: don't mock out jellyfin for testing --- tests/jellyfin-qbittorrent-monitor.nix | 323 +++++++++++++++---------- 1 file changed, 199 insertions(+), 124 deletions(-) diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 4868d83..0d74119 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -4,6 +4,7 @@ ... }: let + # Mock qBittorrent - simple enough to keep mocked mockQBittorrent = pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } '' import http.server import socketserver @@ -33,154 +34,228 @@ let self.end_headers() - with socketserver.TCPServer(("127.0.0.1", 8080), Handler) as s: + with socketserver.TCPServer(("0.0.0.0", 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() - ''; + payloads = { + auth = pkgs.writeText "auth.json" (builtins.toJSON { Username = "jellyfin"; }); + empty = pkgs.writeText "empty.json" (builtins.toJSON { }); + }; in pkgs.testers.runNixOSTest { name = "jellyfin-qbittorrent-monitor"; - nodes.server = { - systemd.services.mock-qbittorrent = { - wantedBy = [ "multi-user.target" ]; - serviceConfig.ExecStart = lib.getExe mockQBittorrent; + nodes = { + server = { + services.jellyfin.enable = true; + environment.systemPackages = with pkgs; [ + curl + ffmpeg + ]; + virtualisation.diskSize = 3 * 1024; + networking.firewall.allowedTCPPorts = [ + 8096 + 8080 + ]; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ + { + address = "192.168.1.1"; + prefixLength = 24; + } + ]; + networking.interfaces.eth1.ipv4.routes = [ + { + address = "203.0.113.0"; + prefixLength = 24; + } + ]; + 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; + + # Public test IP (RFC 5737 TEST-NET-3) so Jellyfin sees it as external + client = { + environment.systemPackages = [ pkgs.curl ]; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ + { + address = "203.0.113.10"; + prefixLength = 24; + } + ]; + networking.interfaces.eth1.ipv4.routes = [ + { + address = "192.168.1.0"; + prefixLength = 24; + } + ]; }; - environment.systemPackages = [ pkgs.curl ]; - networking.firewall.allowedTCPPorts = [ - 8096 - 8080 - ]; }; testScript = '' - import time, json + import json + import time + from urllib.parse import urlencode - start_all() - server.wait_for_unit("multi-user.target") - 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) + auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"' - def set_state(**kwargs): - server.succeed(f"curl -sX POST -H 'Content-Type: application/json' -d '{json.dumps(kwargs)}' http://localhost:8096/control/state") + def api_get(path, token=None): + header = auth_header + (f", Token={token}" if token else "") + return f"curl -sf 'http://server:8096{path}' -H 'X-Emby-Authorization:{header}'" + + def api_post(path, json_file=None, token=None): + header = auth_header + (f", Token={token}" if token else "") + if json_file: + return f"curl -sf -X POST 'http://server:8096{path}' -d '@{json_file}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{header}'" + return f"curl -sf -X POST 'http://server:8096{path}' -H 'X-Emby-Authorization:{header}'" def is_throttled(): return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1" - # Verify initial state - assert not is_throttled(), "Should start unthrottled" - assert "[]" in server.succeed("curl -s http://localhost:8096/Sessions"), "No initial sessions" + movie_id: str = "" + media_source_id: str = "" - # 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=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) + start_all() + server.wait_for_unit("jellyfin.service") + server.wait_for_open_port(8096) + server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60) + server.wait_for_unit("mock-qbittorrent.service") + server.wait_for_open_port(8080) - # 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), - ] + with subtest("Complete Jellyfin setup wizard"): + server.wait_until_succeeds(api_get("/Startup/Configuration")) + server.succeed(api_get("/Startup/FirstUser")) + server.succeed(api_post("/Startup/Complete")) - 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}" + with subtest("Authenticate and get token"): + auth_result = json.loads(server.succeed(api_post("/Users/AuthenticateByName", "${payloads.auth}"))) + token = auth_result["AccessToken"] + user_id = auth_result["User"]["Id"] - # 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 is_throttled(), "Should throttle external movie" + with subtest("Create test video library"): + tempdir = server.succeed("mktemp -d -p /var/lib/jellyfin").strip() + server.succeed(f"chmod 755 '{tempdir}'") + server.succeed(f"ffmpeg -f lavfi -i testsrc2=duration=5 '{tempdir}/Test Movie (2024) [1080p].mkv'") - set_state(streaming=True, media_type="Movie", paused=True, local=False) - time.sleep(1.5) - assert not is_throttled(), "Should unthrottle when paused" + add_folder_query = urlencode({ + "name": "Test Library", + "collectionType": "Movies", + "paths": tempdir, + "refreshLibrary": "true", + }) + server.succeed(api_post(f"/Library/VirtualFolders?{add_folder_query}", "${payloads.empty}", token)) - set_state(streaming=True, media_type="Movie", paused=False, local=False) - time.sleep(1.5) - assert is_throttled(), "Should re-throttle when unpaused" + def is_library_ready(_): + folders = json.loads(server.succeed(api_get("/Library/VirtualFolders", token))) + return all(f.get("RefreshStatus") == "Idle" for f in folders) + retry(is_library_ready, timeout=60) + + def get_movie(_): + global movie_id, media_source_id + items = json.loads(server.succeed(api_get(f"/Users/{user_id}/Items?IncludeItemTypes=Movie&Recursive=true", token))) + if items["TotalRecordCount"] > 0: + movie_id = items["Items"][0]["Id"] + item_info = json.loads(server.succeed(api_get(f"/Users/{user_id}/Items/{movie_id}", token))) + media_source_id = item_info["MediaSources"][0]["Id"] + return True + return False + retry(get_movie, timeout=60) + + with subtest("Start monitor service"): + python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python" + monitor = "${../services/jellyfin-qbittorrent-monitor.py}" + 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 \\ + {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"' + server_ip = "192.168.1.1" + + with subtest("Client authenticates from external network"): + auth_cmd = 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_auth_result = json.loads(client.succeed(auth_cmd)) + client_token = client_auth_result["AccessToken"] + + with subtest("External video playback triggers throttling"): + playback_start = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-1", + "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(2) + assert is_throttled(), "Should throttle for external video playback" + + with subtest("Pausing disables throttling"): + playback_progress = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-1", + "IsPaused": True, + "PositionTicks": 10000000, + } + progress_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Progress' -d '{json.dumps(playback_progress)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(progress_cmd) + time.sleep(2) + + assert not is_throttled(), "Should unthrottle when paused" + + with subtest("Resuming re-enables throttling"): + playback_progress["IsPaused"] = False + playback_progress["PositionTicks"] = 20000000 + progress_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Progress' -d '{json.dumps(playback_progress)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(progress_cmd) + time.sleep(2) + + assert is_throttled(), "Should re-throttle when resumed" + + with subtest("Stopping playback disables throttling"): + playback_stop = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-1", + "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(2) + + assert not is_throttled(), "Should unthrottle when playback stops" + + 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( + f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}'" + )) + local_token = local_auth_result["AccessToken"] + + local_playback = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-local", + "CanSeek": True, + "IsPaused": False, + } + server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'") + time.sleep(2) + assert not is_throttled(), "Should NOT throttle for local playback" + + local_playback["PositionTicks"] = 50000000 + server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'") ''; }