reg: multi-scrobbler live replication between last.fm & teal.fm

Changed files
+241
hosts
modules
nixos
pkgs
secrets
+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
··· 12 12 ./hardware.nix 13 13 ./pds.nix 14 14 ./pds-backup.nix 15 + ./multi-scrobbler.nix 15 16 ]; 16 17 17 18 boot.tmp.cleanOnBoot = true;
+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
··· 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
··· 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
··· 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 + }