diff options
Diffstat (limited to 'nixos/modules/services/audio')
19 files changed, 2322 insertions, 0 deletions
diff --git a/nixos/modules/services/audio/alsa.nix b/nixos/modules/services/audio/alsa.nix new file mode 100644 index 00000000000..0d743ed31da --- /dev/null +++ b/nixos/modules/services/audio/alsa.nix @@ -0,0 +1,133 @@ +# ALSA sound support. +{ config, lib, pkgs, ... }: + +with lib; + +let + + inherit (pkgs) alsa-utils; + + pulseaudioEnabled = config.hardware.pulseaudio.enable; + +in + +{ + imports = [ + (mkRenamedOptionModule [ "sound" "enableMediaKeys" ] [ "sound" "mediaKeys" "enable" ]) + ]; + + ###### interface + + options = { + + sound = { + + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable ALSA sound. + ''; + }; + + enableOSSEmulation = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable ALSA OSS emulation (with certain cards sound mixing may not work!). + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + example = '' + defaults.pcm.!card 3 + ''; + description = '' + Set addition configuration for system-wide alsa. + ''; + }; + + mediaKeys = { + + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable volume and capture control with keyboard media keys. + + You want to leave this disabled if you run a desktop environment + like KDE, Gnome, Xfce, etc, as those handle such things themselves. + You might want to enable this if you run a minimalistic desktop + environment or work from bare linux ttys/framebuffers. + + Enabling this will turn on <option>services.actkbd</option>. + ''; + }; + + volumeStep = mkOption { + type = types.str; + default = "1"; + example = "1%"; + description = '' + The value by which to increment/decrement volume on media keys. + + See amixer(1) for allowed values. + ''; + }; + + }; + + }; + + }; + + + ###### implementation + + config = mkIf config.sound.enable { + + environment.systemPackages = [ alsa-utils ]; + + environment.etc = mkIf (!pulseaudioEnabled && config.sound.extraConfig != "") + { "asound.conf".text = config.sound.extraConfig; }; + + # ALSA provides a udev rule for restoring volume settings. + services.udev.packages = [ alsa-utils ]; + + boot.kernelModules = optional config.sound.enableOSSEmulation "snd_pcm_oss"; + + systemd.services.alsa-store = + { description = "Store Sound Card State"; + wantedBy = [ "multi-user.target" ]; + unitConfig.RequiresMountsFor = "/var/lib/alsa"; + unitConfig.ConditionVirtualization = "!systemd-nspawn"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.coreutils}/bin/mkdir -p /var/lib/alsa"; + ExecStop = "${alsa-utils}/sbin/alsactl store --ignore"; + }; + }; + + services.actkbd = mkIf config.sound.mediaKeys.enable { + enable = true; + bindings = [ + # "Mute" media key + { keys = [ 113 ]; events = [ "key" ]; command = "${alsa-utils}/bin/amixer -q set Master toggle"; } + + # "Lower Volume" media key + { keys = [ 114 ]; events = [ "key" "rep" ]; command = "${alsa-utils}/bin/amixer -q set Master ${config.sound.mediaKeys.volumeStep}- unmute"; } + + # "Raise Volume" media key + { keys = [ 115 ]; events = [ "key" "rep" ]; command = "${alsa-utils}/bin/amixer -q set Master ${config.sound.mediaKeys.volumeStep}+ unmute"; } + + # "Mic Mute" media key + { keys = [ 190 ]; events = [ "key" ]; command = "${alsa-utils}/bin/amixer -q set Capture toggle"; } + ]; + }; + + }; + +} diff --git a/nixos/modules/services/audio/botamusique.nix b/nixos/modules/services/audio/botamusique.nix new file mode 100644 index 00000000000..f4fa0ead4f0 --- /dev/null +++ b/nixos/modules/services/audio/botamusique.nix @@ -0,0 +1,115 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.botamusique; + + format = pkgs.formats.ini {}; + configFile = format.generate "botamusique.ini" cfg.settings; +in +{ + meta.maintainers = with lib.maintainers; [ hexa ]; + + options.services.botamusique = { + enable = mkEnableOption "botamusique, a bot to play audio streams on mumble"; + + package = mkOption { + type = types.package; + default = pkgs.botamusique; + defaultText = literalExpression "pkgs.botamusique"; + description = "The botamusique package to use."; + }; + + settings = mkOption { + type = with types; submodule { + freeformType = format.type; + options = { + server.host = mkOption { + type = types.str; + default = "localhost"; + example = "mumble.example.com"; + description = "Hostname of the mumble server to connect to."; + }; + + server.port = mkOption { + type = types.port; + default = 64738; + description = "Port of the mumble server to connect to."; + }; + + bot.username = mkOption { + type = types.str; + default = "botamusique"; + description = "Name the bot should appear with."; + }; + + bot.comment = mkOption { + type = types.str; + default = "Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun!"; + description = "Comment displayed for the bot."; + }; + }; + }; + default = {}; + description = '' + Your <filename>configuration.ini</filename> as a Nix attribute set. Look up + possible options in the <link xlink:href="https://github.com/azlux/botamusique/blob/master/configuration.example.ini">configuration.example.ini</link>. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.botamusique = { + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + unitConfig.Documentation = "https://github.com/azlux/botamusique/wiki"; + + environment.HOME = "/var/lib/botamusique"; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/botamusique --config ${configFile}"; + Restart = "always"; # the bot exits when the server connection is lost + + # Hardening + CapabilityBoundingSet = [ "" ]; + DynamicUser = true; + IPAddressDeny = [ + "link-local" + "multicast" + ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + ProcSubset = "pid"; + PrivateDevices = true; + PrivateUsers = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + StateDirectory = "botamusique"; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + UMask = "0077"; + WorkingDirectory = "/var/lib/botamusique"; + }; + }; + }; +} diff --git a/nixos/modules/services/audio/hqplayerd.nix b/nixos/modules/services/audio/hqplayerd.nix new file mode 100644 index 00000000000..416d12ce217 --- /dev/null +++ b/nixos/modules/services/audio/hqplayerd.nix @@ -0,0 +1,142 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.hqplayerd; + pkg = pkgs.hqplayerd; + # XXX: This is hard-coded in the distributed binary, don't try to change it. + stateDir = "/var/lib/hqplayer"; + configDir = "/etc/hqplayer"; +in +{ + options = { + services.hqplayerd = { + enable = mkEnableOption "HQPlayer Embedded"; + + auth = { + username = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Username used for HQPlayer's WebUI. + + Without this you will need to manually create the credentials after + first start by going to http://your.ip/8088/auth + ''; + }; + + password = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Password used for HQPlayer's WebUI. + + Without this you will need to manually create the credentials after + first start by going to http://your.ip/8088/auth + ''; + }; + }; + + licenseFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to the HQPlayer license key file. + + Without this, the service will run in trial mode and restart every 30 + minutes. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Opens ports needed for the WebUI and controller API. + ''; + }; + + config = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + HQplayer daemon configuration, written to /etc/hqplayer/hqplayerd.xml. + + Refer to share/doc/hqplayerd/readme.txt in the hqplayerd derivation for possible values. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = (cfg.auth.username != null -> cfg.auth.password != null) + && (cfg.auth.password != null -> cfg.auth.username != null); + message = "You must set either both services.hqplayer.auth.username and password, or neither."; + } + ]; + + environment = { + etc = { + "hqplayer/hqplayerd.xml" = mkIf (cfg.config != null) { source = pkgs.writeText "hqplayerd.xml" cfg.config; }; + "hqplayer/hqplayerd4-key.xml" = mkIf (cfg.licenseFile != null) { source = cfg.licenseFile; }; + "modules-load.d/taudio2.conf".source = "${pkg}/etc/modules-load.d/taudio2.conf"; + }; + systemPackages = [ pkg ]; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ 8088 4321 ]; + }; + + services.udev.packages = [ pkg ]; + + systemd = { + tmpfiles.rules = [ + "d ${configDir} 0755 hqplayer hqplayer - -" + "d ${stateDir} 0755 hqplayer hqplayer - -" + "d ${stateDir}/home 0755 hqplayer hqplayer - -" + ]; + + packages = [ pkg ]; + + services.hqplayerd = { + wantedBy = [ "multi-user.target" ]; + after = [ "systemd-tmpfiles-setup.service" ]; + + environment.HOME = "${stateDir}/home"; + + unitConfig.ConditionPathExists = [ configDir stateDir ]; + + restartTriggers = optionals (cfg.config != null) [ config.environment.etc."hqplayer/hqplayerd.xml".source ]; + + preStart = '' + cp -r "${pkg}/var/lib/hqplayer/web" "${stateDir}" + chmod -R u+wX "${stateDir}/web" + + if [ ! -f "${configDir}/hqplayerd.xml" ]; then + echo "creating initial config file" + install -m 0644 "${pkg}/etc/hqplayer/hqplayerd.xml" "${configDir}/hqplayerd.xml" + fi + '' + optionalString (cfg.auth.username != null && cfg.auth.password != null) '' + ${pkg}/bin/hqplayerd -s ${cfg.auth.username} ${cfg.auth.password} + ''; + }; + }; + + users.groups = { + hqplayer.gid = config.ids.gids.hqplayer; + }; + + users.users = { + hqplayer = { + description = "hqplayer daemon user"; + extraGroups = [ "audio" ]; + group = "hqplayer"; + uid = config.ids.uids.hqplayer; + }; + }; + }; +} diff --git a/nixos/modules/services/audio/icecast.nix b/nixos/modules/services/audio/icecast.nix new file mode 100644 index 00000000000..5ee5bd745f9 --- /dev/null +++ b/nixos/modules/services/audio/icecast.nix @@ -0,0 +1,131 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.icecast; + configFile = pkgs.writeText "icecast.xml" '' + <icecast> + <hostname>${cfg.hostname}</hostname> + + <authentication> + <admin-user>${cfg.admin.user}</admin-user> + <admin-password>${cfg.admin.password}</admin-password> + </authentication> + + <paths> + <logdir>${cfg.logDir}</logdir> + <adminroot>${pkgs.icecast}/share/icecast/admin</adminroot> + <webroot>${pkgs.icecast}/share/icecast/web</webroot> + <alias source="/" dest="/status.xsl"/> + </paths> + + <listen-socket> + <port>${toString cfg.listen.port}</port> + <bind-address>${cfg.listen.address}</bind-address> + </listen-socket> + + <security> + <chroot>0</chroot> + <changeowner> + <user>${cfg.user}</user> + <group>${cfg.group}</group> + </changeowner> + </security> + + ${cfg.extraConf} + </icecast> + ''; +in { + + ###### interface + + options = { + + services.icecast = { + + enable = mkEnableOption "Icecast server"; + + hostname = mkOption { + type = types.nullOr types.str; + description = "DNS name or IP address that will be used for the stream directory lookups or possibily the playlist generation if a Host header is not provided."; + default = config.networking.domain; + defaultText = literalExpression "config.networking.domain"; + }; + + admin = { + user = mkOption { + type = types.str; + description = "Username used for all administration functions."; + default = "admin"; + }; + + password = mkOption { + type = types.str; + description = "Password used for all administration functions."; + }; + }; + + logDir = mkOption { + type = types.path; + description = "Base directory used for logging."; + default = "/var/log/icecast"; + }; + + listen = { + port = mkOption { + type = types.int; + description = "TCP port that will be used to accept client connections."; + default = 8000; + }; + + address = mkOption { + type = types.str; + description = "Address Icecast will listen on."; + default = "::"; + }; + }; + + user = mkOption { + type = types.str; + description = "User privileges for the server."; + default = "nobody"; + }; + + group = mkOption { + type = types.str; + description = "Group privileges for the server."; + default = "nogroup"; + }; + + extraConf = mkOption { + type = types.lines; + description = "icecast.xml content."; + default = ""; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + systemd.services.icecast = { + after = [ "network.target" ]; + description = "Icecast Network Audio Streaming Server"; + wantedBy = [ "multi-user.target" ]; + + preStart = "mkdir -p ${cfg.logDir} && chown ${cfg.user}:${cfg.group} ${cfg.logDir}"; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.icecast}/bin/icecast -c ${configFile}"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + }; + }; + + }; + +} diff --git a/nixos/modules/services/audio/jack.nix b/nixos/modules/services/audio/jack.nix new file mode 100644 index 00000000000..84fc9957b87 --- /dev/null +++ b/nixos/modules/services/audio/jack.nix @@ -0,0 +1,294 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.jack; + + pcmPlugin = cfg.jackd.enable && cfg.alsa.enable; + loopback = cfg.jackd.enable && cfg.loopback.enable; + + enable32BitAlsaPlugins = cfg.alsa.support32Bit && pkgs.stdenv.isx86_64 && pkgs.pkgsi686Linux.alsa-lib != null; + + umaskNeeded = versionOlder cfg.jackd.package.version "1.9.12"; + bridgeNeeded = versionAtLeast cfg.jackd.package.version "1.9.12"; +in { + options = { + services.jack = { + jackd = { + enable = mkEnableOption '' + JACK Audio Connection Kit. You need to add yourself to the "jackaudio" group + ''; + + package = mkOption { + # until jack1 promiscuous mode is fixed + internal = true; + type = types.package; + default = pkgs.jack2; + defaultText = literalExpression "pkgs.jack2"; + example = literalExpression "pkgs.jack1"; + description = '' + The JACK package to use. + ''; + }; + + extraOptions = mkOption { + type = types.listOf types.str; + default = [ + "-dalsa" + ]; + example = literalExpression '' + [ "-dalsa" "--device" "hw:1" ]; + ''; + description = '' + Specifies startup command line arguments to pass to JACK server. + ''; + }; + + session = mkOption { + type = types.lines; + description = '' + Commands to run after JACK is started. + ''; + }; + + }; + + alsa = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Route audio to/from generic ALSA-using applications using ALSA JACK PCM plugin. + ''; + }; + + support32Bit = mkOption { + type = types.bool; + default = false; + description = '' + Whether to support sound for 32-bit ALSA applications on 64-bit system. + ''; + }; + }; + + loopback = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Create ALSA loopback device, instead of using PCM plugin. Has broader + application support (things like Steam will work), but may need fine-tuning + for concrete hardware. + ''; + }; + + index = mkOption { + type = types.int; + default = 10; + description = '' + Index of an ALSA loopback device. + ''; + }; + + config = mkOption { + type = types.lines; + description = '' + ALSA config for loopback device. + ''; + }; + + dmixConfig = mkOption { + type = types.lines; + default = ""; + example = '' + period_size 2048 + periods 2 + ''; + description = '' + For music production software that still doesn't support JACK natively you + would like to put buffer/period adjustments here + to decrease dmix device latency. + ''; + }; + + session = mkOption { + type = types.lines; + description = '' + Additional commands to run to setup loopback device. + ''; + }; + }; + + }; + + }; + + config = mkMerge [ + + (mkIf pcmPlugin { + sound.extraConfig = '' + pcm_type.jack { + libs.native = ${pkgs.alsa-plugins}/lib/alsa-lib/libasound_module_pcm_jack.so ; + ${lib.optionalString enable32BitAlsaPlugins + "libs.32Bit = ${pkgs.pkgsi686Linux.alsa-plugins}/lib/alsa-lib/libasound_module_pcm_jack.so ;"} + } + pcm.!default { + @func getenv + vars [ PCM ] + default "plug:jack" + } + ''; + }) + + (mkIf loopback { + boot.kernelModules = [ "snd-aloop" ]; + boot.kernelParams = [ "snd-aloop.index=${toString cfg.loopback.index}" ]; + sound.extraConfig = cfg.loopback.config; + }) + + (mkIf cfg.jackd.enable { + services.jack.jackd.session = '' + ${lib.optionalString bridgeNeeded "${pkgs.a2jmidid}/bin/a2jmidid -e &"} + ''; + # https://alsa.opensrc.org/Jack_and_Loopback_device_as_Alsa-to-Jack_bridge#id06 + services.jack.loopback.config = '' + pcm.loophw00 { + type hw + card ${toString cfg.loopback.index} + device 0 + subdevice 0 + } + pcm.amix { + type dmix + ipc_key 219345 + slave { + pcm loophw00 + ${cfg.loopback.dmixConfig} + } + } + pcm.asoftvol { + type softvol + slave.pcm "amix" + control { name Master } + } + pcm.cloop { + type hw + card ${toString cfg.loopback.index} + device 1 + subdevice 0 + format S32_LE + } + pcm.loophw01 { + type hw + card ${toString cfg.loopback.index} + device 0 + subdevice 1 + } + pcm.ploop { + type hw + card ${toString cfg.loopback.index} + device 1 + subdevice 1 + format S32_LE + } + pcm.aduplex { + type asym + playback.pcm "asoftvol" + capture.pcm "loophw01" + } + pcm.!default { + type plug + slave.pcm aduplex + } + ''; + services.jack.loopback.session = '' + alsa_in -j cloop -dcloop & + alsa_out -j ploop -dploop & + while [ "$(jack_lsp cloop)" == "" ] || [ "$(jack_lsp ploop)" == "" ]; do sleep 1; done + jack_connect cloop:capture_1 system:playback_1 + jack_connect cloop:capture_2 system:playback_2 + jack_connect system:capture_1 ploop:playback_1 + jack_connect system:capture_2 ploop:playback_2 + ''; + + assertions = [ + { + assertion = !(cfg.alsa.enable && cfg.loopback.enable); + message = "For JACK both alsa and loopback options shouldn't be used at the same time."; + } + ]; + + users.users.jackaudio = { + group = "jackaudio"; + extraGroups = [ "audio" ]; + description = "JACK Audio system service user"; + isSystemUser = true; + }; + # http://jackaudio.org/faq/linux_rt_config.html + security.pam.loginLimits = [ + { domain = "@jackaudio"; type = "-"; item = "rtprio"; value = "99"; } + { domain = "@jackaudio"; type = "-"; item = "memlock"; value = "unlimited"; } + ]; + users.groups.jackaudio = {}; + + environment = { + systemPackages = [ cfg.jackd.package ]; + etc."alsa/conf.d/50-jack.conf".source = "${pkgs.alsa-plugins}/etc/alsa/conf.d/50-jack.conf"; + variables.JACK_PROMISCUOUS_SERVER = "jackaudio"; + }; + + services.udev.extraRules = '' + ACTION=="add", SUBSYSTEM=="sound", ATTRS{id}!="Loopback", TAG+="systemd", ENV{SYSTEMD_WANTS}="jack.service" + ''; + + systemd.services.jack = { + description = "JACK Audio Connection Kit"; + serviceConfig = { + User = "jackaudio"; + SupplementaryGroups = lib.optional + (config.hardware.pulseaudio.enable + && !config.hardware.pulseaudio.systemWide) "users"; + ExecStart = "${cfg.jackd.package}/bin/jackd ${lib.escapeShellArgs cfg.jackd.extraOptions}"; + LimitRTPRIO = 99; + LimitMEMLOCK = "infinity"; + } // optionalAttrs umaskNeeded { + UMask = "007"; + }; + path = [ cfg.jackd.package ]; + environment = { + JACK_PROMISCUOUS_SERVER = "jackaudio"; + JACK_NO_AUDIO_RESERVATION = "1"; + }; + restartIfChanged = false; + }; + systemd.services.jack-session = { + description = "JACK session"; + script = '' + jack_wait -w + ${cfg.jackd.session} + ${lib.optionalString cfg.loopback.enable cfg.loopback.session} + ''; + serviceConfig = { + RemainAfterExit = true; + User = "jackaudio"; + StateDirectory = "jack"; + LimitRTPRIO = 99; + LimitMEMLOCK = "infinity"; + }; + path = [ cfg.jackd.package ]; + environment = { + JACK_PROMISCUOUS_SERVER = "jackaudio"; + HOME = "/var/lib/jack"; + }; + wantedBy = [ "jack.service" ]; + partOf = [ "jack.service" ]; + after = [ "jack.service" ]; + restartIfChanged = false; + }; + }) + + ]; + + meta.maintainers = [ ]; +} diff --git a/nixos/modules/services/audio/jmusicbot.nix b/nixos/modules/services/audio/jmusicbot.nix new file mode 100644 index 00000000000..e0f8d461af0 --- /dev/null +++ b/nixos/modules/services/audio/jmusicbot.nix @@ -0,0 +1,48 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.jmusicbot; +in +{ + options = { + services.jmusicbot = { + enable = mkEnableOption "jmusicbot, a Discord music bot that's easy to set up and run yourself"; + + package = mkOption { + type = types.package; + default = pkgs.jmusicbot; + defaultText = literalExpression "pkgs.jmusicbot"; + description = "JMusicBot package to use"; + }; + + stateDir = mkOption { + type = types.path; + description = '' + The directory where config.txt and serversettings.json is saved. + If left as the default value this directory will automatically be created before JMusicBot starts, otherwise the sysadmin is responsible for ensuring the directory exists with appropriate ownership and permissions. + Untouched by the value of this option config.txt needs to be placed manually into this directory. + ''; + default = "/var/lib/jmusicbot/"; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.jmusicbot = { + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + description = "Discord music bot that's easy to set up and run yourself!"; + serviceConfig = mkMerge [{ + ExecStart = "${cfg.package}/bin/JMusicBot"; + WorkingDirectory = cfg.stateDir; + Restart = "always"; + RestartSec = 20; + DynamicUser = true; + } + (mkIf (cfg.stateDir == "/var/lib/jmusicbot") { StateDirectory = "jmusicbot"; })]; + }; + }; + + meta.maintainers = with maintainers; [ SuperSandro2000 ]; +} diff --git a/nixos/modules/services/audio/liquidsoap.nix b/nixos/modules/services/audio/liquidsoap.nix new file mode 100644 index 00000000000..ffeefc0f988 --- /dev/null +++ b/nixos/modules/services/audio/liquidsoap.nix @@ -0,0 +1,69 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + streams = builtins.attrNames config.services.liquidsoap.streams; + + streamService = + name: + let stream = builtins.getAttr name config.services.liquidsoap.streams; in + { inherit name; + value = { + after = [ "network-online.target" "sound.target" ]; + description = "${name} liquidsoap stream"; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.wget ]; + serviceConfig = { + ExecStart = "${pkgs.liquidsoap}/bin/liquidsoap ${stream}"; + User = "liquidsoap"; + LogsDirectory = "liquidsoap"; + }; + }; + }; +in +{ + + ##### interface + + options = { + + services.liquidsoap.streams = mkOption { + + description = + '' + Set of Liquidsoap streams to start, + one systemd service per stream. + ''; + + default = {}; + + example = { + myStream1 = "/etc/liquidsoap/myStream1.liq"; + myStream2 = literalExpression "./myStream2.liq"; + myStream3 = "out(playlist(\"/srv/music/\"))"; + }; + + type = types.attrsOf (types.either types.path types.str); + }; + + }; + ##### implementation + + config = mkIf (builtins.length streams != 0) { + + users.users.liquidsoap = { + uid = config.ids.uids.liquidsoap; + group = "liquidsoap"; + extraGroups = [ "audio" ]; + description = "Liquidsoap streaming user"; + home = "/var/lib/liquidsoap"; + createHome = true; + }; + + users.groups.liquidsoap.gid = config.ids.gids.liquidsoap; + + systemd.services = builtins.listToAttrs ( map streamService streams ); + }; + +} diff --git a/nixos/modules/services/audio/mopidy.nix b/nixos/modules/services/audio/mopidy.nix new file mode 100644 index 00000000000..9937feadaeb --- /dev/null +++ b/nixos/modules/services/audio/mopidy.nix @@ -0,0 +1,108 @@ +{ config, lib, pkgs, ... }: + +with pkgs; +with lib; + +let + uid = config.ids.uids.mopidy; + gid = config.ids.gids.mopidy; + cfg = config.services.mopidy; + + mopidyConf = writeText "mopidy.conf" cfg.configuration; + + mopidyEnv = buildEnv { + name = "mopidy-with-extensions-${mopidy.version}"; + paths = closePropagation cfg.extensionPackages; + pathsToLink = [ "/${mopidyPackages.python.sitePackages}" ]; + buildInputs = [ makeWrapper ]; + postBuild = '' + makeWrapper ${mopidy}/bin/mopidy $out/bin/mopidy \ + --prefix PYTHONPATH : $out/${mopidyPackages.python.sitePackages} + ''; + }; +in { + + options = { + + services.mopidy = { + + enable = mkEnableOption "Mopidy, a music player daemon"; + + dataDir = mkOption { + default = "/var/lib/mopidy"; + type = types.str; + description = '' + The directory where Mopidy stores its state. + ''; + }; + + extensionPackages = mkOption { + default = []; + type = types.listOf types.package; + example = literalExpression "[ pkgs.mopidy-spotify ]"; + description = '' + Mopidy extensions that should be loaded by the service. + ''; + }; + + configuration = mkOption { + default = ""; + type = types.lines; + description = '' + The configuration that Mopidy should use. + ''; + }; + + extraConfigFiles = mkOption { + default = []; + type = types.listOf types.str; + description = '' + Extra config file read by Mopidy when the service starts. + Later files in the list overrides earlier configuration. + ''; + }; + + }; + + }; + + ###### implementation + + config = mkIf cfg.enable { + + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}' - mopidy mopidy - -" + ]; + + systemd.services.mopidy = { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "sound.target" ]; + description = "mopidy music player daemon"; + serviceConfig = { + ExecStart = "${mopidyEnv}/bin/mopidy --config ${concatStringsSep ":" ([mopidyConf] ++ cfg.extraConfigFiles)}"; + User = "mopidy"; + }; + }; + + systemd.services.mopidy-scan = { + description = "mopidy local files scanner"; + serviceConfig = { + ExecStart = "${mopidyEnv}/bin/mopidy --config ${concatStringsSep ":" ([mopidyConf] ++ cfg.extraConfigFiles)} local scan"; + User = "mopidy"; + Type = "oneshot"; + }; + }; + + users.users.mopidy = { + inherit uid; + group = "mopidy"; + extraGroups = [ "audio" ]; + description = "Mopidy daemon user"; + home = cfg.dataDir; + }; + + users.groups.mopidy.gid = gid; + + }; + +} diff --git a/nixos/modules/services/audio/mpd.nix b/nixos/modules/services/audio/mpd.nix new file mode 100644 index 00000000000..586b9ffa688 --- /dev/null +++ b/nixos/modules/services/audio/mpd.nix @@ -0,0 +1,265 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + name = "mpd"; + + uid = config.ids.uids.mpd; + gid = config.ids.gids.mpd; + cfg = config.services.mpd; + + credentialsPlaceholder = (creds: + let + placeholders = (imap0 + (i: c: ''password "{{password-${toString i}}}@${concatStringsSep "," c.permissions}"'') + creds); + in + concatStringsSep "\n" placeholders); + + mpdConf = pkgs.writeText "mpd.conf" '' + # This file was automatically generated by NixOS. Edit mpd's configuration + # via NixOS' configuration.nix, as this file will be rewritten upon mpd's + # restart. + + music_directory "${cfg.musicDirectory}" + playlist_directory "${cfg.playlistDirectory}" + ${lib.optionalString (cfg.dbFile != null) '' + db_file "${cfg.dbFile}" + ''} + state_file "${cfg.dataDir}/state" + sticker_file "${cfg.dataDir}/sticker.sql" + + ${optionalString (cfg.network.listenAddress != "any") ''bind_to_address "${cfg.network.listenAddress}"''} + ${optionalString (cfg.network.port != 6600) ''port "${toString cfg.network.port}"''} + ${optionalString (cfg.fluidsynth) '' + decoder { + plugin "fluidsynth" + soundfont "${pkgs.soundfont-fluid}/share/soundfonts/FluidR3_GM2-2.sf2" + } + ''} + + ${optionalString (cfg.credentials != []) (credentialsPlaceholder cfg.credentials)} + + ${cfg.extraConfig} + ''; + +in { + + ###### interface + + options = { + + services.mpd = { + + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable MPD, the music player daemon. + ''; + }; + + startWhenNeeded = mkOption { + type = types.bool; + default = false; + description = '' + If set, <command>mpd</command> is socket-activated; that + is, instead of having it permanently running as a daemon, + systemd will start it on the first incoming connection. + ''; + }; + + musicDirectory = mkOption { + type = with types; either path (strMatching "(http|https|nfs|smb)://.+"); + default = "${cfg.dataDir}/music"; + defaultText = literalExpression ''"''${dataDir}/music"''; + description = '' + The directory or NFS/SMB network share where MPD reads music from. If left + as the default value this directory will automatically be created before + the MPD server starts, otherwise the sysadmin is responsible for ensuring + the directory exists with appropriate ownership and permissions. + ''; + }; + + playlistDirectory = mkOption { + type = types.path; + default = "${cfg.dataDir}/playlists"; + defaultText = literalExpression ''"''${dataDir}/playlists"''; + description = '' + The directory where MPD stores playlists. If left as the default value + this directory will automatically be created before the MPD server starts, + otherwise the sysadmin is responsible for ensuring the directory exists + with appropriate ownership and permissions. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Extra directives added to to the end of MPD's configuration file, + mpd.conf. Basic configuration like file location and uid/gid + is added automatically to the beginning of the file. For available + options see <literal>man 5 mpd.conf</literal>'. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/${name}"; + description = '' + The directory where MPD stores its state, tag cache, playlists etc. If + left as the default value this directory will automatically be created + before the MPD server starts, otherwise the sysadmin is responsible for + ensuring the directory exists with appropriate ownership and permissions. + ''; + }; + + user = mkOption { + type = types.str; + default = name; + description = "User account under which MPD runs."; + }; + + group = mkOption { + type = types.str; + default = name; + description = "Group account under which MPD runs."; + }; + + network = { + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + example = "any"; + description = '' + The address for the daemon to listen on. + Use <literal>any</literal> to listen on all addresses. + ''; + }; + + port = mkOption { + type = types.int; + default = 6600; + description = '' + This setting is the TCP port that is desired for the daemon to get assigned + to. + ''; + }; + + }; + + dbFile = mkOption { + type = types.nullOr types.str; + default = "${cfg.dataDir}/tag_cache"; + defaultText = literalExpression ''"''${dataDir}/tag_cache"''; + description = '' + The path to MPD's database. If set to <literal>null</literal> the + parameter is omitted from the configuration. + ''; + }; + + credentials = mkOption { + type = types.listOf (types.submodule { + options = { + passwordFile = mkOption { + type = types.path; + description = '' + Path to file containing the password. + ''; + }; + permissions = let + perms = ["read" "add" "control" "admin"]; + in mkOption { + type = types.listOf (types.enum perms); + default = [ "read" ]; + description = '' + List of permissions that are granted with this password. + Permissions can be "${concatStringsSep "\", \"" perms}". + ''; + }; + }; + }); + description = '' + Credentials and permissions for accessing the mpd server. + ''; + default = []; + example = [ + {passwordFile = "/var/lib/secrets/mpd_readonly_password"; permissions = [ "read" ];} + {passwordFile = "/var/lib/secrets/mpd_admin_password"; permissions = ["read" "add" "control" "admin"];} + ]; + }; + + fluidsynth = mkOption { + type = types.bool; + default = false; + description = '' + If set, add fluidsynth soundfont and configure the plugin. + ''; + }; + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + # install mpd units + systemd.packages = [ pkgs.mpd ]; + + systemd.sockets.mpd = mkIf cfg.startWhenNeeded { + wantedBy = [ "sockets.target" ]; + listenStreams = [ + (if pkgs.lib.hasPrefix "/" cfg.network.listenAddress + then cfg.network.listenAddress + else "${optionalString (cfg.network.listenAddress != "any") "${cfg.network.listenAddress}:"}${toString cfg.network.port}") + ]; + }; + + systemd.services.mpd = { + wantedBy = optional (!cfg.startWhenNeeded) "multi-user.target"; + + preStart = + '' + set -euo pipefail + install -m 600 ${mpdConf} /run/mpd/mpd.conf + '' + optionalString (cfg.credentials != []) + (concatStringsSep "\n" + (imap0 + (i: c: ''${pkgs.replace-secret}/bin/replace-secret '{{password-${toString i}}}' '${c.passwordFile}' /run/mpd/mpd.conf'') + cfg.credentials)); + + serviceConfig = + { + User = "${cfg.user}"; + # Note: the first "" overrides the ExecStart from the upstream unit + ExecStart = [ "" "${pkgs.mpd}/bin/mpd --systemd /run/mpd/mpd.conf" ]; + RuntimeDirectory = "mpd"; + StateDirectory = [] + ++ optionals (cfg.dataDir == "/var/lib/${name}") [ name ] + ++ optionals (cfg.playlistDirectory == "/var/lib/${name}/playlists") [ name "${name}/playlists" ] + ++ optionals (cfg.musicDirectory == "/var/lib/${name}/music") [ name "${name}/music" ]; + }; + }; + + users.users = optionalAttrs (cfg.user == name) { + ${name} = { + inherit uid; + group = cfg.group; + extraGroups = [ "audio" ]; + description = "Music Player Daemon user"; + home = "${cfg.dataDir}"; + }; + }; + + users.groups = optionalAttrs (cfg.group == name) { + ${name}.gid = gid; + }; + }; + +} diff --git a/nixos/modules/services/audio/mpdscribble.nix b/nixos/modules/services/audio/mpdscribble.nix new file mode 100644 index 00000000000..333ffb70941 --- /dev/null +++ b/nixos/modules/services/audio/mpdscribble.nix @@ -0,0 +1,213 @@ +{ config, lib, options, pkgs, ... }: + +with lib; + +let + cfg = config.services.mpdscribble; + mpdCfg = config.services.mpd; + mpdOpt = options.services.mpd; + + endpointUrls = { + "last.fm" = "http://post.audioscrobbler.com"; + "libre.fm" = "http://turtle.libre.fm"; + "jamendo" = "http://postaudioscrobbler.jamendo.com"; + "listenbrainz" = "http://proxy.listenbrainz.org"; + }; + + mkSection = secname: secCfg: '' + [${secname}] + url = ${secCfg.url} + username = ${secCfg.username} + password = {{${secname}_PASSWORD}} + journal = /var/lib/mpdscribble/${secname}.journal + ''; + + endpoints = concatStringsSep "\n" (mapAttrsToList mkSection cfg.endpoints); + cfgTemplate = pkgs.writeText "mpdscribble.conf" '' + ## This file was automatically genenrated by NixOS and will be overwritten. + ## Do not edit. Edit your NixOS configuration instead. + + ## mpdscribble - an audioscrobbler for the Music Player Daemon. + ## http://mpd.wikia.com/wiki/Client:mpdscribble + + # HTTP proxy URL. + ${optionalString (cfg.proxy != null) "proxy = ${cfg.proxy}"} + + # The location of the mpdscribble log file. The special value + # "syslog" makes mpdscribble use the local syslog daemon. On most + # systems, log messages will appear in /var/log/daemon.log then. + # "-" means log to stderr (the current terminal). + log = - + + # How verbose mpdscribble's logging should be. Default is 1. + verbose = ${toString cfg.verbose} + + # How often should mpdscribble save the journal file? [seconds] + journal_interval = ${toString cfg.journalInterval} + + # The host running MPD, possibly protected by a password + # ([PASSWORD@]HOSTNAME). + host = ${(optionalString (cfg.passwordFile != null) "{{MPD_PASSWORD}}@") + cfg.host} + + # The port that the MPD listens on and mpdscribble should try to + # connect to. + port = ${toString cfg.port} + + ${endpoints} + ''; + + cfgFile = "/run/mpdscribble/mpdscribble.conf"; + + replaceSecret = secretFile: placeholder: targetFile: + optionalString (secretFile != null) '' + ${pkgs.replace-secret}/bin/replace-secret '${placeholder}' '${secretFile}' '${targetFile}' ''; + + preStart = pkgs.writeShellScript "mpdscribble-pre-start" '' + cp -f "${cfgTemplate}" "${cfgFile}" + ${replaceSecret cfg.passwordFile "{{MPD_PASSWORD}}" cfgFile} + ${concatStringsSep "\n" (mapAttrsToList (secname: cfg: + replaceSecret cfg.passwordFile "{{${secname}_PASSWORD}}" cfgFile) + cfg.endpoints)} + ''; + + localMpd = (cfg.host == "localhost" || cfg.host == "127.0.0.1"); + +in { + ###### interface + + options.services.mpdscribble = { + + enable = mkEnableOption "mpdscribble"; + + proxy = mkOption { + default = null; + type = types.nullOr types.str; + description = '' + HTTP proxy URL. + ''; + }; + + verbose = mkOption { + default = 1; + type = types.int; + description = '' + Log level for the mpdscribble daemon. + ''; + }; + + journalInterval = mkOption { + default = 600; + example = 60; + type = types.int; + description = '' + How often should mpdscribble save the journal file? [seconds] + ''; + }; + + host = mkOption { + default = (if mpdCfg.network.listenAddress != "any" then + mpdCfg.network.listenAddress + else + "localhost"); + defaultText = literalExpression '' + if config.${mpdOpt.network.listenAddress} != "any" + then config.${mpdOpt.network.listenAddress} + else "localhost" + ''; + type = types.str; + description = '' + Host for the mpdscribble daemon to search for a mpd daemon on. + ''; + }; + + passwordFile = mkOption { + default = if localMpd then + (findFirst + (c: any (x: x == "read") c.permissions) + { passwordFile = null; } + mpdCfg.credentials).passwordFile + else + null; + defaultText = literalDocBook '' + The first password file with read access configured for MPD when using a local instance, + otherwise <literal>null</literal>. + ''; + type = types.nullOr types.str; + description = '' + File containing the password for the mpd daemon. + If there is a local mpd configured using <option>services.mpd.credentials</option> + the default is automatically set to a matching passwordFile of the local mpd. + ''; + }; + + port = mkOption { + default = mpdCfg.network.port; + defaultText = literalExpression "config.${mpdOpt.network.port}"; + type = types.port; + description = '' + Port for the mpdscribble daemon to search for a mpd daemon on. + ''; + }; + + endpoints = mkOption { + type = (let + endpoint = { name, ... }: { + options = { + url = mkOption { + type = types.str; + default = endpointUrls.${name} or ""; + description = + "The url endpoint where the scrobble API is listening."; + }; + username = mkOption { + type = types.str; + description = '' + Username for the scrobble service. + ''; + }; + passwordFile = mkOption { + type = types.nullOr types.str; + description = + "File containing the password, either as MD5SUM or cleartext."; + }; + }; + }; + in types.attrsOf (types.submodule endpoint)); + default = { }; + example = { + "last.fm" = { + username = "foo"; + passwordFile = "/run/secrets/lastfm_password"; + }; + }; + description = '' + Endpoints to scrobble to. + If the endpoint is one of "${ + concatStringsSep "\", \"" (attrNames endpointUrls) + }" the url is set automatically. + ''; + }; + + }; + + ###### implementation + + config = mkIf cfg.enable { + systemd.services.mpdscribble = { + after = [ "network.target" ] ++ (optional localMpd "mpd.service"); + description = "mpdscribble mpd scrobble client"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + DynamicUser = true; + StateDirectory = "mpdscribble"; + RuntimeDirectory = "mpdscribble"; + RuntimeDirectoryMode = "700"; + # TODO use LoadCredential= instead of running preStart with full privileges? + ExecStartPre = "+${preStart}"; + ExecStart = + "${pkgs.mpdscribble}/bin/mpdscribble --no-daemon --conf ${cfgFile}"; + }; + }; + }; + +} diff --git a/nixos/modules/services/audio/navidrome.nix b/nixos/modules/services/audio/navidrome.nix new file mode 100644 index 00000000000..3660e05310b --- /dev/null +++ b/nixos/modules/services/audio/navidrome.nix @@ -0,0 +1,71 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.navidrome; + settingsFormat = pkgs.formats.json {}; +in { + options = { + services.navidrome = { + + enable = mkEnableOption "Navidrome music server"; + + settings = mkOption rec { + type = settingsFormat.type; + apply = recursiveUpdate default; + default = { + Address = "127.0.0.1"; + Port = 4533; + }; + example = { + MusicFolder = "/mnt/music"; + }; + description = '' + Configuration for Navidrome, see <link xlink:href="https://www.navidrome.org/docs/usage/configuration-options/"/> for supported values. + ''; + }; + + }; + }; + + config = mkIf cfg.enable { + systemd.services.navidrome = { + description = "Navidrome Media Server"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = '' + ${pkgs.navidrome}/bin/navidrome --configfile ${settingsFormat.generate "navidrome.json" cfg.settings} + ''; + DynamicUser = true; + StateDirectory = "navidrome"; + WorkingDirectory = "/var/lib/navidrome"; + RuntimeDirectory = "navidrome"; + RootDirectory = "/run/navidrome"; + ReadWritePaths = ""; + BindReadOnlyPaths = [ + builtins.storeDir + ] ++ lib.optional (cfg.settings ? MusicFolder) cfg.settings.MusicFolder; + CapabilityBoundingSet = ""; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + RestrictRealtime = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + UMask = "0066"; + ProtectHostname = true; + }; + }; + }; +} diff --git a/nixos/modules/services/audio/networkaudiod.nix b/nixos/modules/services/audio/networkaudiod.nix new file mode 100644 index 00000000000..265a4e1d95d --- /dev/null +++ b/nixos/modules/services/audio/networkaudiod.nix @@ -0,0 +1,19 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + name = "networkaudiod"; + cfg = config.services.networkaudiod; +in { + options = { + services.networkaudiod = { + enable = mkEnableOption "Networkaudiod (NAA)"; + }; + }; + + config = mkIf cfg.enable { + systemd.packages = [ pkgs.networkaudiod ]; + systemd.services.networkaudiod.wantedBy = [ "multi-user.target" ]; + }; +} diff --git a/nixos/modules/services/audio/roon-bridge.nix b/nixos/modules/services/audio/roon-bridge.nix new file mode 100644 index 00000000000..e08f8a4f9e7 --- /dev/null +++ b/nixos/modules/services/audio/roon-bridge.nix @@ -0,0 +1,76 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + name = "roon-bridge"; + cfg = config.services.roon-bridge; +in { + options = { + services.roon-bridge = { + enable = mkEnableOption "Roon Bridge"; + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open ports in the firewall for the bridge. + ''; + }; + user = mkOption { + type = types.str; + default = "roon-bridge"; + description = '' + User to run the Roon bridge as. + ''; + }; + group = mkOption { + type = types.str; + default = "roon-bridge"; + description = '' + Group to run the Roon Bridge as. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.roon-bridge = { + after = [ "network.target" ]; + description = "Roon Bridge"; + wantedBy = [ "multi-user.target" ]; + + environment.ROON_DATAROOT = "/var/lib/${name}"; + + serviceConfig = { + ExecStart = "${pkgs.roon-bridge}/start.sh"; + LimitNOFILE = 8192; + User = cfg.user; + Group = cfg.group; + StateDirectory = name; + }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPortRanges = [{ from = 9100; to = 9200; }]; + allowedUDPPorts = [ 9003 ]; + extraCommands = '' + iptables -A INPUT -s 224.0.0.0/4 -j ACCEPT + iptables -A INPUT -d 224.0.0.0/4 -j ACCEPT + iptables -A INPUT -s 240.0.0.0/5 -j ACCEPT + iptables -A INPUT -m pkttype --pkt-type multicast -j ACCEPT + iptables -A INPUT -m pkttype --pkt-type broadcast -j ACCEPT + ''; + }; + + + users.groups.${cfg.group} = {}; + users.users.${cfg.user} = + if cfg.user == "roon-bridge" then { + isSystemUser = true; + description = "Roon Bridge user"; + group = cfg.group; + extraGroups = [ "audio" ]; + } + else {}; + }; +} diff --git a/nixos/modules/services/audio/roon-server.nix b/nixos/modules/services/audio/roon-server.nix new file mode 100644 index 00000000000..de1f61c8e73 --- /dev/null +++ b/nixos/modules/services/audio/roon-server.nix @@ -0,0 +1,79 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + name = "roon-server"; + cfg = config.services.roon-server; +in { + options = { + services.roon-server = { + enable = mkEnableOption "Roon Server"; + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open ports in the firewall for the server. + ''; + }; + user = mkOption { + type = types.str; + default = "roon-server"; + description = '' + User to run the Roon Server as. + ''; + }; + group = mkOption { + type = types.str; + default = "roon-server"; + description = '' + Group to run the Roon Server as. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.roon-server = { + after = [ "network.target" ]; + description = "Roon Server"; + wantedBy = [ "multi-user.target" ]; + + environment.ROON_DATAROOT = "/var/lib/${name}"; + + serviceConfig = { + ExecStart = "${pkgs.roon-server}/bin/RoonServer"; + LimitNOFILE = 8192; + User = cfg.user; + Group = cfg.group; + StateDirectory = name; + }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPortRanges = [ + { from = 9100; to = 9200; } + { from = 9330; to = 9332; } + ]; + allowedUDPPorts = [ 9003 ]; + extraCommands = '' + iptables -A INPUT -s 224.0.0.0/4 -j ACCEPT + iptables -A INPUT -d 224.0.0.0/4 -j ACCEPT + iptables -A INPUT -s 240.0.0.0/5 -j ACCEPT + iptables -A INPUT -m pkttype --pkt-type multicast -j ACCEPT + iptables -A INPUT -m pkttype --pkt-type broadcast -j ACCEPT + ''; + }; + + + users.groups.${cfg.group} = {}; + users.users.${cfg.user} = + if cfg.user == "roon-server" then { + isSystemUser = true; + description = "Roon Server user"; + group = cfg.group; + extraGroups = [ "audio" ]; + } + else {}; + }; +} diff --git a/nixos/modules/services/audio/slimserver.nix b/nixos/modules/services/audio/slimserver.nix new file mode 100644 index 00000000000..ecd26528499 --- /dev/null +++ b/nixos/modules/services/audio/slimserver.nix @@ -0,0 +1,73 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.slimserver; + +in { + options = { + + services.slimserver = { + + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable slimserver. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.slimserver; + defaultText = literalExpression "pkgs.slimserver"; + description = "Slimserver package to use."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/slimserver"; + description = '' + The directory where slimserver stores its state, tag cache, + playlists etc. + ''; + }; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}' - slimserver slimserver - -" + ]; + + systemd.services.slimserver = { + after = [ "network.target" ]; + description = "Slim Server for Logitech Squeezebox Players"; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + User = "slimserver"; + # Issue 40589: Disable broken image/video support (audio still works!) + ExecStart = "${cfg.package}/slimserver.pl --logdir ${cfg.dataDir}/logs --prefsdir ${cfg.dataDir}/prefs --cachedir ${cfg.dataDir}/cache --noimage --novideo"; + }; + }; + + users = { + users.slimserver = { + description = "Slimserver daemon user"; + home = cfg.dataDir; + group = "slimserver"; + isSystemUser = true; + }; + groups.slimserver = {}; + }; + }; + +} + diff --git a/nixos/modules/services/audio/snapserver.nix b/nixos/modules/services/audio/snapserver.nix new file mode 100644 index 00000000000..6d5ce98df89 --- /dev/null +++ b/nixos/modules/services/audio/snapserver.nix @@ -0,0 +1,315 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + name = "snapserver"; + + cfg = config.services.snapserver; + + # Using types.nullOr to inherit upstream defaults. + sampleFormat = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Default sample format. + ''; + example = "48000:16:2"; + }; + + codec = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Default audio compression method. + ''; + example = "flac"; + }; + + streamToOption = name: opt: + let + os = val: + optionalString (val != null) "${val}"; + os' = prefix: val: + optionalString (val != null) (prefix + "${val}"); + flatten = key: value: + "&${key}=${value}"; + in + "--stream.stream=\"${opt.type}://" + os opt.location + "?" + os' "name=" name + + concatStrings (mapAttrsToList flatten opt.query) + "\""; + + optionalNull = val: ret: + optional (val != null) ret; + + optionString = concatStringsSep " " (mapAttrsToList streamToOption cfg.streams + # global options + ++ [ "--stream.bind_to_address=${cfg.listenAddress}" ] + ++ [ "--stream.port=${toString cfg.port}" ] + ++ optionalNull cfg.sampleFormat "--stream.sampleformat=${cfg.sampleFormat}" + ++ optionalNull cfg.codec "--stream.codec=${cfg.codec}" + ++ optionalNull cfg.streamBuffer "--stream.stream_buffer=${toString cfg.streamBuffer}" + ++ optionalNull cfg.buffer "--stream.buffer=${toString cfg.buffer}" + ++ optional cfg.sendToMuted "--stream.send_to_muted" + # tcp json rpc + ++ [ "--tcp.enabled=${toString cfg.tcp.enable}" ] + ++ optionals cfg.tcp.enable [ + "--tcp.bind_to_address=${cfg.tcp.listenAddress}" + "--tcp.port=${toString cfg.tcp.port}" ] + # http json rpc + ++ [ "--http.enabled=${toString cfg.http.enable}" ] + ++ optionals cfg.http.enable [ + "--http.bind_to_address=${cfg.http.listenAddress}" + "--http.port=${toString cfg.http.port}" + ] ++ optional (cfg.http.docRoot != null) "--http.doc_root=\"${toString cfg.http.docRoot}\""); + +in { + imports = [ + (mkRenamedOptionModule [ "services" "snapserver" "controlPort" ] [ "services" "snapserver" "tcp" "port" ]) + ]; + + ###### interface + + options = { + + services.snapserver = { + + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable snapserver. + ''; + }; + + listenAddress = mkOption { + type = types.str; + default = "::"; + example = "0.0.0.0"; + description = '' + The address where snapclients can connect. + ''; + }; + + port = mkOption { + type = types.port; + default = 1704; + description = '' + The port that snapclients can connect to. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = true; + description = '' + Whether to automatically open the specified ports in the firewall. + ''; + }; + + inherit sampleFormat; + inherit codec; + + streamBuffer = mkOption { + type = with types; nullOr int; + default = null; + description = '' + Stream read (input) buffer in ms. + ''; + example = 20; + }; + + buffer = mkOption { + type = with types; nullOr int; + default = null; + description = '' + Network buffer in ms. + ''; + example = 1000; + }; + + sendToMuted = mkOption { + type = types.bool; + default = false; + description = '' + Send audio to muted clients. + ''; + }; + + tcp.enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable the JSON-RPC via TCP. + ''; + }; + + tcp.listenAddress = mkOption { + type = types.str; + default = "::"; + example = "0.0.0.0"; + description = '' + The address where the TCP JSON-RPC listens on. + ''; + }; + + tcp.port = mkOption { + type = types.port; + default = 1705; + description = '' + The port where the TCP JSON-RPC listens on. + ''; + }; + + http.enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable the JSON-RPC via HTTP. + ''; + }; + + http.listenAddress = mkOption { + type = types.str; + default = "::"; + example = "0.0.0.0"; + description = '' + The address where the HTTP JSON-RPC listens on. + ''; + }; + + http.port = mkOption { + type = types.port; + default = 1780; + description = '' + The port where the HTTP JSON-RPC listens on. + ''; + }; + + http.docRoot = mkOption { + type = with types; nullOr path; + default = null; + description = '' + Path to serve from the HTTP servers root. + ''; + }; + + streams = mkOption { + type = with types; attrsOf (submodule { + options = { + location = mkOption { + type = types.oneOf [ types.path types.str ]; + description = '' + For type <literal>pipe</literal> or <literal>file</literal>, the path to the pipe or file. + For type <literal>librespot</literal>, <literal>airplay</literal> or <literal>process</literal>, the path to the corresponding binary. + For type <literal>tcp</literal>, the <literal>host:port</literal> address to connect to or listen on. + For type <literal>meta</literal>, a list of stream names in the form <literal>/one/two/...</literal>. Don't forget the leading slash. + For type <literal>alsa</literal>, use an empty string. + ''; + example = literalExpression '' + "/path/to/pipe" + "/path/to/librespot" + "192.168.1.2:4444" + "/MyTCP/Spotify/MyPipe" + ''; + }; + type = mkOption { + type = types.enum [ "pipe" "librespot" "airplay" "file" "process" "tcp" "alsa" "spotify" "meta" ]; + default = "pipe"; + description = '' + The type of input stream. + ''; + }; + query = mkOption { + type = attrsOf str; + default = {}; + description = '' + Key-value pairs that convey additional parameters about a stream. + ''; + example = literalExpression '' + # for type == "pipe": + { + mode = "create"; + }; + # for type == "process": + { + params = "--param1 --param2"; + logStderr = "true"; + }; + # for type == "tcp": + { + mode = "client"; + } + # for type == "alsa": + { + device = "hw:0,0"; + } + ''; + }; + inherit sampleFormat; + inherit codec; + }; + }); + default = { default = {}; }; + description = '' + The definition for an input source. + ''; + example = literalExpression '' + { + mpd = { + type = "pipe"; + location = "/run/snapserver/mpd"; + sampleFormat = "48000:16:2"; + codec = "pcm"; + }; + }; + ''; + }; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + + # https://github.com/badaix/snapcast/blob/98ac8b2fb7305084376607b59173ce4097c620d8/server/streamreader/stream_manager.cpp#L85 + warnings = filter (w: w != "") (mapAttrsToList (k: v: if v.type == "spotify" then '' + services.snapserver.streams.${k}.type = "spotify" is deprecated, use services.snapserver.streams.${k}.type = "librespot" instead. + '' else "") cfg.streams); + + systemd.services.snapserver = { + after = [ "network.target" ]; + description = "Snapserver"; + wantedBy = [ "multi-user.target" ]; + before = [ "mpd.service" "mopidy.service" ]; + + serviceConfig = { + DynamicUser = true; + ExecStart = "${pkgs.snapcast}/bin/snapserver --daemon ${optionString}"; + Type = "forking"; + LimitRTPRIO = 50; + LimitRTTIME = "infinity"; + NoNewPrivileges = true; + PIDFile = "/run/${name}/pid"; + ProtectKernelTunables = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX AF_NETLINK"; + RestrictNamespaces = true; + RuntimeDirectory = name; + StateDirectory = name; + }; + }; + + networking.firewall.allowedTCPPorts = + optionals cfg.openFirewall [ cfg.port ] + ++ optional cfg.tcp.enable cfg.tcp.port + ++ optional cfg.http.enable cfg.http.port; + }; + + meta = { + maintainers = with maintainers; [ tobim ]; + }; + +} diff --git a/nixos/modules/services/audio/spotifyd.nix b/nixos/modules/services/audio/spotifyd.nix new file mode 100644 index 00000000000..22848ed9800 --- /dev/null +++ b/nixos/modules/services/audio/spotifyd.nix @@ -0,0 +1,68 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.spotifyd; + toml = pkgs.formats.toml {}; + warnConfig = + if cfg.config != "" + then lib.trace "Using the stringly typed .config attribute is discouraged. Use the TOML typed .settings attribute instead." + else id; + spotifydConf = + if cfg.settings != {} + then toml.generate "spotify.conf" cfg.settings + else warnConfig (pkgs.writeText "spotifyd.conf" cfg.config); +in +{ + options = { + services.spotifyd = { + enable = mkEnableOption "spotifyd, a Spotify playing daemon"; + + config = mkOption { + default = ""; + type = types.lines; + description = '' + (Deprecated) Configuration for Spotifyd. For syntax and directives, see + <link xlink:href="https://github.com/Spotifyd/spotifyd#Configuration"/>. + ''; + }; + + settings = mkOption { + default = {}; + type = toml.type; + example = { global.bitrate = 320; }; + description = '' + Configuration for Spotifyd. For syntax and directives, see + <link xlink:href="https://github.com/Spotifyd/spotifyd#Configuration"/>. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.config == "" || cfg.settings == {}; + message = "At most one of the .config attribute and the .settings attribute may be set"; + } + ]; + + systemd.services.spotifyd = { + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "sound.target" ]; + description = "spotifyd, a Spotify playing daemon"; + environment.SHELL = "/bin/sh"; + serviceConfig = { + ExecStart = "${pkgs.spotifyd}/bin/spotifyd --no-daemon --cache-path /var/cache/spotifyd --config-path ${spotifydConf}"; + Restart = "always"; + RestartSec = 12; + DynamicUser = true; + CacheDirectory = "spotifyd"; + SupplementaryGroups = ["audio"]; + }; + }; + }; + + meta.maintainers = [ maintainers.anderslundstedt ]; +} diff --git a/nixos/modules/services/audio/squeezelite.nix b/nixos/modules/services/audio/squeezelite.nix new file mode 100644 index 00000000000..36295e21c60 --- /dev/null +++ b/nixos/modules/services/audio/squeezelite.nix @@ -0,0 +1,46 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkEnableOption mkIf mkOption optionalString types; + + dataDir = "/var/lib/squeezelite"; + cfg = config.services.squeezelite; + pkg = if cfg.pulseAudio then pkgs.squeezelite-pulse else pkgs.squeezelite; + bin = "${pkg}/bin/${pkg.pname}"; + +in +{ + + ###### interface + + options.services.squeezelite = { + enable = mkEnableOption "Squeezelite, a software Squeezebox emulator"; + + pulseAudio = mkEnableOption "pulseaudio support"; + + extraArguments = mkOption { + default = ""; + type = types.str; + description = '' + Additional command line arguments to pass to Squeezelite. + ''; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + systemd.services.squeezelite = { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "sound.target" ]; + description = "Software Squeezebox emulator"; + serviceConfig = { + DynamicUser = true; + ExecStart = "${bin} -N ${dataDir}/player-name ${cfg.extraArguments}"; + StateDirectory = builtins.baseNameOf dataDir; + SupplementaryGroups = "audio"; + }; + }; + }; +} diff --git a/nixos/modules/services/audio/ympd.nix b/nixos/modules/services/audio/ympd.nix new file mode 100644 index 00000000000..84b72d14251 --- /dev/null +++ b/nixos/modules/services/audio/ympd.nix @@ -0,0 +1,57 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.ympd; +in { + + ###### interface + + options = { + + services.ympd = { + + enable = mkEnableOption "ympd, the MPD Web GUI"; + + webPort = mkOption { + type = types.either types.str types.port; # string for backwards compat + default = "8080"; + description = "The port where ympd's web interface will be available."; + example = "ssl://8080:/path/to/ssl-private-key.pem"; + }; + + mpd = { + host = mkOption { + type = types.str; + default = "localhost"; + description = "The host where MPD is listening."; + }; + + port = mkOption { + type = types.int; + default = config.services.mpd.network.port; + defaultText = literalExpression "config.services.mpd.network.port"; + description = "The port where MPD is listening."; + example = 6600; + }; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + systemd.services.ympd = { + description = "Standalone MPD Web GUI written in C"; + wantedBy = [ "multi-user.target" ]; + serviceConfig.ExecStart = "${pkgs.ympd}/bin/ympd --host ${cfg.mpd.host} --port ${toString cfg.mpd.port} --webport ${toString cfg.webPort} --user nobody"; + }; + + }; + +} |