Compare commits

..

2 Commits

Author SHA1 Message Date
141754ca39 ghostty: fix???
All checks were successful
Build and Deploy / mreow (push) Successful in 1m20s
Build and Deploy / yarn (push) Successful in 54s
Build and Deploy / muffin (push) Successful in 1m14s
2026-04-26 01:11:09 -04:00
4b173ef164 jellyfin-qbittorrent-monitor: fix hairpin handling 2026-04-26 01:03:11 -04:00
5 changed files with 136 additions and 20 deletions

View File

@@ -55,6 +55,13 @@
# cursor # cursor
cursor-style = "underline"; cursor-style = "underline";
# always open new windows at $HOME instead of inheriting whatever cwd the
# currently-focused ghostty window has. with gtk-single-instance, the
# focused-window inherit rule otherwise sticks the daemon's first cwd to
# every subsequent niri Mod+T launch.
window-inherit-working-directory = false;
working-directory = "home";
# keep one daemon alive so subsequent launches (e.g. niri Mod+T) are # keep one daemon alive so subsequent launches (e.g. niri Mod+T) are
# instant instead of paying GTK + wgpu init each time. relies on the # instant instead of paying GTK + wgpu init each time. relies on the
# dbus-activated systemd user service that the HM module wires up. # dbus-activated systemd user service that the HM module wires up.

View File

@@ -115,12 +115,8 @@ in
"Mod+O".action = toggle-overview; "Mod+O".action = toggle-overview;
# open a terminal — pass --working-directory=home so the gtk-single-instance # open a terminal
# daemon doesn't keep handing back whatever cwd the focused window has. "Mod+T".action = spawn config.home.sessionVariables.TERMINAL;
"Mod+T".action = spawn [
config.home.sessionVariables.TERMINAL
"--working-directory=home"
];
# lock the screen # lock the screen
"Mod+X".action = spawn (lib.getExe pkgs.swaylock); "Mod+X".action = spawn (lib.getExe pkgs.swaylock);

View File

@@ -41,17 +41,15 @@ in
# silently ignores the standard `patches` attribute. Apply patches via `prePatch` instead # silently ignores the standard `patches` attribute. Apply patches via `prePatch` instead
# so they actually take effect. Tracking: nothing upstream yet. # so they actually take effect. Tracking: nothing upstream yet.
(inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: { (inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: {
prePatch = prePatch = (old.prePatch or "") + ''
(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\`:
# 0001 retry without strict tools when DeepSeek (via OpenRouter) rejects strict-mode # missing field \`type\``.
# `anyOf` nullable unions with `Invalid tool parameters schema : field \`anyOf\`: patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch}
# missing field \`type\``. # 0002 require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro
patch -p1 < ${../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch} # et al. accept follow-up requests in thinking mode.
# 0002 require `reasoning_content` for OpenRouter reasoning models so DeepSeek V4 Pro patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch}
# et al. accept follow-up requests in thinking mode. '';
patch -p1 < ${../../patches/omp/0002-openai-completions-stub-reasoning-content-for-openrouter.patch}
'';
})) }))
]; ];

View File

@@ -38,6 +38,7 @@ class JellyfinQBittorrentMonitor:
stream_bitrate_headroom=1.1, stream_bitrate_headroom=1.1,
webhook_port=0, webhook_port=0,
webhook_bind="127.0.0.1", webhook_bind="127.0.0.1",
gateway_ip=None,
): ):
self.jellyfin_url = jellyfin_url self.jellyfin_url = jellyfin_url
self.qbittorrent_url = qbittorrent_url self.qbittorrent_url = qbittorrent_url
@@ -77,6 +78,15 @@ class JellyfinQBittorrentMonitor:
ipaddress.ip_network("fe80::/10"), # IPv6 link-local 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: def is_local_ip(self, ip_address: str) -> bool:
"""Check if an IP address is from a local network""" """Check if an IP address is from a local network"""
try: try:
@@ -86,6 +96,39 @@ class JellyfinQBittorrentMonitor:
logger.warning(f"Invalid IP address format: {ip_address}") logger.warning(f"Invalid IP address format: {ip_address}")
return True # Treat invalid IPs as local for safety 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): def signal_handler(self, signum, frame):
logger.info("Received shutdown signal, cleaning up...") logger.info("Received shutdown signal, cleaning up...")
self.running = False self.running = False
@@ -164,7 +207,7 @@ class JellyfinQBittorrentMonitor:
if ( if (
"NowPlayingItem" in session "NowPlayingItem" in session
and not session.get("PlayState", {}).get("IsPaused", True) 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 = session["NowPlayingItem"]
item_type = item.get("Type", "").lower() 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"Default stream bitrate: {self.default_stream_bitrate} bps")
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s") 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"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: if self.webhook_port:
logger.info(f"Webhook receiver: {self.webhook_bind}:{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")) stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
webhook_port = int(os.getenv("WEBHOOK_PORT", "0")) webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1") webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
gateway_ip = os.getenv("LAN_GATEWAY_IP") or None
monitor = JellyfinQBittorrentMonitor( monitor = JellyfinQBittorrentMonitor(
jellyfin_url=jellyfin_url, jellyfin_url=jellyfin_url,
@@ -499,6 +546,7 @@ if __name__ == "__main__":
stream_bitrate_headroom=stream_bitrate_headroom, stream_bitrate_headroom=stream_bitrate_headroom,
webhook_port=webhook_port, webhook_port=webhook_port,
webhook_bind=webhook_bind, webhook_bind=webhook_bind,
gateway_ip=gateway_ip,
) )
monitor.run() monitor.run()

View File

@@ -428,6 +428,73 @@ pkgs.testers.runNixOSTest {
local_playback["PositionTicks"] = 50000000 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}'") 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 === # === WEBHOOK TESTS ===
# #
# Configure the Jellyfin Webhook plugin to target the monitor, then verify # 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.succeed("systemctl restart jellyfin.service")
server.wait_for_unit("jellyfin.service") server.wait_for_unit("jellyfin.service")
server.wait_for_open_port(8096) 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 # During Jellyfin restart, monitor can't reach Jellyfin
# After restart, sessions are cleared - monitor should eventually unthrottle # After restart, sessions are cleared - monitor should eventually unthrottle
@@ -645,7 +712,7 @@ pkgs.testers.runNixOSTest {
server.succeed("systemctl start jellyfin.service") server.succeed("systemctl start jellyfin.service")
server.wait_for_unit("jellyfin.service") server.wait_for_unit("jellyfin.service")
server.wait_for_open_port(8096) 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 # After Jellyfin comes back, sessions are gone - should unthrottle
time.sleep(3) time.sleep(3)