main
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.jellyfin-favorites-sync;
12
13 # Convert schedule shortcuts to systemd OnCalendar format
14 scheduleToCalendar =
15 schedule:
16 if schedule == "hourly" then
17 "hourly"
18 else if schedule == "daily" then
19 "daily"
20 else if schedule == "weekly" then
21 "weekly"
22 else
23 schedule;
24
25in
26{
27 options.services.jellyfin-favorites-sync = {
28 enable = mkEnableOption "Jellyfin favorites sync service";
29
30 package = mkOption {
31 type = types.package;
32 default = pkgs.jellyfin-favorites-sync;
33 defaultText = literalExpression "pkgs.jellyfin-favorites-sync";
34 description = "The jellyfin-favorites-sync package to use.";
35 };
36
37 schedule = mkOption {
38 type = types.str;
39 default = "daily";
40 description = ''
41 When to run the sync. Can be "hourly", "daily", "weekly", or a systemd OnCalendar format.
42 See systemd.time(7) for OnCalendar format details.
43 '';
44 example = "daily";
45 };
46
47 jellyfinUrl = mkOption {
48 type = types.str;
49 example = "https://jellyfin.sbr.pm";
50 description = "Jellyfin server URL";
51 };
52
53 apiKeyFile = mkOption {
54 type = types.path;
55 description = "Path to file containing Jellyfin API key (managed by agenix)";
56 };
57
58 userId = mkOption {
59 type = types.str;
60 description = "Jellyfin user ID or username (will be auto-resolved to GUID)";
61 };
62
63 playlistId = mkOption {
64 type = types.nullOr types.str;
65 default = null;
66 description = "Jellyfin playlist ID to sync (instead of favorites)";
67 };
68
69 playlistName = mkOption {
70 type = types.nullOr types.str;
71 default = null;
72 description = "Jellyfin playlist name to sync (instead of favorites)";
73 };
74
75 sourceRoot = mkOption {
76 type = types.str;
77 default = "/neo/videos";
78 description = "Root path of Jellyfin library on source host";
79 };
80
81 destination = {
82 host = mkOption {
83 type = types.str;
84 default = "aix.sbr.pm";
85 description = "Target SSH host for rsync";
86 };
87
88 user = mkOption {
89 type = types.str;
90 default = "vincent";
91 description = "SSH user for remote connection";
92 };
93
94 root = mkOption {
95 type = types.str;
96 default = "/data/videos";
97 description = "Destination path on target host";
98 };
99 };
100
101 sshKeyFile = mkOption {
102 type = types.nullOr types.path;
103 default = null;
104 description = "Path to SSH private key file for rsync authentication (managed by agenix)";
105 example = "/run/agenix/jellyfin-favorites-sync-ssh-key";
106 };
107
108 sshArgs = mkOption {
109 type = types.listOf types.str;
110 default = [
111 "-o StrictHostKeyChecking=no"
112 "-o UserKnownHostsFile=/dev/null"
113 ];
114 description = "Additional SSH arguments";
115 example = [
116 "-p 2222"
117 ];
118 };
119
120 user = mkOption {
121 type = types.str;
122 default = "jellyfin-favorites-sync";
123 description = "System user to run service as";
124 };
125
126 group = mkOption {
127 type = types.str;
128 default = "jellyfin-favorites-sync";
129 description = "System group to run service as";
130 };
131
132 randomizedDelay = mkOption {
133 type = types.str;
134 default = "5m";
135 description = "Randomized delay before starting the job (systemd RandomizedDelaySec)";
136 example = "1h";
137 };
138
139 dryRun = mkOption {
140 type = types.bool;
141 default = false;
142 description = "Enable dry-run mode (show what would be synced without making changes)";
143 };
144 };
145
146 config = mkIf cfg.enable {
147 # Create system user/group
148 users.users.${cfg.user} = {
149 isSystemUser = true;
150 inherit (cfg) group;
151 description = "Jellyfin favorites sync service user";
152 home = "/var/lib/${cfg.user}";
153 createHome = true;
154 };
155
156 users.groups.${cfg.group} = { };
157
158 # Systemd service
159 systemd.services.jellyfin-favorites-sync = {
160 description = "Jellyfin Favorites Sync";
161 after = [ "network-online.target" ];
162 wants = [ "network-online.target" ];
163
164 serviceConfig = {
165 Type = "oneshot";
166 User = cfg.user;
167 Group = cfg.group;
168
169 ExecStart =
170 let
171 # Build SSH args with optional key file
172 sshArgsWithKey = cfg.sshArgs ++ (optionals (cfg.sshKeyFile != null) [ "-i ${cfg.sshKeyFile}" ]);
173 in
174 pkgs.writeShellScript "jellyfin-favorites-sync-start" ''
175 set -euo pipefail
176
177 # Read API key from file
178 API_KEY=$(cat ${cfg.apiKeyFile})
179
180 # Execute sync
181 ${cfg.package}/bin/jellyfin-favorites-sync \
182 --jellyfin-url "${cfg.jellyfinUrl}" \
183 --api-key "$API_KEY" \
184 --user-id "${cfg.userId}" \
185 ${optionalString (cfg.playlistId != null) "--playlist-id \"${cfg.playlistId}\""} \
186 ${optionalString (cfg.playlistName != null) "--playlist-name \"${cfg.playlistName}\""} \
187 --source-root "${cfg.sourceRoot}" \
188 --dest-host "${cfg.destination.host}" \
189 --dest-user "${cfg.destination.user}" \
190 --dest-root "${cfg.destination.root}" \
191 ${concatMapStringsSep " " (arg: "--ssh-arg '${arg}'") sshArgsWithKey} \
192 ${optionalString cfg.dryRun "--dry-run"} \
193 --verbose
194 '';
195
196 # Security hardening
197 PrivateTmp = true;
198 ProtectSystem = "strict";
199 NoNewPrivileges = true;
200 ReadWritePaths = [ "/tmp" ]; # For rsync file lists
201
202 # Resource limits
203 Nice = 15;
204 IOSchedulingClass = "idle";
205 };
206
207 path = with pkgs; [
208 openssh
209 rsync
210 ];
211 };
212
213 # Systemd timer
214 systemd.timers.jellyfin-favorites-sync = {
215 description = "Timer for Jellyfin Favorites Sync";
216 wantedBy = [ "timers.target" ];
217
218 timerConfig = {
219 OnCalendar = scheduleToCalendar cfg.schedule;
220 Persistent = true;
221 RandomizedDelaySec = cfg.randomizedDelay;
222 };
223 };
224 };
225}