main
  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    podcastDir = mkOption {
 73      type = types.nullOr types.str;
 74      default = null;
 75      description = "Path to podcast directory. If set, generates static M3U playlists for each podcast subfolder.";
 76      example = "/neo/music/podcasts";
 77    };
 78
 79    playlistDir = mkOption {
 80      type = types.str;
 81      default = "/neo/music/playlists";
 82      description = "Directory where playlists are written";
 83    };
 84
 85    schedule = mkOption {
 86      type = types.str;
 87      default = "daily";
 88      description = ''
 89        When to run the import. Can be "hourly", "daily", "weekly", "monthly", or a systemd OnCalendar format.
 90        See systemd.time(7) for OnCalendar format details.
 91      '';
 92      example = "weekly";
 93    };
 94
 95    notification = {
 96      enable = mkEnableOption "notifications via ntfy";
 97
 98      ntfyUrl = mkOption {
 99        type = types.str;
100        default = "https://ntfy.sbr.pm";
101        description = "URL of ntfy server";
102      };
103
104      topic = mkOption {
105        type = types.str;
106        default = "homelab";
107        description = "ntfy topic for notifications";
108      };
109
110      tokenFile = mkOption {
111        type = types.nullOr types.path;
112        default = null;
113        description = "Path to file containing ntfy authentication token (optional)";
114      };
115    };
116  };
117
118  config =
119    let
120      # Build the import paths
121      importPaths = map (dir: "${cfg.musicDir}/${dir}") cfg.importDirs;
122
123      # Build the beet import command
124      beetsImportScript = pkgs.writeShellScript "beets-auto-import-run" ''
125        set -euo pipefail
126
127        echo "Starting beets auto-import..."
128        echo "Import directories: ${concatStringsSep " " importPaths}"
129
130        # Import each subdirectory separately for better error isolation
131        for dir in ${concatStringsSep " " importPaths}; do
132          if [ -d "$dir" ] && [ "$(ls -A "$dir" 2>/dev/null)" ]; then
133            echo "Importing subdirectories of: $dir"
134            for subdir in "$dir"/*; do
135              if [ -d "$subdir" ]; then
136                echo "  Importing: $subdir"
137                ${cfg.package}/bin/beet import -q "$subdir" || true
138              fi
139            done
140          else
141            echo "Skipping empty or missing directory: $dir"
142          fi
143        done
144
145        ${optionalString cfg.updatePlaylists ''
146          echo "Updating smart playlists..."
147          ${cfg.package}/bin/beet splupdate
148        ''}
149
150        ${optionalString (cfg.podcastDir != null) ''
151          echo "Generating podcast playlists..."
152          podcast_dir="${cfg.podcastDir}"
153          playlist_dir="${cfg.playlistDir}"
154          for dir in "$podcast_dir"/*/; do
155            [ -d "$dir" ] || continue
156            name=$(${pkgs.coreutils}/bin/basename "$dir")
157            playlist="$playlist_dir/Podcast - $name.m3u"
158            echo "#EXTM3U" > "$playlist"
159            ${pkgs.findutils}/bin/find "$dir" -type f \( -name "*.mp3" -o -name "*.opus" -o -name "*.m4a" -o -name "*.ogg" -o -name "*.flac" \) | ${pkgs.coreutils}/bin/sort | while read -r f; do
160              echo "../podcasts/$name/$(${pkgs.coreutils}/bin/basename "$f")" >> "$playlist"
161            done
162            count=$(${pkgs.gnugrep}/bin/grep -c "^\.\." "$playlist" 2>/dev/null || echo 0)
163            echo "  Podcast - $name.m3u ($count tracks)"
164          done
165        ''}
166
167        echo "Beets auto-import complete!"
168      '';
169    in
170    mkIf cfg.enable {
171      # Systemd timer for scheduled import
172      systemd.timers.beets-auto-import = {
173        wantedBy = [ "timers.target" ];
174        timerConfig = {
175          OnCalendar = scheduleToCalendar cfg.schedule;
176          Persistent = true;
177          RandomizedDelaySec = "15m";
178        };
179      };
180
181      # Systemd service to run beets
182      systemd.services.beets-auto-import = {
183        description = "Auto-import music to beets and update playlists";
184        after = [ "network-online.target" ];
185        wants = [ "network-online.target" ];
186
187        environment = {
188          HOME = "/home/${cfg.user}";
189        };
190
191        serviceConfig = {
192          Type = "oneshot";
193          User = cfg.user;
194          Group = cfg.group;
195
196          # Run the import script
197          ExecStart = beetsImportScript;
198
199          # Notifications on success (if enabled)
200          ExecStartPost = mkIf cfg.notification.enable (
201            pkgs.writeShellScript "beets-auto-import-notify-success" ''
202              ${
203                if cfg.notification.tokenFile != null then
204                  ''
205                    ${pkgs.curl}/bin/curl \
206                      -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.notification.tokenFile})" \
207                      -H "Title: Beets Import Complete" \
208                      -H "Tags: musical_note,white_check_mark" \
209                      -d "Successfully imported music and updated playlists" \
210                      ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
211                  ''
212                else
213                  ''
214                    ${pkgs.curl}/bin/curl \
215                      -H "Title: Beets Import Complete" \
216                      -H "Tags: musical_note,white_check_mark" \
217                      -d "Successfully imported music and updated playlists" \
218                      ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
219                  ''
220              }
221            ''
222          );
223
224          # Resource limits (low priority)
225          Nice = 15;
226          IOSchedulingClass = "idle";
227          CPUSchedulingPolicy = "idle";
228
229          # Security hardening
230          PrivateTmp = true;
231          NoNewPrivileges = true;
232          ProtectSystem = "strict";
233          ProtectHome = "read-only";
234          ReadWritePaths = [
235            cfg.musicDir
236            "/home/${cfg.user}/.config/beets" # beets config/db
237          ];
238        };
239
240        # Notify on failure (if enabled)
241        onFailure = mkIf cfg.notification.enable [ "beets-auto-import-failure.service" ];
242      };
243
244      # Failure notification service
245      systemd.services.beets-auto-import-failure = mkIf cfg.notification.enable {
246        description = "Notify on beets auto-import failure";
247        serviceConfig = {
248          Type = "oneshot";
249          ExecStart = pkgs.writeShellScript "beets-auto-import-notify-failure" ''
250            ${
251              if cfg.notification.tokenFile != null then
252                ''
253                  ${pkgs.curl}/bin/curl \
254                    -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.notification.tokenFile})" \
255                    -H "Title: Beets Import Failed" \
256                    -H "Priority: high" \
257                    -H "Tags: warning,musical_note" \
258                    -d "Beets auto-import failed. Check logs: journalctl -u beets-auto-import" \
259                    ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
260                ''
261              else
262                ''
263                  ${pkgs.curl}/bin/curl \
264                    -H "Title: Beets Import Failed" \
265                    -H "Priority: high" \
266                    -H "Tags: warning,musical_note" \
267                    -d "Beets auto-import failed. Check logs: journalctl -u beets-auto-import" \
268                    ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
269                ''
270            }
271          '';
272        };
273      };
274    };
275}