diff --git a/flake.nix b/flake.nix index 5fa2ff6..97131e6 100644 --- a/flake.nix +++ b/flake.nix @@ -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; + }; + }; }) ]; }; diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index aab3f02..4917952 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -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; diff --git a/test/services/jellyfin.nix b/test/services/jellyfin.nix index 8f86688..eadacb9 100644 --- a/test/services/jellyfin.nix +++ b/test/services/jellyfin.nix @@ -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; };