selfhostblocks/modules/services/jellyfin.nix
2026-02-24 20:00:36 +01:00

869 lines
32 KiB
Nix

{
config,
lib,
pkgs,
shb,
...
}:
let
inherit (lib) types;
cfg = config.shb.jellyfin;
fqdn = "${cfg.subdomain}.${cfg.domain}";
jellyfin = pkgs.buildDotnetModule rec {
pname = "jellyfin";
version = "10.11.6";
src = pkgs.fetchFromGitHub {
owner = "ibizaman";
repo = "jellyfin";
rev = "c58ca41d9ee76d137be788cd6f2d089e288ad561";
hash = "sha256-gTHsz5qRT+9FjAqBb4hDBkHChYDU52snBWu6cQb10i4=";
};
propagatedBuildInputs = [ pkgs.sqlite ];
projectFile = "Jellyfin.Server/Jellyfin.Server.csproj";
executables = [ "jellyfin" ];
nugetDeps = "${pkgs.path}/pkgs/by-name/je/jellyfin/nuget-deps.json";
runtimeDeps = [
pkgs.jellyfin-ffmpeg
pkgs.fontconfig
pkgs.freetype
];
dotnet-sdk = pkgs.dotnetCorePackages.sdk_9_0;
dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_9_0;
dotnetBuildFlags = [ "--no-self-contained" ];
makeWrapperArgs = [
"--append-flags"
"--ffmpeg=${pkgs.jellyfin-ffmpeg}/bin/ffmpeg"
"--append-flags"
"--webdir=${pkgs.jellyfin-web}/share/jellyfin-web"
];
passthru.tests = {
smoke-test = pkgs.nixosTests.jellyfin;
};
meta = with pkgs.lib; {
description = "Free Software Media System";
homepage = "https://jellyfin.org/";
# https://github.com/jellyfin/jellyfin/issues/610#issuecomment-537625510
license = licenses.gpl2Plus;
maintainers = with maintainers; [
nyanloutre
minijackson
purcell
jojosch
];
mainProgram = "jellyfin";
platforms = dotnet-runtime.meta.platforms;
};
};
pluginName =
src:
let
meta = builtins.fromJSON (builtins.readFile "${src}/meta.json");
in
"${meta.name}_${meta.version}";
pluginNamePrefix =
src:
let
meta = builtins.fromJSON (builtins.readFile "${src}/meta.json");
in
"${meta.name}";
in
{
options.shb.jellyfin = {
enable = lib.mkEnableOption "shb jellyfin";
subdomain = lib.mkOption {
type = types.str;
description = "Subdomain under which home-assistant will be served.";
example = "jellyfin";
};
domain = lib.mkOption {
description = "Domain to serve sites under.";
type = types.str;
example = "domain.com";
};
port = lib.mkOption {
description = "Listen on port.";
type = types.port;
default = 8096;
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = types.nullOr shb.contracts.ssl.certs;
default = null;
};
debug = lib.mkOption {
description = "Enable debug logging";
type = types.bool;
default = false;
};
admin = lib.mkOption {
description = "Default admin user info. Only needed if LDAP or SSO is not configured.";
default = null;
type = types.nullOr (
types.submodule {
options = {
username = lib.mkOption {
description = "Username of the default admin user.";
type = types.str;
default = "jellyfin";
};
password = lib.mkOption {
description = "Password of the default admin user.";
type = types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "jellyfin";
group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};
};
};
};
}
);
};
plugins = lib.mkOption {
description = ''
Install plugins declaratively.
The LDAP and SSO plugins will be added if their respective
shb.jellyfin.ldap.enable and shb.jellyfin.sso.enable options are set to true.
The interface for plugin creation is WIP.
Feel free to add yours following the examples from the LDAP and SSO plugins
but know that they may require some tweaks later on.
Notably, configuration is not yet handled by this option
so that will be added in the future.
Each plugin's meta.json must be writeable because Jellyfin appends some information
upon installing the plugin, like its active or disabled status.
SHB automatically enables the plugin
and deletes any plugin with the same prefix but other versions.
Note that SHB does not attempt to find which version is latest.
If twice the same plugin is added, the last one in the "plugins" list wins.
'';
default = [ ];
type = types.listOf types.package;
};
ldap = lib.mkOption {
description = "LDAP configuration.";
default = { };
type = types.submodule {
options = {
enable = lib.mkEnableOption "LDAP";
plugin = lib.mkOption {
type = lib.types.package;
description = "Pluging used for LDAP authentication.";
default = shb.mkJellyfinPlugin (rec {
pname = "jellyfin-plugin-ldapauth";
version = "22";
url = "https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip";
hash = "sha256-m2oD9woEuoSRiV9OeifAxZN7XQULMKS0Yq4TF+LjjpI=";
});
};
host = lib.mkOption {
type = types.str;
description = "Host serving the LDAP server.";
example = "127.0.0.1";
};
port = lib.mkOption {
type = types.int;
description = "Port where the LDAP server is listening.";
example = 389;
};
dcdomain = lib.mkOption {
type = types.str;
description = "DC domain for LDAP.";
example = "dc=mydomain,dc=com";
};
userGroup = lib.mkOption {
type = types.str;
description = "LDAP user group";
default = "jellyfin_user";
};
adminGroup = lib.mkOption {
type = types.str;
description = "LDAP admin group";
default = "jellyfin_admin";
};
adminPassword = lib.mkOption {
description = "LDAP admin password.";
type = types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "jellyfin";
group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};
};
};
};
};
};
sso = lib.mkOption {
description = "SSO configuration.";
default = { };
type = types.submodule {
options = {
enable = lib.mkEnableOption "SSO";
plugin = lib.mkOption {
type = lib.types.package;
description = "Pluging used for SSO authentication.";
default = shb.mkJellyfinPlugin (rec {
pname = "jellyfin-plugin-sso";
version = "4.0.0.3";
url = "https://github.com/9p4/${pname}/releases/download/v${version}/sso-authentication_${version}.zip";
hash = "sha256-Jkuc+Ua7934iSutf/zTY1phTxaltUkfiujOkCi7BW8w=";
});
};
provider = lib.mkOption {
type = types.str;
description = "OIDC provider name";
default = "Authelia";
};
endpoint = lib.mkOption {
type = types.str;
description = "OIDC endpoint for SSO";
example = "https://authelia.example.com";
};
clientID = lib.mkOption {
type = types.str;
description = "Client ID for the OIDC endpoint";
default = "jellyfin";
};
authorization_policy = lib.mkOption {
type = types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
sharedSecret = lib.mkOption {
description = "OIDC shared secret for Jellyfin.";
type = types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "jellyfin";
group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};
};
};
sharedSecretForAuthelia = lib.mkOption {
description = "OIDC shared secret for Authelia.";
type = types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
ownerText = "config.shb.authelia.autheliaUser";
owner = config.shb.authelia.autheliaUser;
};
};
};
};
};
};
backup = lib.mkOption {
description = ''
Backup configuration.
'';
default = { };
type = types.submodule {
options = shb.contracts.backup.mkRequester {
user = "jellyfin";
sourceDirectories = [
config.services.jellyfin.dataDir
];
sourceDirectoriesText = ''
[
"services.jellyfin.dataDir"
]
'';
};
};
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.jellyfin.subdomain}.\${config.shb.jellyfin.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
};
imports = [
../../lib/module.nix
(lib.mkRenamedOptionModule
[ "shb" "jellyfin" "adminPassword" ]
[ "shb" "jellyfin" "admin" "password" ]
)
# (lib.mkRenamedOptionModule
# [ "shb" "jellyfin" "sso" "userGroup" ]
# [ "shb" "jellyfin" "ldap" "userGroup" ]
# )
# (lib.mkRenamedOptionModule
# [ "shb" "jellyfin" "sso" "adminUserGroup" ]
# [ "shb" "jellyfin" "ldap" "adminGroup" ]
# )
];
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (!cfg.ldap.enable && !cfg.sso.enable) -> cfg.admin != null;
message = "Jellyfin admin user must be configured with shb.jellyfin.admin if LDAP or SSO integration are not configured.";
}
];
services.jellyfin.enable = true;
services.jellyfin.package = jellyfin;
networking.firewall = {
# from https://jellyfin.org/docs/general/networking/index.html, for auto-discovery
allowedUDPPorts = [
1900
7359
];
};
services.nginx.enable = true;
# Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex
services.nginx.virtualHosts."${fqdn}" = {
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
http2 = true;
extraConfig = ''
# The default `client_max_body_size` is 1M, this might not be enough for some posters, etc.
client_max_body_size 20M;
# Some players don't reopen a socket and playback stops totally instead of resuming after an extended pause
send_timeout 100m;
# use a variable to store the upstream proxy
# in this example we are using a hostname which is resolved via DNS
# (if you aren't using DNS remove the resolver line and change the variable to point to an IP address e.g `set $jellyfin 127.0.0.1`)
set $jellyfin 127.0.0.1;
# resolver 127.0.0.1 valid=30;
#include /etc/letsencrypt/options-ssl-nginx.conf;
#ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
#add_header Strict-Transport-Security "max-age=31536000" always;
#ssl_trusted_certificate /etc/letsencrypt/live/DOMAIN_NAME/chain.pem;
# Why this is important: https://blog.cloudflare.com/ocsp-stapling-how-cloudflare-just-made-ssl-30/
ssl_stapling on;
ssl_stapling_verify on;
# Security / XSS Mitigation Headers
# NOTE: X-Frame-Options may cause issues with the webOS app
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "0"; # Do NOT enable. This is obsolete/dangerous
add_header X-Content-Type-Options "nosniff";
# COOP/COEP. Disable if you use external plugins/images/assets
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Permissions policy. May cause issues on some clients
add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always;
# Tell browsers to use per-origin process isolation
add_header Origin-Agent-Cluster "?1" always;
# Content Security Policy
# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
# Enforces https content and restricts JS/CSS to origin
# External Javascript (such as cast_sender.js for Chromecast) must be whitelisted.
# NOTE: The default CSP headers may cause issues with the webOS app
#add_header Content-Security-Policy "default-src https: data: blob: http://image.tmdb.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com/cv/js/sender/v1/cast_sender.js https://www.gstatic.com/eureka/clank/95/cast_sender.js https://www.gstatic.com/eureka/clank/96/cast_sender.js https://www.gstatic.com/eureka/clank/97/cast_sender.js https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'";
# From Plex: Plex has A LOT of javascript, xml and html. This helps a lot, but if it causes playback issues with devices turn it off.
gzip on;
gzip_vary on;
gzip_min_length 1000;
gzip_proxied any;
gzip_types text/plain text/css text/xml application/xml text/javascript application/x-javascript image/svg+xml;
gzip_disable "MSIE [1-6]\.";
location = / {
return 302 http://$host/web/;
#return 302 https://$host/web/;
}
location / {
# Proxy main Jellyfin traffic
proxy_pass http://$jellyfin:${toString cfg.port};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
# Disable buffering when the nginx proxy gets very resource heavy upon streaming
proxy_buffering off;
}
# location block for /web - This is purely for aesthetics so /web/#!/ works instead of having to go to /web/index.html/#!/
location = /web/ {
# Proxy main Jellyfin traffic
proxy_pass http://$jellyfin:${toString cfg.port}/web/index.html;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
}
location /socket {
# Proxy Jellyfin Websockets traffic
proxy_pass http://$jellyfin:${toString cfg.port};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
}
'';
};
services.prometheus.scrapeConfigs = [
{
job_name = "jellyfin";
static_configs = [
{
targets = [ "127.0.0.1:${toString cfg.port}" ];
labels = {
"hostname" = config.networking.hostName;
"domain" = cfg.domain;
};
}
];
}
];
# LDAP config but you need to install the plugin by hand
systemd.services.jellyfin.preStart =
let
ldapConfig = pkgs.writeText "LDAP-Auth.xml" ''
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<LdapServer>${cfg.ldap.host}</LdapServer>
<LdapPort>${builtins.toString cfg.ldap.port}</LdapPort>
<UseSsl>false</UseSsl>
<UseStartTls>false</UseStartTls>
<SkipSslVerify>false</SkipSslVerify>
<LdapBindUser>uid=admin,ou=people,${cfg.ldap.dcdomain}</LdapBindUser>
<LdapBindPassword>%SECRET_LDAP_PASSWORD%</LdapBindPassword>
<LdapBaseDn>ou=people,${cfg.ldap.dcdomain}</LdapBaseDn>
<LdapSearchFilter>(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})</LdapSearchFilter>
<LdapAdminBaseDn>ou=people,${cfg.ldap.dcdomain}</LdapAdminBaseDn>
<LdapAdminFilter>(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})</LdapAdminFilter>
<EnableLdapAdminFilterMemberUid>false</EnableLdapAdminFilterMemberUid>
<LdapSearchAttributes>uid, cn, mail, displayName</LdapSearchAttributes>
<LdapClientCertPath />
<LdapClientKeyPath />
<LdapRootCaPath />
<CreateUsersFromLdap>true</CreateUsersFromLdap>
<AllowPassChange>false</AllowPassChange>
<LdapUsernameAttribute>uid</LdapUsernameAttribute>
<LdapPasswordAttribute>userPassword</LdapPasswordAttribute>
<EnableAllFolders>true</EnableAllFolders>
<EnabledFolders />
<PasswordResetUrl />
</PluginConfiguration>
'';
# SchemeOverride is needed because of
# https://github.com/9p4/jellyfin-plugin-sso/issues/264
ssoConfig = pkgs.writeText "SSO-Auth.xml" ''
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<SamlConfigs />
<OidConfigs>
<item>
<key>
<string>${cfg.sso.provider}</string>
</key>
<value>
<PluginConfiguration>
<SchemeOverride>https</SchemeOverride>
<OidEndpoint>${cfg.sso.endpoint}</OidEndpoint>
<OidClientId>${cfg.sso.clientID}</OidClientId>
<OidSecret>%SECRET_SSO_SECRET%</OidSecret>
<Enabled>true</Enabled>
<EnableAuthorization>true</EnableAuthorization>
<EnableAllFolders>true</EnableAllFolders>
<EnabledFolders />
<AdminRoles>
<string>${cfg.ldap.adminGroup}</string>
</AdminRoles>
<Roles>
<string>${cfg.ldap.userGroup}</string>
</Roles>
<EnableFolderRoles>false</EnableFolderRoles>
<FolderRoleMappings />
<RoleClaim>groups</RoleClaim>
<OidScopes>
<string>groups</string>
</OidScopes>
<CanonicalLinks />
<DisablePushedAuthorization>true</DisablePushedAuthorization>
</PluginConfiguration>
</value>
</item>
</OidConfigs>
</PluginConfiguration>
'';
brandingConfig = pkgs.writeText "branding.xml" ''
<?xml version="1.0" encoding="utf-8"?>
<BrandingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<LoginDisclaimer>&lt;a href="https://${cfg.subdomain}.${cfg.domain}/SSO/OID/p/${cfg.sso.provider}" class="raised cancel block emby-button authentik-sso"&gt;
Sign in with ${cfg.sso.provider}&amp;nbsp;
&lt;img alt="OpenID Connect (authentik)" title="OpenID Connect (authentik)" class="oauth-login-image" src="https://raw.githubusercontent.com/goauthentik/authentik/master/web/icons/icon.png"&gt;
&lt;/a&gt;
&lt;a href="https://${cfg.subdomain}.${cfg.domain}/SSOViews/linking" class="raised cancel block emby-button authentik-sso"&gt;
Link ${cfg.sso.provider} config&amp;nbsp;
&lt;/a&gt;
&lt;a href="${cfg.sso.endpoint}" class="raised cancel block emby-button authentik-sso"&gt;
${cfg.sso.provider} config&amp;nbsp;
&lt;/a&gt;
</LoginDisclaimer>
<CustomCss>
/* Hide this in lieu of authentik link */
.emby-button.block.btnForgotPassword {
display: none;
}
/* Make links look like buttons */
a.raised.emby-button {
padding: 0.9em 1em;
color: inherit !important;
}
/* Let disclaimer take full width */
.disclaimerContainer {
display: block;
}
/* Optionally, apply some styling to the `.authentik-sso` class, probably let users configure this */
.authentik-sso {
/* idk set a background image or something lol */
}
.oauth-login-image {
height: 24px;
position: absolute;
top: 12px;
}
</CustomCss>
<SplashscreenEnabled>true</SplashscreenEnabled>
</BrandingOptions>
'';
debugLogging = pkgs.writeText "debugLogging.json" ''
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"": "Debug"
}
}
}
}
'';
networkConfig = pkgs.writeText "" ''
<?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 />
<EnableHttps>false</EnableHttps>
<RequireHttps>false</RequireHttps>
<CertificatePath />
<CertificatePassword />
<InternalHttpPort>${toString cfg.port}</InternalHttpPort>
<InternalHttpsPort>8920</InternalHttpsPort>
<PublicHttpPort>${toString cfg.port}</PublicHttpPort>
<PublicHttpsPort>8920</PublicHttpsPort>
<AutoDiscovery>true</AutoDiscovery>
<EnableUPnP>false</EnableUPnP>
<EnableIPv4>true</EnableIPv4>
<EnableIPv6>false</EnableIPv6>
<EnableRemoteAccess>false</EnableRemoteAccess>
<LocalNetworkSubnets />
<LocalNetworkAddresses />
<KnownProxies />
<IgnoreVirtualInterfaces>true</IgnoreVirtualInterfaces>
<VirtualInterfaceNames>
<string>veth</string>
</VirtualInterfaceNames>
<EnablePublishedServerUriByRequest>false</EnablePublishedServerUriByRequest>
<PublishedServerUriBySubnet />
<RemoteIPFilter />
<IsRemoteIPFilterBlacklist>false</IsRemoteIPFilterBlacklist>
</NetworkConfiguration>
'';
in
lib.strings.optionalString cfg.debug ''
if [ -f "${config.services.jellyfin.configDir}/logging.json" ] && [ ! -L "${config.services.jellyfin.configDir}/logging.json" ]; then
echo "A ${config.services.jellyfin.configDir}/logging.json file exists already, this indicates probably an existing installation. Please remove it before continuing."
exit 1
fi
ln -fs "${debugLogging}" "${config.services.jellyfin.configDir}/logging.json"
''
+ (shb.replaceSecretsScript {
file = networkConfig;
# Write permissions are needed otherwise the jellyfin-cli tool will not work correctly.
permissions = "u=rw,g=rw,o=";
resultPath = "${config.services.jellyfin.dataDir}/config/network.xml";
replacements = [
];
})
+ lib.strings.optionalString cfg.ldap.enable (
(shb.replaceSecretsScript {
file = ldapConfig;
resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/LDAP-Auth.xml";
replacements = [
{
name = [ "LDAP_PASSWORD" ];
source = cfg.ldap.adminPassword.result.path;
}
];
})
)
+ lib.strings.optionalString cfg.sso.enable (
shb.replaceSecretsScript {
file = ssoConfig;
resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/SSO-Auth.xml";
replacements = [
{
name = [ "SSO_SECRET" ];
source = cfg.sso.sharedSecret.result.path;
}
];
}
)
+ lib.strings.optionalString cfg.sso.enable (
shb.replaceSecretsScript {
file = brandingConfig;
resultPath = "${config.services.jellyfin.dataDir}/config/branding.xml";
replacements = [
];
}
)
+ (
let
pluginInstallScript = p: ''
pluginDir="${config.services.jellyfin.dataDir}/plugins/${pluginName p}"
mkdir -p "$pluginDir"
for f in "${p}"/*; do
ln -sf "$f" "$pluginDir"
done
rm "$pluginDir/meta.json"
${pkgs.jq}/bin/jq ". + {
status: \"Active\",
autoUpdate: false,
assemblies: []
}" "${p}/meta.json" > "$pluginDir/meta.json"
echo "Disabling other versions of plugin ${pluginName p}"
for p in "${config.services.jellyfin.dataDir}/plugins/${pluginNamePrefix p}"*; do
if [ "$p" = "$pluginDir" ]; then
continue
fi
echo "Marking plugin $p as disabled"
${pkgs.jq}/bin/jq ". + {
status: \"Disabled\",
}" "$p/meta.json" > "$p/meta.json.new"
mv "$p/meta.json.new" "$p/meta.json"
done
'';
in
lib.concatMapStringsSep "\n" pluginInstallScript cfg.plugins
);
shb.jellyfin.plugins =
lib.optionals cfg.ldap.enable [ cfg.ldap.plugin ]
++ lib.optionals cfg.sso.enable [ cfg.sso.plugin ];
systemd.tmpfiles.rules = lib.optionals cfg.ldap.enable [
"d '${config.services.jellyfin.dataDir}/plugins' 0750 jellyfin jellyfin - -"
];
systemd.services.jellyfin.serviceConfig.ExecStartPost =
let
# We must always wait for the service to be fully initialized,
# even if we're planning on changing the config and restarting.
# And the service is not initialized until this URL returns a 200 and not a 503.
waitForCurl = pkgs.writeShellApplication {
name = "waitForCurl";
runtimeInputs = [ pkgs.curl ];
text = ''
URL="http://127.0.0.1:${toString cfg.port}/System/Info/Public"
SLEEP_INTERVAL_SEC=2
TIMEOUT=60
start_time=$(date +%s)
echo "Waiting for $URL to return HTTP 200..."
while true; do
status_code=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)
if [ "$status_code" = "200" ]; then
echo "Service is up (HTTP 200 received)."
exit 0
fi
now=$(date +%s)
elapsed=$(( now - start_time ))
if [ $elapsed -ge $TIMEOUT ]; then
echo "Timeout reached ($TIMEOUT seconds). Exiting with failure."
exit 1
fi
echo "Waiting for service... (status: $status_code), elapsed: ''${elapsed}s"
sleep "$SLEEP_INTERVAL_SEC"
done
echo "Finished waiting, curl returned a 200."
'';
};
# This file is used to know if the jellyfin service has been restarted
# because a new config just got written to.
#
# If the file does not exist, write the config, create the file then restart.
# If the file exists, do nothing and remove the file, resetting the state for the next time.
restartedFile = "${config.services.jellyfin.dataDir}/shb-jellyfin-restarted";
writeConfig = pkgs.writeShellApplication {
name = "writeConfig";
runtimeInputs = [ pkgs.systemd ];
text = ''
if ! [ -f "${restartedFile}" ]; then
${lib.getExe config.services.jellyfin.package} config \
--datadir='${config.services.jellyfin.dataDir}' \
--configdir='${config.services.jellyfin.configDir}' \
--cachedir='${config.services.jellyfin.cacheDir}' \
--logdir='${config.services.jellyfin.logDir}' \
--username=${cfg.admin.username} \
--password-file=${cfg.admin.password.result.path} \
--enable-remote-access=true \
--write
fi
'';
};
restartJellyfinOnce = pkgs.writeShellApplication {
name = "restartJellyfin";
runtimeInputs = [ pkgs.systemd ];
text = ''
if [ -f "${restartedFile}" ]; then
echo "jellyfin.service has been restarted"
rm "${restartedFile}"
else
echo "Restarting jellyfin.service"
echo "This file is used by SelfHostBlocks to know when to restart jellyfin" > "${restartedFile}"
systemctl reload-or-restart jellyfin.service
fi
'';
};
in
lib.optionals (cfg.admin != null) [
(lib.getExe waitForCurl)
(lib.getExe writeConfig)
# The '+' is to get elevated privileges to be able to restart the service.
"+${lib.getExe restartJellyfinOnce}"
];
systemd.services.jellyfin.serviceConfig.TimeoutStartSec = 300;
shb.authelia.oidcClients = lib.optionals cfg.sso.enable [
{
client_id = cfg.sso.clientID;
client_name = "Jellyfin";
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
public = false;
authorization_policy = cfg.sso.authorization_policy;
redirect_uris = [
"https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}"
"https://${cfg.subdomain}.${cfg.domain}/sso/OID/redirect/${cfg.sso.provider}"
];
require_pkce = true;
pkce_challenge_method = "S256";
userinfo_signed_response_alg = "none";
# Jellyfin SSO plugin uses client_secret_post for token exchange
token_endpoint_auth_method = "client_secret_post";
# Required OIDC scopes for Authelia to return group claims
scopes = [
"openid"
"profile"
"email"
"groups"
];
}
];
};
}