jellyfin-qbittorrent-monitor: fix hairpin handling
This commit is contained in:
@@ -41,9 +41,7 @@ in
|
||||
# silently ignores the standard `patches` attribute. Apply patches via `prePatch` instead
|
||||
# so they actually take effect. Tracking: nothing upstream yet.
|
||||
(inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: {
|
||||
prePatch =
|
||||
(old.prePatch or "")
|
||||
+ ''
|
||||
prePatch = (old.prePatch or "") + ''
|
||||
# 0001 — retry without strict tools when DeepSeek (via OpenRouter) rejects strict-mode
|
||||
# `anyOf` nullable unions with `Invalid tool parameters schema : field \`anyOf\`:
|
||||
# missing field \`type\``.
|
||||
|
||||
@@ -38,6 +38,7 @@ class JellyfinQBittorrentMonitor:
|
||||
stream_bitrate_headroom=1.1,
|
||||
webhook_port=0,
|
||||
webhook_bind="127.0.0.1",
|
||||
gateway_ip=None,
|
||||
):
|
||||
self.jellyfin_url = jellyfin_url
|
||||
self.qbittorrent_url = qbittorrent_url
|
||||
@@ -77,6 +78,15 @@ class JellyfinQBittorrentMonitor:
|
||||
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
||||
]
|
||||
|
||||
# Hairpin marker. When a LAN client reaches Jellyfin via the public
|
||||
# hostname, the router NAT-loopbacks the packet and SNATs the source
|
||||
# to itself — the session arrives looking local but still costs WAN
|
||||
# bandwidth. Sessions whose source equals the gateway must therefore
|
||||
# NOT be skipped. None disables the check (pre-hairpin-aware behavior).
|
||||
if gateway_ip is None:
|
||||
gateway_ip = self._discover_default_gateway()
|
||||
self.gateway_ip = gateway_ip
|
||||
|
||||
def is_local_ip(self, ip_address: str) -> bool:
|
||||
"""Check if an IP address is from a local network"""
|
||||
try:
|
||||
@@ -86,6 +96,39 @@ class JellyfinQBittorrentMonitor:
|
||||
logger.warning(f"Invalid IP address format: {ip_address}")
|
||||
return True # Treat invalid IPs as local for safety
|
||||
|
||||
def _discover_default_gateway(self) -> str | None:
|
||||
"""Read the IPv4 default gateway from /proc/net/route, or None."""
|
||||
try:
|
||||
with open("/proc/net/route") as f:
|
||||
next(f) # skip header
|
||||
for line in f:
|
||||
fields = line.split()
|
||||
if len(fields) < 8 or fields[1] != "00000000":
|
||||
continue
|
||||
flags = int(fields[3], 16)
|
||||
if not flags & 0x2: # RTF_GATEWAY
|
||||
continue
|
||||
gw_bytes = bytes.fromhex(fields[2])[::-1] # little-endian
|
||||
if len(gw_bytes) != 4:
|
||||
continue
|
||||
return ".".join(str(b) for b in gw_bytes)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(f"Could not autodetect default gateway: {e}")
|
||||
return None
|
||||
|
||||
def is_skippable(self, ip_address: str) -> bool:
|
||||
"""True iff this source IP can be ignored when deciding to throttle.
|
||||
|
||||
Truly LAN-direct sessions are skippable (no WAN cost). Hairpin-NAT'd
|
||||
LAN sessions arrive with the LAN gateway as their source — those still
|
||||
cost WAN bandwidth and must NOT be skipped.
|
||||
"""
|
||||
if not self.is_local_ip(ip_address):
|
||||
return False
|
||||
if self.gateway_ip and ip_address == self.gateway_ip:
|
||||
return False
|
||||
return True
|
||||
|
||||
def signal_handler(self, signum, frame):
|
||||
logger.info("Received shutdown signal, cleaning up...")
|
||||
self.running = False
|
||||
@@ -164,7 +207,7 @@ class JellyfinQBittorrentMonitor:
|
||||
if (
|
||||
"NowPlayingItem" in session
|
||||
and not session.get("PlayState", {}).get("IsPaused", True)
|
||||
and not self.is_local_ip(session.get("RemoteEndPoint", ""))
|
||||
and not self.is_skippable(session.get("RemoteEndPoint", ""))
|
||||
):
|
||||
item = session["NowPlayingItem"]
|
||||
item_type = item.get("Type", "").lower()
|
||||
@@ -354,6 +397,9 @@ class JellyfinQBittorrentMonitor:
|
||||
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
|
||||
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
|
||||
logger.info(f"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
||||
logger.info(
|
||||
f"LAN gateway (hairpin marker): {self.gateway_ip or 'none / autodetect failed'}"
|
||||
)
|
||||
if self.webhook_port:
|
||||
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
|
||||
|
||||
@@ -484,6 +530,7 @@ if __name__ == "__main__":
|
||||
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
||||
webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
|
||||
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
|
||||
gateway_ip = os.getenv("LAN_GATEWAY_IP") or None
|
||||
|
||||
monitor = JellyfinQBittorrentMonitor(
|
||||
jellyfin_url=jellyfin_url,
|
||||
@@ -499,6 +546,7 @@ if __name__ == "__main__":
|
||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||
webhook_port=webhook_port,
|
||||
webhook_bind=webhook_bind,
|
||||
gateway_ip=gateway_ip,
|
||||
)
|
||||
|
||||
monitor.run()
|
||||
|
||||
@@ -428,6 +428,73 @@ pkgs.testers.runNixOSTest {
|
||||
local_playback["PositionTicks"] = 50000000
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||
|
||||
with subtest("Hairpin'd LAN session (source IP = configured gateway) DOES throttle"):
|
||||
# Simulates a LAN client reaching Jellyfin via the public hostname:
|
||||
# the router SNATs the source to itself, so Jellyfin sees the gateway
|
||||
# IP and IsInLocalNetwork=True even though WAN bandwidth is in play.
|
||||
# We use 127.0.0.1 as the "gateway" in this VM because the localhost
|
||||
# curl below produces source 127.0.0.1 from Jellyfin's view.
|
||||
server.succeed("systemctl stop monitor-test || true")
|
||||
time.sleep(1)
|
||||
server.succeed(f"""
|
||||
systemd-run --unit=monitor-hairpin \
|
||||
--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 \
|
||||
--setenv=LAN_GATEWAY_IP=127.0.0.1 \
|
||||
{python} {monitor}
|
||||
""")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should start unthrottled (no streams yet)"
|
||||
|
||||
hairpin_auth = 'MediaBrowser Client="Hairpin Client", DeviceId="hairpin-2222", Device="HairpinDevice", Version="1.0"'
|
||||
hairpin_auth_result = json.loads(server.succeed(
|
||||
f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}'"
|
||||
))
|
||||
hairpin_token = hairpin_auth_result["AccessToken"]
|
||||
|
||||
hairpin_playback = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-hairpin",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' -d '{json.dumps(hairpin_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}, Token={hairpin_token}'")
|
||||
time.sleep(3)
|
||||
assert is_throttled(), "Hairpin'd session (source=gateway) should throttle even though source is RFC1918"
|
||||
|
||||
# Cleanup: stop the playback and the override-monitor, restore the normal one.
|
||||
hairpin_playback["PositionTicks"] = 50000000
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(hairpin_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{hairpin_auth}, Token={hairpin_token}'")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should unthrottle after hairpin'd playback stops"
|
||||
|
||||
server.succeed("systemctl stop monitor-hairpin || 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)
|
||||
|
||||
# === WEBHOOK TESTS ===
|
||||
#
|
||||
# Configure the Jellyfin Webhook plugin to target the monitor, then verify
|
||||
@@ -589,7 +656,7 @@ pkgs.testers.runNixOSTest {
|
||||
server.succeed("systemctl restart jellyfin.service")
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=180)
|
||||
|
||||
# During Jellyfin restart, monitor can't reach Jellyfin
|
||||
# After restart, sessions are cleared - monitor should eventually unthrottle
|
||||
@@ -645,7 +712,7 @@ pkgs.testers.runNixOSTest {
|
||||
server.succeed("systemctl start jellyfin.service")
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=180)
|
||||
|
||||
# After Jellyfin comes back, sessions are gone - should unthrottle
|
||||
time.sleep(3)
|
||||
|
||||
Reference in New Issue
Block a user