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=";
|
||||
};
|
||||
});
|
||||
})
|
||||
(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;
|
||||
};
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue