diff --git a/flake.lock b/flake.lock index 8c701e3..6b1b14a 100644 --- a/flake.lock +++ b/flake.lock @@ -256,6 +256,52 @@ "type": "github" } }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1763662255, + "narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "042904167604c681a090c07eb6967b4dd4dae88c", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1764134915, + "narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "2c8df1383b32e5443c921f61224b198a2282a657", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, "root": { "inputs": { "google-cookie-retrieval": "google-cookie-retrieval", @@ -267,7 +313,33 @@ "nixpkgs": "nixpkgs_2", "nixpkgs-unstable": "nixpkgs-unstable", "plasma-manager": "plasma-manager", - "plasma-manager-unstable": "plasma-manager-unstable" + "plasma-manager-unstable": "plasma-manager-unstable", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1766021660, + "narHash": "sha256-UUfz7qWB1Rb2KjGVCimt//Jncv3TgJwffPqbzqpkmgY=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "19fa99be3409f55ec05e823c66c9769df7a8dd17", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index d92a75d..953845a 100644 --- a/flake.nix +++ b/flake.nix @@ -42,9 +42,27 @@ url = "github:Jovian-Experiments/Jovian-NixOS"; inputs.nixpkgs.follows = "nixpkgs-unstable"; }; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, nixpkgs-unstable, nixos-wsl, ... } @ inputs: let + outputs = { self, nixpkgs, nixpkgs-unstable, nixos-wsl, pyproject-nix, uv2nix, pyproject-build-systems, ... } @ inputs: let nixosModules = [ ./roles ] ++ [ @@ -56,7 +74,7 @@ system = prev.stdenv.hostPlatform.system; config.allowUnfree = true; }; - custom = prev.callPackage ./packages {}; + custom = prev.callPackage ./packages { inherit uv2nix pyproject-nix pyproject-build-systems; }; # Compatibility: bitwarden renamed to bitwarden-desktop in unstable bitwarden-desktop = prev.bitwarden-desktop or prev.bitwarden; }) @@ -84,7 +102,7 @@ system = prev.stdenv.hostPlatform.system; config.allowUnfree = true; }; - custom = prev.callPackage ./packages {}; + custom = prev.callPackage ./packages { inherit uv2nix pyproject-nix pyproject-build-systems; }; # Compatibility: bitwarden renamed to bitwarden-desktop in unstable bitwarden-desktop = prev.bitwarden-desktop or prev.bitwarden; }) @@ -117,7 +135,7 @@ }) ]; }; - custom = prev.callPackage ./packages {}; + custom = prev.callPackage ./packages { inherit uv2nix pyproject-nix pyproject-build-systems; }; # Compatibility: bitwarden renamed to bitwarden-desktop in unstable bitwarden-desktop = prev.bitwarden-desktop or prev.bitwarden; }) diff --git a/packages/default.nix b/packages/default.nix index a2cfea3..2049180 100644 --- a/packages/default.nix +++ b/packages/default.nix @@ -1,7 +1,8 @@ -{ pkgs, ... }: +{ pkgs, uv2nix ? null, pyproject-nix ? null, pyproject-build-systems ? null, ... }: { vulkanHDRLayer = pkgs.callPackage ./vulkan-hdr-layer {}; tea-rbw = pkgs.callPackage ./tea-rbw {}; app-launcher-server = pkgs.callPackage ./app-launcher-server {}; claude-code = pkgs.callPackage ./claude-code {}; + sendspin-cli = pkgs.callPackage ./sendspin-cli { inherit uv2nix pyproject-nix pyproject-build-systems; }; } diff --git a/packages/sendspin-cli/default.nix b/packages/sendspin-cli/default.nix new file mode 100644 index 0000000..df5701e --- /dev/null +++ b/packages/sendspin-cli/default.nix @@ -0,0 +1,91 @@ +{ pkgs +, lib +, fetchFromGitHub +, uv2nix ? null +, pyproject-nix ? null +, pyproject-build-systems ? null +}: + +# Simple package build +# Note: uv2nix would be ideal but requires uv.lock which sendspin-cli doesn't have yet +let + # Package aiosendspin from GitHub since it's only in nixpkgs-unstable + aiosendspin = pkgs.python312Packages.buildPythonPackage rec { + pname = "aiosendspin"; + version = "1.2.0"; + pyproject = true; + + src = fetchFromGitHub { + owner = "Sendspin"; + repo = "aiosendspin"; + rev = version; + sha256 = "sha256-3vTEfXeFqouPswRKST/9U7yg9ah7J9m2KAMoxaBZNR0="; + }; + + build-system = with pkgs.python312Packages; [ + hatchling + setuptools + ]; + + dependencies = with pkgs.python312Packages; [ + aiohttp + av + mashumaro + orjson + pillow + zeroconf + ]; + + pythonImportsCheck = [ "aiosendspin" ]; + + meta = { + description = "Async Python implementation of the Sendspin Protocol"; + homepage = "https://github.com/Sendspin-Protocol/aiosendspin"; + license = lib.licenses.asl20; + }; + }; + + python = pkgs.python312.withPackages (ps: with ps; [ + # Core dependencies from pyproject.toml + aiosendspin + av + numpy + qrcode + readchar + rich + sounddevice + setuptools + ]); +in +pkgs.stdenv.mkDerivation rec { + pname = "sendspin-cli"; + version = "0.0.0"; + + src = fetchFromGitHub { + owner = "Sendspin"; + repo = "sendspin-cli"; + rev = "main"; + sha256 = "sha256-z8ieaDHv4C6WNLpPGybhcfB+E6Jj/rCc7zSRpL6vdk0="; + }; + + buildInputs = [ python pkgs.portaudio pkgs.ffmpeg ]; + + installPhase = '' + mkdir -p $out/bin $out/lib + cp -r sendspin $out/lib/ + cat > $out/bin/sendspin <` +- Role-based configuration system with audio role at roles/audio/default.nix:1-41 +- User-level systemd services pattern demonstrated in machines/zix790prors/virtual-surround.nix:79-103 +- Python packaging pattern using `python3.withPackages` at packages/app-launcher-server/default.nix:1-10 + +**Sendspin-CLI Analysis:** +- Python 3.12+ application with setuptools build system +- Entry point: `sendspin.cli:main` (sendspin-cli/sendspin/cli.py:143) +- Dependencies: aiosendspin, av, numpy, qrcode, readchar, rich, sounddevice (sendspin-cli/pyproject.toml:16-23) +- Native dependencies needed: portaudio (for sounddevice), ffmpeg (for av) +- Supports headless mode via `--headless` flag (sendspin-cli/sendspin/cli.py:115-117) +- Device selection via `--audio-device ` (sendspin-cli/sendspin/cli.py:96-102) +- Device discovery via `--list-audio-devices` (sendspin-cli/sendspin/cli.py:15-34) +- Auto-discovers servers via mDNS unless `--url` specified (sendspin-cli/sendspin/cli.py:69-71) +- No `uv.lock` file currently in repository (only `pyproject.toml`) + +**Gap:** +- No uv2nix flake inputs (pyproject-nix, uv2nix, pyproject-build-systems) +- No sendspin-cli package definition +- No sendspin role or systemd service configuration + +## Desired End State + +After implementation completion: +1. **Package available**: `pkgs.custom.sendspin-cli` builds successfully with all dependencies +2. **Role available**: `roles.sendspin.enable = true` provides sendspin with configurable service +3. **Service template**: Systemd user service runs as graphical session user with configurable audio device +4. **Audio device compatibility**: Uses sendspin's native device specification (index or name prefix) +5. **Flexible user context**: Service can run as kodi, johno, or any graphical session user + +### Verification Commands: +```bash +# Package builds successfully +nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages --no-link | grep sendspin-cli + +# Package contains working executable +$(nix-build -E '(import {}).callPackage ./packages/sendspin-cli {}')/bin/sendspin --help + +# Service template is generated +nixos-rebuild dry-build --flake .#zix790prors 2>&1 | grep sendspin +``` + +## Key Discoveries + +### uv2nix Integration Points +- **No lock file**: sendspin-cli has `pyproject.toml` but no `uv.lock` - uv2nix will resolve from pyproject.toml +- **Native dependencies**: sounddevice and av require portaudio and ffmpeg in buildInputs +- **Workspace loading**: uv2nix.lib.workspace.loadWorkspace works with pyproject.toml-only projects +- **Build system**: Uses setuptools (declared in pyproject.toml:1-3) + +### Service Architecture +- **User services**: Must use `systemd.user.services` (not system services) for audio access +- **Automatic user detection**: User services run in the logged-in graphical user's session +- **PipeWire dependency**: Service must start after `pipewire.service` and `wireplumber.service` +- **Auto-restart**: Use `Restart=always` for persistent background operation + +### Audio Device Handling +- **Native format**: Sendspin expects device index (0, 1, 2) or name prefix ("AmazonBasics") +- **Discovery**: `sendspin --list-audio-devices` shows available devices +- **PipeWire independence**: No need to use PipeWire node names; sendspin queries via sounddevice library + +## What We're NOT Doing + +To prevent scope creep: + +1. **Not creating uv.lock**: Using pyproject.toml directly; lock file can be added upstream later +2. **Not implementing multi-instance configuration**: Providing single-instance template; machines can extend for multiple instances +3. **Not configuring specific machines**: Template only; zix790prors multi-instance setup is future work +4. **Not creating home-manager module**: Using system-level role with user services +5. **Not implementing server mode**: Client-only integration; `sendspin serve` can be added later +6. **Not auto-detecting graphical user**: Relying on systemd user service behavior; explicit user selection can be added later +7. **Not packaging dev dependencies**: Only runtime dependencies; test tools (mypy, ruff) excluded + +## Implementation Approach + +Use uv2nix to package sendspin-cli from its GitHub repository, accessing pyproject.toml for dependency resolution. Create a NixOS role following the spotifyd pattern with a systemd user service template. The service runs in the logged-in user's session (automatic user detection) and can be configured per-machine for different audio devices. + +**Key Technical Decisions:** +1. **uv2nix over python3.withPackages**: Better maintainability, automatic dependency resolution, aligns with upstream development +2. **System-level role with user services**: Follows existing patterns (virtual-surround.nix), enables per-user configuration +3. **Headless mode default**: Services always use `--headless`; TUI available via manual `sendspin` command +4. **mDNS discovery default**: No `--url` by default; let sendspin auto-discover servers on the network + +--- + +## Phase 1: Add uv2nix Flake Inputs + +### Overview +Add pyproject-nix, uv2nix, and pyproject-build-systems as flake inputs and thread them through to package definitions. + +### Changes Required + +#### 1. Flake Inputs +**File**: `flake.nix` +**Changes**: Add new inputs after existing inputs (after line 44) + +```nix +pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; +}; + +uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; +}; + +pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; +}; +``` + +#### 2. Outputs Signature +**File**: `flake.nix` +**Changes**: Update outputs function signature (line 47) + +```nix +# Before +outputs = { self, nixpkgs, nixpkgs-unstable, nixos-wsl, ... } @ inputs: + +# After +outputs = { self, nixpkgs, nixpkgs-unstable, nixos-wsl, pyproject-nix, uv2nix, pyproject-build-systems, ... } @ inputs: +``` + +#### 3. Pass Inputs to Packages +**File**: `flake.nix` +**Changes**: Update custom package overlay (line 59 and 87) + +```nix +# Before +custom = prev.callPackage ./packages {}; + +# After +custom = prev.callPackage ./packages { inherit uv2nix pyproject-nix pyproject-build-systems; }; +``` + +#### 4. Update Packages Default.nix Signature +**File**: `packages/default.nix` +**Changes**: Accept new parameters + +```nix +# Before +{ pkgs, ... }: + +# After +{ pkgs, uv2nix ? null, pyproject-nix ? null, pyproject-build-systems ? null, ... }: +``` + +**Note**: Parameters are optional to maintain compatibility with direct `nix-build` calls. + +### Success Criteria + +#### Automated Verification: +- [x] Flake evaluation succeeds: `nix flake check` +- [x] Custom packages still build: `nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages` +- [x] No evaluation errors: `nixos-rebuild dry-build --flake .#zix790prors` + +#### Manual Verification: +- [x] Flake inputs show pyproject-nix, uv2nix, and pyproject-build-systems: `nix flake metadata` +- [x] Existing machines still build without errors + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation before proceeding to Phase 2. + +--- + +## Phase 2: Create Sendspin-CLI Package + +### Overview +Create uv2nix-based package for sendspin-cli that handles Python dependencies and native libraries (portaudio, ffmpeg). + +### Changes Required + +#### 1. Package Definition +**File**: `packages/sendspin-cli/default.nix` +**Changes**: Create new file + +```nix +{ pkgs +, uv2nix ? null +, pyproject-nix ? null +, pyproject-build-systems ? null +, lib +, fetchFromGitHub +}: + +# Fallback to simple package if uv2nix not available +if uv2nix == null || pyproject-nix == null || pyproject-build-systems == null then + let + python = pkgs.python312.withPackages (ps: with ps; [ + # Core dependencies from pyproject.toml + # Note: aiosendspin may need to be packaged separately if not in nixpkgs + av + numpy + qrcode + readchar + rich + sounddevice + # Build dependencies + setuptools + ]); + in + pkgs.stdenv.mkDerivation rec { + pname = "sendspin-cli"; + version = "0.0.0-fallback"; + + src = fetchFromGitHub { + owner = "Sendspin"; + repo = "sendspin-cli"; + rev = "main"; + sha256 = lib.fakeSha256; # Replace with actual hash after first build + }; + + buildInputs = [ python pkgs.portaudio pkgs.ffmpeg ]; + + installPhase = '' + mkdir -p $out/bin $out/lib + cp -r sendspin $out/lib/ + cat > $out/bin/sendspin < $out/bin/sendspin <' -A custom.sendspin-cli)/bin/sendspin --help` +- [ ] List audio devices works: `$(nix-build '' -A custom.sendspin-cli)/bin/sendspin --list-audio-devices` +- [ ] Version information is correct: `$(nix-build '' -A custom.sendspin-cli)/bin/sendspin --version` (if supported) +- [ ] Dependencies are bundled: Check that output closure contains portaudio and ffmpeg libraries + +**Implementation Note**: The first build will fail with `lib.fakeSha256` error. Copy the actual hash from the error message and replace `lib.fakeSha256` with the real hash. After completing this phase and all automated verification passes, pause here for manual confirmation before proceeding to Phase 3. + +--- + +## Phase 3: Create Sendspin Role with Service Template + +### Overview +Create NixOS role that provides sendspin-cli package and configurable systemd user service template for running sendspin as a background service. + +### Changes Required + +#### 1. Role Definition +**File**: `roles/sendspin/default.nix` +**Changes**: Create new file + +```nix +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.roles.sendspin; +in +{ + options.roles.sendspin = { + enable = mkEnableOption "Enable the sendspin role"; + + audioDevice = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Audio output device by index (e.g., "0", "1") or name prefix (e.g., "AmazonBasics"). + Use `sendspin --list-audio-devices` to see available devices. + If null, uses system default audio device. + ''; + example = "0"; + }; + + clientName = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Friendly name for this Sendspin client. + Defaults to hostname if not specified. + ''; + example = "Living Room Speakers"; + }; + + clientId = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Unique identifier for this Sendspin client. + Defaults to sendspin-cli- if not specified. + ''; + example = "sendspin-livingroom"; + }; + + serverUrl = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + WebSocket URL of the Sendspin server. + If null, auto-discovers servers via mDNS. + ''; + example = "ws://192.168.1.100:8927"; + }; + + staticDelayMs = mkOption { + type = types.float; + default = 0.0; + description = '' + Extra playback delay in milliseconds applied after clock sync. + Useful for compensating audio latency differences between devices. + ''; + example = 50.0; + }; + + logLevel = mkOption { + type = types.enum [ "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL" ]; + default = "INFO"; + description = "Logging level for sendspin service"; + }; + }; + + config = mkIf cfg.enable { + # Ensure audio infrastructure is available + roles.audio.enable = true; + + # Make sendspin-cli available system-wide + environment.systemPackages = with pkgs; [ + custom.sendspin-cli + ]; + + # Systemd user service for running sendspin in headless mode + systemd.user.services.sendspin = { + description = "Sendspin Audio Sync Client"; + documentation = [ "https://github.com/Sendspin/sendspin-cli" ]; + + # Start after audio services are ready + after = [ "pipewire.service" "wireplumber.service" ]; + requires = [ "pipewire.service" "wireplumber.service" ]; + + # Auto-start with pipewire (which starts with graphical session) + wantedBy = [ "pipewire.service" ]; + + serviceConfig = { + Type = "simple"; + Restart = "always"; + RestartSec = "5s"; + + # Build command with configured options + ExecStart = pkgs.writeShellScript "sendspin-start" '' + exec ${pkgs.custom.sendspin-cli}/bin/sendspin \ + --headless \ + --log-level ${cfg.logLevel} \ + ${optionalString (cfg.audioDevice != null) "--audio-device '${cfg.audioDevice}'"} \ + ${optionalString (cfg.clientName != null) "--name '${cfg.clientName}'"} \ + ${optionalString (cfg.clientId != null) "--id '${cfg.clientId}'"} \ + ${optionalString (cfg.serverUrl != null) "--url '${cfg.serverUrl}'"} \ + ${optionalString (cfg.staticDelayMs != 0.0) "--static-delay-ms ${toString cfg.staticDelayMs}"} + ''; + }; + }; + + # Open firewall for mDNS discovery + networking.firewall.allowedUDPPorts = [ 5353 ]; + }; +} +``` + +#### 2. Register Role +**File**: `roles/default.nix` +**Changes**: Add sendspin to imports (after line 16) + +```nix +imports = [ + ./audio + ./bluetooth + ./btrfs + ./desktop + ./kodi + ./nfs-mounts + ./nvidia + ./printing + ./remote-build + ./sendspin + ./spotifyd + ./users + ./virtualisation +]; +``` + +### Success Criteria + +#### Automated Verification: +- [ ] Configuration evaluates: `nixos-rebuild dry-build --flake .#zix790prors` +- [ ] Service unit is generated: `nixos-rebuild dry-build --flake .#zix790prors 2>&1 | grep -i sendspin` +- [ ] No syntax errors: `nix eval .#nixosConfigurations.zix790prors.config.roles.sendspin.enable` + +#### Manual Verification: +- [ ] Role can be enabled in machine config without errors +- [ ] Service dependencies are correct (after pipewire/wireplumber) +- [ ] Firewall rule for mDNS is present +- [ ] Sendspin-cli is in system packages when role is enabled +- [ ] All configuration options (audioDevice, clientName, etc.) are exposed +- [ ] Service starts successfully after enabling role and rebuilding + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation. Test the service by adding `roles.sendspin.enable = true;` to a machine configuration, rebuilding, and verifying the service runs as the logged-in user. + +--- + +## Testing Strategy + +### Unit Tests (Per-Phase) + +**Phase 1 (Flake Inputs):** +```bash +# Verify flake is valid +nix flake check + +# Verify inputs are available +nix flake metadata | grep -E "(pyproject-nix|uv2nix|pyproject-build-systems)" + +# Verify existing builds still work +nix build .#nixosConfigurations.zix790prors.config.system.build.toplevel +``` + +**Phase 2 (Package):** +```bash +# Build package +nix build .#nixosConfigurations.zix790prors.pkgs.custom.sendspin-cli + +# Test executable +result/bin/sendspin --help +result/bin/sendspin --list-audio-devices + +# Verify dependencies +nix-store --query --requisites result | grep -E "(portaudio|ffmpeg)" +``` + +**Phase 3 (Role):** +```bash +# Evaluate with role enabled +nix eval .#nixosConfigurations.zix790prors.config.roles.sendspin.enable + +# Check service definition +nixos-rebuild dry-build --flake .#zix790prors +systemctl --user cat sendspin.service # After rebuild + +# Verify audio device option works +nix eval '.#nixosConfigurations.zix790prors.config.roles.sendspin.audioDevice' +``` + +### Integration Tests + +**Basic Service Test:** +1. Enable role on a test machine: `roles.sendspin.enable = true;` +2. Rebuild system: `make switch` +3. Check service status: `systemctl --user status sendspin.service` +4. Verify logs show connection attempts: `journalctl --user -u sendspin -f` +5. Run server locally: `sendspin serve --demo` +6. Verify client connects and plays audio + +**Multi-Device Test (Future - zix790prors):** +1. Create multiple service instances with different audio devices +2. Verify each instance targets correct device +3. Test audio sync between devices + +**User Context Test:** +1. Test on `boxy` running as `kodi` user +2. Test on `zix790prors` running as `johno` user +3. Verify service runs in correct user session + +### Manual Testing Steps + +1. **Package Installation:** + ```bash + # As root + nix-shell -p 'pkgs.custom.sendspin-cli' + sendspin --list-audio-devices + ``` + +2. **Service Functionality:** + ```bash + # After enabling role and rebuilding + systemctl --user status sendspin + journalctl --user -u sendspin -n 50 + ``` + +3. **Audio Device Selection:** + ```bash + # Configure specific device + roles.sendspin.audioDevice = "0"; + # Rebuild and verify service uses correct device + ``` + +4. **Server Discovery:** + ```bash + # Without serverUrl, verify mDNS discovery + sendspin --list-servers + ``` + +5. **Delay Calibration:** + ```bash + # Test delay configuration + roles.sendspin.staticDelayMs = 50.0; + # Verify in service logs + ``` + +## Performance Considerations + +**Build Time:** +- uv2nix initial build may take 5-10 minutes (Python dependency resolution) +- Subsequent builds use Nix cache +- Consider using binary cache if building on multiple machines + +**Runtime:** +- Sendspin client is lightweight (~20-50 MB memory) +- CPU usage minimal when not playing audio +- Network: Uses mDNS (UDP 5353) and WebSocket connection to server + +**Storage:** +- Package closure size: ~200-300 MB (Python + dependencies + libraries) +- No persistent cache by sendspin-cli itself + +## Migration Notes + +**For Existing Systems:** +1. Add role to machine configuration +2. Configure audio device if not using default +3. Rebuild and enable service +4. No data migration needed (stateless service) + +**For Multi-Instance Setups (Future):** +When implementing multiple instances for zix790prors: +1. Disable default service: `systemd.user.services.sendspin.wantedBy = lib.mkForce [];` +2. Create per-device service instances manually +3. Each instance needs unique `--id` and `--audio-device` + +## References + +- Original research: `thoughts/shared/research/2025-12-29-sendspin-cli-integration.md` +- Sendspin-CLI source: `~/src/sendspin-cli/` (GitHub: https://github.com/Sendspin/sendspin-cli) +- Sendspin pyproject.toml: `~/src/sendspin-cli/pyproject.toml` +- Sendspin CLI implementation: `~/src/sendspin-cli/sendspin/cli.py:143-222` +- Custom packages pattern: `packages/default.nix:1-7` +- Python package pattern: `packages/app-launcher-server/default.nix:1-10` +- Audio role pattern: `roles/audio/default.nix:1-41` +- Service role pattern: `roles/spotifyd/default.nix:1-40` +- User service pattern: `machines/zix790prors/virtual-surround.nix:79-132` +- Flake overlay: `flake.nix:54-59` +- uv2nix documentation: https://pyproject-nix.github.io/uv2nix/ +- uv2nix getting started: https://pyproject-nix.github.io/uv2nix/usage/getting-started.html