jellyfin-qbittorrent-monitor: write proper test

This commit is contained in:
Simon Gardling 2025-10-24 00:12:42 -04:00
parent b1b9a3755f
commit e9c1df44e8
Signed by: titaniumtown
GPG Key ID: 9AB28AC10ECE533D
5 changed files with 251 additions and 40 deletions

View File

@ -28,6 +28,7 @@
./services/wg.nix ./services/wg.nix
./services/qbittorrent.nix ./services/qbittorrent.nix
./services/jellyfin-qbittorrent-monitor.nix
./services/bitmagnet.nix ./services/bitmagnet.nix
./services/soulseek.nix ./services/soulseek.nix

View File

@ -0,0 +1,48 @@
{
pkgs,
service_configs,
config,
...
}:
{
systemd.services."jellyfin-qbittorrent-monitor" = {
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
after = [
"network.target"
"jellyfin.service"
"qbittorrent.service"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" ''
export JELLYFIN_API_KEY=$(cat ${config.age.secrets.jellyfin-api-key.path})
exec ${
pkgs.python3.withPackages (ps: with ps; [ requests ])
}/bin/python ${./jellyfin-qbittorrent-monitor.py}
'';
Restart = "always";
RestartSec = "10s";
# Security hardening
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
};
environment = {
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
QBITTORRENT_URL = "http://${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.torrent}";
CHECK_INTERVAL = "30";
};
};
}

View File

@ -15,44 +15,4 @@
]; ];
}; };
systemd.services."jellyfin-qbittorrent-monitor" = {
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
after = [
"network.target"
"jellyfin.service"
"qbittorrent.service"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" ''
export JELLYFIN_API_KEY=$(cat ${config.age.secrets.jellyfin-api-key.path})
exec ${
pkgs.python3.withPackages (ps: with ps; [ requests ])
}/bin/python ${./jellyfin-qbittorrent-monitor.py}
'';
Restart = "always";
RestartSec = "10s";
# Security hardening
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
};
environment = {
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
QBITTORRENT_URL = "http://${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.torrent}";
CHECK_INTERVAL = "30";
};
};
} }

View File

@ -0,0 +1,201 @@
{
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" { } ''
import http.server
import socketserver
import os
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()
# Check if we should simulate streaming
streaming_file = '/tmp/jellyfin_streaming'
if os.path.exists(streaming_file):
# Simulate external streaming session
sessions = [{
'Id': 'test-session-1',
'UserName': 'ExternalUser',
'RemoteEndPoint': '203.0.113.42', # External IP
'NowPlayingItem': {
'Name': 'Test Movie',
'Type': 'Movie'
},
'PlayState': {
'IsPaused': False
}
}]
else:
# No streaming sessions
sessions = []
self.wfile.write(json.dumps(sessions).encode())
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
time.sleep(5)
# TEST 1: Verify initial state - no streaming, normal speed limits
qbt_result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode")
assert qbt_result.strip() == "0", "qBittorrent should start with normal speed limits"
# Verify no streaming sessions initially
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
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 \\
{python_path} {monitor_path}
""")
# Wait for monitor to start
time.sleep(3)
# TEST 2: Verify monitor runs and keeps normal limits with no streaming
qbt_result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode")
assert qbt_result.strip() == "0", "Should maintain normal speed limits with no streaming"
# TEST 3: Simulate external streaming and verify throttling
print("\\nSimulating external streaming session...")
server.succeed("touch /tmp/jellyfin_streaming") # Signal mock to return streaming session
# Wait for monitor to detect streaming and apply throttling (includes hysteresis delay)
time.sleep(15)
qbt_result_streaming = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode")
# Check if throttling was enabled
assert qbt_result_streaming.strip() == "1", "External streaming should enable qBittorrent throttling"
# TEST 4: Stop streaming and verify throttling is removed
print("\\nStopping streaming session...")
server.succeed("rm -f /tmp/jellyfin_streaming") # Signal mock to return no sessions
# Wait for monitor to detect no streaming and remove throttling (includes hysteresis delay)
time.sleep(65)
qbt_result_no_streaming = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode")
assert qbt_result_no_streaming.strip() == "0", "No streaming should disable qBittorrent throttling"
'';
}

View File

@ -11,4 +11,5 @@ in
zfsTest = handleTest ./zfs.nix; zfsTest = handleTest ./zfs.nix;
testTest = handleTest ./testTest.nix; testTest = handleTest ./testTest.nix;
minecraftTest = handleTest ./minecraft.nix; minecraftTest = handleTest ./minecraft.nix;
jellyfinQbittorrentMonitorTest = handleTest ./jellyfin-qbittorrent-monitor.nix;
} }