Compare commits
7 Commits
cebdd3ea96
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d839afb70b | ||
|
|
96a0162b4e | ||
| 4bc5d57fa6 | |||
| 1403c9d3bc | |||
| 48ac68c297 | |||
| fc548a137f | |||
| 9ea45d4558 |
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# server-config (archived)
|
||||||
|
|
||||||
|
This repository has been unified with its sibling `dotfiles` into
|
||||||
|
[**titaniumtown/nixos**](https://git.sigkill.computer/titaniumtown/nixos).
|
||||||
|
|
||||||
|
The final pre-unification commit is tagged `final-before-unify`.
|
||||||
|
|
||||||
|
See the new repo's `README.md` and `AGENTS.md` for:
|
||||||
|
|
||||||
|
- current flake layout (hosts: mreow, yarn, muffin)
|
||||||
|
- deploy workflow
|
||||||
|
- git-crypt / agenix setup
|
||||||
|
|
||||||
|
Do **not** push new commits here — CI has been disabled, and muffin's harmonia
|
||||||
|
binary-cache no longer serves paths from `/var/lib/dotfiles-deploy/`.
|
||||||
@@ -168,6 +168,15 @@
|
|||||||
group = "gitea-runner";
|
group = "gitea-runner";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Git-crypt symmetric key for the new unified nixos repo (Phase 5 of the unify migration).
|
||||||
|
# Added additively here so muffin can decrypt nixos's secrets once Phase 6 cuts CI over.
|
||||||
|
git-crypt-key-nixos = {
|
||||||
|
file = ../secrets/git-crypt-key-nixos.age;
|
||||||
|
mode = "0400";
|
||||||
|
owner = "gitea-runner";
|
||||||
|
group = "gitea-runner";
|
||||||
|
};
|
||||||
|
|
||||||
# Gitea Actions runner registration token
|
# Gitea Actions runner registration token
|
||||||
gitea-runner-token = {
|
gitea-runner-token = {
|
||||||
file = ../secrets/gitea-runner-token.age;
|
file = ../secrets/gitea-runner-token.age;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let
|
|||||||
parent=''${1%%[0-9]*}
|
parent=''${1%%[0-9]*}
|
||||||
dev="/sys/block/$parent"
|
dev="/sys/block/$parent"
|
||||||
[ -d "$dev/queue/iosched" ] || exit 0
|
[ -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 15000 > "$dev/queue/iosched/write_expire"
|
||||||
echo 128 > "$dev/queue/iosched/fifo_batch"
|
echo 128 > "$dev/queue/iosched/fifo_batch"
|
||||||
echo 16 > "$dev/queue/iosched/writes_starved"
|
echo 16 > "$dev/queue/iosched/writes_starved"
|
||||||
@@ -36,11 +36,17 @@ in
|
|||||||
hardware.cpu.amd.updateMicrocode = true;
|
hardware.cpu.amd.updateMicrocode = true;
|
||||||
hardware.enableRedistributableFirmware = 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.
|
# mq-deadline sorts requests into elevator sweeps, reducing seek distance.
|
||||||
# Aggressive deadlines (15s) let the scheduler accumulate more ops before dispatching,
|
# read_expire=500ms keeps reads bounded so a Jellyfin segment can't queue for
|
||||||
# maximizing coalescence — latency is irrelevant since torrent peers tolerate 30-60s.
|
# 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.
|
# fifo_batch=128 keeps sweeps long; writes_starved=16 heavily favors reads.
|
||||||
# 4 MiB readahead matches libtorrent piece extent affinity for sequential prefetch.
|
# 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
|
||||||
|
|
||||||
BIN
secrets/git-crypt-key-nixos.age
Normal file
BIN
secrets/git-crypt-key-nixos.age
Normal file
Binary file not shown.
@@ -81,6 +81,12 @@ rec {
|
|||||||
port = 6011;
|
port = 6011;
|
||||||
proto = "tcp";
|
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 = {
|
bitmagnet = {
|
||||||
port = 3333;
|
port = 3333;
|
||||||
proto = "tcp";
|
proto = "tcp";
|
||||||
|
|||||||
@@ -5,14 +5,80 @@
|
|||||||
lib,
|
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 {
|
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.
|
||||||
|
#
|
||||||
|
# `wantedBy = [ "jellyfin.service" ]` alone is insufficient on initial rollout:
|
||||||
|
# if jellyfin is already running at activation time, systemd won't start the
|
||||||
|
# oneshot until the next jellyfin restart. `restartTriggers` on jellyfin pinned
|
||||||
|
# to the plugin package + install script forces that restart whenever either
|
||||||
|
# changes, which invokes this unit via the `before`/`wantedBy` chain.
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.jellyfin.restartTriggers = [
|
||||||
|
webhookPlugin.package
|
||||||
|
(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" = {
|
systemd.services."jellyfin-qbittorrent-monitor" = {
|
||||||
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
||||||
after = [
|
after = [
|
||||||
"network.target"
|
"network.target"
|
||||||
"jellyfin.service"
|
"jellyfin.service"
|
||||||
"qbittorrent.service"
|
"qbittorrent.service"
|
||||||
|
"jellyfin-webhook-configure.service"
|
||||||
];
|
];
|
||||||
|
wants = [ "jellyfin-webhook-configure.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -44,7 +110,7 @@ lib.mkIf config.services.jellyfin.enable {
|
|||||||
};
|
};
|
||||||
|
|
||||||
environment = {
|
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}";
|
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.torrent.port}";
|
||||||
CHECK_INTERVAL = "30";
|
CHECK_INTERVAL = "30";
|
||||||
# Bandwidth budget configuration
|
# Bandwidth budget configuration
|
||||||
@@ -53,6 +119,9 @@ lib.mkIf config.services.jellyfin.enable {
|
|||||||
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
|
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
|
||||||
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
|
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
|
||||||
STREAM_BITRATE_HEADROOM = "1.1"; # multiplier per stream for bitrate fluctuations
|
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 signal
|
||||||
import json
|
import json
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||||
@@ -34,6 +36,8 @@ class JellyfinQBittorrentMonitor:
|
|||||||
default_stream_bitrate=10000000,
|
default_stream_bitrate=10000000,
|
||||||
min_torrent_speed=100,
|
min_torrent_speed=100,
|
||||||
stream_bitrate_headroom=1.1,
|
stream_bitrate_headroom=1.1,
|
||||||
|
webhook_port=0,
|
||||||
|
webhook_bind="127.0.0.1",
|
||||||
):
|
):
|
||||||
self.jellyfin_url = jellyfin_url
|
self.jellyfin_url = jellyfin_url
|
||||||
self.qbittorrent_url = qbittorrent_url
|
self.qbittorrent_url = qbittorrent_url
|
||||||
@@ -57,6 +61,12 @@ class JellyfinQBittorrentMonitor:
|
|||||||
self.streaming_stop_delay = streaming_stop_delay
|
self.streaming_stop_delay = streaming_stop_delay
|
||||||
self.last_state_change = 0
|
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)
|
# Local network ranges (RFC 1918 private networks + localhost)
|
||||||
self.local_networks = [
|
self.local_networks = [
|
||||||
ipaddress.ip_network("10.0.0.0/8"),
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
@@ -79,9 +89,56 @@ class JellyfinQBittorrentMonitor:
|
|||||||
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
|
||||||
|
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()
|
self.restore_normal_limits()
|
||||||
sys.exit(0)
|
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]:
|
def check_jellyfin_sessions(self) -> list[dict]:
|
||||||
headers = (
|
headers = (
|
||||||
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
{"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"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")
|
||||||
|
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.SIGINT, self.signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||||
|
|
||||||
|
self.start_webhook_server()
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
self.sync_qbittorrent_state()
|
self.sync_qbittorrent_state()
|
||||||
@@ -309,7 +370,7 @@ class JellyfinQBittorrentMonitor:
|
|||||||
active_streams = self.check_jellyfin_sessions()
|
active_streams = self.check_jellyfin_sessions()
|
||||||
except ServiceUnavailable:
|
except ServiceUnavailable:
|
||||||
logger.warning("Jellyfin unavailable, maintaining current state")
|
logger.warning("Jellyfin unavailable, maintaining current state")
|
||||||
time.sleep(self.check_interval)
|
self.sleep_or_wake(self.check_interval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
streaming_active = len(active_streams) > 0
|
streaming_active = len(active_streams) > 0
|
||||||
@@ -394,13 +455,13 @@ class JellyfinQBittorrentMonitor:
|
|||||||
|
|
||||||
self.current_state = desired_state
|
self.current_state = desired_state
|
||||||
self.last_active_streams = active_streams
|
self.last_active_streams = active_streams
|
||||||
time.sleep(self.check_interval)
|
self.sleep_or_wake(self.check_interval)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in monitoring loop: {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()
|
self.restore_normal_limits()
|
||||||
logger.info("Monitor stopped")
|
logger.info("Monitor stopped")
|
||||||
@@ -421,6 +482,8 @@ if __name__ == "__main__":
|
|||||||
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
|
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
|
||||||
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
|
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
|
||||||
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_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
|
||||||
|
|
||||||
monitor = JellyfinQBittorrentMonitor(
|
monitor = JellyfinQBittorrentMonitor(
|
||||||
jellyfin_url=jellyfin_url,
|
jellyfin_url=jellyfin_url,
|
||||||
@@ -434,6 +497,8 @@ if __name__ == "__main__":
|
|||||||
default_stream_bitrate=default_stream_bitrate,
|
default_stream_bitrate=default_stream_bitrate,
|
||||||
min_torrent_speed=min_torrent_speed,
|
min_torrent_speed=min_torrent_speed,
|
||||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||||
|
webhook_port=webhook_port,
|
||||||
|
webhook_bind=webhook_bind,
|
||||||
)
|
)
|
||||||
|
|
||||||
monitor.run()
|
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
|
let
|
||||||
jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; };
|
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
|
in
|
||||||
pkgs.testers.runNixOSTest {
|
pkgs.testers.runNixOSTest {
|
||||||
name = "jellyfin-qbittorrent-monitor";
|
name = "jellyfin-qbittorrent-monitor";
|
||||||
@@ -69,11 +84,30 @@ pkgs.testers.runNixOSTest {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# Create directories for qBittorrent
|
# Create directories for qBittorrent.
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent"
|
"d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent"
|
||||||
"d /var/lib/qbittorrent/incomplete 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
|
# 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
|
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}'")
|
||||||
|
|
||||||
|
# === 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 ===
|
# === SERVICE RESTART TESTS ===
|
||||||
|
|
||||||
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
||||||
|
|||||||
Reference in New Issue
Block a user