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}