1{ config, 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 = ''
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 = ''
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.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.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 = ''
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 = ''
90 The address where snapclients can connect.
91 '';
92 };
93
94 port = mkOption {
95 type = types.port;
96 default = 1704;
97 description = ''
98 The port that snapclients can connect to.
99 '';
100 };
101
102 openFirewall = mkOption {
103 type = types.bool;
104 default = true;
105 description = ''
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 = ''
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 = ''
126 Network buffer in ms.
127 '';
128 example = 1000;
129 };
130
131 sendToMuted = mkOption {
132 type = types.bool;
133 default = false;
134 description = ''
135 Send audio to muted clients.
136 '';
137 };
138
139 tcp.enable = mkOption {
140 type = types.bool;
141 default = true;
142 description = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
203 For type <literal>pipe</literal> or <literal>file</literal>, the path to the pipe or file.
204 For type <literal>librespot</literal>, <literal>airplay</literal> or <literal>process</literal>, the path to the corresponding binary.
205 For type <literal>tcp</literal>, the <literal>host:port</literal> address to connect to or listen on.
206 For type <literal>meta</literal>, a list of stream names in the form <literal>/one/two/...</literal>. Don't forget the leading slash.
207 For type <literal>alsa</literal>, 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 = ''
220 The type of input stream.
221 '';
222 };
223 query = mkOption {
224 type = attrsOf str;
225 default = {};
226 description = ''
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 = ''
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 # https://github.com/badaix/snapcast/blob/98ac8b2fb7305084376607b59173ce4097c620d8/server/streamreader/stream_manager.cpp#L85
277 warnings = filter (w: w != "") (mapAttrsToList (k: v: if v.type == "spotify" then ''
278 services.snapserver.streams.${k}.type = "spotify" is deprecated, use services.snapserver.streams.${k}.type = "librespot" instead.
279 '' else "") cfg.streams);
280
281 systemd.services.snapserver = {
282 after = [ "network.target" ];
283 description = "Snapserver";
284 wantedBy = [ "multi-user.target" ];
285 before = [ "mpd.service" "mopidy.service" ];
286
287 serviceConfig = {
288 DynamicUser = true;
289 ExecStart = "${pkgs.snapcast}/bin/snapserver --daemon ${optionString}";
290 Type = "forking";
291 LimitRTPRIO = 50;
292 LimitRTTIME = "infinity";
293 NoNewPrivileges = true;
294 PIDFile = "/run/${name}/pid";
295 ProtectKernelTunables = true;
296 ProtectControlGroups = true;
297 ProtectKernelModules = true;
298 RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX AF_NETLINK";
299 RestrictNamespaces = true;
300 RuntimeDirectory = name;
301 StateDirectory = name;
302 };
303 };
304
305 networking.firewall.allowedTCPPorts =
306 optionals cfg.openFirewall [ cfg.port ]
307 ++ optional cfg.tcp.enable cfg.tcp.port
308 ++ optional cfg.http.enable cfg.http.port;
309 };
310
311 meta = {
312 maintainers = with maintainers; [ tobim ];
313 };
314
315}