jellyfin-qbittorrent-monitor: handle qbittorrent going down state
This commit is contained in:
@@ -14,6 +14,12 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUnavailable(Exception):
|
||||||
|
"""Raised when a monitored service is temporarily unavailable."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class JellyfinQBittorrentMonitor:
|
class JellyfinQBittorrentMonitor:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -65,17 +71,25 @@ class JellyfinQBittorrentMonitor:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
def check_jellyfin_sessions(self) -> list[str]:
|
def check_jellyfin_sessions(self) -> list[str]:
|
||||||
"""Check if anyone is actively streaming from Jellyfin (external networks only)"""
|
headers = (
|
||||||
try:
|
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
||||||
headers = {"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{self.jellyfin_url}/Sessions", headers=headers, timeout=10
|
f"{self.jellyfin_url}/Sessions", headers=headers, timeout=10
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
sessions = response.json()
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to check Jellyfin sessions: {e}")
|
||||||
|
raise ServiceUnavailable(f"Jellyfin unavailable: {e}") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
sessions = response.json()
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse Jellyfin response: {e}")
|
||||||
|
raise ServiceUnavailable(f"Jellyfin returned invalid JSON: {e}") from e
|
||||||
|
|
||||||
# Count active streaming sessions (video only, external networks only)
|
|
||||||
active_streams = []
|
active_streams = []
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
if (
|
if (
|
||||||
@@ -84,7 +98,6 @@ class JellyfinQBittorrentMonitor:
|
|||||||
and not self.is_local_ip(session.get("RemoteEndPoint", ""))
|
and not self.is_local_ip(session.get("RemoteEndPoint", ""))
|
||||||
):
|
):
|
||||||
item = session["NowPlayingItem"]
|
item = session["NowPlayingItem"]
|
||||||
# Only count video streams (Movies, Episodes, etc.)
|
|
||||||
item_type = item.get("Type", "").lower()
|
item_type = item.get("Type", "").lower()
|
||||||
if item_type in ["movie", "episode", "video"]:
|
if item_type in ["movie", "episode", "video"]:
|
||||||
user = session.get("UserName", "Unknown")
|
user = session.get("UserName", "Unknown")
|
||||||
@@ -92,15 +105,7 @@ class JellyfinQBittorrentMonitor:
|
|||||||
|
|
||||||
return active_streams
|
return active_streams
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
def check_qbittorrent_alternate_limits(self) -> bool:
|
||||||
logger.error(f"Failed to check Jellyfin sessions: {e}")
|
|
||||||
return []
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"Failed to parse Jellyfin response: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def check_qbittorrent_alternate_limits(self):
|
|
||||||
"""Check if alternate speed limits are currently enabled"""
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
f"{self.qbittorrent_url}/api/v2/transfer/speedLimitsMode", timeout=10
|
f"{self.qbittorrent_url}/api/v2/transfer/speedLimitsMode", timeout=10
|
||||||
@@ -111,21 +116,20 @@ class JellyfinQBittorrentMonitor:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"SpeedLimitsMode endpoint returned HTTP {response.status_code}"
|
f"SpeedLimitsMode endpoint returned HTTP {response.status_code}"
|
||||||
)
|
)
|
||||||
|
raise ServiceUnavailable(
|
||||||
|
f"qBittorrent returned HTTP {response.status_code}"
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"SpeedLimitsMode endpoint failed: {e}")
|
logger.error(f"SpeedLimitsMode endpoint failed: {e}")
|
||||||
except Exception as e:
|
raise ServiceUnavailable(f"qBittorrent unavailable: {e}") from e
|
||||||
logger.error(f"Failed to parse speedLimitsMode response: {e}")
|
|
||||||
return self.throttle_active
|
|
||||||
|
|
||||||
def use_alt_limits(self, enable: bool) -> None:
|
def use_alt_limits(self, enable: bool) -> None:
|
||||||
"""Toggle qBittorrent alternate speed limits"""
|
|
||||||
action = "enabled" if enable else "disabled"
|
action = "enabled" if enable else "disabled"
|
||||||
try:
|
try:
|
||||||
current_throttle = self.check_qbittorrent_alternate_limits()
|
current_throttle = self.check_qbittorrent_alternate_limits()
|
||||||
|
|
||||||
if current_throttle == enable:
|
if current_throttle == enable:
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"Alternate speed limits already {action}, no action needed"
|
f"Alternate speed limits already {action}, no action needed"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -138,26 +142,37 @@ class JellyfinQBittorrentMonitor:
|
|||||||
|
|
||||||
self.throttle_active = enable
|
self.throttle_active = enable
|
||||||
|
|
||||||
# Verify the change took effect
|
|
||||||
new_state = self.check_qbittorrent_alternate_limits()
|
new_state = self.check_qbittorrent_alternate_limits()
|
||||||
if new_state == enable:
|
if new_state == enable:
|
||||||
logger.info(f"Activated {action} alternate speed limits")
|
logger.info(f"Alternate speed limits {action}")
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Toggle may have failed: expected {enable}, got {new_state}"
|
f"Toggle may have failed: expected {enable}, got {new_state}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except ServiceUnavailable:
|
||||||
|
logger.warning(
|
||||||
|
f"qBittorrent unavailable, cannot {action} alternate speed limits"
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"Failed to {action} alternate speed limits: {e}")
|
logger.error(f"Failed to {action} alternate speed limits: {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to toggle qBittorrent limits: {e}")
|
|
||||||
|
|
||||||
def restore_normal_limits(self) -> None:
|
def restore_normal_limits(self) -> None:
|
||||||
"""Ensure normal speed limits are restored on shutdown"""
|
|
||||||
if self.throttle_active:
|
if self.throttle_active:
|
||||||
logger.info("Restoring normal speed limits before shutdown...")
|
logger.info("Restoring normal speed limits before shutdown...")
|
||||||
self.use_alt_limits(False)
|
self.use_alt_limits(False)
|
||||||
|
|
||||||
|
def sync_qbittorrent_state(self) -> None:
|
||||||
|
try:
|
||||||
|
actual_state = self.check_qbittorrent_alternate_limits()
|
||||||
|
if actual_state != self.throttle_active:
|
||||||
|
logger.warning(
|
||||||
|
f"qBittorrent state mismatch detected: expected {self.throttle_active}, got {actual_state}. Re-syncing..."
|
||||||
|
)
|
||||||
|
self.use_alt_limits(self.throttle_active)
|
||||||
|
except ServiceUnavailable:
|
||||||
|
pass
|
||||||
|
|
||||||
def should_change_state(self, new_streaming_state: bool) -> bool:
|
def should_change_state(self, new_streaming_state: bool) -> bool:
|
||||||
"""Apply hysteresis to prevent rapid state changes"""
|
"""Apply hysteresis to prevent rapid state changes"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -192,24 +207,30 @@ class JellyfinQBittorrentMonitor:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Main monitoring loop"""
|
|
||||||
logger.info("Starting Jellyfin-qBittorrent monitor")
|
logger.info("Starting Jellyfin-qBittorrent monitor")
|
||||||
logger.info(f"Jellyfin URL: {self.jellyfin_url}")
|
logger.info(f"Jellyfin URL: {self.jellyfin_url}")
|
||||||
logger.info(f"qBittorrent URL: {self.qbittorrent_url}")
|
logger.info(f"qBittorrent URL: {self.qbittorrent_url}")
|
||||||
logger.info(f"Check interval: {self.check_interval}s")
|
logger.info(f"Check interval: {self.check_interval}s")
|
||||||
|
|
||||||
# Set up signal handlers
|
|
||||||
signal.signal(signal.SIGINT, self.signal_handler)
|
signal.signal(signal.SIGINT, self.signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# Check for active streaming
|
self.sync_qbittorrent_state()
|
||||||
|
|
||||||
|
try:
|
||||||
active_streams = self.check_jellyfin_sessions()
|
active_streams = self.check_jellyfin_sessions()
|
||||||
|
except ServiceUnavailable:
|
||||||
|
logger.warning(
|
||||||
|
"Jellyfin unavailable, maintaining current throttle state"
|
||||||
|
)
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
continue
|
||||||
|
|
||||||
streaming_active = len(active_streams) > 0
|
streaming_active = len(active_streams) > 0
|
||||||
|
|
||||||
if active_streams != self.last_active_streams:
|
if active_streams != self.last_active_streams:
|
||||||
# Log current status
|
|
||||||
if streaming_active:
|
if streaming_active:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Active streams ({len(active_streams)}): {', '.join(active_streams)}"
|
f"Active streams ({len(active_streams)}): {', '.join(active_streams)}"
|
||||||
@@ -217,7 +238,6 @@ class JellyfinQBittorrentMonitor:
|
|||||||
elif len(active_streams) == 0 and self.last_streaming_state:
|
elif len(active_streams) == 0 and self.last_streaming_state:
|
||||||
logger.info("No active streaming sessions")
|
logger.info("No active streaming sessions")
|
||||||
|
|
||||||
# Apply hysteresis and change state if needed
|
|
||||||
if self.should_change_state(streaming_active):
|
if self.should_change_state(streaming_active):
|
||||||
self.last_streaming_state = streaming_active
|
self.last_streaming_state = streaming_active
|
||||||
self.use_alt_limits(streaming_active)
|
self.use_alt_limits(streaming_active)
|
||||||
|
|||||||
@@ -257,5 +257,127 @@ pkgs.testers.runNixOSTest {
|
|||||||
|
|
||||||
local_playback["PositionTicks"] = 50000000
|
local_playback["PositionTicks"] = 50000000
|
||||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||||
|
|
||||||
|
# === SERVICE RESTART TESTS ===
|
||||||
|
|
||||||
|
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
||||||
|
# Start external playback to trigger throttling
|
||||||
|
playback_start = {
|
||||||
|
"ItemId": movie_id,
|
||||||
|
"MediaSourceId": media_source_id,
|
||||||
|
"PlaySessionId": "test-play-session-restart-1",
|
||||||
|
"CanSeek": True,
|
||||||
|
"IsPaused": False,
|
||||||
|
}
|
||||||
|
start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||||
|
client.succeed(start_cmd)
|
||||||
|
time.sleep(2)
|
||||||
|
assert is_throttled(), "Should be throttled before qBittorrent restart"
|
||||||
|
|
||||||
|
# Restart mock-qbittorrent (this resets alt_speed to False)
|
||||||
|
server.succeed("systemctl restart mock-qbittorrent.service")
|
||||||
|
server.wait_for_unit("mock-qbittorrent.service")
|
||||||
|
server.wait_for_open_port(8080)
|
||||||
|
|
||||||
|
# qBittorrent restarted - alt_speed is now False (default)
|
||||||
|
# The monitor should detect this and re-apply throttling
|
||||||
|
time.sleep(3) # Give monitor time to detect and re-apply
|
||||||
|
assert is_throttled(), "Monitor should re-apply throttling after qBittorrent restart"
|
||||||
|
|
||||||
|
# Stop playback to clean up
|
||||||
|
playback_stop = {
|
||||||
|
"ItemId": movie_id,
|
||||||
|
"MediaSourceId": media_source_id,
|
||||||
|
"PlaySessionId": "test-play-session-restart-1",
|
||||||
|
"PositionTicks": 50000000,
|
||||||
|
}
|
||||||
|
stop_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(playback_stop)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||||
|
client.succeed(stop_cmd)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
with subtest("qBittorrent restart during unthrottled state stays unthrottled"):
|
||||||
|
# Verify we're unthrottled (no active streams)
|
||||||
|
assert not is_throttled(), "Should be unthrottled before test"
|
||||||
|
|
||||||
|
# Restart mock-qbittorrent
|
||||||
|
server.succeed("systemctl restart mock-qbittorrent.service")
|
||||||
|
server.wait_for_unit("mock-qbittorrent.service")
|
||||||
|
server.wait_for_open_port(8080)
|
||||||
|
|
||||||
|
# Give monitor time to check state
|
||||||
|
time.sleep(3)
|
||||||
|
assert not is_throttled(), "Should remain unthrottled after qBittorrent restart with no streams"
|
||||||
|
|
||||||
|
with subtest("Jellyfin restart during throttled state maintains throttling"):
|
||||||
|
# Start external playback to trigger throttling
|
||||||
|
playback_start = {
|
||||||
|
"ItemId": movie_id,
|
||||||
|
"MediaSourceId": media_source_id,
|
||||||
|
"PlaySessionId": "test-play-session-restart-2",
|
||||||
|
"CanSeek": True,
|
||||||
|
"IsPaused": False,
|
||||||
|
}
|
||||||
|
start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||||
|
client.succeed(start_cmd)
|
||||||
|
time.sleep(2)
|
||||||
|
assert is_throttled(), "Should be throttled before Jellyfin restart"
|
||||||
|
|
||||||
|
# Restart Jellyfin
|
||||||
|
server.succeed("systemctl restart jellyfin.service")
|
||||||
|
server.wait_for_unit("jellyfin.service")
|
||||||
|
server.wait_for_open_port(8096)
|
||||||
|
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||||
|
|
||||||
|
# During Jellyfin restart, monitor can't reach Jellyfin
|
||||||
|
# After restart, sessions are cleared - monitor should eventually unthrottle
|
||||||
|
# But during the unavailability window, throttling should be maintained (fail-safe)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# Re-authenticate (old token invalid after restart)
|
||||||
|
client_auth_result = json.loads(client.succeed(
|
||||||
|
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'"
|
||||||
|
))
|
||||||
|
client_token = client_auth_result["AccessToken"]
|
||||||
|
|
||||||
|
# No active streams after Jellyfin restart, should eventually unthrottle
|
||||||
|
time.sleep(3)
|
||||||
|
assert not is_throttled(), "Should unthrottle after Jellyfin restart clears sessions"
|
||||||
|
|
||||||
|
with subtest("Monitor recovers after Jellyfin temporary unavailability"):
|
||||||
|
# Re-authenticate with fresh token
|
||||||
|
client_auth_result = json.loads(client.succeed(
|
||||||
|
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'"
|
||||||
|
))
|
||||||
|
client_token = client_auth_result["AccessToken"]
|
||||||
|
|
||||||
|
# Start playback
|
||||||
|
playback_start = {
|
||||||
|
"ItemId": movie_id,
|
||||||
|
"MediaSourceId": media_source_id,
|
||||||
|
"PlaySessionId": "test-play-session-restart-3",
|
||||||
|
"CanSeek": True,
|
||||||
|
"IsPaused": False,
|
||||||
|
}
|
||||||
|
start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||||
|
client.succeed(start_cmd)
|
||||||
|
time.sleep(2)
|
||||||
|
assert is_throttled(), "Should be throttled"
|
||||||
|
|
||||||
|
# Stop Jellyfin briefly (simulating temporary unavailability)
|
||||||
|
server.succeed("systemctl stop jellyfin.service")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# During unavailability, throttle state should be maintained (fail-safe)
|
||||||
|
assert is_throttled(), "Should maintain throttle during Jellyfin unavailability"
|
||||||
|
|
||||||
|
# Bring Jellyfin back
|
||||||
|
server.succeed("systemctl start jellyfin.service")
|
||||||
|
server.wait_for_unit("jellyfin.service")
|
||||||
|
server.wait_for_open_port(8096)
|
||||||
|
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||||
|
|
||||||
|
# After Jellyfin comes back, sessions are gone - should unthrottle
|
||||||
|
time.sleep(3)
|
||||||
|
assert not is_throttled(), "Should unthrottle after Jellyfin returns with no sessions"
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user