jellyfin-qbittorrent-monitor: handle qbittorrent going down state

This commit is contained in:
2026-01-19 02:40:43 -05:00
parent eb5d0bb093
commit 86f537c2fe
2 changed files with 178 additions and 48 deletions

View File

@@ -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,42 +71,39 @@ 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 = {"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
try:
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()
# 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: except requests.exceptions.RequestException as e:
logger.error(f"Failed to check Jellyfin sessions: {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: except json.JSONDecodeError as e:
logger.error(f"Failed to parse Jellyfin response: {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): active_streams = []
"""Check if alternate speed limits are currently enabled""" 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: 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,23 +114,18 @@ 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
response = self.session.post( response = self.session.post(
@@ -138,26 +136,33 @@ 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 +197,28 @@ 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()
active_streams = self.check_jellyfin_sessions()
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 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 +226,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)

View File

@@ -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"
''; '';
} }