Skip to content

Commit a5c998f

Browse files
authored
plugins with nested values and secrets (#197)
* support secrets in nested plugin configuration structures * use plugin guid instead of name as identifier * try with api name option instead * generate meta.json * fix interpolation * try again * fix formatting
1 parent 723dc5f commit a5c998f

4 files changed

Lines changed: 161 additions & 58 deletions

File tree

lib/secrets/default.nix

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,56 @@ rec {
8080
inherit refs;
8181
flagsString = lib.concatStringsSep " " flags;
8282
};
83+
84+
# Recursively replace every ._secret ref with null, leaving all other
85+
# values intact so builtins.toJSON produces safe JSON with no file paths.
86+
stripSecretRefs =
87+
value:
88+
if isSecretRef value then
89+
null
90+
else if builtins.isAttrs value && !(value ? __unfix__) then
91+
lib.mapAttrs (_: stripSecretRefs) value
92+
else if builtins.isList value then
93+
map stripSecretRefs value
94+
else
95+
value;
96+
97+
# Collect every ._secret ref in a nested structure as a list of
98+
# { path = ["key" "sub" ...]; file = "/runtime/path"; } records.
99+
collectSecretRefsRec =
100+
path: value:
101+
if isSecretRef value then
102+
[
103+
{
104+
inherit path;
105+
file = toString value._secret;
106+
}
107+
]
108+
else if builtins.isAttrs value && !(value ? __unfix__) then
109+
lib.concatLists (lib.mapAttrsToList (k: v: collectSecretRefsRec (path ++ [ k ]) v) value)
110+
else
111+
[ ];
112+
113+
# Like mkJqSecretArgs but handles ._secret refs at any nesting depth.
114+
# Returns { flagsString, assignments, hasSecrets } where assignments is a
115+
# list of jq path-assignment strings ready to be joined with " | ".
116+
mkNestedJqSecretArgs =
117+
rawConfig:
118+
let
119+
allRefs = collectSecretRefsRec [ ] rawConfig;
120+
indexedRefs = lib.imap0 (i: ref: ref // { varName = "nixflixSecret${toString i}"; }) allRefs;
121+
flags = map (ref: "--rawfile ${ref.varName}Content ${lib.escapeShellArg ref.file}") indexedRefs;
122+
assignments = map (
123+
ref:
124+
let
125+
jqPath = "." + lib.concatMapStringsSep "" (k: ''["${k}"]'') ref.path;
126+
in
127+
"${jqPath} = ($" + ref.varName + ''Content | rtrimstr("\n"))''
128+
) indexedRefs;
129+
in
130+
{
131+
flagsString = lib.concatStringsSep " " flags;
132+
inherit assignments;
133+
hasSecrets = allRefs != [ ];
134+
};
83135
}

modules/jellyfin/options/plugins.nix

Lines changed: 71 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -33,59 +33,81 @@ let
3333
};
3434
};
3535

36-
pluginModule = types.submodule {
37-
options = {
38-
package = mkOption {
39-
type = types.nullOr (
40-
types.oneOf [
41-
types.package
42-
repoPackageSourceType
43-
]
44-
);
45-
default = null;
46-
description = ''
47-
Nix package containing the unpacked Jellyfin plugin files to copy
48-
into Jellyfin's plugin directory.
36+
pluginModule = types.submodule (
37+
{ name, ... }:
38+
{
39+
options = {
40+
package = mkOption {
41+
type = types.nullOr (
42+
types.oneOf [
43+
types.package
44+
repoPackageSourceType
45+
]
46+
);
47+
default = null;
48+
description = ''
49+
Nix package containing the unpacked Jellyfin plugin files to copy
50+
into Jellyfin's plugin directory.
4951
50-
For repository-managed plugins, use
51-
`nixflix.lib.jellyfinPlugins.fromRepo { version = ...; hash = ...; }`
52-
to resolve a deterministic package from the pinned plugin manifests.
53-
'';
54-
example = literalExpression ''
55-
nixflix.lib.jellyfinPlugins.fromRepo {
56-
version = "13.0.0.0";
57-
hash = "sha256-16jaQRh1rIFE27nSSEWNF7UjVsPJDaRf24Ews0BZGas=";
58-
}
59-
'';
60-
};
52+
For repository-managed plugins, use
53+
`nixflix.lib.jellyfinPlugins.fromRepo { version = ...; hash = ...; }`
54+
to resolve a deterministic package from the pinned plugin manifests.
55+
'';
56+
example = literalExpression ''
57+
nixflix.lib.jellyfinPlugins.fromRepo {
58+
version = "13.0.0.0";
59+
hash = "sha256-16jaQRh1rIFE27nSSEWNF7UjVsPJDaRf24Ews0BZGas=";
60+
}
61+
'';
62+
};
6163

62-
config = mkOption {
63-
type = types.attrsOf types.anything;
64-
default = { };
65-
description = ''
66-
Plugin configuration payload as seen in the Jellyfin UI/API. All
67-
attributes under this option are POSTed to
68-
`/Plugins/<id>/Configuration`.
69-
'';
70-
example = literalExpression ''
71-
{
72-
ComicVineApiKey._secret = "/run/secrets/comic-vine-api-key";
73-
}
74-
'';
75-
};
64+
config = mkOption {
65+
type = types.attrsOf types.anything;
66+
default = { };
67+
description = ''
68+
Plugin configuration payload as seen in the Jellyfin UI/API. All
69+
attributes under this option are POSTed to
70+
`/Plugins/<id>/Configuration`.
71+
'';
72+
example = literalExpression ''
73+
{
74+
ComicVineApiKey._secret = "/run/secrets/comic-vine-api-key";
75+
}
76+
'';
77+
};
7678

77-
enable = mkOption {
78-
type = types.bool;
79-
default = true;
80-
description = ''
81-
Whether this plugin should be installed. When false, the plugin is
82-
treated as absent: if it was previously installed by nixflix it will
83-
be uninstalled on the next nixos-rebuild. This is equivalent to
84-
removing the attribute entirely from nixflix.jellyfin.plugins.
85-
'';
79+
apiName = mkOption {
80+
type = types.str;
81+
default = name;
82+
description = ''
83+
The plugin's `Name` as reported by the Jellyfin `/Plugins` API.
84+
Defaults to the attribute name. Set this when the plugin's
85+
self-reported API name differs from its manifest name (e.g. the
86+
SSO-Auth plugin is listed in the manifest as `"SSO Authentication"`
87+
but reports itself via the API as `"SSO-Auth"`).
88+
89+
Can be found when running the following while the plugin is installed:
90+
```bash
91+
curl -s -H "Authorization: MediaBrowser Token=$(sudo cat /run/jellyfin/auth-token)" \
92+
http://127.0.0.1:8096/Plugins | jq '.[].Name'
93+
```
94+
'';
95+
example = "SSO-Auth";
96+
};
97+
98+
enable = mkOption {
99+
type = types.bool;
100+
default = true;
101+
description = ''
102+
Whether this plugin should be installed. When false, the plugin is
103+
treated as absent: if it was previously installed by nixflix it will
104+
be uninstalled on the next nixos-rebuild. This is equivalent to
105+
removing the attribute entirely from nixflix.jellyfin.plugins.
106+
'';
107+
};
86108
};
87-
};
88-
};
109+
}
110+
);
89111
in
90112
{
91113
options.nixflix.jellyfin.plugins = mkOption {

modules/jellyfin/pluginsService.nix

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ let
5050
name: pluginCfg:
5151
let
5252
rawConfig = pluginCfg.config;
53-
plainFields = filterAttrs (_: v: !(secrets.isSecretRef v)) rawConfig;
54-
secretFields = filterAttrs (_: v: secrets.isSecretRef v) rawConfig;
53+
lookupName = pluginCfg.apiName;
5554
in
5655
{
57-
plainFile = pkgs.writeText "jellyfin-plugin-config-${name}.json" (builtins.toJSON plainFields);
58-
jqSecrets = secrets.mkJqSecretArgs secretFields;
56+
plainFile = pkgs.writeText "jellyfin-plugin-config-${name}.json" (
57+
builtins.toJSON (secrets.stripSecretRefs rawConfig)
58+
);
59+
jqSecrets = secrets.mkNestedJqSecretArgs rawConfig;
60+
inherit lookupName;
5961
}
6062
) pluginsWithConfig;
6163
in
@@ -210,15 +212,14 @@ in
210212
mapAttrsToList (
211213
pluginName: configData:
212214
let
213-
secretUpdates = concatStringsSep " | " (
214-
mapAttrsToList (name: ref: ''.["${name}"] = ${ref}'') configData.jqSecrets.refs
215-
);
216-
jqFilter = if secretUpdates != "" then ". * $plain | ${secretUpdates}" else ". * $plain";
215+
secretUpdates = concatStringsSep " | " configData.jqSecrets.assignments;
216+
jqFilter =
217+
if configData.jqSecrets.hasSecrets then ". * $plain | ${secretUpdates}" else ". * $plain";
217218
in
218219
''
219220
echo "Configuring plugin: ${pluginName}..."
220221
PLUGIN_ID=$(echo "$INSTALLED_JSON" | ${pkgs.jq}/bin/jq -r \
221-
--arg name "${pluginName}" '.[] | select(.Name == $name) | .Id // empty')
222+
--arg name "${configData.lookupName}" '.[] | select(.Name == $name) | .Id // empty')
222223
223224
if [ -z "$PLUGIN_ID" ]; then
224225
echo "Warning: Plugin ${pluginName} not found in installed plugins, skipping configuration" >&2

modules/jellyfin/resolvePlugins.nix

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ let
7979
(release: {
8080
inherit (repo) name url;
8181
inherit (release) sourceUrl version targetAbi;
82+
timestamp = release.timestamp or "";
83+
changelog = release.changelog or "";
84+
guid = plugin.guid or "";
85+
category = plugin.category or "";
86+
description = plugin.description or "";
87+
overview = plugin.overview or "";
88+
owner = plugin.owner or "";
89+
imageUrl = plugin.imageUrl or "";
8290
})
8391
(
8492
lib.filter (release: pluginVersion == "latest" || release.version == pluginVersion) (
@@ -148,6 +156,23 @@ let
148156
resolution = findPluginSource pluginName sourceSpec;
149157
resolvedVersion = resolution.match.version;
150158
pluginDirName = repoPluginDirName pluginName resolvedVersion;
159+
metaJson =
160+
pkgs.writeText "jellyfin-plugin-meta-${lib.strings.sanitizeDerivationName pluginName}.json"
161+
(
162+
builtins.toJSON {
163+
inherit (resolution.match) category;
164+
inherit (resolution.match) changelog;
165+
inherit (resolution.match) description;
166+
inherit (resolution.match) guid;
167+
inherit (resolution.match) imageUrl;
168+
name = pluginName;
169+
inherit (resolution.match) overview;
170+
inherit (resolution.match) owner;
171+
inherit (resolution.match) targetAbi;
172+
inherit (resolution.match) timestamp;
173+
version = resolvedVersion;
174+
}
175+
);
151176
in
152177
{
153178
pluginCfg = pluginCfg // {
@@ -160,6 +185,9 @@ let
160185
stripRoot = false;
161186
};
162187
passthru.pluginDirName = pluginDirName;
188+
postInstall = ''
189+
cp ${metaJson} $out/meta.json
190+
'';
163191
};
164192
};
165193
};

0 commit comments

Comments
 (0)