Files
music-agregator/nix/music-agregator.nix
T
Alexander 908c37f73f Add NixOS module for full agregators stack with VM test
Module manages PostgreSQL (both databases + schema init), qBittorrent
(VPN-confined via VPN-Confinement), Jackett, metadata-agregator,
musicfs, and the main orchestrator. All services are independently
enableable with auto-wired inter-service configuration.

Includes NixOS VM test validating PostgreSQL setup, schema
initialization, service startup, and directory creation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 12:39:04 +02:00

276 lines
8.2 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.agregators;
maCfg = cfg.music-agregator;
pgCfg = cfg.postgres;
qbtCfg = cfg.qbittorrent;
jackettCfg = cfg.jackett;
metaCfg = cfg.metadata-agregator;
mfsCfg = cfg.musicfs;
vpnCfg = cfg.vpn;
yaml = pkgs.formats.yaml { };
vpnEnabled = vpnCfg.enable && qbtCfg.vpn.enable or false;
namespaceAddr = "192.168.15.1";
musicfsEnabled = mfsCfg.enable;
dbPasswordPlaceholder = "@MUSIC_DB_PASSWORD@";
qbtPasswordPlaceholder = "@QBT_PASSWORD@";
# Auto-wire qBittorrent address based on VPN
qbtAddr =
if maCfg.qbittorrentUrl != null then
maCfg.qbittorrentUrl
else if vpnEnabled then
"http://${namespaceAddr}:${toString qbtCfg.webuiPort}"
else
"http://127.0.0.1:${toString qbtCfg.webuiPort}";
dbHost = if pgCfg.host == "/run/postgresql" then "localhost" else pgCfg.host;
configFile = yaml.generate "music-agregator.yaml" {
app = {
host = maCfg.host;
port = toString maCfg.port;
};
database = {
url = "postgresql://${pgCfg.musicDatabase.user}:${dbPasswordPlaceholder}@${dbHost}:${toString pgCfg.port}/${pgCfg.musicDatabase.name}?sslmode=disable";
};
indexer = {
url = maCfg.jackettUrl;
type = "jackett";
api_key = "@JACKETT_API_KEY@";
}
// lib.optionalAttrs (maCfg.indexerCache.enable) {
cache = {
enabled = true;
refresh_interval = maCfg.indexerCache.refreshInterval;
ttl = maCfg.indexerCache.ttl;
};
};
torrent = {
client_type = "qbittorrent";
url = qbtAddr;
username = qbtCfg.username;
password = qbtPasswordPlaceholder;
# No container_name — native paths, no Docker
};
metadata = {
endpoint = maCfg.metadataEndpoint;
};
musicfs = {
enabled = musicfsEnabled;
endpoint = maCfg.musicfsEndpoint;
origin_id = lib.optionalString musicfsEnabled mfsCfg.originId;
origin_root = lib.optionalString musicfsEnabled (toString mfsCfg.originPath);
timeout_seconds = maCfg.musicfsTimeoutSeconds;
};
};
in
{
options.services.agregators.music-agregator = {
enable = lib.mkEnableOption "music-agregator orchestrator service";
package = lib.mkOption {
type = lib.types.package;
description = "The music-agregator package.";
};
host = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "gRPC server bind address.";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "gRPC server port.";
};
# --- Auto-wired endpoints (override for external services) ---
metadataEndpoint = lib.mkOption {
type = lib.types.str;
default = "localhost:${toString metaCfg.port}";
defaultText = lib.literalExpression ''"localhost:''${toString cfg.metadata-agregator.port}"'';
description = "metadata-agregator gRPC endpoint.";
};
musicfsEndpoint = lib.mkOption {
type = lib.types.str;
default = "localhost:${toString mfsCfg.port}";
defaultText = lib.literalExpression ''"localhost:''${toString cfg.musicfs.port}"'';
description = "musicfs gRPC endpoint.";
};
musicfsTimeoutSeconds = lib.mkOption {
type = lib.types.int;
default = 300;
description = "Timeout in seconds for musicfs operations.";
};
jackettUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:${toString jackettCfg.port}";
defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString cfg.jackett.port}"'';
description = "Jackett HTTP URL.";
};
jackettApiKeyFile = lib.mkOption {
type = lib.types.path;
description = "File containing the Jackett API key.";
};
qbittorrentUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
qBittorrent WebUI URL override.
If null, auto-detected from VPN and port config.
'';
};
qbittorrentPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = qbtCfg.passwordFile;
defaultText = lib.literalExpression "cfg.qbittorrent.passwordFile";
description = "File containing qBittorrent WebUI password.";
};
indexerCache = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Jackett indexer cache with background refresh.";
};
refreshInterval = lib.mkOption {
type = lib.types.str;
default = "30m";
description = "Background refresh interval.";
};
ttl = lib.mkOption {
type = lib.types.str;
default = "1h";
description = "Cache entry time-to-live.";
};
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the gRPC port in the firewall.";
};
};
config = lib.mkIf (cfg.enable && maCfg.enable) {
assertions = [
{
assertion = metaCfg.enable;
message = "services.agregators.music-agregator requires services.agregators.metadata-agregator.enable";
}
{
assertion = jackettCfg.enable || maCfg.jackettUrl != "http://127.0.0.1:${toString jackettCfg.port}";
message = "services.agregators.music-agregator requires either jackett enabled or a custom jackettUrl";
}
{
assertion = qbtCfg.enable || maCfg.qbittorrentUrl != null;
message = "services.agregators.music-agregator requires either qbittorrent enabled or a custom qbittorrentUrl";
}
];
systemd.services.music-agregator = {
description = "music-agregator - Music automation orchestrator";
after = [
"network.target"
"agregators-schema-music.service"
"metadata-agregator.service"
]
++ lib.optional jackettCfg.enable "jackett.service"
++ lib.optional qbtCfg.enable "qbittorrent.service"
++ lib.optional musicfsEnabled "musicfs.service";
requires = [
"agregators-schema-music.service"
"metadata-agregator.service"
];
wants =
lib.optional jackettCfg.enable "jackett.service"
++ lib.optional qbtCfg.enable "qbittorrent.service"
++ lib.optional musicfsEnabled "musicfs.service";
wantedBy = [ "multi-user.target" ];
preStart = ''
cp --no-preserve=mode ${configFile} /run/music-agregator/config.yaml
# Inject database password
${
if pgCfg.musicDatabase.passwordFile != null then
''
${pkgs.replace-secret}/bin/replace-secret \
'${dbPasswordPlaceholder}' \
'${pgCfg.musicDatabase.passwordFile}' \
/run/music-agregator/config.yaml
''
else
''
${pkgs.gnused}/bin/sed -i "s/${dbPasswordPlaceholder}//" /run/music-agregator/config.yaml
''
}
# Inject Jackett API key
${pkgs.replace-secret}/bin/replace-secret \
'@JACKETT_API_KEY@' \
'${maCfg.jackettApiKeyFile}' \
/run/music-agregator/config.yaml
# Inject qBittorrent password
${
if maCfg.qbittorrentPasswordFile != null then
''
${pkgs.replace-secret}/bin/replace-secret \
'${qbtPasswordPlaceholder}' \
'${maCfg.qbittorrentPasswordFile}' \
/run/music-agregator/config.yaml
''
else
''
${pkgs.gnused}/bin/sed -i "s/${qbtPasswordPlaceholder}//" /run/music-agregator/config.yaml
''
}
'';
serviceConfig = {
Type = "simple";
ExecStart = "${lib.getExe maCfg.package} -config /run/music-agregator/config.yaml";
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = "music-agregator";
RuntimeDirectoryMode = "0750";
Restart = "on-failure";
RestartSec = 5;
ProtectSystem = "strict";
PrivateTmp = true;
NoNewPrivileges = true;
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf maCfg.openFirewall [ maCfg.port ];
};
}