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}";
|
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
actual_state = self.check_qbittorrent_alternate_limits()
|
if self.current_state == "unlimited":
|
||||||
if actual_state != self.throttle_active:
|
actual_state = self.check_qbittorrent_alternate_limits()
|
||||||
logger.warning(
|
if actual_state:
|
||||||
f"qBittorrent state mismatch detected: expected {self.throttle_active}, got {actual_state}. Re-syncing..."
|
logger.warning(
|
||||||
)
|
"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()
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user