jellyfin-qbittorrent-monitor: dynamic bandwidth management

This commit is contained in:
2026-02-15 23:33:45 -05:00
parent cf3e876f27
commit 9a9ecc6556
3 changed files with 370 additions and 23 deletions

View File

@@ -46,6 +46,11 @@
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}"; JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.torrent}"; QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.torrent}";
CHECK_INTERVAL = "30"; CHECK_INTERVAL = "30";
# Bandwidth budget configuration
TOTAL_BANDWIDTH_BUDGET = "30000000"; # 30 Mbps in bits per second
SERVICE_BUFFER = "5000000"; # 5 Mbps reserved for other services (bps)
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
}; };
}; };
} }

View File

@@ -29,13 +29,23 @@ class JellyfinQBittorrentMonitor:
jellyfin_api_key=None, jellyfin_api_key=None,
streaming_start_delay=10, streaming_start_delay=10,
streaming_stop_delay=60, streaming_stop_delay=60,
total_bandwidth_budget=30000000,
service_buffer=5000000,
default_stream_bitrate=10000000,
min_torrent_speed=100,
): ):
self.jellyfin_url = jellyfin_url self.jellyfin_url = jellyfin_url
self.qbittorrent_url = qbittorrent_url self.qbittorrent_url = qbittorrent_url
self.check_interval = check_interval self.check_interval = check_interval
self.jellyfin_api_key = jellyfin_api_key self.jellyfin_api_key = jellyfin_api_key
self.total_bandwidth_budget = total_bandwidth_budget
self.service_buffer = service_buffer
self.default_stream_bitrate = default_stream_bitrate
self.min_torrent_speed = min_torrent_speed
self.last_streaming_state = None self.last_streaming_state = None
self.throttle_active = False self.current_state = "unlimited"
self.torrents_paused = False
self.last_alt_limits = None
self.running = True self.running = True
self.session = requests.Session() # Use session for cookies self.session = requests.Session() # Use session for cookies
self.last_active_streams = [] self.last_active_streams = []
@@ -70,7 +80,7 @@ class JellyfinQBittorrentMonitor:
self.restore_normal_limits() self.restore_normal_limits()
sys.exit(0) sys.exit(0)
def check_jellyfin_sessions(self) -> list[str]: def check_jellyfin_sessions(self) -> list[dict]:
headers = ( headers = (
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {} {"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
) )
@@ -101,7 +111,20 @@ class JellyfinQBittorrentMonitor:
item_type = item.get("Type", "").lower() item_type = item.get("Type", "").lower()
if item_type in ["movie", "episode", "video"]: if item_type in ["movie", "episode", "video"]:
user = session.get("UserName", "Unknown") user = session.get("UserName", "Unknown")
active_streams.append(f"{user}: {item.get('Name', 'Unknown')}") stream_name = f"{user}: {item.get('Name', 'Unknown')}"
if session.get("TranscodingInfo") and session[
"TranscodingInfo"
].get("Bitrate"):
bitrate = session["TranscodingInfo"]["Bitrate"]
elif item.get("Bitrate"):
bitrate = item["Bitrate"]
elif item.get("MediaSources", [{}])[0].get("Bitrate"):
bitrate = item["MediaSources"][0]["Bitrate"]
else:
bitrate = self.default_stream_bitrate
bitrate = min(int(bitrate), 100_000_000)
active_streams.append({"name": stream_name, "bitrate_bps": bitrate})
return active_streams return active_streams
@@ -139,9 +162,6 @@ class JellyfinQBittorrentMonitor:
timeout=10, timeout=10,
) )
response.raise_for_status() response.raise_for_status()
self.throttle_active = enable
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"Alternate speed limits {action}") logger.info(f"Alternate speed limits {action}")
@@ -157,19 +177,76 @@ class JellyfinQBittorrentMonitor:
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}")
def pause_all_torrents(self) -> None:
try:
response = self.session.post(
f"{self.qbittorrent_url}/api/v2/torrents/stop",
data={"hashes": "all"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(f"Failed to pause torrents: {e}")
def resume_all_torrents(self) -> None:
try:
response = self.session.post(
f"{self.qbittorrent_url}/api/v2/torrents/start",
data={"hashes": "all"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(f"Failed to resume torrents: {e}")
def set_alt_speed_limits(self, dl_kbs: float, ul_kbs: float) -> None:
try:
payload = {
"alt_dl_limit": int(dl_kbs * 1024),
"alt_up_limit": int(ul_kbs * 1024),
}
response = self.session.post(
f"{self.qbittorrent_url}/api/v2/app/setPreferences",
data={"json": json.dumps(payload)},
timeout=10,
)
response.raise_for_status()
self.last_alt_limits = (dl_kbs, ul_kbs)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to set alternate speed limits: {e}")
def restore_normal_limits(self) -> None: def restore_normal_limits(self) -> None:
if self.throttle_active: if self.torrents_paused:
logger.info("Resuming all torrents before shutdown...")
self.resume_all_torrents()
self.torrents_paused = False
if self.current_state != "unlimited":
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)
self.current_state = "unlimited"
def sync_qbittorrent_state(self) -> None: def sync_qbittorrent_state(self) -> None:
try: try:
if self.current_state == "unlimited":
actual_state = self.check_qbittorrent_alternate_limits() actual_state = self.check_qbittorrent_alternate_limits()
if actual_state != self.throttle_active: if actual_state:
logger.warning( logger.warning(
f"qBittorrent state mismatch detected: expected {self.throttle_active}, got {actual_state}. Re-syncing..." "qBittorrent state mismatch detected: expected alt speed OFF, got ON. Re-syncing..."
) )
self.use_alt_limits(self.throttle_active) self.use_alt_limits(False)
elif self.current_state == "throttled":
if self.last_alt_limits:
self.set_alt_speed_limits(*self.last_alt_limits)
actual_state = self.check_qbittorrent_alternate_limits()
if not actual_state:
logger.warning(
"qBittorrent state mismatch detected: expected alt speed ON, got OFF. Re-syncing..."
)
self.use_alt_limits(True)
elif self.current_state == "paused":
self.pause_all_torrents()
self.torrents_paused = True
except ServiceUnavailable: except ServiceUnavailable:
pass pass
@@ -182,7 +259,6 @@ class JellyfinQBittorrentMonitor:
time_since_change = now - self.last_state_change time_since_change = now - self.last_state_change
# Start throttling (streaming started)
if new_streaming_state and not self.last_streaming_state: if new_streaming_state and not self.last_streaming_state:
if time_since_change >= self.streaming_start_delay: if time_since_change >= self.streaming_start_delay:
self.last_state_change = now self.last_state_change = now
@@ -190,10 +266,9 @@ class JellyfinQBittorrentMonitor:
else: else:
remaining = self.streaming_start_delay - time_since_change remaining = self.streaming_start_delay - time_since_change
logger.info( logger.info(
f"Streaming started - waiting {remaining:.1f}s before enabling throttling" f"Streaming started - waiting {remaining:.1f}s before enforcing limits"
) )
# Stop throttling (streaming stopped)
elif not new_streaming_state and self.last_streaming_state: elif not new_streaming_state and self.last_streaming_state:
if time_since_change >= self.streaming_stop_delay: if time_since_change >= self.streaming_stop_delay:
self.last_state_change = now self.last_state_change = now
@@ -201,7 +276,7 @@ class JellyfinQBittorrentMonitor:
else: else:
remaining = self.streaming_stop_delay - time_since_change remaining = self.streaming_stop_delay - time_since_change
logger.info( logger.info(
f"Streaming stopped - waiting {remaining:.1f}s before disabling throttling" f"Streaming stopped - waiting {remaining:.1f}s before restoring unlimited mode"
) )
return False return False
@@ -211,6 +286,12 @@ class JellyfinQBittorrentMonitor:
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")
logger.info(f"Streaming start delay: {self.streaming_start_delay}s")
logger.info(f"Streaming stop delay: {self.streaming_stop_delay}s")
logger.info(f"Total bandwidth budget: {self.total_bandwidth_budget} bps")
logger.info(f"Service buffer: {self.service_buffer} bps")
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
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)
@@ -222,26 +303,91 @@ class JellyfinQBittorrentMonitor:
try: try:
active_streams = self.check_jellyfin_sessions() active_streams = self.check_jellyfin_sessions()
except ServiceUnavailable: except ServiceUnavailable:
logger.warning( logger.warning("Jellyfin unavailable, maintaining current state")
"Jellyfin unavailable, maintaining current throttle state"
)
time.sleep(self.check_interval) time.sleep(self.check_interval)
continue continue
streaming_active = len(active_streams) > 0 streaming_active = len(active_streams) > 0
if active_streams:
for stream in active_streams:
logger.debug(
f"Active stream: {stream['name']} ({stream['bitrate_bps']} bps)"
)
if active_streams != self.last_active_streams: if active_streams != self.last_active_streams:
if streaming_active: if streaming_active:
stream_names = ", ".join(
stream["name"] for stream in active_streams
)
logger.info( logger.info(
f"Active streams ({len(active_streams)}): {', '.join(active_streams)}" f"Active streams ({len(active_streams)}): {stream_names}"
) )
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")
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)
streaming_state = bool(self.last_streaming_state)
total_streaming_bps = sum(
stream["bitrate_bps"] for stream in active_streams
)
remaining_bps = (
self.total_bandwidth_budget
- self.service_buffer
- total_streaming_bps
)
remaining_kbs = max(0, remaining_bps) / 8 / 1024
if not streaming_state:
desired_state = "unlimited"
elif streaming_active:
if remaining_kbs >= self.min_torrent_speed:
desired_state = "throttled"
else:
desired_state = "paused"
else:
desired_state = self.current_state
if desired_state != self.current_state:
if desired_state == "unlimited":
action = "resume torrents, disable alt speed"
elif desired_state == "throttled":
action = (
"set alt limits "
f"dl={int(remaining_kbs)}KB/s ul=1KB/s, enable alt speed"
)
else:
action = "pause torrents"
logger.info(
"State change %s -> %s | streams=%d total_bps=%d remaining_bps=%d action=%s",
self.current_state,
desired_state,
len(active_streams),
total_streaming_bps,
remaining_bps,
action,
)
if desired_state == "unlimited":
if self.torrents_paused:
self.resume_all_torrents()
self.torrents_paused = False
self.use_alt_limits(False)
elif desired_state == "throttled":
if self.torrents_paused:
self.resume_all_torrents()
self.torrents_paused = False
self.set_alt_speed_limits(remaining_kbs, 1)
self.use_alt_limits(True)
else:
if not self.torrents_paused:
self.pause_all_torrents()
self.torrents_paused = True
self.current_state = desired_state
self.last_active_streams = active_streams self.last_active_streams = active_streams
time.sleep(self.check_interval) time.sleep(self.check_interval)
@@ -265,6 +411,10 @@ if __name__ == "__main__":
jellyfin_api_key = os.getenv("JELLYFIN_API_KEY") jellyfin_api_key = os.getenv("JELLYFIN_API_KEY")
streaming_start_delay = int(os.getenv("STREAMING_START_DELAY", "10")) streaming_start_delay = int(os.getenv("STREAMING_START_DELAY", "10"))
streaming_stop_delay = int(os.getenv("STREAMING_STOP_DELAY", "60")) streaming_stop_delay = int(os.getenv("STREAMING_STOP_DELAY", "60"))
total_bandwidth_budget = int(os.getenv("TOTAL_BANDWIDTH_BUDGET", "30000000"))
service_buffer = int(os.getenv("SERVICE_BUFFER", "5000000"))
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
monitor = JellyfinQBittorrentMonitor( monitor = JellyfinQBittorrentMonitor(
jellyfin_url=jellyfin_url, jellyfin_url=jellyfin_url,
@@ -273,6 +423,10 @@ if __name__ == "__main__":
jellyfin_api_key=jellyfin_api_key, jellyfin_api_key=jellyfin_api_key,
streaming_start_delay=streaming_start_delay, streaming_start_delay=streaming_start_delay,
streaming_stop_delay=streaming_stop_delay, streaming_stop_delay=streaming_stop_delay,
total_bandwidth_budget=total_bandwidth_budget,
service_buffer=service_buffer,
default_stream_bitrate=default_stream_bitrate,
min_torrent_speed=min_torrent_speed,
) )
monitor.run() monitor.run()

View File

@@ -123,6 +123,20 @@ pkgs.testers.runNixOSTest {
def is_throttled(): def is_throttled():
return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1" return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1"
def get_alt_dl_limit():
prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences"))
return prefs["alt_dl_limit"]
def get_alt_up_limit():
prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences"))
return prefs["alt_up_limit"]
def are_torrents_paused():
torrents = json.loads(server.succeed("curl -s 'http://localhost:8080/api/v2/torrents/info'"))
if not torrents:
return False
return all(t["state"].startswith("stopped") for t in torrents)
movie_id: str = "" movie_id: str = ""
media_source_id: str = "" media_source_id: str = ""
@@ -186,12 +200,17 @@ pkgs.testers.runNixOSTest {
--setenv=CHECK_INTERVAL=1 \ --setenv=CHECK_INTERVAL=1 \
--setenv=STREAMING_START_DELAY=1 \ --setenv=STREAMING_START_DELAY=1 \
--setenv=STREAMING_STOP_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} {python} {monitor}
""") """)
time.sleep(2) time.sleep(2)
assert not is_throttled(), "Should start unthrottled" assert not is_throttled(), "Should start unthrottled"
client_auth = 'MediaBrowser Client="External Client", DeviceId="external-9999", Device="ExternalDevice", Version="1.0"' client_auth = 'MediaBrowser Client="External Client", DeviceId="external-9999", Device="ExternalDevice", Version="1.0"'
client_auth2 = 'MediaBrowser Client="External Client 2", DeviceId="external-8888", Device="ExternalDevice2", Version="1.0"'
server_ip = "192.168.1.1" server_ip = "192.168.1.1"
with subtest("Client authenticates from external network"): with subtest("Client authenticates from external network"):
@@ -199,6 +218,11 @@ pkgs.testers.runNixOSTest {
client_auth_result = json.loads(client.succeed(auth_cmd)) client_auth_result = json.loads(client.succeed(auth_cmd))
client_token = client_auth_result["AccessToken"] client_token = client_auth_result["AccessToken"]
with subtest("Second client authenticates from external network"):
auth_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'"
client_auth_result2 = json.loads(client.succeed(auth_cmd2))
client_token2 = client_auth_result2["AccessToken"]
with subtest("External video playback triggers throttling"): with subtest("External video playback triggers throttling"):
playback_start = { playback_start = {
"ItemId": movie_id, "ItemId": movie_id,
@@ -248,6 +272,162 @@ pkgs.testers.runNixOSTest {
assert not is_throttled(), "Should unthrottle when playback stops" assert not is_throttled(), "Should unthrottle when playback stops"
with subtest("Single stream sets proportional alt speed limits"):
playback_start = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-proportional",
"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(3)
assert is_throttled(), "Should be in alt speed mode during streaming"
dl_limit = get_alt_dl_limit()
ul_limit = get_alt_up_limit()
# Upload should be minimal (1 KB/s = 1024 bytes/s)
assert ul_limit == 1024, f"Upload limit should be 1024 bytes/s, got {ul_limit}"
# Download limit should be > 0 (budget not exhausted for a single stream)
assert dl_limit > 0, f"Download limit should be > 0, got {dl_limit}"
# Stop playback
playback_stop = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-proportional",
"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(3)
with subtest("Multiple streams reduce available bandwidth"):
# Start first stream
playback1 = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-multi-1",
"CanSeek": True,
"IsPaused": False,
}
start_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
client.succeed(start_cmd1)
time.sleep(3)
single_dl_limit = get_alt_dl_limit()
# Start second stream with different client identity
playback2 = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-multi-2",
"CanSeek": True,
"IsPaused": False,
}
start_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'"
client.succeed(start_cmd2)
time.sleep(3)
dual_dl_limit = get_alt_dl_limit()
# Two streams should leave less bandwidth than one stream
assert dual_dl_limit < single_dl_limit, f"Two streams ({dual_dl_limit}) should have lower limit than one ({single_dl_limit})"
# Stop both streams
stop1 = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-multi-1",
"PositionTicks": 50000000,
}
stop_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
client.succeed(stop_cmd1)
stop2 = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-multi-2",
"PositionTicks": 50000000,
}
stop_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'"
client.succeed(stop_cmd2)
time.sleep(3)
with subtest("Budget exhaustion pauses all torrents"):
# Stop current monitor
server.succeed("systemctl stop monitor-test || true")
time.sleep(1)
# Add a dummy torrent so we can check pause state
server.succeed("curl -sf -X POST 'http://localhost:8080/api/v2/torrents/add' -d 'urls=magnet:?xt=urn:btih:0000000000000000000000000000000000000001%26dn=test-torrent'")
time.sleep(2)
# Start monitor with impossibly low budget
server.succeed(f"""
systemd-run --unit=monitor-exhaust \
--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=1000 \
--setenv=SERVICE_BUFFER=500 \
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
--setenv=MIN_TORRENT_SPEED=100 \
{python} {monitor}
""")
time.sleep(2)
# Start a stream - this will exceed the tiny budget
playback_start = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-exhaust",
"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(3)
assert are_torrents_paused(), "Torrents should be paused when budget is exhausted"
with subtest("Recovery from pause restores unlimited"):
# Stop the stream
playback_stop = {
"ItemId": movie_id,
"MediaSourceId": media_source_id,
"PlaySessionId": "test-play-session-exhaust",
"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(3)
assert not is_throttled(), "Should return to unlimited after streams stop"
assert not are_torrents_paused(), "Torrents should be resumed after streams stop"
# Clean up: stop exhaust monitor, restart normal monitor
server.succeed("systemctl stop monitor-exhaust || 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)
with subtest("Local playback does NOT trigger throttling"): with subtest("Local playback does NOT trigger throttling"):
local_auth = 'MediaBrowser Client="Local Client", DeviceId="local-1111", Device="LocalDevice", Version="1.0"' local_auth = 'MediaBrowser Client="Local Client", DeviceId="local-1111", Device="LocalDevice", Version="1.0"'
local_auth_result = json.loads(server.succeed( local_auth_result = json.loads(server.succeed(
@@ -351,6 +531,10 @@ pkgs.testers.runNixOSTest {
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}'" 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"] client_token = client_auth_result["AccessToken"]
client_auth_result2 = 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_auth2}'"
))
client_token2 = client_auth_result2["AccessToken"]
# No active streams after Jellyfin restart, should eventually unthrottle # No active streams after Jellyfin restart, should eventually unthrottle
time.sleep(3) time.sleep(3)
@@ -362,6 +546,10 @@ pkgs.testers.runNixOSTest {
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}'" 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"] client_token = client_auth_result["AccessToken"]
client_auth_result2 = 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_auth2}'"
))
client_token2 = client_auth_result2["AccessToken"]
# Start playback # Start playback
playback_start = { playback_start = {