{ lib, pkgs, inputs, ... }: let 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 = { ... }: { imports = [ inputs.vpn-confinement.nixosModules.default ]; services.jellyfin.enable = true; # Real qBittorrent service services.qbittorrent = { enable = true; webuiPort = 8080; openFirewall = true; serverConfig.LegalNotice.Accepted = true; serverConfig.Preferences = { WebUI = { # Disable authentication for testing AuthSubnetWhitelist = "0.0.0.0/0,::/0"; AuthSubnetWhitelistEnabled = true; LocalHostAuth = false; }; Downloads = { SavePath = "/var/lib/qbittorrent/downloads"; TempPath = "/var/lib/qbittorrent/incomplete"; }; }; serverConfig.BitTorrent.Session = { # Normal speed - unlimited GlobalUPSpeedLimit = 0; GlobalDLSpeedLimit = 0; # Alternate speed limits for when Jellyfin is streaming AlternativeGlobalUPSpeedLimit = 100; AlternativeGlobalDLSpeedLimit = 100; }; }; 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; } ]; # Create directories for qBittorrent systemd.tmpfiles.rules = [ "d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent" "d /var/lib/qbittorrent/incomplete 0755 qbittorrent qbittorrent" ]; }; # 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" def get_alt_dl_limit(): prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences")) return prefs["alt_dl_limit"] def get_alt_up_limit(): prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences")) return prefs["alt_up_limit"] def are_torrents_paused(): torrents = json.loads(server.succeed("curl -s 'http://localhost:8080/api/v2/torrents/info'")) if not torrents: return False return all(t["state"].startswith("stopped") for t in torrents) 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("qbittorrent.service") server.wait_for_open_port(8080) # Wait for qBittorrent WebUI to be responsive server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30) 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 \ --setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \ --setenv=SERVICE_BUFFER=2000000 \ --setenv=DEFAULT_STREAM_BITRATE=10000000 \ --setenv=MIN_TORRENT_SPEED=100 \ {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"' client_auth2 = 'MediaBrowser Client="External Client 2", DeviceId="external-8888", Device="ExternalDevice2", 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("Second client authenticates from external network"): auth_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" client_auth_result2 = json.loads(client.succeed(auth_cmd2)) client_token2 = client_auth_result2["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("Single stream sets proportional alt speed limits"): playback_start = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-proportional", "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(3) assert is_throttled(), "Should be in alt speed mode during streaming" dl_limit = get_alt_dl_limit() ul_limit = get_alt_up_limit() # Upload should be minimal (1 KB/s = 1024 bytes/s) assert ul_limit == 1024, f"Upload limit should be 1024 bytes/s, got {ul_limit}" # Download limit should be > 0 (budget not exhausted for a single stream) assert dl_limit > 0, f"Download limit should be > 0, got {dl_limit}" # Stop playback playback_stop = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-proportional", "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(3) with subtest("Multiple streams reduce available bandwidth"): # Start first stream playback1 = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-multi-1", "CanSeek": True, "IsPaused": False, } start_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" client.succeed(start_cmd1) time.sleep(3) single_dl_limit = get_alt_dl_limit() # Start second stream with different client identity playback2 = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-multi-2", "CanSeek": True, "IsPaused": False, } start_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'" client.succeed(start_cmd2) time.sleep(3) dual_dl_limit = get_alt_dl_limit() # Two streams should leave less bandwidth than one stream assert dual_dl_limit < single_dl_limit, f"Two streams ({dual_dl_limit}) should have lower limit than one ({single_dl_limit})" # Stop both streams stop1 = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-multi-1", "PositionTicks": 50000000, } stop_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" client.succeed(stop_cmd1) stop2 = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-multi-2", "PositionTicks": 50000000, } stop_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'" client.succeed(stop_cmd2) time.sleep(3) with subtest("Budget exhaustion pauses all torrents"): # Stop current monitor server.succeed("systemctl stop monitor-test || true") time.sleep(1) # Add a dummy torrent so we can check pause state server.succeed("curl -sf -X POST 'http://localhost:8080/api/v2/torrents/add' -d 'urls=magnet:?xt=urn:btih:0000000000000000000000000000000000000001%26dn=test-torrent'") time.sleep(2) # Start monitor with impossibly low budget server.succeed(f""" systemd-run --unit=monitor-exhaust \ --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 \ --setenv=TOTAL_BANDWIDTH_BUDGET=1000 \ --setenv=SERVICE_BUFFER=500 \ --setenv=DEFAULT_STREAM_BITRATE=10000000 \ --setenv=MIN_TORRENT_SPEED=100 \ {python} {monitor} """) time.sleep(2) # Start a stream - this will exceed the tiny budget playback_start = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-exhaust", "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(3) assert are_torrents_paused(), "Torrents should be paused when budget is exhausted" with subtest("Recovery from pause restores unlimited"): # Stop the stream playback_stop = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-exhaust", "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(3) assert not is_throttled(), "Should return to unlimited after streams stop" assert not are_torrents_paused(), "Torrents should be resumed after streams stop" # Clean up: stop exhaust monitor, restart normal monitor server.succeed("systemctl stop monitor-exhaust || true") time.sleep(1) 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 \ --setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \ --setenv=SERVICE_BUFFER=2000000 \ --setenv=DEFAULT_STREAM_BITRATE=10000000 \ --setenv=MIN_TORRENT_SPEED=100 \ {python} {monitor} """) time.sleep(2) 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}'") # === SERVICE RESTART TESTS === with subtest("qBittorrent restart during throttled state re-applies throttling"): # Start external playback to trigger throttling playback_start = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-restart-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 be throttled before qBittorrent restart" # Restart qBittorrent (this resets alt_speed to its config default - disabled) server.succeed("systemctl restart qbittorrent.service") server.wait_for_unit("qbittorrent.service") server.wait_for_open_port(8080) server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30) # qBittorrent restarted - alt_speed is now False (default on startup) # The monitor should detect this and re-apply throttling time.sleep(3) # Give monitor time to detect and re-apply assert is_throttled(), "Monitor should re-apply throttling after qBittorrent restart" # Stop playback to clean up playback_stop = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-restart-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) with subtest("qBittorrent restart during unthrottled state stays unthrottled"): # Verify we're unthrottled (no active streams) assert not is_throttled(), "Should be unthrottled before test" # Restart qBittorrent server.succeed("systemctl restart qbittorrent.service") server.wait_for_unit("qbittorrent.service") server.wait_for_open_port(8080) server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30) # Give monitor time to check state time.sleep(3) assert not is_throttled(), "Should remain unthrottled after qBittorrent restart with no streams" with subtest("Jellyfin restart during throttled state maintains throttling"): # Start external playback to trigger throttling playback_start = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-restart-2", "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 be throttled before Jellyfin restart" # Restart Jellyfin server.succeed("systemctl restart jellyfin.service") 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) # During Jellyfin restart, monitor can't reach Jellyfin # After restart, sessions are cleared - monitor should eventually unthrottle # But during the unavailability window, throttling should be maintained (fail-safe) time.sleep(3) # Re-authenticate (old token invalid after restart) client_auth_result = json.loads(client.succeed( 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_token = client_auth_result["AccessToken"] client_auth_result2 = json.loads(client.succeed( f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" )) client_token2 = client_auth_result2["AccessToken"] # No active streams after Jellyfin restart, should eventually unthrottle time.sleep(3) assert not is_throttled(), "Should unthrottle after Jellyfin restart clears sessions" with subtest("Monitor recovers after Jellyfin temporary unavailability"): # Re-authenticate with fresh token client_auth_result = json.loads(client.succeed( 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_token = client_auth_result["AccessToken"] client_auth_result2 = json.loads(client.succeed( f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" )) client_token2 = client_auth_result2["AccessToken"] # Start playback playback_start = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-play-session-restart-3", "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 be throttled" # Stop Jellyfin briefly (simulating temporary unavailability) server.succeed("systemctl stop jellyfin.service") time.sleep(2) # During unavailability, throttle state should be maintained (fail-safe) assert is_throttled(), "Should maintain throttle during Jellyfin unavailability" # Bring Jellyfin back server.succeed("systemctl start jellyfin.service") 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) # After Jellyfin comes back, sessions are gone - should unthrottle time.sleep(3) assert not is_throttled(), "Should unthrottle after Jellyfin returns with no sessions" ''; }