{ lib, pkgs, inputs, ... }: let jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; }; webhookPlugin = import ../services/jellyfin/jellyfin-webhook-plugin.nix { inherit pkgs lib; }; configureWebhook = webhookPlugin.mkConfigureScript { jellyfinUrl = "http://localhost:8096"; webhooks = [ { name = "qBittorrent Monitor"; uri = "http://127.0.0.1:9898/"; notificationTypes = [ "PlaybackStart" "PlaybackProgress" "PlaybackStop" ]; } ]; }; in pkgs.testers.runNixOSTest { name = "jellyfin-qbittorrent-monitor"; nodes = { server = { ... }: { imports = [ jfLib.jellyfinTestConfig inputs.vpn-confinement.nixosModules.default ]; # 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; }; }; 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" ]; # Install the Jellyfin Webhook plugin before Jellyfin starts, mirroring # the production module. Jellyfin rewrites meta.json at runtime so a # read-only nix-store symlink would fail — we materialise a writable copy. systemd.services."jellyfin-webhook-install" = { description = "Install Jellyfin Webhook plugin files"; before = [ "jellyfin.service" ]; wantedBy = [ "jellyfin.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = "jellyfin"; Group = "jellyfin"; UMask = "0077"; ExecStart = webhookPlugin.mkInstallScript { pluginsDir = "/var/lib/jellyfin/plugins"; }; }; }; }; # 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 import importlib.util _spec = importlib.util.spec_from_file_location("jf_helpers", "${jfLib.helpers}") assert _spec and _spec.loader _jf = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_jf) setup_jellyfin = _jf.setup_jellyfin jellyfin_api = _jf.jellyfin_api auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"' 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) start_all() 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) token, user_id, movie_id, media_source_id = setup_jellyfin( server, retry, auth_header, "${jfLib.payloads.auth}", "${jfLib.payloads.empty}", ) with subtest("Start monitor service"): python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python" monitor = "${../services/jellyfin/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 '@${jfLib.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 '@${jfLib.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() # Both upload and download should get remaining bandwidth (proportional) assert dl_limit > 0, f"Download limit should be > 0, got {dl_limit}" assert ul_limit == dl_limit, f"Upload limit ({ul_limit}) should equal download limit ({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 '@${jfLib.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}'") # === WEBHOOK TESTS === # # Configure the Jellyfin Webhook plugin to target the monitor, then verify # the real Jellyfin → plugin → monitor path reacts faster than any possible # poll. CHECK_INTERVAL=30 rules out polling as the cause. WEBHOOK_PORT = 9898 WEBHOOK_CREDS = "/tmp/webhook-creds" # Start a webhook-enabled monitor with long poll interval. server.succeed("systemctl stop monitor-test || true") time.sleep(1) server.succeed(f""" systemd-run --unit=monitor-webhook \ --setenv=JELLYFIN_URL=http://localhost:8096 \ --setenv=JELLYFIN_API_KEY={token} \ --setenv=QBITTORRENT_URL=http://localhost:8080 \ --setenv=CHECK_INTERVAL=30 \ --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 \ --setenv=WEBHOOK_PORT={WEBHOOK_PORT} \ --setenv=WEBHOOK_BIND=127.0.0.1 \ {python} {monitor} """) server.wait_until_succeeds(f"ss -ltn | grep -q ':{WEBHOOK_PORT}'", timeout=15) time.sleep(2) assert not is_throttled(), "Should start unthrottled" # Drop the admin token where the configure script expects it (production uses agenix). server.succeed(f"mkdir -p {WEBHOOK_CREDS} && echo '{token}' > {WEBHOOK_CREDS}/jellyfin-api-key") server.succeed( f"systemd-run --wait --unit=webhook-configure-test " f"--setenv=CREDENTIALS_DIRECTORY={WEBHOOK_CREDS} " f"${configureWebhook}" ) with subtest("Real PlaybackStart event throttles via the plugin"): playback_start = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-plugin-start", "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) server.wait_until_succeeds( "curl -sf http://localhost:8080/api/v2/transfer/speedLimitsMode | grep -q '^1$'", timeout=5, ) # Let STREAMING_STOP_DELAY (1s) elapse so the upcoming stop is not swallowed by hysteresis. time.sleep(2) with subtest("Real PlaybackStop event unthrottles via the plugin"): playback_stop = { "ItemId": movie_id, "MediaSourceId": media_source_id, "PlaySessionId": "test-plugin-start", "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) server.wait_until_succeeds( "curl -sf http://localhost:8080/api/v2/transfer/speedLimitsMode | grep -q '^0$'", timeout=10, ) # Restore fast-polling monitor for the service-restart tests below. server.succeed("systemctl stop monitor-webhook || 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) # === 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 '@${jfLib.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 '@${jfLib.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 '@${jfLib.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 '@${jfLib.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" ''; }