jellyfin-qbittorrent-monitor: take into account soulseek
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user