declarative jellyfin setup with tests
This commit is contained in:
parent
07940cc408
commit
82977bd239
3 changed files with 242 additions and 9 deletions
53
flake.nix
53
flake.nix
|
|
@ -54,14 +54,51 @@
|
||||||
hash = "sha256-TLJSJEXMPj870TkExq6uraX8Wl4kmNerrSlX3LQsr/4=";
|
hash = "sha256-TLJSJEXMPj870TkExq6uraX8Wl4kmNerrSlX3LQsr/4=";
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
})
|
|
||||||
(final: prev: {
|
jellyfin-cli = pkgs.buildDotnetModule rec {
|
||||||
# Leaving commented out as an example.
|
pname = "jellyfin-cli";
|
||||||
# grocy = prev.grocy.overrideAttrs (f: p: {
|
version = "10.10.7";
|
||||||
# patches = p.patches ++ [
|
|
||||||
# ./patches/grocy.patch
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,30 @@ in
|
||||||
default = false;
|
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 {
|
ldap = lib.mkOption {
|
||||||
description = "LDAP configuration.";
|
description = "LDAP configuration.";
|
||||||
default = {};
|
default = {};
|
||||||
|
|
@ -489,6 +513,8 @@ in
|
||||||
''
|
''
|
||||||
+ (shblib.replaceSecretsScript {
|
+ (shblib.replaceSecretsScript {
|
||||||
file = networkConfig;
|
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";
|
resultPath = "${config.services.jellyfin.dataDir}/config/network.xml";
|
||||||
replacements = [
|
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)) [
|
shb.authelia.oidcClients = lib.lists.optionals (!(isNull cfg.sso)) [
|
||||||
{
|
{
|
||||||
client_id = cfg.sso.clientID;
|
client_id = cfg.sso.clientID;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,25 @@ let
|
||||||
waitForUrls = { proto_fqdn, ... }: [
|
waitForUrls = { proto_fqdn, ... }: [
|
||||||
"${proto_fqdn}/System/Info/Public"
|
"${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, ... }: {
|
basic = { config, ... }: {
|
||||||
|
|
@ -31,14 +50,68 @@ let
|
||||||
enable = true;
|
enable = true;
|
||||||
inherit (config.test) subdomain domain;
|
inherit (config.test) subdomain domain;
|
||||||
inherit port;
|
inherit port;
|
||||||
|
admin = {
|
||||||
|
username = "jellyfin";
|
||||||
|
password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;
|
||||||
|
};
|
||||||
debug = true;
|
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, ... }: {
|
https = { config, ... }: {
|
||||||
shb.jellyfin = {
|
shb.jellyfin = {
|
||||||
ssl = config.shb.certs.certs.selfsigned.n;
|
ssl = config.shb.certs.certs.selfsigned.n;
|
||||||
};
|
};
|
||||||
|
test = {
|
||||||
|
hasSSL = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
ldap = { config, ... }: {
|
ldap = { config, ... }: {
|
||||||
|
|
@ -98,9 +171,12 @@ in
|
||||||
nodes.server = {
|
nodes.server = {
|
||||||
imports = [
|
imports = [
|
||||||
basic
|
basic
|
||||||
|
clientLogin
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Client login does not work without SSL.
|
||||||
|
# At least, I couldn't make it work.
|
||||||
nodes.client = {};
|
nodes.client = {};
|
||||||
|
|
||||||
testScript = commonTestScript.access;
|
testScript = commonTestScript.access;
|
||||||
|
|
@ -128,7 +204,12 @@ in
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
nodes.client = {};
|
nodes.client = { config, lib, ... }: {
|
||||||
|
imports = [
|
||||||
|
testLib.baseModule
|
||||||
|
clientLogin
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
testScript = commonTestScript.access;
|
testScript = commonTestScript.access;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue