#!/usr/bin/env python3 import requests import time import logging from datetime import datetime 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 # Hysteresis settings to prevent rapid switching self.streaming_start_delay = 10 # seconds to wait before throttling self.streaming_stop_delay = 60 # seconds to wait before removing throttle self.last_state_change = 0 # Try to authenticate with qBittorrent self.authenticate_qbittorrent() def signal_handler(self, signum, frame): logger.info("Received shutdown signal, cleaning up...") self.running = False self.restore_normal_limits() sys.exit(0) def authenticate_qbittorrent(self): """Try to authenticate with qBittorrent using empty credentials (for whitelist)""" logger.info("Attempting to authenticate with qBittorrent...") try: # First, try to access a simple endpoint to see if auth is needed test_response = self.session.get( f"{self.qbittorrent_url}/api/v2/app/version", timeout=5 ) logger.info( f"Version endpoint test: HTTP {test_response.status_code}, Response: {test_response.text}" ) if test_response.status_code == 200: logger.info( "qBittorrent accessible without explicit login - subnet whitelist working" ) return True except Exception as e: logger.info(f"Version endpoint failed: {e}") try: # Try login with empty credentials (should work with subnet whitelist) login_data = {"username": "", "password": ""} headers = { "Referer": self.qbittorrent_url, "Content-Type": "application/x-www-form-urlencoded", } logger.info(f"Attempting login to {self.qbittorrent_url}/login") response = self.session.post( f"{self.qbittorrent_url}/login", data=login_data, headers=headers, timeout=10, ) logger.info( f"Login response: HTTP {response.status_code}, Response: '{response.text}'" ) if response.status_code == 200: if "Ok." in response.text or response.text.strip() == "Ok.": logger.info("Successfully authenticated with qBittorrent") return True elif "Fails." in response.text: logger.warning( "qBittorrent login failed - authentication may be required" ) else: logger.info(f"Unexpected login response: '{response.text}'") else: logger.warning(f"Login request failed with HTTP {response.status_code}") except Exception as e: logger.error(f"Could not authenticate with qBittorrent: {e}") return False def check_jellyfin_sessions(self): """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 active_streams = [] for session in sessions: if ( "NowPlayingItem" in session and session.get("PlayState", {}).get("IsPaused", True) == False ): item = session["NowPlayingItem"] 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""" # For qBittorrent v5.1.0, use API v2 with GET requests try: # Try the transfer info endpoint first (more reliable) response = self.session.get( f"{self.qbittorrent_url}/api/v2/transfer/info", timeout=10 ) logger.info(f"Transfer info endpoint: HTTP {response.status_code}") if response.status_code == 200: data = response.json() logger.info(f"Transfer info keys: {list(data.keys())}") # Check for alternative speed limit status in the response if "use_alt_speed_limits" in data: is_enabled = data["use_alt_speed_limits"] logger.info(f"Alternative speed limits enabled: {is_enabled}") return is_enabled response.raise_for_status() except requests.exceptions.RequestException as e: logger.error(f"Transfer info endpoint failed: {e}") except json.JSONDecodeError as e: logger.error(f"Failed to parse transfer info JSON: {e}") # Fallback: try app preferences endpoint try: response = self.session.get( f"{self.qbittorrent_url}/api/v2/app/preferences", timeout=10 ) logger.info(f"Preferences endpoint: HTTP {response.status_code}") if response.status_code == 200: data = response.json() # Look for alternative speed settings if "alt_up_limit" in data or "scheduler_enabled" in data: # Check if alternative speeds are currently active # This is a bit indirect but should work logger.info( "Found preferences data, assuming alt speeds not active by default" ) return False except Exception as e: logger.error(f"Preferences endpoint failed: {e}") logger.error( "Failed to check qBittorrent alternate limits status: all endpoints failed" ) return False def toggle_qbittorrent_limits(self, enable_throttle): """Toggle qBittorrent alternate speed limits""" try: # Check current state current_throttle = self.check_qbittorrent_alternate_limits() if enable_throttle and not current_throttle: try: # Use API v2 POST endpoint to toggle alternative speed limits response = self.session.post( f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode", timeout=10, ) logger.info( f"Toggle enable response: HTTP {response.status_code}, {response.text[:100]}" ) response.raise_for_status() self.throttle_active = True logger.info("✓ Enabled alternate speed limits (throttling)") return except requests.exceptions.RequestException as e: logger.error(f"Failed to enable alternate speed limits: {e}") elif not enable_throttle and current_throttle: try: # Use API v2 POST endpoint to toggle alternative speed limits response = self.session.post( f"{self.qbittorrent_url}/api/v2/transfer/toggleSpeedLimitsMode", timeout=10, ) logger.info( f"Toggle disable response: HTTP {response.status_code}, {response.text[:100]}" ) response.raise_for_status() self.throttle_active = False logger.info("✓ Disabled alternate speed limits (normal)") return except requests.exceptions.RequestException as e: logger.error(f"Failed to disable alternate speed limits: {e}") except Exception as e: logger.error(f"Failed to toggle qBittorrent limits: {e}") def restore_normal_limits(self): """Ensure normal speed limits are restored on shutdown""" if self.throttle_active: logger.info("Restoring normal speed limits before shutdown...") self.toggle_qbittorrent_limits(False) def should_change_state(self, new_streaming_state): """Apply hysteresis to prevent rapid state changes""" now = time.time() # If state hasn't changed, no action needed if new_streaming_state == self.last_streaming_state: return False # Calculate time since last state change time_since_change = now - self.last_state_change # If we want to 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 # If we want to 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 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 # Log current status if streaming_active: logger.info( f"Active streams ({len(active_streams)}): {', '.join(active_streams)}" ) else: logger.debug("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.toggle_qbittorrent_limits(streaming_active) 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()