main
  1{
  2  config,
  3  lib,
  4  pkgs,
  5  ...
  6}:
  7
  8with lib;
  9
 10let
 11  cfg = config.services.audible-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 if schedule == "monthly" then
 23      "monthly"
 24    else
 25      schedule;
 26in
 27{
 28  options.services.audible-sync = {
 29    enable = mkEnableOption "Audible to Audiobookshelf sync service";
 30
 31    user = mkOption {
 32      type = types.str;
 33      default = "vincent";
 34      description = "User to run the sync service as";
 35    };
 36
 37    outputDir = mkOption {
 38      type = types.str;
 39      default = "/neo/audiobooks";
 40      description = "Output directory for converted audiobooks";
 41    };
 42
 43    tempDir = mkOption {
 44      type = types.str;
 45      default = "/neo/audiobooks/zz_import";
 46      description = "Temporary directory for downloads";
 47    };
 48
 49    quality = mkOption {
 50      type = types.enum [
 51        "best"
 52        "high"
 53        "normal"
 54      ];
 55      default = "best";
 56      description = "Audio quality for downloads";
 57    };
 58
 59    format = mkOption {
 60      type = types.enum [
 61        "m4b"
 62        "mp3"
 63        "m4a"
 64      ];
 65      default = "m4b";
 66      description = "Output format for converted audiobooks";
 67    };
 68
 69    schedule = mkOption {
 70      type = types.str;
 71      default = "daily";
 72      description = ''
 73        When to run the sync. Can be "hourly", "daily", "weekly", "monthly", or a systemd OnCalendar format.
 74        See systemd.time(7) for OnCalendar format details.
 75      '';
 76      example = "daily";
 77    };
 78
 79    notification = {
 80      enable = mkEnableOption "notifications via ntfy";
 81
 82      ntfyUrl = mkOption {
 83        type = types.str;
 84        default = "https://ntfy.sbr.pm";
 85        description = "URL of ntfy server";
 86      };
 87
 88      topic = mkOption {
 89        type = types.str;
 90        default = "audible-sync";
 91        description = "ntfy topic for notifications";
 92      };
 93
 94      tokenFile = mkOption {
 95        type = types.nullOr types.path;
 96        default = null;
 97        description = "Path to file containing ntfy authentication token (optional)";
 98      };
 99    };
100  };
101
102  config = mkIf cfg.enable {
103    # Install the converter tool
104    environment.systemPackages = with pkgs; [
105      audible-converter
106    ];
107
108    # Systemd timer for scheduled sync
109    systemd.timers.audible-sync = {
110      wantedBy = [ "timers.target" ];
111      timerConfig = {
112        OnCalendar = scheduleToCalendar cfg.schedule;
113        Persistent = true;
114        RandomizedDelaySec = "10m";
115      };
116    };
117
118    # Systemd service to run the sync
119    systemd.services.audible-sync = {
120      description = "Sync Audible library to Audiobookshelf";
121      after = [ "network-online.target" ];
122      wants = [ "network-online.target" ];
123
124      environment = {
125        AUDIBLE_OUTPUT_DIR = cfg.outputDir;
126        AUDIBLE_TEMP_DIR = cfg.tempDir;
127        AUDIBLE_QUALITY = cfg.quality;
128        AUDIBLE_FORMAT = cfg.format;
129        HOME = "/home/${cfg.user}";
130      };
131
132      serviceConfig = {
133        Type = "oneshot";
134        User = cfg.user;
135        Group = "users";
136
137        # Run the sync command
138        ExecStart = "${pkgs.audible-converter}/bin/audible-converter sync";
139
140        # Notifications on success (if enabled)
141        ExecStartPost = mkIf cfg.notification.enable (
142          pkgs.writeShellScript "audible-sync-notify-success" ''
143            ${
144              if cfg.notification.tokenFile != null then
145                ''
146                  ${pkgs.curl}/bin/curl \
147                    -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.notification.tokenFile})" \
148                    -H "Title: Audible Sync Complete" \
149                    -H "Tags: white_check_mark,books" \
150                    -d "Successfully synced Audible library to ${cfg.outputDir}" \
151                    ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
152                ''
153              else
154                ''
155                  ${pkgs.curl}/bin/curl \
156                    -H "Title: Audible Sync Complete" \
157                    -H "Tags: white_check_mark,books" \
158                    -d "Successfully synced Audible library to ${cfg.outputDir}" \
159                    ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
160                ''
161            }
162          ''
163        );
164
165        # Ensure directories exist
166        ExecStartPre = pkgs.writeShellScript "audible-sync-prepare" ''
167          mkdir -p "${cfg.outputDir}"
168          mkdir -p "${cfg.tempDir}"
169        '';
170
171        # Note: We keep tempDir intact to reuse downloaded AAX files
172        # and reduce bandwidth usage on subsequent runs
173
174        # Resource limits
175        Nice = 15;
176        IOSchedulingClass = "idle";
177        CPUSchedulingPolicy = "idle";
178
179        # Security hardening
180        PrivateTmp = true;
181        NoNewPrivileges = true;
182        ProtectSystem = "strict";
183        ProtectHome = "read-only";
184        ReadWritePaths = [
185          cfg.outputDir
186          cfg.tempDir
187        ];
188        # Allow access to token file if configured
189        BindReadOnlyPaths = mkIf (cfg.notification.enable && cfg.notification.tokenFile != null) [
190          cfg.notification.tokenFile
191        ];
192      };
193
194      # Notify on failure (if enabled)
195      onFailure = mkIf cfg.notification.enable [ "audible-sync-failure.service" ];
196    };
197
198    # Failure notification service
199    systemd.services.audible-sync-failure = mkIf cfg.notification.enable {
200      description = "Notify on Audible sync failure";
201      serviceConfig = {
202        Type = "oneshot";
203        ExecStart = pkgs.writeShellScript "audible-sync-notify-failure" ''
204          ${
205            if cfg.notification.tokenFile != null then
206              ''
207                ${pkgs.curl}/bin/curl \
208                  -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.notification.tokenFile})" \
209                  -H "Title: Audible Sync Failed" \
210                  -H "Priority: high" \
211                  -H "Tags: warning,books" \
212                  -d "Audible sync failed. Check logs: journalctl -u audible-sync" \
213                  ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
214              ''
215            else
216              ''
217                ${pkgs.curl}/bin/curl \
218                  -H "Title: Audible Sync Failed" \
219                  -H "Priority: high" \
220                  -H "Tags: warning,books" \
221                  -d "Audible sync failed. Check logs: journalctl -u audible-sync" \
222                  ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
223              ''
224          }
225        '';
226        # Allow access to token file if configured
227        BindReadOnlyPaths = mkIf (cfg.notification.tokenFile != null) [
228          cfg.notification.tokenFile
229        ];
230      };
231    };
232  };
233}