Compare commits
2 Commits
3201b5726e
...
141754ca39
| Author | SHA1 | Date | |
|---|---|---|---|
|
141754ca39
|
|||
|
4b173ef164
|
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
'';
|
|
||||||
}))
|
}))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user