From 293d85b0b50ca0d15c3b19f3ef097d8407b5b6a5 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 15 May 2026 02:42:13 -0400 Subject: [PATCH] jellyfin-qbittorrent-monitor: take into account soulseek --- modules/server-age-secrets.nix | 6 + secrets/secrets.nix | Bin 3724 -> 3774 bytes secrets/server/slskd-api-key.age | Bin 0 -> 270 bytes .../jellyfin/jellyfin-qbittorrent-monitor.nix | 19 +- .../jellyfin/jellyfin-qbittorrent-monitor.py | 85 ++++++++- services/soulseek.nix | 5 + tests/jellyfin-qbittorrent-monitor.nix | 164 ++++++++++++++++++ 7 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 secrets/server/slskd-api-key.age 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 84d796bf34569375de2498a0847451c569345df8..82db933d2d81db7c147f4c875479c8117ee66b34 100644 GIT binary patch literal 3774 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 z�-(w#gknwea`t1o6rhum3;lFSF}iJ2l31d(r3Lsnb?+WP9naELJoSkxL6MmNfi% z?|S=_SGEqf-p$<2Vwm|Yn!PPzz19C33A?0K@h;WAtptvw-5d)di6Z=xBH_-%R27b>K0sAwm%o@RKe_PrM@=N zXLEXbz~kf%Iy?An+86x3CZhi4O}9{=)N|hj`T5T$KHO9M@B*(L^VHYj9hdjKkvLr3j)*dFKl0RqfywI92IibYozU#9&-}Lz16Q1fw799E6 zcJb;@&W9h3X6Gzs^!g?*zep=Ga{58;m=#Z$9x6X9c03j&B+j}wA`(`cfHGaXq_K?ZSOI)KXc@+Ih;&eY{im0H&$TB=b-*v zvm5&@E8hEgEo2M0={)0$$9%#6VS7A`e$86C`P;VRoD1D=oH{MO;jE$EPvJ!V#T9v* zKkf)jmz{QmF>QlM0-O4b5YwNbIzJ1yt`n>cyxcdjeQr#R(1z#YKWq&S9WuIITD0gt zC-Z`Lez%$@lyT%0hw|Bv--Zi z&Bym$d58IDE{b7VnJ(m2|G>dHs4Kc@ujf?G59>Z2dKYx_spp4Z&G#2Iy*0{nclbIb zpMkePS^swZBmZ}iMi!y7CtZ6JxqrU@f&9kYlb3k^R{j@kZaTF65a-c$6Lw)?PLa~> z$4;p8epq%`byv&llxFWT)7RWtUyPsMxN!T&TF#Q$RkaJYO#W>W&E~J`Ps8Vlg`3J=2f=kYYP7!IyU>x{P;5UzB5xd-@Liv#PzNz3#YhC%v_Q= zJ>+lZzv(86my2vX-*8sF;H~#o`6)t^Tz03J-f7!u7iTiH;NAN6zH_&?ZQhde>89SL z122{Mr6yZs6pHzz+CM9OpUIm1Xr_@wZ1|g2_dl1;uGPLUJ9C}^(-qktDu+1!_Gp=} z*gNmx^?$2{nx{$&h-a`1u=US5nI|GXW6k{uH|H$7)~#WBA?9Cl^-WzVvEvJtr7aOT zzFgi;OZMvP119_aEerj^(0|GO=YHn7>!jx2Shap?bo-sOE2Zoc-kg2XD0b^X_nF&A z8UBjcX+@-jdoNG_wIg=6Mf#_Q(Gseb*bYGm)%2 zSxz!{$!m2ND4U;~Qu1;!(^qasRl_4zjGOq}n6fwZF4X+JB}C8SuzG&T$r}oWnytq- ztO+;&ec|U$g1Y?pH@AI2(bZb}JhnbBR7nXV_?2dAqoX%1&=2)gv z)xKq8Yaes6=Mn8N{SDzy*SeS9YFFXC*}9kC>tgX*vnP2$?8hI~oozVQSZ^O26Bs7K zC{bLLr?AfDZttq=i{($Bh$=YS{Y=wp=JwTPw@$KO{%*A9(ei(Pw>cj6RyzDK@9KpT rkIwM@x{`)Mf_L+NZk{h_V7IfXb;9499SnCl1tL`fQrGi)_4fe)TMc;x literal 3724 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 diff --git a/secrets/server/slskd-api-key.age b/secrets/server/slskd-api-key.age new file mode 100644 index 0000000000000000000000000000000000000000..4ca0b07326d323188abb54e500a2053495101901 GIT binary patch literal 270 zcmZQ@_Y83kiVO&0xa7y<*AXqb-Y-Rxab@AU+{jr4O>Zu6A5T^c+WPrU%`VS>*9_Q0 z_qVNl?($kLce2OYbBT)V>EDbv)|&}uOuQWTzVgn+db7)tdpOhnglL#s2hEiYSN!eM zboXtIa`flS?QQn=dD*`*tu@-!Xus#E*^J-UnkT9>$A~{Yr1NC?Nt=pS6S{Y_inp+u zr&TDGimdJW%AY%-@$nYFCobI|E^l>K@!BPKjBmQUW$K$HmO)oEM3-GlcVRv7lyCom z=&pU-IeX6UZ7pm3BKrTLwYX@||AyJgB2W7s$gSl|Xb|jrym4#yEa|EYo+V%EKC#Qy et@|Nf)-L-~w&Ucob>>E4^J_!uH*NBn90LG=N`T)0 literal 0 HcmV?d00001 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) ''; }