diff --git a/services/jellyfin/jellyfin-qbittorrent-monitor.py b/services/jellyfin/jellyfin-qbittorrent-monitor.py index 3973ce9..a80e08a 100644 --- a/services/jellyfin/jellyfin-qbittorrent-monitor.py +++ b/services/jellyfin/jellyfin-qbittorrent-monitor.py @@ -61,6 +61,7 @@ class JellyfinQBittorrentMonitor: self.streaming_start_delay = streaming_start_delay self.streaming_stop_delay = streaming_stop_delay self.last_state_change = 0 + self._last_seen_state = None # tracks previous poll's streaming_active for flicker detection # Webhook receiver: allows Jellyfin to push events instead of waiting for the poll self.webhook_port = webhook_port @@ -355,12 +356,24 @@ class JellyfinQBittorrentMonitor: pass def should_change_state(self, new_streaming_state: bool) -> bool: - """Apply hysteresis to prevent rapid state changes""" + """Apply hysteresis to prevent rapid state changes. + + The delay must be contiguous: if the session flickers (absent→present + while a stop-countdown is in progress, or vice versa), the timer resets + so brief gaps don't accumulate into a spurious state transition. + Routine repeated presence (e.g. webhook PlaybackProgress every second) + does NOT reset the timer — only a direction reversal does. + """ now = time.time() if new_streaming_state == self.last_streaming_state: + if self._last_seen_state != self.last_streaming_state: + self.last_state_change = now + self._last_seen_state = new_streaming_state return False + self._last_seen_state = new_streaming_state + time_since_change = now - self.last_state_change if new_streaming_state and not self.last_streaming_state: diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 39ba24c..55127f6 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -586,6 +586,97 @@ pkgs.testers.runNixOSTest { time.sleep(2) + # === FLICKER TEST === + # + # Session flicker (brief appear→disappear→reappear cycles) is the + # production reality when xmrig starves the transcode CPU and the + # client's buffer oscillates. The hysteresis timer must measure + # CONTIGUOUS absence, not cumulative gaps across flickers. A 5s + # stop delay with flickers of 2s absent / 2s present must NOT + # accumulate into a spurious unlimited transition. + + server.succeed("systemctl stop monitor-test || true") + time.sleep(1) + server.succeed(f""" + systemd-run --unit=monitor-flicker \ + --setenv=JELLYFIN_URL=http://localhost:8096 \ + --setenv=JELLYFIN_API_KEY={token} \ + --setenv=QBITTORRENT_URL=http://localhost:8080 \ + --setenv=CHECK_INTERVAL=1 \ + --setenv=STREAMING_START_DELAY=1 \ + --setenv=STREAMING_STOP_DELAY=5 \ + --setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \ + --setenv=SERVICE_BUFFER=2000000 \ + --setenv=DEFAULT_STREAM_BITRATE=10000000 \ + --setenv=MIN_TORRENT_SPEED=100 \ + {python} {monitor} + """) + time.sleep(2) + assert not is_throttled(), "Should start unthrottled" + + with subtest("Session flicker does not accumulate hysteresis timer"): + # Start playback + playback_flicker = { + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-session-flicker", + "CanSeek": True, + "IsPaused": False, + } + start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_flicker)}' -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 throttle when streaming starts" + + # Flicker 1: stop 2s, restart 2s + playback_flicker["PositionTicks"] = 50000000 + stop_flicker = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(playback_flicker)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'" + client.succeed(stop_flicker) + time.sleep(2) + client.succeed(start_cmd) + time.sleep(2) + + # Flicker 2: stop 2s, restart 2s (total absent = 4s < 5s delay) + client.succeed(stop_flicker) + time.sleep(2) + client.succeed(start_cmd) + time.sleep(2) + + # Should still be throttled — flicker gaps didn't accumulate + assert is_throttled(), "Should remain throttled after session flickers (contiguous absence < 5s)" + + # Genuine stop: absent for full stop delay + client.succeed(stop_flicker) + time.sleep(6) + assert not is_throttled(), "Should unthrottle after contiguous 5s+ absence" + + # Restart after genuine stop + client.succeed(start_cmd) + time.sleep(2) + assert is_throttled(), "Should re-throttle after restart" + + # Clean up + client.succeed(stop_flicker) + time.sleep(2) + + # Restore the fast-polling monitor for subsequent tests + server.succeed("systemctl stop monitor-flicker || true") + time.sleep(1) + server.succeed(f""" + systemd-run --unit=monitor-test \ + --setenv=JELLYFIN_URL=http://localhost:8096 \ + --setenv=JELLYFIN_API_KEY={token} \ + --setenv=QBITTORRENT_URL=http://localhost:8080 \ + --setenv=CHECK_INTERVAL=1 \ + --setenv=STREAMING_START_DELAY=1 \ + --setenv=STREAMING_STOP_DELAY=1 \ + --setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \ + --setenv=SERVICE_BUFFER=2000000 \ + --setenv=DEFAULT_STREAM_BITRATE=10000000 \ + --setenv=MIN_TORRENT_SPEED=100 \ + {python} {monitor} + """) + time.sleep(2) # === SERVICE RESTART TESTS === with subtest("qBittorrent restart during throttled state re-applies throttling"):