jellyfin-qbittorrent-monitor: don't mock out jellyfin for testing
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
# Mock qBittorrent - simple enough to keep mocked
|
||||||
mockQBittorrent = pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } ''
|
mockQBittorrent = pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } ''
|
||||||
import http.server
|
import http.server
|
||||||
import socketserver
|
import socketserver
|
||||||
@@ -33,154 +34,228 @@ let
|
|||||||
self.end_headers()
|
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")
|
print("Mock qBittorrent on port 8080")
|
||||||
s.serve_forever()
|
s.serve_forever()
|
||||||
'';
|
'';
|
||||||
|
|
||||||
mockJellyfin = pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } ''
|
payloads = {
|
||||||
import http.server
|
auth = pkgs.writeText "auth.json" (builtins.toJSON { Username = "jellyfin"; });
|
||||||
import socketserver
|
empty = pkgs.writeText "empty.json" (builtins.toJSON { });
|
||||||
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()
|
|
||||||
'';
|
|
||||||
in
|
in
|
||||||
pkgs.testers.runNixOSTest {
|
pkgs.testers.runNixOSTest {
|
||||||
name = "jellyfin-qbittorrent-monitor";
|
name = "jellyfin-qbittorrent-monitor";
|
||||||
|
|
||||||
nodes.server = {
|
nodes = {
|
||||||
systemd.services.mock-qbittorrent = {
|
server = {
|
||||||
wantedBy = [ "multi-user.target" ];
|
services.jellyfin.enable = true;
|
||||||
serviceConfig.ExecStart = lib.getExe mockQBittorrent;
|
environment.systemPackages = with pkgs; [
|
||||||
};
|
curl
|
||||||
systemd.services.mock-jellyfin = {
|
ffmpeg
|
||||||
wantedBy = [ "multi-user.target" ];
|
];
|
||||||
serviceConfig.ExecStart = lib.getExe mockJellyfin;
|
virtualisation.diskSize = 3 * 1024;
|
||||||
};
|
|
||||||
environment.systemPackages = [ pkgs.curl ];
|
|
||||||
networking.firewall.allowedTCPPorts = [
|
networking.firewall.allowedTCPPorts = [
|
||||||
8096
|
8096
|
||||||
8080
|
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 = ''
|
testScript = ''
|
||||||
import time, json
|
import json
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
start_all()
|
auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"'
|
||||||
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)
|
|
||||||
|
|
||||||
def set_state(**kwargs):
|
def api_get(path, token=None):
|
||||||
server.succeed(f"curl -sX POST -H 'Content-Type: application/json' -d '{json.dumps(kwargs)}' http://localhost:8096/control/state")
|
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():
|
def is_throttled():
|
||||||
return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1"
|
return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1"
|
||||||
|
|
||||||
# Verify initial state
|
movie_id: str = ""
|
||||||
assert not is_throttled(), "Should start unthrottled"
|
media_source_id: str = ""
|
||||||
assert "[]" in server.succeed("curl -s http://localhost:8096/Sessions"), "No initial sessions"
|
|
||||||
|
|
||||||
# Start monitor with fast intervals
|
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"
|
python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python"
|
||||||
monitor = "${../services/jellyfin-qbittorrent-monitor.py}"
|
monitor = "${../services/jellyfin-qbittorrent-monitor.py}"
|
||||||
server.succeed(f"""
|
server.succeed(f"""
|
||||||
systemd-run --unit=monitor-test \
|
systemd-run --unit=monitor-test \\
|
||||||
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
--setenv=JELLYFIN_URL=http://localhost:8096 \\
|
||||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
--setenv=JELLYFIN_API_KEY={token} \\
|
||||||
--setenv=CHECK_INTERVAL=1 \
|
--setenv=QBITTORRENT_URL=http://localhost:8080 \\
|
||||||
--setenv=STREAMING_START_DELAY=1 \
|
--setenv=CHECK_INTERVAL=1 \\
|
||||||
--setenv=STREAMING_STOP_DELAY=1 \
|
--setenv=STREAMING_START_DELAY=1 \\
|
||||||
|
--setenv=STREAMING_STOP_DELAY=1 \\
|
||||||
{python} {monitor}
|
{python} {monitor}
|
||||||
""")
|
""")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
assert not is_throttled(), "Should start unthrottled"
|
||||||
|
|
||||||
# Test cases: (streaming, media_type, paused, local, expected_throttle)
|
client_auth = 'MediaBrowser Client="External Client", DeviceId="external-9999", Device="ExternalDevice", Version="1.0"'
|
||||||
# Throttle only when: external + playing + video content (not audio)
|
server_ip = "192.168.1.1"
|
||||||
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),
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, (streaming, media_type, paused, local, expected) in enumerate(test_cases, 1):
|
with subtest("Client authenticates from external network"):
|
||||||
desc = f"Test {i}: streaming={streaming}, type={media_type}, paused={paused}, local={local}"
|
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}'"
|
||||||
print(f"\n{desc}")
|
client_auth_result = json.loads(client.succeed(auth_cmd))
|
||||||
set_state(streaming=streaming, media_type=media_type, paused=paused, local=local)
|
client_token = client_auth_result["AccessToken"]
|
||||||
time.sleep(1.5)
|
|
||||||
actual = is_throttled()
|
|
||||||
assert actual == expected, f"FAIL {desc}: got {actual}, expected {expected}"
|
|
||||||
|
|
||||||
# Transition tests: pause/unpause while streaming
|
with subtest("External video playback triggers throttling"):
|
||||||
print("\nTransition: external movie playing -> paused -> playing")
|
playback_start = {
|
||||||
set_state(streaming=True, media_type="Movie", paused=False, local=False)
|
"ItemId": movie_id,
|
||||||
time.sleep(1.5)
|
"MediaSourceId": media_source_id,
|
||||||
assert is_throttled(), "Should throttle external movie"
|
"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)
|
||||||
|
|
||||||
set_state(streaming=True, media_type="Movie", paused=True, local=False)
|
|
||||||
time.sleep(1.5)
|
|
||||||
assert not is_throttled(), "Should unthrottle when paused"
|
assert not is_throttled(), "Should unthrottle when paused"
|
||||||
|
|
||||||
set_state(streaming=True, media_type="Movie", paused=False, local=False)
|
with subtest("Resuming re-enables throttling"):
|
||||||
time.sleep(1.5)
|
playback_progress["IsPaused"] = False
|
||||||
assert is_throttled(), "Should re-throttle when unpaused"
|
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}'")
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user