jellyfin-qbittorrent-monitor: take into account soulseek
All checks were successful
Build and Deploy / mreow (push) Successful in 2m3s
Build and Deploy / yarn (push) Successful in 1m3s
Build and Deploy / muffin (push) Successful in 1m15s

This commit is contained in:
2026-05-15 02:42:13 -04:00
parent 9662745d6e
commit 293d85b0b5
7 changed files with 273 additions and 6 deletions

View File

@@ -68,6 +68,12 @@
owner = "root";
group = "root";
};
slskd-api-key = lib.mkIf config.services.slskd.enable {
file = ../secrets/server/slskd-api-key.age;
mode = "0400";
owner = "root";
group = "root";
};
slskd_env = {
file = ../secrets/server/slskd_env.age;

Binary file not shown.

Binary file not shown.

View File

@@ -77,14 +77,21 @@ lib.mkIf config.services.jellyfin.enable {
"jellyfin.service"
"qbittorrent.service"
"jellyfin-webhook-configure.service"
];
wants = [ "jellyfin-webhook-configure.service" ];
]
++ lib.optional config.services.slskd.enable "slskd.service";
wants = [
"jellyfin-webhook-configure.service"
]
++ lib.optional config.services.slskd.enable "slskd.service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" ''
export JELLYFIN_API_KEY=$(cat $CREDENTIALS_DIRECTORY/jellyfin-api-key)
${lib.optionalString config.services.slskd.enable ''
export SLSKD_API_KEY=$(cat $CREDENTIALS_DIRECTORY/slskd-api-key)
''}
exec ${
pkgs.python3.withPackages (ps: with ps; [ requests ])
}/bin/python ${./jellyfin-qbittorrent-monitor.py}
@@ -106,7 +113,10 @@ lib.mkIf config.services.jellyfin.enable {
RemoveIPC = true;
# Load credentials from agenix secrets
LoadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
LoadCredential = [
"jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}"
]
++ lib.optional config.services.slskd.enable "slskd-api-key:${config.age.secrets.slskd-api-key.path}";
};
environment = {
@@ -122,6 +132,9 @@ lib.mkIf config.services.jellyfin.enable {
# Webhook receiver: Jellyfin Webhook plugin POSTs events here to throttle immediately.
WEBHOOK_BIND = "127.0.0.1";
WEBHOOK_PORT = toString webhookPort;
}
// lib.optionalAttrs config.services.slskd.enable {
SLSKD_URL = "http://127.0.0.1:${builtins.toString service_configs.ports.private.soulseek_web.port}";
};
};
}

View File

@@ -39,6 +39,8 @@ class JellyfinQBittorrentMonitor:
webhook_port=0,
webhook_bind="127.0.0.1",
gateway_ip=None,
slskd_url=None,
slskd_api_key=None,
):
self.jellyfin_url = jellyfin_url
self.qbittorrent_url = qbittorrent_url
@@ -69,6 +71,11 @@ class JellyfinQBittorrentMonitor:
self.wake_event = threading.Event()
self.webhook_server = None
# Soulseek (slskd) upload monitoring — optional.
# When slskd is present, active upload bandwidth is subtracted from the
# torrent budget alongside Jellyfin streaming. No soulseek URL → no-op.
self.slskd_url = slskd_url
self.slskd_api_key = slskd_api_key
# Local network ranges (RFC 1918 private networks + localhost)
self.local_networks = [
ipaddress.ip_network("10.0.0.0/8"),
@@ -232,6 +239,66 @@ class JellyfinQBittorrentMonitor:
active_streams.append({"name": stream_name, "bitrate_bps": bitrate})
return active_streams
def check_soulseek_uploads(self) -> int:
"""Return total active upload bandwidth from slskd in bits per second.
Returns 0 when slskd is not configured, unreachable, or has no in-
progress uploads. The slskd REST API returns averageSpeed in bytes/s
per Transfer; we sum across all InProgress uploads and convert to bps.
"""
if not self.slskd_url:
return 0
headers = {}
if self.slskd_api_key:
headers["X-API-Key"] = self.slskd_api_key
try:
response = self.session.get(
f"{self.slskd_url}/api/v0/transfers/uploads",
headers=headers,
timeout=10,
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else "?"
# 401/403 mean our key is wrong or missing — a configuration error
# that needs operator attention. Log loudly so it's not silently ignored.
if status in (401, 403):
logger.error(
f"slskd auth rejected (HTTP {status}). "
f"Verify slskd-api-key agenix secret matches "
f"services.slskd.settings.web.authentication.api_keys.monitor.key"
)
else:
logger.warning(f"slskd HTTP error {status}: {e}")
return 0
except requests.exceptions.RequestException as e:
logger.warning(f"Failed to query slskd uploads: {e}")
return 0
try:
uploads = response.json()
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse slskd uploads response: {e}")
return 0
total_bps = 0
for user in uploads:
for directory in user.get("directories", []):
for transfer in directory.get("files", []):
if transfer.get("state") == "InProgress":
# averageSpeed is in bytes/s
avg_speed = transfer.get("averageSpeed", 0) or 0
total_bps += int(avg_speed * 8)
if total_bps > 0:
logger.debug(
f"Soulseek uploads: {total_bps} bps "
f"({total_bps / 8 / 1024:.0f} KB/s)"
)
return total_bps
def check_qbittorrent_alternate_limits(self) -> bool:
try:
@@ -415,6 +482,8 @@ class JellyfinQBittorrentMonitor:
)
if self.webhook_port:
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
if self.slskd_url:
logger.info(f"Soulseek (slskd) URL: {self.slskd_url}")
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
@@ -458,13 +527,18 @@ class JellyfinQBittorrentMonitor:
total_streaming_bps = sum(
stream["bitrate_bps"] for stream in active_streams
)
# Soulseek uploads also consume WAN bandwidth — subtract from
# the torrent budget alongside Jellyfin streams.
soulseek_upload_bps = self.check_soulseek_uploads()
total_consumer_bps = total_streaming_bps + soulseek_upload_bps
remaining_bps = (
self.total_bandwidth_budget
- self.service_buffer
- total_streaming_bps
- total_consumer_bps
)
remaining_kbs = max(0, remaining_bps) / 8 / 1024
if not streaming_state:
desired_state = "unlimited"
elif streaming_active:
@@ -487,11 +561,12 @@ class JellyfinQBittorrentMonitor:
action = "pause torrents"
logger.info(
"State change %s -> %s | streams=%d total_bps=%d remaining_bps=%d action=%s",
"State change %s -> %s | streams=%d jellyfin_bps=%d slskd_bps=%d remaining_bps=%d action=%s",
self.current_state,
desired_state,
len(active_streams),
total_streaming_bps,
soulseek_upload_bps,
remaining_bps,
action,
)
@@ -544,6 +619,8 @@ if __name__ == "__main__":
webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
gateway_ip = os.getenv("LAN_GATEWAY_IP") or None
slskd_url = os.getenv("SLSKD_URL") or None
slskd_api_key = os.getenv("SLSKD_API_KEY") or None
monitor = JellyfinQBittorrentMonitor(
jellyfin_url=jellyfin_url,
@@ -560,6 +637,8 @@ if __name__ == "__main__":
webhook_port=webhook_port,
webhook_bind=webhook_bind,
gateway_ip=gateway_ip,
slskd_url=slskd_url,
slskd_api_key=slskd_api_key,
)
monitor.run()

View File

@@ -35,6 +35,11 @@
settings = {
web = {
port = service_configs.ports.private.soulseek_web.port;
authentication.api_keys.monitor = {
key = "slskd-monitor-readonly-key-localhost";
role = "readonly";
cidr = "127.0.0.1/32";
};
};
soulseek = {
# description = "smth idk";

View File

@@ -808,5 +808,169 @@ pkgs.testers.runNixOSTest {
# After Jellyfin comes back, sessions are gone - should unthrottle
time.sleep(3)
assert not is_throttled(), "Should unthrottle after Jellyfin returns with no sessions"
# === SLSKD UPLOAD BANDWIDTH TESTS ===
# Spin up a mock slskd API server that simulates active uploads. The
# monitor should query it and subtract upload bandwidth from the torrent
# budget alongside Jellyfin streaming.
mock_slskd_port = 9999
# Start a mock slskd server. It checks the X-API-Key header to verify
# the monitor sends credentials correctly, and returns 401 if they mismatch.
mock_slskd_key = "test-slskd-api-key"
server.succeed(
f"{pkgs.python3}/bin/python -c \""
"import json, http.server, threading;"
f"EXPECTED_KEY='{mock_slskd_key}';"
"class H(http.server.BaseHTTPRequestHandler):"
" uploads_response=[];"
" def do_GET(s):"
" if s.path=='/api/v0/transfers/uploads':"
" if s.headers.get('X-API-Key')!=EXPECTED_KEY:"
" s.send_error(401);"
" return;"
" s.send_response(200);"
" s.send_header('Content-Type','application/json');"
" s.end_headers();"
" s.wfile.write(json.dumps(H.uploads_response).encode());"
" else: s.send_error(404);"
" def log_message(*a): pass;"
f"server=http.server.HTTPServer(('127.0.0.1',{mock_slskd_port}),H);"
"threading.Thread(target=server.serve_forever,daemon=True).start();"
"import signal; signal.pause()\" &"
)
server.wait_for_open_port(mock_slskd_port)
server.succeed(f"curl -sf http://127.0.0.1:{mock_slskd_port}/api/v0/transfers/uploads")
# Stop the normal monitor and start one pointed at the mock slskd
server.succeed("systemctl stop monitor-test || true")
time.sleep(1)
server.succeed(f"""
systemd-run --unit=monitor-slskd \
--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 \
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
--setenv=SERVICE_BUFFER=2000000 \
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
--setenv=MIN_TORRENT_SPEED=100 \
--setenv=SLSKD_URL=http://127.0.0.1:{mock_slskd_port} \
--setenv=SLSKD_API_KEY={mock_slskd_key} \
{python} {monitor}
""")
time.sleep(2)
assert not is_throttled(), "Should start unthrottled (slskd reports no uploads)"
with subtest("Slskd uploads reduce torrent bandwidth budget"):
# Re-authenticate to get fresh token after previous tests
client_auth_result = json.loads(client.succeed(
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' "
f"-d '@${jfLib.payloads.auth}' "
"-H 'Content-Type:application/json' "
f"-H 'X-Emby-Authorization:{client_auth}'"
))
client_token = client_auth_result["AccessToken"]
# Start a single Jellyfin stream to establish a baseline torrent limit
playback_start = {{
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-slskd",
"CanSeek": True,
"IsPaused": False,
}}
start_cmd = (
f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' "
f"-d '{json.dumps(playback_start)}' "
"-H 'Content-Type:application/json' "
f"-H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
)
client.succeed(start_cmd)
time.sleep(3)
assert is_throttled(), "Should throttle with streaming"
baseline_dl = get_alt_dl_limit()
# Now simulate active slskd uploads consuming 15 Mbps (1,875,000 bytes/s)
# by restarting the mock with two in-progress uploads at 937,500 bytes/s each.
server.succeed("pkill -f 'H(http' || true")
time.sleep(1)
# Restart the mock server with upload data; still validates the API key.
server.succeed(
f"{pkgs.python3}/bin/python -c \""
"import json, http.server, threading;"
f"EXPECTED_KEY='{mock_slskd_key}';"
"class H(http.server.BaseHTTPRequestHandler):"
" uploads_response=[{'username':'peer1','directories':[{'directory':'music',"
"'fileCount':2,'files':["
"{'averageSpeed':937500,'state':'InProgress','filename':'t1.flac','size':30000000,'username':'peer1'},"
"{'averageSpeed':937500,'state':'InProgress','filename':'t2.flac','size':25000000,'username':'peer1'}"
"]}]];"
" def do_GET(s):"
" if s.path=='/api/v0/transfers/uploads':"
" if s.headers.get('X-API-Key')!=EXPECTED_KEY:"
" s.send_error(401);"
" return;"
" s.send_response(200);"
" s.send_header('Content-Type','application/json');"
" s.end_headers();"
" s.wfile.write(json.dumps(H.uploads_response).encode());"
" else: s.send_error(404);"
" def log_message(*a): pass;"
f"server=http.server.HTTPServer(('127.0.0.1',{mock_slskd_port}),H);"
"threading.Thread(target=server.serve_forever,daemon=True).start();"
"import signal; signal.pause()\" &"
)
server.wait_for_open_port(mock_slskd_port)
time.sleep(4) # Let monitor poll and adjust
slskd_dl = get_alt_dl_limit()
# With 15 Mbps of slskd uploads consuming the shared budget, the
# remaining bandwidth for torrents must be lower than baseline.
assert slskd_dl < baseline_dl, \
f"Slskd uploads should reduce torrent budget. baseline={baseline_dl}, with_slskd={slskd_dl}"
# Stop Jellyfin playback
playback_stop = {{
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-slskd",
"PositionTicks": 50000000,
}}
stop_cmd = (
f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' "
f"-d '{json.dumps(playback_stop)}' "
"-H 'Content-Type:application/json' "
f"-H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
)
client.succeed(stop_cmd)
time.sleep(3)
# Clean up: stop mock slskd and restore normal monitor
server.succeed("pkill -f 'H(http' || true")
server.succeed("systemctl stop monitor-slskd || true")
time.sleep(1)
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 \
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
--setenv=SERVICE_BUFFER=2000000 \
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
--setenv=MIN_TORRENT_SPEED=100 \
{python} {monitor}
""")
time.sleep(2)
'';
}