From 4b173ef164446ce66288b83ed31fb1430ebc2c40 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sun, 26 Apr 2026 01:02:53 -0400 Subject: [PATCH] jellyfin-qbittorrent-monitor: fix hairpin handling --- home/progs/pi.nix | 20 +++--- .../jellyfin/jellyfin-qbittorrent-monitor.py | 50 ++++++++++++- tests/jellyfin-qbittorrent-monitor.nix | 71 ++++++++++++++++++- 3 files changed, 127 insertions(+), 14 deletions(-) diff --git a/home/progs/pi.nix b/home/progs/pi.nix index c835f68..657e5b6 100644 --- a/home/progs/pi.nix +++ b/home/progs/pi.nix @@ -41,17 +41,15 @@ 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 "") - + '' - # 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\``. - patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch} - # 0002 — require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro - # et al. accept follow-up requests in thinking mode. - patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch} - ''; + 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\``. + patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch} + # 0002 — require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro + # et al. accept follow-up requests in thinking mode. + patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch} + ''; })) ]; diff --git a/services/jellyfin/jellyfin-qbittorrent-monitor.py b/services/jellyfin/jellyfin-qbittorrent-monitor.py index 5c9326b..3973ce9 100644 --- a/services/jellyfin/jellyfin-qbittorrent-monitor.py +++ b/services/jellyfin/jellyfin-qbittorrent-monitor.py @@ -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() diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index e3c248a..39ba24c 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -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)