diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..01c82f4 --- /dev/null +++ b/default.nix @@ -0,0 +1,9 @@ +{ system ? builtins.currentSystem, pkgs ? import { inherit system; } +}: +let + flake = import ./flake.nix; + outputs = flake.outputs { + self = outputs; + nixpkgs = pkgs.path or ; + }; +in outputs.packages.${system}.default diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2e5f9bf --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1767116409, + "narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "cad22e7d996aea55ecab064e84834289143e44a0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..30316e7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,70 @@ +{ + description = "Piper - A teal.fm scrobbler service for ATProto"; + inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; + outputs = { self, nixpkgs }: + let + forAllSystems = nixpkgs.lib.genAttrs [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); + + mkPiper = pkgs: + pkgs.buildGoModule { + pname = "teal-piper"; + version = "0.0.2"; + src = ./.; + vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; + + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ pkgs.sqlite ]; + + env.CGO_ENABLED = 1; + subPackages = [ "cmd" ]; + ldflags = [ "-s" "-w" ]; + + postInstall = '' + mv $out/bin/cmd $out/bin/piper + ''; + + meta = with pkgs.lib; { + description = "A teal.fm tool for scrobbling music to ATProto PDSs"; + homepage = "https://github.com/teal-fm/piper"; + license = licenses.mit; + maintainers = [ ]; + mainProgram = "piper"; + }; + }; + in { + packages = forAllSystems (system: + let + pkgs = nixpkgsFor.${system}; + piper = mkPiper pkgs; + in { + default = piper; + teal-piper = piper; + }); + + apps = forAllSystems (system: + let piper = self.packages.${system}.default; + in { + default = { + type = "app"; + program = "${piper}/bin/piper"; + }; + }); + + devShells = forAllSystems (system: + let pkgs = nixpkgsFor.${system}; + in { + default = pkgs.mkShell { buildInputs = with pkgs; [ go air ]; }; + }); + + nixosModules.default = import ./nixos-module.nix; + nixosModules.teal-piper = import ./nixos-module.nix; + + overlays.default = final: prev: { teal-piper = mkPiper final; }; + }; +} diff --git a/nixos-module.nix b/nixos-module.nix new file mode 100644 index 0000000..10e8dd4 --- /dev/null +++ b/nixos-module.nix @@ -0,0 +1,187 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.teal-piper; + + # Helper function to generate environment variables + settingsFormat = pkgs.formats.keyValue { }; + + # Auto-derive URLs from SERVER_ROOT_URL if not explicitly set + defaultSettings = { + SERVER_PORT = cfg.settings.SERVER_PORT or 8080; + SERVER_HOST = cfg.settings.SERVER_HOST or "localhost"; + DB_PATH = cfg.settings.DB_PATH or "${cfg.dataDir}/piper.db"; + TRACKER_INTERVAL = cfg.settings.TRACKER_INTERVAL or 30; + + # Spotify defaults + SPOTIFY_AUTH_URL = + cfg.settings.SPOTIFY_AUTH_URL or "https://accounts.spotify.com/authorize"; + SPOTIFY_TOKEN_URL = + cfg.settings.SPOTIFY_TOKEN_URL or "https://accounts.spotify.com/api/token"; + SPOTIFY_SCOPES = + cfg.settings.SPOTIFY_SCOPES or "user-read-currently-playing user-read-email"; + }; + + # Auto-derive callback URLs if SERVER_ROOT_URL is set + derivedSettings = optionalAttrs (cfg.settings ? SERVER_ROOT_URL) { + ATPROTO_CLIENT_ID = + cfg.settings.ATPROTO_CLIENT_ID or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json"; + ATPROTO_METADATA_URL = + cfg.settings.ATPROTO_METADATA_URL or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json"; + ATPROTO_CALLBACK_URL = + cfg.settings.ATPROTO_CALLBACK_URL or "${cfg.settings.SERVER_ROOT_URL}/callback/atproto"; + CALLBACK_SPOTIFY = + cfg.settings.CALLBACK_SPOTIFY or "${cfg.settings.SERVER_ROOT_URL}/callback/spotify"; + }; + + # Merge user settings with defaults and derived settings + finalSettings = defaultSettings // cfg.settings // derivedSettings; + + # Create environment file + settingsFile = settingsFormat.generate "teal-piper.env" finalSettings; + +in { + options.services.teal-piper = { + enable = mkEnableOption "Piper - teal.fm scrobbler service"; + + package = mkOption { + type = types.package; + default = pkgs.teal-piper or (throw + "teal-piper package not found. Please add it to your nixpkgs overlay."); + defaultText = literalExpression "pkgs.teal-piper"; + description = "The piper package to use."; + }; + + user = mkOption { + type = types.str; + default = "teal-piper"; + description = "User account under which piper runs."; + }; + + group = mkOption { + type = types.str; + default = "teal-piper"; + description = "Group under which piper runs."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/teal-piper"; + description = "Directory where piper stores its database and data."; + }; + + settings = mkOption { + type = types.attrsOf types.str; + default = { }; + example = literalExpression '' + { + SERVER_PORT = "8080"; + SERVER_HOST = "0.0.0.0"; + SERVER_ROOT_URL = "https://piper.teal.fm"; + TRACKER_INTERVAL = "30"; + } + ''; + description = '' + Configuration for piper. These will be converted to environment variables. + + Required settings: + - ATPROTO_CLIENT_SECRET_KEY (generate with: goat key generate -t P-256) + - ATPROTO_CLIENT_SECRET_KEY_ID + - SERVER_ROOT_URL + + Optional settings: + - SPOTIFY_CLIENT_SECRET + - LASTFM_API_KEY + - APPLE_MUSIC_TEAM_ID + - APPLE_MUSIC_KEY_ID + - APPLE_MUSIC_PRIVATE_KEY_PATH + + URLs are auto-derived from SERVER_ROOT_URL: + - ATPROTO_CLIENT_ID + - ATPROTO_METADATA_URL + - ATPROTO_CALLBACK_URL + - CALLBACK_SPOTIFY + ''; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/teal-piper.env"; + description = '' + Path to a file containing environment variables for secrets. + This file should contain: + + SPOTIFY_CLIENT_ID=your_spotify_client_id + SPOTIFY_CLIENT_SECRET=your_spotify_client_secret + ATPROTO_CLIENT_SECRET_KEY=your_p256_private_key + ATPROTO_CLIENT_SECRET_KEY_ID=1758199756 + LASTFM_API_KEY=your_lastfm_key # optional + + This is the recommended way to configure secrets instead of putting them in settings. + ''; + }; + }; + + config = mkIf cfg.enable { + # Create user and group + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + description = "Piper service user"; + }; + + users.groups.${cfg.group} = { }; + + # Systemd service + systemd.services.teal-piper = { + description = "Piper - teal.fm scrobbler service"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + # Security hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + + # Allow write access to data directory + ReadWritePaths = [ cfg.dataDir ]; + StateDirectory = "teal-piper"; + StateDirectoryMode = "0700"; + + # Working directory + WorkingDirectory = cfg.dataDir; + + # Load environment from generated file + EnvironmentFile = [ settingsFile ] + ++ optional (cfg.environmentFile != null) cfg.environmentFile; + + # Start the service + ExecStart = "${cfg.package}/bin/piper"; + + # Restart policy + Restart = "on-failure"; + RestartSec = "10s"; + }; + }; + }; +} -- 2.51.2 From edddc9dacedfee982f330688af5775ec57a8c3f8 Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Sat, 3 Jan 2026 10:31:04 -0500 Subject: [PATCH 02/20] refactor: nixos module fixes --- default.nix | 12 ++--- flake.nix | 49 +++++------------ nixos-module.nix => module.nix | 99 ++++++++++++++++++++-------------- package.nix | 46 ++++++++++++++++ 4 files changed, 120 insertions(+), 86 deletions(-) rename nixos-module.nix => module.nix (64%) create mode 100644 package.nix diff --git a/default.nix b/default.nix index 01c82f4..7e97d10 100644 --- a/default.nix +++ b/default.nix @@ -1,9 +1,3 @@ -{ system ? builtins.currentSystem, pkgs ? import { inherit system; } -}: -let - flake = import ./flake.nix; - outputs = flake.outputs { - self = outputs; - nixpkgs = pkgs.path or ; - }; -in outputs.packages.${system}.default +{ pkgs ? import { } }: + +pkgs.callPackage ./package.nix { } diff --git a/flake.nix b/flake.nix index 30316e7..d0e17fd 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,8 @@ { description = "Piper - A teal.fm scrobbler service for ATProto"; + inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; + outputs = { self, nixpkgs }: let forAllSystems = nixpkgs.lib.genAttrs [ @@ -10,41 +12,12 @@ "aarch64-darwin" ]; nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); - - mkPiper = pkgs: - pkgs.buildGoModule { - pname = "teal-piper"; - version = "0.0.2"; - src = ./.; - vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; - - nativeBuildInputs = [ pkgs.pkg-config ]; - buildInputs = [ pkgs.sqlite ]; - - env.CGO_ENABLED = 1; - subPackages = [ "cmd" ]; - ldflags = [ "-s" "-w" ]; - - postInstall = '' - mv $out/bin/cmd $out/bin/piper - ''; - - meta = with pkgs.lib; { - description = "A teal.fm tool for scrobbling music to ATProto PDSs"; - homepage = "https://github.com/teal-fm/piper"; - license = licenses.mit; - maintainers = [ ]; - mainProgram = "piper"; - }; - }; in { packages = forAllSystems (system: - let - pkgs = nixpkgsFor.${system}; - piper = mkPiper pkgs; + let pkgs = nixpkgsFor.${system}; in { - default = piper; - teal-piper = piper; + default = pkgs.callPackage ./package.nix { }; + teal-piper = pkgs.callPackage ./package.nix { }; }); apps = forAllSystems (system: @@ -59,12 +32,16 @@ devShells = forAllSystems (system: let pkgs = nixpkgsFor.${system}; in { - default = pkgs.mkShell { buildInputs = with pkgs; [ go air ]; }; + default = pkgs.mkShell { + buildInputs = with pkgs; [ go air nodejs sqlite pkg-config ]; + }; }); - nixosModules.default = import ./nixos-module.nix; - nixosModules.teal-piper = import ./nixos-module.nix; + nixosModules.default = import ./module.nix; + nixosModules.teal-piper = import ./module.nix; - overlays.default = final: prev: { teal-piper = mkPiper final; }; + overlays.default = final: prev: { + teal-piper = final.callPackage ./package.nix { }; + }; }; } diff --git a/nixos-module.nix b/module.nix similarity index 64% rename from nixos-module.nix rename to module.nix index 10e8dd4..f70de96 100644 --- a/nixos-module.nix +++ b/module.nix @@ -1,7 +1,5 @@ { config, lib, pkgs, ... }: -with lib; - let cfg = config.services.teal-piper; @@ -25,7 +23,7 @@ let }; # Auto-derive callback URLs if SERVER_ROOT_URL is set - derivedSettings = optionalAttrs (cfg.settings ? SERVER_ROOT_URL) { + derivedSettings = lib.optionalAttrs (cfg.settings ? SERVER_ROOT_URL) { ATPROTO_CLIENT_ID = cfg.settings.ATPROTO_CLIENT_ID or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json"; ATPROTO_METADATA_URL = @@ -43,39 +41,38 @@ let settingsFile = settingsFormat.generate "teal-piper.env" finalSettings; in { + meta = { + maintainers = with lib.maintainers; + [ ]; # Add maintainer info when upstreaming + }; + options.services.teal-piper = { - enable = mkEnableOption "Piper - teal.fm scrobbler service"; - - package = mkOption { - type = types.package; - default = pkgs.teal-piper or (throw - "teal-piper package not found. Please add it to your nixpkgs overlay."); - defaultText = literalExpression "pkgs.teal-piper"; - description = "The piper package to use."; - }; + enable = lib.mkEnableOption "Piper - teal.fm scrobbler service"; - user = mkOption { - type = types.str; + package = lib.mkPackageOption pkgs "teal-piper" { }; + + user = lib.mkOption { + type = lib.types.str; default = "teal-piper"; description = "User account under which piper runs."; }; - group = mkOption { - type = types.str; + group = lib.mkOption { + type = lib.types.str; default = "teal-piper"; description = "Group under which piper runs."; }; - dataDir = mkOption { - type = types.path; + dataDir = lib.mkOption { + type = lib.types.path; default = "/var/lib/teal-piper"; description = "Directory where piper stores its database and data."; }; - settings = mkOption { - type = types.attrsOf types.str; + settings = lib.mkOption { + type = lib.types.attrsOf lib.types.str; default = { }; - example = literalExpression '' + example = lib.literalExpression '' { SERVER_PORT = "8080"; SERVER_HOST = "0.0.0.0"; @@ -86,17 +83,21 @@ in { description = '' Configuration for piper. These will be converted to environment variables. - Required settings: - - ATPROTO_CLIENT_SECRET_KEY (generate with: goat key generate -t P-256) - - ATPROTO_CLIENT_SECRET_KEY_ID - - SERVER_ROOT_URL + Required settings (set via environmentFile for security): + - SERVER_ROOT_URL: Public URL for OAuth callbacks + - SPOTIFY_CLIENT_ID: From Spotify Developer Dashboard + - SPOTIFY_CLIENT_SECRET: From Spotify Developer Dashboard + - ATPROTO_CLIENT_SECRET_KEY: P-256 private key (generate with: goat key generate -t P-256) + - ATPROTO_CLIENT_SECRET_KEY_ID: Unique persistent identifier Optional settings: - - SPOTIFY_CLIENT_SECRET - - LASTFM_API_KEY - - APPLE_MUSIC_TEAM_ID - - APPLE_MUSIC_KEY_ID - - APPLE_MUSIC_PRIVATE_KEY_PATH + - SERVER_PORT: Port to listen on (default: 8080) + - SERVER_HOST: Host to bind to (default: localhost) + - TRACKER_INTERVAL: Seconds between music checks (default: 30) + - LASTFM_API_KEY: For Last.fm integration + - APPLE_MUSIC_TEAM_ID: For Apple Music integration + - APPLE_MUSIC_KEY_ID: For Apple Music integration + - APPLE_MUSIC_PRIVATE_KEY_PATH: Path to Apple Music private key URLs are auto-derived from SERVER_ROOT_URL: - ATPROTO_CLIENT_ID @@ -106,27 +107,24 @@ in { ''; }; - environmentFile = mkOption { - type = types.nullOr types.path; + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; default = null; example = "/run/secrets/teal-piper.env"; description = '' Path to a file containing environment variables for secrets. - This file should contain: - + ``` SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_SECRET=your_spotify_client_secret ATPROTO_CLIENT_SECRET_KEY=your_p256_private_key ATPROTO_CLIENT_SECRET_KEY_ID=1758199756 - LASTFM_API_KEY=your_lastfm_key # optional - - This is the recommended way to configure secrets instead of putting them in settings. + LASTFM_API_KEY=your_lastfm_key + ``` ''; }; }; - config = mkIf cfg.enable { - # Create user and group + config = lib.mkIf cfg.enable { users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; @@ -136,7 +134,6 @@ in { users.groups.${cfg.group} = { }; - # Systemd service systemd.services.teal-piper = { description = "Piper - teal.fm scrobbler service"; after = [ "network-online.target" ]; @@ -173,7 +170,7 @@ in { # Load environment from generated file EnvironmentFile = [ settingsFile ] - ++ optional (cfg.environmentFile != null) cfg.environmentFile; + ++ lib.optional (cfg.environmentFile != null) cfg.environmentFile; # Start the service ExecStart = "${cfg.package}/bin/piper"; @@ -183,5 +180,25 @@ in { RestartSec = "10s"; }; }; + + assertions = [ + { + assertion = cfg.environmentFile != null + || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY); + message = + "services.teal-piper: ATPROTO_CLIENT_SECRET_KEY must be set via settings or environmentFile"; + } + { + assertion = cfg.environmentFile != null + || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY_ID); + message = + "services.teal-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFile"; + } + { + assertion = cfg.settings ? SERVER_ROOT_URL; + message = + "services.teal-piper: SERVER_ROOT_URL must be set in settings (e.g., https://piper.teal.fm)"; + } + ]; }; } diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..6559122 --- /dev/null +++ b/package.nix @@ -0,0 +1,46 @@ +{ lib, buildGoModule, pkg-config, sqlite }: + +buildGoModule rec { + pname = "teal-piper"; + version = "0.0.2"; + + src = ./.; + + vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; + + nativeBuildInputs = [ pkg-config ]; + buildInputs = [ sqlite ]; + + # CGO is required for sqlite3 support + env.CGO_ENABLED = 1; + + # Only build the cmd package + subPackages = [ "cmd" ]; + + # Strip debug symbols for smaller binary size + ldflags = [ "-s" "-w" ]; + + # Rename binary from 'cmd' to 'piper' for better UX + postInstall = '' + mv $out/bin/cmd $out/bin/piper + ''; + + meta = { + description = "Music scrobbler service for ATProto"; + longDescription = '' + Piper is a teal.fm tool that scrobbles music plays from various + music providers (Spotify, Apple Music, Last.fm) to ATProto Personal + Data Servers using the teal.fm lexicons. + + It runs as a web service that periodically checks configured music + services for currently playing tracks and submits them to your + ATProto PDS for social music listening features. + ''; + homepage = "https://github.com/teal-fm/piper"; + changelog = "https://github.com/teal-fm/piper/releases/tag/v${version}"; + license = lib.licenses.mit; + maintainers = [ ]; + mainProgram = "piper"; + platforms = lib.platforms.unix; + }; +} -- 2.51.2 From 98db17c56be4ee92fba89b4315bdad7f9eb14899 Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Sat, 3 Jan 2026 11:33:01 -0500 Subject: [PATCH 03/20] fix: port type --- module.nix | 164 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 100 insertions(+), 64 deletions(-) diff --git a/module.nix b/module.nix index f70de96..3f16eb9 100644 --- a/module.nix +++ b/module.nix @@ -1,29 +1,15 @@ { config, lib, pkgs, ... }: let + inherit (lib) + mkEnableOption mkIf mkOption mkPackageOption types literalExpression; + cfg = config.services.teal-piper; - # Helper function to generate environment variables settingsFormat = pkgs.formats.keyValue { }; - # Auto-derive URLs from SERVER_ROOT_URL if not explicitly set - defaultSettings = { - SERVER_PORT = cfg.settings.SERVER_PORT or 8080; - SERVER_HOST = cfg.settings.SERVER_HOST or "localhost"; - DB_PATH = cfg.settings.DB_PATH or "${cfg.dataDir}/piper.db"; - TRACKER_INTERVAL = cfg.settings.TRACKER_INTERVAL or 30; - - # Spotify defaults - SPOTIFY_AUTH_URL = - cfg.settings.SPOTIFY_AUTH_URL or "https://accounts.spotify.com/authorize"; - SPOTIFY_TOKEN_URL = - cfg.settings.SPOTIFY_TOKEN_URL or "https://accounts.spotify.com/api/token"; - SPOTIFY_SCOPES = - cfg.settings.SPOTIFY_SCOPES or "user-read-currently-playing user-read-email"; - }; - # Auto-derive callback URLs if SERVER_ROOT_URL is set - derivedSettings = lib.optionalAttrs (cfg.settings ? SERVER_ROOT_URL) { + derivedSettings = lib.optionalAttrs (cfg.settings.SERVER_ROOT_URL != null) { ATPROTO_CLIENT_ID = cfg.settings.ATPROTO_CLIENT_ID or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json"; ATPROTO_METADATA_URL = @@ -34,97 +20,147 @@ let cfg.settings.CALLBACK_SPOTIFY or "${cfg.settings.SERVER_ROOT_URL}/callback/spotify"; }; - # Merge user settings with defaults and derived settings - finalSettings = defaultSettings // cfg.settings // derivedSettings; + dbPathDefault = lib.optionalAttrs (cfg.settings.DB_PATH == null) { + DB_PATH = "${cfg.dataDir}/piper.db"; + }; - # Create environment file + finalSettings = lib.filterAttrs (_: v: v != null) + (cfg.settings // derivedSettings // dbPathDefault); settingsFile = settingsFormat.generate "teal-piper.env" finalSettings; in { meta = { - maintainers = with lib.maintainers; - [ ]; # Add maintainer info when upstreaming + maintainers = with lib.maintainers; [ ]; # TODO: }; options.services.teal-piper = { - enable = lib.mkEnableOption "Piper - teal.fm scrobbler service"; + enable = mkEnableOption "Piper - teal.fm scrobbler service"; - package = lib.mkPackageOption pkgs "teal-piper" { }; + package = mkPackageOption pkgs "teal-piper" { }; - user = lib.mkOption { - type = lib.types.str; + user = mkOption { + type = types.str; default = "teal-piper"; description = "User account under which piper runs."; }; - group = lib.mkOption { - type = lib.types.str; + group = mkOption { + type = types.str; default = "teal-piper"; description = "Group under which piper runs."; }; - dataDir = lib.mkOption { - type = lib.types.path; + dataDir = mkOption { + type = types.path; default = "/var/lib/teal-piper"; description = "Directory where piper stores its database and data."; }; - settings = lib.mkOption { - type = lib.types.attrsOf lib.types.str; + settings = mkOption { + type = types.submodule { + freeformType = types.attrsOf + (types.oneOf [ (types.nullOr types.str) types.int types.port ]); + + options = { + SERVER_PORT = mkOption { + type = types.port; + default = 8080; + description = "Port to listen on."; + }; + + SERVER_HOST = mkOption { + type = types.str; + default = "localhost"; + description = "Host to bind to."; + }; + + SERVER_ROOT_URL = mkOption { + type = types.nullOr types.str; + default = null; + example = "https://piper.teal.fm"; + description = '' + Public URL for OAuth callbacks. Required for OAuth flows. + + Auto-derives the following URLs if not explicitly set: + - ATPROTO_CLIENT_ID + - ATPROTO_METADATA_URL + - ATPROTO_CALLBACK_URL + - CALLBACK_SPOTIFY + ''; + }; + + DB_PATH = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to SQLite database file. + Defaults to {dataDir}/piper.db if not set. + ''; + }; + + TRACKER_INTERVAL = mkOption { + type = types.int; + default = 30; + description = "Seconds between music playback checks."; + }; + + # Spotify defaults + SPOTIFY_AUTH_URL = mkOption { + type = types.str; + default = "https://accounts.spotify.com/authorize"; + description = "Spotify authorization endpoint."; + }; + + SPOTIFY_TOKEN_URL = mkOption { + type = types.str; + default = "https://accounts.spotify.com/api/token"; + description = "Spotify token endpoint."; + }; + + SPOTIFY_SCOPES = mkOption { + type = types.str; + default = "user-read-currently-playing user-read-email"; + description = "Spotify OAuth scopes to request."; + }; + }; + }; + default = { }; - example = lib.literalExpression '' + + example = literalExpression '' { - SERVER_PORT = "8080"; - SERVER_HOST = "0.0.0.0"; + SERVER_PORT = 8080; + SERVER_HOST = "localhost"; SERVER_ROOT_URL = "https://piper.teal.fm"; - TRACKER_INTERVAL = "30"; + TRACKER_INTERVAL = 30; } ''; + description = '' Configuration for piper. These will be converted to environment variables. - - Required settings (set via environmentFile for security): - - SERVER_ROOT_URL: Public URL for OAuth callbacks - - SPOTIFY_CLIENT_ID: From Spotify Developer Dashboard - - SPOTIFY_CLIENT_SECRET: From Spotify Developer Dashboard - - ATPROTO_CLIENT_SECRET_KEY: P-256 private key (generate with: goat key generate -t P-256) - - ATPROTO_CLIENT_SECRET_KEY_ID: Unique persistent identifier - - Optional settings: - - SERVER_PORT: Port to listen on (default: 8080) - - SERVER_HOST: Host to bind to (default: localhost) - - TRACKER_INTERVAL: Seconds between music checks (default: 30) - - LASTFM_API_KEY: For Last.fm integration - - APPLE_MUSIC_TEAM_ID: For Apple Music integration - - APPLE_MUSIC_KEY_ID: For Apple Music integration - - APPLE_MUSIC_PRIVATE_KEY_PATH: Path to Apple Music private key - - URLs are auto-derived from SERVER_ROOT_URL: - - ATPROTO_CLIENT_ID - - ATPROTO_METADATA_URL - - ATPROTO_CALLBACK_URL - - CALLBACK_SPOTIFY ''; }; - environmentFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; + environmentFile = mkOption { + type = types.nullOr types.path; default = null; example = "/run/secrets/teal-piper.env"; description = '' Path to a file containing environment variables for secrets. + + Example content: ``` SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_SECRET=your_spotify_client_secret ATPROTO_CLIENT_SECRET_KEY=your_p256_private_key - ATPROTO_CLIENT_SECRET_KEY_ID=1758199756 + ATPROTO_CLIENT_SECRET_KEY_ID=1234567890 LASTFM_API_KEY=your_lastfm_key ``` ''; }; }; - config = lib.mkIf cfg.enable { + config = mkIf cfg.enable { users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; @@ -195,7 +231,7 @@ in { "services.teal-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFile"; } { - assertion = cfg.settings ? SERVER_ROOT_URL; + assertion = cfg.settings.SERVER_ROOT_URL != null; message = "services.teal-piper: SERVER_ROOT_URL must be set in settings (e.g., https://piper.teal.fm)"; } -- 2.51.2 From bd25c561e82428d8b8fb06cfb9a4a12edd82239a Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:16:49 -0500 Subject: [PATCH 04/20] refactor: nix derivation cleanup/consolidation --- default.nix | 37 +++++++++++++++++++++++++++++++++++-- flake.nix | 12 +++++------- module.nix | 1 + package.nix | 46 ---------------------------------------------- 4 files changed, 41 insertions(+), 55 deletions(-) delete mode 100644 package.nix diff --git a/default.nix b/default.nix index 7e97d10..c3bb9dd 100644 --- a/default.nix +++ b/default.nix @@ -1,3 +1,36 @@ -{ pkgs ? import { } }: +{ lib, buildGoModule, sqlite }: -pkgs.callPackage ./package.nix { } +buildGoModule rec { + pname = "teal-piper"; + version = "0.0.3"; + src = ./.; + vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; + buildInputs = [ sqlite ]; + + env.CGO_ENABLED = 1; + subPackages = [ "cmd" ]; + ldflags = [ "-s" "-w" ]; + + postInstall = '' + mv $out/bin/cmd $out/bin/piper + ''; + + meta = { + description = "Music scrobbler service for ATProto"; + longDescription = '' + Piper is a teal.fm tool that scrobbles music plays from various + music providers (Spotify, Apple Music, Last.fm) to ATProto Personal + Data Servers using the teal.fm lexicons. + + It runs as a web service that periodically checks configured music + services for currently playing tracks and submits them to your + ATProto PDS for social music listening features. + ''; + homepage = "https://github.com/teal-fm/piper"; + changelog = "https://github.com/teal-fm/piper/releases/tag/v${version}"; + license = lib.licenses.mit; + maintainers = [ ]; + mainProgram = "piper"; + platforms = lib.platforms.unix; + }; +} diff --git a/flake.nix b/flake.nix index d0e17fd..02c0702 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,5 @@ { description = "Piper - A teal.fm scrobbler service for ATProto"; - inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; outputs = { self, nixpkgs }: @@ -16,8 +15,8 @@ packages = forAllSystems (system: let pkgs = nixpkgsFor.${system}; in { - default = pkgs.callPackage ./package.nix { }; - teal-piper = pkgs.callPackage ./package.nix { }; + default = pkgs.callPackage ./default.nix { }; + teal-piper = pkgs.callPackage ./default.nix { }; }); apps = forAllSystems (system: @@ -32,16 +31,15 @@ devShells = forAllSystems (system: let pkgs = nixpkgsFor.${system}; in { - default = pkgs.mkShell { - buildInputs = with pkgs; [ go air nodejs sqlite pkg-config ]; - }; + default = + pkgs.mkShell { buildInputs = with pkgs; [ go air nodejs sqlite ]; }; }); nixosModules.default = import ./module.nix; nixosModules.teal-piper = import ./module.nix; overlays.default = final: prev: { - teal-piper = final.callPackage ./package.nix { }; + teal-piper = final.callPackage ./default.nix { }; }; }; } diff --git a/module.nix b/module.nix index 3f16eb9..12e1274 100644 --- a/module.nix +++ b/module.nix @@ -141,6 +141,7 @@ in { ''; }; + # TODO: maybe change to `environmentFiles` environmentFile = mkOption { type = types.nullOr types.path; default = null; diff --git a/package.nix b/package.nix deleted file mode 100644 index 6559122..0000000 --- a/package.nix +++ /dev/null @@ -1,46 +0,0 @@ -{ lib, buildGoModule, pkg-config, sqlite }: - -buildGoModule rec { - pname = "teal-piper"; - version = "0.0.2"; - - src = ./.; - - vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; - - nativeBuildInputs = [ pkg-config ]; - buildInputs = [ sqlite ]; - - # CGO is required for sqlite3 support - env.CGO_ENABLED = 1; - - # Only build the cmd package - subPackages = [ "cmd" ]; - - # Strip debug symbols for smaller binary size - ldflags = [ "-s" "-w" ]; - - # Rename binary from 'cmd' to 'piper' for better UX - postInstall = '' - mv $out/bin/cmd $out/bin/piper - ''; - - meta = { - description = "Music scrobbler service for ATProto"; - longDescription = '' - Piper is a teal.fm tool that scrobbles music plays from various - music providers (Spotify, Apple Music, Last.fm) to ATProto Personal - Data Servers using the teal.fm lexicons. - - It runs as a web service that periodically checks configured music - services for currently playing tracks and submits them to your - ATProto PDS for social music listening features. - ''; - homepage = "https://github.com/teal-fm/piper"; - changelog = "https://github.com/teal-fm/piper/releases/tag/v${version}"; - license = lib.licenses.mit; - maintainers = [ ]; - mainProgram = "piper"; - platforms = lib.platforms.unix; - }; -} -- 2.51.2 From 342241cd2294c90038a9b9073c6c0014d0ad68ef Mon Sep 17 00:00:00 2001 From: ptdewey <57921252+ptdewey@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:29:19 -0500 Subject: [PATCH 05/20] refactor: rename nix package -> 'tealfm-piper' --- default.nix | 15 +++++---------- flake.nix | 8 ++++---- module.nix | 30 ++++++++++++++---------------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/default.nix b/default.nix index c3bb9dd..7086a20 100644 --- a/default.nix +++ b/default.nix @@ -1,12 +1,11 @@ { lib, buildGoModule, sqlite }: buildGoModule rec { - pname = "teal-piper"; + pname = "tealfm-piper"; version = "0.0.3"; src = ./.; vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; buildInputs = [ sqlite ]; - env.CGO_ENABLED = 1; subPackages = [ "cmd" ]; ldflags = [ "-s" "-w" ]; @@ -15,21 +14,17 @@ buildGoModule rec { mv $out/bin/cmd $out/bin/piper ''; - meta = { - description = "Music scrobbler service for ATProto"; + meta = with lib; { + description = "Music scrobbler service for teal.fm"; longDescription = '' Piper is a teal.fm tool that scrobbles music plays from various music providers (Spotify, Apple Music, Last.fm) to ATProto Personal Data Servers using the teal.fm lexicons. - - It runs as a web service that periodically checks configured music - services for currently playing tracks and submits them to your - ATProto PDS for social music listening features. ''; homepage = "https://github.com/teal-fm/piper"; changelog = "https://github.com/teal-fm/piper/releases/tag/v${version}"; - license = lib.licenses.mit; - maintainers = [ ]; + license = licenses.mit; + maintainers = with maintainers; [ ptdewey ]; mainProgram = "piper"; platforms = lib.platforms.unix; }; diff --git a/flake.nix b/flake.nix index 02c0702..7ac48a6 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Piper - A teal.fm scrobbler service for ATProto"; + description = "Piper - A scrobbler service for teal.fm"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; outputs = { self, nixpkgs }: @@ -16,7 +16,7 @@ let pkgs = nixpkgsFor.${system}; in { default = pkgs.callPackage ./default.nix { }; - teal-piper = pkgs.callPackage ./default.nix { }; + tealfm-piper = pkgs.callPackage ./default.nix { }; }); apps = forAllSystems (system: @@ -36,10 +36,10 @@ }); nixosModules.default = import ./module.nix; - nixosModules.teal-piper = import ./module.nix; + nixosModules.tealfm-piper = import ./module.nix; overlays.default = final: prev: { - teal-piper = final.callPackage ./default.nix { }; + tealfm-piper = final.callPackage ./default.nix { }; }; }; } diff --git a/module.nix b/module.nix index 12e1274..6c2cc33 100644 --- a/module.nix +++ b/module.nix @@ -4,7 +4,7 @@ let inherit (lib) mkEnableOption mkIf mkOption mkPackageOption types literalExpression; - cfg = config.services.teal-piper; + cfg = config.services.tealfm-piper; settingsFormat = pkgs.formats.keyValue { }; @@ -26,33 +26,31 @@ let finalSettings = lib.filterAttrs (_: v: v != null) (cfg.settings // derivedSettings // dbPathDefault); - settingsFile = settingsFormat.generate "teal-piper.env" finalSettings; + settingsFile = settingsFormat.generate "tealfm-piper.env" finalSettings; in { - meta = { - maintainers = with lib.maintainers; [ ]; # TODO: - }; + meta = { maintainers = with lib.maintainers; [ ptdewey ]; }; - options.services.teal-piper = { + options.services.tealfm-piper = { enable = mkEnableOption "Piper - teal.fm scrobbler service"; - package = mkPackageOption pkgs "teal-piper" { }; + package = mkPackageOption pkgs "tealfm-piper" { }; user = mkOption { type = types.str; - default = "teal-piper"; + default = "tealfm-piper"; description = "User account under which piper runs."; }; group = mkOption { type = types.str; - default = "teal-piper"; + default = "tealfm-piper"; description = "Group under which piper runs."; }; dataDir = mkOption { type = types.path; - default = "/var/lib/teal-piper"; + default = "/var/lib/tealfm-piper"; description = "Directory where piper stores its database and data."; }; @@ -145,7 +143,7 @@ in { environmentFile = mkOption { type = types.nullOr types.path; default = null; - example = "/run/secrets/teal-piper.env"; + example = "/run/secrets/tealfm-piper.env"; description = '' Path to a file containing environment variables for secrets. @@ -171,7 +169,7 @@ in { users.groups.${cfg.group} = { }; - systemd.services.teal-piper = { + systemd.services.tealfm-piper = { description = "Piper - teal.fm scrobbler service"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; @@ -199,7 +197,7 @@ in { # Allow write access to data directory ReadWritePaths = [ cfg.dataDir ]; - StateDirectory = "teal-piper"; + StateDirectory = "tealfm-piper"; StateDirectoryMode = "0700"; # Working directory @@ -223,18 +221,18 @@ in { assertion = cfg.environmentFile != null || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY); message = - "services.teal-piper: ATPROTO_CLIENT_SECRET_KEY must be set via settings or environmentFile"; + "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY must be set via settings or environmentFile"; } { assertion = cfg.environmentFile != null || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY_ID); message = - "services.teal-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFile"; + "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFile"; } { assertion = cfg.settings.SERVER_ROOT_URL != null; message = - "services.teal-piper: SERVER_ROOT_URL must be set in settings (e.g., https://piper.teal.fm)"; + "services.tealfm-piper: SERVER_ROOT_URL must be set in settings (e.g., https://piper.teal.fm)"; } ]; }; -- 2.51.2 From cfa864c918b29fec67b3a22fcf01e0f5a7978862 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sat, 3 Jan 2026 17:00:57 -0500 Subject: [PATCH 06/20] chore: update nix go modules vendor hash --- default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/default.nix b/default.nix index 7086a20..8ec5c8f 100644 --- a/default.nix +++ b/default.nix @@ -4,7 +4,7 @@ buildGoModule rec { pname = "tealfm-piper"; version = "0.0.3"; src = ./.; - vendorHash = "sha256-gYlVWk1TOUOB2J49smq9TyGw/6AQdyP/A6tzJsfe3kI="; + vendorHash = "sha256-poQutY1V8X6BdmPMXdQuPWIWE/j3xNoEp4PKSimj2bA="; buildInputs = [ sqlite ]; env.CGO_ENABLED = 1; subPackages = [ "cmd" ]; -- 2.51.2 From a7688a6fc76b652aed5f8a91da9a0c194848c4c8 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sun, 4 Jan 2026 15:27:55 -0500 Subject: [PATCH 07/20] refactor: swap to fetchFromGitHub for package install - still builds from local source for local dev with nix run --- default.nix | 31 ------------------------------- flake.nix | 8 ++++---- package.nix | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 35 deletions(-) delete mode 100644 default.nix create mode 100644 package.nix diff --git a/default.nix b/default.nix deleted file mode 100644 index 8ec5c8f..0000000 --- a/default.nix +++ /dev/null @@ -1,31 +0,0 @@ -{ lib, buildGoModule, sqlite }: - -buildGoModule rec { - pname = "tealfm-piper"; - version = "0.0.3"; - src = ./.; - vendorHash = "sha256-poQutY1V8X6BdmPMXdQuPWIWE/j3xNoEp4PKSimj2bA="; - buildInputs = [ sqlite ]; - env.CGO_ENABLED = 1; - subPackages = [ "cmd" ]; - ldflags = [ "-s" "-w" ]; - - postInstall = '' - mv $out/bin/cmd $out/bin/piper - ''; - - meta = with lib; { - description = "Music scrobbler service for teal.fm"; - longDescription = '' - Piper is a teal.fm tool that scrobbles music plays from various - music providers (Spotify, Apple Music, Last.fm) to ATProto Personal - Data Servers using the teal.fm lexicons. - ''; - homepage = "https://github.com/teal-fm/piper"; - changelog = "https://github.com/teal-fm/piper/releases/tag/v${version}"; - license = licenses.mit; - maintainers = with maintainers; [ ptdewey ]; - mainProgram = "piper"; - platforms = lib.platforms.unix; - }; -} diff --git a/flake.nix b/flake.nix index 7ac48a6..e28c49f 100644 --- a/flake.nix +++ b/flake.nix @@ -15,8 +15,9 @@ packages = forAllSystems (system: let pkgs = nixpkgsFor.${system}; in { - default = pkgs.callPackage ./default.nix { }; - tealfm-piper = pkgs.callPackage ./default.nix { }; + tealfm-piper = pkgs.callPackage ./package.nix { }; + # Local development build + default = pkgs.callPackage ./package.nix { source = ./.; }; }); apps = forAllSystems (system: @@ -36,10 +37,9 @@ }); nixosModules.default = import ./module.nix; - nixosModules.tealfm-piper = import ./module.nix; overlays.default = final: prev: { - tealfm-piper = final.callPackage ./default.nix { }; + tealfm-piper = final.callPackage ./package.nix { }; }; }; } diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..9363189 --- /dev/null +++ b/package.nix @@ -0,0 +1,45 @@ +{ lib, buildGoModule, tailwindcss_4, fetchFromGitHub, source ? null }: + +let + # Default to GitHub source for builds. + # Override with `source = ./.` for local development. + defaultSource = fetchFromGitHub { + owner = "teal-fm"; + repo = "piper"; + rev = "ccb72442021bd9f6ed20acc63f9703cf475b0f51"; + hash = "sha256-wXA2RnvQ0J0QwUeDIg2gLRI2DNjgu07+QYjw5pRmyyI="; + }; +in buildGoModule { + pname = "tealfm-piper"; + version = "0.0.3"; + + src = if source != null then source else defaultSource; + + vendorHash = "sha256-poQutY1V8X6BdmPMXdQuPWIWE/j3xNoEp4PKSimj2bA="; + + nativeBuildInputs = [ tailwindcss_4 ]; + + env.CGO_ENABLED = 1; + + subPackages = [ "cmd" ]; + + ldflags = [ "-s" "-w" ]; + + postBuild = '' + cp -r ./pages/templates $out/ + cp -r ./pages/static $out/ + tailwindcss -i $out/static/base.css -o $out/static/main.css -m + ''; + + postInstall = '' + mv $out/bin/cmd $out/bin/piper + ''; + + meta = with lib; { + description = "Music scrobbler service for teal.fm"; + homepage = "https://github.com/teal-fm/piper"; + license = licenses.mit; + maintainers = with maintainers; [ ptdewey ]; + mainProgram = "piper"; + }; +} -- 2.51.2 From ee07d25de6e0a115ac2c30cc15342a5c866e19c2 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sun, 4 Jan 2026 16:04:51 -0500 Subject: [PATCH 08/20] feat: change to environmentFiles in module.nix --- module.nix | 51 ++++++++++++++++----------------------------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/module.nix b/module.nix index 6c2cc33..30ed464 100644 --- a/module.nix +++ b/module.nix @@ -8,7 +8,6 @@ let settingsFormat = pkgs.formats.keyValue { }; - # Auto-derive callback URLs if SERVER_ROOT_URL is set derivedSettings = lib.optionalAttrs (cfg.settings.SERVER_ROOT_URL != null) { ATPROTO_CLIENT_ID = cfg.settings.ATPROTO_CLIENT_ID or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json"; @@ -102,7 +101,6 @@ in { description = "Seconds between music playback checks."; }; - # Spotify defaults SPOTIFY_AUTH_URL = mkOption { type = types.str; default = "https://accounts.spotify.com/authorize"; @@ -139,22 +137,18 @@ in { ''; }; - # TODO: maybe change to `environmentFiles` - environmentFile = mkOption { - type = types.nullOr types.path; - default = null; - example = "/run/secrets/tealfm-piper.env"; + environmentFiles = mkOption { + type = types.listOf types.path; + default = [ ]; + example = literalExpression '' + [ + "/run/secrets/tealfm-piper.env" + "/run/secrets/tealfm-piper-apple-music.env" + ] + ''; description = '' - Path to a file containing environment variables for secrets. - - Example content: - ``` - SPOTIFY_CLIENT_ID=your_spotify_client_id - SPOTIFY_CLIENT_SECRET=your_spotify_client_secret - ATPROTO_CLIENT_SECRET_KEY=your_p256_private_key - ATPROTO_CLIENT_SECRET_KEY_ID=1234567890 - LASTFM_API_KEY=your_lastfm_key - ``` + List of files containing environment variables for secrets. + Files are loaded in order, with later files overriding earlier ones. ''; }; }; @@ -179,8 +173,6 @@ in { Type = "simple"; User = cfg.user; Group = cfg.group; - - # Security hardening NoNewPrivileges = true; PrivateTmp = true; PrivateDevices = true; @@ -194,23 +186,12 @@ in { RestrictRealtime = true; RestrictSUIDSGID = true; LockPersonality = true; - - # Allow write access to data directory ReadWritePaths = [ cfg.dataDir ]; StateDirectory = "tealfm-piper"; StateDirectoryMode = "0700"; - - # Working directory WorkingDirectory = cfg.dataDir; - - # Load environment from generated file - EnvironmentFile = [ settingsFile ] - ++ lib.optional (cfg.environmentFile != null) cfg.environmentFile; - - # Start the service + EnvironmentFile = [ settingsFile ] ++ cfg.environmentFiles; ExecStart = "${cfg.package}/bin/piper"; - - # Restart policy Restart = "on-failure"; RestartSec = "10s"; }; @@ -218,16 +199,16 @@ in { assertions = [ { - assertion = cfg.environmentFile != null + assertion = (cfg.environmentFiles != [ ]) || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY); message = - "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY must be set via settings or environmentFile"; + "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY must be set via settings or environmentFiles"; } { - assertion = cfg.environmentFile != null + assertion = (cfg.environmentFiles != [ ]) || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY_ID); message = - "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFile"; + "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFiles"; } { assertion = cfg.settings.SERVER_ROOT_URL != null; -- 2.51.2 From b58b01dd9f7fe42e790f49be9ab2ca38185aef14 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sun, 4 Jan 2026 16:13:32 -0500 Subject: [PATCH 09/20] feat: remove need for importing overlay --- flake.nix | 9 ++++++++- module.nix | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index e28c49f..f83cddf 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,14 @@ pkgs.mkShell { buildInputs = with pkgs; [ go air nodejs sqlite ]; }; }); - nixosModules.default = import ./module.nix; + nixosModules.default = { config, lib, pkgs, ... }: + let + piperPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.tealfm-piper; + in { + imports = [ + (import ./module.nix { defaultPackage = piperPackage; }) + ]; + }; overlays.default = final: prev: { tealfm-piper = final.callPackage ./package.nix { }; diff --git a/module.nix b/module.nix index 30ed464..7781a91 100644 --- a/module.nix +++ b/module.nix @@ -1,8 +1,9 @@ +{ defaultPackage ? null }: { config, lib, pkgs, ... }: let inherit (lib) - mkEnableOption mkIf mkOption mkPackageOption types literalExpression; + mkEnableOption mkIf mkOption types literalExpression; cfg = config.services.tealfm-piper; @@ -33,7 +34,14 @@ in { options.services.tealfm-piper = { enable = mkEnableOption "Piper - teal.fm scrobbler service"; - package = mkPackageOption pkgs "tealfm-piper" { }; + package = mkOption { + type = types.package; + default = if defaultPackage != null + then defaultPackage + else pkgs.tealfm-piper; + defaultText = literalExpression "pkgs.tealfm-piper"; + description = "The piper package to use."; + }; user = mkOption { type = types.str; -- 2.51.2 From c96ed11215d42a99ed4782fd88c56d992ae94bf7 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sun, 4 Jan 2026 16:26:57 -0500 Subject: [PATCH 10/20] refactor: general nix cleanup --- .gitignore | 3 ++- flake.nix | 34 ++++++++++++---------------------- module.nix | 6 +++--- package.nix | 21 ++++++++------------- 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 6243d21..a8282e2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ jwk*.json **.bak .idea AM_AUTHKEY.p8 -.DS_Store \ No newline at end of file +.DS_Store +result diff --git a/flake.nix b/flake.nix index f83cddf..9c9c3a1 100644 --- a/flake.nix +++ b/flake.nix @@ -10,40 +10,30 @@ "x86_64-darwin" "aarch64-darwin" ]; - nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); in { packages = forAllSystems (system: - let pkgs = nixpkgsFor.${system}; + let pkgs = import nixpkgs { inherit system; }; in { tealfm-piper = pkgs.callPackage ./package.nix { }; - # Local development build default = pkgs.callPackage ./package.nix { source = ./.; }; }); - apps = forAllSystems (system: - let piper = self.packages.${system}.default; - in { - default = { - type = "app"; - program = "${piper}/bin/piper"; - }; - }); + apps = forAllSystems (system: { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/piper"; + }; + }); devShells = forAllSystems (system: - let pkgs = nixpkgsFor.${system}; + let pkgs = import nixpkgs { inherit system; }; in { - default = - pkgs.mkShell { buildInputs = with pkgs; [ go air nodejs sqlite ]; }; + default = pkgs.mkShell { + buildInputs = with pkgs; [ go air nodejs sqlite ]; + }; }); - nixosModules.default = { config, lib, pkgs, ... }: - let - piperPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.tealfm-piper; - in { - imports = [ - (import ./module.nix { defaultPackage = piperPackage; }) - ]; - }; + nixosModules.default = import ./module.nix { inherit self; }; overlays.default = final: prev: { tealfm-piper = final.callPackage ./package.nix { }; diff --git a/module.nix b/module.nix index 7781a91..8035332 100644 --- a/module.nix +++ b/module.nix @@ -1,4 +1,4 @@ -{ defaultPackage ? null }: +{ self ? null }: { config, lib, pkgs, ... }: let @@ -36,8 +36,8 @@ in { package = mkOption { type = types.package; - default = if defaultPackage != null - then defaultPackage + default = if self != null + then self.packages.${pkgs.stdenv.hostPlatform.system}.tealfm-piper else pkgs.tealfm-piper; defaultText = literalExpression "pkgs.tealfm-piper"; description = "The piper package to use."; diff --git a/package.nix b/package.nix index 9363189..c3da375 100644 --- a/package.nix +++ b/package.nix @@ -1,19 +1,14 @@ -{ lib, buildGoModule, tailwindcss_4, fetchFromGitHub, source ? null }: - -let - # Default to GitHub source for builds. - # Override with `source = ./.` for local development. - defaultSource = fetchFromGitHub { - owner = "teal-fm"; - repo = "piper"; - rev = "ccb72442021bd9f6ed20acc63f9703cf475b0f51"; - hash = "sha256-wXA2RnvQ0J0QwUeDIg2gLRI2DNjgu07+QYjw5pRmyyI="; - }; -in buildGoModule { +{ lib, buildGoModule, tailwindcss_4, fetchFromGitHub, source ? fetchFromGitHub { + owner = "teal-fm"; + repo = "piper"; + rev = "ccb72442021bd9f6ed20acc63f9703cf475b0f51"; + hash = "sha256-wXA2RnvQ0J0QwUeDIg2gLRI2DNjgu07+QYjw5pRmyyI="; +} }: +buildGoModule { pname = "tealfm-piper"; version = "0.0.3"; - src = if source != null then source else defaultSource; + src = source; vendorHash = "sha256-poQutY1V8X6BdmPMXdQuPWIWE/j3xNoEp4PKSimj2bA="; -- 2.51.2 From bf80a89def70b1d520140a1863bed57128cba036 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sun, 4 Jan 2026 16:33:35 -0500 Subject: [PATCH 11/20] feat: add `ALLOWED_DIDS` support to nix module --- flake.nix | 5 ++--- module.nix | 28 ++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/flake.nix b/flake.nix index 9c9c3a1..e3c02a6 100644 --- a/flake.nix +++ b/flake.nix @@ -28,9 +28,8 @@ devShells = forAllSystems (system: let pkgs = import nixpkgs { inherit system; }; in { - default = pkgs.mkShell { - buildInputs = with pkgs; [ go air nodejs sqlite ]; - }; + default = + pkgs.mkShell { buildInputs = with pkgs; [ go air nodejs sqlite ]; }; }); nixosModules.default = import ./module.nix { inherit self; }; diff --git a/module.nix b/module.nix index 8035332..aa6f87e 100644 --- a/module.nix +++ b/module.nix @@ -2,8 +2,7 @@ { config, lib, pkgs, ... }: let - inherit (lib) - mkEnableOption mkIf mkOption types literalExpression; + inherit (lib) mkEnableOption mkIf mkOption types literalExpression; cfg = config.services.tealfm-piper; @@ -24,8 +23,12 @@ let DB_PATH = "${cfg.dataDir}/piper.db"; }; + allowedDidsString = lib.optionalAttrs (cfg.settings.ALLOWED_DIDS != null) { + ALLOWED_DIDS = lib.concatStringsSep " " cfg.settings.ALLOWED_DIDS; + }; + finalSettings = lib.filterAttrs (_: v: v != null) - (cfg.settings // derivedSettings // dbPathDefault); + (cfg.settings // derivedSettings // dbPathDefault // allowedDidsString); settingsFile = settingsFormat.generate "tealfm-piper.env" finalSettings; in { @@ -36,9 +39,10 @@ in { package = mkOption { type = types.package; - default = if self != null - then self.packages.${pkgs.stdenv.hostPlatform.system}.tealfm-piper - else pkgs.tealfm-piper; + default = if self != null then + self.packages.${pkgs.stdenv.hostPlatform.system}.tealfm-piper + else + pkgs.tealfm-piper; defaultText = literalExpression "pkgs.tealfm-piper"; description = "The piper package to use."; }; @@ -126,6 +130,18 @@ in { default = "user-read-currently-playing user-read-email"; description = "Spotify OAuth scopes to request."; }; + + ALLOWED_DIDS = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + example = + literalExpression ''[ "did:plc:abcdefg" "did:web:example.com" ]''; + description = '' + List of ATProto DIDs allowed to sign in. + When set, restricts instance access to only these accounts. + Leave null to allow any ATProto account to sign in. + ''; + }; }; }; -- 2.51.2 From 33d086ecc47549e7d6bea6fb43d0bbac59968789 Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Tue, 6 Jan 2026 23:00:32 +0000 Subject: [PATCH 12/20] Fetch all scrobbles since last timestamp --- service/lastfm/lastfm.go | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/service/lastfm/lastfm.go b/service/lastfm/lastfm.go index 2609668..4668fb9 100644 --- a/service/lastfm/lastfm.go +++ b/service/lastfm/lastfm.go @@ -23,6 +23,8 @@ import ( const ( lastfmAPIBaseURL = "https://ws.audioscrobbler.com/2.0/" + downloadLimit = 200 + noTimestampLimit = 5 ) type Service struct { @@ -97,17 +99,42 @@ func (l *Service) loadUsernames() error { } // getRecentTracks fetches the most recent tracks for a given Last.fm user. -func (l *Service) getRecentTracks(ctx context.Context, username string, limit int) (*RecentTracksResponse, error) { +func (l *Service) getRecentTracks(ctx context.Context, username string) (*RecentTracksResponse, error) { if username == "" { return nil, fmt.Errorf("username cannot be empty") } + if l.db == nil { + return nil, fmt.Errorf("database connection is nil") + } + + user, err := l.db.GetUserByLastFM(username) + if err != nil { + return nil, fmt.Errorf("failed to get user ID for %s: %w", username, err) + } + + lastKnownTimestamp, err := l.db.GetLastKnownTimestamp(user.ID) + if err != nil { + return nil, fmt.Errorf("failed to get last scrobble timestamp for %s: %w", username, err) + } + params := url.Values{} params.Set("method", "user.getrecenttracks") params.Set("user", username) params.Set("api_key", l.apiKey) params.Set("format", "json") - params.Set("limit", strconv.Itoa(limit)) // Fetch a few more to handle duplicates/now playing + + if lastKnownTimestamp == nil { + // If no timestamp, then just get the {noTimestampLimit} most recent tracks + l.logger.Printf("no previous scrobble timestamp found for user %s. retrieving last 5 tracks", username) + params.Set("limit", strconv.Itoa(noTimestampLimit)) + } else { + // As last.fm returns tracks >= from, this will always return at leastthe user's most recent track, but should + // cover the (extremely remote) edge case where a user scrobbles multiple tracks within a single second + l.logger.Printf("retrieving all tracks for %s since last timestamp %s", username, lastKnownTimestamp.Format(time.RFC3339)) + params.Set("limit", strconv.Itoa(downloadLimit)) + params.Set("from", strconv.FormatInt(lastKnownTimestamp.Unix(), 10)) + } apiURL := lastfmAPIBaseURL + "?" + params.Encode() @@ -232,8 +259,7 @@ func (l *Service) fetchAllUserTracks(ctx context.Context) { // Fetch slightly more than 1 track to better handle edge cases // where the latest is 'now playing' or duplicates exist. - const fetchLimit = 5 - recentTracks, err := l.getRecentTracks(ctx, uname, fetchLimit) + recentTracks, err := l.getRecentTracks(ctx, uname) if err != nil { l.logger.Printf("Error fetching tracks for %s: %v", uname, err) fetchErrors <- fmt.Errorf("fetch failed for %s: %w", uname, err) // Report error @@ -332,7 +358,7 @@ func (l *Service) processTracks(ctx context.Context, username string, tracks []T // find last non-now-playing track var lastNonNowPlaying *Track - for i := len(tracks) - 1; i >= 0; i-- { + for i := 0; i <= len(tracks)-1; i++ { if tracks[i].Attr == nil || tracks[i].Attr.NowPlaying != "true" { lastNonNowPlaying = &tracks[i] break -- 2.51.2 From 50034ed6b2deac4ccf3eb7212ca3634fbac2b00b Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Wed, 7 Jan 2026 10:30:29 +0000 Subject: [PATCH 13/20] Switch to simpler loop statement Co-authored-by: PL Pery --- service/lastfm/lastfm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lastfm/lastfm.go b/service/lastfm/lastfm.go index 4668fb9..4656ae7 100644 --- a/service/lastfm/lastfm.go +++ b/service/lastfm/lastfm.go @@ -358,7 +358,7 @@ func (l *Service) processTracks(ctx context.Context, username string, tracks []T // find last non-now-playing track var lastNonNowPlaying *Track - for i := 0; i <= len(tracks)-1; i++ { + for i := range tracks { if tracks[i].Attr == nil || tracks[i].Attr.NowPlaying != "true" { lastNonNowPlaying = &tracks[i] break -- 2.51.2 From e7264d7c6d99d041ded8757acaced49959edef84 Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Sun, 11 Jan 2026 19:00:53 +0000 Subject: [PATCH 14/20] Set upload hash for uploaded AM tracks --- service/applemusic/applemusic.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go index def17fb..9da9442 100644 --- a/service/applemusic/applemusic.go +++ b/service/applemusic/applemusic.go @@ -3,6 +3,7 @@ package applemusic import ( "context" "crypto/ecdsa" + "crypto/sha256" "crypto/x509" "encoding/json" "encoding/pem" @@ -285,6 +286,19 @@ type AppleRecentTrack struct { } `json:"attributes"` } +// Generates a hash representing the track name, album name, and artists' concatenated names, +// to be used for comparing subsequent uploaded Apple Music tracks +func generateUploadHash(track *models.Track) string { + artists := make([]string, len(track.Artist)) + for i, artist := range track.Artist { + artists[i] = artist.Name + } + + input := track.Name + track.Album + strings.Join(artists, ",") + hash := sha256.Sum256([]byte(input)) + return fmt.Sprintf("am_uploaded_%x", hash) +} + type recentPlayedResponse struct { Data []AppleRecentTrack `json:"data"` } @@ -366,6 +380,10 @@ func (s *Service) toTrack(t AppleRecentTrack) *models.Track { Timestamp: time.Now().UTC(), } + if track.URL == "" { + track.URL = generateUploadHash(track) + } + if s.mbService != nil { hydrated, err := musicbrainz.HydrateTrack(s.mbService, *track) if err == nil && hydrated != nil { -- 2.51.2 From 840e4ae38649ccc1d1c7044fb727c7bfba3546d1 Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Sun, 11 Jan 2026 19:09:36 +0000 Subject: [PATCH 15/20] Whoops this should act on the incoming apple track --- service/applemusic/applemusic.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go index 9da9442..cff749b 100644 --- a/service/applemusic/applemusic.go +++ b/service/applemusic/applemusic.go @@ -288,13 +288,8 @@ type AppleRecentTrack struct { // Generates a hash representing the track name, album name, and artists' concatenated names, // to be used for comparing subsequent uploaded Apple Music tracks -func generateUploadHash(track *models.Track) string { - artists := make([]string, len(track.Artist)) - for i, artist := range track.Artist { - artists[i] = artist.Name - } - - input := track.Name + track.Album + strings.Join(artists, ",") +func generateUploadHash(track *AppleRecentTrack) string { + input := track.Attributes.Name + track.Attributes.AlbumName + track.Attributes.ArtistName hash := sha256.Sum256([]byte(input)) return fmt.Sprintf("am_uploaded_%x", hash) } @@ -381,7 +376,7 @@ func (s *Service) toTrack(t AppleRecentTrack) *models.Track { } if track.URL == "" { - track.URL = generateUploadHash(track) + track.URL = generateUploadHash(&t) } if s.mbService != nil { -- 2.51.2 From d9ab38ac4d9e31b89d146cdb76b7daed6b56be53 Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Sun, 11 Jan 2026 19:17:11 +0000 Subject: [PATCH 16/20] Add test for generateUploadHash --- service/applemusic/applemusic_test.go | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 service/applemusic/applemusic_test.go diff --git a/service/applemusic/applemusic_test.go b/service/applemusic/applemusic_test.go new file mode 100644 index 0000000..04a0d16 --- /dev/null +++ b/service/applemusic/applemusic_test.go @@ -0,0 +1,60 @@ +package applemusic + +import ( + "strings" + "testing" +) + +// Helper to create AppleRecentTrack for testing +func makeTestTrack(name, album, artist string) *AppleRecentTrack { + track := &AppleRecentTrack{} + track.Attributes.Name = name + track.Attributes.AlbumName = album + track.Attributes.ArtistName = artist + return track +} + +func TestGenerateUploadHash(t *testing.T) { + tests := []struct { + name string + track *AppleRecentTrack + wantHash string + }{ + { + name: "basic track", + track: makeTestTrack("Test Song", "Test Album", "Test Artist"), + wantHash: "am_uploaded_ec50bb20ebeddc6f04cb65bcff156fed3c59d7e311e3d3efbbea506c3f8ad5ae", + }, + { + name: "track with different data", + track: makeTestTrack("Collaboration", "Best Hits", "Artist One"), + wantHash: "am_uploaded_c387e87af520fd4dae9b9cb872bd9cfbc8f7a88ca1b49d874dc1045183ba43c3", + }, + { + name: "track with empty album", + track: makeTestTrack("Single Track", "", "Solo Artist"), + wantHash: "am_uploaded_f73402f47a46c5ff050b64a86d5a1fcbaa20a734a8963f458dca81be290731f7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateUploadHash(tt.track) + + // The hash should be prefixed with "am_uploaded_" so that it's clear it's an uploaded Apple Music track + if !strings.HasPrefix(got, "am_uploaded_") { + t.Errorf("generateUploadHash() = %v, want prefix 'am_uploaded_'", got) + } + + if got != tt.wantHash { + t.Errorf("generateUploadHash() = %v, want %v", got, tt.wantHash) + } + + // Hash is deterministic -- same track will return same hash + got2 := generateUploadHash(tt.track) + if got != got2 { + t.Errorf("generateUploadHash() is not deterministic: first=%v, second=%v", got, got2) + } + }) + } +} -- 2.51.2 From 1a828be1d54061da163997689b4a27ce383db63a Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Sun, 11 Jan 2026 19:37:23 +0000 Subject: [PATCH 17/20] Comment to explain --- service/applemusic/applemusic.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go index cff749b..20a6ce5 100644 --- a/service/applemusic/applemusic.go +++ b/service/applemusic/applemusic.go @@ -375,6 +375,8 @@ func (s *Service) toTrack(t AppleRecentTrack) *models.Track { Timestamp: time.Now().UTC(), } + // If an Apple Music track has no URL, it's an uploaded track; generate an uploadHash so that the + // track can be distinguished from other uploaded tracks if track.URL == "" { track.URL = generateUploadHash(&t) } -- 2.51.2 From 4ffb3ad3b5b2d8ed182d6541734323de69ec4b3b Mon Sep 17 00:00:00 2001 From: pdewey Date: Fri, 16 Jan 2026 19:41:44 -0500 Subject: [PATCH 18/20] docs: document nix usage --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 7da0c41..d07589c 100644 --- a/README.md +++ b/README.md @@ -112,3 +112,40 @@ We also provide a docker compose file to use to run piper locally. There are a f `DB_PATH` = `/db/piper.db` to persist your piper db through container restarts Make sure you have docker and docker compose installed, then you can run piper with `docker compose up` + +#### nix + +For local development, the flake provides a dev shell with all dependencies: `nix develop`. +You can also run piper directly with `nix run`. + +Piper can also be installed as a NixOS module. +Sensitive environment variables (Spotify, ATProto, Last.fm credentials) should be securely managed. + +Example nix module configuration (using [sops](https://github.com/getsops/sops) for secrets): + +```nix +{ config, ... }: + +{ + sops.secrets.piper = { + owner = "tealfm-piper"; + group = "tealfm-piper"; + sopsFile = ./sops/piper.env; + format = "dotenv"; + }; + + services.tealfm-piper = { + enable = true; + environmentFiles = [ config.sops.secrets.piper.path ]; + settings = { + SERVER_PORT = 19990; + SERVER_HOST = "localhost"; + SERVER_ROOT_URL = "https://piper.example.com"; + # Optional: DID allow list + ALLOWED_DIDS = [ "did:plc:tas6hj2xjrqben5653v5kohk" ]; + }; + }; +} +``` + +See [module.nix](./module.nix) for additional configuration options. -- 2.51.2 From 7c11e7cb8b1357a3a85efbe0a84f66106bdacd61 Mon Sep 17 00:00:00 2001 From: pdewey Date: Fri, 16 Jan 2026 19:46:36 -0500 Subject: [PATCH 19/20] docs: add nix instructions for installation with flake --- README.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d07589c..28da5fb 100644 --- a/README.md +++ b/README.md @@ -115,15 +115,34 @@ Make sure you have docker and docker compose installed, then you can run piper w #### nix -For local development, the flake provides a dev shell with all dependencies: `nix develop`. -You can also run piper directly with `nix run`. +For local development, the flake provides a dev shell with all dependencies: `nix develop`. You can also run piper directly with `nix run`. -Piper can also be installed as a NixOS module. -Sensitive environment variables (Spotify, ATProto, Last.fm credentials) should be securely managed. +Piper can be installed as a NixOS module. Add the flake to your system configuration: -Example nix module configuration (using [sops](https://github.com/getsops/sops) for secrets): +```nix +# flake.nix +{ + inputs = { + # ... + tealfm-piper.url = "github:teal-fm/piper"; + }; + outputs = { self, nixpkgs, tealfm-piper, ... }: { + nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { + # ... + modules = [ + # ... + ./modules/piper.nix # piper config + tealfm-piper.nixosModules.default # import piper module + ]; + }; + }; +} +``` + +Sensitive environment variables (Spotify, ATProto, Last.fm credentials) should be securely managed with a tool like [sops](https://github.com/getsops/sops): ```nix +# modules/piper.nix { config, ... }: { -- 2.51.2 From 25478e412556a1bf439726fcfb534d13ffe5a2b9 Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Sun, 18 Jan 2026 18:48:44 +0000 Subject: [PATCH 20/20] Fix out-of-date comment --- service/applemusic/applemusic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go index 20a6ce5..71bd2f8 100644 --- a/service/applemusic/applemusic.go +++ b/service/applemusic/applemusic.go @@ -286,7 +286,7 @@ type AppleRecentTrack struct { } `json:"attributes"` } -// Generates a hash representing the track name, album name, and artists' concatenated names, +// Generates a hash representing the track name, album name, and artist name, // to be used for comparing subsequent uploaded Apple Music tracks func generateUploadHash(track *AppleRecentTrack) string { input := track.Attributes.Name + track.Attributes.AlbumName + track.Attributes.ArtistName