diff --git a/modules/server-age-secrets.nix b/modules/server-age-secrets.nix index 3cb5c09..66e249c 100644 --- a/modules/server-age-secrets.nix +++ b/modules/server-age-secrets.nix @@ -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; diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 84d796b..82db933 100644 Binary files a/secrets/secrets.nix and b/secrets/secrets.nix differ diff --git a/secrets/server/slskd-api-key.age b/secrets/server/slskd-api-key.age new file mode 100644 index 0000000..4ca0b07 Binary files /dev/null and b/secrets/server/slskd-api-key.age differ diff --git a/services/jellyfin/jellyfin-qbittorrent-monitor.nix b/services/jellyfin/jellyfin-qbittorrent-monitor.nix index 6a167c4..856ace0 100644 --- a/services/jellyfin/jellyfin-qbittorrent-monitor.nix +++ b/services/jellyfin/jellyfin-qbittorrent-monitor.nix @@ -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}"; }; }; } diff --git a/services/jellyfin/jellyfin-qbittorrent-monitor.py b/services/jellyfin/jellyfin-qbittorrent-monitor.py index a80e08a..51257c3 100644 --- a/services/jellyfin/jellyfin-qbittorrent-monitor.py +++ b/services/jellyfin/jellyfin-qbittorrent-monitor.py @@ -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() diff --git a/services/soulseek.nix b/services/soulseek.nix index 3158f29..63a42e5 100644 --- a/services/soulseek.nix +++ b/services/soulseek.nix @@ -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"; diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 55127f6..150337e 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -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) ''; }