Compare commits
4 Commits
cebdd3ea96
...
1403c9d3bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 1403c9d3bc | |||
| 48ac68c297 | |||
| fc548a137f | |||
| 9ea45d4558 |
@@ -12,7 +12,7 @@ let
|
||||
parent=''${1%%[0-9]*}
|
||||
dev="/sys/block/$parent"
|
||||
[ -d "$dev/queue/iosched" ] || exit 0
|
||||
echo 15000 > "$dev/queue/iosched/read_expire"
|
||||
echo 500 > "$dev/queue/iosched/read_expire"
|
||||
echo 15000 > "$dev/queue/iosched/write_expire"
|
||||
echo 128 > "$dev/queue/iosched/fifo_batch"
|
||||
echo 16 > "$dev/queue/iosched/writes_starved"
|
||||
@@ -36,11 +36,17 @@ in
|
||||
hardware.cpu.amd.updateMicrocode = true;
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
# HDD I/O tuning for torrent seeding workload (high-concurrency random reads).
|
||||
# HDD I/O tuning for torrent seeding workload (high-concurrency random reads)
|
||||
# sharing the pool with latency-sensitive sequential reads (Jellyfin playback).
|
||||
#
|
||||
# mq-deadline sorts requests into elevator sweeps, reducing seek distance.
|
||||
# Aggressive deadlines (15s) let the scheduler accumulate more ops before dispatching,
|
||||
# maximizing coalescence — latency is irrelevant since torrent peers tolerate 30-60s.
|
||||
# read_expire=500ms keeps reads bounded so a Jellyfin segment can't queue for
|
||||
# seconds behind a torrent burst; write_expire=15s lets the scheduler batch
|
||||
# writes for coalescence (torrent writes are async and tolerate delay).
|
||||
# The bulk of read coalescence already happens above the scheduler via ZFS
|
||||
# aggregation (zfs_vdev_aggregation_limit=4M, read_gap_limit=128K,
|
||||
# async_read_max=32), so the scheduler deadline only needs to be large enough
|
||||
# to keep the elevator sweep coherent -- 500ms is plenty on rotational disks.
|
||||
# fifo_batch=128 keeps sweeps long; writes_starved=16 heavily favors reads.
|
||||
# 4 MiB readahead matches libtorrent piece extent affinity for sequential prefetch.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
From f0582558f0a8b0ef543b3251c4a07afab89fde63 Mon Sep 17 00:00:00 2001
|
||||
From: Simon Gardling <titaniumtown@proton.me>
|
||||
Date: Fri, 17 Apr 2026 19:37:11 -0400
|
||||
Subject: [PATCH] nixos/jellyfin: add declarative network.xml options
|
||||
|
||||
Adds services.jellyfin.network.* (baseUrl, ports, IPv4/6, LAN subnets,
|
||||
known proxies, remote IP filter, etc.) and services.jellyfin.forceNetworkConfig,
|
||||
mirroring the existing hardwareAcceleration / forceEncodingConfig pattern.
|
||||
|
||||
Motivation: running Jellyfin behind a reverse proxy requires configuring
|
||||
KnownProxies (so the real client IP is extracted from X-Forwarded-For)
|
||||
and LocalNetworkSubnets (so LAN clients are correctly classified and not
|
||||
subject to RemoteClientBitrateLimit). These settings previously had no
|
||||
declarative option -- they could only be set via the web dashboard or
|
||||
by hand-editing network.xml, with no guarantee they would survive a
|
||||
reinstall or be consistent across deployments.
|
||||
|
||||
Implementation:
|
||||
- Adds a networkXmlText template alongside the existing encodingXmlText.
|
||||
- Factors the force-vs-soft install logic out of preStart into a
|
||||
small 'manage_config_xml' shell helper; encoding.xml and network.xml
|
||||
now share the same install/backup semantics.
|
||||
- Extends the VM test with a machineWithNetworkConfig node and a
|
||||
subtest that verifies the declared values land in network.xml,
|
||||
Jellyfin parses them at startup, and the backup-on-overwrite path
|
||||
works (same shape as the existing 'Force encoding config' subtest).
|
||||
---
|
||||
nixos/modules/services/misc/jellyfin.nix | 303 ++++++++++++++++++++---
|
||||
nixos/tests/jellyfin.nix | 50 ++++
|
||||
2 files changed, 317 insertions(+), 36 deletions(-)
|
||||
|
||||
diff --git a/nixos/modules/services/misc/jellyfin.nix b/nixos/modules/services/misc/jellyfin.nix
|
||||
index 5c08fc478e45..387da907c652 100644
|
||||
--- a/nixos/modules/services/misc/jellyfin.nix
|
||||
+++ b/nixos/modules/services/misc/jellyfin.nix
|
||||
@@ -26,8 +26,10 @@ let
|
||||
bool
|
||||
enum
|
||||
ints
|
||||
+ listOf
|
||||
nullOr
|
||||
path
|
||||
+ port
|
||||
str
|
||||
submodule
|
||||
;
|
||||
@@ -68,6 +70,41 @@ let
|
||||
</EncodingOptions>
|
||||
'';
|
||||
encodingXmlFile = pkgs.writeText "encoding.xml" encodingXmlText;
|
||||
+ stringListToXml =
|
||||
+ tag: items:
|
||||
+ if items == [ ] then
|
||||
+ "<${tag} />"
|
||||
+ else
|
||||
+ "<${tag}>\n ${
|
||||
+ concatMapStringsSep "\n " (item: "<string>${escapeXML item}</string>") items
|
||||
+ }\n </${tag}>";
|
||||
+ networkXmlText = ''
|
||||
+ <?xml version="1.0" encoding="utf-8"?>
|
||||
+ <NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
+ <BaseUrl>${escapeXML cfg.network.baseUrl}</BaseUrl>
|
||||
+ <EnableHttps>${boolToString cfg.network.enableHttps}</EnableHttps>
|
||||
+ <RequireHttps>${boolToString cfg.network.requireHttps}</RequireHttps>
|
||||
+ <InternalHttpPort>${toString cfg.network.internalHttpPort}</InternalHttpPort>
|
||||
+ <InternalHttpsPort>${toString cfg.network.internalHttpsPort}</InternalHttpsPort>
|
||||
+ <PublicHttpPort>${toString cfg.network.publicHttpPort}</PublicHttpPort>
|
||||
+ <PublicHttpsPort>${toString cfg.network.publicHttpsPort}</PublicHttpsPort>
|
||||
+ <AutoDiscovery>${boolToString cfg.network.autoDiscovery}</AutoDiscovery>
|
||||
+ <EnableUPnP>${boolToString cfg.network.enableUPnP}</EnableUPnP>
|
||||
+ <EnableIPv4>${boolToString cfg.network.enableIPv4}</EnableIPv4>
|
||||
+ <EnableIPv6>${boolToString cfg.network.enableIPv6}</EnableIPv6>
|
||||
+ <EnableRemoteAccess>${boolToString cfg.network.enableRemoteAccess}</EnableRemoteAccess>
|
||||
+ ${stringListToXml "LocalNetworkSubnets" cfg.network.localNetworkSubnets}
|
||||
+ ${stringListToXml "LocalNetworkAddresses" cfg.network.localNetworkAddresses}
|
||||
+ ${stringListToXml "KnownProxies" cfg.network.knownProxies}
|
||||
+ <IgnoreVirtualInterfaces>${boolToString cfg.network.ignoreVirtualInterfaces}</IgnoreVirtualInterfaces>
|
||||
+ ${stringListToXml "VirtualInterfaceNames" cfg.network.virtualInterfaceNames}
|
||||
+ <EnablePublishedServerUriByRequest>${boolToString cfg.network.enablePublishedServerUriByRequest}</EnablePublishedServerUriByRequest>
|
||||
+ ${stringListToXml "PublishedServerUriBySubnet" cfg.network.publishedServerUriBySubnet}
|
||||
+ ${stringListToXml "RemoteIPFilter" cfg.network.remoteIPFilter}
|
||||
+ <IsRemoteIPFilterBlacklist>${boolToString cfg.network.isRemoteIPFilterBlacklist}</IsRemoteIPFilterBlacklist>
|
||||
+ </NetworkConfiguration>
|
||||
+ '';
|
||||
+ networkXmlFile = pkgs.writeText "network.xml" networkXmlText;
|
||||
codecListToType =
|
||||
desc: list:
|
||||
submodule {
|
||||
@@ -205,6 +242,196 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
+ network = {
|
||||
+ baseUrl = mkOption {
|
||||
+ type = str;
|
||||
+ default = "";
|
||||
+ example = "/jellyfin";
|
||||
+ description = ''
|
||||
+ Prefix added to Jellyfin's internal URLs when it sits behind a reverse proxy at a sub-path.
|
||||
+ Leave empty when Jellyfin is served at the root of its host.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ enableHttps = mkOption {
|
||||
+ type = bool;
|
||||
+ default = false;
|
||||
+ description = ''
|
||||
+ Serve HTTPS directly from Jellyfin. Usually unnecessary when terminating TLS in a reverse proxy.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ requireHttps = mkOption {
|
||||
+ type = bool;
|
||||
+ default = false;
|
||||
+ description = ''
|
||||
+ Redirect plaintext HTTP requests to HTTPS. Only meaningful when {option}`enableHttps` is true.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ internalHttpPort = mkOption {
|
||||
+ type = port;
|
||||
+ default = 8096;
|
||||
+ description = "TCP port Jellyfin binds for HTTP.";
|
||||
+ };
|
||||
+
|
||||
+ internalHttpsPort = mkOption {
|
||||
+ type = port;
|
||||
+ default = 8920;
|
||||
+ description = "TCP port Jellyfin binds for HTTPS. Only used when {option}`enableHttps` is true.";
|
||||
+ };
|
||||
+
|
||||
+ publicHttpPort = mkOption {
|
||||
+ type = port;
|
||||
+ default = 8096;
|
||||
+ description = "HTTP port Jellyfin advertises in server discovery responses and published URIs.";
|
||||
+ };
|
||||
+
|
||||
+ publicHttpsPort = mkOption {
|
||||
+ type = port;
|
||||
+ default = 8920;
|
||||
+ description = "HTTPS port Jellyfin advertises in server discovery responses and published URIs.";
|
||||
+ };
|
||||
+
|
||||
+ autoDiscovery = mkOption {
|
||||
+ type = bool;
|
||||
+ default = true;
|
||||
+ description = "Respond to LAN client auto-discovery broadcasts (UDP 7359).";
|
||||
+ };
|
||||
+
|
||||
+ enableUPnP = mkOption {
|
||||
+ type = bool;
|
||||
+ default = false;
|
||||
+ description = "Attempt to open the public ports on the router via UPnP.";
|
||||
+ };
|
||||
+
|
||||
+ enableIPv4 = mkOption {
|
||||
+ type = bool;
|
||||
+ default = true;
|
||||
+ description = "Listen on IPv4.";
|
||||
+ };
|
||||
+
|
||||
+ enableIPv6 = mkOption {
|
||||
+ type = bool;
|
||||
+ default = true;
|
||||
+ description = "Listen on IPv6.";
|
||||
+ };
|
||||
+
|
||||
+ enableRemoteAccess = mkOption {
|
||||
+ type = bool;
|
||||
+ default = true;
|
||||
+ description = ''
|
||||
+ Allow connections from clients outside the subnets listed in {option}`localNetworkSubnets`.
|
||||
+ When false, Jellyfin rejects non-local requests regardless of reverse proxy configuration.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ localNetworkSubnets = mkOption {
|
||||
+ type = listOf str;
|
||||
+ default = [ ];
|
||||
+ example = [
|
||||
+ "192.168.1.0/24"
|
||||
+ "10.0.0.0/8"
|
||||
+ ];
|
||||
+ description = ''
|
||||
+ CIDR ranges (or bare IPs) that Jellyfin classifies as the local network.
|
||||
+ Clients originating from these ranges -- as seen after {option}`knownProxies` X-Forwarded-For
|
||||
+ unwrapping -- are not subject to {option}`services.jellyfin` remote-client bitrate limits.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ localNetworkAddresses = mkOption {
|
||||
+ type = listOf str;
|
||||
+ default = [ ];
|
||||
+ example = [ "192.168.1.50" ];
|
||||
+ description = ''
|
||||
+ Specific interface addresses Jellyfin binds to. Leave empty to bind all interfaces.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ knownProxies = mkOption {
|
||||
+ type = listOf str;
|
||||
+ default = [ ];
|
||||
+ example = [ "127.0.0.1" ];
|
||||
+ description = ''
|
||||
+ Addresses of reverse proxies trusted to forward the real client IP via `X-Forwarded-For`.
|
||||
+ Without this, Jellyfin sees the proxy's address for every request and cannot apply
|
||||
+ {option}`localNetworkSubnets` classification to the true client.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ ignoreVirtualInterfaces = mkOption {
|
||||
+ type = bool;
|
||||
+ default = true;
|
||||
+ description = "Skip virtual network interfaces (matching {option}`virtualInterfaceNames`) during auto-bind.";
|
||||
+ };
|
||||
+
|
||||
+ virtualInterfaceNames = mkOption {
|
||||
+ type = listOf str;
|
||||
+ default = [ "veth" ];
|
||||
+ description = "Interface name prefixes treated as virtual when {option}`ignoreVirtualInterfaces` is true.";
|
||||
+ };
|
||||
+
|
||||
+ enablePublishedServerUriByRequest = mkOption {
|
||||
+ type = bool;
|
||||
+ default = false;
|
||||
+ description = ''
|
||||
+ Derive the server's public URI from the incoming request's Host header instead of any
|
||||
+ configured {option}`publishedServerUriBySubnet` entry.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ publishedServerUriBySubnet = mkOption {
|
||||
+ type = listOf str;
|
||||
+ default = [ ];
|
||||
+ example = [ "192.168.1.0/24=http://jellyfin.lan:8096" ];
|
||||
+ description = ''
|
||||
+ Per-subnet overrides for the URI Jellyfin advertises to clients, in `subnet=uri` form.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ remoteIPFilter = mkOption {
|
||||
+ type = listOf str;
|
||||
+ default = [ ];
|
||||
+ example = [ "203.0.113.0/24" ];
|
||||
+ description = ''
|
||||
+ IPs or CIDRs used as the allow- or denylist for remote access.
|
||||
+ Behaviour is controlled by {option}`isRemoteIPFilterBlacklist`.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
+ isRemoteIPFilterBlacklist = mkOption {
|
||||
+ type = bool;
|
||||
+ default = false;
|
||||
+ description = ''
|
||||
+ When true, {option}`remoteIPFilter` is a denylist; when false, it is an allowlist
|
||||
+ (and an empty list allows all remote addresses).
|
||||
+ '';
|
||||
+ };
|
||||
+ };
|
||||
+
|
||||
+ forceNetworkConfig = mkOption {
|
||||
+ type = bool;
|
||||
+ default = false;
|
||||
+ description = ''
|
||||
+ Whether to overwrite Jellyfin's `network.xml` configuration file on each service start.
|
||||
+
|
||||
+ When enabled, the network configuration specified in {option}`services.jellyfin.network`
|
||||
+ is applied on every service restart. A backup of the existing `network.xml` will be
|
||||
+ created at `network.xml.backup-$timestamp`.
|
||||
+
|
||||
+ ::: {.warning}
|
||||
+ Enabling this option means that any changes made to networking settings through
|
||||
+ Jellyfin's web dashboard will be lost on the next service restart. The NixOS configuration
|
||||
+ becomes the single source of truth for network settings.
|
||||
+ :::
|
||||
+
|
||||
+ When disabled (the default), the network configuration is only written if no `network.xml`
|
||||
+ exists yet. This allows settings to be changed through Jellyfin's web dashboard and persist
|
||||
+ across restarts, but means the NixOS configuration options will be ignored after the initial setup.
|
||||
+ '';
|
||||
+ };
|
||||
+
|
||||
transcoding = {
|
||||
maxConcurrentStreams = mkOption {
|
||||
type = nullOr ints.positive;
|
||||
@@ -384,46 +611,50 @@ in
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
- preStart = mkIf cfg.hardwareAcceleration.enable (
|
||||
- ''
|
||||
- configDir=${escapeShellArg cfg.configDir}
|
||||
- encodingXml="$configDir/encoding.xml"
|
||||
- ''
|
||||
- + (
|
||||
- if cfg.forceEncodingConfig then
|
||||
- ''
|
||||
- if [[ -e $encodingXml ]]; then
|
||||
+ preStart =
|
||||
+ let
|
||||
+ # manage_config_xml <source> <destination> <force> <description>
|
||||
+ #
|
||||
+ # Installs a NixOS-declared XML config at <destination>, preserving
|
||||
+ # any existing file as a timestamped backup when <force> is true.
|
||||
+ # With <force>=false, leaves existing files untouched and warns if
|
||||
+ # the on-disk content differs from the declared content.
|
||||
+ helper = ''
|
||||
+ manage_config_xml() {
|
||||
+ local src="$1" dest="$2" force="$3" desc="$4"
|
||||
+ if [[ -e "$dest" ]]; then
|
||||
# this intentionally removes trailing newlines
|
||||
- currentText="$(<"$encodingXml")"
|
||||
- configuredText="$(<${encodingXmlFile})"
|
||||
- if [[ $currentText == "$configuredText" ]]; then
|
||||
- # don't need to do anything
|
||||
- exit 0
|
||||
- else
|
||||
- encodingXmlBackup="$configDir/encoding.xml.backup-$(date -u +"%FT%H_%M_%SZ")"
|
||||
- mv --update=none-fail -T "$encodingXml" "$encodingXmlBackup"
|
||||
+ local currentText configuredText
|
||||
+ currentText="$(<"$dest")"
|
||||
+ configuredText="$(<"$src")"
|
||||
+ if [[ "$currentText" == "$configuredText" ]]; then
|
||||
+ return 0
|
||||
fi
|
||||
- fi
|
||||
- cp --update=none-fail -T ${encodingXmlFile} "$encodingXml"
|
||||
- chmod u+w "$encodingXml"
|
||||
- ''
|
||||
- else
|
||||
- ''
|
||||
- if [[ -e $encodingXml ]]; then
|
||||
- # this intentionally removes trailing newlines
|
||||
- currentText="$(<"$encodingXml")"
|
||||
- configuredText="$(<${encodingXmlFile})"
|
||||
- if [[ $currentText != "$configuredText" ]]; then
|
||||
- echo "WARN: $encodingXml already exists and is different from the configured settings. transcoding options NOT applied." >&2
|
||||
- echo "WARN: Set config.services.jellyfin.forceEncodingConfig = true to override." >&2
|
||||
+ if [[ "$force" == true ]]; then
|
||||
+ local backup
|
||||
+ backup="$dest.backup-$(date -u +"%FT%H_%M_%SZ")"
|
||||
+ mv --update=none-fail -T "$dest" "$backup"
|
||||
+ else
|
||||
+ echo "WARN: $dest already exists and is different from the configured settings. $desc options NOT applied." >&2
|
||||
+ echo "WARN: Set the corresponding force*Config option to override." >&2
|
||||
+ return 0
|
||||
fi
|
||||
- else
|
||||
- cp --update=none-fail -T ${encodingXmlFile} "$encodingXml"
|
||||
- chmod u+w "$encodingXml"
|
||||
fi
|
||||
- ''
|
||||
- )
|
||||
- );
|
||||
+ cp --update=none-fail -T "$src" "$dest"
|
||||
+ chmod u+w "$dest"
|
||||
+ }
|
||||
+ configDir=${escapeShellArg cfg.configDir}
|
||||
+ '';
|
||||
+ in
|
||||
+ (
|
||||
+ helper
|
||||
+ + optionalString cfg.hardwareAcceleration.enable ''
|
||||
+ manage_config_xml ${encodingXmlFile} "$configDir/encoding.xml" ${boolToString cfg.forceEncodingConfig} transcoding
|
||||
+ ''
|
||||
+ + ''
|
||||
+ manage_config_xml ${networkXmlFile} "$configDir/network.xml" ${boolToString cfg.forceNetworkConfig} network
|
||||
+ ''
|
||||
+ );
|
||||
|
||||
# This is mostly follows: https://github.com/jellyfin/jellyfin/blob/master/fedora/jellyfin.service
|
||||
# Upstream also disable some hardenings when running in LXC, we do the same with the isContainer option
|
||||
diff --git a/nixos/tests/jellyfin.nix b/nixos/tests/jellyfin.nix
|
||||
index 4896c13d4eca..0c9191960f78 100644
|
||||
--- a/nixos/tests/jellyfin.nix
|
||||
+++ b/nixos/tests/jellyfin.nix
|
||||
@@ -63,6 +63,26 @@
|
||||
environment.systemPackages = with pkgs; [ ffmpeg ];
|
||||
virtualisation.diskSize = 3 * 1024;
|
||||
};
|
||||
+
|
||||
+ machineWithNetworkConfig = {
|
||||
+ services.jellyfin = {
|
||||
+ enable = true;
|
||||
+ forceNetworkConfig = true;
|
||||
+ network = {
|
||||
+ localNetworkSubnets = [
|
||||
+ "192.168.1.0/24"
|
||||
+ "10.0.0.0/8"
|
||||
+ ];
|
||||
+ knownProxies = [ "127.0.0.1" ];
|
||||
+ enableUPnP = false;
|
||||
+ enableIPv6 = false;
|
||||
+ remoteIPFilter = [ "203.0.113.5" ];
|
||||
+ isRemoteIPFilterBlacklist = true;
|
||||
+ };
|
||||
+ };
|
||||
+ environment.systemPackages = with pkgs; [ ffmpeg ];
|
||||
+ virtualisation.diskSize = 3 * 1024;
|
||||
+ };
|
||||
};
|
||||
|
||||
# Documentation of the Jellyfin API: https://api.jellyfin.org/
|
||||
@@ -122,6 +142,36 @@
|
||||
# Verify the new encoding.xml does not have the marker (was overwritten)
|
||||
machineWithForceConfig.fail("grep -q 'MARKER' /var/lib/jellyfin/config/encoding.xml")
|
||||
|
||||
+ # Test forceNetworkConfig and network.xml generation
|
||||
+ with subtest("Force network config writes declared values and backs up on overwrite"):
|
||||
+ wait_for_jellyfin(machineWithNetworkConfig)
|
||||
+
|
||||
+ # Verify network.xml exists and contains the declared values
|
||||
+ machineWithNetworkConfig.succeed("test -f /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<string>192.168.1.0/24</string>' /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<string>10.0.0.0/8</string>' /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<string>127.0.0.1</string>' /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<string>203.0.113.5</string>' /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<IsRemoteIPFilterBlacklist>true</IsRemoteIPFilterBlacklist>' /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<EnableIPv6>false</EnableIPv6>' /var/lib/jellyfin/config/network.xml")
|
||||
+ machineWithNetworkConfig.succeed("grep -F '<EnableUPnP>false</EnableUPnP>' /var/lib/jellyfin/config/network.xml")
|
||||
+
|
||||
+ # Stop service before modifying config
|
||||
+ machineWithNetworkConfig.succeed("systemctl stop jellyfin.service")
|
||||
+
|
||||
+ # Plant a marker so we can prove the backup-and-overwrite path runs
|
||||
+ machineWithNetworkConfig.succeed("echo '<!-- NETMARKER -->' > /var/lib/jellyfin/config/network.xml")
|
||||
+
|
||||
+ # Restart the service to trigger the backup
|
||||
+ machineWithNetworkConfig.succeed("systemctl restart jellyfin.service")
|
||||
+ wait_for_jellyfin(machineWithNetworkConfig)
|
||||
+
|
||||
+ # Verify the marked content was preserved as a timestamped backup
|
||||
+ machineWithNetworkConfig.succeed("grep -q 'NETMARKER' /var/lib/jellyfin/config/network.xml.backup-*")
|
||||
+
|
||||
+ # Verify the new network.xml does not have the marker (was overwritten)
|
||||
+ machineWithNetworkConfig.fail("grep -q 'NETMARKER' /var/lib/jellyfin/config/network.xml")
|
||||
+
|
||||
auth_header = 'MediaBrowser Client="NixOS Integration Tests", DeviceId="1337", Device="Apple II", Version="20.09"'
|
||||
|
||||
|
||||
--
|
||||
2.53.0
|
||||
|
||||
@@ -81,6 +81,12 @@ rec {
|
||||
port = 6011;
|
||||
proto = "tcp";
|
||||
};
|
||||
# Webhook receiver for the Jellyfin-qBittorrent monitor — Jellyfin pushes
|
||||
# playback events here so throttling reacts without waiting for the poll.
|
||||
jellyfin_qbittorrent_monitor_webhook = {
|
||||
port = 9898;
|
||||
proto = "tcp";
|
||||
};
|
||||
bitmagnet = {
|
||||
port = 3333;
|
||||
proto = "tcp";
|
||||
|
||||
@@ -5,14 +5,67 @@
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
webhookPlugin = import ./jellyfin-webhook-plugin.nix { inherit pkgs lib; };
|
||||
jellyfinPort = service_configs.ports.private.jellyfin.port;
|
||||
webhookPort = service_configs.ports.private.jellyfin_qbittorrent_monitor_webhook.port;
|
||||
in
|
||||
lib.mkIf config.services.jellyfin.enable {
|
||||
# Materialise the Jellyfin Webhook plugin into Jellyfin's plugins dir before
|
||||
# Jellyfin starts. Jellyfin rewrites meta.json at runtime, so a read-only
|
||||
# nix-store symlink would EACCES — we copy instead.
|
||||
systemd.services.jellyfin-webhook-install = {
|
||||
before = [ "jellyfin.service" ];
|
||||
wantedBy = [ "jellyfin.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = config.services.jellyfin.user;
|
||||
Group = config.services.jellyfin.group;
|
||||
ExecStart = webhookPlugin.mkInstallScript {
|
||||
pluginsDir = "${config.services.jellyfin.dataDir}/plugins";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# After Jellyfin starts, POST the plugin configuration so the webhook
|
||||
# targets the monitor's receiver. Idempotent; runs on every boot.
|
||||
systemd.services.jellyfin-webhook-configure = {
|
||||
after = [ "jellyfin.service" ];
|
||||
wants = [ "jellyfin.service" ];
|
||||
before = [ "jellyfin-qbittorrent-monitor.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
DynamicUser = true;
|
||||
LoadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
|
||||
ExecStart = webhookPlugin.mkConfigureScript {
|
||||
jellyfinUrl = "http://127.0.0.1:${toString jellyfinPort}";
|
||||
webhooks = [
|
||||
{
|
||||
name = "qBittorrent Monitor";
|
||||
uri = "http://127.0.0.1:${toString webhookPort}/";
|
||||
notificationTypes = [
|
||||
"PlaybackStart"
|
||||
"PlaybackProgress"
|
||||
"PlaybackStop"
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services."jellyfin-qbittorrent-monitor" = {
|
||||
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
||||
after = [
|
||||
"network.target"
|
||||
"jellyfin.service"
|
||||
"qbittorrent.service"
|
||||
"jellyfin-webhook-configure.service"
|
||||
];
|
||||
wants = [ "jellyfin-webhook-configure.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
@@ -44,7 +97,7 @@ lib.mkIf config.services.jellyfin.enable {
|
||||
};
|
||||
|
||||
environment = {
|
||||
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.private.jellyfin.port}";
|
||||
JELLYFIN_URL = "http://localhost:${builtins.toString jellyfinPort}";
|
||||
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.torrent.port}";
|
||||
CHECK_INTERVAL = "30";
|
||||
# Bandwidth budget configuration
|
||||
@@ -53,6 +106,9 @@ lib.mkIf config.services.jellyfin.enable {
|
||||
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
|
||||
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
|
||||
STREAM_BITRATE_HEADROOM = "1.1"; # multiplier per stream for bitrate fluctuations
|
||||
# Webhook receiver: Jellyfin Webhook plugin POSTs events here to throttle immediately.
|
||||
WEBHOOK_BIND = "127.0.0.1";
|
||||
WEBHOOK_PORT = toString webhookPort;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import sys
|
||||
import signal
|
||||
import json
|
||||
import ipaddress
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
@@ -34,6 +36,8 @@ class JellyfinQBittorrentMonitor:
|
||||
default_stream_bitrate=10000000,
|
||||
min_torrent_speed=100,
|
||||
stream_bitrate_headroom=1.1,
|
||||
webhook_port=0,
|
||||
webhook_bind="127.0.0.1",
|
||||
):
|
||||
self.jellyfin_url = jellyfin_url
|
||||
self.qbittorrent_url = qbittorrent_url
|
||||
@@ -57,6 +61,12 @@ class JellyfinQBittorrentMonitor:
|
||||
self.streaming_stop_delay = streaming_stop_delay
|
||||
self.last_state_change = 0
|
||||
|
||||
# Webhook receiver: allows Jellyfin to push events instead of waiting for the poll
|
||||
self.webhook_port = webhook_port
|
||||
self.webhook_bind = webhook_bind
|
||||
self.wake_event = threading.Event()
|
||||
self.webhook_server = None
|
||||
|
||||
# Local network ranges (RFC 1918 private networks + localhost)
|
||||
self.local_networks = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
@@ -79,9 +89,56 @@ class JellyfinQBittorrentMonitor:
|
||||
def signal_handler(self, signum, frame):
|
||||
logger.info("Received shutdown signal, cleaning up...")
|
||||
self.running = False
|
||||
if self.webhook_server is not None:
|
||||
# shutdown() blocks until serve_forever returns; run from a thread so we don't deadlock
|
||||
threading.Thread(target=self.webhook_server.shutdown, daemon=True).start()
|
||||
self.restore_normal_limits()
|
||||
sys.exit(0)
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Signal the main loop to re-evaluate state immediately."""
|
||||
self.wake_event.set()
|
||||
|
||||
def sleep_or_wake(self, seconds: float) -> None:
|
||||
"""Wait up to `seconds`, returning early if a webhook wakes the loop."""
|
||||
self.wake_event.wait(seconds)
|
||||
self.wake_event.clear()
|
||||
|
||||
def start_webhook_server(self) -> None:
|
||||
"""Start a background HTTP server that wakes the monitor on any POST."""
|
||||
if not self.webhook_port:
|
||||
return
|
||||
|
||||
monitor = self
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(min(length, 65536)) if length else b""
|
||||
event = "unknown"
|
||||
try:
|
||||
if body:
|
||||
event = json.loads(body).get("NotificationType", "unknown")
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
logger.info(f"Webhook received: {event}")
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
monitor.wake()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return # suppress default access log
|
||||
|
||||
self.webhook_server = HTTPServer(
|
||||
(self.webhook_bind, self.webhook_port), WebhookHandler
|
||||
)
|
||||
threading.Thread(
|
||||
target=self.webhook_server.serve_forever, daemon=True, name="webhook-server"
|
||||
).start()
|
||||
logger.info(
|
||||
f"Webhook receiver listening on http://{self.webhook_bind}:{self.webhook_port}"
|
||||
)
|
||||
|
||||
def check_jellyfin_sessions(self) -> list[dict]:
|
||||
headers = (
|
||||
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
||||
@@ -297,10 +354,14 @@ 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")
|
||||
if self.webhook_port:
|
||||
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
|
||||
|
||||
signal.signal(signal.SIGINT, self.signal_handler)
|
||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||
|
||||
self.start_webhook_server()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self.sync_qbittorrent_state()
|
||||
@@ -309,7 +370,7 @@ class JellyfinQBittorrentMonitor:
|
||||
active_streams = self.check_jellyfin_sessions()
|
||||
except ServiceUnavailable:
|
||||
logger.warning("Jellyfin unavailable, maintaining current state")
|
||||
time.sleep(self.check_interval)
|
||||
self.sleep_or_wake(self.check_interval)
|
||||
continue
|
||||
|
||||
streaming_active = len(active_streams) > 0
|
||||
@@ -394,13 +455,13 @@ class JellyfinQBittorrentMonitor:
|
||||
|
||||
self.current_state = desired_state
|
||||
self.last_active_streams = active_streams
|
||||
time.sleep(self.check_interval)
|
||||
self.sleep_or_wake(self.check_interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in monitoring loop: {e}")
|
||||
time.sleep(self.check_interval)
|
||||
self.sleep_or_wake(self.check_interval)
|
||||
|
||||
self.restore_normal_limits()
|
||||
logger.info("Monitor stopped")
|
||||
@@ -421,6 +482,8 @@ if __name__ == "__main__":
|
||||
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
|
||||
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
|
||||
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")
|
||||
|
||||
monitor = JellyfinQBittorrentMonitor(
|
||||
jellyfin_url=jellyfin_url,
|
||||
@@ -434,6 +497,8 @@ if __name__ == "__main__":
|
||||
default_stream_bitrate=default_stream_bitrate,
|
||||
min_torrent_speed=min_torrent_speed,
|
||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||
webhook_port=webhook_port,
|
||||
webhook_bind=webhook_bind,
|
||||
)
|
||||
|
||||
monitor.run()
|
||||
|
||||
105
services/jellyfin/jellyfin-webhook-plugin.nix
Normal file
105
services/jellyfin/jellyfin-webhook-plugin.nix
Normal file
@@ -0,0 +1,105 @@
|
||||
{ pkgs, lib }:
|
||||
let
|
||||
pluginVersion = "18.0.0.0";
|
||||
# GUID from the plugin's meta.json; addresses it on /Plugins/<guid>/Configuration.
|
||||
pluginGuid = "71552a5a-5c5c-4350-a2ae-ebe451a30173";
|
||||
|
||||
package = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "jellyfin-plugin-webhook";
|
||||
version = pluginVersion;
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://repo.jellyfin.org/files/plugin/webhook/webhook_${pluginVersion}.zip";
|
||||
hash = "sha256-LFFojiPnBGl9KJ0xVyPBnCmatcaeVbllRwRkz5Z3dqI=";
|
||||
};
|
||||
nativeBuildInputs = [ pkgs.unzip ];
|
||||
unpackPhase = ''unzip "$src"'';
|
||||
installPhase = ''
|
||||
mkdir -p "$out"
|
||||
cp *.dll meta.json "$out/"
|
||||
'';
|
||||
dontFixup = true; # managed .NET assemblies must not be patched
|
||||
};
|
||||
|
||||
# Minimal Handlebars template, base64 encoded. The monitor only needs the POST;
|
||||
# NotificationType is parsed for the debug log line.
|
||||
# Decoded: {"NotificationType":"{{NotificationType}}"}
|
||||
templateB64 = "eyJOb3RpZmljYXRpb25UeXBlIjoie3tOb3RpZmljYXRpb25UeXBlfX0ifQ==";
|
||||
|
||||
# Build a PluginConfiguration payload accepted by Jellyfin's JSON deserializer.
|
||||
# Each webhook is `{ name, uri, notificationTypes }`.
|
||||
mkConfigJson =
|
||||
webhooks:
|
||||
builtins.toJSON {
|
||||
ServerUrl = "";
|
||||
GenericOptions = map (w: {
|
||||
NotificationTypes = w.notificationTypes;
|
||||
WebhookName = w.name;
|
||||
WebhookUri = w.uri;
|
||||
EnableMovies = true;
|
||||
EnableEpisodes = true;
|
||||
EnableVideos = true;
|
||||
EnableWebhook = true;
|
||||
Template = templateB64;
|
||||
Headers = [
|
||||
{
|
||||
Key = "Content-Type";
|
||||
Value = "application/json";
|
||||
}
|
||||
];
|
||||
}) webhooks;
|
||||
};
|
||||
|
||||
# Oneshot that POSTs the plugin configuration. Retries past the window
|
||||
# between Jellyfin API health and plugin registration.
|
||||
mkConfigureScript =
|
||||
{ jellyfinUrl, webhooks }:
|
||||
pkgs.writeShellScript "jellyfin-webhook-configure" ''
|
||||
set -euo pipefail
|
||||
export PATH=${
|
||||
lib.makeBinPath [
|
||||
pkgs.coreutils
|
||||
pkgs.curl
|
||||
]
|
||||
}
|
||||
|
||||
URL=${lib.escapeShellArg jellyfinUrl}
|
||||
AUTH="Authorization: MediaBrowser Token=\"$(cat "$CREDENTIALS_DIRECTORY/jellyfin-api-key")\""
|
||||
CONFIG=${lib.escapeShellArg (mkConfigJson webhooks)}
|
||||
|
||||
for _ in $(seq 1 120); do curl -sf -o /dev/null "$URL/health" && break; sleep 1; done
|
||||
curl -sf -o /dev/null "$URL/health"
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if printf '%s' "$CONFIG" | curl -sf -X POST \
|
||||
-H "$AUTH" -H "Content-Type: application/json" --data-binary @- \
|
||||
"$URL/Plugins/${pluginGuid}/Configuration"; then
|
||||
echo "Jellyfin webhook plugin configured"; exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Failed to configure webhook plugin" >&2; exit 1
|
||||
'';
|
||||
|
||||
# Materialise a writable copy of the plugin. Jellyfin rewrites meta.json at
|
||||
# runtime, so a read-only nix-store symlink would EACCES.
|
||||
mkInstallScript =
|
||||
{ pluginsDir }:
|
||||
pkgs.writeShellScript "jellyfin-webhook-install" ''
|
||||
set -euo pipefail
|
||||
export PATH=${lib.makeBinPath [ pkgs.coreutils ]}
|
||||
dst=${lib.escapeShellArg "${pluginsDir}/Webhook_${pluginVersion}"}
|
||||
mkdir -p ${lib.escapeShellArg pluginsDir}
|
||||
rm -rf "$dst" && mkdir -p "$dst"
|
||||
cp ${package}/*.dll ${package}/meta.json "$dst/"
|
||||
chmod u+rw "$dst"/*
|
||||
'';
|
||||
in
|
||||
{
|
||||
inherit
|
||||
package
|
||||
pluginVersion
|
||||
pluginGuid
|
||||
mkConfigureScript
|
||||
mkInstallScript
|
||||
;
|
||||
}
|
||||
@@ -6,6 +6,21 @@
|
||||
}:
|
||||
let
|
||||
jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; };
|
||||
webhookPlugin = import ../services/jellyfin/jellyfin-webhook-plugin.nix { inherit pkgs lib; };
|
||||
configureWebhook = webhookPlugin.mkConfigureScript {
|
||||
jellyfinUrl = "http://localhost:8096";
|
||||
webhooks = [
|
||||
{
|
||||
name = "qBittorrent Monitor";
|
||||
uri = "http://127.0.0.1:9898/";
|
||||
notificationTypes = [
|
||||
"PlaybackStart"
|
||||
"PlaybackProgress"
|
||||
"PlaybackStop"
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "jellyfin-qbittorrent-monitor";
|
||||
@@ -69,11 +84,30 @@ pkgs.testers.runNixOSTest {
|
||||
}
|
||||
];
|
||||
|
||||
# Create directories for qBittorrent
|
||||
# Create directories for qBittorrent.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent"
|
||||
"d /var/lib/qbittorrent/incomplete 0755 qbittorrent qbittorrent"
|
||||
];
|
||||
|
||||
# Install the Jellyfin Webhook plugin before Jellyfin starts, mirroring
|
||||
# the production module. Jellyfin rewrites meta.json at runtime so a
|
||||
# read-only nix-store symlink would fail — we materialise a writable copy.
|
||||
systemd.services."jellyfin-webhook-install" = {
|
||||
description = "Install Jellyfin Webhook plugin files";
|
||||
before = [ "jellyfin.service" ];
|
||||
wantedBy = [ "jellyfin.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = "jellyfin";
|
||||
Group = "jellyfin";
|
||||
UMask = "0077";
|
||||
ExecStart = webhookPlugin.mkInstallScript {
|
||||
pluginsDir = "/var/lib/jellyfin/plugins";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Public test IP (RFC 5737 TEST-NET-3) so Jellyfin sees it as external
|
||||
@@ -394,6 +428,97 @@ 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}'")
|
||||
|
||||
# === WEBHOOK TESTS ===
|
||||
#
|
||||
# Configure the Jellyfin Webhook plugin to target the monitor, then verify
|
||||
# the real Jellyfin → plugin → monitor path reacts faster than any possible
|
||||
# poll. CHECK_INTERVAL=30 rules out polling as the cause.
|
||||
|
||||
WEBHOOK_PORT = 9898
|
||||
WEBHOOK_CREDS = "/tmp/webhook-creds"
|
||||
|
||||
# Start a webhook-enabled monitor with long poll interval.
|
||||
server.succeed("systemctl stop monitor-test || true")
|
||||
time.sleep(1)
|
||||
server.succeed(f"""
|
||||
systemd-run --unit=monitor-webhook \
|
||||
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
||||
--setenv=JELLYFIN_API_KEY={token} \
|
||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
||||
--setenv=CHECK_INTERVAL=30 \
|
||||
--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=WEBHOOK_PORT={WEBHOOK_PORT} \
|
||||
--setenv=WEBHOOK_BIND=127.0.0.1 \
|
||||
{python} {monitor}
|
||||
""")
|
||||
server.wait_until_succeeds(f"ss -ltn | grep -q ':{WEBHOOK_PORT}'", timeout=15)
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should start unthrottled"
|
||||
|
||||
# Drop the admin token where the configure script expects it (production uses agenix).
|
||||
server.succeed(f"mkdir -p {WEBHOOK_CREDS} && echo '{token}' > {WEBHOOK_CREDS}/jellyfin-api-key")
|
||||
server.succeed(
|
||||
f"systemd-run --wait --unit=webhook-configure-test "
|
||||
f"--setenv=CREDENTIALS_DIRECTORY={WEBHOOK_CREDS} "
|
||||
f"${configureWebhook}"
|
||||
)
|
||||
|
||||
with subtest("Real PlaybackStart event throttles via the plugin"):
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-plugin-start",
|
||||
"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)
|
||||
server.wait_until_succeeds(
|
||||
"curl -sf http://localhost:8080/api/v2/transfer/speedLimitsMode | grep -q '^1$'",
|
||||
timeout=5,
|
||||
)
|
||||
# Let STREAMING_STOP_DELAY (1s) elapse so the upcoming stop is not swallowed by hysteresis.
|
||||
time.sleep(2)
|
||||
|
||||
with subtest("Real PlaybackStop event unthrottles via the plugin"):
|
||||
playback_stop = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-plugin-start",
|
||||
"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)
|
||||
server.wait_until_succeeds(
|
||||
"curl -sf http://localhost:8080/api/v2/transfer/speedLimitsMode | grep -q '^0$'",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Restore fast-polling monitor for the service-restart tests below.
|
||||
server.succeed("systemctl stop monitor-webhook || 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)
|
||||
|
||||
|
||||
# === SERVICE RESTART TESTS ===
|
||||
|
||||
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
||||
|
||||
Reference in New Issue
Block a user