declarative jellyfin setup with tests

This commit is contained in:
ibizaman 2025-07-11 16:59:48 +02:00 committed by Pierre Penninckx
parent 07940cc408
commit 82977bd239
3 changed files with 242 additions and 9 deletions

View file

@ -54,14 +54,51 @@
hash = "sha256-TLJSJEXMPj870TkExq6uraX8Wl4kmNerrSlX3LQsr/4=";
};
});
})
(final: prev: {
# Leaving commented out as an example.
# grocy = prev.grocy.overrideAttrs (f: p: {
# patches = p.patches ++ [
# ./patches/grocy.patch
# ];
# });
jellyfin-cli = pkgs.buildDotnetModule rec {
pname = "jellyfin-cli";
version = "10.10.7";
src = pkgs.fetchFromGitHub {
owner = "ibizaman";
repo = "jellyfin";
rev = "0b1a5d929960f852dba90c1fc36f3a19dc094f8d";
hash = "sha256-H9V65+886EYMn/xDEgmxvoEOrbZaI1wSfmkN9vAzGhw=";
};
propagatedBuildInputs = [ pkgs.sqlite ];
projectFile = "Jellyfin.Cli/Jellyfin.Cli.csproj";
executables = [ "jellyfin-cli" ];
nugetDeps = "${pkgs.path}/pkgs/by-name/je/jellyfin/nuget-deps.json";
runtimeDeps = [
pkgs.jellyfin-ffmpeg
pkgs.fontconfig
pkgs.freetype
];
dotnet-sdk = pkgs.dotnetCorePackages.sdk_8_0;
dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_8_0;
dotnetBuildFlags = [ "--no-self-contained" ];
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-cli";
platforms = dotnet-runtime.meta.platforms;
};
};
})
];
};

View file

@ -44,6 +44,30 @@ in
default = false;
};
admin = lib.mkOption {
description = "Default admin user info. Only needed if LDAP or SSO is not configured.";
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 = contracts.secret.mkRequester {
mode = "0440";
owner = "jellyfin";
group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};
};
};
};
});
};
ldap = lib.mkOption {
description = "LDAP configuration.";
default = {};
@ -489,6 +513,8 @@ in
''
+ (shblib.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 = [
];
@ -520,6 +546,95 @@ in
];
});
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.
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}/.jellyfin-restarted";
writeConfig = pkgs.writeShellApplication {
name = "writeConfig";
runtimeInputs = [ pkgs.systemd ];
text = ''
if ! [ -f "${restartedFile}" ]; then
${lib.getExe pkgs.jellyfin-cli} wizard \
--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"
touch "${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.lists.optionals (!(isNull cfg.sso)) [
{
client_id = cfg.sso.clientID;

View file

@ -16,6 +16,25 @@ let
waitForUrls = { proto_fqdn, ... }: [
"${proto_fqdn}/System/Info/Public"
];
extraScript = { node, ... }: ''
headers = unline_with(" ", """
-H 'Content-Type: application/json'
-H 'Authorization: MediaBrowser Client="Android TV", Device="Nvidia Shield", DeviceId="ZQ9YQHHrUzk24vV", Version="0.15.3"'
""")
with subtest("api login success"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "jellyfin", "Pw": "admin"}""",
extra=headers)
if response['code'] != 200:
raise Exception(f"Expected success, got: {response['code']}")
with subtest("api login failure"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "jellyfin", "Pw": "badpassword"}""",
extra=headers)
if response['code'] != 401:
raise Exception(f"Expected failure, got: {response['code']}")
'';
};
basic = { config, ... }: {
@ -31,14 +50,68 @@ let
enable = true;
inherit (config.test) subdomain domain;
inherit port;
admin = {
username = "jellyfin";
password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;
};
debug = true;
};
shb.hardcodedsecret.jellyfinAdminPassword = {
request = config.shb.jellyfin.admin.password.request;
settings.content = "admin";
};
environment.systemPackages = [
pkgs.sqlite
];
};
clientLogin = { config, ... }: {
imports = [
testLib.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
browser = "firefox";
# I tried without the path part but it randomly selects either the wizard
# or the page that selects a server.
# startUrl = "${config.test.proto}://${config.test.fqdn}/web/#/wizardstart.html";
# startUrl = "${config.test.proto}://${config.test.fqdn}";
startUrl = "${config.test.proto}://${config.test.fqdn}/web/#/login.html";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
# I just couldn't make this work. It's very flaky.
# Most of the time, the login jellyfin page doesn't even load
# and the playwright browser is stuck on the splash page.
# I resorted to test the API directly.
# { username = "jellyfin"; password = "badpassword"; nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=30000)"
# ]; }
# { username = "jellyfin"; password = "admin"; nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=30000)"
# "expect(page.get_by_role('label', re.compile('[Uu]ser'))).not_to_be_visible(timeout=30000)"
# "expect(page.get_by_text(re.compile('[Pp]assword'))).not_to_be_visible(timeout=30000)"
# ]; }
];
};
};
https = { config, ... }: {
shb.jellyfin = {
ssl = config.shb.certs.certs.selfsigned.n;
};
test = {
hasSSL = true;
};
};
ldap = { config, ... }: {
@ -98,9 +171,12 @@ in
nodes.server = {
imports = [
basic
clientLogin
];
};
# Client login does not work without SSL.
# At least, I couldn't make it work.
nodes.client = {};
testScript = commonTestScript.access;
@ -128,7 +204,12 @@ in
];
};
nodes.client = {};
nodes.client = { config, lib, ... }: {
imports = [
testLib.baseModule
clientLogin
];
};
testScript = commonTestScript.access;
};