{ lib, pkgs, ... }: let # Mock qBittorrent - simple enough to keep mocked 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(("0.0.0.0", 8080), Handler) as s: print("Mock qBittorrent on port 8080") 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 = { 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; }; }; # 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; } ]; }; }; testScript = '' import json import time from urllib.parse import urlencode auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"' 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" movie_id: str = "" media_source_id: str = "" 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) 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")) 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"] 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'") 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)) 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}'") ''; }