nixos/immichframe: init module

This commit is contained in:
Jeremy Fleischman 2025-11-20 13:43:24 -08:00
commit 20aa784406
No known key found for this signature in database
6 changed files with 314 additions and 0 deletions

View file

@ -12,6 +12,8 @@
- [knot-resolver](https://www.knot-resolver.cz/) in version 6. Available as `services.knot-resolver`. A module for knot-resolver 5 was already available as `services.kresd`.
- [ImmichFrame](https://immichframe.dev/), display your photos from Immich as a digital photo frame. Available as `services.immichframe`.
- [udp-over-tcp](https://github.com/mullvad/udp-over-tcp), a tunnel for proxying UDP traffic over a TCP stream. Available as `services.udp-over-tcp`.
## Backward Incompatibilities {#sec-release-26.05-incompatibilities}

View file

@ -1637,6 +1637,7 @@
./services/web-apps/immich-kiosk.nix
./services/web-apps/immich-public-proxy.nix
./services/web-apps/immich.nix
./services/web-apps/immichframe.nix
./services/web-apps/invidious.nix
./services/web-apps/isso.nix
./services/web-apps/jirafeau.nix

View file

@ -0,0 +1,142 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.immichframe;
format = pkgs.formats.json { };
inherit (lib)
types
mkIf
mkOption
mkEnableOption
;
in
{
options.services.immichframe = {
enable = mkEnableOption "ImmichFrame";
package = lib.mkPackageOption pkgs "immichframe" { };
port = mkOption {
type = types.port;
default = 3000;
description = "The port that ImmichFrame will listen on.";
};
settings = mkOption {
type = types.submodule {
freeformType = format.type;
options = {
Accounts = mkOption {
type = types.listOf (
types.submodule {
freeformType = format.type;
options = {
ImmichServerUrl = mkOption {
type = types.str;
example = "http://photos.example.com";
description = "The URL of your Immich server.";
};
ApiKey = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
API key to talk to the Immich server.
Warning: it will be world-readable in /nix/store.
Consider using {option}`ApiKeyFile` instead.
See
<https://immichframe.online/docs/getting-started/configuration#api-key-permissions>
for details on what permissions this key needs.
'';
};
ApiKeyFile = mkOption {
type = types.nullOr types.externalPath;
default = null;
description = ''
File containing an API key to talk to the Immich server.
See
<https://immichframe.online/docs/getting-started/configuration#api-key-permissions>
for details on what permissions this key needs.
'';
};
};
}
);
description = ''
Accounts configuration, multiple are permitted. See
<https://immichframe.online/docs/getting-started/configuration>.
'';
};
};
};
default = { };
description = ''
Configuration for ImmichFrame. See
<https://immichframe.online/docs/getting-started/configuration> for
options and defaults.
'';
};
};
config = mkIf cfg.enable {
assertions = lib.imap0 (i: account: {
assertion = lib.xor (account.ApiKey == null) (account.ApiKeyFile == null);
message = "Exactly one of {option}`services.immichframe.settings.Accounts[${toString i}].ApiKey` and {option}`services.immichframe.settings.Accounts[${toString i}].ApiKeyFile` must be specified";
}) cfg.settings.Accounts;
systemd.services.immichframe =
let
accountsWithApiKeyFiles = lib.filter (account: account.ApiKeyFile != null) cfg.settings.Accounts;
apiKeyFileToId = lib.listToAttrs (
lib.imap0 (
index: account: lib.nameValuePair account.ApiKeyFile "api-key-${toString index}"
) accountsWithApiKeyFiles
);
settingsPatchWithCredentialPaths = {
Accounts = map (
account:
account
// (
if account.ApiKeyFile != null then
{
ApiKeyFile = "/run/credentials/${config.systemd.services.immichframe.name}/${
apiKeyFileToId.${account.ApiKeyFile}
}";
}
else
{ }
)
) cfg.settings.Accounts;
};
settingsWithFixedSecretPaths = lib.recursiveUpdate cfg.settings settingsPatchWithCredentialPaths;
in
{
description = "Display your photos from Immich as a digital photo frame";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
IMMICHFRAME_CONFIG_PATH = pkgs.runCommand "Config" { } ''
mkdir $out
ln -s ${format.generate "Settings.json" settingsWithFixedSecretPaths} $out/Settings.json
'';
};
serviceConfig = {
ExecStart = "${lib.getExe cfg.package} --urls=http://localhost:${toString cfg.port}";
LoadCredential = lib.concatMapAttrsStringSep ":" (
apiKeyFile: id: "${id}:${apiKeyFile}"
) apiKeyFileToId;
DynamicUser = true;
Type = "simple";
Restart = "on-failure";
RestartSec = 3;
};
};
};
meta.maintainers = with lib.maintainers; [ jfly ];
}

View file

@ -756,6 +756,7 @@ in
immich-public-proxy = runTest ./web-apps/immich-public-proxy.nix;
immich-vectorchord-migration = runTest ./web-apps/immich-vectorchord-migration.nix;
immich-vectorchord-reindex = runTest ./web-apps/immich-vectorchord-reindex.nix;
immichframe = runTest ./web-apps/immichframe.nix;
incron = runTest ./incron.nix;
incus = import ./incus {
inherit runTestOn;

View file

@ -0,0 +1,145 @@
let
apiKeyFile = "/tmp/immich-api.key";
customInterval = 5;
in
{
name = "immichframe";
enableOCR = true;
nodes.machine =
{
config,
pkgs,
lib,
...
}:
{
imports = [ ../common/x11.nix ];
# When setting this to 2500 I got "Kernel panic - not syncing: Out of
# memory: compulsory panic_on_oom is enabled".
virtualisation.memorySize = 3000;
environment.systemPackages = with pkgs; [
imagemagick
immich-cli
firefox
xdotool
];
fonts.packages = [ pkgs.liberation_ttf ];
services.immich = {
enable = true;
port = 2283;
openFirewall = true;
# Disable a bunch of features that aren't needed for this test.
machine-learning.enable = false;
settings = {
backup.database.enabled = false;
machineLearning.enabled = false;
map.enabled = false;
reverseGeocoding.enabled = false;
metadata.faces.import = false;
newVersionCheck.enabled = false;
notifications.smtp.enabled = false;
};
};
services.immichframe = {
enable = true;
port = 8002;
settings = {
General.Interval = customInterval;
Accounts = [
{
ImmichServerUrl = "http://localhost:${toString config.services.immich.port}";
ApiKeyFile = apiKeyFile;
}
];
};
};
};
testScript = /* python */ ''
import json
import tempfile
from shlex import quote
custom_interval = ${toString customInterval}
machine.wait_for_unit("immich-server.service")
machine.wait_for_open_port(2283)
# Log in to Immich and create an access key.
machine.succeed("""
curl --no-progress-meter -f --json '{ "email": "test@example.com", "name": "Admin", "password": "admin" }' http://localhost:2283/api/auth/admin-sign-up
""")
res = machine.succeed("""
curl --no-progress-meter -f --json '{ "email": "test@example.com", "password": "admin" }' http://localhost:2283/api/auth/login
""")
token = json.loads(res)['accessToken']
res = machine.succeed("""
curl --no-progress-meter -f -H 'Cookie: immich_access_token=%s' --json '{ "name": "API Key", "permissions": ["all"] }' http://localhost:2283/api/api-keys
""" % token)
key = json.loads(res)['secret']
machine.succeed(f"immich login http://localhost:2283/api {key}")
res = machine.succeed("immich server-info")
print(res)
# Create the API key so ImmichFrame can start.
with tempfile.NamedTemporaryFile("w", delete_on_close=False) as f:
f.write(key)
f.close()
machine.copy_from_host(f.name, ${builtins.toJSON apiKeyFile})
# We finally have an api key! Make sure ImmichFrame starts up.
machine.wait_for_unit("immichframe.service")
machine.wait_for_open_port(8002)
# Check ImmichFrame's configuration.
res = machine.succeed("curl --no-progress-meter -f http://localhost:8002/api/Config")
frame_config = json.loads(res)
assert frame_config['interval'] == custom_interval, frame_config
# At this point, ImmichFrame should not find any images to serve.
res = machine.succeed("curl -f http://localhost:8002/api/Asset")
assets = json.loads(res)
assert len(assets) == 0, assets
# Repeat to make it easier for OCR to pick up given overlays
image_text = '\\n'.join(['reproduce this moment', 'with NixOS tests <3'] * 6)
# These settings make it display fine given potential cropping.
common_args = f"-gravity center -font Liberation-Mono -pointsize 50 -annotate 0 {quote(image_text)}"
# Upload some images to a new album.
album_title = ' Reproducible Moments '
machine.succeed(f"magick -size 1920x1080 canvas:white -fill black {common_args} /tmp/white.png")
machine.succeed(f"immich upload -A {quote(album_title)} /tmp/white.png")
machine.succeed(f"magick -size 1920x1080 canvas:black -fill white {common_args} /tmp/black.png")
machine.succeed(f"immich upload -A {quote(album_title)} /tmp/black.png")
res = machine.succeed("immich server-info")
print(res)
# ImmichFrame should now find some assets.
# Note: we restart ImmichFrame because it has some caching that prevents it
# from immediately seeing the newly uploaded photos.
machine.succeed("systemctl restart immichframe.service")
machine.wait_for_open_port(8002)
res = machine.succeed("curl --no-progress-meter -f http://localhost:8002/api/Asset")
assets = json.loads(res)
assert len(assets) == 2, assets
# Wait for a photo to be displayed.
machine.wait_for_x()
machine.execute("xterm -e 'firefox --kiosk http://localhost:8002' >&2 &")
machine.wait_for_window("immichFrame")
_, active_window = machine.execute("xdotool getactivewindow")
machine.succeed(f"xdotool windowsize {quote(active_window.strip())} 100% 100%")
machine.wait_for_text('reproduce this moment')
machine.wait_for_text('with NixOS tests')
machine.screenshot("screen")
'';
}

View file

@ -3,7 +3,9 @@
buildNpmPackage,
dotnet-sdk,
fetchFromGitHub,
fetchpatch,
lib,
nixosTests,
writeShellApplication,
}:
@ -23,6 +25,25 @@ let
nugetDeps = ./deps.json;
dotnet-runtime = dotnet-sdk.aspnetcore;
patches = [
# This not-yet-released commit has landed upstream. It adds a
# `IMMICHFRAME_CONFIG_PATH` environment variable for a "configurable"
# config path.
(fetchpatch {
name = "Configurable config path";
url = "https://github.com/immichFrame/ImmichFrame/commit/f6680f23bcf107ce27372dfb37809c0f92ebb2f2.patch";
hash = "sha256-dQnspQEKixQgBpCvNxrYL51z5wg5BhdN0uTuaXgKQZU=";
})
# This patch adds an `ApiKeyFile` option, which makes it possible to
# configure ImmichFrame without leaking secrets into your configuration.
# See [upstream PR](https://github.com/immichFrame/ImmichFrame/pull/511)
(fetchpatch {
name = "Add a `ApiKeyFile` option";
url = "https://github.com/immichFrame/ImmichFrame/commit/f5bb164170460b1020bfe6bce8e8abb3315e32e3.diff";
hash = "sha256-F3BVIxcu8Hm6wbWmzVnfgm6XvqdBw4IiS61CDQiMRVg=";
})
];
meta.mainProgram = "ImmichFrame.WebApi";
};
@ -53,6 +74,8 @@ writeShellApplication {
exec ${lib.getExe publishApi} "$@"
'';
passthru.tests = { inherit (nixosTests) immichframe; };
meta = {
description = "Display your photos from Immich as a digital photo frame";
homepage = "https://immichframe.dev";