auto-update-daily-20260202
  1{
  2  config,
  3  lib,
  4  pkgs,
  5  ...
  6}:
  7
  8with lib;
  9
 10let
 11  cfg = config.services.beets-auto-import;
 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.beets-auto-import = {
 29    enable = mkEnableOption "Beets auto-import and playlist update service";
 30
 31    package = mkOption {
 32      type = types.package;
 33      default = pkgs.beets;
 34      defaultText = literalExpression "pkgs.beets";
 35      description = "Beets package to use (can include custom plugins)";
 36    };
 37
 38    user = mkOption {
 39      type = types.str;
 40      default = "vincent";
 41      description = "User to run the beets service as";
 42    };
 43
 44    group = mkOption {
 45      type = types.str;
 46      default = "users";
 47      description = "Group to run the beets service as";
 48    };
 49
 50    musicDir = mkOption {
 51      type = types.str;
 52      default = "/neo/music";
 53      description = "Base music directory";
 54    };
 55
 56    importDirs = mkOption {
 57      type = types.listOf types.str;
 58      default = [
 59        "library"
 60        "soundtrack"
 61        "compilation"
 62      ];
 63      description = "Subdirectories under musicDir to import (relative paths)";
 64    };
 65
 66    updatePlaylists = mkOption {
 67      type = types.bool;
 68      default = true;
 69      description = "Run beet splupdate after import to regenerate smart playlists";
 70    };
 71
 72    schedule = mkOption {
 73      type = types.str;
 74      default = "daily";
 75      description = ''
 76        When to run the import. Can be "hourly", "daily", "weekly", "monthly", or a systemd OnCalendar format.
 77        See systemd.time(7) for OnCalendar format details.
 78      '';
 79      example = "weekly";
 80    };
 81
 82    notification = {
 83      enable = mkEnableOption "notifications via ntfy";
 84
 85      ntfyUrl = mkOption {
 86        type = types.str;
 87        default = "https://ntfy.sbr.pm";
 88        description = "URL of ntfy server";
 89      };
 90
 91      topic = mkOption {
 92        type = types.str;
 93        default = "homelab";
 94        description = "ntfy topic for notifications";
 95      };
 96
 97      tokenFile = mkOption {
 98        type = types.nullOr types.path;
 99        default = null;
100        description = "Path to file containing ntfy authentication token (optional)";
101      };
102    };
103  };
104
105  config =
106    let
107      # Build the import paths
108      importPaths = map (dir: "${cfg.musicDir}/${dir}") cfg.importDirs;
109
110      # Build the beet import command
111      beetsImportScript = pkgs.writeShellScript "beets-auto-import-run" ''
112        set -euo pipefail
113
114        echo "Starting beets auto-import..."
115        echo "Import directories: ${concatStringsSep " " importPaths}"
116
117        # Import each subdirectory separately for better error isolation
118        for dir in ${concatStringsSep " " importPaths}; do
119          if [ -d "$dir" ] && [ "$(ls -A "$dir" 2>/dev/null)" ]; then
120            echo "Importing subdirectories of: $dir"
121            for subdir in "$dir"/*; do
122              if [ -d "$subdir" ]; then
123                echo "  Importing: $subdir"
124                ${cfg.package}/bin/beet import -q "$subdir" || true
125              fi
126            done
127          else
128            echo "Skipping empty or missing directory: $dir"
129          fi
130        done
131
132        ${optionalString cfg.updatePlaylists ''
133          echo "Updating smart playlists..."
134          ${cfg.package}/bin/beet splupdate
135        ''}
136
137        echo "Beets auto-import complete!"
138      '';
139    in
140    mkIf cfg.enable {
141      # Systemd timer for scheduled import
142      systemd.timers.beets-auto-import = {
143        wantedBy = [ "timers.target" ];
144        timerConfig = {
145          OnCalendar = scheduleToCalendar cfg.schedule;
146          Persistent = true;
147          RandomizedDelaySec = "15m";
148        };
149      };
150
151      # Systemd service to run beets
152      systemd.services.beets-auto-import = {
153        description = "Auto-import music to beets and update playlists";
154        after = [ "network-online.target" ];
155        wants = [ "network-online.target" ];
156
157        environment = {
158          HOME = "/home/${cfg.user}";
159        };
160
161        serviceConfig = {
162          Type = "oneshot";
163          User = cfg.user;
164          Group = cfg.group;
165
166          # Run the import script
167          ExecStart = beetsImportScript;
168
169          # Notifications on success (if enabled)
170          ExecStartPost = mkIf cfg.notification.enable (
171            pkgs.writeShellScript "beets-auto-import-notify-success" ''
172              ${
173                if cfg.notification.tokenFile != null then
174                  ''
175                    ${pkgs.curl}/bin/curl \
176                      -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.notification.tokenFile})" \
177                      -H "Title: Beets Import Complete" \
178                      -H "Tags: musical_note,white_check_mark" \
179                      -d "Successfully imported music and updated playlists" \
180                      ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
181                  ''
182                else
183                  ''
184                    ${pkgs.curl}/bin/curl \
185                      -H "Title: Beets Import Complete" \
186                      -H "Tags: musical_note,white_check_mark" \
187                      -d "Successfully imported music and updated playlists" \
188                      ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
189                  ''
190              }
191            ''
192          );
193
194          # Resource limits (low priority)
195          Nice = 15;
196          IOSchedulingClass = "idle";
197          CPUSchedulingPolicy = "idle";
198
199          # Security hardening
200          PrivateTmp = true;
201          NoNewPrivileges = true;
202          ProtectSystem = "strict";
203          ProtectHome = "read-only";
204          ReadWritePaths = [
205            cfg.musicDir
206            "/home/${cfg.user}/.config/beets" # beets config/db
207          ];
208        };
209
210        # Notify on failure (if enabled)
211        onFailure = mkIf cfg.notification.enable [ "beets-auto-import-failure.service" ];
212      };
213
214      # Failure notification service
215      systemd.services.beets-auto-import-failure = mkIf cfg.notification.enable {
216        description = "Notify on beets auto-import failure";
217        serviceConfig = {
218          Type = "oneshot";
219          ExecStart = pkgs.writeShellScript "beets-auto-import-notify-failure" ''
220            ${
221              if cfg.notification.tokenFile != null then
222                ''
223                  ${pkgs.curl}/bin/curl \
224                    -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.notification.tokenFile})" \
225                    -H "Title: Beets Import Failed" \
226                    -H "Priority: high" \
227                    -H "Tags: warning,musical_note" \
228                    -d "Beets auto-import failed. Check logs: journalctl -u beets-auto-import" \
229                    ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
230                ''
231              else
232                ''
233                  ${pkgs.curl}/bin/curl \
234                    -H "Title: Beets Import Failed" \
235                    -H "Priority: high" \
236                    -H "Tags: warning,musical_note" \
237                    -d "Beets auto-import failed. Check logs: journalctl -u beets-auto-import" \
238                    ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
239                ''
240            }
241          '';
242        };
243      };
244    };
245}