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>
This commit is contained in:
Generated
+17
-1
@@ -125,7 +125,23 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"git-hooks": "git-hooks",
|
"git-hooks": "git-hooks",
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"vpnconfinement": "vpnconfinement"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vpnconfinement": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778182451,
|
||||||
|
"narHash": "sha256-Bz3n2THDGf90Z9gMqhH/J492prYH8B6RFRlxv/fPBwc=",
|
||||||
|
"owner": "Maroka-chan",
|
||||||
|
"repo": "VPN-Confinement",
|
||||||
|
"rev": "cf5bfc4c3559f2e783698b1aa23c165072039a7d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Maroka-chan",
|
||||||
|
"repo": "VPN-Confinement",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
nixpkgs.url = "github:nixos/nixpkgs";
|
nixpkgs.url = "github:nixos/nixpkgs";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
git-hooks.url = "github:cachix/git-hooks.nix";
|
git-hooks.url = "github:cachix/git-hooks.nix";
|
||||||
|
vpnconfinement.url = "github:Maroka-chan/VPN-Confinement";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
@@ -18,6 +19,18 @@
|
|||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
flake = {
|
||||||
|
nixosModules = {
|
||||||
|
agregators = {
|
||||||
|
imports = [
|
||||||
|
./nix/default.nix
|
||||||
|
inputs.vpnconfinement.nixosModules.default
|
||||||
|
];
|
||||||
|
};
|
||||||
|
default = self.nixosModules.agregators;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{
|
{
|
||||||
system,
|
system,
|
||||||
@@ -49,6 +62,9 @@
|
|||||||
|
|
||||||
checks = {
|
checks = {
|
||||||
inherit pre-commit-check;
|
inherit pre-commit-check;
|
||||||
|
simple-test = pkgs.callPackage ./tests/simple-test.nix {
|
||||||
|
nixosModules = self.nixosModules;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./postgres.nix
|
||||||
|
./vpn.nix
|
||||||
|
./qbittorrent.nix
|
||||||
|
./jackett.nix
|
||||||
|
./metadata-agregator.nix
|
||||||
|
./musicfs.nix
|
||||||
|
./music-agregator.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
options.services.agregators = {
|
||||||
|
enable = lib.mkEnableOption "agregators music automation pipeline";
|
||||||
|
|
||||||
|
mediaDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/data/music";
|
||||||
|
description = ''
|
||||||
|
Root directory for music files.
|
||||||
|
qBittorrent downloads here, musicfs reads from here.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
stateDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/var/lib/agregators";
|
||||||
|
description = "Root state directory. Per-service subdirectories are created automatically.";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "agregators";
|
||||||
|
description = "Shared system user for all agregators services.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "agregators";
|
||||||
|
description = "Shared system group for all agregators services.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "agregators") {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.stateDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf (cfg.group == "agregators") { };
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.mediaDir}' 0775 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${cfg.stateDir}/postgres' 0770 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
jackettCfg = cfg.jackett;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.agregators.jackett = {
|
||||||
|
enable = lib.mkEnableOption "Jackett torrent indexer aggregator";
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 9117;
|
||||||
|
description = "Jackett HTTP port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
stateDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${cfg.stateDir}/jackett";
|
||||||
|
defaultText = lib.literalExpression ''"''${cfg.stateDir}/jackett"'';
|
||||||
|
description = "Jackett data directory.";
|
||||||
|
};
|
||||||
|
|
||||||
|
apiKeyFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
File containing the Jackett API key.
|
||||||
|
If null, music-agregator must be configured with the API key manually.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "jackett" { };
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Open the Jackett port in the firewall.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (cfg.enable && jackettCfg.enable) {
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${jackettCfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.jackett = {
|
||||||
|
description = "Jackett torrent indexer aggregator";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
ExecStart = lib.concatStringsSep " " [
|
||||||
|
"${lib.getExe jackettCfg.package}"
|
||||||
|
"--DataFolder=${jackettCfg.stateDir}"
|
||||||
|
"--Port=${toString jackettCfg.port}"
|
||||||
|
"--NoUpdates"
|
||||||
|
];
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf jackettCfg.openFirewall [ jackettCfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
metaCfg = cfg.metadata-agregator;
|
||||||
|
pgCfg = cfg.postgres;
|
||||||
|
db = pgCfg.metadataDatabase;
|
||||||
|
yaml = pkgs.formats.yaml { };
|
||||||
|
|
||||||
|
dbPasswordPlaceholder = "@META_DB_PASSWORD@";
|
||||||
|
|
||||||
|
configFile = yaml.generate "metadata-agregator.yaml" {
|
||||||
|
server.port = metaCfg.port;
|
||||||
|
database = {
|
||||||
|
host = if pgCfg.host == "/run/postgresql" then "localhost" else pgCfg.host;
|
||||||
|
port = pgCfg.port;
|
||||||
|
name = db.name;
|
||||||
|
user = db.user;
|
||||||
|
password = dbPasswordPlaceholder;
|
||||||
|
sslmode = "disable";
|
||||||
|
};
|
||||||
|
logging = {
|
||||||
|
level = metaCfg.logging.level;
|
||||||
|
format = metaCfg.logging.format;
|
||||||
|
};
|
||||||
|
metrics = {
|
||||||
|
enabled = metaCfg.metrics.enable;
|
||||||
|
port = metaCfg.metrics.port;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.agregators.metadata-agregator = {
|
||||||
|
enable = lib.mkEnableOption "metadata-agregator music metadata gRPC service";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
description = "The metadata-agregator package.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 50051;
|
||||||
|
description = "gRPC listen port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
logging = {
|
||||||
|
level = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "info";
|
||||||
|
description = "Log level.";
|
||||||
|
};
|
||||||
|
|
||||||
|
format = lib.mkOption {
|
||||||
|
type = lib.types.enum [
|
||||||
|
"json"
|
||||||
|
"console"
|
||||||
|
];
|
||||||
|
default = "json";
|
||||||
|
description = "Log output format.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable Prometheus metrics endpoint.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 9091;
|
||||||
|
description = "Prometheus metrics HTTP port.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Open the gRPC port in the firewall.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (cfg.enable && metaCfg.enable) {
|
||||||
|
systemd.services.metadata-agregator = {
|
||||||
|
description = "metadata-agregator - Music metadata gRPC service";
|
||||||
|
after = [
|
||||||
|
"network.target"
|
||||||
|
"agregators-schema-metadata.service"
|
||||||
|
];
|
||||||
|
requires = [ "agregators-schema-metadata.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
preStart = ''
|
||||||
|
cp --no-preserve=mode ${configFile} /run/metadata-agregator/config.yaml
|
||||||
|
|
||||||
|
${
|
||||||
|
if db.passwordFile != null then
|
||||||
|
''
|
||||||
|
${pkgs.replace-secret}/bin/replace-secret \
|
||||||
|
'${dbPasswordPlaceholder}' \
|
||||||
|
'${db.passwordFile}' \
|
||||||
|
/run/metadata-agregator/config.yaml
|
||||||
|
''
|
||||||
|
else
|
||||||
|
''
|
||||||
|
${pkgs.gnused}/bin/sed -i "s/${dbPasswordPlaceholder}//" /run/metadata-agregator/config.yaml
|
||||||
|
''
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
ExecStart = "${lib.getExe metaCfg.package} -config /run/metadata-agregator/config.yaml";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
RuntimeDirectory = "metadata-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 metaCfg.openFirewall (
|
||||||
|
[ metaCfg.port ] ++ lib.optional metaCfg.metrics.enable metaCfg.metrics.port
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
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 ];
|
||||||
|
};
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
mfsCfg = cfg.musicfs;
|
||||||
|
toml = pkgs.formats.toml { };
|
||||||
|
|
||||||
|
configFile = toml.generate "musicfs.toml" (
|
||||||
|
{
|
||||||
|
mount_point = mfsCfg.mountPoint;
|
||||||
|
cache_dir = mfsCfg.cacheDir;
|
||||||
|
|
||||||
|
origins = [
|
||||||
|
{
|
||||||
|
id = mfsCfg.originId;
|
||||||
|
origin_type = "local";
|
||||||
|
priority = 1;
|
||||||
|
enabled = true;
|
||||||
|
path = mfsCfg.originPath;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (mfsCfg.cache != { }) { cache = mfsCfg.cache; }
|
||||||
|
// lib.optionalAttrs (mfsCfg.logging != { }) { logging = mfsCfg.logging; }
|
||||||
|
// mfsCfg.extraConfig
|
||||||
|
);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.agregators.musicfs = {
|
||||||
|
enable = lib.mkEnableOption "musicfs virtual FUSE filesystem";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
description = "The musicfs package.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 50052;
|
||||||
|
description = "gRPC control API port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
mountPoint = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/mnt/music";
|
||||||
|
description = "Where to mount the virtual filesystem.";
|
||||||
|
};
|
||||||
|
|
||||||
|
cacheDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${cfg.stateDir}/musicfs/cache";
|
||||||
|
defaultText = lib.literalExpression ''"''${cfg.stateDir}/musicfs/cache"'';
|
||||||
|
description = "Directory for cache data (CAS chunks, metadata, search index).";
|
||||||
|
};
|
||||||
|
|
||||||
|
originPath = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${cfg.mediaDir}/downloads";
|
||||||
|
defaultText = lib.literalExpression ''"''${cfg.mediaDir}/downloads"'';
|
||||||
|
description = "Source directory for music files (typically qBittorrent download dir).";
|
||||||
|
};
|
||||||
|
|
||||||
|
originId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "local-storage";
|
||||||
|
description = "Origin identifier used by musicfs and music-agregator.";
|
||||||
|
};
|
||||||
|
|
||||||
|
cache = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf toml.type;
|
||||||
|
default = { };
|
||||||
|
description = "Cache settings passed to [cache] in config.";
|
||||||
|
};
|
||||||
|
|
||||||
|
logging = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf toml.type;
|
||||||
|
default = { };
|
||||||
|
description = "Logging settings passed to [logging] in config.";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraConfig = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf toml.type;
|
||||||
|
default = { };
|
||||||
|
description = "Additional top-level config keys.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Open the gRPC port in the firewall.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (cfg.enable && mfsCfg.enable) {
|
||||||
|
programs.fuse.userAllowOther = true;
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${mfsCfg.mountPoint}' 0755 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${mfsCfg.cacheDir}' 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.musicfs = {
|
||||||
|
description = "MusicFS - Virtual FUSE Filesystem for Music";
|
||||||
|
after = [
|
||||||
|
"network.target"
|
||||||
|
"local-fs.target"
|
||||||
|
];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "notify";
|
||||||
|
ExecStart = "${lib.getExe mfsCfg.package} mount --config ${configFile} --grpc-port ${toString mfsCfg.port}";
|
||||||
|
ExecStopPost = "${pkgs.fuse3}/bin/fusermount3 -u ${mfsCfg.mountPoint}";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ReadWritePaths = [
|
||||||
|
mfsCfg.mountPoint
|
||||||
|
mfsCfg.cacheDir
|
||||||
|
mfsCfg.originPath
|
||||||
|
];
|
||||||
|
PrivateTmp = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
DeviceAllow = [ "/dev/fuse rw" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf mfsCfg.openFirewall [ mfsCfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
pgCfg = cfg.postgres;
|
||||||
|
|
||||||
|
# For local: run as the db user via peer auth (service runs as that user)
|
||||||
|
# For remote: run as anyone, use password auth via PGPASSWORD
|
||||||
|
mkPsql =
|
||||||
|
db:
|
||||||
|
lib.concatStringsSep " " (
|
||||||
|
[
|
||||||
|
"${pkgs.postgresql}/bin/psql"
|
||||||
|
]
|
||||||
|
++ lib.optionals (!isLocal) [
|
||||||
|
"-h ${lib.escapeShellArg pgCfg.host}"
|
||||||
|
"-p ${toString pgCfg.port}"
|
||||||
|
"-U ${lib.escapeShellArg db.user}"
|
||||||
|
]
|
||||||
|
++ [
|
||||||
|
"-d ${lib.escapeShellArg db.name}"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
mkPasswordExport =
|
||||||
|
db:
|
||||||
|
if db.passwordFile != null then
|
||||||
|
''export PGPASSWORD="$(cat ${lib.escapeShellArg db.passwordFile})"''
|
||||||
|
else
|
||||||
|
"";
|
||||||
|
|
||||||
|
isLocal = pgCfg.host == "/run/postgresql" || pgCfg.host == "localhost" || pgCfg.host == "127.0.0.1";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.agregators.postgres = {
|
||||||
|
host = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/run/postgresql";
|
||||||
|
description = ''
|
||||||
|
PostgreSQL host. Use a path for unix socket (e.g. /run/postgresql)
|
||||||
|
or a hostname for TCP.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 5432;
|
||||||
|
description = "PostgreSQL port (ignored for unix socket connections).";
|
||||||
|
};
|
||||||
|
|
||||||
|
musicDatabase = {
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "music_agregator";
|
||||||
|
description = "Database name for music-agregator.";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "music_agregator";
|
||||||
|
description = "Database user for music-agregator.";
|
||||||
|
};
|
||||||
|
|
||||||
|
passwordFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Password file for music-agregator database. Null for peer auth.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
metadataDatabase = {
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "metadata_agregator";
|
||||||
|
description = "Database name for metadata-agregator.";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "metadata_agregator";
|
||||||
|
description = "Database user for metadata-agregator.";
|
||||||
|
};
|
||||||
|
|
||||||
|
passwordFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Password file for metadata-agregator database. Null for peer auth.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
settings = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
description = "Extra PostgreSQL settings merged into services.postgresql.settings.";
|
||||||
|
example = {
|
||||||
|
shared_buffers = "256MB";
|
||||||
|
effective_cache_size = "768MB";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Create system users for peer auth (local postgres only)
|
||||||
|
users.users = lib.mkIf isLocal {
|
||||||
|
${pgCfg.musicDatabase.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
};
|
||||||
|
${pgCfg.metadataDatabase.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Enable and configure local PostgreSQL
|
||||||
|
services.postgresql = lib.mkIf isLocal {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
settings = {
|
||||||
|
shared_preload_libraries = "pg_prewarm";
|
||||||
|
}
|
||||||
|
// pgCfg.settings;
|
||||||
|
|
||||||
|
ensureDatabases = [
|
||||||
|
pgCfg.musicDatabase.name
|
||||||
|
pgCfg.metadataDatabase.name
|
||||||
|
];
|
||||||
|
|
||||||
|
ensureUsers = [
|
||||||
|
{
|
||||||
|
name = pgCfg.musicDatabase.user;
|
||||||
|
ensureDBOwnership = true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name = pgCfg.metadataDatabase.user;
|
||||||
|
ensureDBOwnership = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Schema init for metadata_agregator
|
||||||
|
systemd.services.agregators-schema-metadata = {
|
||||||
|
description = "Initialize metadata_agregator database schema";
|
||||||
|
after = [ "network.target" ] ++ lib.optionals isLocal [ "postgresql.service" ];
|
||||||
|
requires = lib.optionals isLocal [ "postgresql.service" ];
|
||||||
|
before = [ "metadata-agregator.service" ];
|
||||||
|
requiredBy = [ "metadata-agregator.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
# Run as the db user for peer auth on local postgres
|
||||||
|
User = if isLocal then pgCfg.metadataDatabase.user else cfg.user;
|
||||||
|
};
|
||||||
|
|
||||||
|
script =
|
||||||
|
let
|
||||||
|
psql = mkPsql pgCfg.metadataDatabase;
|
||||||
|
passwordExport = mkPasswordExport pgCfg.metadataDatabase;
|
||||||
|
in
|
||||||
|
''
|
||||||
|
${passwordExport}
|
||||||
|
MARKER="${cfg.stateDir}/postgres/.schema-metadata-initialized"
|
||||||
|
|
||||||
|
# Wait for database
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if ${psql} -c "SELECT 1" >/dev/null 2>&1; then break; fi
|
||||||
|
echo "Waiting for metadata database... ($i/30)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -f "$MARKER" ]; then
|
||||||
|
echo "metadata_agregator schema already initialized"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying metadata_agregator schema..."
|
||||||
|
${psql} -f ${./sql/metadata/001_schema.sql}
|
||||||
|
|
||||||
|
touch "$MARKER"
|
||||||
|
echo "metadata_agregator schema initialized"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Schema init for music_agregator
|
||||||
|
systemd.services.agregators-schema-music = {
|
||||||
|
description = "Initialize music_agregator database schema";
|
||||||
|
after = [ "network.target" ] ++ lib.optionals isLocal [ "postgresql.service" ];
|
||||||
|
requires = lib.optionals isLocal [ "postgresql.service" ];
|
||||||
|
before = [ "music-agregator.service" ];
|
||||||
|
requiredBy = [ "music-agregator.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
# Run as the db user for peer auth on local postgres
|
||||||
|
User = if isLocal then pgCfg.musicDatabase.user else cfg.user;
|
||||||
|
};
|
||||||
|
|
||||||
|
script =
|
||||||
|
let
|
||||||
|
psql = mkPsql pgCfg.musicDatabase;
|
||||||
|
passwordExport = mkPasswordExport pgCfg.musicDatabase;
|
||||||
|
in
|
||||||
|
''
|
||||||
|
${passwordExport}
|
||||||
|
MARKER="${cfg.stateDir}/postgres/.schema-music-initialized"
|
||||||
|
|
||||||
|
# Wait for database
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if ${psql} -c "SELECT 1" >/dev/null 2>&1; then break; fi
|
||||||
|
echo "Waiting for music database... ($i/30)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -f "$MARKER" ]; then
|
||||||
|
echo "music_agregator schema already initialized"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying music_agregator schema..."
|
||||||
|
${psql} -f ${./sql/music/001_river.sql}
|
||||||
|
${psql} -f ${./sql/music/002_schema.sql}
|
||||||
|
${psql} -f ${./sql/music/003_event_bus.sql}
|
||||||
|
|
||||||
|
touch "$MARKER"
|
||||||
|
echo "music_agregator schema initialized"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
qbtCfg = cfg.qbittorrent;
|
||||||
|
vpnCfg = cfg.vpn;
|
||||||
|
vpnEnabled = vpnCfg.enable && qbtCfg.vpn.enable;
|
||||||
|
|
||||||
|
# Inside VPN namespace, services bind to the namespace address
|
||||||
|
namespaceAddr = "192.168.15.1";
|
||||||
|
listenAddr = if vpnEnabled then namespaceAddr else "0.0.0.0";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.agregators.qbittorrent = {
|
||||||
|
enable = lib.mkEnableOption "qBittorrent torrent client";
|
||||||
|
|
||||||
|
vpn = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Confine qBittorrent to the VPN network namespace.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
webuiPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8080;
|
||||||
|
description = "qBittorrent WebUI port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
peerPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 6881;
|
||||||
|
description = "BitTorrent peer port (opened through VPN).";
|
||||||
|
};
|
||||||
|
|
||||||
|
downloadDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${cfg.mediaDir}/downloads";
|
||||||
|
defaultText = lib.literalExpression ''"''${cfg.mediaDir}/downloads"'';
|
||||||
|
description = "Directory where qBittorrent saves completed downloads.";
|
||||||
|
};
|
||||||
|
|
||||||
|
username = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "admin";
|
||||||
|
description = "qBittorrent WebUI username.";
|
||||||
|
};
|
||||||
|
|
||||||
|
passwordFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing qBittorrent WebUI password.";
|
||||||
|
};
|
||||||
|
|
||||||
|
stateDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${cfg.stateDir}/qbittorrent";
|
||||||
|
defaultText = lib.literalExpression ''"''${cfg.stateDir}/qbittorrent"'';
|
||||||
|
description = "qBittorrent state/config directory.";
|
||||||
|
};
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "qbittorrent-nox" { };
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Open the WebUI port in the firewall.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (cfg.enable && qbtCfg.enable) {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = qbtCfg.vpn.enable -> vpnCfg.enable;
|
||||||
|
message = "services.agregators.qbittorrent.vpn.enable requires services.agregators.vpn.enable";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${qbtCfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${qbtCfg.stateDir}/qBittorrent' 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${qbtCfg.stateDir}/qBittorrent/config' 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${qbtCfg.downloadDir}' 0775 ${cfg.user} ${cfg.group} - -"
|
||||||
|
"d '${qbtCfg.downloadDir}/.incomplete' 0775 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.qbittorrent = {
|
||||||
|
description = "qBittorrent-nox BitTorrent client";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
ExecStart = lib.concatStringsSep " " [
|
||||||
|
"${lib.getExe qbtCfg.package}"
|
||||||
|
"--webui-port=${toString qbtCfg.webuiPort}"
|
||||||
|
"--profile=${qbtCfg.stateDir}"
|
||||||
|
];
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# VPN confinement
|
||||||
|
systemd.services.qbittorrent.vpnConfinement = lib.mkIf vpnEnabled {
|
||||||
|
enable = true;
|
||||||
|
vpnNamespace = vpnCfg.namespace;
|
||||||
|
};
|
||||||
|
|
||||||
|
vpnNamespaces.${vpnCfg.namespace} = lib.mkIf vpnEnabled {
|
||||||
|
portMappings = [
|
||||||
|
{
|
||||||
|
from = qbtCfg.webuiPort;
|
||||||
|
to = qbtCfg.webuiPort;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
openVPNPorts = [
|
||||||
|
{
|
||||||
|
port = qbtCfg.peerPort;
|
||||||
|
protocol = "both";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf qbtCfg.openFirewall [ qbtCfg.webuiPort ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pg_prewarm;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE TABLE artists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_name TEXT,
|
||||||
|
artist_type TEXT,
|
||||||
|
country TEXT,
|
||||||
|
formed_date DATE,
|
||||||
|
disbanded_date DATE,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE works (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
work_type TEXT,
|
||||||
|
language TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tracks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
work_id UUID REFERENCES works(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
duration_ms INT,
|
||||||
|
isrc TEXT,
|
||||||
|
explicit BOOLEAN DEFAULT false,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE labels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT,
|
||||||
|
founded_date DATE,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE albums (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
label_id UUID REFERENCES labels(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
album_type TEXT,
|
||||||
|
secondary_types TEXT[] DEFAULT '{}',
|
||||||
|
release_date DATE,
|
||||||
|
upc TEXT,
|
||||||
|
total_tracks INT,
|
||||||
|
total_discs INT DEFAULT 1,
|
||||||
|
cover_url TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE genres (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
parent_id UUID REFERENCES genres(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE track_artists (
|
||||||
|
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
role TEXT DEFAULT 'primary',
|
||||||
|
position INT DEFAULT 0,
|
||||||
|
PRIMARY KEY (track_id, artist_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE album_artists (
|
||||||
|
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
role TEXT DEFAULT 'primary',
|
||||||
|
position INT DEFAULT 0,
|
||||||
|
PRIMARY KEY (album_id, artist_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE album_tracks (
|
||||||
|
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
disc_number INT DEFAULT 1,
|
||||||
|
track_number INT NOT NULL,
|
||||||
|
PRIMARY KEY (album_id, track_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE work_artists (
|
||||||
|
work_id UUID REFERENCES works(id) ON DELETE CASCADE,
|
||||||
|
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
role TEXT DEFAULT 'writer',
|
||||||
|
PRIMARY KEY (work_id, artist_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE artist_genres (
|
||||||
|
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
genre_id UUID REFERENCES genres(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (artist_id, genre_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE album_genres (
|
||||||
|
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
genre_id UUID REFERENCES genres(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (album_id, genre_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE similar_artists (
|
||||||
|
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
similar_artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
score REAL DEFAULT 0.5,
|
||||||
|
PRIMARY KEY (artist_id, similar_artist_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE lyrics (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
content TEXT,
|
||||||
|
synced_content JSONB,
|
||||||
|
language TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_public BOOLEAN DEFAULT true,
|
||||||
|
cover_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_tracks (
|
||||||
|
playlist_id UUID REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
position INT NOT NULL,
|
||||||
|
added_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (playlist_id, track_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE artist_external_ids (
|
||||||
|
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
fetched_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (artist_id, source, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE album_external_ids (
|
||||||
|
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
fetched_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (album_id, source, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE track_external_ids (
|
||||||
|
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
fetched_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (track_id, source, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_artists_name ON artists(name);
|
||||||
|
CREATE INDEX idx_artists_name_trgm ON artists USING gin (name gin_trgm_ops);
|
||||||
|
CREATE INDEX idx_artists_source ON artists(source, source_id);
|
||||||
|
CREATE INDEX idx_tracks_isrc ON tracks(isrc) WHERE isrc IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tracks_source ON tracks(source, source_id);
|
||||||
|
CREATE INDEX idx_albums_upc ON albums(upc) WHERE upc IS NOT NULL;
|
||||||
|
CREATE INDEX idx_albums_source ON albums(source, source_id);
|
||||||
|
CREATE INDEX idx_albums_release_date ON albums(release_date);
|
||||||
|
CREATE INDEX idx_genres_name ON genres(name);
|
||||||
|
CREATE INDEX idx_lyrics_track_id ON lyrics(track_id);
|
||||||
|
CREATE INDEX idx_playlist_tracks_position ON playlist_tracks(playlist_id, position);
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
-- Version 1
|
||||||
|
CREATE TABLE river_migration(
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
version bigint NOT NULL,
|
||||||
|
CONSTRAINT version CHECK (version >= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ON river_migration USING btree(version);
|
||||||
|
|
||||||
|
-- Version 2
|
||||||
|
CREATE TYPE river_job_state AS ENUM(
|
||||||
|
'available',
|
||||||
|
'cancelled',
|
||||||
|
'completed',
|
||||||
|
'discarded',
|
||||||
|
'retryable',
|
||||||
|
'running',
|
||||||
|
'scheduled'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE river_job(
|
||||||
|
-- 8 bytes
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
|
||||||
|
-- 8 bytes (4 bytes + 2 bytes + 2 bytes)
|
||||||
|
--
|
||||||
|
-- `state` is kept near the top of the table for operator convenience -- when
|
||||||
|
-- looking at jobs with `SELECT *` it'll appear first after ID. The other two
|
||||||
|
-- fields aren't as important but are kept adjacent to `state` for alignment
|
||||||
|
-- to get an 8-byte block.
|
||||||
|
state river_job_state NOT NULL DEFAULT 'available',
|
||||||
|
attempt smallint NOT NULL DEFAULT 0,
|
||||||
|
max_attempts smallint NOT NULL,
|
||||||
|
|
||||||
|
-- 8 bytes each (no alignment needed)
|
||||||
|
attempted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
finalized_at timestamptz,
|
||||||
|
scheduled_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- 2 bytes (some wasted padding probably)
|
||||||
|
priority smallint NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- types stored out-of-band
|
||||||
|
args jsonb,
|
||||||
|
attempted_by text[],
|
||||||
|
errors jsonb[],
|
||||||
|
kind text NOT NULL,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
queue text NOT NULL DEFAULT 'default',
|
||||||
|
tags varchar(255)[],
|
||||||
|
|
||||||
|
CONSTRAINT finalized_or_finalized_at_null CHECK ((state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL),
|
||||||
|
CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0),
|
||||||
|
CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4),
|
||||||
|
CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128),
|
||||||
|
CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- We may want to consider adding another property here after `kind` if it seems
|
||||||
|
-- like it'd be useful for something.
|
||||||
|
CREATE INDEX river_job_kind ON river_job USING btree(kind);
|
||||||
|
|
||||||
|
CREATE INDEX river_job_state_and_finalized_at_index ON river_job USING btree(state, finalized_at) WHERE finalized_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX river_job_prioritized_fetching_index ON river_job USING btree(state, queue, priority, scheduled_at, id);
|
||||||
|
|
||||||
|
CREATE INDEX river_job_args_index ON river_job USING GIN(args);
|
||||||
|
|
||||||
|
CREATE INDEX river_job_metadata_index ON river_job USING GIN(metadata);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION river_job_notify()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
payload json;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.state = 'available' THEN
|
||||||
|
-- Notify will coalesce duplicate notifications within a transaction, so
|
||||||
|
-- keep these payloads generalized:
|
||||||
|
payload = json_build_object('queue', NEW.queue);
|
||||||
|
PERFORM
|
||||||
|
pg_notify('river_insert', payload::text);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER river_notify
|
||||||
|
AFTER INSERT ON river_job
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE river_job_notify();
|
||||||
|
|
||||||
|
CREATE UNLOGGED TABLE river_leader(
|
||||||
|
-- 8 bytes each (no alignment needed)
|
||||||
|
elected_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
|
||||||
|
-- types stored out-of-band
|
||||||
|
leader_id text NOT NULL,
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
|
||||||
|
CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128),
|
||||||
|
CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- Version 3
|
||||||
|
ALTER TABLE river_job ALTER COLUMN tags SET DEFAULT '{}';
|
||||||
|
UPDATE river_job SET tags = '{}' WHERE tags IS NULL;
|
||||||
|
ALTER TABLE river_job ALTER COLUMN tags SET NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- Version 4
|
||||||
|
-- The args column never had a NOT NULL constraint or default value at the
|
||||||
|
-- database level, though we tried to ensure one at the application level.
|
||||||
|
ALTER TABLE river_job ALTER COLUMN args SET DEFAULT '{}';
|
||||||
|
UPDATE river_job SET args = '{}' WHERE args IS NULL;
|
||||||
|
ALTER TABLE river_job ALTER COLUMN args SET NOT NULL;
|
||||||
|
ALTER TABLE river_job ALTER COLUMN args DROP DEFAULT;
|
||||||
|
|
||||||
|
-- The metadata column never had a NOT NULL constraint or default value at the
|
||||||
|
-- database level, though we tried to ensure one at the application level.
|
||||||
|
ALTER TABLE river_job ALTER COLUMN metadata SET DEFAULT '{}';
|
||||||
|
UPDATE river_job SET metadata = '{}' WHERE metadata IS NULL;
|
||||||
|
ALTER TABLE river_job ALTER COLUMN metadata SET NOT NULL;
|
||||||
|
|
||||||
|
-- The 'pending' job state will be used for upcoming functionality:
|
||||||
|
ALTER TYPE river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded';
|
||||||
|
|
||||||
|
ALTER TABLE river_job DROP CONSTRAINT finalized_or_finalized_at_null;
|
||||||
|
ALTER TABLE river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK (
|
||||||
|
(finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR
|
||||||
|
(finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded'))
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TRIGGER river_notify ON river_job;
|
||||||
|
DROP FUNCTION river_job_notify;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Create table `river_queue`.
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE river_queue (
|
||||||
|
name text PRIMARY KEY NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}' ::jsonb,
|
||||||
|
paused_at timestamptz,
|
||||||
|
updated_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Alter `river_leader` to add a default value of 'default` to `name`.
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE river_leader
|
||||||
|
ALTER COLUMN name SET DEFAULT 'default',
|
||||||
|
DROP CONSTRAINT name_length,
|
||||||
|
ADD CONSTRAINT name_length CHECK (name = 'default');
|
||||||
|
|
||||||
|
-- Version 5
|
||||||
|
--
|
||||||
|
-- Rebuild the migration table so it's based on `(line, version)`.
|
||||||
|
--
|
||||||
|
|
||||||
|
DO
|
||||||
|
$body$
|
||||||
|
BEGIN
|
||||||
|
-- Tolerate users who may be using their own migration system rather than
|
||||||
|
-- River's. If they are, they will have skipped version 001 containing
|
||||||
|
-- `CREATE TABLE river_migration`, so this table won't exist.
|
||||||
|
IF (SELECT to_regclass('river_migration') IS NOT NULL) THEN
|
||||||
|
ALTER TABLE river_migration
|
||||||
|
RENAME TO river_migration_old;
|
||||||
|
|
||||||
|
CREATE TABLE river_migration(
|
||||||
|
line TEXT NOT NULL,
|
||||||
|
version bigint NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128),
|
||||||
|
CONSTRAINT version_gte_1 CHECK (version >= 1),
|
||||||
|
PRIMARY KEY (line, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO river_migration
|
||||||
|
(created_at, line, version)
|
||||||
|
SELECT created_at, 'main', version
|
||||||
|
FROM river_migration_old;
|
||||||
|
|
||||||
|
DROP TABLE river_migration_old;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$body$
|
||||||
|
LANGUAGE 'plpgsql';
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Add `river_job.unique_key` and bring up an index on it.
|
||||||
|
--
|
||||||
|
|
||||||
|
-- These statements use `IF NOT EXISTS` to allow users with a `river_job` table
|
||||||
|
-- of non-trivial size to build the index `CONCURRENTLY` out of band of this
|
||||||
|
-- migration, then follow by completing the migration.
|
||||||
|
ALTER TABLE river_job
|
||||||
|
ADD COLUMN IF NOT EXISTS unique_key bytea;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON river_job (kind, unique_key) WHERE unique_key IS NOT NULL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Create `river_client` and derivative.
|
||||||
|
--
|
||||||
|
-- This feature hasn't quite yet been implemented, but we're taking advantage of
|
||||||
|
-- the migration to add the schema early so that we can add it later without an
|
||||||
|
-- additional migration.
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE UNLOGGED TABLE river_client (
|
||||||
|
id text PRIMARY KEY NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
paused_at timestamptz,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Differs from `river_queue` in that it tracks the queue state for a particular
|
||||||
|
-- active client.
|
||||||
|
CREATE UNLOGGED TABLE river_client_queue (
|
||||||
|
river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
max_workers bigint NOT NULL DEFAULT 0,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
num_jobs_completed bigint NOT NULL DEFAULT 0,
|
||||||
|
num_jobs_running bigint NOT NULL DEFAULT 0,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (river_client_id, name),
|
||||||
|
CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128),
|
||||||
|
CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0),
|
||||||
|
CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Version 6
|
||||||
|
CREATE OR REPLACE FUNCTION river_job_state_in_bitmask(bitmask BIT(8), state river_job_state)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE SQL
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
SELECT CASE state
|
||||||
|
WHEN 'available' THEN get_bit(bitmask, 7)
|
||||||
|
WHEN 'cancelled' THEN get_bit(bitmask, 6)
|
||||||
|
WHEN 'completed' THEN get_bit(bitmask, 5)
|
||||||
|
WHEN 'discarded' THEN get_bit(bitmask, 4)
|
||||||
|
WHEN 'pending' THEN get_bit(bitmask, 3)
|
||||||
|
WHEN 'retryable' THEN get_bit(bitmask, 2)
|
||||||
|
WHEN 'running' THEN get_bit(bitmask, 1)
|
||||||
|
WHEN 'scheduled' THEN get_bit(bitmask, 0)
|
||||||
|
ELSE 0
|
||||||
|
END = 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Add `river_job.unique_states` and bring up an index on it.
|
||||||
|
--
|
||||||
|
-- This column may exist already if users manually created the column and index
|
||||||
|
-- as instructed in the changelog so the index could be created `CONCURRENTLY`.
|
||||||
|
--
|
||||||
|
ALTER TABLE river_job ADD COLUMN IF NOT EXISTS unique_states BIT(8);
|
||||||
|
|
||||||
|
-- This statement uses `IF NOT EXISTS` to allow users with a `river_job` table
|
||||||
|
-- of non-trivial size to build the index `CONCURRENTLY` out of band of this
|
||||||
|
-- migration, then follow by completing the migration.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON river_job (unique_key)
|
||||||
|
WHERE unique_key IS NOT NULL
|
||||||
|
AND unique_states IS NOT NULL
|
||||||
|
AND river_job_state_in_bitmask(unique_states, state);
|
||||||
|
|
||||||
|
-- Remove the old unique index. Users who are actively using the unique jobs
|
||||||
|
-- feature and who wish to avoid deploy downtime may want od drop this in a
|
||||||
|
-- subsequent migration once all jobs using the old unique system have been
|
||||||
|
-- completed (i.e. no more rows with non-null unique_key and null
|
||||||
|
-- unique_states).
|
||||||
|
DROP INDEX river_job_kind_unique_key_idx;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
CREATE TYPE monitor_state AS ENUM ('monitored', 'unmonitored', 'excluded');
|
||||||
|
|
||||||
|
CREATE TABLE artists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(500) NOT NULL,
|
||||||
|
artist_type VARCHAR(50),
|
||||||
|
country VARCHAR(10),
|
||||||
|
genres TEXT[],
|
||||||
|
image_url TEXT,
|
||||||
|
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE albums (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
album_type VARCHAR(50),
|
||||||
|
release_date DATE,
|
||||||
|
total_tracks INT,
|
||||||
|
total_discs INT DEFAULT 1,
|
||||||
|
label VARCHAR(255),
|
||||||
|
genres TEXT[],
|
||||||
|
cover_url TEXT,
|
||||||
|
monitor_state monitor_state NOT NULL DEFAULT 'unmonitored',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_albums_artist ON albums(artist_id);
|
||||||
|
CREATE INDEX idx_albums_monitored ON albums(monitor_state) WHERE monitor_state = 'monitored';
|
||||||
|
|
||||||
|
CREATE TABLE tracks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
duration_ms INT,
|
||||||
|
isrc VARCHAR(20),
|
||||||
|
disc_number INT DEFAULT 1,
|
||||||
|
track_number INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tracks_album ON tracks(album_id);
|
||||||
|
|
||||||
|
CREATE TABLE torrents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
info_hash VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
tracker VARCHAR(100) NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
format VARCHAR(20) NOT NULL,
|
||||||
|
quality VARCHAR(20),
|
||||||
|
source VARCHAR(20),
|
||||||
|
bit_depth INT,
|
||||||
|
sample_rate INT,
|
||||||
|
seeders INT DEFAULT 0,
|
||||||
|
peers INT DEFAULT 0,
|
||||||
|
size BIGINT,
|
||||||
|
track_count INT,
|
||||||
|
has_cover_art BOOLEAN DEFAULT FALSE,
|
||||||
|
has_cue_sheet BOOLEAN DEFAULT FALSE,
|
||||||
|
has_rip_log BOOLEAN DEFAULT FALSE,
|
||||||
|
download_link TEXT,
|
||||||
|
torrent_file BYTEA,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_torrents_album ON torrents(album_id);
|
||||||
|
CREATE INDEX idx_torrents_album_format ON torrents(album_id, format);
|
||||||
|
|
||||||
|
CREATE TYPE download_state AS ENUM (
|
||||||
|
'pending', 'downloading', 'completed', 'failed', 'seeding', 'paused'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
torrent_id UUID NOT NULL REFERENCES torrents(id) ON DELETE CASCADE,
|
||||||
|
album_id UUID NOT NULL,
|
||||||
|
format VARCHAR(20) NOT NULL,
|
||||||
|
quality VARCHAR(20),
|
||||||
|
state download_state NOT NULL DEFAULT 'pending',
|
||||||
|
qbit_hash VARCHAR(64),
|
||||||
|
save_path TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_unique_active_torrent
|
||||||
|
ON downloads(torrent_id)
|
||||||
|
WHERE state NOT IN ('failed');
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_unique_owned_album_quality
|
||||||
|
ON downloads(album_id, format, quality)
|
||||||
|
WHERE state IN ('completed', 'seeding');
|
||||||
|
|
||||||
|
CREATE INDEX idx_downloads_state ON downloads(state);
|
||||||
|
CREATE INDEX idx_downloads_album ON downloads(album_id);
|
||||||
|
|
||||||
|
CREATE TABLE download_files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
download_id UUID NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||||
|
track_id UUID REFERENCES tracks(id),
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
file_type VARCHAR(20) NOT NULL,
|
||||||
|
sha256_hash VARCHAR(64),
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_download_files_download ON download_files(download_id);
|
||||||
|
CREATE INDEX idx_download_files_hash ON download_files(sha256_hash) WHERE sha256_hash IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE album_releases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
download_id UUID NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||||
|
format VARCHAR(20) NOT NULL,
|
||||||
|
bit_depth INT,
|
||||||
|
sample_rate INT,
|
||||||
|
channels INT DEFAULT 2,
|
||||||
|
is_lossless BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
source VARCHAR(20),
|
||||||
|
total_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_duration_ms INT NOT NULL DEFAULT 0,
|
||||||
|
track_count INT NOT NULL DEFAULT 0,
|
||||||
|
has_cover_art BOOLEAN DEFAULT FALSE,
|
||||||
|
has_cue_sheet BOOLEAN DEFAULT FALSE,
|
||||||
|
has_rip_log BOOLEAN DEFAULT FALSE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_album_releases_album ON album_releases(album_id);
|
||||||
|
CREATE UNIQUE INDEX idx_album_releases_download ON album_releases(download_id);
|
||||||
|
|
||||||
|
CREATE TABLE track_releases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
album_release_id UUID NOT NULL REFERENCES album_releases(id) ON DELETE CASCADE,
|
||||||
|
track_id UUID REFERENCES tracks(id),
|
||||||
|
download_file_id UUID REFERENCES download_files(id),
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
track_number INT NOT NULL DEFAULT 1,
|
||||||
|
disc_number INT NOT NULL DEFAULT 1,
|
||||||
|
duration_ms INT,
|
||||||
|
format VARCHAR(20) NOT NULL,
|
||||||
|
bit_depth INT,
|
||||||
|
sample_rate INT,
|
||||||
|
channels INT DEFAULT 2,
|
||||||
|
bitrate_kbps INT,
|
||||||
|
file_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_track_releases_album_release ON track_releases(album_release_id);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE workflow_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
quality VARCHAR(20) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||||
|
error_message TEXT,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
running_lock BOOLEAN GENERATED ALWAYS AS (CASE WHEN status = 'running' THEN TRUE ELSE NULL END) STORED,
|
||||||
|
CONSTRAINT idx_workflow_runs_active UNIQUE (album_id, quality, running_lock)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);
|
||||||
|
|
||||||
|
CREATE TABLE album_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
seq BIGSERIAL NOT NULL,
|
||||||
|
workflow_run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
||||||
|
album_id UUID NOT NULL,
|
||||||
|
event_type VARCHAR(20) NOT NULL,
|
||||||
|
step VARCHAR(50) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
data_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_album_events_workflow ON album_events(workflow_run_id);
|
||||||
|
CREATE INDEX idx_album_events_album ON album_events(album_id);
|
||||||
|
CREATE INDEX idx_album_events_seq ON album_events(seq);
|
||||||
|
|
||||||
|
ALTER TYPE download_state ADD VALUE IF NOT EXISTS 'cancelled';
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.agregators;
|
||||||
|
vpnCfg = cfg.vpn;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.agregators.vpn = {
|
||||||
|
enable = lib.mkEnableOption "WireGuard VPN via VPN-Confinement for torrent traffic";
|
||||||
|
|
||||||
|
wgConfigFile = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
description = ''
|
||||||
|
Path to WireGuard configuration file.
|
||||||
|
Must not be in the Nix store — use a runtime path.
|
||||||
|
'';
|
||||||
|
example = "/run/secrets/wireguard.conf";
|
||||||
|
};
|
||||||
|
|
||||||
|
accessibleFrom = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [
|
||||||
|
"192.168.1.0/24"
|
||||||
|
"192.168.0.0/24"
|
||||||
|
"127.0.0.1"
|
||||||
|
];
|
||||||
|
description = "CIDRs allowed to reach services inside the VPN namespace.";
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "wg";
|
||||||
|
description = "Name of the VPN network namespace.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (cfg.enable && vpnCfg.enable) {
|
||||||
|
vpnNamespaces.${vpnCfg.namespace} = {
|
||||||
|
enable = true;
|
||||||
|
wireguardConfigFile = vpnCfg.wgConfigFile;
|
||||||
|
accessibleFrom = vpnCfg.accessibleFrom;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
nixosModules,
|
||||||
|
}:
|
||||||
|
|
||||||
|
pkgs.testers.nixosTest {
|
||||||
|
name = "agregators-simple-test";
|
||||||
|
|
||||||
|
nodes.machine =
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
imports = [ nixosModules.default ];
|
||||||
|
|
||||||
|
networking.firewall.enable = false;
|
||||||
|
|
||||||
|
virtualisation.memorySize = 4096;
|
||||||
|
virtualisation.diskSize = 8192;
|
||||||
|
|
||||||
|
services.agregators = {
|
||||||
|
enable = true;
|
||||||
|
mediaDir = "/data/music";
|
||||||
|
|
||||||
|
# No VPN in test — no WireGuard endpoint to connect to
|
||||||
|
# qbittorrent.vpn.enable is false by implication
|
||||||
|
|
||||||
|
qbittorrent = {
|
||||||
|
enable = true;
|
||||||
|
vpn.enable = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
jackett.enable = true;
|
||||||
|
|
||||||
|
metadata-agregator = {
|
||||||
|
enable = true;
|
||||||
|
package = config.services.agregators.music-agregator.package;
|
||||||
|
# In real usage this would be the actual metadata-agregator package.
|
||||||
|
# For the simple test we just check the module evaluates and
|
||||||
|
# systemd units are created. The service itself won't start
|
||||||
|
# without the real binary — that's fine, we test structure here.
|
||||||
|
};
|
||||||
|
|
||||||
|
musicfs = {
|
||||||
|
enable = true;
|
||||||
|
package = config.services.agregators.music-agregator.package;
|
||||||
|
};
|
||||||
|
|
||||||
|
music-agregator = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.hello; # placeholder — real package tested separately
|
||||||
|
jackettApiKeyFile = "/dev/null";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
# PostgreSQL should be running with both databases
|
||||||
|
machine.succeed("systemctl is-active postgresql")
|
||||||
|
machine.succeed("sudo -u postgres psql -lqt | grep -q music_agregator")
|
||||||
|
machine.succeed("sudo -u postgres psql -lqt | grep -q metadata_agregator")
|
||||||
|
|
||||||
|
# Schema init oneshots should have completed
|
||||||
|
machine.succeed("systemctl is-active agregators-schema-music")
|
||||||
|
machine.succeed("systemctl is-active agregators-schema-metadata")
|
||||||
|
|
||||||
|
# Verify schemas were applied (marker files exist)
|
||||||
|
machine.succeed("test -f /var/lib/agregators/postgres/.schema-music-initialized")
|
||||||
|
machine.succeed("test -f /var/lib/agregators/postgres/.schema-metadata-initialized")
|
||||||
|
|
||||||
|
# Verify tables exist in music_agregator
|
||||||
|
machine.succeed("sudo -u music_agregator psql -d music_agregator -c 'SELECT 1 FROM river_job LIMIT 0'")
|
||||||
|
machine.succeed("sudo -u music_agregator psql -d music_agregator -c 'SELECT 1 FROM artists LIMIT 0'")
|
||||||
|
machine.succeed("sudo -u music_agregator psql -d music_agregator -c 'SELECT 1 FROM workflow_runs LIMIT 0'")
|
||||||
|
|
||||||
|
# Verify tables exist in metadata_agregator
|
||||||
|
machine.succeed("sudo -u metadata_agregator psql -d metadata_agregator -c 'SELECT 1 FROM artists LIMIT 0'")
|
||||||
|
machine.succeed("sudo -u metadata_agregator psql -d metadata_agregator -c 'SELECT 1 FROM albums LIMIT 0'")
|
||||||
|
|
||||||
|
# Verify extensions
|
||||||
|
machine.succeed("sudo -u metadata_agregator psql -d metadata_agregator -c \"SELECT 1 FROM pg_extension WHERE extname='pg_trgm'\" | grep -q 1")
|
||||||
|
|
||||||
|
# qBittorrent should be running (no VPN in test)
|
||||||
|
machine.succeed("systemctl is-active qbittorrent")
|
||||||
|
|
||||||
|
# Jackett should be running
|
||||||
|
machine.succeed("systemctl is-active jackett")
|
||||||
|
|
||||||
|
# Directories should exist with correct ownership
|
||||||
|
machine.succeed("test -d /data/music")
|
||||||
|
machine.succeed("test -d /data/music/downloads")
|
||||||
|
machine.succeed("test -d /var/lib/agregators")
|
||||||
|
|
||||||
|
# Verify the agregators user/group exist
|
||||||
|
machine.succeed("id agregators")
|
||||||
|
machine.succeed("getent group agregators")
|
||||||
|
|
||||||
|
print("\n=== Agregators Simple Test Completed ===")
|
||||||
|
'';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user