jellyfin-qbittorrent-monitor: dynamic bandwidth management
This commit is contained in:
@@ -46,6 +46,11 @@
|
||||
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
|
||||
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.torrent}";
|
||||
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
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,13 +29,23 @@ class JellyfinQBittorrentMonitor:
|
||||
jellyfin_api_key=None,
|
||||
streaming_start_delay=10,
|
||||
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.qbittorrent_url = qbittorrent_url
|
||||
self.check_interval = check_interval
|
||||
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.throttle_active = False
|
||||
self.current_state = "unlimited"
|
||||
self.torrents_paused = False
|
||||
self.last_alt_limits = None
|
||||
self.running = True
|
||||
self.session = requests.Session() # Use session for cookies
|
||||
self.last_active_streams = []
|
||||
@@ -70,7 +80,7 @@ class JellyfinQBittorrentMonitor:
|
||||
self.restore_normal_limits()
|
||||
sys.exit(0)
|
||||
|
||||
def check_jellyfin_sessions(self) -> list[str]:
|
||||
def check_jellyfin_sessions(self) -> list[dict]:
|
||||
headers = (
|
||||
{"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()
|
||||
if item_type in ["movie", "episode", "video"]:
|
||||
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
|
||||
|
||||
@@ -139,9 +162,6 @@ class JellyfinQBittorrentMonitor:
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
self.throttle_active = enable
|
||||
|
||||
new_state = self.check_qbittorrent_alternate_limits()
|
||||
if new_state == enable:
|
||||
logger.info(f"Alternate speed limits {action}")
|
||||
@@ -157,19 +177,76 @@ class JellyfinQBittorrentMonitor:
|
||||
except requests.exceptions.RequestException as 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:
|
||||
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...")
|
||||
self.use_alt_limits(False)
|
||||
self.current_state = "unlimited"
|
||||
|
||||
def sync_qbittorrent_state(self) -> None:
|
||||
try:
|
||||
if self.current_state == "unlimited":
|
||||
actual_state = self.check_qbittorrent_alternate_limits()
|
||||
if actual_state != self.throttle_active:
|
||||
if actual_state:
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -182,7 +259,6 @@ class JellyfinQBittorrentMonitor:
|
||||
|
||||
time_since_change = now - self.last_state_change
|
||||
|
||||
# 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
|
||||
@@ -190,10 +266,9 @@ class JellyfinQBittorrentMonitor:
|
||||
else:
|
||||
remaining = self.streaming_start_delay - time_since_change
|
||||
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:
|
||||
if time_since_change >= self.streaming_stop_delay:
|
||||
self.last_state_change = now
|
||||
@@ -201,7 +276,7 @@ class JellyfinQBittorrentMonitor:
|
||||
else:
|
||||
remaining = self.streaming_stop_delay - time_since_change
|
||||
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
|
||||
@@ -211,6 +286,12 @@ class JellyfinQBittorrentMonitor:
|
||||
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")
|
||||
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.SIGTERM, self.signal_handler)
|
||||
@@ -222,26 +303,91 @@ class JellyfinQBittorrentMonitor:
|
||||
try:
|
||||
active_streams = self.check_jellyfin_sessions()
|
||||
except ServiceUnavailable:
|
||||
logger.warning(
|
||||
"Jellyfin unavailable, maintaining current throttle state"
|
||||
)
|
||||
logger.warning("Jellyfin unavailable, maintaining current state")
|
||||
time.sleep(self.check_interval)
|
||||
continue
|
||||
|
||||
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 streaming_active:
|
||||
stream_names = ", ".join(
|
||||
stream["name"] for stream in active_streams
|
||||
)
|
||||
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:
|
||||
logger.info("No active streaming sessions")
|
||||
|
||||
if self.should_change_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
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
@@ -265,6 +411,10 @@ if __name__ == "__main__":
|
||||
jellyfin_api_key = os.getenv("JELLYFIN_API_KEY")
|
||||
streaming_start_delay = int(os.getenv("STREAMING_START_DELAY", "10"))
|
||||
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(
|
||||
jellyfin_url=jellyfin_url,
|
||||
@@ -273,6 +423,10 @@ if __name__ == "__main__":
|
||||
jellyfin_api_key=jellyfin_api_key,
|
||||
streaming_start_delay=streaming_start_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()
|
||||
|
||||
@@ -123,6 +123,20 @@ pkgs.testers.runNixOSTest {
|
||||
def is_throttled():
|
||||
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 = ""
|
||||
media_source_id: str = ""
|
||||
|
||||
@@ -186,12 +200,17 @@ pkgs.testers.runNixOSTest {
|
||||
--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)
|
||||
assert not is_throttled(), "Should start unthrottled"
|
||||
|
||||
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"
|
||||
|
||||
with subtest("Client authenticates from external network"):
|
||||
@@ -199,6 +218,11 @@ pkgs.testers.runNixOSTest {
|
||||
client_auth_result = json.loads(client.succeed(auth_cmd))
|
||||
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"):
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
@@ -248,6 +272,162 @@ pkgs.testers.runNixOSTest {
|
||||
|
||||
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"):
|
||||
local_auth = 'MediaBrowser Client="Local Client", DeviceId="local-1111", Device="LocalDevice", Version="1.0"'
|
||||
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}'"
|
||||
))
|
||||
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
|
||||
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}'"
|
||||
))
|
||||
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
|
||||
playback_start = {
|
||||
|
||||
Reference in New Issue
Block a user