309 lines
13 KiB
Nix
309 lines
13 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
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
|
|
];
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
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")
|
|
|
|
# Wait for services to be accessible
|
|
server.wait_for_open_port(8096) # Mock Jellyfin
|
|
server.wait_for_open_port(8080) # Mock qBittorrent
|
|
|
|
import time
|
|
import json
|
|
|
|
time.sleep(5)
|
|
|
|
# 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}"
|
|
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=2 \\
|
|
--setenv=STREAMING_START_DELAY=2 \\
|
|
--setenv=STREAMING_STOP_DELAY=3 \\
|
|
{python_path} {monitor_path}
|
|
""")
|
|
|
|
time.sleep(3)
|
|
|
|
# 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
|
|
|
|
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(6) # 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"]
|
|
)
|
|
|
|
if should_throttle:
|
|
assert throttling_active, f"Expected throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}"
|
|
else:
|
|
assert not throttling_active, f"Expected no throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}"
|
|
|
|
set_jellyfin_state(streaming=False)
|
|
time.sleep(7) # 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")
|
|
time.sleep(6)
|
|
assert get_throttling_state(), "Should enable throttling for external Movie"
|
|
|
|
# Switch to paused (should disable throttling)
|
|
set_jellyfin_state(streaming=True, paused=True, local=False, media_type="Movie")
|
|
time.sleep(6)
|
|
assert not get_throttling_state(), "Should disable throttling when paused"
|
|
|
|
# Switch back to playing (should re-enable throttling)
|
|
set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie")
|
|
time.sleep(6)
|
|
assert get_throttling_state(), "Should re-enable throttling when unpaused"
|
|
'';
|
|
}
|