jellyfin-qbittorrent-monitor: fix hysterisis
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user