Files
nixos-configs/thoughts/shared/plans/2025-12-29-sendspin-cli-integration.md
John Ogle 4b0adcc020 Add sendspin-cli package with Python dependencies
Integrate sendspin-cli as a custom package using python3.withPackages.
Packages aiosendspin from GitHub since it's only available in
nixpkgs-unstable. Includes all required dependencies: aiohttp, av,
numpy, qrcode, readchar, rich, sounddevice, and native libraries
(portaudio, ffmpeg).

Adds uv2nix flake inputs for future migration when sendspin-cli
adds a uv.lock file.
2025-12-29 23:46:55 -08:00

23 KiB

Sendspin-CLI Integration Implementation Plan

Overview

Integrate sendspin-cli (https://github.com/Sendspin/sendspin-cli) into the NixOS configuration using uv2nix for Python packaging. Provide a flexible systemd service template that runs as the graphical user, supporting multiple use cases: standalone media centers (like boxy running as kodi user) and desktop workstations (like zix790prors running as johno user).

Current State Analysis

Existing Infrastructure:

  • Custom package system using overlays at flake.nix:54-59
  • Packages defined in packages/default.nix and exposed as pkgs.custom.<name>
  • 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 <index|name> (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:

# Package builds successfully
nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages --no-link | grep sendspin-cli

# Package contains working executable
$(nix-build -E '(import <nixpkgs> {}).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)

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)

# 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)

# 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

# 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:

  • Flake evaluation succeeds: nix flake check
  • Custom packages still build: nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages
  • No evaluation errors: nixos-rebuild dry-build --flake .#zix790prors

Manual Verification:

  • Flake inputs show pyproject-nix, uv2nix, and pyproject-build-systems: nix flake metadata
  • 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

{ 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 <<EOF
#!/bin/sh
export PYTHONPATH="$out/lib:\$PYTHONPATH"
exec ${python}/bin/python3 -m sendspin.cli "\$@"
EOF
      chmod +x $out/bin/sendspin
    '';

    meta = {
      description = "Synchronized audio player for Sendspin servers (fallback build)";
      homepage = "https://github.com/Sendspin/sendspin-cli";
      license = lib.licenses.asl20;
    };
  }
else
  let
    # Fetch sendspin-cli source
    src = fetchFromGitHub {
      owner = "Sendspin";
      repo = "sendspin-cli";
      rev = "main";  # TODO: Pin to specific release tag
      sha256 = lib.fakeSha256;  # Replace with actual hash after first build
    };

    # Load workspace from pyproject.toml
    workspace = uv2nix.lib.workspace.loadWorkspace {
      workspaceRoot = src;
    };

    # Create overlay from pyproject.toml dependencies
    overlay = workspace.mkPyprojectOverlay {
      sourcePreference = "wheel";  # Prefer wheels for faster builds
    };

    # Build Python package set with native dependency overrides
    pythonSet = (pkgs.callPackage pyproject-nix.build.packages {
      python = pkgs.python312;
    }).overrideScope (lib.composeManyExtensions [
      pyproject-build-systems.overlays.default
      overlay
      # Override for packages with native dependencies
      (final: prev: {
        # sounddevice needs portaudio
        sounddevice = prev.sounddevice.overrideAttrs (old: {
          buildInputs = (old.buildInputs or []) ++ [ pkgs.portaudio ];
          nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.portaudio ];
        });
        # av (PyAV) needs ffmpeg
        av = prev.av.overrideAttrs (old: {
          buildInputs = (old.buildInputs or []) ++ [ pkgs.ffmpeg ];
          nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.pkg-config ];
        });
      })
    ]);

    # Create virtual environment with all dependencies
    venv = pythonSet.mkVirtualEnv "sendspin-cli-env" workspace.deps.default;
  in
  pkgs.stdenv.mkDerivation {
    pname = "sendspin-cli";
    version = "0.0.0";
    inherit src;

    buildInputs = [ venv pkgs.portaudio pkgs.ffmpeg ];

    installPhase = ''
      mkdir -p $out/bin
      # Copy virtual environment
      cp -r ${venv} $out/venv
      # Create wrapper script
      cat > $out/bin/sendspin <<EOF
#!/bin/sh
export LD_LIBRARY_PATH="${pkgs.portaudio}/lib:${pkgs.ffmpeg}/lib:\$LD_LIBRARY_PATH"
exec $out/venv/bin/sendspin "\$@"
EOF
      chmod +x $out/bin/sendspin
    '';

    meta = {
      description = "Synchronized audio player for Sendspin servers";
      homepage = "https://github.com/Sendspin/sendspin-cli";
      license = lib.licenses.asl20;
      platforms = lib.platforms.linux;
    };
  }

2. Register Package

File: packages/default.nix Changes: Add sendspin-cli to exports (after line 6)

{ 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; };
}

Success Criteria

Automated Verification:

  • Package builds successfully: nix build .#nixosConfigurations.zix790prors.pkgs.custom.sendspin-cli
  • Binary exists in output: nix path-info .#nixosConfigurations.zix790prors.pkgs.custom.sendspin-cli
  • No build errors in dry-run: nixos-rebuild dry-build --flake .#zix790prors

Manual Verification:

  • Help text displays correctly: $(nix-build '<nixpkgs>' -A custom.sendspin-cli)/bin/sendspin --help
  • List audio devices works: $(nix-build '<nixpkgs>' -A custom.sendspin-cli)/bin/sendspin --list-audio-devices
  • Version information is correct: $(nix-build '<nixpkgs>' -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

{ 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-<hostname> 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)

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):

# 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):

# 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):

# 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:

    # As root
    nix-shell -p 'pkgs.custom.sendspin-cli'
    sendspin --list-audio-devices
    
  2. Service Functionality:

    # After enabling role and rebuilding
    systemctl --user status sendspin
    journalctl --user -u sendspin -n 50
    
  3. Audio Device Selection:

    # Configure specific device
    roles.sendspin.audioDevice = "0";
    # Rebuild and verify service uses correct device
    
  4. Server Discovery:

    # Without serverUrl, verify mDNS discovery
    sendspin --list-servers
    
  5. Delay Calibration:

    # 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