+2
flake.nix
+2
flake.nix
···
75
75
inherit (inputs) tree-sitter-nu topiary-nushell;
76
76
};
77
77
atproto-lastfm-importer = pkgs.callPackage ./pkgs/atproto-lastfm-importer.nix { };
78
+
multi-scrobbler = pkgs.callPackage ./pkgs/multi-scrobbler.nix { };
78
79
79
80
wakuna-image = self.lib.sdImageFromSystem self.nixosConfigurations.wakuna;
80
81
};
···
130
131
nixosModules = {
131
132
dev = import ./modules/dev/nixos.nix;
132
133
desktop = import ./modules/desktop/nixos.nix;
134
+
multi-scrobbler = import ./modules/nixos/services/multi-scrobbler.nix;
133
135
};
134
136
};
135
137
}
+1
hosts/reg/default.nix
+1
hosts/reg/default.nix
+17
hosts/reg/multi-scrobbler.nix
+17
hosts/reg/multi-scrobbler.nix
···
1
+
{ config, self, ... }:
2
+
{
3
+
imports = [ self.nixosModules.multi-scrobbler ];
4
+
5
+
sops.secrets."multi-scrobbler.json" = {
6
+
# https://github.com/Mic92/sops-nix?tab=readme-ov-file#emit-plain-file-for-yaml-and-json-formats
7
+
key = "";
8
+
format = "json";
9
+
sopsFile = ../../secrets/multi-scrobbler.json;
10
+
owner = config.services.multi-scrobbler.group;
11
+
group = config.services.multi-scrobbler.user;
12
+
path = config.services.multi-scrobbler.configFile;
13
+
restartUnits = [ "multi-scrobbler.service" ];
14
+
};
15
+
16
+
services.multi-scrobbler.enable = true;
17
+
}
+106
modules/nixos/services/multi-scrobbler.nix
+106
modules/nixos/services/multi-scrobbler.nix
···
1
+
{
2
+
config,
3
+
lib,
4
+
self',
5
+
...
6
+
}:
7
+
let
8
+
cfg = config.services.multi-scrobbler;
9
+
in
10
+
{
11
+
options.services.multi-scrobbler = {
12
+
enable = lib.mkEnableOption "Multi-Scrobbler service";
13
+
14
+
configFile = lib.mkOption {
15
+
type = lib.types.path;
16
+
default = "/var/lib/multi-scrobbler/config.json";
17
+
description = "Path to the multi-scrobbler configuration file (AIO JSON format).";
18
+
};
19
+
20
+
openFirewall = lib.mkOption {
21
+
type = lib.types.bool;
22
+
default = false;
23
+
description = "Open firewall port for multi-scrobbler web UI.";
24
+
};
25
+
26
+
port = lib.mkOption {
27
+
type = lib.types.port;
28
+
default = 9078;
29
+
description = "Port for the multi-scrobbler web UI.";
30
+
};
31
+
32
+
resourceLimits = {
33
+
memoryMax = lib.mkOption {
34
+
type = lib.types.str;
35
+
default = "1G";
36
+
description = "Maximum memory for the systemd service.";
37
+
};
38
+
39
+
cpuQuota = lib.mkOption {
40
+
type = lib.types.str;
41
+
default = "50%";
42
+
description = "CPU quota for the systemd service.";
43
+
};
44
+
};
45
+
46
+
user = lib.mkOption {
47
+
type = lib.types.str;
48
+
default = "multi-scrobbler";
49
+
description = "User account under which multi-scrobbler runs.";
50
+
};
51
+
52
+
group = lib.mkOption {
53
+
type = lib.types.str;
54
+
default = "multi-scrobbler";
55
+
description = "Group account under which multi-scrobbler runs.";
56
+
};
57
+
};
58
+
59
+
config = lib.mkIf cfg.enable {
60
+
users.users.${cfg.user} = {
61
+
isSystemUser = true;
62
+
group = cfg.group;
63
+
description = "Multi-Scrobbler service user";
64
+
};
65
+
66
+
users.groups.${cfg.group} = { };
67
+
68
+
systemd.services.multi-scrobbler = {
69
+
description = "Multi-Scrobbler - scrobble plays from multiple sources to multiple clients";
70
+
after = [ "network.target" ];
71
+
wantedBy = [ "multi-user.target" ];
72
+
73
+
serviceConfig = {
74
+
Type = "simple";
75
+
User = cfg.user;
76
+
Group = cfg.group;
77
+
StateDirectory = "multi-scrobbler";
78
+
79
+
ExecStart = "${self'.packages.multi-scrobbler}/bin/multi-scrobbler";
80
+
Environment = [
81
+
"PORT=${toString cfg.port}"
82
+
"CONFIG_DIR=/var/lib/multi-scrobbler"
83
+
"NODE_ENV=production"
84
+
"NODE_PATH=${self'.packages.multi-scrobbler}/share/multi-scrobbler/node_modules"
85
+
];
86
+
87
+
Restart = "on-failure";
88
+
RestartSec = "30s";
89
+
90
+
MemoryMax = cfg.resourceLimits.memoryMax;
91
+
CPUQuota = cfg.resourceLimits.cpuQuota;
92
+
93
+
ReadOnlyPaths = [ "/nix/store" ];
94
+
ReadWritePaths = [ "/var/lib/multi-scrobbler" ];
95
+
96
+
ProtectSystem = "yes";
97
+
ProtectHome = "yes";
98
+
PrivateTmp = "yes";
99
+
NoNewPrivileges = "yes";
100
+
PrivateDevices = "yes";
101
+
};
102
+
};
103
+
104
+
networking.firewall.allowedTCPPorts = lib.optional cfg.openFirewall cfg.port;
105
+
};
106
+
}
+70
pkgs/multi-scrobbler.nix
+70
pkgs/multi-scrobbler.nix
···
1
+
{
2
+
lib,
3
+
buildNpmPackage,
4
+
fetchFromGitHub,
5
+
nodejs,
6
+
makeWrapper,
7
+
bashNonInteractive,
8
+
...
9
+
}:
10
+
buildNpmPackage rec {
11
+
pname = "multi-scrobbler";
12
+
version = "0.10.8";
13
+
14
+
src = fetchFromGitHub {
15
+
owner = "FoxxMD";
16
+
repo = "multi-scrobbler";
17
+
rev = version;
18
+
hash = "sha256-knHOAE5reDN7fVmA2guQFG49jiQobzLpFlm6N1TioSI=";
19
+
};
20
+
21
+
npmDepsHash = "sha256-4do1Hm6c82v0I2N7eO700k4rOBjLOx373QKKuhi5/uU=";
22
+
23
+
nativeBuildInputs = [
24
+
makeWrapper
25
+
bashNonInteractive
26
+
];
27
+
28
+
inherit nodejs;
29
+
30
+
buildPhase = ''
31
+
runHook preBuild
32
+
33
+
npm run build:backend
34
+
npm run build:frontend
35
+
36
+
runHook postBuild
37
+
'';
38
+
39
+
installPhase = ''
40
+
runHook preInstall
41
+
42
+
npm prune --production
43
+
44
+
mkdir -p $out/bin $out/share/multi-scrobbler
45
+
cp -r * $out/share/multi-scrobbler/
46
+
47
+
runHook postInstall
48
+
'';
49
+
50
+
postInstall = ''
51
+
# Copy tsconfig for ts-json-schema-generator to find at runtime
52
+
# The app expects tsconfig.json to be in the working directory under src/backend/
53
+
# We'll preserve the source directory structure
54
+
mkdir -p $out/share/multi-scrobbler/src/backend
55
+
cp src/backend/tsconfig.json $out/share/multi-scrobbler/src/backend/
56
+
57
+
# Create wrapper with working directory set to the source install location
58
+
makeWrapper ${nodejs}/bin/node $out/bin/multi-scrobbler \
59
+
--add-flags "$out/share/multi-scrobbler/node_modules/tsx/dist/cli.mjs" \
60
+
--add-flags "$out/share/multi-scrobbler/src/backend/index.ts" \
61
+
--chdir "$out/share/multi-scrobbler"
62
+
'';
63
+
64
+
meta = with lib; {
65
+
description = "Scrobble plays from multiple sources to multiple clients";
66
+
homepage = "https://github.com/FoxxMD/multi-scrobbler";
67
+
license = licenses.mit;
68
+
mainProgram = "multi-scrobbler";
69
+
};
70
+
}
+45
secrets/multi-scrobbler.json
+45
secrets/multi-scrobbler.json
···
1
+
{
2
+
"baseUrl": "ENC[AES256_GCM,data:yYLCuM4GP27ZQfFxQWzh2nCZwY8xbpfvFz5ACjk=,iv:4JZsXLTLCwOSNXPHU64kOc5DsXUKtkPDqPWGzDPQ874=,tag:lJpM72UKRytnJYzriiB0DA==,type:str]",
3
+
"port": "ENC[AES256_GCM,data:1XlBjA==,iv:VGfqSOcizd0CE/TvLOI4kSQcuzN6dnZ7PR0p4F2vWwc=,tag:+i+1O+y3EY1GIpe/rIn8aw==,type:float]",
4
+
"disableWeb": "ENC[AES256_GCM,data:cmqa5A==,iv:fj0o/4vhLLFf70hJpKEaU/31PX7OZbnViyhZa4ebfyM=,tag:M2p0VCKD+gUIGXfjwYEX5Q==,type:bool]",
5
+
"sources": [
6
+
{
7
+
"name": "ENC[AES256_GCM,data:9u7Zpqt8,iv:vQPL8Bz1imd46YJui/hCQfEMzZmIqngq27fS0Z2MoGY=,tag:472xcnGthNh2h48QODGnvA==,type:str]",
8
+
"enable": "ENC[AES256_GCM,data:gFSqNw==,iv:5IXFnP/xbzPnBYc8ZS7i0djDTEDBTZ3qbylkGbOrjxI=,tag:f2i8FvzH20RWWP+kVSErCA==,type:bool]",
9
+
"configureAs": "ENC[AES256_GCM,data:HA0huzns,iv:rtpFCnWZQ9j7+zy87qxlgKpJSaKO7RPm8ctEluyasBg=,tag:3MbOkmlAjjSfKIfkYaa5Iw==,type:str]",
10
+
"data": {
11
+
"apiKey": "ENC[AES256_GCM,data:8jJB6GzpZDj3PVHN8K9DHFWECEC+EhJD7qCRxnRjjuE=,iv:Qjd9svBPILjMuCp7aBKZrrBgO96rAxZSke1ykevhAqM=,tag:nEpORMnjYoPkvXNAYooYnw==,type:str]",
12
+
"secret": "ENC[AES256_GCM,data:1uk037gi0lmUX/s4gPx136hIn8d5muibypZDo4mNd/g=,iv:DnH/L44jD8QCOxaB2fxg95DdjD9GgnG8Tdwlfav7XXU=,tag:6Zjsc8WXvXplBzFf45bo4w==,type:str]",
13
+
"redirectUri": "ENC[AES256_GCM,data:A6dBy1NNnhKYCpY92PXzZpkha5wE3Eu5gtSLoqj+dLjc1NdS4B9F7quq130rrWq7whg=,iv:oUN0g4xKPmKh8zmdxC+a/mUKapMRpKrRwAiXRD4enyA=,tag:61keezAyOwU+QfC6o2TdyA==,type:str]"
14
+
},
15
+
"type": "ENC[AES256_GCM,data:Hr5rSHFs,iv:vCT3cAAm0wJiKzrG+DrV1xxvyNHYXU8nk+/qZAfcO9I=,tag:dwbgcBuGgg/buaBDkZFkqw==,type:str]"
16
+
}
17
+
],
18
+
"clients": [
19
+
{
20
+
"name": "ENC[AES256_GCM,data:idcmJJEBmg==,iv:UZQExJQdK6ErkYqyROFyjVqFzqrUvxHBKTAjCbJfeDU=,tag:G47ii6rB7EYUk2C0l3YWOA==,type:str]",
21
+
"configureAs": "ENC[AES256_GCM,data:h4okforZ,iv:4JTOI9Tqd1rj500HpLQukfSEJshawurXILg69Ojd+E4=,tag:Up+IqiiG+w/GZ3/fK+dU0w==,type:str]",
22
+
"data": {
23
+
"identifier": "ENC[AES256_GCM,data:n1rA/WV2ognCJUnS,iv:MixZmu14psLgv58OXleIhrcgla+pbAEyY+fwd/0AygU=,tag:811ykA+acxOUfnqsyz4G+w==,type:str]",
24
+
"appPassword": "ENC[AES256_GCM,data:7Zp8BQ/GcVJThP1GaFA3sMOuVg==,iv:+By/Yfgpfg3v0FK5RxqZxU55Uq+5nzREehRELYAJcvE=,tag:MsvVCyGIEFdYjG4i2xiEqg==,type:str]"
25
+
},
26
+
"type": "ENC[AES256_GCM,data:pbzf7Waa,iv:2J/Uu6rNMmRd6Shzznvcwg1PC+H+tHYBbhcQk3x5SS4=,tag:DIjFRrfdvPMKiZA762Dfpw==,type:str]"
27
+
}
28
+
],
29
+
"sops": {
30
+
"age": [
31
+
{
32
+
"recipient": "age17cxj5zwkkxjkjvmpskpkyh6yt4xj4l8h6jyjxez3nmq6y9tvhqjsdp0m5j",
33
+
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpUkpKWnZnOTF3TzRvR25k\nT0kzbVNPbWdCUGY2Ym8wVC8xemZuZ0ZSMEhNCjVWdEpROVI4NWJ1TVVGRzdIL3A1\nT2pJbGZKOTg3UG5zWi9RV29QVUI1dncKLS0tIDJiV0JBQXBsWTdyVGczeTlVZlY0\ndHNpb2ZwSmpEYWU0MjlYZVZLTUQyNHcKt5UrpeW+edhOow2gEp6rcpG7/bJCepNc\nokRByUWoIMoNB7+UWNLyLG1vddjmSQ5sGie+tQqKi2OQSHJNkp2LTA==\n-----END AGE ENCRYPTED FILE-----\n"
34
+
},
35
+
{
36
+
"recipient": "age1j6j2ldpsj7jmchstwl3nktvatut9hzxnemmy6py84rrga5eaf93q5w8s39",
37
+
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXdjJVMVRpN3JpZTdaSHdt\nWGVlaGlkK2NFV1pqR3lsZVJxb3ROeTR1ZUNvCi94bExycHZBekUzL0w0aHpRbUpo\nVWo0bFU0dkc5YmUxVXQ3M0hicVJ3QVkKLS0tIEhYcDlyc2lBbUhpckJqMFJQWnR6\neGJyY2xwQlRtUkp5SG9MYlNWUUZ5S1UKqB3jGLdJpmOPxUaQgEeQ5CpxqIwffTjM\njeyPPDWqznc2bqKengKIOM8jn87NiOiqYg3c2eBR8XIJnofF6WkWVQ==\n-----END AGE ENCRYPTED FILE-----\n"
38
+
}
39
+
],
40
+
"lastmodified": "2026-01-01T20:12:58Z",
41
+
"mac": "ENC[AES256_GCM,data:7YZwSQwL7fRkkdenuUhdbjrlFKDob5x1I3IQAXGj9XWWc77vSeXuW/IEk/auEs0CpIC41FplC3ER5CrsiBUFKYsiPpGFeNW3Yl/ow0ejXXtsR0nGkDsFJ8PsG8cc73KaA0jyt0tXCwldy2+FAEnkkMFrSbVICj8nU7z2PvfsCrI=,iv:V3euyskty/ms80lsd/K0GktAmkAYtv1BNN4EFXRY4cY=,tag:sLGAq2DPoPikRNeER8X7+A==,type:str]",
42
+
"unencrypted_suffix": "_unencrypted",
43
+
"version": "3.11.0"
44
+
}
45
+
}