rework qbittorrent jellyfin monitor test

This commit is contained in:
2026-01-13 13:41:23 -05:00
parent de89e70a05
commit df1d983b63

View File

@@ -1,305 +1,186 @@
{
config,
lib,
pkgs,
...
}:
let
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(("127.0.0.1", 8080), Handler) as s:
print("Mock qBittorrent on port 8080")
s.serve_forever()
'';
mockJellyfin = pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } ''
import http.server
import socketserver
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
pkgs.testers.runNixOSTest {
name = "jellyfin-qbittorrent-monitor";
nodes = {
server =
{ pkgs, config, ... }:
{
# Mock qBittorrent service
systemd.services.mock-qbittorrent = {
description = "Mock qBittorrent API server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = lib.getExe (
pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } ''
import http.server
import socketserver
class MockQBittorrentHandler(http.server.BaseHTTPRequestHandler):
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()
response = '1' if getattr(self.server, 'speed_limits_mode', False) else '0'
self.wfile.write(response.encode())
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path == '/api/v2/transfer/toggleSpeedLimitsMode':
self.server.speed_limits_mode = not getattr(self.server, 'speed_limits_mode', False)
self.send_response(200)
self.end_headers()
print(f'MONITOR_TEST: Speed limits toggled to {self.server.speed_limits_mode}')
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print(f'qBittorrent Mock: {format % args}')
with socketserver.TCPServer(('127.0.0.1', 8080), MockQBittorrentHandler) as httpd:
httpd.speed_limits_mode = False
print('Mock qBittorrent server started on port 8080')
httpd.serve_forever()
''
);
Restart = "always";
RestartSec = "5s";
};
};
# Mock Jellyfin service with controllable streaming state
systemd.services.mock-jellyfin = {
description = "Mock Jellyfin API server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = lib.getExe (
pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } ''
import http.server
import socketserver
import json
class MockJellyfinHandler(http.server.BaseHTTPRequestHandler):
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, 'test_state', {
'streaming': False,
'paused': False,
'local': False,
'media_type': 'Movie'
})
if state['streaming']:
if state['local']:
remote_ip = '192.168.1.100'
else:
remote_ip = '203.0.113.42'
# Map media types to names
type_names = {
'Movie': 'Test Movie',
'Episode': 'Test Episode S01E01',
'Video': 'Test Video',
'Audio': 'Test Song'
}
sessions = [{
'Id': 'test-session-1',
'UserName': 'ExternalUser',
'RemoteEndPoint': remote_ip,
'NowPlayingItem': {
'Name': type_names.get(
state['media_type'], 'Test Content'
),
'Type': state['media_type']
},
'PlayState': {
'IsPaused': state['paused']
}
}]
else:
sessions = []
self.wfile.write(json.dumps(sessions).encode())
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path.startswith('/control/'):
try:
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode()) if post_data else {}
if not hasattr(self.server, 'test_state'):
self.server.test_state = {
'streaming': False,
'paused': False,
'local': False,
'media_type': 'Movie'
}
if self.path == '/control/state':
# Set complete state
self.server.test_state.update(data)
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
state_str = str(self.server.test_state)
print(f'Jellyfin Mock: State updated to {state_str}')
elif self.path == '/control/reset':
# Reset to default state
self.server.test_state = {
'streaming': False,
'paused': False,
'local': False,
'media_type': 'Movie'
}
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
print('Jellyfin Mock: State reset')
else:
self.send_response(404)
self.end_headers()
except Exception as e:
print(f'Jellyfin Mock: Control error: {e}')
self.send_response(500)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print(f'Jellyfin Mock: {format % args}')
with socketserver.TCPServer(('127.0.0.1', 8096), MockJellyfinHandler) as httpd:
print('Mock Jellyfin server started on port 8096')
httpd.serve_forever()
''
);
Restart = "always";
RestartSec = "5s";
};
};
environment.systemPackages = with pkgs; [
curl
python3
];
networking.firewall.allowedTCPPorts = [
8096
8080
];
};
nodes.server = {
systemd.services.mock-qbittorrent = {
wantedBy = [ "multi-user.target" ];
serviceConfig.ExecStart = lib.getExe mockQBittorrent;
};
systemd.services.mock-jellyfin = {
wantedBy = [ "multi-user.target" ];
serviceConfig.ExecStart = lib.getExe mockJellyfin;
};
environment.systemPackages = [ pkgs.curl ];
networking.firewall.allowedTCPPorts = [
8096
8080
];
};
testScript = ''
import time, json
start_all()
# Wait for services to start
server.wait_for_unit("multi-user.target")
server.wait_for_unit("mock-jellyfin.service")
server.wait_for_unit("mock-qbittorrent.service")
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)
# Wait for services to be accessible
server.wait_for_open_port(8096) # Mock Jellyfin
server.wait_for_open_port(8080) # Mock qBittorrent
def set_state(**kwargs):
server.succeed(f"curl -sX POST -H 'Content-Type: application/json' -d '{json.dumps(kwargs)}' http://localhost:8096/control/state")
import time
import json
def is_throttled():
return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1"
time.sleep(5)
# Verify initial state
assert not is_throttled(), "Should start unthrottled"
assert "[]" in server.succeed("curl -s http://localhost:8096/Sessions"), "No initial sessions"
# Helper function to set mock server state
def set_jellyfin_state(streaming=False, paused=False, local=False, media_type="Movie"):
state = {
"streaming": streaming,
"paused": paused,
"local": local,
"media_type": media_type
}
server.succeed(f"curl -s -X POST -H 'Content-Type: application/json' -d '{json.dumps(state)}' http://localhost:8096/control/state")
# Helper function to get current qBittorrent throttling state
def get_throttling_state():
result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode")
return result.strip() == "1"
print("\\nTesting initial state...")
assert not get_throttling_state(), "qBittorrent should start with normal speed limits"
sessions_result = server.succeed("curl -s http://localhost:8096/Sessions")
print(f"Initial Jellyfin sessions: {sessions_result}")
assert "[]" in sessions_result, "Should be no streaming sessions initially"
# Start the monitor with fast delays for testing
python_path = "${pkgs.python3.withPackages (ps: with ps; [ requests ])}/bin/python"
monitor_path = "${../services/jellyfin-qbittorrent-monitor.py}"
# Start monitor with fast intervals
python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python"
monitor = "${../services/jellyfin-qbittorrent-monitor.py}"
server.succeed(f"""
systemd-run --unit=jellyfin-qbittorrent-monitor-test \\
--setenv=JELLYFIN_URL=http://localhost:8096 \\
--setenv=QBITTORRENT_URL=http://localhost:8080 \\
--setenv=CHECK_INTERVAL=1 \\
--setenv=STREAMING_START_DELAY=1 \\
--setenv=STREAMING_STOP_DELAY=1 \\
{python_path} {monitor_path}
systemd-run --unit=monitor-test \
--setenv=JELLYFIN_URL=http://localhost:8096 \
--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)
time.sleep(3)
# Test cases: (streaming, media_type, paused, local, expected_throttle)
# Throttle only when: external + playing + video content (not audio)
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),
]
# Define test scenarios
media_types = ["Movie", "Episode", "Video", "Audio"]
playback_states = [False, True] # False=playing, True=paused
network_locations = [False, True] # False=external, True=local
for i, (streaming, media_type, paused, local, expected) in enumerate(test_cases, 1):
desc = f"Test {i}: streaming={streaming}, type={media_type}, paused={paused}, local={local}"
print(f"\n{desc}")
set_state(streaming=streaming, media_type=media_type, paused=paused, local=local)
time.sleep(1.5)
actual = is_throttled()
assert actual == expected, f"FAIL {desc}: got {actual}, expected {expected}"
test_count = 0
for media_type in media_types:
for is_paused in playback_states:
for is_local in network_locations:
test_count += 1
print(f"\\nTest {test_count}: {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}")
# Set streaming state
set_jellyfin_state(streaming=True, paused=is_paused, local=is_local, media_type=media_type)
time.sleep(1.5) # Wait for monitor to detect and apply changes
throttling_active = get_throttling_state()
# Determine expected behavior:
# Throttling should be active only if:
# - Not paused AND
# - Not local AND
# - Media type is video (Movie, Episode, Video) - NOT Audio
should_throttle = (
not is_paused and
not is_local and
media_type in ["Movie", "Episode", "Video"]
)
assert throttling_active == should_throttle, f"Expected {"no " if not should_throttle else ""} throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}"
set_jellyfin_state(streaming=False)
time.sleep(1.5) # Wait for stop delay
assert not get_throttling_state(), "No streaming should disable throttling"
# Start with throttling-enabled state
set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie")
# Transition tests: pause/unpause while streaming
print("\nTransition: external movie playing -> paused -> playing")
set_state(streaming=True, media_type="Movie", paused=False, local=False)
time.sleep(1.5)
assert get_throttling_state(), "Should enable throttling for external Movie"
assert is_throttled(), "Should throttle external movie"
# Switch to paused (should disable throttling)
set_jellyfin_state(streaming=True, paused=True, local=False, media_type="Movie")
set_state(streaming=True, media_type="Movie", paused=True, local=False)
time.sleep(1.5)
assert not get_throttling_state(), "Should disable throttling when paused"
assert not is_throttled(), "Should unthrottle when paused"
# Switch back to playing (should re-enable throttling)
set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie")
set_state(streaming=True, media_type="Movie", paused=False, local=False)
time.sleep(1.5)
assert get_throttling_state(), "Should re-enable throttling when unpaused"
assert is_throttled(), "Should re-throttle when unpaused"
'';
}