🎉 live server seems to be working now
This commit is contained in:
1464
third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md
vendored
Normal file
1464
third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
828
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md
vendored
Normal file
828
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md
vendored
Normal file
@@ -0,0 +1,828 @@
|
||||
# Drop Oto Backend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the `github.com/ebitengine/oto/v3` dependency entirely by replacing both oto-using audio output implementations with `pkg/audio/output.Malgo`, leaving a single 24-bit-capable audio backend.
|
||||
|
||||
**Architecture:** Delete `pkg/audio/output/oto.go` (library backend used by `pkg/sendspin.Player`) and `internal/player/output.go` (CLI backend used by `internal/app.Player`). Simplify the `pkg/sendspin.Player` backend selector to always use malgo. Migrate `internal/app.Player` to construct an `output.Malgo` directly via the `pkg/audio/output.Output` interface, and track volume/mute state inside the app layer. Run `go mod tidy` to drop the oto dependency.
|
||||
|
||||
**Tech Stack:** Go 1.24+, `github.com/gen2brain/malgo` (miniaudio via CGo).
|
||||
|
||||
**Context:**
|
||||
- This supersedes PR #25 (`feat/oto-float32-output`), which proposed switching oto to `FormatFloat32LE` as a defensive fix for issue #3. That PR is obsolete because the real fix is deletion, not format negotiation.
|
||||
- The layered-architecture plan at `docs/superpowers/plans/2026-04-12-layered-architecture.md` is scoped to `pkg/sendspin` and does NOT touch `internal/app` or `internal/player` — so this plan does not conflict with it.
|
||||
- After this plan: issue #3 closes (malgo handles 24-bit natively via `FormatS24`), PR #25 closes without merging, and the tree has exactly one audio backend.
|
||||
|
||||
**Out of scope:**
|
||||
- Deleting other `internal/player/*` files (e.g. `scheduler.go`) — that belongs to the layered-architecture refactor, not this one.
|
||||
- Touching `internal/client`, `internal/audio`, `internal/sync` — the legacy CLI pipeline stays functional with its internal packages; only the audio output layer swaps.
|
||||
- Removing `malgo`'s `write16Bit` branch — it stays as defensive code for 16-bit sources.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `pkg/audio/output/volume.go` | CREATE | Shared `applyVolume` / `getVolumeMultiplier` (moved out of `oto.go` before `oto.go` is deleted) |
|
||||
| `pkg/audio/output/volume_test.go` | CREATE | Tests for the moved helpers (ported from `internal/player/output_test.go`) |
|
||||
| `pkg/audio/output/oto.go` | DELETE | Oto backend and its exported `NewOto` constructor |
|
||||
| `pkg/audio/output/output_test.go` | MODIFY | Drop `TestOtoImplementsOutput` and `TestNewOto` |
|
||||
| `pkg/audio/output/doc.go` | MODIFY | Update usage example if it references `NewOto` |
|
||||
| `pkg/sendspin/player.go` | MODIFY | Collapse `onStreamStart` backend selector to always use `NewMalgo` |
|
||||
| `internal/app/player.go` | MODIFY | Replace `*internal/player.Output` with `pkg/audio/output.Output`; add `volume`/`muted` fields; adapt call sites |
|
||||
| `internal/player/output.go` | DELETE | The duplicate oto-based output used by legacy CLIs |
|
||||
| `internal/player/output_test.go` | DELETE | Covered by the new `pkg/audio/output/volume_test.go` |
|
||||
| `go.mod` / `go.sum` | MODIFY | `go mod tidy` removes `github.com/ebitengine/oto/v3` |
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight: Branch setup
|
||||
|
||||
- [ ] **Step 0a: Start from clean main**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only
|
||||
git checkout -b feat/drop-oto
|
||||
```
|
||||
|
||||
- [ ] **Step 0b: Verify baseline green before any changes**
|
||||
|
||||
```bash
|
||||
export PATH="/c/msys64/mingw64/bin:$PATH"
|
||||
go build ./...
|
||||
go test ./... 2>&1 | tail -20
|
||||
```
|
||||
Expected: all packages pass. If anything is red before you start, stop and investigate — do not conflate an unrelated failure with this work.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract shared volume helpers into `pkg/audio/output/volume.go`
|
||||
|
||||
Both `oto.go` and `malgo.go` (in the same package) use package-level `applyVolume` and `getVolumeMultiplier`. They currently live in `oto.go`. We need to move them out before deleting `oto.go`, otherwise `malgo.go` loses its symbol.
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/audio/output/volume.go`
|
||||
- Create: `pkg/audio/output/volume_test.go`
|
||||
- Modify: `pkg/audio/output/oto.go` (temporary — helpers deleted from here)
|
||||
|
||||
- [ ] **Step 1: Create `volume.go` with the helpers**
|
||||
|
||||
Write `pkg/audio/output/volume.go`:
|
||||
|
||||
```go
|
||||
// ABOUTME: Volume and mute helpers shared by audio output backends
|
||||
// ABOUTME: Extracted from oto.go during the oto removal cleanup
|
||||
package output
|
||||
|
||||
import "github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
|
||||
// applyVolume applies volume and mute to samples with clipping protection.
|
||||
// Samples are expected to be in the int32 24-bit range.
|
||||
func applyVolume(samples []int32, volume int, muted bool) []int32 {
|
||||
multiplier := getVolumeMultiplier(volume, muted)
|
||||
|
||||
result := make([]int32, len(samples))
|
||||
for i, sample := range samples {
|
||||
scaled := int64(float64(sample) * multiplier)
|
||||
|
||||
if scaled > audio.Max24Bit {
|
||||
scaled = audio.Max24Bit
|
||||
} else if scaled < audio.Min24Bit {
|
||||
scaled = audio.Min24Bit
|
||||
}
|
||||
|
||||
result[i] = int32(scaled)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getVolumeMultiplier returns the float multiplier for a given volume/mute state.
|
||||
func getVolumeMultiplier(volume int, muted bool) float64 {
|
||||
if muted {
|
||||
return 0.0
|
||||
}
|
||||
return float64(volume) / 100.0
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the helpers from `oto.go`**
|
||||
|
||||
Open `pkg/audio/output/oto.go` and remove the `applyVolume` and `getVolumeMultiplier` functions (currently around lines 172-202). Leave the rest of the file alone — it will be deleted entirely in Task 4.
|
||||
|
||||
Also remove the now-unused `github.com/Sendspin/sendspin-go/pkg/audio` import from `oto.go` only if it was the last user of that package within `oto.go` — it isn't, because `SampleToInt16` is still called in `Write`. Leave the import alone.
|
||||
|
||||
- [ ] **Step 3: Create `volume_test.go` with ported tests**
|
||||
|
||||
Write `pkg/audio/output/volume_test.go`:
|
||||
|
||||
```go
|
||||
// ABOUTME: Tests for shared volume/mute helpers
|
||||
package output
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
func TestVolumeMultiplier(t *testing.T) {
|
||||
tests := []struct {
|
||||
volume int
|
||||
muted bool
|
||||
expected float64
|
||||
}{
|
||||
{100, false, 1.0},
|
||||
{50, false, 0.5},
|
||||
{0, false, 0.0},
|
||||
{80, true, 0.0}, // muted overrides volume
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := getVolumeMultiplier(tt.volume, tt.muted)
|
||||
if result != tt.expected {
|
||||
t.Errorf("volume=%d muted=%v: expected %f, got %f",
|
||||
tt.volume, tt.muted, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVolume_HalfScale(t *testing.T) {
|
||||
samples := []int32{1000 << 8, -1000 << 8, 500 << 8, -500 << 8}
|
||||
|
||||
result := applyVolume(samples, 50, false)
|
||||
|
||||
if result[0] != int32(500<<8) {
|
||||
t.Errorf("sample 0: expected %d, got %d", 500<<8, result[0])
|
||||
}
|
||||
if result[1] != int32(-500<<8) {
|
||||
t.Errorf("sample 1: expected %d, got %d", -500<<8, result[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVolume_Muted(t *testing.T) {
|
||||
samples := []int32{audio.Max24Bit, audio.Min24Bit, 1 << 20}
|
||||
|
||||
result := applyVolume(samples, 100, true)
|
||||
|
||||
for i, got := range result {
|
||||
if got != 0 {
|
||||
t.Errorf("sample %d: expected 0 when muted, got %d", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVolume_Clamps24Bit(t *testing.T) {
|
||||
// Volume > 100 is not expected from callers, but the clamping must still
|
||||
// prevent overflow past the 24-bit range.
|
||||
samples := []int32{audio.Max24Bit, audio.Min24Bit}
|
||||
|
||||
result := applyVolume(samples, 100, false)
|
||||
|
||||
if result[0] != audio.Max24Bit {
|
||||
t.Errorf("max sample: expected %d, got %d", audio.Max24Bit, result[0])
|
||||
}
|
||||
if result[1] != audio.Min24Bit {
|
||||
t.Errorf("min sample: expected %d, got %d", audio.Min24Bit, result[1])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify tests compile and pass**
|
||||
|
||||
```bash
|
||||
go test ./pkg/audio/output/ -run 'TestVolumeMultiplier|TestApplyVolume' -v
|
||||
```
|
||||
Expected: all four test functions PASS.
|
||||
|
||||
- [ ] **Step 5: Verify the full output package still builds and passes**
|
||||
|
||||
```bash
|
||||
go test ./pkg/audio/output/
|
||||
```
|
||||
Expected: `ok`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/audio/output/volume.go pkg/audio/output/volume_test.go pkg/audio/output/oto.go
|
||||
git commit -m "refactor(audio/output): move volume helpers out of oto.go
|
||||
|
||||
Preparatory step for removing the oto backend. Extracts applyVolume
|
||||
and getVolumeMultiplier into a shared volume.go file so malgo.go
|
||||
retains access to them after oto.go is deleted. Ports the volume
|
||||
tests from internal/player/output_test.go (which will be deleted
|
||||
in a later step) to the new package location."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Simplify `pkg/sendspin.Player` backend selector
|
||||
|
||||
Remove the `BitDepth <= 16 ? oto : malgo` switch in `onStreamStart` — always use malgo. Also update the `PlayerConfig.Output` doc comment that mentions auto-selection.
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/sendspin/player.go` (lines 43-44 doc comment; lines 167-176 selector)
|
||||
|
||||
- [ ] **Step 1: Update `PlayerConfig.Output` doc comment**
|
||||
|
||||
In `pkg/sendspin/player.go`, find:
|
||||
|
||||
```go
|
||||
// Output overrides the default audio output backend.
|
||||
// When nil, auto-selects oto (16-bit) or malgo (24-bit) based on stream format.
|
||||
Output output.Output
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```go
|
||||
// Output overrides the default audio output backend.
|
||||
// When nil, a malgo-backed output is created on stream start.
|
||||
Output output.Output
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Simplify `onStreamStart`**
|
||||
|
||||
Find the block at roughly lines 167-176:
|
||||
|
||||
```go
|
||||
func (p *Player) onStreamStart(format audio.Format) {
|
||||
if p.output == nil {
|
||||
if format.BitDepth <= 16 {
|
||||
p.output = output.NewOto()
|
||||
log.Printf("Using oto backend for %d-bit audio", format.BitDepth)
|
||||
} else {
|
||||
p.output = output.NewMalgo()
|
||||
log.Printf("Using malgo backend for %d-bit audio", format.BitDepth)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```go
|
||||
func (p *Player) onStreamStart(format audio.Format) {
|
||||
if p.output == nil {
|
||||
p.output = output.NewMalgo()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify `pkg/sendspin` still builds**
|
||||
|
||||
```bash
|
||||
go build ./pkg/sendspin/...
|
||||
go test ./pkg/sendspin/ 2>&1 | tail -20
|
||||
```
|
||||
Expected: clean build, all tests pass. Any test that mocked the `Output` interface continues to work because it passes `PlayerConfig.Output` directly; the selector branch is unreachable by tests.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/sendspin/player.go
|
||||
git commit -m "refactor(sendspin): always use malgo backend in Player
|
||||
|
||||
Collapses the bit-depth-based oto/malgo selector to a single malgo
|
||||
construction. malgo handles 16/24/32-bit natively and is the only
|
||||
backend that will remain after oto is removed."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Delete `pkg/audio/output/oto.go` and update references
|
||||
|
||||
**Files:**
|
||||
- Delete: `pkg/audio/output/oto.go`
|
||||
- Modify: `pkg/audio/output/output_test.go` (drop `TestOtoImplementsOutput`, `TestNewOto`)
|
||||
- Modify: `pkg/audio/output/doc.go` (update usage example if it references `NewOto`)
|
||||
|
||||
- [ ] **Step 1: Delete `oto.go`**
|
||||
|
||||
```bash
|
||||
git rm pkg/audio/output/oto.go
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `output_test.go`**
|
||||
|
||||
Current contents should leave only the Malgo assertion. Replace the file body with:
|
||||
|
||||
```go
|
||||
// ABOUTME: Audio output interface tests
|
||||
// ABOUTME: Verifies Output interface implementation
|
||||
package output
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMalgoImplementsOutput(t *testing.T) {
|
||||
var _ Output = (*Malgo)(nil)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check `doc.go` for oto references**
|
||||
|
||||
```bash
|
||||
grep -n -i "oto\|NewOto" pkg/audio/output/doc.go
|
||||
```
|
||||
If any line mentions `NewOto` or the oto library, edit the example to use `NewMalgo` instead. Leave unrelated content alone.
|
||||
|
||||
- [ ] **Step 4: Verify package builds**
|
||||
|
||||
```bash
|
||||
go build ./pkg/audio/output/
|
||||
go test ./pkg/audio/output/
|
||||
```
|
||||
Expected: no references to `Oto` remaining, tests pass.
|
||||
|
||||
- [ ] **Step 5: Verify dependent packages still build**
|
||||
|
||||
```bash
|
||||
go build ./pkg/sendspin/...
|
||||
```
|
||||
Expected: clean build. If `pkg/sendspin/player.go` still imports or calls `output.NewOto`, Task 2 was not applied correctly — go back and fix.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A pkg/audio/output/
|
||||
git commit -m "refactor(audio/output): delete oto backend
|
||||
|
||||
malgo is now the only audio output backend in pkg/audio/output.
|
||||
The oto backend had one fixed int16 output format and could not
|
||||
be reinitialized; malgo supports 16/24/32-bit natively and handles
|
||||
format changes cleanly."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Migrate `internal/app.Player` off `internal/player.Output`
|
||||
|
||||
Replace the `*player.Output` field with a `pkg/audio/output.Output` interface value (satisfied by `*output.Malgo`). Track volume and mute state on the `Player` struct itself because the `output.Output` interface does not expose `GetVolume`/`IsMuted`. Adapt the `Initialize(format)` / `Play(buf)` call sites to the interface's `Open(sr,ch,bd)` / `Write([]int32)` shape.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/app/player.go`
|
||||
|
||||
- [ ] **Step 1: Add the new import and swap the struct field**
|
||||
|
||||
In `internal/app/player.go`, add the import (alongside the existing `github.com/Sendspin/sendspin-go/internal/player`):
|
||||
|
||||
```go
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
```
|
||||
|
||||
Change the `Player` struct field:
|
||||
|
||||
```go
|
||||
output *player.Output
|
||||
```
|
||||
to:
|
||||
|
||||
```go
|
||||
output output.Output
|
||||
volume int
|
||||
muted bool
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the `New()` constructor**
|
||||
|
||||
Find:
|
||||
|
||||
```go
|
||||
return &Player{
|
||||
config: config,
|
||||
clockSync: clockSync,
|
||||
output: player.NewOutput(),
|
||||
artwork: artworkDL,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
playerState: "idle", // Start in idle state
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```go
|
||||
return &Player{
|
||||
config: config,
|
||||
clockSync: clockSync,
|
||||
output: output.NewMalgo(),
|
||||
volume: 100,
|
||||
artwork: artworkDL,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
playerState: "idle", // Start in idle state
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `handleStreamStart` to call `Open(sampleRate, channels, bitDepth)`**
|
||||
|
||||
Find the block inside `handleStreamStart` that currently reads:
|
||||
|
||||
```go
|
||||
format := audio.Format{
|
||||
Codec: start.Player.Codec,
|
||||
SampleRate: start.Player.SampleRate,
|
||||
Channels: start.Player.Channels,
|
||||
BitDepth: start.Player.BitDepth,
|
||||
}
|
||||
|
||||
// Initialize decoder
|
||||
decoder, err := audio.NewDecoder(format)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create decoder: %v", err)
|
||||
continue
|
||||
}
|
||||
p.decoder = decoder
|
||||
|
||||
// Initialize output
|
||||
if err := p.output.Initialize(format); err != nil {
|
||||
log.Printf("Failed to initialize output: %v", err)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
Replace the `p.output.Initialize(format)` call with:
|
||||
|
||||
```go
|
||||
if err := p.output.Open(start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth); err != nil {
|
||||
log.Printf("Failed to initialize output: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply any pre-stream volume/mute state to the fresh device
|
||||
p.output.SetVolume(p.volume)
|
||||
p.output.SetMuted(p.muted)
|
||||
```
|
||||
|
||||
Leave the `format := audio.Format{...}` declaration alone — `audio.NewDecoder(format)` above still uses it. Only the `p.output.Initialize(format)` call is being replaced.
|
||||
|
||||
- [ ] **Step 4: Update `handleScheduledAudio` to call `Write([]int32)`**
|
||||
|
||||
Find:
|
||||
|
||||
```go
|
||||
func (p *Player) handleScheduledAudio(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case buf := <-p.scheduler.Output():
|
||||
if err := p.output.Play(buf); err != nil {
|
||||
log.Printf("Playback error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
Replace the playback call:
|
||||
|
||||
```go
|
||||
func (p *Player) handleScheduledAudio(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case buf := <-p.scheduler.Output():
|
||||
if err := p.output.Write(buf.Samples); err != nil {
|
||||
log.Printf("Playback error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `handleControls` to read volume/mute from `p`, not `p.output`**
|
||||
|
||||
Find:
|
||||
|
||||
```go
|
||||
case "volume":
|
||||
p.output.SetVolume(cmd.Volume)
|
||||
p.client.SendState(protocol.ClientState{
|
||||
State: p.playerState,
|
||||
Volume: cmd.Volume,
|
||||
Muted: p.output.IsMuted(),
|
||||
})
|
||||
|
||||
case "mute":
|
||||
p.output.SetMuted(cmd.Mute)
|
||||
p.client.SendState(protocol.ClientState{
|
||||
State: p.playerState,
|
||||
Volume: p.output.GetVolume(),
|
||||
Muted: cmd.Mute,
|
||||
})
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```go
|
||||
case "volume":
|
||||
p.volume = cmd.Volume
|
||||
p.output.SetVolume(cmd.Volume)
|
||||
p.client.SendState(protocol.ClientState{
|
||||
State: p.playerState,
|
||||
Volume: p.volume,
|
||||
Muted: p.muted,
|
||||
})
|
||||
|
||||
case "mute":
|
||||
p.muted = cmd.Mute
|
||||
p.output.SetMuted(cmd.Mute)
|
||||
p.client.SendState(protocol.ClientState{
|
||||
State: p.playerState,
|
||||
Volume: p.volume,
|
||||
Muted: p.muted,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `handleVolumeControl`**
|
||||
|
||||
Find:
|
||||
|
||||
```go
|
||||
// Apply to output
|
||||
if p.output != nil {
|
||||
p.output.SetVolume(vol.Volume)
|
||||
p.output.SetMuted(vol.Muted)
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```go
|
||||
if p.output != nil {
|
||||
p.volume = vol.Volume
|
||||
p.muted = vol.Muted
|
||||
p.output.SetVolume(vol.Volume)
|
||||
p.output.SetMuted(vol.Muted)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update `Stop()` — `output.Close()` now returns an error**
|
||||
|
||||
Find:
|
||||
|
||||
```go
|
||||
if p.output != nil {
|
||||
p.output.Close()
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```go
|
||||
if p.output != nil {
|
||||
if err := p.output.Close(); err != nil {
|
||||
log.Printf("Warning: output close error: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Build `internal/app` and the two CLIs that depend on it**
|
||||
|
||||
```bash
|
||||
go build ./internal/app/
|
||||
go build ./cmd/ma-player/
|
||||
go build ./cmd/test-sync/
|
||||
```
|
||||
Expected: all three clean. If any step fails, read the error — most likely a missed `p.output.IsMuted()` / `p.output.GetVolume()` call site or a stray `player.Output` reference. Grep to find any remaining:
|
||||
|
||||
```bash
|
||||
grep -n "player\.Output\|player\.NewOutput\|output\.GetVolume\|output\.IsMuted" internal/app/player.go
|
||||
```
|
||||
Should return no hits.
|
||||
|
||||
- [ ] **Step 9: Run the full test suite**
|
||||
|
||||
```bash
|
||||
go test ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages pass.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/app/player.go
|
||||
git commit -m "refactor(internal/app): use pkg/audio/output.Malgo directly
|
||||
|
||||
Replaces the internal/player.Output field with the public
|
||||
output.Output interface, constructed as output.NewMalgo(). Volume
|
||||
and mute are now tracked on Player itself since the interface does
|
||||
not expose getters. This removes the last caller of
|
||||
internal/player.Output and clears the way for deleting that file."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Delete `internal/player/output.go` and its test
|
||||
|
||||
At this point nothing references the legacy output. The scheduler files in the same package stay — they are still used by `internal/app.Player`.
|
||||
|
||||
**Files:**
|
||||
- Delete: `internal/player/output.go`
|
||||
- Delete: `internal/player/output_test.go`
|
||||
|
||||
- [ ] **Step 1: Confirm no remaining callers**
|
||||
|
||||
```bash
|
||||
grep -rn "player\.Output\|player\.NewOutput" internal/ cmd/
|
||||
```
|
||||
Expected: no hits. If anything matches, resolve it before deleting.
|
||||
|
||||
- [ ] **Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
git rm internal/player/output.go internal/player/output_test.go
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify `internal/player` still builds (scheduler remains)**
|
||||
|
||||
```bash
|
||||
go build ./internal/player/
|
||||
go test ./internal/player/
|
||||
```
|
||||
Expected: clean. The `scheduler.go` and `scheduler_test.go` files are unaffected.
|
||||
|
||||
- [ ] **Step 4: Full build and test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A internal/player/
|
||||
git commit -m "refactor(internal/player): delete legacy oto-based Output
|
||||
|
||||
The only caller, internal/app.Player, was migrated to
|
||||
pkg/audio/output.Malgo in the previous commit. The scheduler in
|
||||
the same package remains and is still used."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Drop the oto dependency from `go.mod`
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`
|
||||
- Modify: `go.sum`
|
||||
|
||||
- [ ] **Step 1: Run `go mod tidy`**
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify oto is gone from `go.mod`**
|
||||
|
||||
```bash
|
||||
grep -n oto go.mod
|
||||
```
|
||||
Expected: no match. If `go.mod` still shows the `github.com/ebitengine/oto/v3` line, something is still importing it — grep the tree:
|
||||
|
||||
```bash
|
||||
grep -rn "ebitengine/oto" --include='*.go'
|
||||
```
|
||||
Resolve any hits before proceeding. Do NOT hand-edit `go.mod` to force the dependency out.
|
||||
|
||||
- [ ] **Step 3: Verify build and test still green**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages pass. A dependency removal that breaks a build means the package was still being pulled in transitively — investigate.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add go.mod go.sum
|
||||
git commit -m "chore(deps): drop github.com/ebitengine/oto/v3
|
||||
|
||||
No longer imported after both oto-using audio output
|
||||
implementations were replaced with pkg/audio/output.Malgo."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Sweep docs, Makefile, install-deps for stale oto references
|
||||
|
||||
**Files (check each; modify only if stale):**
|
||||
- `README.md`
|
||||
- `CHANGELOG.md`
|
||||
- `CLAUDE.md`
|
||||
- `install-deps.sh`
|
||||
- `Makefile`
|
||||
- `docs/hires-audio-verification.md`
|
||||
- `docs/MA_HIRES_ISSUE.md`
|
||||
- `docs/FORMAT_NEGOTIATION_FIX.md`
|
||||
- `examples/README.md`
|
||||
- `pkg/audio/output/doc.go`
|
||||
|
||||
- [ ] **Step 1: Grep for any remaining oto mentions**
|
||||
|
||||
```bash
|
||||
grep -rn -i "\boto\b\|ebitengine/oto\|NewOto" \
|
||||
--include='*.md' --include='*.sh' --include='Makefile' \
|
||||
--include='*.go'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: For each hit, decide**
|
||||
|
||||
- **Stale instructions / setup steps / API examples** → update to malgo.
|
||||
- **Historical notes / CHANGELOG entries / resolved-issue docs** → leave alone. They accurately describe history.
|
||||
- **`CLAUDE.md` project guidance** → update if it mentions oto as part of the current architecture.
|
||||
|
||||
Edit in place with `Edit` or manual editor. Do not rewrite history in docs files.
|
||||
|
||||
- [ ] **Step 3: Commit (skip if nothing changed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git diff --cached --stat
|
||||
git commit -m "docs: remove stale oto references after backend deletion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Close loose ends — PR #25, issue #3, and open the new PR
|
||||
|
||||
- [ ] **Step 1: Push the feature branch**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/drop-oto
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open the PR**
|
||||
|
||||
```bash
|
||||
gh pr create --title "refactor(audio): drop oto backend, standardize on malgo" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Deletes both oto-using audio output implementations (`pkg/audio/output/oto.go` and `internal/player/output.go`).
|
||||
- Migrates `internal/app.Player` to construct `pkg/audio/output.Malgo` directly via the `Output` interface, tracking volume/mute on the app struct.
|
||||
- Collapses `pkg/sendspin.Player`'s backend selector to always use malgo.
|
||||
- Removes the `github.com/ebitengine/oto/v3` dependency from `go.mod`.
|
||||
- Closes #3 (true 24-bit output — malgo's `FormatS24` path handles this natively).
|
||||
|
||||
## Why
|
||||
`pkg/audio/output/malgo.go` already supports 16/24/32-bit output via miniaudio and was used for hi-res sources. oto was a lossy fallback path that existed only because malgo landed later and nobody removed the original backend. Having two audio stacks meant two volume/mute code paths, two sets of tests, and a bit-depth-based selector that obscured which backend was actually in use.
|
||||
|
||||
## Scope
|
||||
- In: both oto implementations, the `pkg/sendspin` selector, the `internal/app` migration, the dep removal, doc sweep.
|
||||
- Out: `internal/player/scheduler.go` (still used by `internal/app`), `internal/client` / `internal/sync` (unchanged), the `malgo.write16Bit` branch (kept as defensive fallback).
|
||||
|
||||
## Supersedes
|
||||
- PR #25 (`feat/oto-float32-output`) — that PR proposed switching oto to float32 as a defensive fix. Obsolete: we're deleting oto instead.
|
||||
|
||||
## Test plan
|
||||
- [x] `go build ./...` clean
|
||||
- [x] `go test ./...` green
|
||||
- [x] New `pkg/audio/output/volume_test.go` covers the ported helpers
|
||||
- [ ] **Manual playback verification required**: needs the main player binary and at least one legacy CLI (`ma-player` or `test-sync`) to be run against a real server on each platform that matters. I cannot run interactive audio from the dev environment.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Close PR #25**
|
||||
|
||||
```bash
|
||||
gh pr close 25 --comment "Superseded by the 'drop oto' work. The float32 fix was defensive against a backend we are now deleting entirely — see the newer PR for context."
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Close issue #3**
|
||||
|
||||
```bash
|
||||
gh issue close 3 --repo Sendspin/sendspin-go --comment "$(cat <<'EOF'
|
||||
Closing — resolved by removing the oto backend entirely.
|
||||
|
||||
Hi-res sources were already being routed to \`pkg/audio/output/malgo.go\`, which uses miniaudio's native \`FormatS24\` / \`FormatS32\` paths (see \`malgo.go:148-154\` and the \`write24Bit\` / \`write32Bit\` callbacks). The only lossy code in the tree was the oto backend, which was used as a fallback for \`BitDepth <= 16\` sources (where there was nothing to lose). There was also a duplicate oto-based output in \`internal/player/output.go\` used by \`cmd/ma-player\` and \`cmd/test-sync\`.
|
||||
|
||||
The newer PR deletes both oto implementations, makes malgo the only backend, and drops the \`github.com/ebitengine/oto/v3\` dependency. The 24-bit path is now the only path.
|
||||
|
||||
Note: as with any of the originally proposed solutions, whether 24-bit samples actually reach the DAC still depends on the OS audio stack (WASAPI/CoreAudio/ALSA mixer configuration). That portion is out of our control.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Delete the stale `feat/oto-float32-output` branch**
|
||||
|
||||
```bash
|
||||
git push origin --delete feat/oto-float32-output
|
||||
git branch -D feat/oto-float32-output 2>/dev/null || true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist (before merging the PR)
|
||||
|
||||
- [ ] `go build ./...` clean on a fresh checkout
|
||||
- [ ] `go test ./...` green
|
||||
- [ ] `grep -rn "ebitengine/oto\|NewOto\|FormatSignedInt16LE" --include='*.go'` returns nothing
|
||||
- [ ] `go.mod` no longer lists `github.com/ebitengine/oto/v3`
|
||||
- [ ] Main player (`main.go`) plays a hi-res source end-to-end (manual)
|
||||
- [ ] At least one legacy CLI (`ma-player` or `test-sync`) connects and plays (manual)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If malgo turns out to fail on a platform we care about and we need oto back:
|
||||
|
||||
1. `git revert` the PR from this plan (a single revert commit undoes everything including the dep removal).
|
||||
2. `go mod tidy` reintroduces the oto dependency from the restored imports.
|
||||
3. The `internal/app.Player` migration reverts alongside the deletions, so `cmd/ma-player` and `cmd/test-sync` return to their previous audio path.
|
||||
|
||||
The whole plan is designed as one merge-unit revertable in one step.
|
||||
750
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
vendored
Normal file
750
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
vendored
Normal file
@@ -0,0 +1,750 @@
|
||||
# Rip Legacy CLI Stack Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Delete `cmd/ma-player`, `cmd/test-sync`, and the entire `internal/` legacy player pipeline they depend on. Unify on the `pkg/sendspin.Player` + `sendspin-player` code path as the only player in the tree.
|
||||
|
||||
**Architecture:** `cmd/ma-player` and `cmd/test-sync` both call `internal/app.Player`, which wires together `internal/client`, `internal/audio`, `internal/player` (scheduler), `internal/artwork`, and `internal/sync` into a parallel player pipeline that predates `pkg/sendspin`. Since PR #26 migrated `internal/app.Player`'s output layer to `pkg/audio/output.Malgo`, `ma-player` is functionally equivalent to `sendspin-player` — both negotiate the same supported-formats list, both route 24-bit hi-res through malgo, both connect to Music Assistant cleanly. Empirically verified: `sendspin-player.exe` connects to Music Assistant and plays 192kHz/24-bit successfully (log evidence in `sendspin-player.log` from v1.2.0 verification). This plan removes the duplicate pipeline and its supporting packages, then deletes the `pkg/sync.SetGlobalClockSync`/`ServerMicrosNow` deprecation shims that only existed to keep the legacy readers working.
|
||||
|
||||
**Tech Stack:** Go 1.24+, existing `pkg/sendspin`, `pkg/sync`, `pkg/audio`, `pkg/protocol`, `pkg/discovery`.
|
||||
|
||||
**Context:**
|
||||
- Resolves issue #28 (sync deprecation migration).
|
||||
- Partially addresses issue #35 (release workflow only builds `ma-player` + `sendspin-player` Linux binaries — after this, only `sendspin-player` remains to ship).
|
||||
- Does NOT fix issue #27 (unknown binary type 8), #29 (memory stats), #30 (clock offset display), #31 (duplicate Resampler), #32 (hello log spam), #33 (resonate-artwork cache dir), #34 (FLAC stub) — those are separate and unrelated to this cleanup.
|
||||
- The `docs/superpowers/plans/2026-04-12-layered-architecture.md` plan is scoped to `pkg/sendspin` and does not overlap with this work.
|
||||
|
||||
**Out of scope:**
|
||||
- Any refactor of `pkg/sendspin.Player` itself.
|
||||
- Deletion of `internal/server/` — that package is still live and imported by `cmd/sendspin-server` and `pkg/sendspin/server.go`.
|
||||
- Deletion of `internal/discovery/` — still imported by `internal/server`, `main.go`, and `pkg/sendspin`.
|
||||
- Deletion of `internal/version/` — still imported by `main.go`.
|
||||
- Deletion of `internal/ui/` — still imported by `main.go` for the TUI.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Audit Summary
|
||||
|
||||
Reverse-import scan before writing this plan confirmed the following (all grep paths relative to repo root):
|
||||
|
||||
| Package | Non-legacy importers (survive) | Legacy importers (die with `internal/app`) |
|
||||
|---|---|---|
|
||||
| `internal/app` | (none) | `cmd/ma-player/main.go`, `cmd/test-sync/main.go` |
|
||||
| `internal/audio` | (none) | `internal/app/player.go`, `internal/player/scheduler.go` |
|
||||
| `internal/client` | (none) | `internal/app/player.go` |
|
||||
| `internal/player` | (none) | `internal/app/player.go` |
|
||||
| `internal/artwork` | (none) | `internal/app/player.go` |
|
||||
| `internal/protocol` | (none — already orphaned) | (none) |
|
||||
| `internal/sync` | `main.go` (Quality enum only), `internal/ui/model.go`, `internal/ui/model_test.go` | `internal/app/player.go`, `internal/player/scheduler.go` |
|
||||
| `internal/ui` | `main.go` | `internal/app/player.go` |
|
||||
| `internal/version` | `main.go` | `internal/app/player.go` |
|
||||
| `internal/discovery` | `internal/server/server.go`, `pkg/sendspin/{client_dialer,client_dialer_test,client_discovery_integration_test,server}.go`, `main.go` | `internal/app/player.go` |
|
||||
| `internal/server` | `cmd/sendspin-server/main.go`, `pkg/sendspin/server.go` | (none) |
|
||||
|
||||
**Key observation:** `pkg/sync.Quality` is byte-for-byte equivalent to `internal/sync.Quality` (same type, same three constants in the same iota order). `main.go`'s current `pkg/sync.Quality` → `internal/sync.Quality` conversion block (lines 207–216) is pure ceremony. Migrating the three callers of `internal/sync.Quality` (`internal/ui/model.go`, `internal/ui/model_test.go`, `main.go`) to `pkg/sync.Quality` allows `internal/sync/` to be deleted after `internal/app` and `internal/player` go.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Delete entirely:**
|
||||
|
||||
| Path | Lines | Notes |
|
||||
|---|---|---|
|
||||
| `cmd/ma-player/main.go` | ~90 | Duplicate of root `main.go` via legacy stack |
|
||||
| `cmd/test-sync/main.go` | ~50 | Clock-sync debug harness, unused |
|
||||
| `internal/app/player.go` | 567 | Legacy orchestrator |
|
||||
| `internal/app/player_test.go` | 229 | |
|
||||
| `internal/audio/decoder.go` | 125 | Duplicate of `pkg/audio/decode` |
|
||||
| `internal/audio/decoder_test.go` | 64 | |
|
||||
| `internal/audio/types.go` | 59 | |
|
||||
| `internal/client/websocket.go` | 378 | Duplicate of `pkg/protocol.Client` |
|
||||
| `internal/client/websocket_test.go` | 24 | |
|
||||
| `internal/player/scheduler.go` | 238 | Duplicate of `pkg/sendspin.Scheduler` |
|
||||
| `internal/player/scheduler_test.go` | 44 | |
|
||||
| `internal/artwork/downloader.go` | 108 | Not used by `pkg/sendspin.Player` today |
|
||||
| `internal/artwork/downloader_test.go` | 278 | |
|
||||
| `internal/sync/clock.go` | 139 | Duplicate of `pkg/sync/clock.go` |
|
||||
| `internal/sync/clock_test.go` | (varies) | |
|
||||
| `internal/protocol/messages.go` | 130 | Already orphaned (no non-internal importers) |
|
||||
| `internal/protocol/messages_test.go` | 79 | |
|
||||
|
||||
**Modify:**
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `internal/ui/model.go` | Replace `internal/sync` import with `pkg/sync`; type of `syncQuality` and `StatusMsg.SyncQuality` unchanged in name, changes in import source |
|
||||
| `internal/ui/model_test.go` | Same replacement |
|
||||
| `main.go` | Drop `internalsync "github.com/Sendspin/sendspin-go/internal/sync"` import; delete the Quality conversion block at ~lines 207–216; pass `stats.SyncQuality` directly to `ui.StatusMsg` |
|
||||
| `pkg/sync/clock.go` | Delete package-level deprecated `SetGlobalClockSync`, `ServerMicrosNow`, and the `globalClockSync` / `globalDeprecationWarned` package variables |
|
||||
| `pkg/sendspin/player.go` | Delete the `sync.SetGlobalClockSync(recv.ClockSync())` compatibility-shim call (currently at line 157 per PR #26 final state) |
|
||||
| `.github/workflows/release.yml` | Remove `ma-player` from build matrix if present |
|
||||
| `Makefile` | Remove any `ma-player` or `test-sync` targets if present |
|
||||
|
||||
Total deletion: ~2500–2600 lines across ~17 files, plus small modifications to 5 surviving files.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight: Branch setup
|
||||
|
||||
- [ ] **Step 0a: Start from clean main**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only
|
||||
git worktree add -b feat/rip-legacy-cli ../sendspin-go-worktrees/rip-legacy-cli origin/main
|
||||
cd ../sendspin-go-worktrees/rip-legacy-cli
|
||||
```
|
||||
|
||||
- [ ] **Step 0b: Verify baseline green before any changes**
|
||||
|
||||
```bash
|
||||
export PATH="/c/msys64/mingw64/bin:$PATH"
|
||||
go build ./...
|
||||
go test -count=1 ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages pass. If anything is red before you start, stop and investigate.
|
||||
|
||||
- [ ] **Step 0c: Commit the plan doc on the feature branch**
|
||||
|
||||
```bash
|
||||
# If the plan file is on main, pull it in. If the plan lives elsewhere, cp or cherry-pick.
|
||||
# Assuming the plan file is already at docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md on main:
|
||||
git log --oneline main -- docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
|
||||
# If the file exists, it's already on the branch via the fresh origin/main base.
|
||||
# If not, commit it explicitly:
|
||||
# git add docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
|
||||
# git commit -m "docs: add rip-legacy-cli implementation plan"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Migrate `internal/ui` to `pkg/sync.Quality`
|
||||
|
||||
`internal/ui/model.go` and its test file both import `internal/sync` for the `Quality` enum. Switch them to `pkg/sync`, which has the same type and the same constants (`QualityGood`, `QualityDegraded`, `QualityLost`). After this task, `internal/ui` no longer imports `internal/sync`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/ui/model.go`
|
||||
- Modify: `internal/ui/model_test.go`
|
||||
|
||||
- [ ] **Step 1: Update the import in `internal/ui/model.go`**
|
||||
|
||||
Find the line:
|
||||
|
||||
```go
|
||||
"github.com/Sendspin/sendspin-go/internal/sync"
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```go
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
```
|
||||
|
||||
The package name used in the file is `sync` in both cases (no alias needed). All existing references like `sync.Quality`, `sync.QualityGood`, `sync.QualityDegraded`, `sync.QualityLost` continue to resolve correctly because `pkg/sync` has the same identifiers.
|
||||
|
||||
- [ ] **Step 2: Update the import in `internal/ui/model_test.go`**
|
||||
|
||||
Same replacement. If the test file uses `sync.Quality*` constants they all resolve against the new package identically.
|
||||
|
||||
- [ ] **Step 3: Verify `internal/ui` builds and tests pass**
|
||||
|
||||
```bash
|
||||
go build ./internal/ui/
|
||||
go test ./internal/ui/
|
||||
```
|
||||
Expected: clean build, all tests pass.
|
||||
|
||||
- [ ] **Step 4: Verify `main.go` still builds**
|
||||
|
||||
```bash
|
||||
go build .
|
||||
```
|
||||
Expected: clean. `main.go` still converts `pkg/sync.Quality` → `internal/sync.Quality` in its own code at this point; that conversion keeps working because `internal/sync` is still alive.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/ui/model.go internal/ui/model_test.go
|
||||
git commit -m "refactor(internal/ui): use pkg/sync.Quality instead of internal/sync.Quality
|
||||
|
||||
pkg/sync.Quality is byte-for-byte equivalent to internal/sync.Quality
|
||||
(same type, same three constants in the same iota order). Switching
|
||||
the TUI's only internal/sync consumer is step 1 of deleting the
|
||||
legacy internal/ stack."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Drop the `internal/sync` conversion layer in `main.go`
|
||||
|
||||
`main.go` currently imports both `pkg/sync` (as `sync`) and `internal/sync` (as `internalsync`), and explicitly converts `pkg/sync.Quality` to `internal/sync.Quality` before constructing a `ui.StatusMsg`. After Task 1, `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality`, so the conversion is dead ceremony. Remove it and drop the `internal/sync` import.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.go`
|
||||
|
||||
- [ ] **Step 1: Remove the `internalsync` import**
|
||||
|
||||
Find in the import block:
|
||||
|
||||
```go
|
||||
internalsync "github.com/Sendspin/sendspin-go/internal/sync"
|
||||
```
|
||||
|
||||
Delete that line.
|
||||
|
||||
- [ ] **Step 2: Remove the Quality conversion block**
|
||||
|
||||
Find in `statsUpdateLoop` (currently around lines 207–216):
|
||||
|
||||
```go
|
||||
// Convert pkg/sync.Quality to internal/sync.Quality
|
||||
var syncQuality internalsync.Quality
|
||||
switch stats.SyncQuality {
|
||||
case 0: // QualityGood
|
||||
syncQuality = internalsync.QualityGood
|
||||
case 1: // QualityDegraded
|
||||
syncQuality = internalsync.QualityDegraded
|
||||
case 2: // QualityLost
|
||||
syncQuality = internalsync.QualityLost
|
||||
}
|
||||
```
|
||||
|
||||
Delete the entire block.
|
||||
|
||||
Then find the `ui.StatusMsg` construction that uses the `syncQuality` local variable (a few lines below the deleted block):
|
||||
|
||||
```go
|
||||
updateTUI(ui.StatusMsg{
|
||||
Received: stats.Received,
|
||||
Played: stats.Played,
|
||||
Dropped: stats.Dropped,
|
||||
BufferDepth: stats.BufferDepth,
|
||||
SyncRTT: stats.SyncRTT,
|
||||
SyncQuality: syncQuality,
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemAlloc: 0,
|
||||
MemSys: 0,
|
||||
})
|
||||
```
|
||||
|
||||
Change the `SyncQuality: syncQuality,` line to:
|
||||
|
||||
```go
|
||||
SyncQuality: stats.SyncQuality,
|
||||
```
|
||||
|
||||
(Passing `pkg/sync.Quality` directly, now that `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality` after Task 1.)
|
||||
|
||||
- [ ] **Step 3: Verify the root binary builds**
|
||||
|
||||
```bash
|
||||
go build .
|
||||
```
|
||||
Expected: clean build, produces `sendspin-player` (or `sendspin-player.exe` on Windows). If you see `undefined: internalsync`, a reference was missed — grep `internalsync` in `main.go` and remove any remaining hits.
|
||||
|
||||
- [ ] **Step 4: Verify full build and tests**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages still pass. `internal/sync` is still present and still imported by `internal/app/player.go` and `internal/player/scheduler.go` — those die in a later task.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.go
|
||||
git commit -m "refactor(main): drop internal/sync conversion layer
|
||||
|
||||
ui.StatusMsg.SyncQuality is now pkg/sync.Quality (see prior commit),
|
||||
so the pkg/sync -> internal/sync conversion block is pure ceremony.
|
||||
Pass stats.SyncQuality through directly."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Delete `cmd/ma-player` and `cmd/test-sync`
|
||||
|
||||
Both CLIs import `internal/app`, which is the only non-legacy consumer of that package. Deleting them orphans `internal/app` for deletion in the next task.
|
||||
|
||||
**Files:**
|
||||
- Delete: `cmd/ma-player/main.go`
|
||||
- Delete: `cmd/test-sync/main.go`
|
||||
|
||||
- [ ] **Step 1: Confirm these are the only importers of `internal/app`**
|
||||
|
||||
```bash
|
||||
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
|
||||
```
|
||||
Expected output (exactly):
|
||||
```
|
||||
./cmd/ma-player/main.go
|
||||
./cmd/test-sync/main.go
|
||||
```
|
||||
If there are other hits, STOP and report as BLOCKED — the scope was assumed wrong.
|
||||
|
||||
- [ ] **Step 2: Delete the CLI directories**
|
||||
|
||||
```bash
|
||||
git rm -r cmd/ma-player cmd/test-sync
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check the Makefile for references**
|
||||
|
||||
```bash
|
||||
grep -n "ma-player\|test-sync" Makefile
|
||||
```
|
||||
|
||||
If any targets or recipes name `ma-player` or `test-sync`, delete those lines. The existing `build`, `server`, `player`, `test`, `lint`, `clean`, `install` targets should be unaffected. Example: if you see
|
||||
|
||||
```makefile
|
||||
ma-player:
|
||||
go build -o ma-player ./cmd/ma-player
|
||||
```
|
||||
|
||||
delete the target and any references to it in the phony list or the top-level `all:`/`build:` dependency lists.
|
||||
|
||||
- [ ] **Step 4: Check the release workflow for references**
|
||||
|
||||
```bash
|
||||
grep -n "ma-player\|test-sync" .github/workflows/release.yml
|
||||
```
|
||||
|
||||
If any build steps name these binaries, delete them. This partially addresses issue #35 (release workflow gap) by removing the entries that will no longer exist.
|
||||
|
||||
- [ ] **Step 5: Verify the full module still builds**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
Expected: clean. Nothing compiles against `cmd/ma-player` or `cmd/test-sync` (they were standalone `package main` binaries), so the rest of the tree builds fine.
|
||||
|
||||
- [ ] **Step 6: Verify `internal/app` now has no importers**
|
||||
|
||||
```bash
|
||||
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
|
||||
```
|
||||
Expected output: empty (no hits).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A cmd/ Makefile .github/workflows/release.yml
|
||||
git commit -m "refactor: delete cmd/ma-player and cmd/test-sync
|
||||
|
||||
ma-player duplicated sendspin-player's functionality via the legacy
|
||||
internal/app stack; verified empirically that sendspin-player.exe
|
||||
handles the Music Assistant case cleanly, including 192kHz/24-bit
|
||||
PCM hi-res streaming (v1.2.0 verification logs). test-sync was a
|
||||
one-off clock-sync debug harness with no remaining users. Removing
|
||||
both is step 1 of deleting the duplicate internal/ pipeline."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Delete `internal/app`
|
||||
|
||||
After Task 3, no caller remains. `internal/app/player.go` and its test file both die. This orphans `internal/audio`, `internal/client`, `internal/player`, `internal/artwork`, `internal/version`, and `internal/discovery` of their legacy consumer — though some of those packages (version, discovery) still have non-legacy consumers and survive.
|
||||
|
||||
**Files:**
|
||||
- Delete: `internal/app/player.go`
|
||||
- Delete: `internal/app/player_test.go`
|
||||
|
||||
- [ ] **Step 1: Confirm `internal/app` still has zero importers**
|
||||
|
||||
```bash
|
||||
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
|
||||
```
|
||||
Expected output: empty.
|
||||
|
||||
- [ ] **Step 2: Delete the package**
|
||||
|
||||
```bash
|
||||
git rm -r internal/app
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build and test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test -count=1 ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all surviving packages build and pass. `internal/audio`, `internal/client`, `internal/player`, `internal/artwork` will still build independently — their tests continue to pass within their own packages because they don't depend on `internal/app`. The one surprise that could happen is if any package depends on `internal/app` in a way the grep missed (e.g., via test helpers or a build-tagged file). If the build fails, STOP and report.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A internal/app/
|
||||
git commit -m "refactor(internal/app): delete legacy player orchestrator
|
||||
|
||||
Only callers were cmd/ma-player and cmd/test-sync, both deleted in
|
||||
the previous commit. This removes ~800 lines of duplicate pipeline
|
||||
wiring that predated pkg/sendspin."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Delete `internal/player`
|
||||
|
||||
`internal/player/scheduler.go` was only used by `internal/app`. After Task 4, it has no importers. This is the last non-test consumer of `internal/audio` and (along with the earlier `internal/ui`/`main.go` migration) of `internal/sync`.
|
||||
|
||||
**Files:**
|
||||
- Delete: `internal/player/scheduler.go`
|
||||
- Delete: `internal/player/scheduler_test.go`
|
||||
|
||||
- [ ] **Step 1: Confirm `internal/player` has zero importers**
|
||||
|
||||
```bash
|
||||
grep -rln "\"github.com/Sendspin/sendspin-go/internal/player\"" --include='*.go' .
|
||||
```
|
||||
Expected output: empty.
|
||||
|
||||
- [ ] **Step 2: Delete the package**
|
||||
|
||||
```bash
|
||||
git rm -r internal/player
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build and test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test -count=1 ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A internal/player/
|
||||
git commit -m "refactor(internal/player): delete legacy scheduler
|
||||
|
||||
The only caller, internal/app, was deleted in the previous commit.
|
||||
pkg/sendspin has its own Scheduler that serves the modern player
|
||||
pipeline."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Delete the remaining orphaned `internal/` packages
|
||||
|
||||
After Tasks 4 and 5, the following packages have no importers anywhere in the tree:
|
||||
|
||||
- `internal/audio/` (decoder.go, decoder_test.go, types.go)
|
||||
- `internal/client/` (websocket.go, websocket_test.go)
|
||||
- `internal/artwork/` (downloader.go, downloader_test.go)
|
||||
- `internal/sync/` (clock.go, clock_test.go)
|
||||
- `internal/protocol/` (messages.go, messages_test.go) — already orphaned before this PR; deleting it now finishes the sweep
|
||||
|
||||
**Files:**
|
||||
- Delete: `internal/audio/` (directory)
|
||||
- Delete: `internal/client/` (directory)
|
||||
- Delete: `internal/artwork/` (directory)
|
||||
- Delete: `internal/sync/` (directory)
|
||||
- Delete: `internal/protocol/` (directory)
|
||||
|
||||
- [ ] **Step 1: Confirm every target package has zero importers**
|
||||
|
||||
```bash
|
||||
for pkg in audio client artwork sync protocol; do
|
||||
echo "=== internal/$pkg ==="
|
||||
grep -rln "\"github.com/Sendspin/sendspin-go/internal/$pkg\"" --include='*.go' . || echo "(clean)"
|
||||
done
|
||||
```
|
||||
Expected: all five should print `(clean)`. If any one still has importers, STOP and report — earlier tasks missed something.
|
||||
|
||||
- [ ] **Step 2: Delete the five packages**
|
||||
|
||||
```bash
|
||||
git rm -r internal/audio internal/client internal/artwork internal/sync internal/protocol
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build and test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test -count=1 ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all surviving packages pass. The only remaining `internal/` packages should now be:
|
||||
|
||||
```bash
|
||||
ls internal/
|
||||
# expected: discovery server ui version
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A internal/
|
||||
git commit -m "refactor: delete orphaned internal/ packages
|
||||
|
||||
After removing internal/app and internal/player, the following
|
||||
packages have no remaining importers in the tree:
|
||||
|
||||
- internal/audio (duplicate of pkg/audio/decode)
|
||||
- internal/client (duplicate of pkg/protocol.Client)
|
||||
- internal/artwork (unused by pkg/sendspin.Player)
|
||||
- internal/sync (duplicate of pkg/sync)
|
||||
- internal/protocol (already orphaned pre-PR)
|
||||
|
||||
Surviving internal/ packages: discovery, server, ui, version."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Delete the `pkg/sync` deprecation shims
|
||||
|
||||
With `internal/player/scheduler.go` and `internal/app/player.go` deleted, nothing in the tree reads from the package-level `sync.ServerMicrosNow()` or writes to the global via `sync.SetGlobalClockSync()`. The compatibility shim at `pkg/sendspin/player.go` (line ~157 per PR #26 final state) also becomes dead — it was only there so the `internal/` readers could see the `ClockSync` via the global.
|
||||
|
||||
This resolves issue #28 (sync deprecation migration) in full.
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/sync/clock.go`
|
||||
- Modify: `pkg/sendspin/player.go`
|
||||
|
||||
- [ ] **Step 1: Confirm no caller of `sync.ServerMicrosNow()` or `sync.SetGlobalClockSync`**
|
||||
|
||||
```bash
|
||||
grep -rn "sync\.ServerMicrosNow\|sync\.SetGlobalClockSync" --include='*.go' . | grep -v "_test.go" | grep -v "pkg/sync/clock.go"
|
||||
```
|
||||
Expected: no hits in production code. Test files may still reference the deprecated symbols if the `pkg/sync` package's own tests exercise them — those will need updating in Step 3 if so.
|
||||
|
||||
- [ ] **Step 2: Delete the deprecated package-level functions from `pkg/sync/clock.go`**
|
||||
|
||||
In `pkg/sync/clock.go`, find and delete these three sections:
|
||||
|
||||
```go
|
||||
// Deprecated: ServerMicrosNow returns current time in server's reference frame (us).
|
||||
// Use ClockSync.ServerMicrosNow() on the instance from Receiver.ClockSync() instead.
|
||||
func ServerMicrosNow() int64 {
|
||||
cs := globalClockSync
|
||||
// ... (entire function body)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
var (
|
||||
globalClockSync *ClockSync
|
||||
globalDeprecationWarned bool
|
||||
)
|
||||
|
||||
// Deprecated: SetGlobalClockSync sets the global clock sync instance.
|
||||
// Use Receiver.ClockSync() instead for new code.
|
||||
func SetGlobalClockSync(cs *ClockSync) {
|
||||
if !globalDeprecationWarned {
|
||||
log.Printf("Warning: SetGlobalClockSync is deprecated, use Receiver.ClockSync() instead")
|
||||
globalDeprecationWarned = true
|
||||
}
|
||||
globalClockSync = cs
|
||||
}
|
||||
```
|
||||
|
||||
Delete both declarations (`var (...)` block and both functions) entirely. Do NOT touch the instance method `(cs *ClockSync) ServerMicrosNow()` — that one stays; it's the replacement API.
|
||||
|
||||
After the delete, check whether `pkg/sync/clock.go` still imports `"log"`. If `log.Printf` is no longer called anywhere in the file, `go build` will flag the unused import — remove it.
|
||||
|
||||
- [ ] **Step 3: Update `pkg/sync/clock_test.go` if it references the deleted symbols**
|
||||
|
||||
```bash
|
||||
grep -n "ServerMicrosNow\b\|SetGlobalClockSync\|globalClockSync" pkg/sync/clock_test.go
|
||||
```
|
||||
|
||||
If the test file references the package-level `ServerMicrosNow()` (no receiver) or `SetGlobalClockSync`, those tests need to migrate to the instance method `cs.ServerMicrosNow()` or be deleted. In particular, if there's a `TestServerMicrosNow` (package-level) AND a `TestClockSync_ServerMicrosNow` (instance method), keep the latter and delete the former.
|
||||
|
||||
If `pkg/sync/clock_test.go` has no references to the deleted symbols, skip this step.
|
||||
|
||||
- [ ] **Step 4: Delete the compat shim from `pkg/sendspin/player.go`**
|
||||
|
||||
In `pkg/sendspin/player.go`, find the line (currently around line 157, inside `Connect()`):
|
||||
|
||||
```go
|
||||
// Backward compat: set global clock sync
|
||||
sync.SetGlobalClockSync(recv.ClockSync())
|
||||
```
|
||||
|
||||
Delete both lines (the comment and the call).
|
||||
|
||||
After the delete, check whether `pkg/sendspin/player.go` still uses the `"github.com/Sendspin/sendspin-go/pkg/sync"` import for anything. If `sync.` no longer appears in the file, remove the import. If it appears for other types (e.g., `sync.Quality`), leave the import alone.
|
||||
|
||||
- [ ] **Step 5: Verify build and test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test -count=1 ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: all packages pass. Particularly `pkg/sync`, `pkg/sendspin`, and `main.go` all still build cleanly.
|
||||
|
||||
- [ ] **Step 6: Grep to confirm the deprecation warning string is gone**
|
||||
|
||||
```bash
|
||||
grep -rn "SetGlobalClockSync is deprecated" --include='*.go' .
|
||||
```
|
||||
Expected: no hits. The deprecation warning no longer exists because the function that emits it no longer exists.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/sync/clock.go pkg/sync/clock_test.go pkg/sendspin/player.go
|
||||
git commit -m "refactor(pkg/sync): delete deprecated global clock-sync shims
|
||||
|
||||
The only callers of sync.ServerMicrosNow() (package-level) and
|
||||
sync.SetGlobalClockSync() lived in the internal/ legacy stack,
|
||||
which was deleted in earlier commits. The instance method
|
||||
(*ClockSync).ServerMicrosNow() is now the only way to read
|
||||
server-frame time.
|
||||
|
||||
Resolves #28."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final sweep and `go mod tidy`
|
||||
|
||||
Dropping the `internal/` packages may have orphaned module dependencies that were only pulled in by the legacy stack. Run `go mod tidy` to clean `go.mod` and `go.sum`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`, `go.sum` (if anything changed)
|
||||
|
||||
- [ ] **Step 1: Run `go mod tidy`**
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Review the diff**
|
||||
|
||||
```bash
|
||||
git diff go.mod go.sum
|
||||
```
|
||||
|
||||
If `go.mod` / `go.sum` are unchanged, skip steps 3 and 4. If they are changed, read the diff — you should only see REMOVALS (a dependency that `internal/client` or `internal/audio` was the only user of). Any ADDITION is suspicious; investigate before committing.
|
||||
|
||||
Candidate dependencies that may disappear:
|
||||
- Anything the legacy `internal/client/websocket.go` used that `pkg/protocol/client.go` doesn't
|
||||
- Anything `internal/audio/decoder.go` used that `pkg/audio/decode/` doesn't
|
||||
|
||||
- [ ] **Step 3: Verify final build and full test suite**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test -count=1 ./... 2>&1 | tail -25
|
||||
```
|
||||
Expected: clean + all packages pass.
|
||||
|
||||
- [ ] **Step 4: Commit (skip if `go mod tidy` was a no-op)**
|
||||
|
||||
```bash
|
||||
git add go.mod go.sum
|
||||
git commit -m "chore(deps): go mod tidy after legacy stack removal"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Final structural check**
|
||||
|
||||
```bash
|
||||
ls internal/
|
||||
# expected: discovery server ui version
|
||||
|
||||
find cmd -type d
|
||||
# expected: cmd cmd/sendspin-server
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Sanity: grep for any string references to deleted binaries**
|
||||
|
||||
```bash
|
||||
grep -rn "ma-player\|test-sync" --include='*.md' --include='*.yml' --include='*.sh' --include='Makefile' .
|
||||
```
|
||||
|
||||
Review every hit. Historical notes in `CHANGELOG.md`, `docs/PHASE1_IMPLEMENTATION.md`, and frozen plan docs under `docs/superpowers/plans/` may legitimately mention them — leave those alone. Current-state references (instructions, scripts, CI configs) need cleanup. Commit any doc updates as a separate trailing commit if needed:
|
||||
|
||||
```bash
|
||||
git add <any updated docs>
|
||||
git commit -m "docs: remove stale references to deleted ma-player/test-sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Open the PR
|
||||
|
||||
- [ ] **Step 1: Push the feature branch**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/rip-legacy-cli
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open the PR**
|
||||
|
||||
```bash
|
||||
gh pr create --title "refactor: delete legacy internal/ CLI stack (ma-player, test-sync, internal/app and deps)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Delete \`cmd/ma-player\`, \`cmd/test-sync\`, and the entire \`internal/\` legacy player pipeline they depended on:
|
||||
|
||||
- \`cmd/ma-player/\`, \`cmd/test-sync/\`
|
||||
- \`internal/app/\`, \`internal/audio/\`, \`internal/client/\`, \`internal/player/\`, \`internal/artwork/\`, \`internal/sync/\`, \`internal/protocol/\`
|
||||
|
||||
Surviving \`internal/\` packages: \`discovery\`, \`server\`, \`ui\`, \`version\`.
|
||||
|
||||
Also removes the \`pkg/sync.SetGlobalClockSync\` / \`ServerMicrosNow\` package-level deprecation shims (and the compat-shim call in \`pkg/sendspin.Player.Connect\`), now that no readers remain. Resolves #28.
|
||||
|
||||
## Why
|
||||
|
||||
\`ma-player\` duplicated \`sendspin-player\`'s functionality via the legacy \`internal/\` stack. After PR #26 migrated \`internal/app.Player\`'s output layer to \`pkg/audio/output.Malgo\`, the only difference between the two binaries was that one went through \`pkg/sendspin.Receiver\` and the other went through \`internal/app.Player\` → \`internal/client\` → \`internal/player.Scheduler\`. Both negotiate the same hi-res-first format list, both route 24-bit PCM through malgo, both connect to Music Assistant. Empirically verified: \`sendspin-player.exe\` was tested end-to-end against a real Music Assistant server at 192kHz/24-bit during the v1.2.0 verification pass — log evidence in \`sendspin-player.log\`.
|
||||
|
||||
\`test-sync\` was a one-off clock-sync debugging CLI from the earlier "make server sync work" era; nobody runs it today. It's in git history if anyone needs it back.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In:** delete the two CLIs and the seven orphaned \`internal/\` packages; migrate \`internal/ui\` and \`main.go\` off \`internal/sync.Quality\` onto \`pkg/sync.Quality\`; delete the \`pkg/sync\` deprecation shims.
|
||||
- **Out:** \`internal/server\` (still used by \`cmd/sendspin-server\` and \`pkg/sendspin/server.go\`), \`internal/discovery\`, \`internal/ui\`, \`internal/version\` — all still have non-legacy consumers.
|
||||
|
||||
## Stats
|
||||
|
||||
~2500–2600 lines deleted across ~17 files. No new functionality, no behavioural change to \`sendspin-player\` or \`sendspin-server\`.
|
||||
|
||||
## Resolves
|
||||
|
||||
- #28 — deprecated \`sync.SetGlobalClockSync\` / \`sync.ServerMicrosNow\` shims removed along with their last readers.
|
||||
- Partially addresses #35 — release workflow now only needs to ship \`sendspin-player\` and \`sendspin-server\` (ma-player gone).
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] \`go build ./...\` clean
|
||||
- [x] \`go test -count=1 ./...\` green on every surviving package
|
||||
- [ ] **Manual playback verification** — run \`./sendspin-player.exe --server <addr>\` against a real server and confirm audio plays, volume/mute work, TUI shows expected state. The expected behavior is identical to v1.2.0 since \`sendspin-player\` was untouched by this PR.
|
||||
|
||||
## Rollback
|
||||
|
||||
\`git revert\` the PR merge commit. All deletions are in one linear chain; a revert restores the full legacy stack with no partial state.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist (before merging the PR)
|
||||
|
||||
- [ ] `go build ./...` clean on a fresh checkout
|
||||
- [ ] `go test -count=1 ./...` green
|
||||
- [ ] `ls internal/` shows exactly `discovery server ui version`
|
||||
- [ ] `ls cmd/` shows exactly `sendspin-server`
|
||||
- [ ] `grep -rn "internal/app\|internal/client\|internal/player\|internal/audio\|internal/artwork\|internal/sync\|internal/protocol" --include='*.go' .` returns no hits
|
||||
- [ ] `grep -rn "SetGlobalClockSync\|package-level ServerMicrosNow" --include='*.go' .` returns no hits
|
||||
- [ ] `./sendspin-player` plays audio end-to-end against a real server (manual)
|
||||
- [ ] `./sendspin-server` accepts a client and streams audio (manual)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If anything turns out to depend on a deleted symbol and can't be trivially replaced:
|
||||
|
||||
1. `git revert` the PR merge commit. The full chain was designed as one revertable unit.
|
||||
2. `go mod tidy` restores any dependencies that got pruned.
|
||||
3. The legacy stack returns to life with no partial state.
|
||||
|
||||
The most likely sources of trouble, in order of probability:
|
||||
|
||||
1. An external consumer of the library that imports `internal/` (shouldn't happen — `internal/` packages are not importable from outside the module, by Go spec).
|
||||
2. A build-tagged or generated file that references one of the deleted packages (extremely unlikely in this repo; grep confirmed no such files).
|
||||
3. A script in `scripts/` or a CI workflow that invokes `ma-player` or `test-sync` binaries directly (Task 3 Step 4 sweeps for these).
|
||||
|
||||
None of these are expected to actually bite.
|
||||
1372
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md
vendored
Normal file
1372
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1278
third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md
vendored
Normal file
1278
third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1012
third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md
vendored
Normal file
1012
third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
276
third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md
vendored
Normal file
276
third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
# sendspin-server: YAML config file + daemon mode
|
||||
|
||||
**Status:** Design approved 2026-04-20
|
||||
**Tracks:** parity with `sendspin-player` config (closes equivalent of #40 for the server side)
|
||||
|
||||
## Problem
|
||||
|
||||
`sendspin-server` has no config-file support and no `--daemon` flag. To run it
|
||||
under systemd today, an operator has to stuff every option into `ExecStart` or
|
||||
an env file, and logging fights `journalctl` because the binary always opens
|
||||
`sendspin-server.log`. The player solved this with a three-layer precedence
|
||||
model (CLI > env > YAML > default), `WriteStringKey` for comment-preserving
|
||||
round-trips, and a `--daemon` flag that logs to stdout only. The server should
|
||||
get the same treatment.
|
||||
|
||||
## Goals
|
||||
|
||||
1. YAML config file at `/etc/sendspin/server.yaml` or `~/.config/sendspin/server.yaml` with the same search-order contract as `player.yaml`.
|
||||
2. `SENDSPIN_SERVER_*` env var overlay with the same precedence rules as the player.
|
||||
3. A `--daemon` flag that logs to stdout only (journalctl-friendly) and suppresses both the TUI and the log file.
|
||||
4. `systemctl enable --now sendspin-server` works after `make install-daemon`.
|
||||
5. Zero behavior change for users who don't opt in to any of the above.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No write-back.** The server has no analog of the player's `client_id`; mDNS rediscovery handles instance identity at the network layer. `WriteStringKey` stays unused by the server (and stays generic in case future features need it).
|
||||
- **No `-stream-logs` alias.** The player has it for historical reasons; the server starts clean without the redundant alias.
|
||||
- **No unified `ConfigFile` struct.** Each binary keeps its own typed struct. Only the machinery (search paths, flag overlay) is shared.
|
||||
|
||||
## Approach
|
||||
|
||||
Refactor `pkg/sendspin/config.go` so the generic machinery takes a
|
||||
`(envPrefix, map[string]string)` pair instead of a typed `*PlayerConfigFile`,
|
||||
then add a parallel `ServerConfigFile` / `LoadServerConfig` /
|
||||
`DefaultServerConfigPath` surface alongside the player's. The player's
|
||||
`asStringMap()` already produces exactly the map the refactored function
|
||||
needs, so the call-site change is one line.
|
||||
|
||||
No public API outside `pkg/sendspin` uses the old `ApplyEnvAndFile` signature
|
||||
(verified by grep: only `main.go`, `config.go`, and `config_test.go`). The
|
||||
technically-breaking signature change is contained.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `pkg/sendspin/config.go` — refactor
|
||||
|
||||
Change:
|
||||
|
||||
```go
|
||||
// Before
|
||||
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, cfg *PlayerConfigFile) error
|
||||
|
||||
// After
|
||||
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error
|
||||
```
|
||||
|
||||
New unexported helpers:
|
||||
|
||||
- `loadYAMLConfig(searchPaths []string, out any) (string, error)` — opens the first existing file, unmarshals into `out`. Missing files are not an error (`(nil, "", nil)` equivalent).
|
||||
- `userConfigPath(relative string) (string, error)` — wraps `os.UserConfigDir() + "/sendspin/" + relative`. Used by both `DefaultPlayerConfigPath` and `DefaultServerConfigPath`.
|
||||
|
||||
Existing public surface (`WriteStringKey`, `topLevelMapping`, `setOrAppendStringKey`, `atomicWriteFile`) is already generic and unchanged.
|
||||
|
||||
### `pkg/sendspin/config.go` — new server surface
|
||||
|
||||
```go
|
||||
const ServerEnvPrefix = "SENDSPIN_SERVER_"
|
||||
|
||||
type ServerConfigFile struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Port *int `yaml:"port,omitempty"`
|
||||
LogFile string `yaml:"log_file,omitempty"`
|
||||
Debug *bool `yaml:"debug,omitempty"`
|
||||
NoMDNS *bool `yaml:"no_mdns,omitempty"`
|
||||
NoTUI *bool `yaml:"no_tui,omitempty"`
|
||||
Audio string `yaml:"audio,omitempty"`
|
||||
DiscoverClients *bool `yaml:"discover_clients,omitempty"`
|
||||
Daemon *bool `yaml:"daemon,omitempty"`
|
||||
}
|
||||
|
||||
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error)
|
||||
func DefaultServerConfigPath() (string, error)
|
||||
func (c *ServerConfigFile) asStringMap() map[string]string
|
||||
```
|
||||
|
||||
`LoadServerConfig` search order (first existing wins; missing is not an error):
|
||||
|
||||
1. `explicitPath` if non-empty
|
||||
2. `$SENDSPIN_SERVER_CONFIG`
|
||||
3. `<UserConfigDir>/sendspin/server.yaml`
|
||||
4. `/etc/sendspin/server.yaml`
|
||||
|
||||
### Player call-site update
|
||||
|
||||
```go
|
||||
// main.go (player)
|
||||
if err := sendspin.ApplyEnvAndFile(
|
||||
flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap(),
|
||||
); err != nil { ... }
|
||||
```
|
||||
|
||||
`cfg.asStringMap()` on a nil `*PlayerConfigFile` must keep returning an empty map (existing behavior — confirmed in `TestApplyEnvAndFile_NilConfigStillHonorsEnv`). Preserve that contract.
|
||||
|
||||
### `cmd/sendspin-server/main.go` — new flags & wiring
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
|
||||
configPath = flag.String("config", "", "Path to server.yaml config file. Default search: $SENDSPIN_SERVER_CONFIG, ~/.config/sendspin/server.yaml, /etc/sendspin/server.yaml.")
|
||||
```
|
||||
|
||||
After `flag.Parse()`:
|
||||
|
||||
```go
|
||||
setByUser := map[string]bool{"config": true}
|
||||
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
|
||||
|
||||
cfg, _, err := sendspin.LoadServerConfig(*configPath)
|
||||
if err != nil { log.Fatalf("config: %v", err) }
|
||||
if err := sendspin.ApplyEnvAndFile(
|
||||
flag.CommandLine, setByUser, sendspin.ServerEnvPrefix, cfg.asStringMap(),
|
||||
); err != nil { log.Fatalf("config overlay: %v", err) }
|
||||
```
|
||||
|
||||
### Daemon mode
|
||||
|
||||
- `useTUI := !(*noTUI || *daemon)`
|
||||
- Logging branch:
|
||||
- `-daemon` → `log.SetOutput(os.Stdout)`; skip opening `*logFile`.
|
||||
- TUI on → log to file only (unchanged).
|
||||
- Neither → `io.MultiWriter(os.Stdout, f)` (unchanged).
|
||||
- Non-TUI startup banner logs `"Starting Sendspin Server: %s (port %d)"` and, when `-daemon`, adds `"Daemon mode: logging to stdout only"`.
|
||||
|
||||
### Distribution artifacts
|
||||
|
||||
**`dist/systemd/sendspin-server.service`** — shape mirrors `sendspin-player.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Sendspin Server
|
||||
Documentation=https://github.com/Sendspin/sendspin-go
|
||||
After=network-online.target sound.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/sendspin-server --daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=-/etc/default/sendspin-server
|
||||
ExecStart=
|
||||
ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
`ProtectHome=read-only` (not `yes`) so `-audio /home/user/Music/...` still works.
|
||||
|
||||
**`dist/systemd/sendspin-server.env`** — operator-editable env file installed to `/etc/default/sendspin-server`:
|
||||
|
||||
```sh
|
||||
# sendspin-server: extra CLI flags appended to ExecStart
|
||||
# Prefer /etc/sendspin/server.yaml for structured config; use this for
|
||||
# ad-hoc overrides or keys that aren't (yet) in the YAML.
|
||||
# SENDSPIN_SERVER_OPTS="--debug --discover-clients"
|
||||
SENDSPIN_SERVER_OPTS=""
|
||||
```
|
||||
|
||||
**`dist/config/server.example.yaml`** — annotated starter, every key commented out so the file is a no-op until edited:
|
||||
|
||||
```yaml
|
||||
# sendspin-server configuration
|
||||
#
|
||||
# Search order (first existing file wins; missing is not an error):
|
||||
# 1. --config <path>
|
||||
# 2. $SENDSPIN_SERVER_CONFIG
|
||||
# 3. ~/.config/sendspin/server.yaml (user install)
|
||||
# 4. /etc/sendspin/server.yaml (daemon / system-wide)
|
||||
#
|
||||
# Per-value precedence for every key below:
|
||||
# CLI flag > SENDSPIN_SERVER_<UPPER_SNAKE> env > this file > built-in default
|
||||
|
||||
# --- Identity ---
|
||||
# name: "Living Room Server"
|
||||
|
||||
# --- Network ---
|
||||
# port: 8927
|
||||
# no_mdns: false
|
||||
# discover_clients: false
|
||||
|
||||
# --- Audio source ---
|
||||
# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone.
|
||||
# audio: "/srv/music/radio.m3u8"
|
||||
|
||||
# --- Logging / runtime ---
|
||||
# daemon: false
|
||||
# no_tui: false
|
||||
# log_file: "sendspin-server.log"
|
||||
# debug: false
|
||||
```
|
||||
|
||||
### Makefile
|
||||
|
||||
Split `install-daemon` / `uninstall-daemon` into leaf + aggregate targets:
|
||||
|
||||
- `install-player-daemon` — existing player install logic, renamed.
|
||||
- `install-server-daemon` — new; installs binary to `/usr/local/bin`, unit file to `/etc/systemd/system`, env file to `/etc/default/sendspin-server`, example YAML to `/etc/sendspin/server.yaml`. Each of the two editable files is installed only if absent (guard pattern from the player).
|
||||
- `install-daemon` — aggregate: `install-player-daemon install-server-daemon`.
|
||||
- `uninstall-server-daemon` — stops/disables unit, removes binary and unit file, leaves `/etc/default/sendspin-server` and `/etc/sendspin/server.yaml` intact.
|
||||
- `uninstall-daemon` — aggregate.
|
||||
|
||||
`clean` already removes `sendspin-server`; no change.
|
||||
|
||||
## Flag → YAML key mapping
|
||||
|
||||
| Flag | YAML key | Type | Default |
|
||||
|---|---|---|---|
|
||||
| `-port` | `port` | `*int` | 8927 |
|
||||
| `-name` | `name` | `string` | `<hostname>-sendspin-server` |
|
||||
| `-log-file` | `log_file` | `string` | `sendspin-server.log` |
|
||||
| `-debug` | `debug` | `*bool` | false |
|
||||
| `-no-mdns` | `no_mdns` | `*bool` | false |
|
||||
| `-no-tui` | `no_tui` | `*bool` | false |
|
||||
| `-audio` | `audio` | `string` | *(empty → test tone)* |
|
||||
| `-discover-clients` | `discover_clients` | `*bool` | false |
|
||||
| `-daemon` | `daemon` | `*bool` | false |
|
||||
| `-config` | *(not in YAML — circular)* | `string` | *(empty)* |
|
||||
|
||||
## Precedence
|
||||
|
||||
For every key: **CLI flag > `SENDSPIN_SERVER_<UPPER_SNAKE>` env > `server.yaml` > built-in default**.
|
||||
|
||||
Matches the player exactly. Enforced by the shared `ApplyEnvAndFile` helper.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Invalid YAML → `log.Fatalf("config: %v", err)` at startup.
|
||||
- Invalid env var (e.g., `SENDSPIN_SERVER_PORT=abc`) → fatal; error message includes the offending flag name. Behavior inherited from the shared helper.
|
||||
- Missing config file → silent no-op; all keys fall through to flag defaults. This is the documented contract.
|
||||
- `-audio` validation stays in `server.NewAudioSource`; config loading only carries the string.
|
||||
- `-daemon` combined with TUI flags → daemon wins, no warning. Matches player behavior.
|
||||
|
||||
## Testing
|
||||
|
||||
**Existing player tests** (`pkg/sendspin/config_test.go`) — each `ApplyEnvAndFile(..., cfg)` call-site must be rewritten to `ApplyEnvAndFile(..., PlayerEnvPrefix, cfg.asStringMap())` to match the new signature. `TestApplyEnvAndFile_NilConfigStillHonorsEnv` passes a nil map instead of a nil struct (a nil `map[string]string` iterates as empty, so the env-only path behaves identically). No assertion changes; the precedence tests then exercise the shared code path for both binaries.
|
||||
|
||||
**New server tests** — structural coverage only:
|
||||
|
||||
- `TestLoadServerConfig_ExplicitPathWithAllKeys` — round-trip every `ServerConfigFile` field through YAML.
|
||||
- `TestLoadServerConfig_MissingFileIsNotAnError` — contract symmetry.
|
||||
- `TestLoadServerConfig_EnvPathHonored` — `SENDSPIN_SERVER_CONFIG` picked up.
|
||||
- `TestApplyEnvAndFile_ServerEnvPrefix` — confirms the generalized `envPrefix` parameter routes `SENDSPIN_SERVER_*` correctly.
|
||||
|
||||
No new `WriteStringKey` tests (server doesn't use write-back).
|
||||
|
||||
## Manual verification (part of the plan's completion step)
|
||||
|
||||
1. `sendspin-server` with no config file → unchanged behavior.
|
||||
2. `sendspin-server --config /tmp/s.yaml` with `port: 9000` → binds 9000.
|
||||
3. `SENDSPIN_SERVER_PORT=9001 sendspin-server --config /tmp/s.yaml` → binds 9001 (env beats file).
|
||||
4. `sendspin-server --port 9002 --config /tmp/s.yaml` → binds 9002 (CLI beats env + file).
|
||||
5. `sendspin-server --daemon` → no TUI, stdout logging with timestamps, no `sendspin-server.log` created.
|
||||
6. Linux box: `make install-daemon && systemctl enable --now sendspin-server && journalctl -u sendspin-server -f` → clean startup.
|
||||
|
||||
## Out-of-scope (future work)
|
||||
|
||||
- `server_id` / persistent identity for the server.
|
||||
- A unified `pkg/sendspin/configfile` sub-package if a third binary ever joins (YAGNI until then).
|
||||
- README deep-dive on daemon operation — this work adds a one-line pointer only.
|
||||
97
third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md
vendored
Normal file
97
third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# sendspin-player: Raspberry Pi quickstart script
|
||||
|
||||
**Status:** Design approved 2026-05-01
|
||||
**Tracks:** lower the on-ramp for Pi-as-player setups using prebuilt arm64 release tarballs
|
||||
|
||||
## Problem
|
||||
|
||||
Setting up a Raspberry Pi as a Sendspin player today requires the user to: install build-time deps via `install-deps.sh`, clone the repo, build with the CGo toolchain, then either run the binary by hand or wire up systemd through `make install-player-daemon`. None of that is friendly for a "stick a Pi behind the speakers and forget about it" workflow, and almost all of it is unnecessary now that the GitHub Releases pipeline ships ready-to-run `linux-arm64` tarballs.
|
||||
|
||||
A `curl | sudo bash` quickstart that takes a fresh 64-bit Raspberry Pi OS install to a running, mDNS-discoverable player in one command closes that gap.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Single-command install: `curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash` brings up the player on a fresh 64-bit Raspberry Pi OS — Lite is the recommended target (headless, no PulseAudio/PipeWire, lower scheduling jitter), full desktop also works. Bookworm or newer is required for the `libflac12` runtime package.
|
||||
2. Idempotent re-run: invoking the script a second time upgrades the binary and refreshes the unit file in place.
|
||||
3. Optional one-shot configuration via flags: `bash -s -- --name living-room --device "USB DAC"`.
|
||||
4. Clean uninstall via `--uninstall`.
|
||||
5. Pin a specific version via `--version v1.6.2` (default: latest).
|
||||
6. Zero changes to existing build, daemon-install, or release flows.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No 32-bit Pi support.** Releases ship `linux-arm64` only. The script detects `armv6l` / `armv7l` and exits with a clear pointer to 64-bit Raspberry Pi OS. Pi 1 / Zero (v1) / Zero W are excluded by hardware; Pi 2 is excluded by OS choice.
|
||||
- **No non-Debian distros.** The script's package-install step targets `apt-get`. Other distros must follow the README's manual install steps.
|
||||
- **No Debian < 12 (Bullseye and earlier).** The script hard-codes `libflac12`, which is Bookworm's package name. On older Debian / Pi OS, `apt-get install libflac12` fails loudly with "no installation candidate" — that error is acceptable as the user-facing rejection rather than adding fallback logic.
|
||||
- **No checksum / signature verification.** HTTPS to `github.com` is the same trust boundary the `curl | bash` itself crosses; layering a second integrity check adds operational cost without materially changing the threat model. May reconsider if signed releases land later.
|
||||
- **No interactive device picker.** `--device "<exact name>"` is the documented path. Users wanting a list run `sendspin-player --list-audio-devices` after install.
|
||||
- **No support for running `sendspin-server` via this script.** Pi-as-server is a less common configuration; if it becomes one, a sibling script is the right move, not flag bloat in this one.
|
||||
- **No purge of `/etc/sendspin/` on `--uninstall`.** Matches the existing `make uninstall-player-daemon` behavior; user state is precious.
|
||||
|
||||
## Approach
|
||||
|
||||
A single shell script at `scripts/quickstart-pi.sh`, fetched via raw GitHub URL and piped to `sudo bash`. Self-contained: every helper is inlined, no second fetch, no source tree dependency.
|
||||
|
||||
The script reuses every artifact already shipped in the repo:
|
||||
|
||||
- The `dist/systemd/sendspin-player.service` unit (already root-running with `ProtectSystem=strict` / `ProtectHome=read-only` hardening; no change).
|
||||
- The `dist/systemd/sendspin-player.env` file (already wires `SENDSPIN_PLAYER_OPTS` into `ExecStart`).
|
||||
- The `dist/config/player.example.yaml` file (already configured with sensible defaults; the daemon writes its own `client_id` back on first launch).
|
||||
|
||||
These are downloaded directly from `https://raw.githubusercontent.com/Sendspin/sendspin-go/<ref>/dist/...` at the resolved release tag (so a `--version v1.6.2` install gets the unit/config/env files from that tag, not from `main`).
|
||||
|
||||
The release tarball comes from GitHub's `/releases/latest/download/` redirect by default, or `/releases/download/<tag>/` when `--version` is set. No GitHub API call, no JSON parsing.
|
||||
|
||||
`/etc/default/sendspin-player` is touched only on first install OR when `--name` / `--device` are passed on this invocation. That makes "re-run with new flags" the documented reconfigure path; "re-run with no flags" is purely an upgrade.
|
||||
|
||||
## Flow
|
||||
|
||||
The script executes the following stages in order. Any non-zero exit aborts the run; idempotency is the recovery story.
|
||||
|
||||
1. **Argument parse.** `--name <s>`, `--device <s>`, `--version <tag>`, `--uninstall`, `-h|--help`. Unknown flags abort with usage.
|
||||
2. **Pre-flight.**
|
||||
- Effective UID must be 0 (else: print sudo hint, exit).
|
||||
- `uname -m` must be `aarch64` (else: print 64-bit-OS hint, exit).
|
||||
- `/etc/debian_version` must exist (else: point at README manual steps, exit).
|
||||
- `command -v systemctl` must succeed.
|
||||
3. **Uninstall short-circuit.** If `--uninstall` is set:
|
||||
- `systemctl disable --now sendspin-player` (tolerate "no such unit" cleanly).
|
||||
- Remove `/usr/local/bin/sendspin-player` and `/etc/systemd/system/sendspin-player.service`.
|
||||
- `systemctl daemon-reload`.
|
||||
- Print note: "Config preserved at `/etc/sendspin/` and `/etc/default/sendspin-player`. Remove manually for a full purge."
|
||||
- Exit 0.
|
||||
4. **Install runtime deps.** `apt-get update && apt-get install -y libopus0 libopusfile0 libflac12 libasound2 ca-certificates curl tar`. `libasound2` is the ALSA userspace library miniaudio links against; it is usually present on Pi OS Lite but not guaranteed on a truly minimal image, so we install it explicitly.
|
||||
5. **Resolve version.** Default URL: `https://github.com/Sendspin/sendspin-go/releases/latest/download/sendspin-player-linux-arm64.tar.gz`. With `--version`: `https://github.com/Sendspin/sendspin-go/releases/download/<tag>/sendspin-player-linux-arm64.tar.gz`. Same logic produces the `dist/...` raw URL ref: `main` for default, `<tag>` for pinned.
|
||||
6. **Stop service if running.** `systemctl is-active --quiet sendspin-player && systemctl stop sendspin-player`.
|
||||
7. **Download + extract.** `curl -fSL <url> -o $TMP/sp.tar.gz`, `tar -xzf $TMP/sp.tar.gz -C $TMP`, `install -m 755 $TMP/sendspin-player /usr/local/bin/sendspin-player`. `$TMP` is `mktemp -d` and removed via `EXIT` trap.
|
||||
8. **Install systemd unit.** `curl -fSL <raw-dist-url>/systemd/sendspin-player.service -o /etc/systemd/system/sendspin-player.service`. Always overwritten.
|
||||
9. **Install env file (conditional).**
|
||||
- If `/etc/default/sendspin-player` does not exist, fetch `dist/systemd/sendspin-player.env` from raw GitHub.
|
||||
- If `--name` or `--device` were passed, write `SENDSPIN_PLAYER_OPTS="--name <n> --audio-device <d>"` to `/etc/default/sendspin-player` (overwriting). Each flag value is wrapped in single quotes with embedded single quotes escaped via the standard `'\''` pattern, so names like `Bob's Living Room` survive intact. Only the flags actually passed are emitted.
|
||||
- Otherwise (file exists, no flags), leave it alone.
|
||||
10. **Install YAML config (conditional).** If `/etc/sendspin/player.yaml` does not exist, `mkdir -p /etc/sendspin && curl -fSL <raw-dist-url>/config/player.example.yaml -o /etc/sendspin/player.yaml`. Otherwise leave it alone (preserves any `client_id` the daemon has already written back).
|
||||
11. **Reload + enable + start.** `systemctl daemon-reload && systemctl enable --now sendspin-player`.
|
||||
12. **Health check.** Sleep 2s; check `systemctl is-active sendspin-player`. If not active, print last 20 lines of `journalctl -u sendspin-player --no-pager` and exit non-zero.
|
||||
13. **Success message.** Print resolved version, config file path, env file path, and `journalctl -u sendspin-player -f` for live logs.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `set -euo pipefail` at the top of the script.
|
||||
- Every download uses `curl -fSL` so a 404 (e.g. typo'd `--version`) exits cleanly with the curl error.
|
||||
- `EXIT` trap removes the staging tempdir and, on non-zero status, prints a one-line "If install failed mid-way, re-run the script — it's idempotent" hint.
|
||||
- The binary is `install -m 755`'d only after the tarball extracts cleanly. A failed download leaves `/usr/local/bin/sendspin-player` untouched.
|
||||
- Pre-flight failures print the *specific* missing requirement, never a generic "system not supported".
|
||||
|
||||
## Testing
|
||||
|
||||
- **Local smoke**: run in `arm64v8/debian:bookworm` (without systemd — verify deps install, binary lands, files in place; `systemctl` steps will fail loudly which is expected without an init system).
|
||||
- **Real Pi**: run the actual `curl | bash` on a fresh 64-bit Raspberry Pi OS install, confirm the player appears in mDNS and Music Assistant.
|
||||
- **Idempotency**: run twice on the Pi, confirm second invocation completes cleanly and the service stays up.
|
||||
- **Reconfigure**: run with `--name` after a vanilla install, confirm `/etc/default/sendspin-player` is rewritten and the service picks up the new name.
|
||||
- **Arch rejection**: run on `armv7l` (real Pi 2 or `qemu-user-static`), confirm clean error message naming the supported set.
|
||||
- **Uninstall**: `bash quickstart-pi.sh --uninstall`, confirm service stopped/disabled, binary and unit removed, config preserved.
|
||||
- **CI**: add `shellcheck scripts/quickstart-pi.sh` to the existing GitHub Actions lint workflow. No new workflow.
|
||||
|
||||
## Open questions
|
||||
|
||||
None at design time. Flag any post-implementation surprises in the PR.
|
||||
Reference in New Issue
Block a user