jellyfin-qbittorrent-monitor: take into account soulseek
This commit is contained in:
@@ -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.
BIN
secrets/server/slskd-api-key.age
Normal file
BIN
secrets/server/slskd-api-key.age
Normal file
Binary file not shown.
@@ -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}";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user