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}