Files
server-config/tests/jellyfin-qbittorrent-monitor.nix

262 lines
10 KiB
Nix

{
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}'")
'';
}