nixpkgs/nixos/modules/services/audio/mpd.nix
2026-01-22 18:37:56 -03:00

458 lines
13 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
name = "mpd";
uid = config.ids.uids.mpd;
gid = config.ids.gids.mpd;
cfg = config.services.mpd;
mkKeyValue =
a:
lib.mapAttrsToList (
k: v:
k
+ " "
+ (
if builtins.isBool v then
# Mainly for https://mpd.readthedocs.io/en/stable/user.html#zeroconf
"\"" + (lib.boolToYesNo v) + "\""
else
"\"" + (toString v) + "\""
)
) a;
nonBlockSettings = lib.filterAttrs (n: v: !(builtins.isAttrs v || builtins.isList v)) cfg.settings;
pureBlockSettings = removeAttrs cfg.settings (builtins.attrNames nonBlockSettings);
blocks =
pureBlockSettings
// lib.optionalAttrs cfg.fluidsynth {
decoder = (pureBlockSettings.decoder or [ ]) ++ [
{
plugin = "fluidsynth";
soundfont = "${pkgs.soundfont-fluid}/share/soundfonts/FluidR3_GM2-2.sf2";
}
];
};
processSingleBlock =
n: v:
[
(n + " {")
]
# Add indentation, for better readability
++ (map (l: " " + l) (mkKeyValue v))
++ [ "}" ];
mpdConf = pkgs.writeTextFile {
name = "mpd.conf";
text = ''
# 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.
''
+ lib.concatStringsSep "\n" (
mkKeyValue (
{
state_file = "${cfg.dataDir}/state";
sticker_file = "${cfg.dataDir}/sticker.sql";
}
// nonBlockSettings
)
++ lib.flatten (
lib.mapAttrsToList (
n: v: if builtins.isList v then (map (b: processSingleBlock n b) v) else (processSingleBlock n v)
) blocks
)
++ lib.imap0 (
i: a: "password \"{{password-${toString i}}}@${lib.concatStringsSep "," a.permissions}\""
) cfg.credentials
);
derivationArgs = {
expectScript = ''
spawn ${lib.getExe pkgs.buildPackages.mpd} --no-daemon "$env(out)"
expect {
"exception: Error in \"$env(out)\"" {
puts "Config file invalid\n"
exit 1
}
"exception:" {
exit 0
}
}
'';
passAsFile = [
"expectScript"
];
};
checkPhase = ''
${lib.getExe pkgs.buildPackages.expect} -f "$expectScriptPath"
'';
};
in
{
###### interface
options = {
services.mpd = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable MPD, the music player daemon.
'';
};
startWhenNeeded = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If set, {command}`mpd` is socket-activated; that
is, instead of having it permanently running as a daemon,
systemd will start it on the first incoming connection.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = name;
description = "User account under which MPD runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = name;
description = "Group account under which MPD runs.";
};
dataDir = lib.mkOption {
type = lib.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.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open ports in the firewall for mpd.";
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType =
let
inherit (lib.types)
oneOf
attrsOf
listOf
str
int
bool
path
;
atomType = oneOf [
str
int
bool
path
];
in
attrsOf (oneOf [
atomType
(listOf (attrsOf atomType))
]);
options = {
music_directory = lib.mkOption {
type = with lib.types; either path (strMatching "([a-z]+)://.+");
default = "${cfg.dataDir}/music";
defaultText = lib.literalExpression ''"''${dataDir}/music"'';
description = ''
The directory or URI 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.
'';
};
playlist_directory = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/playlists";
defaultText = lib.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.
'';
};
bind_to_address = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
example = "any";
description = ''
The address for the daemon to listen on.
Use `any` to listen on all addresses.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 6600;
description = ''
This setting is the TCP port that is desired for the daemon to get assigned
to.
'';
};
db_file = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/tag_cache";
defaultText = lib.literalExpression ''"''${dataDir}/tag_cache"'';
description = ''
The path to MPD's database.
'';
};
};
};
default = { };
description = ''
Configuration for MPD. MPD supports key-value like blocks for settings
like `audio_output` and `neighbor`. Some of these blocks can be
specified multiple times, so the following configuration:
```txt
audio_output {
device "iec958:CARD=Intel,DEV=0"
mixer_control "PCM"
name "My specific ALSA output"
type "alsa"
}
audio_output {
mixer_type "null"
name "ALSA Null"
type "alsa"
}
audio_output {
name "The Pulse"
type "pulse"
}
```
Can be inserted with:
```nix
audio_output = [
{
type = "alsa";
name = "My specific ALSA output";
device = "iec958:CARD=Intel,DEV=0";
mixer_control = "PCM";
}
{
type = "alsa";
name = "ALSA Null";
mixer_type = "null";
}
{
type = "pulse";
name = "The Pulse";
}
];
```
'';
};
credentials = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
passwordFile = lib.mkOption {
type = lib.types.path;
description = ''
Path to file containing the password.
'';
};
permissions =
let
perms = [
"read"
"add"
"control"
"admin"
];
in
lib.mkOption {
type = lib.types.listOf (lib.types.enum perms);
default = [ "read" ];
description = ''
List of permissions that are granted with this password.
Permissions can be "${lib.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 = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If set, add fluidsynth soundfont `decoder` block.
'';
};
};
};
###### implementation
imports = [
(lib.mkRenamedOptionModule
[ "services" "mpd" "musicDirectory" ]
[ "services" "mpd" "settings" "music_directory" ]
)
(lib.mkRenamedOptionModule
[ "services" "mpd" "playlistDirectory" ]
[ "services" "mpd" "settings" "playlist_directory" ]
)
(lib.mkRenamedOptionModule [ "services" "mpd" "dbFile" ] [ "services" "mpd" "settings" "db_file" ])
(lib.mkRenamedOptionModule
[ "services" "mpd" "network" "listenAddress" ]
[ "services" "mpd" "settings" "bind_to_address" ]
)
(lib.mkRenamedOptionModule
[ "services" "mpd" "network" "port" ]
[ "services" "mpd" "settings" "port" ]
)
(lib.mkRemovedOptionModule
[
"services"
"mpd"
"extraConfig"
]
"services.mpd.extraConfig was replaced by the declarative services.mpd.settings option, per RFC42."
)
];
config = lib.mkIf cfg.enable {
warnings =
lib.optional
(
!(
(builtins.elem cfg.settings.bind_to_address [
"localhost"
"127.0.0.1"
])
|| (lib.hasPrefix "/" cfg.settings.bind_to_address)
)
&& !cfg.openFirewall
)
"Using '${cfg.settings.bind_to_address}' as services.mpd.settings.bind_to_address without enabling services.mpd.openFirewall, might prevent you from accessing MPD from other clients.";
# install mpd units
systemd.packages = [ pkgs.mpd ];
systemd.sockets.mpd = lib.mkIf cfg.startWhenNeeded {
wantedBy = [ "sockets.target" ];
listenStreams = [
"" # Note: this is needed to override the upstream unit
(
if pkgs.lib.hasPrefix "/" cfg.settings.bind_to_address then
cfg.settings.bind_to_address
else
"${
lib.optionalString (cfg.settings.bind_to_address != "any") "${cfg.settings.bind_to_address}:"
}${toString cfg.settings.port}"
)
];
};
systemd.services.mpd = {
wantedBy = lib.optional (!cfg.startWhenNeeded) "multi-user.target";
preStart = ''
set -euo pipefail
install -m 600 ${mpdConf} /run/mpd/mpd.conf
''
+ lib.optionalString (cfg.credentials != [ ]) (
lib.concatStringsSep "\n" (
lib.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 =
[ ]
++ lib.optionals (cfg.dataDir == "/var/lib/${name}") [ name ]
++ lib.optionals (cfg.settings.playlist_directory == "/var/lib/${name}/playlists") [
name
"${name}/playlists"
]
++ lib.optionals (cfg.settings.music_directory == "/var/lib/${name}/music") [
name
"${name}/music"
];
};
};
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ cfg.settings.port ];
users.users = lib.optionalAttrs (cfg.user == name) {
${name} = {
inherit uid;
group = cfg.group;
extraGroups = [ "audio" ];
description = "Music Player Daemon user";
home = "${cfg.dataDir}";
};
};
users.groups = lib.optionalAttrs (cfg.group == name) {
${name}.gid = gid;
};
};
}