diff --git a/services/jellyfin-qbittorrent-monitor.py b/services/jellyfin-qbittorrent-monitor.py index b749e27..cb3214b 100644 --- a/services/jellyfin-qbittorrent-monitor.py +++ b/services/jellyfin-qbittorrent-monitor.py @@ -14,6 +14,12 @@ logging.basicConfig( logger = logging.getLogger(__name__) +class ServiceUnavailable(Exception): + """Raised when a monitored service is temporarily unavailable.""" + + pass + + class JellyfinQBittorrentMonitor: def __init__( self, @@ -65,42 +71,39 @@ class JellyfinQBittorrentMonitor: sys.exit(0) def check_jellyfin_sessions(self) -> list[str]: - """Check if anyone is actively streaming from Jellyfin (external networks only)""" - try: - headers = {"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( f"{self.jellyfin_url}/Sessions", headers=headers, timeout=10 ) response.raise_for_status() - sessions = response.json() - - # Count active streaming sessions (video only, external networks only) - active_streams = [] - for session in sessions: - if ( - "NowPlayingItem" in session - and not session.get("PlayState", {}).get("IsPaused", True) - and not self.is_local_ip(session.get("RemoteEndPoint", "")) - ): - item = session["NowPlayingItem"] - # Only count video streams (Movies, Episodes, etc.) - item_type = item.get("Type", "").lower() - if item_type in ["movie", "episode", "video"]: - user = session.get("UserName", "Unknown") - active_streams.append(f"{user}: {item.get('Name', 'Unknown')}") - - return active_streams - except requests.exceptions.RequestException as e: logger.error(f"Failed to check Jellyfin sessions: {e}") - return [] + 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}") - return [] + raise ServiceUnavailable(f"Jellyfin returned invalid JSON: {e}") from e - def check_qbittorrent_alternate_limits(self): - """Check if alternate speed limits are currently enabled""" + active_streams = [] + for session in sessions: + if ( + "NowPlayingItem" in session + and not session.get("PlayState", {}).get("IsPaused", True) + and not self.is_local_ip(session.get("RemoteEndPoint", "")) + ): + item = session["NowPlayingItem"] + item_type = item.get("Type", "").lower() + if item_type in ["movie", "episode", "video"]: + user = session.get("UserName", "Unknown") + active_streams.append(f"{user}: {item.get('Name', 'Unknown')}") + + return active_streams + + def check_qbittorrent_alternate_limits(self) -> bool: try: response = self.session.get( f"{self.qbittorrent_url}/api/v2/transfer/speedLimitsMode", timeout=10 @@ -111,23 +114,18 @@ class JellyfinQBittorrentMonitor: logger.warning( f"SpeedLimitsMode endpoint returned HTTP {response.status_code}" ) - + raise ServiceUnavailable(f"qBittorrent returned HTTP {response.status_code}") except requests.exceptions.RequestException as e: logger.error(f"SpeedLimitsMode endpoint failed: {e}") - except Exception as e: - logger.error(f"Failed to parse speedLimitsMode response: {e}") - return self.throttle_active + raise ServiceUnavailable(f"qBittorrent unavailable: {e}") from e def use_alt_limits(self, enable: bool) -> None: - """Toggle qBittorrent alternate speed limits""" action = "enabled" if enable else "disabled" try: current_throttle = self.check_qbittorrent_alternate_limits() if current_throttle == enable: - logger.info( - f"Alternate speed limits already {action}, no action needed" - ) + logger.debug(f"Alternate speed limits already {action}, no action needed") return response = self.session.post( @@ -138,26 +136,33 @@ class JellyfinQBittorrentMonitor: self.throttle_active = enable - # Verify the change took effect new_state = self.check_qbittorrent_alternate_limits() if new_state == enable: - logger.info(f"Activated {action} alternate speed limits") + logger.info(f"Alternate speed limits {action}") else: - logger.warning( - f"Toggle may have failed: expected {enable}, got {new_state}" - ) + logger.warning(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: 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: - """Ensure normal speed limits are restored on shutdown""" if self.throttle_active: logger.info("Restoring normal speed limits before shutdown...") 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: """Apply hysteresis to prevent rapid state changes""" now = time.time() @@ -192,24 +197,28 @@ class JellyfinQBittorrentMonitor: return False def run(self): - """Main monitoring loop""" logger.info("Starting Jellyfin-qBittorrent monitor") logger.info(f"Jellyfin URL: {self.jellyfin_url}") logger.info(f"qBittorrent URL: {self.qbittorrent_url}") logger.info(f"Check interval: {self.check_interval}s") - # Set up signal handlers signal.signal(signal.SIGINT, self.signal_handler) signal.signal(signal.SIGTERM, self.signal_handler) while self.running: try: - # Check for active streaming - active_streams = self.check_jellyfin_sessions() + self.sync_qbittorrent_state() + + try: + 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 if active_streams != self.last_active_streams: - # Log current status if streaming_active: logger.info( f"Active streams ({len(active_streams)}): {', '.join(active_streams)}" @@ -217,7 +226,6 @@ class JellyfinQBittorrentMonitor: elif len(active_streams) == 0 and self.last_streaming_state: logger.info("No active streaming sessions") - # Apply hysteresis and change state if needed if self.should_change_state(streaming_active): self.last_streaming_state = streaming_active self.use_alt_limits(streaming_active) diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 0d74119..281bf61 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -257,5 +257,127 @@ pkgs.testers.runNixOSTest { 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}'") + + # === 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" ''; }