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}