mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
feat(youko): add Jellyfin media server (#321)
* feat(youko): add Jellyfin media server Jellyfin reads the existing Transmission download tree on the public HDD (via the shared `fileshare` group) and transcodes with the AMD Barceló iGPU through VA-API. Encoding settings are declared in Nix (`forceEncodingConfig`), so the web UI cannot silently drift. Replace the per-service public ACME certificates with a single `*.writefor.fun` wildcard (plus the apex) shared by every public vhost, so adding a service needs no new ACME entry; a wildcard also keeps individual hostnames out of public CT logs. Pin the jellyfin uid/gid to avoid the dynamic-id drift fixed for the other services in #320. * fix(youko): enable graphics for Jellyfin VA-API VA-API needs Mesa's radeonsi VA driver under /run/opengl-driver, but youko is headless so hardware.graphics was off. Without it Jellyfin's ffmpeg fails with 'va_openDriver() returns -1'. Enable it so the iGPU can be used for hardware transcoding. * fix(youko): use VA-API hardware encoding for Jellyfin Enabling hardware decoding alone left encoding on the CPU (libx264), which pins ~3 cores per 720p stream. Set transcoding.enableHardwareEncoding so the iGPU (Vega VCN) does the H.264 encode too. * feat(youko): harden Jellyfin proxy trust, groups, and HEVC encode Add jellyfin to the render/video groups for robust VA-API access (was relying on the world-writable render node), enable HEVC hardware encoding (Vega's VCN supports it; AV1 does not), and patch network.xml on start to trust Caddy as KnownProxies and advertise the public URL. * fix(youko): run Jellyfin network.xml patch every start; allow LAN discovery The module's preStart exits early once encoding.xml matches, so the mkAfter network.xml patch only ran when encoding settings changed. Run it with mkBefore instead. Also allow Jellyfin app auto-discovery (UDP 7359 / TCP 8096) from the home network only.
This commit is contained in:
1 parent
9187e4d920
commit
a111676ede
3 files changed
+139
-20
No files matched your search
@@ -11,7 +11,7 @@ let
|
||||
# certificate. Only clients that already installed the private CA (this
|
||||
# repo's desktops/servers, via `modules/base/security.nix`) trust it.
|
||||
# Mobile apps do NOT trust a private CA, so any site a phone must reach uses
|
||||
# `publicClientTlsConfig` instead of this one.
|
||||
# `publicTlsConfig` instead of this one.
|
||||
privateTlsConfig = ''
|
||||
encode zstd gzip
|
||||
tls ${mylib.relativeToRoot "certs/ecc-server.crt"} ${
|
||||
@@ -22,18 +22,20 @@ let
|
||||
}
|
||||
'';
|
||||
|
||||
# TLS for sites that must be reachable from unmodified mobile apps. The cert
|
||||
# is issued by a public CA (Let's Encrypt) so the phone app trusts it without
|
||||
# installing `ecc-ca`. Caddy does NOT issue it: auto-HTTPS is off
|
||||
# (`auto_https disable_certs`) and the private wildcard cert already covers
|
||||
# every `*.writefor.fun` name, so caddy would refuse to manage this one.
|
||||
# Instead NixOS `security.acme` (lego) runs the ACME DNS-01 challenge against
|
||||
# the Cloudflare zone (see the `security.acme` block below) and caddy only
|
||||
# loads the resulting files. DNS-01 needs no inbound ports or public IP, so
|
||||
# the service stays on the LAN; the hostname does appear in public CT logs.
|
||||
publicClientTlsConfig = domain: ''
|
||||
# TLS for sites that must be reachable from unmodified mobile/TV apps. A
|
||||
# single publicly-trusted Let's Encrypt *wildcard* cert (`*.writefor.fun`,
|
||||
# plus the apex) is issued once via the ACME DNS-01 challenge and shared by
|
||||
# every such vhost, so adding a service never needs new ACME setup. A
|
||||
# wildcard also keeps individual hostnames out of public CT logs.
|
||||
# Caddy does NOT issue it: auto-HTTPS is off (`auto_https disable_certs`) and
|
||||
# the private wildcard cert already covers every `*.writefor.fun` name, so
|
||||
# caddy would refuse to manage this one. Instead NixOS `security.acme` (lego)
|
||||
# runs the challenge against the Cloudflare zone (see the `security.acme`
|
||||
# block below) and caddy only loads the resulting files. DNS-01 needs no
|
||||
# inbound ports or public IP, so the services stay on the LAN.
|
||||
publicTlsConfig = ''
|
||||
encode zstd gzip
|
||||
tls /var/lib/acme/${domain}/fullchain.pem /var/lib/acme/${domain}/key.pem {
|
||||
tls /var/lib/acme/writefor.fun/fullchain.pem /var/lib/acme/writefor.fun/key.pem {
|
||||
protocols tls1.3 tls1.3
|
||||
curves x25519 secp384r1 secp521r1
|
||||
}
|
||||
@@ -133,14 +135,22 @@ in
|
||||
# Immich photo library (websockets for live updates). Uploads stream through
|
||||
# Caddy, which has no request-body limit by default.
|
||||
# Unlike the LAN-only services above, this one is opened from the Immich
|
||||
# mobile app, which refuses the private `ecc-ca` certificate -- so it gets a
|
||||
# publicly-trusted Let's Encrypt cert instead (see `publicClientTlsConfig`).
|
||||
# mobile app, which refuses the private `ecc-ca` certificate -- so it gets
|
||||
# the publicly-trusted Let's Encrypt wildcard (see `publicTlsConfig`).
|
||||
virtualHosts."immich.writefor.fun".extraConfig = ''
|
||||
${publicClientTlsConfig "immich.writefor.fun"}
|
||||
${publicTlsConfig}
|
||||
reverse_proxy http://localhost:2283 {
|
||||
header_up Host {http.request.host}
|
||||
}
|
||||
'';
|
||||
# Jellyfin media server (websockets for live dashboard/playstate). Like
|
||||
# immich, it is opened from phone/TV apps, so it also uses a public cert.
|
||||
virtualHosts."jellyfin.writefor.fun".extraConfig = ''
|
||||
${publicTlsConfig}
|
||||
reverse_proxy http://localhost:8096 {
|
||||
header_up Host {http.request.host}
|
||||
}
|
||||
'';
|
||||
# Every other name is not a service on this host: answer 404 instead of
|
||||
# caddy's empty 200 fallback, which hides typos.
|
||||
virtualHosts."*.writefor.fun".extraConfig = ''
|
||||
@@ -186,17 +196,20 @@ in
|
||||
# '';
|
||||
};
|
||||
|
||||
# Issue the certs for `publicClientTlsConfig` sites with lego (packaged in
|
||||
# Issue one public wildcard cert (plus the apex) with lego (packaged in
|
||||
# nixpkgs, so no custom caddy build is needed) via the ACME DNS-01 challenge
|
||||
# against the Cloudflare-managed `writefor.fun` zone. The token is an agenix
|
||||
# environment file holding `CLOUDFLARE_DNS_API_TOKEN=...`.
|
||||
# environment file holding `CLOUDFLARE_DNS_API_TOKEN=...`. The wildcard is
|
||||
# shared by every `publicTlsConfig` vhost, so new services need no new entry.
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
# lego runs as the unprivileged `acme` user by default, which cannot read
|
||||
# the root-owned agenix file; run it as root so it can.
|
||||
useRoot = true;
|
||||
defaults.email = myvars.useremail;
|
||||
certs."immich.writefor.fun" = {
|
||||
certs."writefor.fun" = {
|
||||
domain = "*.writefor.fun";
|
||||
extraDomainNames = [ "writefor.fun" ];
|
||||
dnsProvider = "cloudflare";
|
||||
environmentFile = config.age.secrets."cloudflare-dns-api-token".path;
|
||||
# lego's own propagation checks are unreliable from youko (its resolver
|
||||
@@ -220,8 +233,8 @@ in
|
||||
# caddy loads the lego-issued files at startup, so wait for the acme service;
|
||||
# otherwise its config fails to load before the cert exists.
|
||||
systemd.services.caddy = {
|
||||
wants = [ "acme-immich.writefor.fun.service" ];
|
||||
after = [ "acme-immich.writefor.fun.service" ];
|
||||
wants = [ "acme-writefor.fun.service" ];
|
||||
after = [ "acme-writefor.fun.service" ];
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Jellyfin only reads media; it does not download it. The library is the
|
||||
# existing Transmission download tree on the (public) HDD, reached through
|
||||
# the shared `fileshare` group -- no Jellyfin-owned library directory.
|
||||
mediaDir = "/data/fileshare/public";
|
||||
|
||||
# Public URL, written into network.xml so clients get a reachable address.
|
||||
domain = "jellyfin.writefor.fun";
|
||||
in
|
||||
{
|
||||
# Read access to the Transmission downloads (owned by group `fileshare`) and
|
||||
# to the GPU device nodes (`render`/`video`) for VA-API transcoding.
|
||||
users.users.jellyfin.extraGroups = [
|
||||
"fileshare"
|
||||
"render"
|
||||
"video"
|
||||
];
|
||||
|
||||
# VA-API needs Mesa's `radeonsi` driver under /run/opengl-driver. youko is a
|
||||
# headless server, so `hardware.graphics` is off by default and the driver
|
||||
# (and therefore VA-API) is missing.
|
||||
hardware.graphics.enable = true;
|
||||
|
||||
services.jellyfin = {
|
||||
enable = true;
|
||||
|
||||
# AMD Barceló iGPU: VA-API hardware transcoding. renderD128 is world
|
||||
# readable/writable, and the user is in `render`/`video` as well.
|
||||
hardwareAcceleration = {
|
||||
enable = true;
|
||||
type = "vaapi";
|
||||
device = "/dev/dri/renderD128";
|
||||
};
|
||||
|
||||
# Make the Nix configuration the source of truth for encoding.xml, so
|
||||
# changes made in the web UI cannot silently drift from this file.
|
||||
forceEncodingConfig = true;
|
||||
transcoding = {
|
||||
enableToneMapping = true;
|
||||
# Use the iGPU for encoding too. Without this Jellyfin uses VA-API only
|
||||
# for decode/scale and falls back to the CPU `libx264`, which pins
|
||||
# several cores per stream.
|
||||
enableHardwareEncoding = true;
|
||||
# Vega (Barceló) decodes these.
|
||||
hardwareDecodingCodecs = {
|
||||
h264 = true;
|
||||
hevc = true;
|
||||
hevc10bit = true;
|
||||
mpeg2 = true;
|
||||
vc1 = true;
|
||||
vp8 = true;
|
||||
vp9 = true;
|
||||
};
|
||||
# H.264 hardware encode is always on; Vega's VCN also encodes HEVC
|
||||
# (verified with the bundled ffmpeg), but it has no AV1 encoder, so av1
|
||||
# is left off.
|
||||
hardwareEncodingCodecs.hevc = true;
|
||||
};
|
||||
};
|
||||
|
||||
# The module creates /var/lib/jellyfin and /var/cache/jellyfin itself, both
|
||||
# on the preserved, encrypted nvme -- no extra tmpfiles rule is needed.
|
||||
|
||||
# network.xml has no module option, so patch it on every start (idempotent;
|
||||
# only these two elements are touched):
|
||||
# - KnownProxies: trust Caddy (127.0.0.1) so Jellyfin reads the real client
|
||||
# IP from `X-Forwarded-For` (correct remote-access checks and per-IP
|
||||
# login lockout instead of lumping everyone under the proxy's IP).
|
||||
# - PublishedServerUriBySubnet: advertise the public URL to clients.
|
||||
#
|
||||
# `mkBefore` matters: the module's own preStart ends with `exit 0` once
|
||||
# encoding.xml already matches, so an `mkAfter` patch would only run on the
|
||||
# deploy that changes encoding settings. Running first is always safe.
|
||||
systemd.services.jellyfin.preStart = lib.mkBefore ''
|
||||
networkXml=${lib.escapeShellArg "${config.services.jellyfin.configDir}/network.xml"}
|
||||
if [ -f "$networkXml" ]; then
|
||||
${lib.getExe pkgs.xmlstarlet} ed -L \
|
||||
-d '/NetworkConfiguration/KnownProxies/*' \
|
||||
-s '/NetworkConfiguration/KnownProxies' -t elem -n string -v '127.0.0.1' \
|
||||
-d '/NetworkConfiguration/PublishedServerUriBySubnet/*' \
|
||||
-s '/NetworkConfiguration/PublishedServerUriBySubnet' -t elem -n string -v ${lib.escapeShellArg "all=https://${domain}"} \
|
||||
"$networkXml"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Jellyfin apps discover the server over the LAN via UDP 7359, then talk to
|
||||
# TCP 8096 directly. Allow both only from the home network; the public entry
|
||||
# point stays the Caddy vhost on 443.
|
||||
networking.firewall.extraInputRules = ''
|
||||
ip saddr 192.168.5.0/24 tcp dport 8096 accept
|
||||
ip saddr 192.168.5.0/24 udp dport 7359 accept
|
||||
'';
|
||||
|
||||
# The library is a separate, `nofail` mount; without this the unit can start
|
||||
# before it is mounted. The module's own RequiresMountsFor entries for
|
||||
# config/log/cache are merged with these list definitions.
|
||||
systemd.services.jellyfin.unitConfig.RequiresMountsFor = [ mediaDir ];
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
# their own values and data), so this lives in the host config rather than a
|
||||
# shared module.
|
||||
users.users = {
|
||||
jellyfin.uid = 984;
|
||||
sftpgo.uid = 987;
|
||||
rustfs.uid = 988;
|
||||
redis-shared.uid = 989;
|
||||
@@ -29,6 +30,7 @@
|
||||
};
|
||||
|
||||
users.groups = {
|
||||
jellyfin.gid = 974;
|
||||
wireshark.gid = 975;
|
||||
victoriametrics-data.gid = 976;
|
||||
uinput.gid = 977;
|
||||
|
||||
Reference in new issue
Block a user