1{
2 config,
3 options,
4 lib,
5 pkgs,
6 ...
7}:
8let
9
10 name = "snapserver";
11
12 cfg = config.services.snapserver;
13
14 # Using types.nullOr to inherit upstream defaults.
15 sampleFormat = lib.mkOption {
16 type = with lib.types; nullOr str;
17 default = null;
18 description = ''
19 Default sample format.
20 '';
21 example = "48000:16:2";
22 };
23
24 codec = lib.mkOption {
25 type = with lib.types; nullOr str;
26 default = null;
27 description = ''
28 Default audio compression method.
29 '';
30 example = "flac";
31 };
32
33 streamToOption =
34 name: opt:
35 let
36 os = val: lib.optionalString (val != null) "${val}";
37 os' = prefix: val: lib.optionalString (val != null) (prefix + "${val}");
38 toQueryString = key: value: "&${key}=${value}";
39 in
40 "--stream.stream=\"${opt.type}://"
41 + os opt.location
42 + "?"
43 + os' "name=" name
44 + os' "&sampleformat=" opt.sampleFormat
45 + os' "&codec=" opt.codec
46 + lib.concatStrings (lib.mapAttrsToList toQueryString opt.query)
47 + "\"";
48
49 optionalNull = val: ret: lib.optional (val != null) ret;
50
51 optionString = lib.concatStringsSep " " (
52 lib.mapAttrsToList streamToOption cfg.streams
53 # global options
54 ++ [ "--stream.bind_to_address=${cfg.listenAddress}" ]
55 ++ [ "--stream.port=${toString cfg.port}" ]
56 ++ optionalNull cfg.sampleFormat "--stream.sampleformat=${cfg.sampleFormat}"
57 ++ optionalNull cfg.codec "--stream.codec=${cfg.codec}"
58 ++ optionalNull cfg.streamBuffer "--stream.stream_buffer=${toString cfg.streamBuffer}"
59 ++ optionalNull cfg.buffer "--stream.buffer=${toString cfg.buffer}"
60 ++ lib.optional cfg.sendToMuted "--stream.send_to_muted"
61 # tcp json rpc
62 ++ [ "--tcp.enabled=${toString cfg.tcp.enable}" ]
63 ++ lib.optionals cfg.tcp.enable [
64 "--tcp.bind_to_address=${cfg.tcp.listenAddress}"
65 "--tcp.port=${toString cfg.tcp.port}"
66 ]
67 # http json rpc
68 ++ [ "--http.enabled=${toString cfg.http.enable}" ]
69 ++ lib.optionals cfg.http.enable [
70 "--http.bind_to_address=${cfg.http.listenAddress}"
71 "--http.port=${toString cfg.http.port}"
72 ]
73 ++ lib.optional (cfg.http.docRoot != null) "--http.doc_root=\"${toString cfg.http.docRoot}\""
74 );
75
76in
77{
78 imports = [
79 (lib.mkRenamedOptionModule
80 [ "services" "snapserver" "controlPort" ]
81 [ "services" "snapserver" "tcp" "port" ]
82 )
83 ];
84
85 ###### interface
86
87 options = {
88
89 services.snapserver = {
90
91 enable = lib.mkOption {
92 type = lib.types.bool;
93 default = false;
94 description = ''
95 Whether to enable snapserver.
96 '';
97 };
98
99 package = lib.options.mkPackageOption pkgs "snapcast" { };
100
101 listenAddress = lib.mkOption {
102 type = lib.types.str;
103 default = "::";
104 example = "0.0.0.0";
105 description = ''
106 The address where snapclients can connect.
107 '';
108 };
109
110 port = lib.mkOption {
111 type = lib.types.port;
112 default = 1704;
113 description = ''
114 The port that snapclients can connect to.
115 '';
116 };
117
118 openFirewall = lib.mkOption {
119 type = lib.types.bool;
120 default = false;
121 description = ''
122 Whether to automatically open the specified ports in the firewall.
123 '';
124 };
125
126 inherit sampleFormat;
127 inherit codec;
128
129 streamBuffer = lib.mkOption {
130 type = with lib.types; nullOr int;
131 default = null;
132 description = ''
133 Stream read (input) buffer in ms.
134 '';
135 example = 20;
136 };
137
138 buffer = lib.mkOption {
139 type = with lib.types; nullOr int;
140 default = null;
141 description = ''
142 Network buffer in ms.
143 '';
144 example = 1000;
145 };
146
147 sendToMuted = lib.mkOption {
148 type = lib.types.bool;
149 default = false;
150 description = ''
151 Send audio to muted clients.
152 '';
153 };
154
155 tcp.enable = lib.mkOption {
156 type = lib.types.bool;
157 default = true;
158 description = ''
159 Whether to enable the JSON-RPC via TCP.
160 '';
161 };
162
163 tcp.listenAddress = lib.mkOption {
164 type = lib.types.str;
165 default = "::";
166 example = "0.0.0.0";
167 description = ''
168 The address where the TCP JSON-RPC listens on.
169 '';
170 };
171
172 tcp.port = lib.mkOption {
173 type = lib.types.port;
174 default = 1705;
175 description = ''
176 The port where the TCP JSON-RPC listens on.
177 '';
178 };
179
180 http.enable = lib.mkOption {
181 type = lib.types.bool;
182 default = true;
183 description = ''
184 Whether to enable the JSON-RPC via HTTP.
185 '';
186 };
187
188 http.listenAddress = lib.mkOption {
189 type = lib.types.str;
190 default = "::";
191 example = "0.0.0.0";
192 description = ''
193 The address where the HTTP JSON-RPC listens on.
194 '';
195 };
196
197 http.port = lib.mkOption {
198 type = lib.types.port;
199 default = 1780;
200 description = ''
201 The port where the HTTP JSON-RPC listens on.
202 '';
203 };
204
205 http.docRoot = lib.mkOption {
206 type = with lib.types; nullOr path;
207 default = pkgs.snapweb;
208 defaultText = lib.literalExpression "pkgs.snapweb";
209 description = ''
210 Path to serve from the HTTP servers root.
211 '';
212 };
213
214 streams = lib.mkOption {
215 type =
216 with lib.types;
217 attrsOf (submodule {
218 options = {
219 location = lib.mkOption {
220 type = lib.types.oneOf [
221 lib.types.path
222 lib.types.str
223 ];
224 description = ''
225 For type `pipe` or `file`, the path to the pipe or file.
226 For type `librespot`, `airplay` or `process`, the path to the corresponding binary.
227 For type `tcp`, the `host:port` address to connect to or listen on.
228 For type `meta`, a list of stream names in the form `/one/two/...`. Don't forget the leading slash.
229 For type `alsa`, use an empty string.
230 '';
231 example = lib.literalExpression ''
232 "/path/to/pipe"
233 "/path/to/librespot"
234 "192.168.1.2:4444"
235 "/MyTCP/Spotify/MyPipe"
236 '';
237 };
238 type = lib.mkOption {
239 type = lib.types.enum [
240 "pipe"
241 "librespot"
242 "airplay"
243 "file"
244 "process"
245 "tcp"
246 "alsa"
247 "spotify"
248 "meta"
249 ];
250 default = "pipe";
251 description = ''
252 The type of input stream.
253 '';
254 };
255 query = lib.mkOption {
256 type = attrsOf str;
257 default = { };
258 description = ''
259 Key-value pairs that convey additional parameters about a stream.
260 '';
261 example = lib.literalExpression ''
262 # for type == "pipe":
263 {
264 mode = "create";
265 };
266 # for type == "process":
267 {
268 params = "--param1 --param2";
269 logStderr = "true";
270 };
271 # for type == "tcp":
272 {
273 mode = "client";
274 }
275 # for type == "alsa":
276 {
277 device = "hw:0,0";
278 }
279 '';
280 };
281 inherit sampleFormat;
282 inherit codec;
283 };
284 });
285 default = {
286 default = { };
287 };
288 description = ''
289 The definition for an input source.
290 '';
291 example = lib.literalExpression ''
292 {
293 mpd = {
294 type = "pipe";
295 location = "/run/snapserver/mpd";
296 sampleFormat = "48000:16:2";
297 codec = "pcm";
298 };
299 };
300 '';
301 };
302 };
303 };
304
305 ###### implementation
306
307 config = lib.mkIf cfg.enable {
308
309 warnings =
310 # https://github.com/badaix/snapcast/blob/98ac8b2fb7305084376607b59173ce4097c620d8/server/streamreader/stream_manager.cpp#L85
311 lib.filter (w: w != "") (
312 lib.mapAttrsToList (
313 k: v:
314 lib.optionalString (v.type == "spotify") ''
315 services.snapserver.streams.${k}.type = "spotify" is deprecated, use services.snapserver.streams.${k}.type = "librespot" instead.
316 ''
317 ) cfg.streams
318 );
319
320 systemd.services.snapserver = {
321 after = [
322 "network.target"
323 "nss-lookup.target"
324 ];
325 description = "Snapserver";
326 wantedBy = [ "multi-user.target" ];
327 before = [
328 "mpd.service"
329 "mopidy.service"
330 ];
331
332 serviceConfig = {
333 DynamicUser = true;
334 ExecStart = "${cfg.package}/bin/snapserver --daemon ${optionString}";
335 Type = "forking";
336 LimitRTPRIO = 50;
337 LimitRTTIME = "infinity";
338 NoNewPrivileges = true;
339 PIDFile = "/run/${name}/pid";
340 ProtectKernelTunables = true;
341 ProtectControlGroups = true;
342 ProtectKernelModules = true;
343 Restart = "on-failure";
344 RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX AF_NETLINK";
345 RestrictNamespaces = true;
346 RuntimeDirectory = name;
347 StateDirectory = name;
348 };
349 };
350
351 networking.firewall.allowedTCPPorts =
352 lib.optionals cfg.openFirewall [ cfg.port ]
353 ++ lib.optional (cfg.openFirewall && cfg.tcp.enable) cfg.tcp.port
354 ++ lib.optional (cfg.openFirewall && cfg.http.enable) cfg.http.port;
355 };
356
357 meta = {
358 maintainers = with lib.maintainers; [ tobim ];
359 };
360
361}