#!/usr/bin/env python3 import requests import time import logging import sys import signal import json logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) class JellyfinQBittorrentMonitor: def __init__( self, jellyfin_url="http://localhost:8096", qbittorrent_url="http://localhost:8080", check_interval=30, jellyfin_api_key=None, ): self.jellyfin_url = jellyfin_url self.qbittorrent_url = qbittorrent_url self.check_interval = check_interval self.jellyfin_api_key = jellyfin_api_key self.last_streaming_state = None self.throttle_active = False self.running = True self.session = requests.Session() # Use session for cookies self.last_active_streams = [] # Hysteresis settings to prevent rapid switching self.streaming_start_delay = 10 self.streaming_stop_delay = 60 self.last_state_change = 0 def signal_handler(self, signum, frame): logger.info("Received shutdown signal, cleaning up...") self.running = False self.restore_normal_limits() sys.exit(0) def check_jellyfin_sessions(self) -> list[str]: """Check if anyone is actively streaming from Jellyfin""" try: headers = {} if self.jellyfin_api_key: headers["X-Emby-Token"] = self.jellyfin_api_key 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) active_streams = [] for session in sessions: if ( "NowPlayingItem" in session and session.get("PlayState", {}).get("IsPaused", True) == False ): 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 [] 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: response = self.session.get( f"{self.qbittorrent_url}/api/v2/transfer/speedLimitsMode", timeout=10 ) if response.status_code == 200: return response.text.strip() == "1" else: logger.warning( f"SpeedLimitsMode endpoint 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 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" ) return response = self.session.post( f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode", timeout=10, ) response.raise_for_status() 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") else: logger.warning( f"Toggle may have failed: expected {enable}, got {new_state}" ) 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 should_change_state(self, new_streaming_state: bool) -> bool: """Apply hysteresis to prevent rapid state changes""" now = time.time() if new_streaming_state == self.last_streaming_state: return False time_since_change = now - self.last_state_change # Start throttling (streaming started) if new_streaming_state and not self.last_streaming_state: if time_since_change >= self.streaming_start_delay: self.last_state_change = now return True else: remaining = self.streaming_start_delay - time_since_change logger.info( f"Streaming started - waiting {remaining:.1f}s before enabling throttling" ) # Stop throttling (streaming stopped) elif not new_streaming_state and self.last_streaming_state: if time_since_change >= self.streaming_stop_delay: self.last_state_change = now return True else: remaining = self.streaming_stop_delay - time_since_change logger.info( f"Streaming stopped - waiting {remaining:.1f}s before disabling throttling" ) 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() 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)}" ) 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) self.last_active_streams = active_streams time.sleep(self.check_interval) except KeyboardInterrupt: break except Exception as e: logger.error(f"Unexpected error in monitoring loop: {e}") time.sleep(self.check_interval) self.restore_normal_limits() logger.info("Monitor stopped") if __name__ == "__main__": import os # Configuration from environment variables jellyfin_url = os.getenv("JELLYFIN_URL", "http://localhost:8096") qbittorrent_url = os.getenv("QBITTORRENT_URL", "http://localhost:8080") check_interval = int(os.getenv("CHECK_INTERVAL", "30")) jellyfin_api_key = os.getenv("JELLYFIN_API_KEY") monitor = JellyfinQBittorrentMonitor( jellyfin_url=jellyfin_url, qbittorrent_url=qbittorrent_url, check_interval=check_interval, jellyfin_api_key=jellyfin_api_key, ) monitor.run()