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

@@ -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()