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