From 92bdfcb29484017154e3722c8f44af987ff1dfde Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 1 Jul 2026 22:26:07 +0800 Subject: [PATCH 01/59] docs(spotify): design spec for optional hybrid librespot audio source (#112) Adds the approved design for a new optional `spotify` MusicProvider that streams real Spotify audio via a librespot-family sidecar behind one SpotifyAudioBackend interface (go-librespot on Linux/Docker, Rust librespot on Windows), with metadata via the Spotify Web API. Disabled by default, opt-in, Premium-only, ToS-risk warned. Includes verified research appendix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-01-spotify-source-design.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-spotify-source-design.md diff --git a/docs/superpowers/specs/2026-07-01-spotify-source-design.md b/docs/superpowers/specs/2026-07-01-spotify-source-design.md new file mode 100644 index 0000000..441c019 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-spotify-source-design.md @@ -0,0 +1,202 @@ +# Spotify audio source (optional, hybrid librespot) — design spec + +- **Issue:** [#112 — Support for Spotify audio source](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/112) +- **Date:** 2026-07-01 +- **Status:** Approved (design) — pending implementation plan +- **Chosen approach:** Hybrid — real Spotify streaming via a librespot-family sidecar, with **go-librespot on Linux/Docker** and **Rust librespot on Windows**, behind one backend interface. + +--- + +## 1. Summary + +Add `spotify` as a new **optional** `MusicProvider`. Metadata (search / track / album / playlist) is read from the official **Spotify Web API**. Audio is the *real* Spotify stream, produced by a librespot-family sidecar and piped through ffmpeg into the bot's existing voice path. + +The feature is **disabled by default**, opt-in, and **requires the user's own Spotify Premium account**. librespot is an unofficial, reverse-engineered client and using it **violates Spotify's Terms of Service** (account-ban risk). This is surfaced to the user as an explicit experimental warning, and no credentials are ever bundled. + +If the feature is disabled, unauthenticated, or the sidecar binary is missing, the provider disables itself gracefully — exactly like `YouTubeProvider` when `yt-dlp` is absent (empty results, greyed-out in the UI, no crash). + +## 2. Goals / Non-goals + +**Goals** +- First-class `spotify` source: search, play, playlist/album import, lyrics best-effort, login status. +- Real Spotify audio (Premium), not a YouTube match. +- Cross-platform: works on the project's three deployments — native Windows one-click, Linux systemd, Docker. +- Strictly optional and safe-by-default; zero impact when off. +- Mixed queues keep working (Spotify tracks interleaved with netease/qq/etc.). + +**Non-goals** +- No free-tier audio (Premium is mandatory for librespot streaming). +- No bundled Spotify credentials or shared Developer app. +- No replacement of, or change to, existing sources' behavior. +- No CI e2e against live Spotify (requires Premium; manual only). + +## 3. Backend selection + +`spotify.backend: "auto" | "go-librespot" | "librespot"` (default `auto`). + +| Platform | `auto` resolves to | Why | +|---|---|---| +| Windows | `librespot` (Rust) | go-librespot has **no** Windows binary and its FIFO capture is POSIX-only | +| Linux / Docker | `go-librespot` (fallback `librespot`) | clean REST play-by-URI + prebuilt Linux binary | +| macOS | `librespot` (or `go-librespot` if built) | no go-librespot macOS binary published | + +The split is **platform-determined**, not a per-run user toggle (a user may still force one via config if they have the binary). + +## 4. Architecture + +### 4.1 The seam — `SpotifyAudioBackend` + +New file `src/music/spotify/backend.ts`: + +```ts +export interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error"; } + +export interface SpotifyAudioBackend { + start(): Promise; // launch sidecar + resampling ffmpeg + stop(): void; // tear everything down + isReady(): boolean; // device online & streamable + playTrack(uri: string): Promise; // begin ONE track (spotify:track:...) + pause(): Promise; + resume(): Promise; + seek(ms: number): Promise; + getPcmStream(): Readable; // 48kHz s16le stereo (post-ffmpeg) + getPosition(): number; // ms into current track + on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void; + on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void; + on(event: "ready" | "error", cb: (arg?: unknown) => void): void; +} +``` + +Both implementations emit the **same** 48 kHz s16le stereo PCM and the **same** events, so everything above the seam (provider, controller, player, UI) is backend-agnostic. + +### 4.2 `GoLibrespotBackend` (Linux/Docker) — `src/music/spotify/go-librespot.ts` + +- Writes a `config.yml` with `server: { enabled: true, address: 'localhost', port:

}`, `audio_backend: 'pipe'`, `audio_output_pipe: ''`, `audio_output_pipe_format: 's16le'`, `bitrate: 320`, credentials block. +- `start()`: `mkfifo ` → **spawn the FIFO-reading ffmpeg first** → then spawn go-librespot. (FIFO open ordering is mandatory: go-librespot opens the write end with `O_WRONLY|O_NONBLOCK` and errors `ENXIO` if no reader exists yet.) ffmpeg: `-f s16le -ar 44100 -ac 2 -i -f s16le -ar 48000 -ac 2 -`. ffmpeg stdout = `getPcmStream()`. +- Control (REST): `POST /player/play {uri}`, `POST /player/pause`, `POST /player/resume`, `POST /player/seek {position}`. `GET /status` for position. +- Events (WebSocket `/events`): `metadata` → `metadata`; `not_playing` → `trackEnded` (track boundaries can NOT be read from the FIFO — it is gapless/continuous and never EOFs between tracks). +- Metadata token: go-librespot exposes `POST /token` (first-party session token) and a `/web-api/` proxy to `api.spotify.com` — so on this backend a separate Spotify Developer app is **optional**. +- Recovery: on reader death go-librespot's write returns `EPIPE` and closes the pipe output; backend restarts the ffmpeg reader + reactivates the device. + +### 4.3 `RustLibrespotBackend` (Windows / cross-platform) — `src/music/spotify/rust-librespot.ts` + +Rust librespot is a **passive Spotify Connect receiver**; the bot acts as the **Connect controller** via the Spotify Web API. + +- `start()`: spawn `librespot --backend pipe --name "" --bitrate 320 --cache

[--access-token | --enable-oauth]`. With **no `--device`**, librespot writes raw PCM (**s16le, 44100 Hz, stereo**) to **stdout** (cross-platform; verified against `pipe.rs` — `None => Box::new(io::stdout())`). Pipe stdout → ffmpeg `-f s16le -ar 44100 -ac 2 -i pipe:0 -f s16le -ar 48000 -ac 2 -` → `getPcmStream()`. Do **not** pass `--passthrough` (that emits raw Ogg, not PCM). +- `playTrack(uri)`: `GET /v1/me/player/devices` → find our `device_id` by `deviceName` → `PUT /v1/me/player/play?device_id={id}` body `{uris:[uri]}`. `pause/resume/seek` → `PUT /v1/me/player/{pause,play,seek}`. +- `trackEnded`: poll `GET /v1/me/player` (is_playing → false / `item` changed / `progress_ms ≈ duration_ms`) plus optional `--onevent` hook (read-only notifications). Add a watchdog + retry/backoff for device-visibility latency and command `202/404` flakiness. +- Requires a **user OAuth token** with scopes `streaming user-read-playback-state user-modify-playback-state user-read-currently-playing` (+ `playlist-read-private playlist-read-collaborative` for user playlists). + +## 5. Shared subsystems + +### 5.1 Metadata — `src/music/spotify/webapi.ts` + +Thin `axios` client mapping Spotify catalog objects → the bot's `Song` / `Playlist` / `Album`: +- `GET /v1/search?type=track,album,playlist&q=…` +- `GET /v1/tracks/{id}`, `GET /v1/albums/{id}/tracks`, `GET /v1/playlists/{id}/tracks` + +All confirmed still available with a normal token **after** Spotify's 2024-11-27 cut (that cut removed related-artists, recommendations, audio-features/analysis, featured/category playlists, and `preview_url` — none of which we use). Token source is pluggable: user Developer-app token (primary) or go-librespot `/web-api` proxy (go-librespot path). Handle `429 Retry-After` (rolling 30 s window; dev-mode quota). + +### 5.2 Auth — `src/music/spotify/auth.ts` + +One web-UI **Authorization-Code + PKCE** login. +- **Primary:** the user registers their own Spotify Developer app (Client ID [+ Secret] + redirect URI) — reliable, ToS-cleaner. The resulting access token drives metadata + Web-API control; the refresh token is persisted (credential store); access tokens (~1 h) auto-refresh. The same token bootstraps librespot via `--access-token`, after which librespot caches reusable credentials — **one user-facing login**. +- **Fallbacks:** librespot `--enable-oauth` (built-in client `65b708073fc0480ea92a077233ca87bd`, redirect `http://127.0.0.1:8898/login`) or go-librespot interactive login (`http://127.0.0.1:36842/login?code=…`) as a separate one-time step; go-librespot `/web-api` proxy when no Developer app is provided. +- Username/password is **dead** (removed by Spotify in 2024); do not implement it. + +### 5.3 Provider — `src/music/spotify/provider.ts` + +`SpotifyProvider implements MusicProvider` with `platform: "spotify"`: +- `search`, `getSongDetail`, `getPlaylistSongs`, `getAlbumSongs`, `getLyrics` (best-effort/empty), `getRecommendPlaylists` → Web API. +- `getAuthStatus()` reports login state **and** backend/binary availability (drives greying-out in UI, like YouTube). +- `getSongUrl(id)` returns a **sentinel** (`{ url: "spotify:track:" }`) — actual playback is via the backend/controller, not a URL. `instance.ts` recognizes the sentinel and routes to the `SpotifyController`. +- QR-code login methods are no-ops; Spotify uses the OAuth card instead. + +## 6. Queue / player / instance integration + +- **`SpotifyController`** (`src/music/spotify/controller.ts`, one per bot): owns the chosen backend and the Web-API/auth clients; exposes `playTrack/pause/resume/seek/stop` and forwards `trackEnded`/`metadata`. +- **`AudioPlayer` — new external-PCM mode:** add `playPcmStream(readable, { onExternalEnd })` that feeds the existing `pcmBuffer` → 20 ms frame loop → encoder → `frame` path **without spawning a url-ffmpeg**. For Spotify, `trackEnd` is driven by the backend's `trackEnded` event (the librespot→ffmpeg pipeline is long-lived and does not exit per song). `pause/resume/seek` on a Spotify song are routed to the backend by `instance.ts` (and gate frame emission locally for crisp UI state). +- **`instance.ts`:** `getProviderFor("spotify")`, a `-s` command flag in `getProvider(flags)`. When a dequeued `song.platform === "spotify"`: ensure the controller/backend is started + device active, `playTrack(uri)`, attach the player to the backend PCM. On `trackEnded` → advance the queue. When a **non-Spotify** song is next, **pause the sidecar** (so it doesn't buffer ahead) and use the normal `player.play(url)` path. This preserves one-track-at-a-time on-demand playback and mixed-source queues. +- Real-time pacing is guaranteed by the voice consumer: TS voice pulls 20 ms frames at real time → the player reads PCM at real time → ffmpeg's read of the sidecar stalls → sidecar backpressure pauses decode. (This is why go-librespot's pull model avoids the classic librespot "plays too fast / skips" bug; Rust librespot to stdout is likewise paced by our reads.) + +## 7. Config, opt-in & safety + +`BotConfig.spotify` (all default-off), added to `getDefaultConfig()` and sanitized in `loadConfig()`: + +```ts +spotify: { + enabled: false, + backend: "auto", // "auto" | "go-librespot" | "librespot" + clientId: "", // user's Developer app (optional on go-librespot path) + clientSecret: "", // optional — PKCE needs none; only for confidential/client-credentials flows + deviceName: "TSMusicBot", + bitrate: 320, // 96 | 160 | 320 +} +``` + +Inert unless `enabled` **and** logged-in **and** a resolvable binary. First-run/settings shows the experimental + ToS + Premium + own-credentials warning. Never store or transmit shared secrets. + +## 8. Web UI + +- Add `spotify` to `platform` and `Source` unions (`web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`). +- `SourceTabs.vue`: add "Spotify" tab; `SongCard.vue`: green **#1DB954** badge; `variables.scss`: `--brand-spotify` tokens. +- `stores/player.ts`: extend `authStatus` / `recommendPlaylists` / `dailySongs` / `userPlaylists` maps + the auth/recommend fetch fan-out. +- Settings: a **Spotify login card distinct from the QR cards** — "Connect Spotify" OAuth button, optional Client ID/Secret fields, backend/binary status indicator, and the risk disclaimer. + +## 9. Binary / dependency resolution — `src/music/spotify/binary.ts` + +Mirror `findYtDlp()`: resolve `go-librespot` / `librespot(.exe)` from `bin/` then PATH; positive availability cached, negative retried (install-while-running). +- **No prebuilt Rust librespot exists** (source-only: `cargo install librespot`, distro/`scoop`/`choco`, or a binary dropped in `bin/`). +- **go-librespot** ships **Linux-only** assets (`go-librespot_linux_{x86_64,arm64,armv6,armv6_rpi}.tar.gz`, ~6 MB, at `github.com/devgianlu/go-librespot/releases`). Docker build downloads the correct Linux asset; optional runtime auto-download (like yt-dlp). go-librespot is **GPL-3.0** → sidecar = mere aggregation (does not infect the Node code); ship its license text + a source offer. +- Unresolved binary → source disabled with an actionable "install X / see docs" message. + +## 10. Testing + +Vitest, mocking child processes and HTTP: +- Web-API response → `Song/Playlist/Album` mapping. +- `chooseBackend()` per platform/config. +- Auth: PKCE challenge, token refresh, credential persistence. +- Config load/sanitize (defaults, hand-edited/corrupt input). +- `SpotifyProvider` methods (mock webapi). +- Player external-PCM path: feed a fake `Readable` → assert `frame` emission + `trackEnd` on external end. +- go-librespot `/events` → `trackEnded` translation; Rust-backend Web-API poll → `trackEnded`. +- e2e against live Spotify is **manual & documented** (Premium required), not CI. + +## 11. Files touched + +**New:** `src/music/spotify/{provider,backend,go-librespot,rust-librespot,webapi,auth,controller,binary}.ts` (+ `.test.ts`). +**Edited (backend):** `src/music/provider.ts` (union: `Song`/`Playlist`/`Album`/`MusicProvider`), `src/index.ts`, `src/bot/manager.ts`, `src/bot/instance.ts` (router + `-s` flag + spotify routing), `src/audio/player.ts` (external-PCM mode), `src/data/config.ts`, `src/music/auth.ts` (credential store union), `src/web/api/auth.ts`, `src/web/api/music.ts` (routers + search aggregation), `src/web/server.ts`, `src/data/database.ts` (platform). +**Edited (frontend):** `web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`, `web/src/components/SourceTabs.vue`, `web/src/components/SongCard.vue`, `web/src/styles/variables.scss`. +**Docs:** `README.md` (Spotify section + warnings + install notes). + +## 12. Staged rollout + +1. **Metadata + provider + config + UI plumbing** (no audio): `spotify` source searchable/browsable; playback returns "not yet playable". Fully testable without a sidecar. +2. **go-librespot backend (Linux/Docker):** real playback on Linux; REST + FIFO + WebSocket. +3. **Rust librespot backend (Windows):** stdout PCM + Web-API Connect control. +4. **Polish:** recovery/watchdog, docs, binary auto-download, README. + +## 13. Risks & open questions + +- **Rust librespot control is the top risk** — Connect device visibility/latency, poll-based track-end. Mitigation: retry/backoff + watchdog; degrade to "skipped" on repeated failure. +- **One-OAuth-token bootstraps librespot** (`--access-token` from the user's own app client) is *plausible but unverified* — may fall back to two one-time logins. Verify with a spike in stage 3. +- **Windows go-librespot is impossible** (FIFO) → Windows always uses Rust librespot. +- **Token scope from librespot's built-in client** may not include `user-modify-playback-state`; if so, a user Developer app is required for the Rust/Windows control path. +- Large surface area → staged rollout above; each stage independently shippable. + +## Appendix A — Verified technical facts (with sources) + +go-librespot API (`devgianlu/go-librespot`, v0.7.x): +- REST: `POST /player/{play,pause,resume,playpause,stop,next,prev,seek,volume,add_to_queue}`, `GET /status`, `GET /` (`{playback_ready}`), `POST /token`, `GET|POST /web-api/`. `POST /player/play` body `{uri, skip_to_uri?, paused?}`. Server enabled only when `server.enabled: true`. +- WebSocket `/events` envelopes `{type, data}`; types include `metadata, will_play, playing, paused, not_playing, stopped, seek, volume, active, inactive`. Track-end = `not_playing`. +- Pipe backend: continuous raw PCM, no header, 44100 Hz stereo, format `s16le|s32le|f32le`; FIFO opened once; POSIX-only. Backpressure real (blocks when reader stalls). +- Sources: `github.com/devgianlu/go-librespot` (README, `api-spec.yml`, `daemon/api_server.go`, `output/driver-pipe.go`). + +Rust librespot (`librespot-org/librespot`, v0.8.0, MIT): +- `--backend pipe` with no `--device` → raw PCM to **stdout**; default S16 / 44100 / stereo; `--format` for higher bit depth; `--passthrough` = raw Ogg (do not use). Passive Connect receiver — no play-by-URI; control via Web API Connect. `--onevent` = read-only hook. No prebuilt binaries. +- Sources: `github.com/librespot-org/librespot` wiki (Audio-Backends, Options), `librespot_playback/audio_backend/pipe.rs`, `docs/authentication.md`. + +Spotify auth & Web API: +- Username/password removed (2024); use OAuth (Auth-Code+PKCE) or Zeroconf. Premium required for librespot audio. +- Client-Credentials token authorizes `/v1/search` + public track/album/playlist GETs; 2024-11-27 cut did **not** touch these. Token endpoint `POST https://accounts.spotify.com/api/token` (`grant_type=client_credentials`, Basic base64(id:secret), `expires_in=3600`, no refresh). 429 on a rolling 30 s window. +- Sources: `developer.spotify.com` (Web API docs; 2024-11-27 blog), `librespot` `docs/authentication.md`. From b352d71528d1bddca61235bc78bc80a18ffc860e Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 1 Jul 2026 23:04:35 +0800 Subject: [PATCH 02/59] docs(spotify): stage 1 implementation plan (metadata + provider + wiring) (#112) Bite-sized TDD plan for the first shippable increment: Spotify becomes a searchable/browsable source (Web API), with playback cleanly reporting "not playable yet". Adversarially verified against the codebase (3 critics) and fixed: getProviderFor signature, pre-existing config.test.ts imports, all three BotInstance sites, enabled-gate safety, 429 handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-07-01-spotify-stage1-metadata-provider.md | 1175 +++++++++++++++++ 1 file changed, 1175 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-spotify-stage1-metadata-provider.md diff --git a/docs/superpowers/plans/2026-07-01-spotify-stage1-metadata-provider.md b/docs/superpowers/plans/2026-07-01-spotify-stage1-metadata-provider.md new file mode 100644 index 0000000..d299416 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-spotify-stage1-metadata-provider.md @@ -0,0 +1,1175 @@ +# Spotify Source — Stage 1 (Metadata + Provider + Wiring) 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:** Add `spotify` as an optional `MusicProvider` that can search and browse Spotify via the Web API and appears fully in the bot + web UI; actual audio playback is deferred to Stage 2/3 and cleanly reports "not playable yet". + +**Architecture:** A new `src/music/spotify/` module hosts a `SpotifyWebApi` client (client-credentials token + catalog mappers) and a `SpotifyProvider` implementing the existing `MusicProvider` interface. `getSongUrl` returns a `spotify:track:` **sentinel**; the play path recognizes it and skips with a user message instead of spawning ffmpeg. The provider is threaded through the same wiring every other source uses (`index.ts` → `BotManager` → `BotInstance`, the two web routers, and the Vue frontend). + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), Node 25, `axios`, `vitest`, Vue 3 + Pinia frontend. + +## Global Constraints + +- ESM project: **all relative imports use the `.js` extension** (e.g. `./webapi.js`), even from `.ts` files. +- Spotify support is **disabled by default**, opt-in, Premium-only, and unofficial/ToS-risky — Stage 1 adds no audio and must not enable anything automatically. +- Metadata endpoints used are Client-Credentials-safe: `/v1/search`, `/v1/tracks/{id}`, `/v1/albums/{id}/tracks`, `/v1/playlists/{id}/tracks` (unaffected by Spotify's 2024-11-27 cut). +- `platform` is stored as SQLite `TEXT` — **no DB migration**; only the TypeScript union on `src/data/database.ts:11` changes. +- Follow existing provider conventions: pure exported mapper functions unit-tested with captured JSON shapes (see `src/music/kugou.test.ts`); providers degrade gracefully (empty results, never throw to callers) when unconfigured — mirror `YouTubeProvider`. +- Source command flag for Spotify is `-s`. Platform id is `"spotify"`. Brand color `#1DB954`. +- Run tests with `npx vitest run `; build check with `npx tsc --noEmit`. + +--- + +## File structure + +**New files** +- `src/music/spotify/webapi.ts` — Spotify Web API client + catalog→`Song`/`Album`/`Playlist` mappers + `isSpotifyUri`. +- `src/music/spotify/webapi.test.ts` — mapper + client tests. +- `src/music/spotify/provider.ts` — `SpotifyProvider implements MusicProvider`. +- `src/music/spotify/provider.test.ts` — provider tests (mocked webapi). + +**Modified files** +- `src/music/provider.ts` — add `"spotify"` to the platform unions. +- `src/data/database.ts:11` — add `"spotify"` to the `PlayHistory.platform` union. +- `src/data/config.ts` — add `spotify` config block + sanitize. +- `src/bot/manager.ts` — `spotifyProvider` field/param, pass into `BotInstance`. +- `src/bot/instance.ts` — options/field, `getProviderFor`, `-s` flag, sentinel skip. +- `src/index.ts` — instantiate `SpotifyProvider`, wire creds, pass to manager + web server. +- `src/web/api/music.ts` — `getProvider` router + quality aggregation. +- `src/web/api/auth.ts` — `getProvider` router. +- `src/web/server.ts` — thread `spotifyProvider` into the two API factories. +- `web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`, `web/src/components/SourceTabs.vue`, `web/src/components/SongCard.vue`, `web/src/styles/variables.scss` — frontend plumbing. + +--- + +## Task 1: Spotify Web API client + mappers + +**Files:** +- Create: `src/music/spotify/webapi.ts` +- Test: `src/music/spotify/webapi.test.ts` +- Modify: `src/music/provider.ts` (add `"spotify"` to unions) +- Modify: `src/data/database.ts` (line 11 union) + +**Interfaces:** +- Produces: + - `mapSpotifyTrack(raw: any): Song`, `mapSpotifyTracks(raw: any[]): Song[]`, `mapSpotifyAlbum(raw: any): Album`, `mapSpotifyPlaylist(raw: any): Playlist` + - `isSpotifyUri(url: string): boolean` + - `interface SpotifyCreds { clientId: string; clientSecret: string }` + - `class SpotifyWebApi` with `constructor(getCreds: () => SpotifyCreds, deps?: { http?: AxiosInstance; auth?: AxiosInstance })`, `setCreds(c: SpotifyCreds): void`, `hasCreds(): boolean`, `search(query: string, limit?: number): Promise`, `getTrack(id: string): Promise`, `getAlbumTracks(albumId: string): Promise`, `getPlaylistTracks(playlistId: string): Promise` + +- [ ] **Step 1: Add `"spotify"` to the platform unions in `src/music/provider.ts`** + +In `src/music/provider.ts`, every union currently reading `"netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"` gains `| "spotify"`. There are four: `Song.platform`, `Playlist.platform`, `Album.platform`, and `MusicProvider.platform`. Change each to: + +```ts +platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; +``` + +- [ ] **Step 2: Add `"spotify"` to the `PlayHistory` union in `src/data/database.ts:11`** + +```ts + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; +``` + +- [ ] **Step 3: Write the failing mapper tests** + +Create `src/music/spotify/webapi.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { + mapSpotifyTrack, + mapSpotifyTracks, + mapSpotifyAlbum, + mapSpotifyPlaylist, + isSpotifyUri, + SpotifyWebApi, +} from "./webapi.js"; + +describe("mapSpotifyTrack", () => { + // Shape trimmed from GET /v1/search?type=track. + const raw = { + id: "4iV5W9uYEdYUVa79Axb7Rh", + name: "Bohemian Rhapsody", + artists: [{ name: "Queen" }], + album: { name: "A Night at the Opera", images: [{ url: "https://i.scdn.co/x.jpg" }] }, + duration_ms: 354320, + }; + + it("maps a track to a Song with platform 'spotify' and seconds duration", () => { + const s = mapSpotifyTrack(raw); + expect(s.platform).toBe("spotify"); + expect(s.id).toBe("4iV5W9uYEdYUVa79Axb7Rh"); + expect(s.name).toBe("Bohemian Rhapsody"); + expect(s.artist).toBe("Queen"); + expect(s.album).toBe("A Night at the Opera"); + expect(s.duration).toBe(354); // 354320ms → 354s + expect(s.coverUrl).toBe("https://i.scdn.co/x.jpg"); + }); + + it("joins multiple artists with ', '", () => { + const s = mapSpotifyTrack({ ...raw, artists: [{ name: "A" }, { name: "B" }] }); + expect(s.artist).toBe("A, B"); + }); + + it("tolerates missing fields", () => { + const s = mapSpotifyTrack({}); + expect(s.id).toBe(""); + expect(s.name).toBe("Unknown"); + expect(s.artist).toBe(""); + expect(s.duration).toBe(0); + expect(s.coverUrl).toBe(""); + expect(s.platform).toBe("spotify"); + }); + + it("mapSpotifyTracks returns [] for non-array input", () => { + expect(mapSpotifyTracks(undefined as any)).toEqual([]); + }); +}); + +describe("mapSpotifyAlbum", () => { + it("maps an album with total_tracks → songCount", () => { + const a = mapSpotifyAlbum({ + id: "1abc", + name: "A Night at the Opera", + artists: [{ name: "Queen" }], + images: [{ url: "https://i.scdn.co/a.jpg" }], + total_tracks: 12, + }); + expect(a).toEqual({ + id: "1abc", + name: "A Night at the Opera", + artist: "Queen", + coverUrl: "https://i.scdn.co/a.jpg", + songCount: 12, + platform: "spotify", + }); + }); +}); + +describe("mapSpotifyPlaylist", () => { + it("maps a playlist with tracks.total → songCount", () => { + const p = mapSpotifyPlaylist({ + id: "37i9", + name: "Today's Top Hits", + images: [{ url: "https://i.scdn.co/p.jpg" }], + tracks: { total: 50 }, + }); + expect(p).toEqual({ + id: "37i9", + name: "Today's Top Hits", + coverUrl: "https://i.scdn.co/p.jpg", + songCount: 50, + platform: "spotify", + }); + }); +}); + +describe("isSpotifyUri", () => { + it("recognizes the sentinel URI", () => { + expect(isSpotifyUri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh")).toBe(true); + expect(isSpotifyUri("https://music.126.net/x.mp3")).toBe(false); + expect(isSpotifyUri("")).toBe(false); + }); +}); + +describe("SpotifyWebApi rate-limit handling", () => { + it("retries once on 429 (honoring Retry-After) then returns data", async () => { + const auth = { + post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }), + } as any; + let call = 0; + const http = { + get: vi.fn().mockImplementation(() => { + call += 1; + if (call === 1) { + return Promise.reject({ response: { status: 429, headers: { "retry-after": "0" } } }); + } + return Promise.resolve({ + data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } }, + }); + }), + } as any; + const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth }); + const out = await api.search("queen"); + expect(http.get).toHaveBeenCalledTimes(2); // one 429, one success + expect(out.songs[0].id).toBe("t1"); + }); + + it("returns empty results when unconfigured (no creds → no token)", async () => { + const api = new SpotifyWebApi(() => ({ clientId: "", clientSecret: "" })); + expect(await api.search("queen")).toEqual({ songs: [], playlists: [], albums: [] }); + }); +}); +``` + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `npx vitest run src/music/spotify/webapi.test.ts` +Expected: FAIL — `Cannot find module './webapi.js'`. + +- [ ] **Step 5: Implement `src/music/spotify/webapi.ts`** + +```ts +import axios, { type AxiosInstance } from "axios"; +import type { Song, Album, Playlist, SearchResult } from "../provider.js"; + +export interface SpotifyCreds { + clientId: string; + clientSecret: string; +} + +const ACCOUNTS_BASE = "https://accounts.spotify.com"; +const API_BASE = "https://api.spotify.com"; + +function artistsToString(artists: unknown): string { + return Array.isArray(artists) + ? artists.map((a: any) => a?.name).filter(Boolean).join(", ") + : ""; +} + +/** Map a Spotify track object (search / tracks / playlist item .track) to a Song. */ +export function mapSpotifyTrack(raw: any): Song { + return { + id: raw?.id ?? "", + name: raw?.name ?? "Unknown", + artist: artistsToString(raw?.artists), + album: raw?.album?.name ?? "", + duration: Math.round((raw?.duration_ms ?? 0) / 1000), + coverUrl: raw?.album?.images?.[0]?.url ?? "", + platform: "spotify", + }; +} + +export function mapSpotifyTracks(raw: any): Song[] { + return Array.isArray(raw) ? raw.map(mapSpotifyTrack) : []; +} + +export function mapSpotifyAlbum(raw: any): Album { + return { + id: raw?.id ?? "", + name: raw?.name ?? "Unknown", + artist: artistsToString(raw?.artists), + coverUrl: raw?.images?.[0]?.url ?? "", + songCount: raw?.total_tracks ?? 0, + platform: "spotify", + }; +} + +export function mapSpotifyPlaylist(raw: any): Playlist { + return { + id: raw?.id ?? "", + name: raw?.name ?? "Unknown", + coverUrl: raw?.images?.[0]?.url ?? "", + songCount: raw?.tracks?.total ?? 0, + platform: "spotify", + }; +} + +/** True for the getSongUrl sentinel (spotify:track:); real audio lands in Stage 2/3. */ +export function isSpotifyUri(url: string): boolean { + return typeof url === "string" && url.startsWith("spotify:"); +} + +export class SpotifyWebApi { + private getCreds: () => SpotifyCreds; + private http: AxiosInstance; + private auth: AxiosInstance; + private token = ""; + private tokenExpiresAt = 0; + + constructor( + getCreds: () => SpotifyCreds, + deps?: { http?: AxiosInstance; auth?: AxiosInstance } + ) { + this.getCreds = getCreds; + this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 }); + this.auth = deps?.auth ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 }); + } + + setCreds(_c: SpotifyCreds): void { + // Creds are read live via getCreds(); force a token refresh on next call. + this.token = ""; + this.tokenExpiresAt = 0; + } + + hasCreds(): boolean { + const c = this.getCreds(); + return !!c.clientId && !!c.clientSecret; + } + + /** Client-Credentials app token, cached until ~30s before expiry. */ + private async getToken(): Promise { + if (!this.hasCreds()) return null; + if (this.token && Date.now() < this.tokenExpiresAt) return this.token; + const { clientId, clientSecret } = this.getCreds(); + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); + try { + const { data } = await this.auth.post( + "/api/token", + "grant_type=client_credentials", + { + headers: { + Authorization: `Basic ${basic}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + this.token = data?.access_token ?? ""; + this.tokenExpiresAt = Date.now() + ((data?.expires_in ?? 3600) - 30) * 1000; + return this.token || null; + } catch { + return null; + } + } + + private async get( + path: string, + params?: Record, + retryOn429 = true + ): Promise { + const token = await this.getToken(); + if (!token) return null; + try { + const { data } = await this.http.get(path, { + params, + headers: { Authorization: `Bearer ${token}` }, + }); + return data; + } catch (err: any) { + // Spotify rate-limits on a rolling 30s window (429 + Retry-After seconds). + // Retry once after the advised delay before giving up. + if (retryOn429 && err?.response?.status === 429) { + const retryAfter = Number(err.response.headers?.["retry-after"] ?? 1); + await new Promise((r) => setTimeout(r, Math.min(retryAfter, 10) * 1000)); + return this.get(path, params, false); + } + return null; + } + } + + async search(query: string, limit = 20): Promise { + const data = await this.get("/v1/search", { + q: query, + type: "track,album,playlist", + limit, + }); + if (!data) return { songs: [], playlists: [], albums: [] }; + return { + songs: mapSpotifyTracks(data?.tracks?.items), + albums: Array.isArray(data?.albums?.items) + ? data.albums.items.filter(Boolean).map(mapSpotifyAlbum) + : [], + playlists: Array.isArray(data?.playlists?.items) + ? data.playlists.items.filter(Boolean).map(mapSpotifyPlaylist) + : [], + }; + } + + async getTrack(id: string): Promise { + const data = await this.get(`/v1/tracks/${id}`); + return data ? mapSpotifyTrack(data) : null; + } + + async getAlbumTracks(albumId: string): Promise { + // Album-track objects omit the album block; fetch the album cover once and inject it. + const album = await this.get(`/v1/albums/${albumId}`); + const cover = album?.images?.[0]?.url ?? ""; + const albumName = album?.name ?? ""; + const items = album?.tracks?.items; + if (!Array.isArray(items)) return []; + return items.filter(Boolean).map((t: any) => ({ + ...mapSpotifyTrack(t), + album: albumName, + coverUrl: cover, + })); + } + + async getPlaylistTracks(playlistId: string): Promise { + const data = await this.get(`/v1/playlists/${playlistId}/tracks`, { limit: 100 }); + const items = data?.items; + if (!Array.isArray(items)) return []; + return items + .map((it: any) => it?.track) + .filter((t: any) => t && t.id) + .map(mapSpotifyTrack); + } +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npx vitest run src/music/spotify/webapi.test.ts` +Expected: PASS (all mapper + `isSpotifyUri` tests green). + +- [ ] **Step 7: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/music/spotify/webapi.ts src/music/spotify/webapi.test.ts src/music/provider.ts src/data/database.ts +git commit -m "feat(spotify): Web API client + catalog mappers, add spotify platform" +``` + +--- + +## Task 2: SpotifyProvider + +**Files:** +- Create: `src/music/spotify/provider.ts` +- Test: `src/music/spotify/provider.test.ts` + +**Interfaces:** +- Consumes: `SpotifyWebApi`, `mapSpotify*`, `isSpotifyUri` (Task 1); `MusicProvider` and its DTOs (`src/music/provider.ts`). +- Produces: `class SpotifyProvider implements MusicProvider` with `constructor(api?: SpotifyWebApi)` and `setCreds(clientId: string, clientSecret: string): void`. + +- [ ] **Step 1: Write the failing provider test** + +Create `src/music/spotify/provider.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { SpotifyProvider } from "./provider.js"; +import { SpotifyWebApi } from "./webapi.js"; + +function fakeApi(over: Partial = {}): SpotifyWebApi { + return { + hasCreds: () => true, + setCreds: vi.fn(), + search: vi.fn().mockResolvedValue({ songs: [], playlists: [], albums: [] }), + getTrack: vi.fn().mockResolvedValue(null), + getAlbumTracks: vi.fn().mockResolvedValue([]), + getPlaylistTracks: vi.fn().mockResolvedValue([]), + ...over, + } as unknown as SpotifyWebApi; +} + +describe("SpotifyProvider", () => { + it("has platform 'spotify'", () => { + expect(new SpotifyProvider(fakeApi()).platform).toBe("spotify"); + }); + + it("getSongUrl returns the spotify: sentinel, not a real URL", async () => { + const p = new SpotifyProvider(fakeApi()); + const r = await p.getSongUrl("4iV5W9uYEdYUVa79Axb7Rh"); + expect(r).toEqual({ url: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh" }); + }); + + it("search delegates to the web API", async () => { + const api = fakeApi({ + search: vi.fn().mockResolvedValue({ + songs: [{ id: "t1", platform: "spotify" }], + playlists: [], + albums: [], + }), + }); + const out = await new SpotifyProvider(api).search("queen", 5); + expect(api.search).toHaveBeenCalledWith("queen", 5); + expect(out.songs[0].id).toBe("t1"); + }); + + it("getAuthStatus reflects credential presence", async () => { + expect((await new SpotifyProvider(fakeApi({ hasCreds: () => true })).getAuthStatus()).loggedIn).toBe(true); + expect((await new SpotifyProvider(fakeApi({ hasCreds: () => false })).getAuthStatus()).loggedIn).toBe(false); + }); + + it("getPlaylistSongs / getAlbumSongs delegate to the web API", async () => { + const api = fakeApi({ + getPlaylistTracks: vi.fn().mockResolvedValue([{ id: "p", platform: "spotify" }]), + getAlbumTracks: vi.fn().mockResolvedValue([{ id: "a", platform: "spotify" }]), + }); + const p = new SpotifyProvider(api); + expect((await p.getPlaylistSongs("37i9"))[0].id).toBe("p"); + expect((await p.getAlbumSongs("1abc"))[0].id).toBe("a"); + }); + + it("no-op auth surfaces (QR expired, empty lyrics/recommend)", async () => { + const p = new SpotifyProvider(fakeApi()); + expect(await p.getLyrics("x")).toEqual([]); + expect(await p.getRecommendPlaylists()).toEqual([]); + expect((await p.getQrCode()).key).toBe(""); + expect(await p.checkQrCodeStatus("k")).toBe("expired"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/music/spotify/provider.test.ts` +Expected: FAIL — `Cannot find module './provider.js'`. + +- [ ] **Step 3: Implement `src/music/spotify/provider.ts`** + +```ts +import type { + MusicProvider, + Song, + SongUrlResult, + Playlist, + Album, + SearchResult, + LyricLine, + QrCodeResult, + AuthStatus, +} from "../provider.js"; +import { SpotifyWebApi, type SpotifyCreds } from "./webapi.js"; + +export class SpotifyProvider implements MusicProvider { + readonly platform = "spotify" as const; + private api: SpotifyWebApi; + private creds: SpotifyCreds = { clientId: "", clientSecret: "" }; + private quality = "320"; + + constructor(api?: SpotifyWebApi) { + this.api = api ?? new SpotifyWebApi(() => this.creds); + } + + setCreds(clientId: string, clientSecret: string): void { + this.creds = { clientId: clientId ?? "", clientSecret: clientSecret ?? "" }; + this.api.setCreds(this.creds); + } + + async search(query: string, limit = 20): Promise { + return this.api.search(query, limit); + } + + // Stage 1: return a sentinel URI. The play path recognizes `spotify:` and + // skips with a "not playable yet" message; real audio arrives in Stage 2/3. + async getSongUrl(songId: string): Promise { + return { url: `spotify:track:${songId}` }; + } + + setQuality(quality: string): void { + this.quality = quality; + } + getQuality(): string { + return this.quality; + } + + async getSongDetail(songId: string): Promise { + return this.api.getTrack(songId); + } + + async getPlaylistSongs(playlistId: string): Promise { + return this.api.getPlaylistTracks(playlistId); + } + + async getAlbumSongs(albumId: string): Promise { + return this.api.getAlbumTracks(albumId); + } + + async getRecommendPlaylists(): Promise { + return []; + } + + async getLyrics(_songId: string): Promise { + return []; + } + + async getQrCode(): Promise { + return { qrUrl: "", key: "" }; + } + + async checkQrCodeStatus( + _key: string + ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { + return "expired"; + } + + setCookie(_cookie: string): void {} + getCookie(): string { + return ""; + } + + async getAuthStatus(): Promise { + return this.api.hasCreds() + ? { loggedIn: true, nickname: "Spotify" } + : { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" }; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/music/spotify/provider.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck + commit** + +```bash +npx tsc --noEmit +git add src/music/spotify/provider.ts src/music/spotify/provider.test.ts +git commit -m "feat(spotify): SpotifyProvider (search/browse; playback sentinel)" +``` + +--- + +## Task 3: Config block + +**Files:** +- Modify: `src/data/config.ts` +- Test: `src/data/config.test.ts` (append) + +**Interfaces:** +- Produces: `BotConfig.spotify: SpotifyConfig` where `interface SpotifyConfig { enabled: boolean; backend: "auto" | "go-librespot" | "librespot"; clientId: string; clientSecret: string; deviceName: string; bitrate: number }`, present in `getDefaultConfig()` and sanitized by `loadConfig`. + +- [ ] **Step 1: Write the failing config tests** + +`src/data/config.test.ts` **already exists** and already imports `{ describe, it, expect }` from `vitest`, `{ getDefaultConfig, loadConfig }` from `./config.js`, and `writeFileSync`/`mkdtempSync`/`tmpdir`/`join` from the node modules. **Append only the `describe` block below — do NOT add any import lines** (they would be duplicate identifiers). Add at the end of the file: + +```ts +describe("spotify config", () => { + it("defaults are present and disabled", () => { + const c = getDefaultConfig(); + expect(c.spotify).toEqual({ + enabled: false, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + }); + }); + + it("loadConfig coerces bad spotify values back to safe defaults", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const p = join(dir, "config.json"); + writeFileSync( + p, + JSON.stringify({ + spotify: { enabled: "yes", backend: "bogus", bitrate: 7, clientId: 5 }, + }) + ); + const c = loadConfig(p); + expect(c.spotify.enabled).toBe(false); // non-boolean → false + expect(c.spotify.backend).toBe("auto"); // invalid enum → auto + expect(c.spotify.bitrate).toBe(320); // invalid → 320 + expect(c.spotify.clientId).toBe(""); // non-string → "" + expect(c.spotify.deviceName).toBe("TSMusicBot"); // missing → default + }); + + it("loadConfig preserves valid spotify values", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const p = join(dir, "config.json"); + writeFileSync( + p, + JSON.stringify({ + spotify: { + enabled: true, + backend: "librespot", + clientId: "abc", + clientSecret: "def", + deviceName: "MyBot", + bitrate: 160, + }, + }) + ); + const c = loadConfig(p); + expect(c.spotify).toEqual({ + enabled: true, + backend: "librespot", + clientId: "abc", + clientSecret: "def", + deviceName: "MyBot", + bitrate: 160, + }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/data/config.test.ts` +Expected: FAIL — `c.spotify` is `undefined`. + +- [ ] **Step 3: Add the `SpotifyConfig` interface and default** + +In `src/data/config.ts`, add the interface above `BotConfig`: + +```ts +export interface SpotifyConfig { + enabled: boolean; + backend: "auto" | "go-librespot" | "librespot"; + clientId: string; + clientSecret: string; + deviceName: string; + bitrate: number; +} +``` + +Add to the `BotConfig` interface (after `guestMode: GuestModeConfig;`): + +```ts + spotify: SpotifyConfig; +``` + +Add to the object returned by `getDefaultConfig()` (after the `guestMode: { ... }` block, inside the returned object): + +```ts + spotify: { + enabled: false, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + }, +``` + +- [ ] **Step 4: Sanitize `spotify` in `loadConfig`** + +In `loadConfig`, after the `adminGroups` sanitization block and before the `return { ...defaults, ...partial, adminGroups, guestMode: gm }`, add: + +```ts + const partialSp = (partial.spotify ?? {}) as Partial; + const validBackends = ["auto", "go-librespot", "librespot"] as const; + const validBitrates = [96, 160, 320]; + const spotify: SpotifyConfig = { + enabled: partialSp.enabled === true, + backend: (validBackends as readonly string[]).includes(partialSp.backend as string) + ? (partialSp.backend as SpotifyConfig["backend"]) + : defaults.spotify.backend, + clientId: typeof partialSp.clientId === "string" ? partialSp.clientId : defaults.spotify.clientId, + clientSecret: + typeof partialSp.clientSecret === "string" ? partialSp.clientSecret : defaults.spotify.clientSecret, + deviceName: + typeof partialSp.deviceName === "string" && partialSp.deviceName.trim() + ? partialSp.deviceName + : defaults.spotify.deviceName, + bitrate: validBitrates.includes(partialSp.bitrate as number) + ? (partialSp.bitrate as number) + : defaults.spotify.bitrate, + }; +``` + +Then update the return statement to include it: + +```ts + return { + ...defaults, + ...partial, + adminGroups, + guestMode: gm, + spotify, + }; +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run src/data/config.test.ts` +Expected: PASS. + +- [ ] **Step 6: Typecheck + commit** + +```bash +npx tsc --noEmit +git add src/data/config.ts src/data/config.test.ts +git commit -m "feat(spotify): config block (disabled by default) + sanitize" +``` + +--- + +## Task 4: Bot wiring (index, manager, instance) + sentinel skip + +**Files:** +- Modify: `src/bot/instance.ts` +- Modify: `src/bot/manager.ts` +- Modify: `src/index.ts` +- Test: `src/bot/instance.test.ts` (append a routing test) + +**Interfaces:** +- Consumes: `SpotifyProvider` (Task 2), `isSpotifyUri` (Task 1), `MusicProvider`. +- Produces: `BotInstance.getProviderFor("spotify")` returns the spotify provider; `-s` flag selects it; the play path skips `spotify:` sentinels. + +- [ ] **Step 1: Write a failing routing test** + +`src/bot/instance.test.ts` tests methods by invoking them on a hand-built context via `.call(ctx)` (there is no bot factory). `getProviderFor` only reads `this.` fields, so a minimal `ctx` suffices. `BotInstance` is already imported in this file — reuse that import. Append: + +```ts +it("getProviderFor routes 'spotify' to the injected spotify provider", () => { + const spotify = { platform: "spotify" } as any; + const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any; + expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/bot/instance.test.ts -t "routes 'spotify'"` +Expected: FAIL — before the branch exists, `getProviderFor("spotify")` falls through to `this.neteaseProvider`, so the returned value is not the `spotify` sentinel object. + +- [ ] **Step 3: Add `spotifyProvider` to `BotInstanceOptions` and the class fields** + +In `src/bot/instance.ts`, in `BotInstanceOptions` (near `kugouProvider?: MusicProvider;`): + +```ts + spotifyProvider?: MusicProvider; +``` + +In the class fields (near `private kugouProvider: MusicProvider;`): + +```ts + private spotifyProvider: MusicProvider; +``` + +In the constructor (near `this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;`): + +```ts + this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider; +``` + +- [ ] **Step 4: Add the routing branches** + +First **widen the `getProviderFor` parameter type** (line ~530) to include `spotify` — otherwise `tsc` fails (the `=== "spotify"` comparison has no type overlap, and callers now pass `song.platform` which includes `"spotify"`): + +```ts + getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider { +``` + +Then, in `getProviderFor` (currently ending `if (platform === "kugou") return this.kugouProvider;`), add before the final `return`: + +```ts + if (platform === "spotify") return this.spotifyProvider; +``` + +In `getProvider(flags)` (currently `if (flags.has("k")) return this.kugouProvider;`), add: + +```ts + if (flags.has("s")) return this.spotifyProvider; +``` + +- [ ] **Step 5: Skip the sentinel in the play path** + +In `resolveAndPlay`, immediately after the post-resolve disconnect re-check and before `song.url = result.url;`, insert: + +```ts + // Stage 1: Spotify metadata works but audio is not wired yet. getSongUrl + // returns a `spotify:` sentinel — never hand it to ffmpeg. Tell the user + // and skip so the queue keeps moving. `sendTextMessage` is the same + // channel-message helper the command handlers use elsewhere in this file. + if (isSpotifyUri(result.url)) { + this.logger.info({ songId: song.id, name: song.name }, "Spotify playback not enabled yet — skipping"); + await this.tsClient.sendTextMessage( + "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" + ); + return false; + } +``` + +Add the import at the top of `src/bot/instance.ts`: + +```ts +import { isSpotifyUri } from "../music/spotify/webapi.js"; +``` + +- [ ] **Step 6: Thread the provider through `BotManager`** + +In `src/bot/manager.ts`: add a field near `private kugouProvider: MusicProvider;`: + +```ts + private spotifyProvider: MusicProvider; +``` + +Add a constructor parameter after `kugouProvider?: MusicProvider`: + +```ts + spotifyProvider?: MusicProvider, +``` + +Assign it near `this.kugouProvider = kugouProvider ?? neteaseProvider;`: + +```ts + this.spotifyProvider = spotifyProvider ?? neteaseProvider; +``` + +There are **three** `new BotInstance({ ... })` sites in `manager.ts` — `createBot` (~line 123), `startBot` (~line 259), and `loadSavedBots` (~line 316). Add the following line (after `kugouProvider: this.kugouProvider,`) to **all three** options objects: + +```ts + spotifyProvider: this.spotifyProvider, +``` + +> ⚠️ Missing `startBot` is silent: because the `BotInstance` constructor falls back `options.spotifyProvider ?? options.neteaseProvider`, a bot restarted from the UI would route Spotify to the NetEase provider with no error. Verify with `grep -n "new BotInstance(" src/bot/manager.ts` that all three are updated. + +- [ ] **Step 7: Instantiate + wire in `src/index.ts`** + +Add the import near the other providers: + +```ts +import { SpotifyProvider } from "./music/spotify/provider.js"; +``` + +After `const kugouProvider = new KugouProvider();`: + +```ts + const spotifyProvider = new SpotifyProvider(); + // Safety gate (spec §7): the source is inert unless EXPLICITLY enabled. + // Only feed credentials when enabled — otherwise the provider has no creds, + // hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false, + // so setting a Client ID/Secret alone (enabled:false) never activates Spotify. + if (config.spotify.enabled && config.spotify.clientId) { + spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret); + } +``` + +Add `spotifyProvider` as the last argument to `new BotManager(...)` (after `kugouProvider`): + +```ts + kugouProvider, + spotifyProvider +``` + +Add `spotifyProvider,` to the `createWebServer({ ... })` options object (after `kugouProvider,`). + +- [ ] **Step 8: Run tests + typecheck** + +Run: `npx vitest run src/bot` then `npx tsc --noEmit` +Expected: PASS / no errors. (If `manager.test.ts` constructs `BotManager` positionally, the new trailing optional param is backward-compatible.) + +- [ ] **Step 9: Commit** + +```bash +git add src/bot/instance.ts src/bot/manager.ts src/index.ts src/bot/instance.test.ts +git commit -m "feat(spotify): wire provider through manager/instance; skip playback sentinel" +``` + +--- + +## Task 5: Web API routers + +**Files:** +- Modify: `src/web/api/music.ts` +- Modify: `src/web/api/auth.ts` +- Modify: `src/web/server.ts` + +**Interfaces:** +- Consumes: the `spotifyProvider` created in `index.ts` (Task 4). +- Produces: `getProvider("spotify")` resolves the spotify provider in both API routers; `/api/music/quality` includes `spotify`. + +- [ ] **Step 1: Accept `spotifyProvider` in the music router factory** + +In `src/web/api/music.ts`, add `spotifyProvider` to the factory's options/params (mirror how `kugouProvider` is accepted). In `getProvider(platform)`, add before the final `return`: + +```ts + if (platform === "spotify" && spotifyProvider) return spotifyProvider; +``` + +In the `GET /quality` response object, add: + +```ts + spotify: spotifyProvider?.getQuality() ?? "320", +``` + +In `POST /quality`, add: + +```ts + if ((!platform || platform === "spotify") && spotifyProvider) { + spotifyProvider.setQuality(quality); + } +``` + +- [ ] **Step 2: Accept `spotifyProvider` in the auth router factory** + +In `src/web/api/auth.ts`, add `spotifyProvider` to the factory params and, in `getProvider(platform)`, add before the final `return`: + +```ts + if (platform === "spotify" && spotifyProvider) return spotifyProvider; +``` + +- [ ] **Step 3: Thread `spotifyProvider` from `server.ts`** + +`src/web/server.ts` does not destructure options — it reads `options.X` and passes providers **positionally**. Do two things: +1. Add `spotifyProvider: MusicProvider;` to the `WebServerOptions` interface (required, mirroring `kugouProvider` which has no `?`). +2. Pass `options.spotifyProvider` as the **trailing positional argument** to both `createMusicRouter(...)` and `createAuthRouter(...)` (after the existing `options.kugouProvider` argument). `index.ts` already supplies `spotifyProvider` in the `createWebServer({ ... })` options (Task 4 Step 7). + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Run the web API tests** + +Run: `npx vitest run src/web` +Expected: PASS (existing tests unaffected; the new optional param is backward-compatible). + +- [ ] **Step 6: Commit** + +```bash +git add src/web/api/music.ts src/web/api/auth.ts src/web/server.ts +git commit -m "feat(spotify): expose provider through web music/auth routers" +``` + +--- + +## Task 6: Frontend plumbing + +**Files:** +- Modify: `web/src/stores/player.ts` +- Modify: `web/src/stores/sourceTabs.ts` +- Modify: `web/src/components/SourceTabs.vue` +- Modify: `web/src/components/SongCard.vue` +- Modify: `web/src/styles/variables.scss` +- Test: `web/src/stores/sourceTabs.test.ts` (if present; else skip the test step) + +**Interfaces:** +- Produces: `spotify` is a valid `Source`, has a tab, a green badge, and an auth-status slot. + +- [ ] **Step 1: Extend the store unions and maps in `web/src/stores/player.ts`** + +Line ~13 — `Song.platform` union: add `| 'spotify'`: + +```ts + platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify'; +``` + +Line ~16 — `Source` type: add `'spotify'`: + +```ts +export type Source = 'netease' | 'qq' | 'kugou' | 'spotify'; +``` + +Lines ~103–107 — add `spotify` to the record maps: + +```ts + recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, + dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] }, + userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, + authStatus: { netease: false, qq: false, kugou: false, spotify: false }, +``` + +Line ~160 — push spotify into the source list when authed: + +```ts + if (this.authStatus.spotify) s.push('spotify'); +``` + +Auth fetch fan-out in `fetchHomeData()` (~575–591) — the code uses `Promise.allSettled`. Extend the destructured array, the `Promise.allSettled([...])` list, the `newAuth` object, the `authChanged` check, and the assignments to include `spotify`, mirroring `kugou`: + +```ts + const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([ + axios.get('/api/auth/status', { params: { platform: 'netease' } }), + axios.get('/api/auth/status', { params: { platform: 'qq' } }), + axios.get('/api/auth/status', { params: { platform: 'kugou' } }), + axios.get('/api/auth/status', { params: { platform: 'spotify' } }), + ]); + const newAuth = { + netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, + qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, + kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn, + spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn, + }; + const authChanged = + newAuth.netease !== this.authStatus.netease || + newAuth.qq !== this.authStatus.qq || + newAuth.kugou !== this.authStatus.kugou || + newAuth.spotify !== this.authStatus.spotify; + this.authStatus.netease = newAuth.netease; + this.authStatus.qq = newAuth.qq; + this.authStatus.kugou = newAuth.kugou; + this.authStatus.spotify = newAuth.spotify; +``` + +> **Recommend/daily/userPlaylists fetch fan-out is intentionally NOT extended for `spotify` in Stage 1.** `SpotifyProvider.getRecommendPlaylists()` returns `[]` (and there are no daily/user-playlist endpoints yet), so the map slots added above stay empty and the Spotify home view is simply blank — correct for Stage 1. The recommend fan-out gets wired when those endpoints arrive in a later stage. (This consciously discharges the spec §8 "recommend fetch fan-out" item for this stage.) + +- [ ] **Step 2: Accept `spotify` in `web/src/stores/sourceTabs.ts`** + +Line ~31 coercion: + +```ts + return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback; +``` + +- [ ] **Step 3: Add the tab label in `web/src/components/SourceTabs.vue`** + +In the label map (near `kugou: '酷狗',`): + +```ts + spotify: 'Spotify', +``` + +- [ ] **Step 4: Add the badge in `web/src/components/SongCard.vue`** + +Extend the badge class ternary (line ~10) and label ternary (line ~11) to handle `spotify` (append before the netease fallback): + +``` +... : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'" +... : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }} +``` + +Add the style block near `.badge-kugou`: + +```scss +.badge-spotify { + background: var(--brand-spotify-12); + color: var(--brand-spotify); +} +``` + +- [ ] **Step 5: Add brand colors in `web/src/styles/variables.scss`** + +Near `--brand-kugou`: + +```scss + --brand-spotify: #1DB954; + --brand-spotify-12: rgba(29, 185, 84, 0.12); +``` + +- [ ] **Step 6: (If it exists) update `web/src/stores/sourceTabs.test.ts`** + +If a test file asserts valid sources, add a case: + +```ts +it("accepts 'spotify'", () => { + expect(coerceSource('spotify', 'netease')).toBe('spotify'); +}); +``` + +- [ ] **Step 7: Build the frontend to verify it compiles** + +Run: `cd web && npm run build` +Expected: build succeeds (Vite + vue-tsc), no type errors from the new `spotify` members. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/stores/player.ts web/src/stores/sourceTabs.ts web/src/components/SourceTabs.vue web/src/components/SongCard.vue web/src/styles/variables.scss web/src/stores/sourceTabs.test.ts +git commit -m "feat(spotify): frontend plumbing (source tab, badge, auth status)" +``` + +--- + +## Task 7: Full verification + +- [ ] **Step 1: Run the entire test suite** + +Run: `npx vitest run` +Expected: all tests pass (no regressions in existing suites; new spotify tests green). + +- [ ] **Step 2: Typecheck backend + build frontend** + +Run: `npx tsc --noEmit && cd web && npm run build` +Expected: no type errors; frontend builds. + +- [ ] **Step 3: Manual smoke (documented, optional)** + +With a Spotify Developer app's Client ID/Secret placed in `data/config.json` under `spotify` and `enabled: true`, start the bot (`npm run dev`), open the web UI, pick the **Spotify** tab, search a track → results render with a green badge. Queue one → the bot posts the "Spotify 播放尚未启用" message and advances. Confirms Stage 1 end-to-end without any sidecar. + +- [ ] **Step 4: Final commit (if any docs/tidy)** + +```bash +git add -A +git commit -m "chore(spotify): stage 1 verification pass" --allow-empty +``` + +--- + +## Self-review notes (for the plan author / reviewer) + +- **Spec coverage (Stage 1 scope §12.1):** metadata client (Task 1), provider (Task 2), config (Task 3), bot wiring + graceful non-playback (Task 4), web routers (Task 5), UI plumbing (Task 6). Audio backends (§4.2/4.3), OAuth/PKCE (§5.2), binary resolution (§9), and the player external-PCM mode (§6) are intentionally **out of scope for Stage 1** and handled in later plans. +- **Sentinel type consistency:** `getSongUrl` → `{ url: \`spotify:track:${id}\` }` (Task 2) is detected by `isSpotifyUri` (Task 1) in the play path (Task 4). Names match across tasks. +- **Backward compatibility:** every new constructor/factory parameter (`spotifyProvider`) is optional/trailing, so existing positional callers and tests keep compiling. +- **Codebase-verified specifics:** the user-message call in `instance.ts` uses `await this.tsClient.sendTextMessage(...)` (the same helper the command handlers use), and the `player.ts` auth fan-out extends the real `Promise.allSettled([...])` block — both confirmed against the current source. +- **Enabled gate (spec §7):** `index.ts` feeds credentials only when `config.spotify.enabled && config.spotify.clientId`, so a Client ID/Secret with `enabled:false` leaves the source fully inert (empty search, `loggedIn:false`). +- **Adversarial verification pass (applied):** a 3-critic review against the live repo fixed — (a) widening the `getProviderFor` signature to include `spotify` (build-breaker), (b) not re-importing in the pre-existing `config.test.ts` (build-breaker), (c) updating **all three** `new BotInstance` sites incl. `startBot` (silent-miswire), (d) accurate `server.ts` positional threading, (e) the `instance.test.ts` `.call(ctx)` test shape, (f) a spec-required 429 Retry-After handler + test, and (g) dropping the unused `searchTracks`. The type/interface critic confirmed `SpotifyProvider` implements every non-optional `MusicProvider` member and all mapper outputs match the `Song`/`Album`/`Playlist` shapes exactly. From 6c16e2d966801f7ca4e8122c1285f4a187c0043b Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 1 Jul 2026 23:52:22 +0800 Subject: [PATCH 03/59] feat(spotify): Web API client + catalog mappers, add spotify platform Adds src/music/spotify/webapi.ts (client-credentials token, catalog mappers, 429 retry) + tests, and threads the new "spotify" platform id through the type unions in provider.ts and database.ts. Also widens the downstream QueuedSong.platform union (audio/queue.ts) and the getProviderFor parameter (bot/instance.ts) so tsc --noEmit stays clean; these two are the necessary call-site fixes for the new union member. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/queue.ts | 2 +- src/bot/instance.ts | 2 +- src/data/database.ts | 2 +- src/music/provider.ts | 8 +- src/music/spotify/webapi.test.ts | 125 +++++++++++++++++++++ src/music/spotify/webapi.ts | 184 +++++++++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 src/music/spotify/webapi.test.ts create mode 100644 src/music/spotify/webapi.ts diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 221b74b..31f49b4 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -10,7 +10,7 @@ export interface QueuedSong { name: string; artist: string; album: string; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; url?: string; // resolved lazily at play time coverUrl: string; duration: number; // seconds diff --git a/src/bot/instance.ts b/src/bot/instance.ts index f0bb312..68197d7 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -527,7 +527,7 @@ export class BotInstance extends EventEmitter { } } - getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"): MusicProvider { + getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider { if (platform === "bilibili") return this.bilibiliProvider; if (platform === "youtube") return this.youtubeProvider; if (platform === "local") return this.localProvider; diff --git a/src/data/database.ts b/src/data/database.ts index b08ca19..5bf375c 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -8,7 +8,7 @@ export interface PlayHistoryEntry { songName: string; artist: string; album: string; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; coverUrl: string; } diff --git a/src/music/provider.ts b/src/music/provider.ts index 6c66bd9..b484a57 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -5,7 +5,7 @@ export interface Song { album: string; duration: number; // seconds coverUrl: string; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; /** VIP / copyright-restricted: non-VIP users can only play a trial fragment * (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */ vip?: boolean; @@ -27,7 +27,7 @@ export interface Playlist { name: string; coverUrl: string; songCount: number; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; } export interface PlaylistDetail { @@ -44,7 +44,7 @@ export interface Album { artist: string; coverUrl: string; songCount: number; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; } export interface LyricLine { @@ -72,7 +72,7 @@ export interface AuthStatus { } export interface MusicProvider { - readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; search(query: string, limit?: number): Promise; getSongUrl(songId: string, quality?: string): Promise; diff --git a/src/music/spotify/webapi.test.ts b/src/music/spotify/webapi.test.ts new file mode 100644 index 0000000..2003a62 --- /dev/null +++ b/src/music/spotify/webapi.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from "vitest"; +import { + mapSpotifyTrack, + mapSpotifyTracks, + mapSpotifyAlbum, + mapSpotifyPlaylist, + isSpotifyUri, + SpotifyWebApi, +} from "./webapi.js"; + +describe("mapSpotifyTrack", () => { + // Shape trimmed from GET /v1/search?type=track. + const raw = { + id: "4iV5W9uYEdYUVa79Axb7Rh", + name: "Bohemian Rhapsody", + artists: [{ name: "Queen" }], + album: { name: "A Night at the Opera", images: [{ url: "https://i.scdn.co/x.jpg" }] }, + duration_ms: 354320, + }; + + it("maps a track to a Song with platform 'spotify' and seconds duration", () => { + const s = mapSpotifyTrack(raw); + expect(s.platform).toBe("spotify"); + expect(s.id).toBe("4iV5W9uYEdYUVa79Axb7Rh"); + expect(s.name).toBe("Bohemian Rhapsody"); + expect(s.artist).toBe("Queen"); + expect(s.album).toBe("A Night at the Opera"); + expect(s.duration).toBe(354); // 354320ms → 354s + expect(s.coverUrl).toBe("https://i.scdn.co/x.jpg"); + }); + + it("joins multiple artists with ', '", () => { + const s = mapSpotifyTrack({ ...raw, artists: [{ name: "A" }, { name: "B" }] }); + expect(s.artist).toBe("A, B"); + }); + + it("tolerates missing fields", () => { + const s = mapSpotifyTrack({}); + expect(s.id).toBe(""); + expect(s.name).toBe("Unknown"); + expect(s.artist).toBe(""); + expect(s.duration).toBe(0); + expect(s.coverUrl).toBe(""); + expect(s.platform).toBe("spotify"); + }); + + it("mapSpotifyTracks returns [] for non-array input", () => { + expect(mapSpotifyTracks(undefined as any)).toEqual([]); + }); +}); + +describe("mapSpotifyAlbum", () => { + it("maps an album with total_tracks → songCount", () => { + const a = mapSpotifyAlbum({ + id: "1abc", + name: "A Night at the Opera", + artists: [{ name: "Queen" }], + images: [{ url: "https://i.scdn.co/a.jpg" }], + total_tracks: 12, + }); + expect(a).toEqual({ + id: "1abc", + name: "A Night at the Opera", + artist: "Queen", + coverUrl: "https://i.scdn.co/a.jpg", + songCount: 12, + platform: "spotify", + }); + }); +}); + +describe("mapSpotifyPlaylist", () => { + it("maps a playlist with tracks.total → songCount", () => { + const p = mapSpotifyPlaylist({ + id: "37i9", + name: "Today's Top Hits", + images: [{ url: "https://i.scdn.co/p.jpg" }], + tracks: { total: 50 }, + }); + expect(p).toEqual({ + id: "37i9", + name: "Today's Top Hits", + coverUrl: "https://i.scdn.co/p.jpg", + songCount: 50, + platform: "spotify", + }); + }); +}); + +describe("isSpotifyUri", () => { + it("recognizes the sentinel URI", () => { + expect(isSpotifyUri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh")).toBe(true); + expect(isSpotifyUri("https://music.126.net/x.mp3")).toBe(false); + expect(isSpotifyUri("")).toBe(false); + }); +}); + +describe("SpotifyWebApi rate-limit handling", () => { + it("retries once on 429 (honoring Retry-After) then returns data", async () => { + const auth = { + post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }), + } as any; + let call = 0; + const http = { + get: vi.fn().mockImplementation(() => { + call += 1; + if (call === 1) { + return Promise.reject({ response: { status: 429, headers: { "retry-after": "0" } } }); + } + return Promise.resolve({ + data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } }, + }); + }), + } as any; + const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth }); + const out = await api.search("queen"); + expect(http.get).toHaveBeenCalledTimes(2); // one 429, one success + expect(out.songs[0].id).toBe("t1"); + }); + + it("returns empty results when unconfigured (no creds → no token)", async () => { + const api = new SpotifyWebApi(() => ({ clientId: "", clientSecret: "" })); + expect(await api.search("queen")).toEqual({ songs: [], playlists: [], albums: [] }); + }); +}); diff --git a/src/music/spotify/webapi.ts b/src/music/spotify/webapi.ts new file mode 100644 index 0000000..5d94345 --- /dev/null +++ b/src/music/spotify/webapi.ts @@ -0,0 +1,184 @@ +import axios, { type AxiosInstance } from "axios"; +import type { Song, Album, Playlist, SearchResult } from "../provider.js"; + +export interface SpotifyCreds { + clientId: string; + clientSecret: string; +} + +const ACCOUNTS_BASE = "https://accounts.spotify.com"; +const API_BASE = "https://api.spotify.com"; + +function artistsToString(artists: unknown): string { + return Array.isArray(artists) + ? artists.map((a: any) => a?.name).filter(Boolean).join(", ") + : ""; +} + +/** Map a Spotify track object (search / tracks / playlist item .track) to a Song. */ +export function mapSpotifyTrack(raw: any): Song { + return { + id: raw?.id ?? "", + name: raw?.name ?? "Unknown", + artist: artistsToString(raw?.artists), + album: raw?.album?.name ?? "", + duration: Math.round((raw?.duration_ms ?? 0) / 1000), + coverUrl: raw?.album?.images?.[0]?.url ?? "", + platform: "spotify", + }; +} + +export function mapSpotifyTracks(raw: any): Song[] { + return Array.isArray(raw) ? raw.map(mapSpotifyTrack) : []; +} + +export function mapSpotifyAlbum(raw: any): Album { + return { + id: raw?.id ?? "", + name: raw?.name ?? "Unknown", + artist: artistsToString(raw?.artists), + coverUrl: raw?.images?.[0]?.url ?? "", + songCount: raw?.total_tracks ?? 0, + platform: "spotify", + }; +} + +export function mapSpotifyPlaylist(raw: any): Playlist { + return { + id: raw?.id ?? "", + name: raw?.name ?? "Unknown", + coverUrl: raw?.images?.[0]?.url ?? "", + songCount: raw?.tracks?.total ?? 0, + platform: "spotify", + }; +} + +/** True for the getSongUrl sentinel (spotify:track:); real audio lands in Stage 2/3. */ +export function isSpotifyUri(url: string): boolean { + return typeof url === "string" && url.startsWith("spotify:"); +} + +export class SpotifyWebApi { + private getCreds: () => SpotifyCreds; + private http: AxiosInstance; + private auth: AxiosInstance; + private token = ""; + private tokenExpiresAt = 0; + + constructor( + getCreds: () => SpotifyCreds, + deps?: { http?: AxiosInstance; auth?: AxiosInstance } + ) { + this.getCreds = getCreds; + this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 }); + this.auth = deps?.auth ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 }); + } + + setCreds(_c: SpotifyCreds): void { + // Creds are read live via getCreds(); force a token refresh on next call. + this.token = ""; + this.tokenExpiresAt = 0; + } + + hasCreds(): boolean { + const c = this.getCreds(); + return !!c.clientId && !!c.clientSecret; + } + + /** Client-Credentials app token, cached until ~30s before expiry. */ + private async getToken(): Promise { + if (!this.hasCreds()) return null; + if (this.token && Date.now() < this.tokenExpiresAt) return this.token; + const { clientId, clientSecret } = this.getCreds(); + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); + try { + const { data } = await this.auth.post( + "/api/token", + "grant_type=client_credentials", + { + headers: { + Authorization: `Basic ${basic}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + this.token = data?.access_token ?? ""; + this.tokenExpiresAt = Date.now() + ((data?.expires_in ?? 3600) - 30) * 1000; + return this.token || null; + } catch { + return null; + } + } + + private async get( + path: string, + params?: Record, + retryOn429 = true + ): Promise { + const token = await this.getToken(); + if (!token) return null; + try { + const { data } = await this.http.get(path, { + params, + headers: { Authorization: `Bearer ${token}` }, + }); + return data; + } catch (err: any) { + // Spotify rate-limits on a rolling 30s window (429 + Retry-After seconds). + // Retry once after the advised delay before giving up. + if (retryOn429 && err?.response?.status === 429) { + const retryAfter = Number(err.response.headers?.["retry-after"] ?? 1); + await new Promise((r) => setTimeout(r, Math.min(retryAfter, 10) * 1000)); + return this.get(path, params, false); + } + return null; + } + } + + async search(query: string, limit = 20): Promise { + const data = await this.get("/v1/search", { + q: query, + type: "track,album,playlist", + limit, + }); + if (!data) return { songs: [], playlists: [], albums: [] }; + return { + songs: mapSpotifyTracks(data?.tracks?.items), + albums: Array.isArray(data?.albums?.items) + ? data.albums.items.filter(Boolean).map(mapSpotifyAlbum) + : [], + playlists: Array.isArray(data?.playlists?.items) + ? data.playlists.items.filter(Boolean).map(mapSpotifyPlaylist) + : [], + }; + } + + async getTrack(id: string): Promise { + const data = await this.get(`/v1/tracks/${id}`); + return data ? mapSpotifyTrack(data) : null; + } + + async getAlbumTracks(albumId: string): Promise { + // Album-track objects omit the album block; fetch the album cover once and inject it. + const album = await this.get(`/v1/albums/${albumId}`); + const cover = album?.images?.[0]?.url ?? ""; + const albumName = album?.name ?? ""; + const items = album?.tracks?.items; + if (!Array.isArray(items)) return []; + return items.filter(Boolean).map((t: any) => ({ + ...mapSpotifyTrack(t), + album: albumName, + coverUrl: cover, + })); + } + + async getPlaylistTracks(playlistId: string): Promise { + const data = await this.get(`/v1/playlists/${playlistId}/tracks`, { limit: 100 }); + const items = data?.items; + if (!Array.isArray(items)) return []; + return items + .map((it: any) => it?.track) + .filter((t: any) => t && t.id) + .map(mapSpotifyTrack); + } +} From 828fd21e2976f7d3bcb25e433dfb09336ffa42c4 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 1 Jul 2026 23:59:41 +0800 Subject: [PATCH 04/59] feat(spotify): SpotifyProvider (search/browse; playback sentinel) --- src/music/spotify/provider.test.ts | 63 ++++++++++++++++++++++ src/music/spotify/provider.ts | 86 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/music/spotify/provider.test.ts create mode 100644 src/music/spotify/provider.ts diff --git a/src/music/spotify/provider.test.ts b/src/music/spotify/provider.test.ts new file mode 100644 index 0000000..aabe6b9 --- /dev/null +++ b/src/music/spotify/provider.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { SpotifyProvider } from "./provider.js"; +import { SpotifyWebApi } from "./webapi.js"; + +function fakeApi(over: Partial = {}): SpotifyWebApi { + return { + hasCreds: () => true, + setCreds: vi.fn(), + search: vi.fn().mockResolvedValue({ songs: [], playlists: [], albums: [] }), + getTrack: vi.fn().mockResolvedValue(null), + getAlbumTracks: vi.fn().mockResolvedValue([]), + getPlaylistTracks: vi.fn().mockResolvedValue([]), + ...over, + } as unknown as SpotifyWebApi; +} + +describe("SpotifyProvider", () => { + it("has platform 'spotify'", () => { + expect(new SpotifyProvider(fakeApi()).platform).toBe("spotify"); + }); + + it("getSongUrl returns the spotify: sentinel, not a real URL", async () => { + const p = new SpotifyProvider(fakeApi()); + const r = await p.getSongUrl("4iV5W9uYEdYUVa79Axb7Rh"); + expect(r).toEqual({ url: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh" }); + }); + + it("search delegates to the web API", async () => { + const api = fakeApi({ + search: vi.fn().mockResolvedValue({ + songs: [{ id: "t1", platform: "spotify" }], + playlists: [], + albums: [], + }), + }); + const out = await new SpotifyProvider(api).search("queen", 5); + expect(api.search).toHaveBeenCalledWith("queen", 5); + expect(out.songs[0].id).toBe("t1"); + }); + + it("getAuthStatus reflects credential presence", async () => { + expect((await new SpotifyProvider(fakeApi({ hasCreds: () => true })).getAuthStatus()).loggedIn).toBe(true); + expect((await new SpotifyProvider(fakeApi({ hasCreds: () => false })).getAuthStatus()).loggedIn).toBe(false); + }); + + it("getPlaylistSongs / getAlbumSongs delegate to the web API", async () => { + const api = fakeApi({ + getPlaylistTracks: vi.fn().mockResolvedValue([{ id: "p", platform: "spotify" }]), + getAlbumTracks: vi.fn().mockResolvedValue([{ id: "a", platform: "spotify" }]), + }); + const p = new SpotifyProvider(api); + expect((await p.getPlaylistSongs("37i9"))[0].id).toBe("p"); + expect((await p.getAlbumSongs("1abc"))[0].id).toBe("a"); + }); + + it("no-op auth surfaces (QR expired, empty lyrics/recommend)", async () => { + const p = new SpotifyProvider(fakeApi()); + expect(await p.getLyrics("x")).toEqual([]); + expect(await p.getRecommendPlaylists()).toEqual([]); + expect((await p.getQrCode()).key).toBe(""); + expect(await p.checkQrCodeStatus("k")).toBe("expired"); + }); +}); diff --git a/src/music/spotify/provider.ts b/src/music/spotify/provider.ts new file mode 100644 index 0000000..8d4d644 --- /dev/null +++ b/src/music/spotify/provider.ts @@ -0,0 +1,86 @@ +import type { + MusicProvider, + Song, + SongUrlResult, + Playlist, + Album, + SearchResult, + LyricLine, + QrCodeResult, + AuthStatus, +} from "../provider.js"; +import { SpotifyWebApi, type SpotifyCreds } from "./webapi.js"; + +export class SpotifyProvider implements MusicProvider { + readonly platform = "spotify" as const; + private api: SpotifyWebApi; + private creds: SpotifyCreds = { clientId: "", clientSecret: "" }; + private quality = "320"; + + constructor(api?: SpotifyWebApi) { + this.api = api ?? new SpotifyWebApi(() => this.creds); + } + + setCreds(clientId: string, clientSecret: string): void { + this.creds = { clientId: clientId ?? "", clientSecret: clientSecret ?? "" }; + this.api.setCreds(this.creds); + } + + async search(query: string, limit = 20): Promise { + return this.api.search(query, limit); + } + + // Stage 1: return a sentinel URI. The play path recognizes `spotify:` and + // skips with a "not playable yet" message; real audio arrives in Stage 2/3. + async getSongUrl(songId: string): Promise { + return { url: `spotify:track:${songId}` }; + } + + setQuality(quality: string): void { + this.quality = quality; + } + getQuality(): string { + return this.quality; + } + + async getSongDetail(songId: string): Promise { + return this.api.getTrack(songId); + } + + async getPlaylistSongs(playlistId: string): Promise { + return this.api.getPlaylistTracks(playlistId); + } + + async getAlbumSongs(albumId: string): Promise { + return this.api.getAlbumTracks(albumId); + } + + async getRecommendPlaylists(): Promise { + return []; + } + + async getLyrics(_songId: string): Promise { + return []; + } + + async getQrCode(): Promise { + return { qrUrl: "", key: "" }; + } + + async checkQrCodeStatus( + _key: string + ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { + return "expired"; + } + + setCookie(_cookie: string): void {} + getCookie(): string { + return ""; + } + + async getAuthStatus(): Promise { + return this.api.hasCreds() + ? { loggedIn: true, nickname: "Spotify" } + : { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" }; + } +} From 848b3931b8b0bc954bbccad10030f20ae0de5842 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 00:04:31 +0800 Subject: [PATCH 05/59] feat(spotify): config block (disabled by default) + sanitize Co-Authored-By: Claude Opus 4.8 (1M context) --- src/data/config.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++ src/data/config.ts | 39 +++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/data/config.test.ts b/src/data/config.test.ts index dee0dcc..3b30d60 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -205,3 +205,61 @@ describe("adminGroups normalization", () => { expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]); }); }); + +describe("spotify config", () => { + it("defaults are present and disabled", () => { + const c = getDefaultConfig(); + expect(c.spotify).toEqual({ + enabled: false, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + }); + }); + + it("loadConfig coerces bad spotify values back to safe defaults", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const p = join(dir, "config.json"); + writeFileSync( + p, + JSON.stringify({ + spotify: { enabled: "yes", backend: "bogus", bitrate: 7, clientId: 5 }, + }) + ); + const c = loadConfig(p); + expect(c.spotify.enabled).toBe(false); // non-boolean → false + expect(c.spotify.backend).toBe("auto"); // invalid enum → auto + expect(c.spotify.bitrate).toBe(320); // invalid → 320 + expect(c.spotify.clientId).toBe(""); // non-string → "" + expect(c.spotify.deviceName).toBe("TSMusicBot"); // missing → default + }); + + it("loadConfig preserves valid spotify values", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const p = join(dir, "config.json"); + writeFileSync( + p, + JSON.stringify({ + spotify: { + enabled: true, + backend: "librespot", + clientId: "abc", + clientSecret: "def", + deviceName: "MyBot", + bitrate: 160, + }, + }) + ); + const c = loadConfig(p); + expect(c.spotify).toEqual({ + enabled: true, + backend: "librespot", + clientId: "abc", + clientSecret: "def", + deviceName: "MyBot", + bitrate: 160, + }); + }); +}); diff --git a/src/data/config.ts b/src/data/config.ts index 1668fe2..3b708d6 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -9,6 +9,15 @@ export interface GuestModeConfig { permissions: GuestPermissions; } +export interface SpotifyConfig { + enabled: boolean; + backend: "auto" | "go-librespot" | "librespot"; + clientId: string; + clientSecret: string; + deviceName: string; + bitrate: number; +} + export interface BotConfig { webPort: number; locale: "zh" | "en"; @@ -33,6 +42,7 @@ export interface BotConfig { // behind HTTPS-terminating proxies. trustProxy: boolean; guestMode: GuestModeConfig; + spotify: SpotifyConfig; } export function getDefaultConfig(): BotConfig { @@ -69,6 +79,14 @@ export function getDefaultConfig(): BotConfig { playCollection: false, }, }, + spotify: { + enabled: false, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + }, }; } @@ -118,11 +136,32 @@ export function loadConfig(path: string): BotConfig { ) : defaults.adminGroups; + const partialSp = (partial.spotify ?? {}) as Partial; + const validBackends = ["auto", "go-librespot", "librespot"] as const; + const validBitrates = [96, 160, 320]; + const spotify: SpotifyConfig = { + enabled: partialSp.enabled === true, + backend: (validBackends as readonly string[]).includes(partialSp.backend as string) + ? (partialSp.backend as SpotifyConfig["backend"]) + : defaults.spotify.backend, + clientId: typeof partialSp.clientId === "string" ? partialSp.clientId : defaults.spotify.clientId, + clientSecret: + typeof partialSp.clientSecret === "string" ? partialSp.clientSecret : defaults.spotify.clientSecret, + deviceName: + typeof partialSp.deviceName === "string" && partialSp.deviceName.trim() + ? partialSp.deviceName + : defaults.spotify.deviceName, + bitrate: validBitrates.includes(partialSp.bitrate as number) + ? (partialSp.bitrate as number) + : defaults.spotify.bitrate, + }; + return { ...defaults, ...partial, adminGroups, guestMode: gm, + spotify, }; } catch { return defaults; From 9a9f68c446905a1e00f46a537f00f0acf03f13b5 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 00:13:22 +0800 Subject: [PATCH 06/59] feat(spotify): wire provider through manager/instance; skip playback sentinel Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.test.ts | 8 ++++++++ src/bot/instance.ts | 17 +++++++++++++++++ src/bot/manager.ts | 8 +++++++- src/index.ts | 13 ++++++++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index e745258..7011741 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -210,3 +210,11 @@ describe("BotInstance.handleTextMessage — command permission gate", () => { expect(ctx.executeCommand).toHaveBeenCalledTimes(1); }); }); + +describe("BotInstance.getProviderFor — spotify routing", () => { + it("getProviderFor routes 'spotify' to the injected spotify provider", () => { + const spotify = { platform: "spotify" } as any; + const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any; + expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 68197d7..c96f895 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -23,6 +23,7 @@ import { occupancyFromClientList, shouldResumeOnReturn, } from "./auto-pause.js"; +import { isSpotifyUri } from "../music/spotify/webapi.js"; /** Reply sent when a non-admin invokes an admin-only chat command. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; @@ -37,6 +38,7 @@ export interface BotInstanceOptions { youtubeProvider: MusicProvider; localProvider?: MusicProvider; kugouProvider?: MusicProvider; + spotifyProvider?: MusicProvider; database: BotDatabase; config: BotConfig; logger: Logger; @@ -71,6 +73,7 @@ export class BotInstance extends EventEmitter { private youtubeProvider: MusicProvider; private localProvider: MusicProvider; private kugouProvider: MusicProvider; + private spotifyProvider: MusicProvider; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -101,6 +104,7 @@ export class BotInstance extends EventEmitter { this.youtubeProvider = options.youtubeProvider; this.localProvider = options.localProvider ?? options.neteaseProvider; this.kugouProvider = options.kugouProvider ?? options.neteaseProvider; + this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider; this.database = options.database; this.config = options.config; this.logger = options.logger.child({ botId: this.id }); @@ -532,6 +536,7 @@ export class BotInstance extends EventEmitter { if (platform === "youtube") return this.youtubeProvider; if (platform === "local") return this.localProvider; if (platform === "kugou") return this.kugouProvider; + if (platform === "spotify") return this.spotifyProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider; } @@ -545,6 +550,7 @@ export class BotInstance extends EventEmitter { if (flags.has("q")) return this.qqProvider; if (flags.has("y")) return this.youtubeProvider; if (flags.has("k")) return this.kugouProvider; + if (flags.has("s")) return this.spotifyProvider; return this.neteaseProvider; } @@ -581,6 +587,17 @@ export class BotInstance extends EventEmitter { ); return false; } + // Stage 1: Spotify metadata works but audio is not wired yet. getSongUrl + // returns a `spotify:` sentinel — never hand it to ffmpeg. Tell the user + // and skip so the queue keeps moving. `sendTextMessage` is the same + // channel-message helper the command handlers use elsewhere in this file. + if (isSpotifyUri(result.url)) { + this.logger.info({ songId: song.id, name: song.name }, "Spotify playback not enabled yet — skipping"); + await this.tsClient.sendTextMessage( + "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" + ); + return false; + } song.url = result.url; // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration this.effectiveDuration = result.trialDuration ?? song.duration; diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 8e5310d..15daa04 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -76,6 +76,7 @@ export class BotManager extends EventEmitter { private youtubeProvider: MusicProvider; private localProvider: MusicProvider; private kugouProvider: MusicProvider; + private spotifyProvider: MusicProvider; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -94,7 +95,8 @@ export class BotManager extends EventEmitter { permissions: PermissionStore, configPath: string, localProvider?: MusicProvider, - kugouProvider?: MusicProvider + kugouProvider?: MusicProvider, + spotifyProvider?: MusicProvider ) { super(); this.neteaseProvider = neteaseProvider; @@ -103,6 +105,7 @@ export class BotManager extends EventEmitter { this.youtubeProvider = new YouTubeProvider(); this.localProvider = localProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider; + this.spotifyProvider = spotifyProvider ?? neteaseProvider; // Let the local provider see which uploads are still referenced by any // bot's queue, so it never deletes a file another queue/bot still needs. const referenceable = this.localProvider as Partial<{ @@ -141,6 +144,7 @@ export class BotManager extends EventEmitter { youtubeProvider: this.youtubeProvider, localProvider: this.localProvider, kugouProvider: this.kugouProvider, + spotifyProvider: this.spotifyProvider, database: this.database, config: this.config, logger: this.logger, @@ -281,6 +285,7 @@ export class BotManager extends EventEmitter { youtubeProvider: this.youtubeProvider, localProvider: this.localProvider, kugouProvider: this.kugouProvider, + spotifyProvider: this.spotifyProvider, database: this.database, config: this.config, logger: this.logger, @@ -335,6 +340,7 @@ export class BotManager extends EventEmitter { youtubeProvider: this.youtubeProvider, localProvider: this.localProvider, kugouProvider: this.kugouProvider, + spotifyProvider: this.spotifyProvider, database: this.database, config: this.config, logger: this.logger, diff --git a/src/index.ts b/src/index.ts index 9eebc07..5c206e8 100755 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js"; import { BiliBiliProvider } from "./music/bilibili.js"; import { LocalMusicProvider } from "./music/local.js"; import { KugouProvider } from "./music/kugou.js"; +import { SpotifyProvider } from "./music/spotify/provider.js"; import { createCookieStore } from "./music/auth.js"; import { createAvatarStore } from "./data/avatars.js"; import { createPermissionStore } from "./data/permissions.js"; @@ -59,6 +60,14 @@ async function main() { const bilibiliProvider = new BiliBiliProvider(); const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR); const kugouProvider = new KugouProvider(); + const spotifyProvider = new SpotifyProvider(); + // Safety gate (spec §7): the source is inert unless EXPLICITLY enabled. + // Only feed credentials when enabled — otherwise the provider has no creds, + // hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false, + // so setting a Client ID/Secret alone (enabled:false) never activates Spotify. + if (config.spotify.enabled && config.spotify.clientId) { + spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret); + } const cookieStore = createCookieStore(COOKIE_DIR); const avatarStore = createAvatarStore(AVATAR_DIR); @@ -84,7 +93,8 @@ async function main() { permissions, CONFIG_PATH, localProvider, - kugouProvider + kugouProvider, + spotifyProvider ); await botManager.loadSavedBots(); @@ -96,6 +106,7 @@ async function main() { bilibiliProvider, localProvider, kugouProvider, + spotifyProvider, database: db, avatarStore, config, From e1171dbcfead9644208291ccd6c6ebaa1a72eaae Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 00:19:22 +0800 Subject: [PATCH 07/59] feat(spotify): expose provider through web music/auth routers Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/api/auth.ts | 4 +++- src/web/api/music.ts | 8 +++++++- src/web/server.ts | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts index daed411..d42a410 100644 --- a/src/web/api/auth.ts +++ b/src/web/api/auth.ts @@ -12,7 +12,8 @@ export function createAuthRouter( bilibiliProvider: MusicProvider, logger: Logger, cookieStore?: CookieStore, - kugouProvider?: MusicProvider + kugouProvider?: MusicProvider, + spotifyProvider?: MusicProvider ): Router { const router = Router(); // YouTube is auth-less; we only use this instance so /auth/status can @@ -23,6 +24,7 @@ export function createAuthRouter( if (platform === "bilibili") return bilibiliProvider; if (platform === "youtube") return youtubeProvider; if (platform === "kugou" && kugouProvider) return kugouProvider; + if (platform === "spotify" && spotifyProvider) return spotifyProvider; return platform === "qq" ? qqProvider : neteaseProvider; } diff --git a/src/web/api/music.ts b/src/web/api/music.ts index a80545e..a547dea 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -14,7 +14,8 @@ export function createMusicRouter( logger: Logger, localProvider?: MusicProvider, config?: BotConfig, - kugouProvider?: MusicProvider + kugouProvider?: MusicProvider, + spotifyProvider?: MusicProvider ): Router { const router = Router(); const youtubeProvider: MusicProvider = new YouTubeProvider(); @@ -28,6 +29,7 @@ export function createMusicRouter( if (platform === "youtube") return youtubeProvider; if (platform === "local" && localProvider) return localProvider; if (platform === "kugou" && kugouProvider) return kugouProvider; + if (platform === "spotify" && spotifyProvider) return spotifyProvider; return platform === "qq" ? qqProvider : neteaseProvider; } @@ -291,6 +293,7 @@ export function createMusicRouter( bilibili: bilibiliProvider.getQuality(), local: localProvider?.getQuality() ?? "original", kugou: kugouProvider?.getQuality() ?? "128", + spotify: spotifyProvider?.getQuality() ?? "320", }); }); @@ -313,6 +316,9 @@ export function createMusicRouter( if ((!platform || platform === "kugou") && kugouProvider) { kugouProvider.setQuality(quality); } + if ((!platform || platform === "spotify") && spotifyProvider) { + spotifyProvider.setQuality(quality); + } logger.info({ quality, platform }, "Audio quality changed"); res.json({ success: true, quality }); }); diff --git a/src/web/server.ts b/src/web/server.ts index 16da691..428db8c 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -40,6 +40,7 @@ export interface WebServerOptions { bilibiliProvider: MusicProvider; localProvider: MusicProvider; kugouProvider: MusicProvider; + spotifyProvider: MusicProvider; database: BotDatabase; config: BotConfig; configPath: string; @@ -125,7 +126,7 @@ export function createWebServer(options: WebServerOptions): WebServer { ); app.use( "/api/music", - createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider) + createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider) ); app.use("/api/player", createPlayerRouter( options.botManager, logger, options.database, @@ -133,7 +134,7 @@ export function createWebServer(options: WebServerOptions): WebServer { )); app.use( "/api/auth", - createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider) + createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider) ); app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); From 50bdaae26a1d0b7b7037a7e8ac2ec572b7448b67 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 00:22:45 +0800 Subject: [PATCH 08/59] feat(spotify): frontend plumbing (source tab, badge, auth status) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/components/SongCard.vue | 9 +++++++-- web/src/components/SourceTabs.vue | 1 + web/src/stores/player.ts | 21 +++++++++++++-------- web/src/stores/sourceTabs.ts | 2 +- web/src/styles/variables.scss | 2 ++ 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 9dbd3de..e79889d 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -7,8 +7,8 @@ {{ song.name }} {{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }} + :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'" + >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}
{{ song.artist }}
@@ -145,6 +145,11 @@ function formatDuration(seconds: number): string { color: var(--brand-kugou); } +.badge-spotify { + background: var(--brand-spotify-12); + color: var(--brand-spotify); +} + .song-artist { font-size: 12px; color: var(--text-secondary); diff --git a/web/src/components/SourceTabs.vue b/web/src/components/SourceTabs.vue index ef4b02d..0b33372 100644 --- a/web/src/components/SourceTabs.vue +++ b/web/src/components/SourceTabs.vue @@ -25,6 +25,7 @@ const LABELS: Record = { netease: '网易云', qq: 'QQ', kugou: '酷狗', + spotify: 'Spotify', }; defineProps<{ diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 6abc868..496eb7d 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -10,10 +10,10 @@ export interface Song { album: string; duration: number; coverUrl: string; - platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou'; + platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify'; } -export type Source = 'netease' | 'qq' | 'kugou'; +export type Source = 'netease' | 'qq' | 'kugou' | 'spotify'; export interface BotStatus { id: string; @@ -100,11 +100,11 @@ export const usePlayerStore = defineStore('player', { theme: 'dark' as 'dark' | 'light', // Home page cache, split by source - recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] }, - dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[] }, - userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] }, + recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, + dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] }, + userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, bilibiliPopular: [] as Song[], - authStatus: { netease: false, qq: false, kugou: false }, + authStatus: { netease: false, qq: false, kugou: false, spotify: false }, lastFetchTime: 0, // Favorited playlists (fetched from server, isolated per WebUI user) @@ -158,6 +158,7 @@ export const usePlayerStore = defineStore('player', { if (this.authStatus.netease) s.push('netease'); if (this.authStatus.qq) s.push('qq'); if (this.authStatus.kugou) s.push('kugou'); + if (this.authStatus.spotify) s.push('spotify'); return s; }, }, @@ -572,23 +573,27 @@ export const usePlayerStore = defineStore('player', { // Always check auth status first — if it changed since the cached // fetch (e.g., user logged in/out as a different account), the // cached playlists belong to a different user and we MUST refetch. - const [neAuthRes, qqAuthRes, kugouAuthRes] = await Promise.allSettled([ + const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([ axios.get('/api/auth/status', { params: { platform: 'netease' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }), axios.get('/api/auth/status', { params: { platform: 'kugou' } }), + axios.get('/api/auth/status', { params: { platform: 'spotify' } }), ]); const newAuth = { netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn, + spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn, }; const authChanged = newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq || - newAuth.kugou !== this.authStatus.kugou; + newAuth.kugou !== this.authStatus.kugou || + newAuth.spotify !== this.authStatus.spotify; this.authStatus.netease = newAuth.netease; this.authStatus.qq = newAuth.qq; this.authStatus.kugou = newAuth.kugou; + this.authStatus.spotify = newAuth.spotify; // Favorites are user-local and cheap; always refresh them, even on a // home-data cache hit, so hearts stay correct across tabs/sessions. diff --git a/web/src/stores/sourceTabs.ts b/web/src/stores/sourceTabs.ts index f3c7472..adf19d7 100644 --- a/web/src/stores/sourceTabs.ts +++ b/web/src/stores/sourceTabs.ts @@ -28,7 +28,7 @@ function readAll(): Partial> { export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source { const all = readAll(); const v = all[key]; - return v === 'netease' || v === 'qq' || v === 'kugou' ? v : fallback; + return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback; } export function saveTabSource(key: TabKey, value: Source): void { diff --git a/web/src/styles/variables.scss b/web/src/styles/variables.scss index 1e2d32a..031868a 100644 --- a/web/src/styles/variables.scss +++ b/web/src/styles/variables.scss @@ -82,6 +82,8 @@ --brand-youtube-12: rgba(255, 0, 0, 0.12); --brand-kugou: #2ca2f9; --brand-kugou-12: rgba(44, 162, 249, 0.12); + --brand-spotify: #1DB954; + --brand-spotify-12: rgba(29, 185, 84, 0.12); } // Dark theme (default) From 77d71fe418a2e9b71f51f0dbc7007cb0abbff1e2 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 00:32:08 +0800 Subject: [PATCH 09/59] docs(spotify): note intentional Spotify omission from unified /search/all (stage 1) Spotify tracks are metadata-only until the librespot audio backend lands, so they are surfaced only from the dedicated Spotify tab, not the default all-sources search. Conscious decision from the whole-branch review (#112). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/api/music.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/web/api/music.ts b/src/web/api/music.ts index a547dea..2f74234 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -115,6 +115,11 @@ export function createMusicRouter( return; } const parsedLimit = parseInt(limit as string) || 20; + // Spotify is intentionally EXCLUDED from unified /search/all in Stage 1: + // its tracks are metadata-only (not yet playable) until the librespot audio + // backend lands (Stage 2/3), so surfacing them in the default all-sources + // view would only yield results that get skipped. Spotify search remains + // available from its own tab via /search?platform=spotify. const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([ neteaseProvider.search(q as string, parsedLimit), qqProvider.search(q as string, parsedLimit), From 978e6ee7f200fae93e37706e9403c96ea19f9d6e Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 20:30:45 +0800 Subject: [PATCH 10/59] =?UTF-8?q?docs(spotify):=20stage=202=20plan=20?= =?UTF-8?q?=E2=80=94=20go-librespot=20audio=20backend=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafted from a locked interface contract + integration map, then adversarially verified (3 critics). Includes REQUIRED CORRECTIONS fixing the gapless-handoff blocker (no stream re-attach on spotify->spotify), detach-not-destroy teardown, ffmpeg-static resolution, occupancy/seek transport delegation, and unhandled-error guarding. Linux/Docker-only + gated; not e2e-testable without Premium. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-02-spotify-stage2-go-librespot.md | 3186 +++++++++++++++++ 1 file changed, 3186 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-spotify-stage2-go-librespot.md diff --git a/docs/superpowers/plans/2026-07-02-spotify-stage2-go-librespot.md b/docs/superpowers/plans/2026-07-02-spotify-stage2-go-librespot.md new file mode 100644 index 0000000..32be361 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-spotify-stage2-go-librespot.md @@ -0,0 +1,3186 @@ +# Spotify Source — Stage 2 (go-librespot Audio Backend, Linux/Docker) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Real Spotify audio playback on Linux/Docker via a go-librespot sidecar, fed as a continuous PCM stream through the existing voice pipeline; cleanly gated so non-Linux/unconfigured installs keep the Stage-1 "not playable yet" behavior. + +**Architecture:** A per-bot `SpotifyController` owns a `GoLibrespotBackend` (implements `SpotifyAudioBackend`) that runs go-librespot as a sidecar (config.yml + REST + WebSocket) writing raw PCM to a FIFO, with one long-lived ffmpeg resampling 44.1k→48k. `AudioPlayer` gains an additive external-PCM mode (`playPcmStream`) that reuses its frame loop/encoder but is fed by the sidecar stream and advanced by the WebSocket `not_playing` event instead of ffmpeg EOF. `instance.ts` branches spotify songs to the controller and delegates transport. + +**Tech Stack:** TypeScript (ESM, `.js` specifiers), Node 25, `axios` + `ws` (both already deps), `vitest`. External binary: go-librespot (Linux only). + +## Global Constraints + +- ESM: all relative imports use the `.js` extension. +- **Linux/Docker-only, gated:** the go-librespot backend activates ONLY when `isGoLibrespotSupported()` (`process.platform === "linux"`) AND `config.spotify.enabled` AND a resolvable `go-librespot` binary AND login succeeds. On any other platform or when unavailable, `resolveAndPlay` MUST fall back to the Stage-1 sentinel-skip message and keep the queue moving. Never crash an unconfigured/non-Linux install. +- **Not end-to-end testable here:** real audio needs Spotify Premium + Linux + a live sidecar. "Done" = code complete, unit-tested with INJECTED/mocked `child_process`/`http`/`ws`/`fs` (no real binary or network in tests), `tsc --noEmit` clean, full `vitest run` green. Do NOT claim audio "works" — only that the logic is unit-verified. +- **Additive only:** do NOT change the existing `AudioPlayer.play(url)` behavior or break any existing test. Every Stage-2 change to a tested file (`player.ts`, `instance.ts`) is additive and gated. +- Metadata keeps using the Stage-1 Client-Credentials Web API path unchanged; go-librespot interactive OAuth is ONLY for audio. +- PCM contract: go-librespot pipe emits s16le/44100/stereo; ffmpeg resamples to s16le/48000/stereo (the frame loop consumes `PCM_FRAME_BYTES = 3840`). `getPcmStream()` returns the 48k ffmpeg stdout `Readable`. +- No new npm deps (`ws` + `axios` already present). Do NOT add a YAML package — hand-build config.yml. +- The verified go-librespot facts (config keys, REST endpoints, WS event types, exact ffmpeg command, mkfifo/spawn patterns, release-asset naming) live in `.superpowers/sdd/stage2-integration-map.md` — the source of truth; implementers should read it. +- Run tests: `npx vitest run `; typecheck `npx tsc --noEmit`; full suite `npx vitest run`. + +## REQUIRED CORRECTIONS (post-review — these OVERRIDE the task sections below where they conflict) + +The task sections were drafted before an adversarial review. Apply these corrections; they fix one blocker + several integration gaps that the mocked unit tests do not catch. The controller (Task 6) will pass exact instructions per task, but they are recorded here too. + +**C1 (Task 4 — ffmpeg binary resolution).** Do NOT hard-code `spawn("ffmpeg", …)`. The repo resolves ffmpeg via `getFfmpegCommand()` in `src/audio/player.ts:50` (falls back to the bundled `ffmpeg-static` when `ffmpeg` is not on PATH — the Docker case). Add `export` to that function (`export function getFfmpegCommand()`), import it into `go-librespot.ts` (`import { getFfmpegCommand } from "../../audio/player.js"`), and resolve the ffmpeg command as `this.deps?.ffmpegCommand ?? getFfmpegCommand()`. Keep the FIFO-reader arg array exactly as the map specifies. In the test, inject `deps.ffmpegCommand = "ffmpeg"` so the arg-array assertion still pins the args while production honors the fallback. + +**C2 (Task 5 — external-PCM teardown = DETACH, not destroy).** The external `Readable` is the backend's **long-lived, shared** ffmpeg stdout (one stream across all tracks). `stop()` and the internal fence inside `playPcmStream()` MUST detach (remove the `data`/`end`/`error` listeners the player added, `pause()` the readable) and clear `externalMode`/`onExternalEnd`/`externalStream` — they MUST NOT call `externalStream.destroy()` (that would kill the whole sidecar pipe for every future track). Add tests: (a) after one `playPcmStream(readable)`, pushing a first chunk then a LATER chunk both emit `frame`s with no re-attach (models a gapless track change driven by the sidecar); (b) a second `playPcmStream(newReadable)` detaches the first (first readable is NOT destroyed and no longer feeds `pcmBuffer`) and attaches the second; (c) `stop()` detaches without destroying and fences via `sessionId`; (d) the existing `play(url)` tests still pass unchanged. `getElapsed()` in external mode is frame-count based and therefore only APPROXIMATE for Spotify — acceptable for Stage 2, document it. + +**C3 (Task 6 — no unhandled `error` event).** Node throws on an `EventEmitter` `"error"` event with no listener. The controller MUST subscribe to the backend's `"error"` (log via `logger`, mark itself not-ready so the next `ensureStarted()` can relaunch) and MUST NOT re-emit a raw `"error"` event. `getPcmStream()` returns the backend's single persistent stream (no per-attach `PassThrough`), paired with C2 and C4. + +**C4 (Task 7 — gapless handoff, transport, occupancy, seek).** +- Add a private `currentSourceIsSpotify` flag. In `resolveAndPlay` for a **spotify** song: `ensureStarted()` → if false, keep the Stage-1 fallback message + `return false`; else `await controller.playTrack(uri)`; then **only if `!this.currentSourceIsSpotify`** call `this.player.playPcmStream(controller.getPcmStream(), { onExternalEnd })` (it internally fences the prior url-ffmpeg — do NOT also call `player.stop()`). If the previous song was already spotify, do NOT re-attach (leave the persistent stream flowing; go-librespot changes tracks into the same FIFO). Set `currentSourceIsSpotify = true`. +- In `resolveAndPlay` for a **non-spotify** song: if `this.currentSourceIsSpotify` was true, `this.spotifyController.pause().catch(…)` (stop the sidecar decoding) before the normal `player.play(url)` path; set `currentSourceIsSpotify = false`. +- `setupPlayerEvents`: `controller.on("trackEnded", () => { if (this.queue.current()?.platform === "spotify") this.playNext().catch(…) })`; rely on the `isAdvancing` guard. +- `cmdPause`/`cmdResume`/`cmdStop` AND `handleOccupancy` (≈333-353) AND `updateAutoPause` (≈311-315): when `this.queue.current()?.platform === "spotify"`, also delegate to `controller.pause()/resume()/stop()` (fire-and-forget `.catch`) alongside the existing `player.*` calls — occupancy auto-pause bypasses the cmd handlers, so it MUST be patched too or the sidecar keeps decoding into an empty channel. +- Web seek: add `BotInstance.seek(ms)` that routes to `controller.seek(ms)` when the current song is spotify, else `player.seek(ms)`; change `src/web/api/player.ts:201` from `bot.getPlayer().seek(position)` to `bot.seek(position)`. (Add `src/web/api/player.ts` to Task 7's file list.) + +## File structure + +**New:** `src/music/spotify/{binary,backend,go-librespot-config,go-librespot-api,go-librespot,controller}.ts` (+ `.test.ts` for each except `backend.ts` which is interface-only). +**Modified:** `src/audio/player.ts` (external-PCM mode), `src/bot/instance.ts` (spotify orchestration + transport delegation), `src/bot/manager.ts` + `src/index.ts` (thread controller construction params). + +--- + +### Task 1: go-librespot binary resolver + platform gate + +**Files:** +- CREATE `src/music/spotify/binary.ts` +- CREATE `src/music/spotify/binary.test.ts` + +This is the leaf module of Stage 2 — it has no dependency on any other new Spotify file and nothing here modifies an existing tested file, so no existing behavior is at risk. It mirrors `src/music/youtube.ts` (`findYtDlp` / `checkYtDlpAvailable` / `resetYtDlpAvailabilityCache`) exactly, adjusting the bin depth (`src/music/spotify/` is one directory deeper than `src/music/`, so the repo `bin/` is reached via `../../../bin`) and adding the Linux support gate. + +**Interfaces:** + +Consumes (Node built-ins only — no new deps): +- `node:child_process` `execFile`, `node:util` `promisify`, `node:fs` `existsSync`, `node:url` `fileURLToPath`, `node:path` `dirname`/`join`. + +Produces (locked contract — exact signatures): +- `export function isGoLibrespotSupported(): boolean` // `process.platform === "linux"` +- `export function findGoLibrespot(): string` // `bin/go-librespot` then PATH +- `export function resetGoLibrespotBinaryCache(): void` // test hook +- `export async function checkGoLibrespotAvailable(): Promise` // supported && binary runs + +Additive test-only seams (not part of the public backend contract; used only so the tests need no real binary and the locked functions stay param-free): +- `export function pickGoLibrespotPath(candidates: string[], exists: (p: string) => boolean): string` — pure ordering core behind `findGoLibrespot()`. +- `export function __setGoLibrespotVersionProbe(probe: ((bin: string) => Promise) | null): void` — override/restore the `--version` probe. + +--- + +- [ ] **Step 1: Write the failing test `src/music/spotify/binary.test.ts` (TDD red).** + + Full test file — asserts real path-ordering and gate/cache behavior, injecting the version probe and stubbing `process.platform` so no real binary or network is touched: + + ```ts + import { describe, it, expect, vi, afterEach } from "vitest"; + import { join } from "node:path"; + import { + isGoLibrespotSupported, + pickGoLibrespotPath, + findGoLibrespot, + checkGoLibrespotAvailable, + resetGoLibrespotBinaryCache, + __setGoLibrespotVersionProbe, + } from "./binary.js"; + + const origPlatform = process.platform; + function setPlatform(p: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: p, configurable: true }); + } + + afterEach(() => { + setPlatform(origPlatform); + __setGoLibrespotVersionProbe(null); + resetGoLibrespotBinaryCache(); + }); + + describe("isGoLibrespotSupported", () => { + it("is true only on linux", () => { + setPlatform("linux"); + expect(isGoLibrespotSupported()).toBe(true); + setPlatform("win32"); + expect(isGoLibrespotSupported()).toBe(false); + setPlatform("darwin"); + expect(isGoLibrespotSupported()).toBe(false); + }); + }); + + describe("pickGoLibrespotPath (bin/ then PATH ordering)", () => { + const binPath = join("some", "root", "bin", "go-librespot"); + + it("prefers the bin/ path when the file exists", () => { + expect( + pickGoLibrespotPath([binPath, "go-librespot"], (p) => p === binPath), + ).toBe(binPath); + }); + + it("falls through to the bare PATH name when the bin/ file is missing", () => { + expect(pickGoLibrespotPath([binPath, "go-librespot"], () => false)).toBe( + "go-librespot", + ); + }); + + it("returns bare command names without touching the filesystem", () => { + const exists = vi.fn(() => false); + expect(pickGoLibrespotPath(["go-librespot"], exists)).toBe("go-librespot"); + expect(exists).not.toHaveBeenCalled(); + }); + }); + + describe("findGoLibrespot", () => { + it("returns the bare command name when bin/go-librespot is absent", () => { + // No go-librespot binary is committed under bin/, so resolution must + // fall back to the bare PATH name (execFile resolves it at run time). + expect(findGoLibrespot()).toBe("go-librespot"); + }); + }); + + describe("checkGoLibrespotAvailable", () => { + it("returns false immediately on unsupported platforms without probing", async () => { + setPlatform("darwin"); + const probe = vi.fn(async () => {}); + __setGoLibrespotVersionProbe(probe); + expect(await checkGoLibrespotAvailable()).toBe(false); + expect(probe).not.toHaveBeenCalled(); + }); + + it("returns true when the binary responds to --version on linux", async () => { + setPlatform("linux"); + __setGoLibrespotVersionProbe(async () => {}); + expect(await checkGoLibrespotAvailable()).toBe(true); + }); + + it("caches only positive results and probes once", async () => { + setPlatform("linux"); + const probe = vi.fn(async () => {}); + __setGoLibrespotVersionProbe(probe); + expect(await checkGoLibrespotAvailable()).toBe(true); + expect(await checkGoLibrespotAvailable()).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("does not cache a failed probe (retries on the next call)", async () => { + setPlatform("linux"); + __setGoLibrespotVersionProbe(async () => { + throw new Error("ENOENT"); + }); + expect(await checkGoLibrespotAvailable()).toBe(false); + // A later successful probe must now succeed — negatives are not cached. + __setGoLibrespotVersionProbe(async () => {}); + expect(await checkGoLibrespotAvailable()).toBe(true); + }); + + it("resetGoLibrespotBinaryCache clears a cached positive", async () => { + setPlatform("linux"); + __setGoLibrespotVersionProbe(async () => {}); + expect(await checkGoLibrespotAvailable()).toBe(true); + resetGoLibrespotBinaryCache(); + __setGoLibrespotVersionProbe(async () => { + throw new Error("gone"); + }); + expect(await checkGoLibrespotAvailable()).toBe(false); + }); + }); + ``` + + Verify (expected FAIL — module does not exist yet): + ``` + npx vitest run src/music/spotify/binary.test.ts + ``` + Expected: suite errors / cannot resolve `./binary.js`. + +- [ ] **Step 2: Implement `src/music/spotify/binary.ts` (TDD green).** + + Full implementation — mirrors `youtube.ts` conventions (`execFileAsync`, 5s timeout, `maxBuffer: 1024`, cache-positive-only) with the Linux gate and the `../../../bin` depth: + + ```ts + import { execFile } from "node:child_process"; + import { promisify } from "node:util"; + import { existsSync } from "node:fs"; + import { fileURLToPath } from "node:url"; + import { dirname, join } from "node:path"; + + const execFileAsync = promisify(execFile); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + + /** + * True only on Linux. go-librespot ships Linux-only release binaries and the + * sidecar relies on a POSIX FIFO (mkfifo), so the Spotify audio backend is + * gated to Linux/Docker. Everywhere else the caller falls back to the Stage-1 + * sentinel-skip message. + */ + export function isGoLibrespotSupported(): boolean { + return process.platform === "linux"; + } + + /** + * Pure resolver core behind findGoLibrespot(). Returns the first candidate + * that is either a bare command name (left for execFile to resolve via PATH) + * or an existing bin/ file. Exported so tests can inject candidates + a fake + * existence predicate and need no real binary on disk. + */ + export function pickGoLibrespotPath( + candidates: string[], + exists: (p: string) => boolean, + ): string { + for (const c of candidates) { + // bin/ paths only count when the file is actually present; bare names are + // returned unconditionally and resolved later via PATH. + const isBinPath = c.includes(join("bin", "go-librespot")); + if (!isBinPath || exists(c)) return c; + } + return "go-librespot"; + } + + /** Resolve the go-librespot binary path: project bin/ dir first, then PATH. */ + export function findGoLibrespot(): string { + // src/music/spotify -> ../../../bin (one level deeper than youtube.ts). + const binPath = join(__dirname, "..", "..", "..", "bin", "go-librespot"); + return pickGoLibrespotPath([binPath, "go-librespot"], existsSync); + } + + // Injectable `--version` probe. Defaults to the real execFile call; tests + // override it so checkGoLibrespotAvailable() needs no real binary. Keeps the + // public checkGoLibrespotAvailable() signature param-free per the contract. + type VersionProbe = (bin: string) => Promise; + const realProbe: VersionProbe = async (bin) => { + await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 }); + }; + let versionProbe: VersionProbe = realProbe; + + /** Test hook: override the `--version` probe, or restore the default with null. */ + export function __setGoLibrespotVersionProbe( + probe: VersionProbe | null, + ): void { + versionProbe = probe ?? realProbe; + } + + /** + * Availability check for go-librespot. Returns false immediately on non-Linux + * platforms (unsupported). Otherwise runs `go-librespot --version` (5s timeout) + * and caches ONLY the positive result — a missing binary is retried on the + * next call so the operator can install it without restarting the server. + */ + let cachedAvailable = false; + let pendingCheck: Promise | null = null; + export async function checkGoLibrespotAvailable(): Promise { + if (!isGoLibrespotSupported()) return false; + if (cachedAvailable) return true; + if (pendingCheck) return pendingCheck; + pendingCheck = (async () => { + try { + await versionProbe(findGoLibrespot()); + cachedAvailable = true; + return true; + } catch { + return false; + } finally { + pendingCheck = null; + } + })(); + return pendingCheck; + } + + /** Force re-detection on the next call (for tests). */ + export function resetGoLibrespotBinaryCache(): void { + cachedAvailable = false; + pendingCheck = null; + } + ``` + + Verify (expected PASS — all cases green): + ``` + npx vitest run src/music/spotify/binary.test.ts + ``` + Expected: all tests pass (isGoLibrespotSupported gate, bin-first/PATH-fallback ordering, unsupported-gate short-circuit with no probe call, positive-only caching probed once, negative-not-cached retry, cache reset). + +- [ ] **Step 3: Typecheck.** + ``` + npx tsc --noEmit + ``` + Expected: PASS (no errors). `NodeJS.Platform` comes from `@types/node` globals; NodeNext/ESM `.js` import specifier resolves to `binary.ts`. + +- [ ] **Step 4: Commit.** + ``` + git add src/music/spotify/binary.ts src/music/spotify/binary.test.ts + git commit -m "$(cat <<'EOF' + feat(spotify): add go-librespot binary resolver + Linux support gate + + Mirror youtube.ts findYtDlp/checkYtDlpAvailable (bin/ then PATH, + cache-positive-only availability, reset test hook) and add + isGoLibrespotSupported() Linux gate for the Stage 2 audio backend. + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +--- + +Now I have everything needed. Here is the task section. + +--- + +### Task 2: SpotifyAudioBackend interface + config.yml generator + +**Files:** +- CREATE `src/music/spotify/backend.ts` (interface + DTOs only, no runtime code) +- CREATE `src/music/spotify/go-librespot-config.ts` (`renderConfigYml`) +- CREATE `src/music/spotify/go-librespot-config.test.ts` (vitest) + +**Interfaces:** + +Consumes (nothing external — these are pure/declaration modules): +- `GoLibrespotConfigOptions { deviceName: string; bitrate: number; fifoPath: string; apiAddress: string; apiPort: number; callbackPort: number }` + +Produces (exact signatures from the locked contract): +- `backend.ts` — `export interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error" }` +- `backend.ts` — `export interface SpotifyNowPlaying { uri: string; name: string; artist: string; album: string; coverUrl: string; durationMs: number }` +- `backend.ts` — `export interface SpotifyAudioBackend { start(): Promise; stop(): void; isReady(): boolean; playTrack(uri: string): Promise; pause(): Promise; resume(): Promise; seek(ms: number): Promise; getPcmStream(): import("node:stream").Readable; getPositionMs(): number; on(event, cb): void }` +- `go-librespot-config.ts` — `export interface GoLibrespotConfigOptions { ... }` +- `go-librespot-config.ts` — `export function renderConfigYml(o: GoLibrespotConfigOptions): string` + +Notes: Both new files are self-contained (no imports from existing tested modules), so nothing existing changes or breaks. `backend.ts` is types-only — it is consumed by `go-librespot.ts` and `controller.ts` in later tasks. No `yaml` dependency is added; the config is a hand-built string and the test verifies it by exact-line assertions plus a minimal structural parse. + +--- + +- [ ] **Step 1: Create `backend.ts` — the interface + two event DTOs, interface-only (no runtime code).** + + Create `src/music/spotify/backend.ts` with exactly the locked contract (ESM, but note this file has no runtime imports — the `Readable` type is referenced inline via `import("node:stream")` so nothing is emitted): + + ```ts + // src/music/spotify/backend.ts + // Type contract for the Spotify audio backend (go-librespot sidecar). + // Interface-only: this module intentionally contains NO runtime code so it + // can be imported for types by go-librespot.ts and controller.ts without + // pulling in child_process/ws/ffmpeg at type-check time. + + /** Emitted when the currently playing Spotify track finishes or is stopped. */ + export interface SpotifyTrackEndedEvent { + uri: string; + reason: "ended" | "stopped" | "error"; + } + + /** Now-playing metadata surfaced from the go-librespot "metadata" event. */ + export interface SpotifyNowPlaying { + uri: string; + name: string; + artist: string; + album: string; + coverUrl: string; + durationMs: number; + } + + /** + * Long-lived Spotify audio source: owns the go-librespot sidecar + FIFO->ffmpeg + * PCM pipe and exposes transport control plus a continuous 48kHz s16le stereo + * PCM stream to feed AudioPlayer.playPcmStream(). + */ + export interface SpotifyAudioBackend { + start(): Promise; + stop(): void; + isReady(): boolean; + playTrack(uri: string): Promise; + pause(): Promise; + resume(): Promise; + seek(ms: number): Promise; + getPcmStream(): import("node:stream").Readable; + getPositionMs(): number; + on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void; + on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void; + on(event: "ready" | "error", cb: (arg?: unknown) => void): void; + } + ``` + + Verify it type-checks (no runtime output expected): + - Run: `npx tsc --noEmit` + - Expected: PASS (exit 0). `backend.ts` has no value-level exports, so nothing to unit-test here; it is exercised by later tasks. + +- [ ] **Step 2 (RED): Write the failing test for `renderConfigYml`.** + + Create `src/music/spotify/go-librespot-config.test.ts`. The test asserts the rendered YAML contains the exact keys/values from the GO-LIBRESPOT CONCRETE FACTS (pipe backend, `s16le`, `device_type: computer`, server enabled, `credentials.type: interactive`), and round-trips through a tiny dependency-free structural parser (we do NOT add a yaml package). Assertions are on real rendered output, not mocks: + + ```ts + // src/music/spotify/go-librespot-config.test.ts + import { describe, it, expect } from "vitest"; + import { renderConfigYml, type GoLibrespotConfigOptions } from "./go-librespot-config.js"; + + const OPTS: GoLibrespotConfigOptions = { + deviceName: "TeamSpeak Music Bot", + bitrate: 320, + fifoPath: "/tmp/go-librespot.fifo", + apiAddress: "0.0.0.0", + apiPort: 3678, + callbackPort: 8080, + }; + + /** + * Minimal 2-level YAML reader for the exact shape renderConfigYml emits + * (flat scalars + one level of nesting under `server:` / `credentials:`). + * Avoids adding a yaml dependency while still proving the output round-trips. + */ + function parseTinyYaml(src: string): Record { + const root: Record = {}; + const stack: Array<{ indent: number; obj: Record }> = [ + { indent: -1, obj: root }, + ]; + for (const rawLine of src.split("\n")) { + if (rawLine.trim() === "") continue; + const indent = rawLine.length - rawLine.trimStart().length; + const line = rawLine.trim(); + const idx = line.indexOf(":"); + const key = line.slice(0, idx).trim(); + let valRaw = line.slice(idx + 1).trim(); + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + const parent = stack[stack.length - 1].obj; + if (valRaw === "") { + const child: Record = {}; + parent[key] = child; + stack.push({ indent, obj: child }); + continue; + } + let val: unknown = valRaw; + if (valRaw.startsWith('"') && valRaw.endsWith('"')) val = JSON.parse(valRaw); + else if (valRaw === "true") val = true; + else if (valRaw === "false") val = false; + else if (/^-?\d+$/.test(valRaw)) val = Number(valRaw); + parent[key] = val; + } + return root; + } + + describe("renderConfigYml", () => { + it("emits the exact top-level go-librespot keys/values", () => { + const lines = renderConfigYml(OPTS).split("\n"); + expect(lines).toContain('device_name: "TeamSpeak Music Bot"'); + expect(lines).toContain("device_type: computer"); + expect(lines).toContain("bitrate: 320"); + expect(lines).toContain("audio_backend: pipe"); + expect(lines).toContain("audio_output_pipe: /tmp/go-librespot.fifo"); + expect(lines).toContain("audio_output_pipe_format: s16le"); + }); + + it("emits a server block with enabled/address/port set explicitly", () => { + const parsed = parseTinyYaml(renderConfigYml(OPTS)); + expect(parsed.server).toEqual({ + enabled: true, + address: "0.0.0.0", + port: 3678, + }); + }); + + it("emits interactive OAuth credentials with the callback port", () => { + const parsed = parseTinyYaml(renderConfigYml(OPTS)); + expect(parsed.credentials).toEqual({ + type: "interactive", + interactive: { callback_port: 8080 }, + }); + }); + + it("full round-trip reflects every provided option", () => { + const parsed = parseTinyYaml(renderConfigYml(OPTS)); + expect(parsed).toEqual({ + device_name: "TeamSpeak Music Bot", + device_type: "computer", + bitrate: 320, + audio_backend: "pipe", + audio_output_pipe: "/tmp/go-librespot.fifo", + audio_output_pipe_format: "s16le", + server: { enabled: true, address: "0.0.0.0", port: 3678 }, + credentials: { type: "interactive", interactive: { callback_port: 8080 } }, + }); + }); + + it("threads distinct option values through unchanged (no hard-coded ports)", () => { + const parsed = parseTinyYaml( + renderConfigYml({ + deviceName: "Other Bot", + bitrate: 160, + fifoPath: "/run/librespot/pipe", + apiAddress: "127.0.0.1", + apiPort: 4000, + callbackPort: 9099, + }), + ); + expect(parsed).toMatchObject({ + device_name: "Other Bot", + bitrate: 160, + audio_output_pipe: "/run/librespot/pipe", + server: { address: "127.0.0.1", port: 4000 }, + credentials: { interactive: { callback_port: 9099 } }, + }); + }); + + it("safely quotes device names containing special characters", () => { + const yml = renderConfigYml({ ...OPTS, deviceName: 'My "Cool" Bot' }); + expect(yml.split("\n")).toContain('device_name: "My \\"Cool\\" Bot"'); + // and still round-trips back to the original string + expect(parseTinyYaml(yml).device_name).toBe('My "Cool" Bot'); + }); + }); + ``` + + Run it (module does not exist yet): + - Run: `npx vitest run src/music/spotify/go-librespot-config.test.ts` + - Expected: FAIL (cannot resolve `./go-librespot-config.js`). + +- [ ] **Step 3 (GREEN): Implement `renderConfigYml` as a hand-built string.** + + Create `src/music/spotify/go-librespot-config.ts`. Values are threaded from the options; `device_type`/`audio_backend`/`audio_output_pipe_format`/`credentials.type` are fixed per the confirmed go-librespot facts. `device_name` is quoted via `JSON.stringify` so spaces/quotes are escaped safely (valid YAML double-quoted scalar): + + ```ts + // src/music/spotify/go-librespot-config.ts + // Hand-built go-librespot config.yml. Keys/values verified against + // devgianlu/go-librespot cmd/daemon/cli_config.go koanf tags. No yaml + // dependency is used; the file is a small, fixed-shape document. + + export interface GoLibrespotConfigOptions { + deviceName: string; + bitrate: number; + fifoPath: string; + apiAddress: string; + apiPort: number; + callbackPort: number; + } + + /** + * Render a headless go-librespot config.yml: + * - pipe audio backend writing raw 44.1kHz/s16le stereo PCM to a FIFO, + * - HTTP+WebSocket control server enabled (port has NO built-in default, + * so it is always written explicitly), + * - interactive OAuth credentials (persisted automatically to + * /credentials.json after first login). + */ + export function renderConfigYml(o: GoLibrespotConfigOptions): string { + return ( + [ + `device_name: ${JSON.stringify(o.deviceName)}`, + `device_type: computer`, + `bitrate: ${o.bitrate}`, + `audio_backend: pipe`, + `audio_output_pipe: ${o.fifoPath}`, + `audio_output_pipe_format: s16le`, + `server:`, + ` enabled: true`, + ` address: ${o.apiAddress}`, + ` port: ${o.apiPort}`, + `credentials:`, + ` type: interactive`, + ` interactive:`, + ` callback_port: ${o.callbackPort}`, + ].join("\n") + "\n" + ); + } + ``` + + Re-run the test: + - Run: `npx vitest run src/music/spotify/go-librespot-config.test.ts` + - Expected: PASS (6 tests green). + +- [ ] **Step 4: Type-check the whole project (confirms `backend.ts` + config module compile and nothing else regressed).** + - Run: `npx tsc --noEmit` + - Expected: PASS (exit 0). + +- [ ] **Step 5: Commit.** + - Run: `git add src/music/spotify/backend.ts src/music/spotify/go-librespot-config.ts src/music/spotify/go-librespot-config.test.ts` + - Run: + ``` + git commit -m "$(cat <<'EOF' + feat(spotify): add SpotifyAudioBackend interface + go-librespot config.yml renderer + + - backend.ts: type-only SpotifyAudioBackend contract + track/metadata DTOs + - go-librespot-config.ts: renderConfigYml() hand-built config (pipe/s16le, + server enabled, interactive OAuth), no yaml dependency + - tests assert exact keys/values and round-trip via a tiny structural parser + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + - Expected: commit succeeds on the current feature branch (do not commit to `main`; branch first if on `main`). + +--- + +### Task 3: go-librespot REST client + WebSocket event client + +**Files:** +- CREATE `src/music/spotify/go-librespot-api.ts` +- CREATE `src/music/spotify/go-librespot-api.test.ts` + +**Interfaces:** + +Consumes (external): +- `axios` (`axios.create` style mirrored from `src/music/bilibili.ts:43`), injected for tests via `deps.http: import("axios").AxiosInstance` +- `ws` package `WebSocket` (default), injected for tests via `deps.WebSocketCtor` +- `node:events` `EventEmitter` (base class for the event client) +- go-librespot REST/WS facts from the map: `POST /player/play {uri}`, `/player/pause`, `/player/resume`, `/player/stop`, `POST /player/seek {position, relative:false}` (position in **ms**), `GET /status` (track.position/duration in **ms**), `GET /` reachability, `ws://…/events` envelope `{"type","data"}` + +Produces (locked contract, verbatim): +```ts +export interface GoLibrespotStatusTrack { uri: string; name: string; artist_names: string[]; album_name: string; album_cover_url: string | null; position: number; duration: number } +export interface GoLibrespotStatus { stopped: boolean; paused: boolean; buffering: boolean; track: GoLibrespotStatusTrack | null } +export class GoLibrespotRestClient { + constructor(baseUrl: string, deps?: { http?: import("axios").AxiosInstance }) + ping(): Promise // GET / -> 200; false on error + playTrack(uri: string): Promise // POST /player/play {uri} + pause(): Promise // POST /player/pause + resume(): Promise // POST /player/resume + stop(): Promise // POST /player/stop + seek(ms: number): Promise // POST /player/seek {position: ms, relative: false} + getStatus(): Promise // GET /status; null on error +} +export type GoLibrespotEventType = "metadata"|"playing"|"paused"|"not_playing"|"stopped"|"will_play"|"seek"|"active"|"inactive"|"volume"|"playback_ready" +export class GoLibrespotEventClient extends EventEmitter { + constructor(wsUrl: string, deps?: { WebSocketCtor?: any }) + start(): void + stop(): void + // emits (msg.type as-is) with msg.data; e.g. .on("not_playing", d => …), .on("metadata", d => …) +} +``` + +--- + +- [ ] **Step 1: Write the REST-client tests first (red).** + Create `src/music/spotify/go-librespot-api.test.ts`. Import from the not-yet-existent module so the run fails on missing module / assertions. Assert exact method + path + body, and the error-swallowing contract (`ping`→`false`, `getStatus`→`null`). + + ```ts + import { describe, it, expect, vi } from "vitest"; + import type { AxiosInstance } from "axios"; + import { GoLibrespotRestClient } from "./go-librespot-api.js"; + + /** Minimal axios stub: only get/post are exercised by the client. */ + function makeHttp(overrides?: Partial>) { + return { + get: vi.fn().mockResolvedValue({ status: 200, data: {} }), + post: vi.fn().mockResolvedValue({ status: 200, data: {} }), + ...overrides, + } as unknown as AxiosInstance; + } + + describe("GoLibrespotRestClient", () => { + it("ping() returns true on GET / -> 200", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.ping()).resolves.toBe(true); + expect(http.get).toHaveBeenCalledWith("/"); + }); + + it("ping() returns false when GET / rejects (daemon not up)", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.ping()).resolves.toBe(false); + }); + + it("playTrack() POSTs /player/play with the uri body", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await client.playTrack("spotify:track:abc123"); + expect(http.post).toHaveBeenCalledWith("/player/play", { uri: "spotify:track:abc123" }); + }); + + it("pause/resume/stop POST their bodyless endpoints", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await client.pause(); + await client.resume(); + await client.stop(); + expect(http.post).toHaveBeenNthCalledWith(1, "/player/pause"); + expect(http.post).toHaveBeenNthCalledWith(2, "/player/resume"); + expect(http.post).toHaveBeenNthCalledWith(3, "/player/stop"); + }); + + it("seek() POSTs /player/seek with position(ms) and relative:false", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await client.seek(42000); + expect(http.post).toHaveBeenCalledWith("/player/seek", { position: 42000, relative: false }); + }); + + it("playTrack() rejects when the POST fails (surfaced to caller)", async () => { + const http = makeHttp({ post: vi.fn().mockRejectedValue(new Error("boom")) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.playTrack("spotify:track:x")).rejects.toThrow("boom"); + }); + + it("getStatus() normalizes the /status shape (ms position/duration)", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + stopped: false, + paused: false, + buffering: false, + track: { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "https://i.scdn.co/c.jpg", + position: 12345, + duration: 200000, + }, + }, + }), + }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + const status = await client.getStatus(); + expect(http.get).toHaveBeenCalledWith("/status"); + expect(status).toEqual({ + stopped: false, + paused: false, + buffering: false, + track: { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "https://i.scdn.co/c.jpg", + position: 12345, + duration: 200000, + }, + }); + }); + + it("getStatus() returns null with a null track when nothing is loaded", async () => { + const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 200, data: { stopped: true, paused: false, buffering: false, track: null } }) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + const status = await client.getStatus(); + expect(status).toEqual({ stopped: true, paused: false, buffering: false, track: null }); + }); + + it("getStatus() returns null when GET /status rejects", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("down")) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.getStatus()).resolves.toBeNull(); + }); + }); + ``` + + Verify (expect FAIL — module `./go-librespot-api.js` does not exist yet): + ``` + npx vitest run src/music/spotify/go-librespot-api.test.ts + ``` + +- [ ] **Step 2: Implement `GoLibrespotRestClient` (green).** + Create `src/music/spotify/go-librespot-api.ts`. `axios.create` mirrors the `baseURL`/`timeout`/`headers` style of `src/music/bilibili.ts:43`. `ping`/`getStatus` swallow errors per contract; the mutating ops let rejections propagate. (The `EventEmitter`/`ws` imports are added now so Step 4 needs no re-edit of the header.) + + ```ts + import { EventEmitter } from "node:events"; + import axios, { type AxiosInstance } from "axios"; + import WebSocket from "ws"; + + export interface GoLibrespotStatusTrack { + uri: string; + name: string; + artist_names: string[]; + album_name: string; + album_cover_url: string | null; + position: number; + duration: number; + } + + export interface GoLibrespotStatus { + stopped: boolean; + paused: boolean; + buffering: boolean; + track: GoLibrespotStatusTrack | null; + } + + export class GoLibrespotRestClient { + private http: AxiosInstance; + + constructor(baseUrl: string, deps?: { http?: AxiosInstance }) { + this.http = + deps?.http ?? + axios.create({ + baseURL: baseUrl, + timeout: 10000, + headers: { "Content-Type": "application/json" }, + }); + } + + async ping(): Promise { + try { + const res = await this.http.get("/"); + return res.status === 200; + } catch { + return false; + } + } + + async playTrack(uri: string): Promise { + await this.http.post("/player/play", { uri }); + } + + async pause(): Promise { + await this.http.post("/player/pause"); + } + + async resume(): Promise { + await this.http.post("/player/resume"); + } + + async stop(): Promise { + await this.http.post("/player/stop"); + } + + async seek(ms: number): Promise { + await this.http.post("/player/seek", { position: ms, relative: false }); + } + + async getStatus(): Promise { + try { + const res = await this.http.get("/status"); + const d = res.data ?? {}; + const t = d.track; + return { + stopped: Boolean(d.stopped), + paused: Boolean(d.paused), + buffering: Boolean(d.buffering), + track: t + ? { + uri: t.uri ?? "", + name: t.name ?? "", + artist_names: Array.isArray(t.artist_names) ? t.artist_names : [], + album_name: t.album_name ?? "", + album_cover_url: t.album_cover_url ?? null, + position: t.position ?? 0, + duration: t.duration ?? 0, + } + : null, + }; + } catch { + return null; + } + } + } + ``` + + Verify (expect PASS for the REST describe; the WS describe does not exist yet): + ``` + npx vitest run src/music/spotify/go-librespot-api.test.ts + ``` + +- [ ] **Step 3: Add the event-client tests (red).** + Append to `src/music/spotify/go-librespot-api.test.ts`. Use a `FakeWebSocket` (an `EventEmitter`) injected via `deps.WebSocketCtor` so no real socket opens; assert the parsed `type` is emitted with `data`, focusing on `not_playing` (track-end) and `metadata`, plus reconnect-on-close (fake timers) and `stop()` teardown. Update the top import to include `GoLibrespotEventClient`. + + Change the existing import line: + ```ts + import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js"; + ``` + Then add (after the REST `describe`): + ```ts + import { EventEmitter } from "node:events"; + + /** Fake ws: records instances, lets tests drive open/message/close/error. */ + class FakeWebSocket extends EventEmitter { + static instances: FakeWebSocket[] = []; + closed = false; + constructor(public url: string) { + super(); + FakeWebSocket.instances.push(this); + } + close() { + this.closed = true; + this.emit("close"); + } + } + + function frame(type: string, data: unknown): Buffer { + return Buffer.from(JSON.stringify({ type, data })); + } + + describe("GoLibrespotEventClient", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + }); + + it("emits 'not_playing' (track-end) with its data payload", () => { + const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", { + WebSocketCtor: FakeWebSocket as any, + }); + const onEnded = vi.fn(); + client.on("not_playing", onEnded); + client.start(); + + const ws = FakeWebSocket.instances[0]; + expect(ws.url).toBe("ws://127.0.0.1:3678/events"); + ws.emit("message", frame("not_playing", { uri: "spotify:track:abc", play_origin: "go-librespot" })); + + expect(onEnded).toHaveBeenCalledTimes(1); + expect(onEnded).toHaveBeenCalledWith({ uri: "spotify:track:abc", play_origin: "go-librespot" }); + client.stop(); + }); + + it("emits 'metadata' with the now-playing object", () => { + const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", { + WebSocketCtor: FakeWebSocket as any, + }); + const onMeta = vi.fn(); + client.on("metadata", onMeta); + client.start(); + + FakeWebSocket.instances[0].emit( + "message", + frame("metadata", { uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }), + ); + + expect(onMeta).toHaveBeenCalledWith({ uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }); + client.stop(); + }); + + it("ignores non-JSON frames without throwing", () => { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + const onAny = vi.fn(); + client.on("metadata", onAny); + client.start(); + expect(() => FakeWebSocket.instances[0].emit("message", Buffer.from("not json"))).not.toThrow(); + expect(onAny).not.toHaveBeenCalled(); + client.stop(); + }); + + it("reconnects with backoff after the socket closes", () => { + vi.useFakeTimers(); + try { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + client.start(); + expect(FakeWebSocket.instances).toHaveLength(1); + + FakeWebSocket.instances[0].emit("close"); + expect(FakeWebSocket.instances).toHaveLength(1); // not immediate + vi.advanceTimersByTime(500); + expect(FakeWebSocket.instances).toHaveLength(2); // reconnected + client.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it("stop() closes the socket and prevents reconnect", () => { + vi.useFakeTimers(); + try { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + client.start(); + const ws = FakeWebSocket.instances[0]; + client.stop(); + expect(ws.closed).toBe(true); + vi.advanceTimersByTime(60000); + expect(FakeWebSocket.instances).toHaveLength(1); // no new socket + } finally { + vi.useRealTimers(); + } + }); + + it("does not throw on socket 'error' when no error listener is attached", () => { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + client.start(); + expect(() => FakeWebSocket.instances[0].emit("error", new Error("net"))).not.toThrow(); + client.stop(); + }); + }); + ``` + Add `beforeEach` to the vitest import at the top of the file: + ```ts + import { describe, it, expect, vi, beforeEach } from "vitest"; + ``` + + Verify (expect FAIL — `GoLibrespotEventClient` is not exported yet): + ``` + npx vitest run src/music/spotify/go-librespot-api.test.ts + ``` + +- [ ] **Step 4: Implement `GoLibrespotEventClient` (green).** + Append to `src/music/spotify/go-librespot-api.ts` (the `EventEmitter`/`WebSocket` imports are already present from Step 2). Parses each `{type,data}` frame and re-emits `type` with `data`; reconnects on close with capped exponential backoff; `stop()` fences reconnects and closes. Guards `emit("error", …)` behind a listener count so a socket error with no listener does not crash the process. + + ```ts + export type GoLibrespotEventType = + | "metadata" + | "playing" + | "paused" + | "not_playing" + | "stopped" + | "will_play" + | "seek" + | "active" + | "inactive" + | "volume" + | "playback_ready"; + + interface WsLike { + on(event: string, cb: (...args: any[]) => void): void; + close(): void; + } + type WebSocketCtor = new (url: string) => WsLike; + + const INITIAL_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 10000; + + export class GoLibrespotEventClient extends EventEmitter { + private wsUrl: string; + private WebSocketCtor: WebSocketCtor; + private ws: WsLike | null = null; + private stopped = false; + private reconnectDelay = INITIAL_RECONNECT_MS; + private reconnectTimer: ReturnType | null = null; + + constructor(wsUrl: string, deps?: { WebSocketCtor?: WebSocketCtor }) { + super(); + this.wsUrl = wsUrl; + this.WebSocketCtor = deps?.WebSocketCtor ?? (WebSocket as unknown as WebSocketCtor); + } + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private connect(): void { + if (this.stopped) return; + const ws = new this.WebSocketCtor(this.wsUrl); + this.ws = ws; + ws.on("open", () => { + this.reconnectDelay = INITIAL_RECONNECT_MS; + }); + ws.on("message", (buf: unknown) => this.handleMessage(buf)); + ws.on("close", () => { + this.ws = null; + this.scheduleReconnect(); + }); + ws.on("error", (err: unknown) => { + if (this.listenerCount("error") > 0) this.emit("error", err); + }); + } + + private handleMessage(buf: unknown): void { + let parsed: unknown; + try { + const text = Buffer.isBuffer(buf) + ? buf.toString("utf8") + : typeof buf === "string" + ? buf + : String(buf); + parsed = JSON.parse(text); + } catch { + return; + } + if (parsed && typeof (parsed as any).type === "string") { + this.emit((parsed as any).type, (parsed as any).data ?? {}); + } + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_MS); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + } + ``` + + Verify (expect PASS — all REST + WS tests green): + ``` + npx vitest run src/music/spotify/go-librespot-api.test.ts + ``` + +- [ ] **Step 5: Typecheck, full-suite sanity, and commit.** + No existing files were modified (both files are new and additive), so existing tests are untouched. Confirm types and the whole suite, then commit. + + Verify (expect PASS / no type errors): + ``` + npx tsc --noEmit + npx vitest run src/music/spotify/go-librespot-api.test.ts + ``` + Then commit: + ``` + git add src/music/spotify/go-librespot-api.ts src/music/spotify/go-librespot-api.test.ts + git commit -m "$(cat <<'EOF' + feat(spotify): add go-librespot REST client + WS event client (Stage 2) + + GoLibrespotRestClient wraps axios (injectable via deps.http) for + /player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus + swallow errors to false/null, mutating ops reject. GoLibrespotEventClient + (EventEmitter) parses {type,data} /events frames and re-emits type with data, + reconnects on close with capped backoff, stop() tears down. TDD with a mock + AxiosInstance and a fake WebSocket (no real binary/network). + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +--- + +### Task 4: GoLibrespotBackend (process + ffmpeg + PCM + events) + +**Files:** +- CREATE `src/music/spotify/go-librespot.ts` +- CREATE `src/music/spotify/go-librespot.test.ts` +- Depends on Tasks 1–3 already committed: `src/music/spotify/backend.ts` (interface), `src/music/spotify/binary.ts` (`findGoLibrespot`), `src/music/spotify/go-librespot-config.ts` (`renderConfigYml`), `src/music/spotify/go-librespot-api.ts` (`GoLibrespotRestClient`, `GoLibrespotEventClient`). This task does NOT touch `player.ts`/`instance.ts` (those are later tasks); their existing behavior is unaffected. + +**Interfaces:** + +Consumes (exact signatures from the locked contract): +- `renderConfigYml(o: GoLibrespotConfigOptions): string` where `GoLibrespotConfigOptions = { deviceName: string; bitrate: number; fifoPath: string; apiAddress: string; apiPort: number; callbackPort: number }` +- `findGoLibrespot(): string` +- `new GoLibrespotRestClient(baseUrl: string)` → `ping(): Promise`, `playTrack(uri): Promise`, `pause()/resume()/stop(): Promise`, `seek(ms): Promise`, `getStatus(): Promise` +- `new GoLibrespotEventClient(wsUrl: string)` (EventEmitter) → `start(): void`, `stop(): void`, emits `"metadata" | "not_playing" | "stopped" | "seek" | ...` with the parsed data object + +Produces: +- `export interface GoLibrespotBackendOptions { deviceName: string; bitrate: number; workDir: string; configDir: string; apiPort?: number; logger: import("pino").Logger; deps?: any }` +- `export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBackend` — `start(): Promise`, `stop(): void`, `isReady(): boolean`, `playTrack(uri): Promise`, `pause()/resume(): Promise`, `seek(ms): Promise`, `getPcmStream(): Readable`, `getPositionMs(): number`, `on("trackEnded"|"metadata"|"ready"|"error", cb)` + +--- + +- [ ] **Step 1: Write the failing test `src/music/spotify/go-librespot.test.ts`.** All external deps (child_process, fs, REST/WS clients, binary lookup, sleep) are injected so no real binary/FIFO/network is touched. Tests assert real behavior: spawn ORDER (mkfifo → ffmpeg → go-librespot), config written, WS `not_playing`/`stopped`/`metadata` mapping, REST delegation, PCM stream identity, and teardown. + +```ts +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import pino from "pino"; +import { GoLibrespotBackend } from "./go-librespot.js"; + +const log = pino({ level: "silent" }); + +/** A minimal stand-in for a spawned ChildProcess with real Readable stdout/stderr. */ +function makeFakeChild() { + const child: any = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +function makeHarness() { + const calls: string[] = []; + const ffmpegChild = makeFakeChild(); + const gliChild = makeFakeChild(); + + const spawn = vi.fn((cmd: string) => { + const isGli = cmd.includes("go-librespot"); + calls.push(`spawn:${isGli ? "go-librespot" : cmd}`); + return isGli ? gliChild : ffmpegChild; + }); + const execFileSync = vi.fn((cmd: string) => { + calls.push(`exec:${cmd}`); + return Buffer.from(""); + }); + const writeFileSync = vi.fn(() => calls.push("write:config")); + const mkdirSync = vi.fn(); + const unlinkSync = vi.fn(() => calls.push("unlink:fifo")); + const existsSync = vi.fn(() => false); + + const rest = { + ping: vi.fn(async () => true), + playTrack: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + getStatus: vi.fn(async () => null), + }; + const events: any = new EventEmitter(); + events.start = vi.fn(() => calls.push("ws:start")); + events.stop = vi.fn(); + + const backend = new GoLibrespotBackend({ + deviceName: "Test Bot", + bitrate: 320, + workDir: "/tmp/work", + configDir: "/tmp/cfg", + apiPort: 3678, + logger: log, + deps: { + spawn, + execFileSync, + writeFileSync, + mkdirSync, + unlinkSync, + existsSync, + findBinary: () => "/bin/go-librespot", + makeRest: () => rest, + makeEvents: () => events, + sleep: async () => {}, + pollIntervalMs: 1, + pollTimeoutMs: 100, + } as any, + }); + + return { backend, calls, spawn, execFileSync, writeFileSync, existsSync, unlinkSync, rest, events, ffmpegChild, gliChild }; +} + +describe("GoLibrespotBackend.start", () => { + it("creates the FIFO with mkfifo before spawning ffmpeg, and spawns ffmpeg BEFORE go-librespot", async () => { + const h = makeHarness(); + await h.backend.start(); + + expect(h.execFileSync).toHaveBeenCalledWith("mkfifo", ["/tmp/work/go-librespot.fifo"]); + expect(h.writeFileSync).toHaveBeenCalled(); + + const mkfifoIdx = h.calls.indexOf("exec:mkfifo"); + const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg"); + const gliIdx = h.calls.indexOf("spawn:go-librespot"); + expect(mkfifoIdx).toBeGreaterThanOrEqual(0); + expect(ffmpegIdx).toBeGreaterThan(mkfifoIdx); // ffmpeg attaches to the FIFO first + expect(gliIdx).toBeGreaterThan(ffmpegIdx); // then the writer (go-librespot) + expect(h.calls.indexOf("ws:start")).toBeGreaterThan(gliIdx); // WS connects last + }); + + it("passes --config_dir to go-librespot using the resolved binary path", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.spawn).toHaveBeenCalledWith( + "/bin/go-librespot", + ["--config_dir", "/tmp/cfg"], + expect.anything(), + ); + }); + + it("uses the 44100->48000 s16le ffmpeg command reading the FIFO", async () => { + const h = makeHarness(); + await h.backend.start(); + const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[]; + expect(ffmpegArgs).toEqual([ + "-hide_banner", "-loglevel", "error", + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "/tmp/work/go-librespot.fifo", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ]); + }); + + it("emits 'ready' and reports isReady() true once the REST ping succeeds", async () => { + const h = makeHarness(); + const ready = vi.fn(); + h.backend.on("ready", ready); + await h.backend.start(); + expect(h.rest.ping).toHaveBeenCalled(); + expect(ready).toHaveBeenCalledTimes(1); + expect(h.backend.isReady()).toBe(true); + }); + + it("keeps polling ping() until it returns true", async () => { + const h = makeHarness(); + h.rest.ping.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); + await h.backend.start(); + expect(h.rest.ping).toHaveBeenCalledTimes(3); + expect(h.backend.isReady()).toBe(true); + }); +}); + +describe("GoLibrespotBackend WebSocket event mapping", () => { + it("maps a not_playing event to trackEnded{reason:'ended'}", async () => { + const h = makeHarness(); + await h.backend.start(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.events.emit("not_playing", { uri: "spotify:track:abc" }); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:abc", reason: "ended" }); + }); + + it("maps a stopped event to trackEnded{reason:'stopped'}", async () => { + const h = makeHarness(); + await h.backend.start(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.events.emit("stopped", { uri: "spotify:track:xyz" }); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:xyz", reason: "stopped" }); + }); + + it("maps a metadata event to a SpotifyNowPlaying and updates getPositionMs()", async () => { + const h = makeHarness(); + await h.backend.start(); + const meta = vi.fn(); + h.backend.on("metadata", meta); + h.events.emit("metadata", { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "http://x/y.jpg", + position: 1234, + duration: 200000, + }); + expect(meta).toHaveBeenCalledWith({ + uri: "spotify:track:abc", + name: "Song", + artist: "A, B", + album: "Alb", + coverUrl: "http://x/y.jpg", + durationMs: 200000, + }); + expect(h.backend.getPositionMs()).toBe(1234); + }); +}); + +describe("GoLibrespotBackend transport delegation + PCM", () => { + it("playTrack delegates to the REST client", async () => { + const h = makeHarness(); + await h.backend.start(); + await h.backend.playTrack("spotify:track:go"); + expect(h.rest.playTrack).toHaveBeenCalledWith("spotify:track:go"); + }); + + it("pause/resume/seek delegate to the REST client and seek updates position", async () => { + const h = makeHarness(); + await h.backend.start(); + await h.backend.pause(); + await h.backend.resume(); + await h.backend.seek(5000); + expect(h.rest.pause).toHaveBeenCalled(); + expect(h.rest.resume).toHaveBeenCalled(); + expect(h.rest.seek).toHaveBeenCalledWith(5000); + expect(h.backend.getPositionMs()).toBe(5000); + }); + + it("getPcmStream() returns the ffmpeg stdout Readable", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout); + }); +}); + +describe("GoLibrespotBackend.stop", () => { + it("kills ffmpeg + go-librespot, stops the WS, removes the FIFO, and clears ready", async () => { + const h = makeHarness(); + await h.backend.start(); + h.existsSync.mockReturnValue(true); // FIFO now present, so stop() unlinks it + h.backend.stop(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.gliChild.kill).toHaveBeenCalled(); + expect(h.events.stop).toHaveBeenCalled(); + expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo"); + expect(h.backend.isReady()).toBe(false); + }); +}); +``` + +Verify (expect FAIL — module not implemented yet): +`npx vitest run src/music/spotify/go-librespot.test.ts` + +- [ ] **Step 2: Implement `src/music/spotify/go-librespot.ts` to make the tests pass.** ESM `.js` import specifiers throughout. Deps default to the real Node modules; every branch the tests exercise is overridable via `options.deps`. + +```ts +import { EventEmitter } from "node:events"; +import { join } from "node:path"; +import type { Readable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { + execFileSync as realExecFileSync, + spawn as realSpawn, +} from "node:child_process"; +import { + existsSync as realExistsSync, + mkdirSync as realMkdirSync, + unlinkSync as realUnlinkSync, + writeFileSync as realWriteFileSync, +} from "node:fs"; +import type { Logger } from "pino"; +import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, +} from "./backend.js"; +import { findGoLibrespot } from "./binary.js"; +import { renderConfigYml } from "./go-librespot-config.js"; +import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js"; + +export interface GoLibrespotBackendOptions { + deviceName: string; + bitrate: number; + workDir: string; + configDir: string; + apiPort?: number; + logger: Logger; + deps?: GoLibrespotBackendDeps; +} + +/** Injectable seams so the whole lifecycle is testable without a real binary/FIFO/network. */ +export interface GoLibrespotBackendDeps { + spawn?: typeof realSpawn; + execFileSync?: typeof realExecFileSync; + existsSync?: typeof realExistsSync; + mkdirSync?: typeof realMkdirSync; + unlinkSync?: typeof realUnlinkSync; + writeFileSync?: typeof realWriteFileSync; + findBinary?: () => string; + makeRest?: (baseUrl: string) => GoLibrespotRestClient; + makeEvents?: (wsUrl: string) => GoLibrespotEventClient; + sleep?: (ms: number) => Promise; + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +const DEFAULT_API_PORT = 3678; +const DEFAULT_CALLBACK_PORT = 8080; +const FIFO_NAME = "go-librespot.fifo"; + +export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBackend { + private readonly opts: GoLibrespotBackendOptions; + private readonly log: Logger; + private readonly deps: GoLibrespotBackendDeps; + private readonly apiPort: number; + private readonly fifoPath: string; + + private ffmpeg: ChildProcess | null = null; + private proc: ChildProcess | null = null; + private rest: GoLibrespotRestClient | null = null; + private events: GoLibrespotEventClient | null = null; + private ready = false; + private positionMs = 0; + + constructor(o: GoLibrespotBackendOptions) { + super(); + this.opts = o; + this.log = o.logger; + this.deps = o.deps ?? {}; + this.apiPort = o.apiPort ?? DEFAULT_API_PORT; + this.fifoPath = join(o.workDir, FIFO_NAME); + } + + async start(): Promise { + const spawn = this.deps.spawn ?? realSpawn; + const execFileSync = this.deps.execFileSync ?? realExecFileSync; + const existsSync = this.deps.existsSync ?? realExistsSync; + const mkdirSync = this.deps.mkdirSync ?? realMkdirSync; + const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync; + const writeFileSync = this.deps.writeFileSync ?? realWriteFileSync; + const findBinary = this.deps.findBinary ?? findGoLibrespot; + + // 1. Ensure work + config directories exist. + mkdirSync(this.opts.workDir, { recursive: true }); + mkdirSync(this.opts.configDir, { recursive: true }); + + // 2. (Re)create the FIFO — mkfifo fails if the path already exists. + if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath); + execFileSync("mkfifo", [this.fifoPath]); + + // 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before + // go-librespot (the writer) starts pushing raw 44.1k s16le into it. + this.ffmpeg = spawn( + "ffmpeg", + [ + "-hide_banner", "-loglevel", "error", + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath, + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + this.ffmpeg.stderr?.on("data", (b: Buffer) => + this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"), + ); + this.ffmpeg.on("error", (err) => this.emit("error", err)); + + // 4. Render + write config.yml into the config dir. + const yml = renderConfigYml({ + deviceName: this.opts.deviceName, + bitrate: this.opts.bitrate, + fifoPath: this.fifoPath, + apiAddress: "0.0.0.0", + apiPort: this.apiPort, + callbackPort: DEFAULT_CALLBACK_PORT, + }); + writeFileSync(join(this.opts.configDir, "config.yml"), yml, "utf8"); + + // 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/ + // stderr carry the interactive OAuth URL on first run — surface via logger. + const bin = findBinary(); + this.proc = spawn(bin, ["--config_dir", this.opts.configDir], { + stdio: ["ignore", "pipe", "pipe"], + }); + const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot"); + this.proc.stdout?.on("data", onLog); + this.proc.stderr?.on("data", onLog); + this.proc.on("error", (err) => this.emit("error", err)); + this.proc.on("exit", (code, signal) => { + this.ready = false; + this.log.warn({ code, signal }, "go-librespot exited"); + }); + + // 6. REST client, then poll GET / until the HTTP server answers. + const baseUrl = `http://127.0.0.1:${this.apiPort}`; + this.rest = this.deps.makeRest + ? this.deps.makeRest(baseUrl) + : new GoLibrespotRestClient(baseUrl); + await this.waitUntilReady(); + + // 7. Connect the WebSocket event stream and wire event mapping. + const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`; + this.events = this.deps.makeEvents + ? this.deps.makeEvents(wsUrl) + : new GoLibrespotEventClient(wsUrl); + this.wireEvents(this.events); + this.events.start(); + + this.ready = true; + this.emit("ready"); + } + + private async waitUntilReady(): Promise { + const sleep = this.deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const interval = this.deps.pollIntervalMs ?? 200; + const timeout = this.deps.pollTimeoutMs ?? 15_000; + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (this.rest && (await this.rest.ping())) return; + await sleep(interval); + } + throw new Error("go-librespot API did not become ready within timeout"); + } + + private wireEvents(ev: GoLibrespotEventClient): void { + ev.on("metadata", (d: any) => { + const np: SpotifyNowPlaying = { + uri: typeof d?.uri === "string" ? d.uri : "", + name: typeof d?.name === "string" ? d.name : "", + artist: Array.isArray(d?.artist_names) ? d.artist_names.join(", ") : "", + album: typeof d?.album_name === "string" ? d.album_name : "", + coverUrl: typeof d?.album_cover_url === "string" ? d.album_cover_url : "", + durationMs: typeof d?.duration === "number" ? d.duration : 0, + }; + if (typeof d?.position === "number") this.positionMs = d.position; + this.emit("metadata", np); + }); + ev.on("seek", (d: any) => { + if (typeof d?.position === "number") this.positionMs = d.position; + }); + ev.on("not_playing", (d: any) => { + const e: SpotifyTrackEndedEvent = { uri: typeof d?.uri === "string" ? d.uri : "", reason: "ended" }; + this.emit("trackEnded", e); + }); + ev.on("stopped", (d: any) => { + const e: SpotifyTrackEndedEvent = { uri: typeof d?.uri === "string" ? d.uri : "", reason: "stopped" }; + this.emit("trackEnded", e); + }); + } + + isReady(): boolean { + return this.ready; + } + + async playTrack(uri: string): Promise { + if (!this.rest) throw new Error("go-librespot backend not started"); + await this.rest.playTrack(uri); + } + + async pause(): Promise { + if (this.rest) await this.rest.pause(); + } + + async resume(): Promise { + if (this.rest) await this.rest.resume(); + } + + async seek(ms: number): Promise { + if (this.rest) await this.rest.seek(ms); + this.positionMs = ms; + } + + getPcmStream(): Readable { + const out = this.ffmpeg?.stdout; + if (!out) throw new Error("PCM stream unavailable (go-librespot backend not started)"); + return out; + } + + getPositionMs(): number { + return this.positionMs; + } + + stop(): void { + this.ready = false; + try { + this.events?.stop(); + } catch { + /* ignore */ + } + this.events = null; + this.rest = null; + if (this.proc) { + try { + this.proc.kill("SIGTERM"); + } catch { + /* ignore */ + } + this.proc = null; + } + if (this.ffmpeg) { + try { + this.ffmpeg.kill("SIGTERM"); + } catch { + /* ignore */ + } + this.ffmpeg = null; + } + const existsSync = this.deps.existsSync ?? realExistsSync; + const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync; + try { + if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath); + } catch { + /* ignore */ + } + } +} +``` + +Verify (expect PASS — all specs green): +`npx vitest run src/music/spotify/go-librespot.test.ts` + +- [ ] **Step 3: Typecheck the whole project.** `GoLibrespotBackend` must structurally satisfy `SpotifyAudioBackend` (EventEmitter's `on(): this` is assignable where the interface expects `on(): void`). + +Verify (expect PASS — no type errors): +`npx tsc --noEmit` + +- [ ] **Step 4: Commit.** + +```bash +git add src/music/spotify/go-librespot.ts src/music/spotify/go-librespot.test.ts +git commit -m "$(cat <<'EOF' +feat(spotify): GoLibrespotBackend sidecar (FIFO + ffmpeg PCM + REST/WS) + +Implements SpotifyAudioBackend over a go-librespot sidecar: start() mkfifos +the pipe, spawns the FIFO->48k s16le ffmpeg reader BEFORE go-librespot, writes +config.yml, polls the REST / until ready, then connects the WS event stream. +Maps not_playing/stopped -> trackEnded and metadata -> SpotifyNowPlaying; +play/pause/resume/seek delegate to the REST client. All child_process/fs/REST/WS +seams are injectable so the lifecycle is fully unit-tested without a real binary. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +> **Note (report):** Live Spotify audio is NOT exercised by these tests — it requires a Spotify Premium account, an interactive OAuth login, and a Linux host with `mkfifo` + the `go-librespot` binary. The unit tests verify orchestration only (spawn order, config/FIFO creation, WS→event mapping, REST delegation, PCM stream wiring, teardown) with every OS/network dependency injected. End-to-end audio validation is deferred to the Linux/Docker manual-QA stage. + +--- + +### Task 5: AudioPlayer external-PCM mode (playPcmStream) + +**Files:** +- MODIFY `src/audio/player.ts` — add external-PCM mode (new fields, `playPcmStream()`, external-aware frame loop / `sendNextFrame` / `stop` / `seek`). The existing url `play(url)` path, `playViaPowerShellDownload`, `spawnFfmpegFromFile`, and all exported pure functions (`buildFfmpegArgs`, `shouldEndOnStall`, `volumeToFactor`, `shouldUsePowerShellDownload`, `cleanupTempDir`) stay **100% unchanged**. +- ADD tests to `src/audio/player.test.ts` — new `describe("AudioPlayer external-PCM mode (playPcmStream)")` block; the existing `describe` blocks (buildFfmpegArgs / volumeToFactor / shouldUsePowerShellDownload / cleanupTempDir / shouldEndOnStall) are left untouched and must keep passing. + +**Interfaces:** + +Consumes (existing, verbatim): +- `import { Readable } from "node:stream"` — the external PCM source (contract: `getPcmStream(): import("node:stream").Readable`). +- `PCM_FRAME_BYTES = 3840`, `Encoder.encode(pcm: Buffer): Buffer` from `./encoder.js` (already imported in player.ts). +- `AudioPlayer` reuses its own `pcmBuffer`, `sessionId`, `startFrameLoop()`, `scheduleNextFrame()`, `sendNextFrame()`, `applyVolume()`, `stop()`, `PlayerEvents` (`"frame" | "trackEnd" | "error"`), `BUFFER_HIGH_WATER`/`BUFFER_LOW_WATER`. + +Produces (new public API — matches the locked player.ts contract): +- `playPcmStream(readable: import("node:stream").Readable, opts: { onExternalEnd?: () => void }): void` + - fences via `stop()` (bumps `sessionId`), sets internal `externalMode = true`, does NOT spawn ffmpeg, feeds `pcmBuffer` from `readable "data"` under the `sessionId` guard with the SAME high/low-water backpressure (pausing/resuming the Readable), `state = "playing"`, `startFrameLoop()`; suppresses the underrun `trackEnd` drain/stall branches while `externalMode` (emits a silence frame instead); `stop()` tears down `externalMode`; `seek()` is a local no-op while `externalMode`. + +--- + +- [ ] **Step 1: Write the RED tests (feed a fake Readable, assert real behavior).** + Append this block to `src/audio/player.test.ts`. It constructs a real `AudioPlayer` (the real `@discordjs/opus` encoder already loads via the existing `import ... from "./player.js"`, so `"frame"` events carry real Opus buffers) and drives a controllable `Readable`. + + ```ts + import { Readable } from "node:stream"; + import { AudioPlayer } from "./player.js"; + import type { Logger } from "../logger.js"; + + // Minimal stub: AudioPlayer only calls debug/info/warn/error; child() returns self. + const silentLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + fatal() {}, + trace() {}, + child() { + return silentLogger; + }, + } as unknown as Logger; + + // A readable we fully control: no underlying source; we push PCM manually and + // keep it open (never push(null)) to model the long-lived go-librespot sidecar. + function openPcmReadable(): Readable { + return new Readable({ read() {} }); + } + + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const FRAME_BYTES = 3840; // PCM_FRAME_BYTES: 960 samples * 2ch * 2 bytes @48k s16le + + describe("AudioPlayer external-PCM mode (playPcmStream)", () => { + it("emits Opus 'frame' events from the external PCM stream without spawning ffmpeg", async () => { + const player = new AudioPlayer(silentLogger); + const frames: Buffer[] = []; + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 10)); // ~10 frames of PCM + + await wait(150); // ~7 frame ticks at 20ms + + expect(player.getState()).toBe("playing"); + expect(frames.length).toBeGreaterThan(0); + expect(Buffer.isBuffer(frames[0])).toBe(true); + player.stop(); + }); + + it("does NOT emit 'trackEnd' on underrun while external (stream stays open)", async () => { + const player = new AudioPlayer(silentLogger); + let ended = 0; + const frames: Buffer[] = []; + player.on("trackEnd", () => ended++); + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 2)); // only 2 frames, then underrun + + await wait(200); // long after those 2 frames have drained + + // In the url path, ffmpeg===null + empty buffer would fire trackEnd; here it must not. + expect(ended).toBe(0); + // Silence frames keep the 20ms timeline alive -> more than the 2 fed frames emitted. + expect(frames.length).toBeGreaterThan(2); + expect(player.getState()).toBe("playing"); + player.stop(); + }); + + it("stop() tears down external mode, fences via sessionId, and destroys the readable", async () => { + const player = new AudioPlayer(silentLogger); + const frames: Buffer[] = []; + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 5)); + await wait(80); + + player.stop(); + expect(player.getState()).toBe("idle"); + expect(stream.destroyed).toBe(true); + + const countAtStop = frames.length; + // sessionId fence: PCM pushed after stop must not resurrect the timeline. + try { + stream.push(Buffer.alloc(FRAME_BYTES * 5)); + } catch { + /* readable already destroyed */ + } + await wait(80); + expect(frames.length).toBe(countAtStop); + }); + + it("fires onExternalEnd when the readable ends (drives controller-based advance)", async () => { + const player = new AudioPlayer(silentLogger); + let endedCb = 0; + + const stream = openPcmReadable(); + player.playPcmStream(stream, { onExternalEnd: () => endedCb++ }); + stream.push(Buffer.alloc(FRAME_BYTES)); + await wait(40); + stream.push(null); // end-of-stream + await wait(40); + + expect(endedCb).toBe(1); + player.stop(); + }); + + it("seek() is a local no-op in external mode (never respawns ffmpeg on a spotify sentinel)", async () => { + const player = new AudioPlayer(silentLogger); + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 3)); + await wait(40); + + expect(() => player.seek(30)).not.toThrow(); + // Still external, still playing — no url-ffmpeg respawn, state unchanged. + expect(player.getState()).toBe("playing"); + player.stop(); + }); + + it("pause()/resume() still gate local emission in external mode (unchanged semantics)", async () => { + const player = new AudioPlayer(silentLogger); + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 3)); + await wait(40); + + player.pause(); + expect(player.getState()).toBe("paused"); + player.resume(); + expect(player.getState()).toBe("playing"); + player.stop(); + }); + }); + ``` + + Verify (RED): `npx vitest run src/audio/player.test.ts` → the new block **FAILS** (TypeScript: `Property 'playPcmStream' does not exist on type 'AudioPlayer'`; the 5 existing describe blocks still pass). + +- [ ] **Step 2: Add the external-mode fields + `Readable` type import to `src/audio/player.ts`.** + Add a type-only import near the top (player.ts never constructs a `Readable`, only annotates one): + + ```ts + import type { Readable } from "node:stream"; + ``` + + Then add three fields immediately after `private currentSongDuration = 0;` (line 188): + + ```ts + // --- External PCM mode (Stage 2: go-librespot Spotify sidecar) --- + // When true, PCM arrives from a long-lived external Readable instead of a + // per-URL ffmpeg: this.ffmpeg stays null, and the underrun-driven trackEnd + // branches are suppressed (advance is driven by the controller, not EOF). + private externalMode = false; + private externalStream: Readable | null = null; + private onExternalEnd: (() => void) | null = null; + ``` + +- [ ] **Step 3: Implement `playPcmStream()` in `src/audio/player.ts`.** + Insert this method between the end of `spawnFfmpegFromFile()` (line 391, `this.startFrameLoop(); }`) and `stop()` (line 393). It mirrors `play()`'s state-reset + sessionId-guarded ingestion but skips ffmpeg entirely. + + ```ts + /** + * External-PCM mode (Stage 2 go-librespot Spotify sidecar). + * + * Feeds an already-normalized 48kHz/s16le/stereo PCM Readable (the + * go-librespot FIFO -> ffmpeg output) straight into the existing pcmBuffer + + * 20ms frame loop + Opus encoder + "frame" emission, WITHOUT spawning a + * per-URL ffmpeg. The url play() path is left completely untouched. + * + * Track advance is NOT driven by buffer underrun here (the sidecar stream is + * continuous and never EOFs per song); the caller drives advance via the + * SpotifyController "trackEnded" WebSocket event. onExternalEnd fires only if + * the underlying readable itself ends or errors. + */ + playPcmStream(readable: Readable, opts: { onExternalEnd?: () => void } = {}): void { + // 1. Fence current playback: stop() bumps sessionId, clears pcmBuffer, kills + // any ffmpeg, and tears down any prior external stream. + this.stop(); + + const currentSessionId = this.sessionId; + this.externalMode = true; + this.externalStream = readable; + this.onExternalEnd = opts.onExternalEnd ?? null; + // Leave this.ffmpeg = null; clear currentUrl so seek() cannot respawn ffmpeg. + this.currentUrl = ""; + this.seekOffset = 0; + this.framesPlayed = 0; + this.healthyFrames = 0; + this.ffmpegPaused = false; + this.spawnFailed = false; + this.emptyFrameAttempts = 0; + this.currentSongDuration = 0; + + // Same ingestion + high-water backpressure as the ffmpeg.stdout handler, + // but pausing the Readable instead of ffmpeg.stdout. sessionId-guarded so + // stale sidecar PCM can't leak into a new track after stop()/skip. + readable.on("data", (chunk: Buffer) => { + if (this.sessionId !== currentSessionId) return; + this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]); + if ( + this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && + !this.ffmpegPaused && + this.externalStream === readable + ) { + readable.pause(); + this.ffmpegPaused = true; + } + }); + + readable.on("end", () => { + if (this.sessionId !== currentSessionId) return; + this.onExternalEnd?.(); + }); + readable.on("error", (err) => { + if (this.sessionId !== currentSessionId) return; + this.logger.warn({ err }, "External PCM stream error"); + this.onExternalEnd?.(); + }); + + this.state = "playing"; + this.startFrameLoop(); + } + ``` + +- [ ] **Step 4: Suppress the two underrun `trackEnd` drain/stall branches while `externalMode`.** + Gate both branches in `scheduleNextFrame()` behind `!this.externalMode` so a continuous stream's transient underrun never ends the track (Branch B at line 529 would otherwise fire instantly because `this.ffmpeg === null`). + + Branch A (line 483) — replace: + ```ts + if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) { + ``` + with: + ```ts + if (!this.externalMode && this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) { + ``` + + Branch B (line 529) — replace: + ```ts + if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { + ``` + with: + ```ts + if (!this.externalMode && !this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { + ``` + + In external mode both branches are skipped; the `else` at line 524 still resets `emptyFrameAttempts`, and `scheduleNextFrame()` reschedules indefinitely while `frameLoopRunning`. The url path (externalMode=false) is behaviorally identical. + +- [ ] **Step 5: Make `sendNextFrame()` external-aware (silence on underrun + resume the Readable) and add `emitSilenceFrame()`.** + In `sendNextFrame()` (line 544), replace the early return: + ```ts + private sendNextFrame(): void { + if (this.pcmBuffer.length < PCM_FRAME_BYTES) return; + ``` + with: + ```ts + private sendNextFrame(): void { + if (this.pcmBuffer.length < PCM_FRAME_BYTES) { + // External mode: the sidecar PCM stream is long-lived and must NOT end on + // a transient underrun. Emit an encoded silence frame so the 20ms voice + // timeline stays continuous instead of returning (which would desync TS). + if (this.externalMode) this.emitSilenceFrame(); + return; + } + ``` + + Replace the low-water resume block (lines 549-552): + ```ts + if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) { + this.ffmpeg.stdout.resume(); + this.ffmpegPaused = false; + } + ``` + with: + ```ts + if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER) { + if (this.externalMode && this.externalStream) { + this.externalStream.resume(); + this.ffmpegPaused = false; + } else if (this.ffmpeg?.stdout) { + this.ffmpeg.stdout.resume(); + this.ffmpegPaused = false; + } + } + ``` + + Add the helper immediately after `sendNextFrame()` closes (before `applyVolume`, line 569): + ```ts + private emitSilenceFrame(): void { + try { + const opusFrame = this.encoder.encode(Buffer.alloc(PCM_FRAME_BYTES)); + this.emit("frame", opusFrame); + this.framesPlayed++; + } catch (err) { + this.emit("error", err as Error); + } + } + ``` + +- [ ] **Step 6: Extend `stop()` teardown and make `seek()` a no-op in external mode.** + In `stop()`, insert the external teardown just before `this.ffmpegPaused = false;` (line 422). `sessionId++` (already at the top of stop) fences the external `"data"`/`"end"`/`"error"` handlers; destroying the readable stops the sidecar PCM at the source. + + ```ts + if (this.externalStream) { + const stream = this.externalStream; + this.externalStream = null; + try { + stream.destroy(); + } catch { + /* best-effort */ + } + } + this.externalMode = false; + this.onExternalEnd = null; + + this.ffmpegPaused = false; + ``` + + Replace `seek()` (lines 582-586): + ```ts + seek(seconds: number): void { + if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) { + this.play(this.currentUrl, seconds, this.currentSongDuration); + } + } + ``` + with: + ```ts + seek(seconds: number): void { + // External (Spotify sidecar) mode: local seek is a no-op. Respawning ffmpeg + // on the spotify: sentinel would collide with the continuous PCM source; + // transport is delegated to the SpotifyController by the caller. + if (this.externalMode) return; + if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) { + this.play(this.currentUrl, seconds, this.currentSongDuration); + } + } + ``` + + `pause()`/`resume()` (lines 587-588) are intentionally left unchanged — they only flip `state`, which already gates local frame emission for both modes (the real transport pause is delegated to the controller by the instance layer in a later task). + +- [ ] **Step 7: Verify GREEN + types.** + - `npx vitest run src/audio/player.test.ts` → **all** tests pass: the 6 new external-mode tests AND the 5 pre-existing describe blocks (buildFfmpegArgs / volumeToFactor / shouldUsePowerShellDownload / cleanupTempDir / shouldEndOnStall) — confirming the url `play()` path is unchanged. + - `npx tsc --noEmit` → passes with no errors (no new type errors from the `Readable` import, the new fields, or the `playPcmStream` signature). + +- [ ] **Step 8: Commit.** + ```bash + git add src/audio/player.ts src/audio/player.test.ts + git commit -m "$(cat <<'EOF' + feat(audio): add external-PCM mode (playPcmStream) for Spotify sidecar + + Adds AudioPlayer.playPcmStream(readable, {onExternalEnd}) that feeds a + long-lived external 48kHz/s16le/stereo Readable into the existing pcmBuffer + + 20ms frame loop + Opus encoder without spawning a per-URL ffmpeg. Reuses the + same high/low-water backpressure (pausing/resuming the Readable), suppresses the + underrun trackEnd drain/stall branches while external (emitting a silence frame + to keep the 20ms timeline), tears down externalMode in stop() (destroy readable, + clear onExternalEnd) and makes seek() a local no-op in external mode. The url + play() path and all exported pure functions are unchanged. + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +--- + +### Task 6: SpotifyController (backend lifecycle + gating + events) + +**Files:** +- CREATE `src/music/spotify/controller.ts` +- CREATE `src/music/spotify/controller.test.ts` + +**Interfaces:** + +Consumes: +- `interface SpotifyConfig { enabled: boolean; backend: "auto"|"go-librespot"|"librespot"; clientId: string; clientSecret: string; deviceName: string; bitrate: number }` — `../../data/config.js` +- `isGoLibrespotSupported(): boolean` and `findGoLibrespot(): string` — `./binary.js` +- `interface SpotifyAudioBackend { start(): Promise; stop(): void; isReady(): boolean; playTrack(uri: string): Promise; pause(): Promise; resume(): Promise; seek(ms: number): Promise; getPcmStream(): Readable; getPositionMs(): number; on(...) }`, `interface SpotifyTrackEndedEvent { uri: string; reason: "ended"|"stopped"|"error" }`, `interface SpotifyNowPlaying { uri; name; artist; album; coverUrl; durationMs }` — `./backend.js` +- `class GoLibrespotBackend implements SpotifyAudioBackend` (default factory only) — `./go-librespot.js` + +Produces: +- `class SpotifyController extends EventEmitter` + - `constructor(o: { config: SpotifyConfig; workDir: string; configDir: string; logger: import("pino").Logger; backendFactory?: () => SpotifyAudioBackend })` + - `isAvailable(): boolean` — `config.enabled && isGoLibrespotSupported() && existsSync(findGoLibrespot())` + - `ensureStarted(): Promise` — idempotent; `false` if unavailable or start throws + - `playTrack(uri: string): Promise` — ensureStarted + `backend.playTrack`; `false` on failure + - `pause(): Promise`, `resume(): Promise`, `seek(ms: number): Promise`, `stop(): void` + - `getPcmStream(): import("node:stream").Readable` + - re-emits backend `"trackEnded"(SpotifyTrackEndedEvent)` and `"metadata"(SpotifyNowPlaying)` + +--- + +- [ ] **Step 1: Write the failing test (`src/music/spotify/controller.test.ts`).** + Mock `./binary.js` with `vi.hoisted` state so availability is controllable without a real Linux binary. Point `findGoLibrespot()` at a real temp file so `existsSync` is exercised for real (no `node:fs` mock). Use a fake backend (a real `EventEmitter` with call counters) so assertions check real delegation/re-emission, not mock identity. + + ```ts + import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; + import { EventEmitter } from "node:events"; + import { Readable } from "node:stream"; + import { writeFileSync, rmSync } from "node:fs"; + import { join } from "node:path"; + import { tmpdir } from "node:os"; + import type { Logger } from "pino"; + import type { SpotifyConfig } from "../../data/config.js"; + import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, + } from "./backend.js"; + + // Controllable, hoisted so the vi.mock factory can close over it. + const bin = vi.hoisted(() => ({ supported: true, path: "" })); + vi.mock("./binary.js", () => ({ + isGoLibrespotSupported: () => bin.supported, + findGoLibrespot: () => bin.path, + resetGoLibrespotBinaryCache: () => {}, + checkGoLibrespotAvailable: async () => bin.supported && !!bin.path, + })); + + // Import AFTER vi.mock so the mocked binary module is used. + const { SpotifyController } = await import("./controller.js"); + + const existingBin = join(tmpdir(), `tsmb-golibrespot-${process.pid}`); + const missingBin = join(tmpdir(), `tsmb-golibrespot-missing-${process.pid}`); + + beforeAll(() => { + writeFileSync(existingBin, "#!/bin/sh\n"); + }); + afterAll(() => { + try { + rmSync(existingBin); + } catch { + /* ignore */ + } + }); + beforeEach(() => { + bin.supported = true; + bin.path = existingBin; + }); + + class FakeBackend extends EventEmitter implements SpotifyAudioBackend { + startCalls = 0; + stopCalls = 0; + playCalls: string[] = []; + pauseCalls = 0; + resumeCalls = 0; + seekCalls: number[] = []; + ready = false; + startShouldReject = false; + playShouldReject = false; + readonly pcm = Readable.from([Buffer.alloc(0)]); + + async start(): Promise { + this.startCalls++; + if (this.startShouldReject) throw new Error("start boom"); + this.ready = true; + } + stop(): void { + this.stopCalls++; + this.ready = false; + } + isReady(): boolean { + return this.ready; + } + async playTrack(uri: string): Promise { + this.playCalls.push(uri); + if (this.playShouldReject) throw new Error("play boom"); + } + async pause(): Promise { + this.pauseCalls++; + } + async resume(): Promise { + this.resumeCalls++; + } + async seek(ms: number): Promise { + this.seekCalls.push(ms); + } + getPcmStream(): Readable { + return this.pcm; + } + getPositionMs(): number { + return 0; + } + } + + const silentLogger = { + info() {}, + error() {}, + warn() {}, + debug() {}, + trace() {}, + fatal() {}, + child() { + return silentLogger; + }, + } as unknown as Logger; + + function cfg(over: Partial = {}): SpotifyConfig { + return { + enabled: true, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + ...over, + }; + } + + function makeCtrl(over: { + config?: Partial; + backendFactory?: () => SpotifyAudioBackend; + } = {}) { + const be = new FakeBackend(); + const ctrl = new SpotifyController({ + config: cfg(over.config), + workDir: "/tmp/work", + configDir: "/tmp/cfg", + logger: silentLogger, + backendFactory: over.backendFactory ?? (() => be), + }); + return { ctrl, be }; + } + + describe("SpotifyController.isAvailable", () => { + it("true when enabled + supported + binary present", () => { + const { ctrl } = makeCtrl(); + expect(ctrl.isAvailable()).toBe(true); + }); + it("false when config disabled", () => { + const { ctrl } = makeCtrl({ config: { enabled: false } }); + expect(ctrl.isAvailable()).toBe(false); + }); + it("false when platform unsupported", () => { + bin.supported = false; + const { ctrl } = makeCtrl(); + expect(ctrl.isAvailable()).toBe(false); + }); + it("false when binary file is absent", () => { + bin.path = missingBin; + const { ctrl } = makeCtrl(); + expect(ctrl.isAvailable()).toBe(false); + }); + }); + + describe("SpotifyController.ensureStarted", () => { + it("starts the backend exactly once across repeated calls", async () => { + let built = 0; + const be = new FakeBackend(); + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return be; + }, + }); + expect(await ctrl.ensureStarted()).toBe(true); + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(1); + expect(be.startCalls).toBe(1); + }); + + it("is idempotent under concurrent calls (single start)", async () => { + let built = 0; + const be = new FakeBackend(); + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return be; + }, + }); + const [a, b] = await Promise.all([ctrl.ensureStarted(), ctrl.ensureStarted()]); + expect(a).toBe(true); + expect(b).toBe(true); + expect(built).toBe(1); + expect(be.startCalls).toBe(1); + }); + + it("returns false and does not build a backend when unavailable", async () => { + let built = 0; + const { ctrl } = makeCtrl({ + config: { enabled: false }, + backendFactory: () => { + built++; + return new FakeBackend(); + }, + }); + expect(await ctrl.ensureStarted()).toBe(false); + expect(built).toBe(0); + }); + + it("returns false when backend.start() throws, and allows a later retry", async () => { + const be = new FakeBackend(); + be.startShouldReject = true; + let built = 0; + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return be; + }, + }); + expect(await ctrl.ensureStarted()).toBe(false); + // start failure clears the cached promise so a subsequent call retries. + be.startShouldReject = false; + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(2); + expect(be.startCalls).toBe(2); + }); + }); + + describe("SpotifyController.playTrack", () => { + it("ensures started then delegates the uri, returning true", async () => { + const { ctrl, be } = makeCtrl(); + expect(await ctrl.playTrack("spotify:track:abc")).toBe(true); + expect(be.startCalls).toBe(1); + expect(be.playCalls).toEqual(["spotify:track:abc"]); + }); + it("returns false when the controller is unavailable", async () => { + const { ctrl, be } = makeCtrl({ config: { enabled: false } }); + expect(await ctrl.playTrack("spotify:track:abc")).toBe(false); + expect(be.playCalls).toEqual([]); + }); + it("returns false when backend.playTrack rejects", async () => { + const be = new FakeBackend(); + be.playShouldReject = true; + const { ctrl } = makeCtrl({ backendFactory: () => be }); + expect(await ctrl.playTrack("spotify:track:abc")).toBe(false); + }); + }); + + describe("SpotifyController transport delegation", () => { + it("pause/resume/seek forward to the backend after start", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + await ctrl.pause(); + await ctrl.resume(); + await ctrl.seek(4200); + expect(be.pauseCalls).toBe(1); + expect(be.resumeCalls).toBe(1); + expect(be.seekCalls).toEqual([4200]); + }); + it("pause/resume/seek are safe no-ops before start", async () => { + const { ctrl, be } = makeCtrl(); + await expect(ctrl.pause()).resolves.toBeUndefined(); + await expect(ctrl.resume()).resolves.toBeUndefined(); + await expect(ctrl.seek(10)).resolves.toBeUndefined(); + expect(be.pauseCalls).toBe(0); + }); + it("getPcmStream returns the backend stream", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + expect(ctrl.getPcmStream()).toBe(be.pcm); + }); + it("getPcmStream throws before the backend is started", () => { + const { ctrl } = makeCtrl(); + expect(() => ctrl.getPcmStream()).toThrow(); + }); + }); + + describe("SpotifyController event re-emission", () => { + it("re-emits backend trackEnded with the same payload", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + const got: SpotifyTrackEndedEvent[] = []; + ctrl.on("trackEnded", (e) => got.push(e)); + const evt: SpotifyTrackEndedEvent = { uri: "spotify:track:x", reason: "ended" }; + be.emit("trackEnded", evt); + expect(got).toEqual([evt]); + }); + it("re-emits backend metadata with the same payload", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + const got: SpotifyNowPlaying[] = []; + ctrl.on("metadata", (m) => got.push(m)); + const meta: SpotifyNowPlaying = { + uri: "spotify:track:x", + name: "Song", + artist: "Artist", + album: "Album", + coverUrl: "http://img", + durationMs: 1000, + }; + be.emit("metadata", meta); + expect(got).toEqual([meta]); + }); + }); + + describe("SpotifyController.stop", () => { + it("stops the backend and tears down state so a later start rebuilds", async () => { + const be1 = new FakeBackend(); + const be2 = new FakeBackend(); + const backends = [be1, be2]; + const { ctrl } = makeCtrl({ backendFactory: () => backends.shift()! }); + await ctrl.ensureStarted(); + ctrl.stop(); + expect(be1.stopCalls).toBe(1); + // After teardown getPcmStream is invalid again until re-started. + expect(() => ctrl.getPcmStream()).toThrow(); + // A fresh ensureStarted builds a new backend. + expect(await ctrl.ensureStarted()).toBe(true); + expect(be2.startCalls).toBe(1); + }); + it("stop before start is a safe no-op", () => { + const { ctrl, be } = makeCtrl(); + expect(() => ctrl.stop()).not.toThrow(); + expect(be.stopCalls).toBe(0); + }); + }); + ``` + + Verify (RED): `npx vitest run src/music/spotify/controller.test.ts` — expected to FAIL (cannot resolve `./controller.js`). + +- [ ] **Step 2: Implement `src/music/spotify/controller.ts` to make the suite green.** + Extends `EventEmitter`. `isAvailable()` is synchronous (`existsSync(findGoLibrespot())` — mirrors youtube.ts' bin/-first resolution). `ensureStarted()` caches an in-flight promise for idempotency and clears it on failure so a later call retries. The default `backendFactory` constructs `GoLibrespotBackend`; tests inject a fake so no binary/network is touched. Event re-emission is wired once, at backend construction time. + + ```ts + import { EventEmitter } from "node:events"; + import { existsSync } from "node:fs"; + import type { Readable } from "node:stream"; + import type { Logger } from "pino"; + import type { SpotifyConfig } from "../../data/config.js"; + import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, + } from "./backend.js"; + import { isGoLibrespotSupported, findGoLibrespot } from "./binary.js"; + import { GoLibrespotBackend } from "./go-librespot.js"; + + export interface SpotifyControllerOptions { + config: SpotifyConfig; + workDir: string; + configDir: string; + logger: Logger; + /** Injected for tests; defaults to constructing a real GoLibrespotBackend. */ + backendFactory?: () => SpotifyAudioBackend; + } + + /** + * Per-bot orchestrator for the go-librespot Spotify sidecar. Owns backend + * lifecycle, gates on availability (config + platform + binary), delegates + * transport, and re-emits the backend's "trackEnded"/"metadata" events so + * BotInstance can advance the queue exactly as it does for the ffmpeg path. + */ + export class SpotifyController extends EventEmitter { + private readonly config: SpotifyConfig; + private readonly workDir: string; + private readonly configDir: string; + private readonly logger: Logger; + private readonly backendFactory: () => SpotifyAudioBackend; + + private backend: SpotifyAudioBackend | null = null; + private started = false; + private startPromise: Promise | null = null; + + constructor(o: SpotifyControllerOptions) { + super(); + this.config = o.config; + this.workDir = o.workDir; + this.configDir = o.configDir; + this.logger = o.logger; + this.backendFactory = + o.backendFactory ?? + (() => + new GoLibrespotBackend({ + deviceName: this.config.deviceName, + bitrate: this.config.bitrate, + workDir: this.workDir, + configDir: this.configDir, + logger: this.logger, + })); + } + + /** enabled in config AND on a supported OS AND the binary is present on disk. */ + isAvailable(): boolean { + return ( + this.config.enabled && + isGoLibrespotSupported() && + existsSync(findGoLibrespot()) + ); + } + + /** + * Idempotently start the backend. Returns false (without building a backend) + * when unavailable, so callers fall back to the Stage-1 sentinel message. + * A failed start clears the cached promise so a later call can retry. + */ + async ensureStarted(): Promise { + if (!this.isAvailable()) return false; + if (this.started) return true; + if (this.startPromise) return this.startPromise; + + this.startPromise = (async () => { + try { + const backend = this.backendFactory(); + backend.on("trackEnded", (e: SpotifyTrackEndedEvent) => + this.emit("trackEnded", e), + ); + backend.on("metadata", (m: SpotifyNowPlaying) => + this.emit("metadata", m), + ); + backend.on("error", (err?: unknown) => this.emit("error", err)); + await backend.start(); + this.backend = backend; + this.started = true; + return true; + } catch (err) { + this.logger.error({ err }, "Spotify backend failed to start"); + this.startPromise = null; + return false; + } + })(); + return this.startPromise; + } + + /** Ensure started, then play the spotify: URI. False on any failure. */ + async playTrack(uri: string): Promise { + const ok = await this.ensureStarted(); + if (!ok || !this.backend) return false; + try { + await this.backend.playTrack(uri); + return true; + } catch (err) { + this.logger.error({ err, uri }, "Spotify playTrack failed"); + return false; + } + } + + async pause(): Promise { + if (this.backend) await this.backend.pause(); + } + + async resume(): Promise { + if (this.backend) await this.backend.resume(); + } + + async seek(ms: number): Promise { + if (this.backend) await this.backend.seek(ms); + } + + getPcmStream(): Readable { + if (!this.backend) { + throw new Error("Spotify backend not started"); + } + return this.backend.getPcmStream(); + } + + /** Tear down the backend and reset lifecycle state (safe before start). */ + stop(): void { + if (this.backend) { + this.backend.stop(); + this.backend = null; + } + this.started = false; + this.startPromise = null; + } + } + ``` + + Verify (GREEN): `npx vitest run src/music/spotify/controller.test.ts` — all cases PASS. + +- [ ] **Step 3: Typecheck the whole project.** + Run `npx tsc --noEmit` — expected to PASS with no errors (confirms the `SpotifyAudioBackend`/`SpotifyConfig`/`GoLibrespotBackend` imports and the `FakeBackend implements SpotifyAudioBackend` structural match are correct). No existing files were modified in this task, so no existing tests can regress; if `tsc` flags a pre-existing unrelated error, do not fix it here. + +- [ ] **Step 4: Commit.** + ```sh + git add src/music/spotify/controller.ts src/music/spotify/controller.test.ts + git commit -m "$(cat <<'EOF' + feat(spotify): SpotifyController backend lifecycle, gating, and event re-emission + + Per-bot orchestrator: isAvailable() gates on config.enabled + platform + + binary presence; ensureStarted() starts the backend once (idempotent, retries + on failure); playTrack/pause/resume/seek/stop delegate; getPcmStream() proxies + the backend PCM; re-emits backend trackEnded/metadata. backendFactory injected + for tests (fake backend, no real binary/network). + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +--- + +### Task 7: instance.ts orchestration + manager/index wiring + +**Files:** +- MODIFY `src/bot/instance.ts` — construct one `SpotifyController` per `BotInstance`; replace the Stage‑1 sentinel-skip block in `resolveAndPlay` with the Spotify branch; wire controller `"trackEnded"` → `playNext`; delegate transport in `cmdPause`/`cmdResume`/`cmdStop` (+ teardown paths). +- MODIFY `src/bot/manager.ts` — add a `spotifyDataDir` ctor param and thread it into all three `new BotInstance({...})` sites. +- MODIFY `src/index.ts` — compute `SPOTIFY_DATA_DIR` under `DATA_DIR` and pass it to `new BotManager(...)`. +- ADD tests to `src/bot/instance.test.ts` — routing/branch/transport decisions on hand-built `ctx` via `Prototype.method.call(ctx)` (the existing test style). Live audio is not testable here. + +**Interfaces:** + +Consumes (verbatim from the LOCKED CONTRACT — do not redefine): +- `SpotifyController.constructor(o: { config: import("../../data/config.js").SpotifyConfig; workDir: string; configDir: string; logger: import("pino").Logger; backendFactory?: () => SpotifyAudioBackend })` +- `SpotifyController.ensureStarted(): Promise` — idempotent; `false` ⇒ caller keeps the Stage‑1 fallback. +- `SpotifyController.playTrack(uri: string): Promise` +- `SpotifyController.pause(): Promise`; `resume(): Promise`; `seek(ms: number): Promise`; `stop(): void` +- `SpotifyController.getPcmStream(): import("node:stream").Readable` +- `SpotifyController.on("trackEnded", cb: (e: SpotifyTrackEndedEvent) => void)` / re-emitted `"metadata"` (extends `EventEmitter`) +- `interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error" }` (from `backend.ts`) +- `AudioPlayer.playPcmStream(readable: Readable, opts: { onExternalEnd?: () => void }): void` (Player task) + +Produces: +- `BotInstanceOptions` gains `spotifyDataDir?: string` and `spotifyControllerFactory?: (o: { config: SpotifyConfig; workDir: string; configDir: string; logger: Logger }) => SpotifyController`. +- `BotManager.constructor(...)` gains a trailing `spotifyDataDir?: string`. +- `BotInstance.resolveAndPlay(song: QueuedSong): Promise` — now returns `true` for a started Spotify track (via `playPcmStream`), still returns `false` on the Stage‑1 fallback. + +--- + +- [ ] **Step 1: Scaffold the per-bot SpotifyController (imports, options, field, ctor wiring). Existing behavior preserved.** + + In `src/bot/instance.ts`, extend the imports. Change line 18 and add two new imports after line 26: + + ```ts + // line 18 — add SpotifyConfig to the existing type import + import type { BotConfig, SpotifyConfig } from "../data/config.js"; + ``` + + ```ts + // after line 26 (import { isSpotifyUri } ...) + import path from "node:path"; + import { SpotifyController } from "../music/spotify/controller.js"; + import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js"; + ``` + + Add the new option fields to `BotInstanceOptions` (after `avatarStore: AvatarStore;`, before the closing brace ~line 45): + + ```ts + avatarStore: AvatarStore; + /** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */ + spotifyDataDir?: string; + /** Test seam: build a fake controller instead of a real go-librespot one. */ + spotifyControllerFactory?: (o: { + config: SpotifyConfig; + workDir: string; + configDir: string; + logger: Logger; + }) => SpotifyController; + ``` + + Declare the two new private fields (after `private player: AudioPlayer;` ~line 68, and near `private autoPaused = false;`): + + ```ts + private player: AudioPlayer; + private spotifyController: SpotifyController; + ``` + + ```ts + private autoPaused = false; + /** True while the audible track is served by the Spotify sidecar (external + * PCM mode) — drives fence/handoff decisions in resolveAndPlay + cmdStop. */ + private currentSourceIsSpotify = false; + ``` + + Construct the controller in the ctor, immediately after `this.queue = new PlayQueue();` (line 115) — it must exist before `setupPlayerEvents()` runs: + + ```ts + this.queue = new PlayQueue(); + + // One long-lived Spotify sidecar controller per bot. Construction is + // cheap and side-effect-free — nothing spawns until ensureStarted(). + const spotifyBase = + options.spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); + const spotifyWorkDir = path.join(spotifyBase, this.id, "work"); + const spotifyConfigDir = path.join(spotifyBase, this.id, "config"); + const buildController = + options.spotifyControllerFactory ?? + ((o) => new SpotifyController({ ...o })); + this.spotifyController = buildController({ + config: this.config.spotify, + workDir: spotifyWorkDir, + configDir: spotifyConfigDir, + logger: this.logger, + }); + ``` + + Verify (scaffolding compiles; existing tests untouched): + - `npx tsc --noEmit` → PASS + - `npx vitest run src/bot/instance.test.ts` → PASS (existing suites still green) + +- [ ] **Step 2 (RED): Add the Spotify orchestration tests to `src/bot/instance.test.ts`.** + + Append these suites at the end of the file. They drive the REAL prototype methods on a hand-built `ctx` (the file's established `.call(ctx)` style) and assert real routing behavior — a Spotify song must hit `controller.playTrack` + `player.playPcmStream`, never `player.play`. + + ```ts + // --- Spotify orchestration (Task 7) -------------------------------------- + + const resolveAndPlay = BotInstance.prototype.resolveAndPlay as ( + this: unknown, + song: any, + ) => Promise; + const setupPlayerEvents = (BotInstance.prototype as any).setupPlayerEvents as ( + this: unknown, + ) => void; + const cmdPause = (BotInstance.prototype as any).cmdPause as (this: unknown) => string; + const cmdResume = (BotInstance.prototype as any).cmdResume as (this: unknown) => string; + const cmdStop = (BotInstance.prototype as any).cmdStop as (this: unknown) => string; + + function makeController() { + return { + ensureStarted: vi.fn(async () => true), + playTrack: vi.fn(async () => true), + getPcmStream: vi.fn(() => ({ kind: "pcm" } as any)), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(() => {}), + on: vi.fn(), + }; + } + function makePlayer() { + return { + play: vi.fn(), + stop: vi.fn(), + playPcmStream: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + }; + } + function makeResolveCtx(opts: { + controller: ReturnType; + player: ReturnType; + url: string; + song: any; + currentSourceIsSpotify?: boolean; + }) { + return { + connected: true, + config: {}, + id: "bot1", + voteSkipUsers: new Set(), + autoPaused: false, + currentSourceIsSpotify: opts.currentSourceIsSpotify ?? false, + effectiveDuration: undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + tsClient: { sendTextMessage: vi.fn(async () => {}) }, + database: { addPlayHistory: vi.fn() }, + spotifyController: opts.controller, + player: opts.player, + getProviderFor: vi.fn(() => ({ getSongUrl: async () => ({ url: opts.url }) })), + syncProfileToSong: vi.fn(async () => {}), + emit: vi.fn(), + } as any; + } + function spotifySong() { + return { + id: "abc", + name: "Song", + artist: "Artist", + album: "Album", + platform: "spotify", + coverUrl: "c", + duration: 200, + url: "", + }; + } + + describe("BotInstance.resolveAndPlay — Spotify routing", () => { + it("routes a spotify song to controller.playTrack + player.playPcmStream, not player.play", async () => { + const controller = makeController(); + const player = makePlayer(); + const song = spotifySong(); + const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", song }); + + const ok = await resolveAndPlay.call(ctx, song); + + expect(ok).toBe(true); + expect(controller.ensureStarted).toHaveBeenCalledTimes(1); + expect(controller.playTrack).toHaveBeenCalledWith("spotify:track:abc"); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + expect(player.playPcmStream.mock.calls[0][0]).toEqual({ kind: "pcm" }); + expect(player.play).not.toHaveBeenCalled(); + expect(ctx.currentSourceIsSpotify).toBe(true); + expect(ctx.database.addPlayHistory).toHaveBeenCalledTimes(1); + expect(ctx.emit).toHaveBeenCalledWith("stateChange"); + }); + + it("returns false + sends the Stage-1 fallback when the backend is unavailable", async () => { + const controller = makeController(); + controller.ensureStarted = vi.fn(async () => false); + const player = makePlayer(); + const song = spotifySong(); + const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", song }); + + const ok = await resolveAndPlay.call(ctx, song); + + expect(ok).toBe(false); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); + expect(controller.playTrack).not.toHaveBeenCalled(); + expect(player.playPcmStream).not.toHaveBeenCalled(); + expect(player.play).not.toHaveBeenCalled(); + }); + + it("fences the URL player (player.stop) when switching from non-spotify to spotify", async () => { + const controller = makeController(); + const player = makePlayer(); + const song = spotifySong(); + const ctx = makeResolveCtx({ + controller, player, url: "spotify:track:abc", song, currentSourceIsSpotify: false, + }); + + await resolveAndPlay.call(ctx, song); + + expect(player.stop).toHaveBeenCalledTimes(1); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + }); + + it("keeps the stream attached (no player.stop) on a spotify→spotify handoff", async () => { + const controller = makeController(); + const player = makePlayer(); + const song = spotifySong(); + const ctx = makeResolveCtx({ + controller, player, url: "spotify:track:abc", song, currentSourceIsSpotify: true, + }); + + await resolveAndPlay.call(ctx, song); + + expect(player.stop).not.toHaveBeenCalled(); + expect(controller.playTrack).toHaveBeenCalledWith("spotify:track:abc"); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + }); + + it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => { + const controller = makeController(); + const player = makePlayer(); + const song = { ...spotifySong(), platform: "netease" }; + const ctx = makeResolveCtx({ + controller, player, url: "http://cdn/x.mp3", song, currentSourceIsSpotify: true, + }); + + const ok = await resolveAndPlay.call(ctx, song); + + expect(ok).toBe(true); + expect(controller.pause).toHaveBeenCalledTimes(1); + expect(ctx.currentSourceIsSpotify).toBe(false); + expect(player.play).toHaveBeenCalledWith("http://cdn/x.mp3", 0, 200); + expect(player.playPcmStream).not.toHaveBeenCalled(); + }); + }); + + describe("BotInstance.setupPlayerEvents — controller trackEnded wiring", () => { + function makeEventCtx(currentPlatform: string) { + return { + spotifyController: { on: vi.fn() }, + player: { on: vi.fn() }, + queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, + logger: { debug: vi.fn(), error: vi.fn() }, + playNext: vi.fn(async () => true), + } as any; + } + function trackEndedHandler(ctx: any) { + const call = ctx.spotifyController.on.mock.calls.find( + (c: any[]) => c[0] === "trackEnded", + ); + expect(call).toBeDefined(); + return call[1] as (e: any) => void; + } + + it("advances via playNext when the current song is spotify", () => { + const ctx = makeEventCtx("spotify"); + setupPlayerEvents.call(ctx); + trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" }); + expect(ctx.playNext).toHaveBeenCalledTimes(1); + }); + + it("ignores controller trackEnded when the current song is not spotify", () => { + const ctx = makeEventCtx("netease"); + setupPlayerEvents.call(ctx); + trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" }); + expect(ctx.playNext).not.toHaveBeenCalled(); + }); + }); + + describe("BotInstance transport delegation — spotify current song", () => { + function makeCmdCtx(currentPlatform: string) { + return { + player: { pause: vi.fn(), resume: vi.fn(), stop: vi.fn() }, + spotifyController: { + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(() => {}), + }, + queue: { current: vi.fn(() => ({ platform: currentPlatform })), clear: vi.fn() }, + logger: { warn: vi.fn() }, + emit: vi.fn(), + autoPaused: true, + currentSourceIsSpotify: true, + sweepLocalAudio: vi.fn(), + disableFmMode: vi.fn(), + profileManager: { onSongChange: vi.fn(async () => {}) }, + } as any; + } + + it("cmdPause delegates to controller.pause when current is spotify", () => { + const ctx = makeCmdCtx("spotify"); + cmdPause.call(ctx); + expect(ctx.player.pause).toHaveBeenCalled(); + expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1); + }); + + it("cmdResume delegates to controller.resume when current is spotify", () => { + const ctx = makeCmdCtx("spotify"); + cmdResume.call(ctx); + expect(ctx.player.resume).toHaveBeenCalled(); + expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1); + }); + + it("cmdStop stops the sidecar + player and clears the spotify flag", () => { + const ctx = makeCmdCtx("spotify"); + cmdStop.call(ctx); + expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1); + expect(ctx.player.stop).toHaveBeenCalledTimes(1); + expect(ctx.queue.clear).toHaveBeenCalledTimes(1); + expect(ctx.currentSourceIsSpotify).toBe(false); + }); + + it("does NOT touch the controller when current is not spotify", () => { + const ctx = makeCmdCtx("netease"); + cmdPause.call(ctx); + cmdResume.call(ctx); + expect(ctx.spotifyController.pause).not.toHaveBeenCalled(); + expect(ctx.spotifyController.resume).not.toHaveBeenCalled(); + }); + }); + ``` + + Verify (implementation absent ⇒ RED): + - `npx vitest run src/bot/instance.test.ts` → FAIL (the `resolveAndPlay — Spotify routing`, `setupPlayerEvents`, and `transport delegation` suites fail: current code sends the sentinel message and returns `false`, never calls `playTrack`/`playPcmStream`, and `setupPlayerEvents` never registers a `"trackEnded"` handler) + +- [ ] **Step 3 (GREEN): Replace the Stage‑1 sentinel-skip block in `resolveAndPlay` with the Spotify branch + non-spotify transition pause.** + + In `src/bot/instance.ts`, replace the sentinel block at lines 594–600 (the `if (isSpotifyUri(result.url)) { ... return false; }` that only warns and skips). Everything before it (connection guard, `getSongUrl`, post-await reconnect check) and the non-spotify tail (`song.url = result.url; this.effectiveDuration = ...; this.player.play(...)` etc.) is preserved. Replace only the block: + + ```ts + // Stage 2: a `spotify:` sentinel URI means the go-librespot sidecar + // serves the audio, NOT ffmpeg. Start the per-bot sidecar on demand; + // if it can't run (disabled / non-Linux / binary missing) keep the + // Stage-1 fallback message + skip so the queue keeps moving. + if (isSpotifyUri(result.url)) { + const ready = await this.spotifyController.ensureStarted(); + if (!ready) { + this.logger.info({ songId: song.id, name: song.name }, "Spotify backend unavailable — skipping"); + await this.tsClient.sendTextMessage( + "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" + ); + return false; + } + // Coming from a URL track: fence the per-URL ffmpeg player so its + // pcmBuffer can't collide with the external PCM stream. On a + // spotify→spotify handoff keep the sidecar stream attached (gapless). + if (!this.currentSourceIsSpotify) { + this.player.stop(); + } + await this.spotifyController.playTrack(result.url); + this.player.playPcmStream(this.spotifyController.getPcmStream(), { + onExternalEnd: () => { + // The sidecar PCM pipe is long-lived; per-track end arrives via + // the controller "trackEnded" WS event, not stream EOF. A real + // EOF here means the sidecar died — log; recovery is the + // controller's job. + this.logger.warn("Spotify PCM stream ended unexpectedly"); + }, + }); + this.currentSourceIsSpotify = true; + song.url = result.url; + // No trial clip for Spotify — full-track duration only (near-end + // stall logic is disabled for the external stream anyway). + this.effectiveDuration = song.duration; + this.autoPaused = false; + this.database.addPlayHistory({ + botId: this.id, + songId: song.id, + songName: song.name, + artist: song.artist, + album: song.album, + platform: song.platform, + coverUrl: song.coverUrl, + }); + await this.syncProfileToSong(song); + this.emit("stateChange"); + return true; + } + // Non-Spotify track: if we were on Spotify, pause the sidecar so it + // stops decoding ahead before the URL ffmpeg reclaims the PCM buffer. + if (this.currentSourceIsSpotify) { + this.spotifyController.pause().catch((err) => + this.logger.warn({ err }, "Failed to pause Spotify sidecar on source switch")); + this.currentSourceIsSpotify = false; + } + ``` + + Do not touch the lines below it (`song.url = result.url;` onward) — the non-spotify path continues to call `this.player.play(...)` exactly as before. + +- [ ] **Step 4 (GREEN): Wire the controller `"trackEnded"` → `playNext` in `setupPlayerEvents`. Existing player `"frame"`/`"trackEnd"`/`"error"` handlers preserved.** + + In `src/bot/instance.ts`, append to the end of `setupPlayerEvents()` (after the existing `this.player.on("error", ...)` block, before the method's closing brace ~line 158): + + ```ts + // Spotify advances exclusively via the sidecar's WebSocket "trackEnded" + // (the continuous go-librespot→ffmpeg pipe never EOFs per track, so the + // player's own underrun "trackEnd" is suppressed in external mode). Guard + // on the current song being spotify so a stray event can't double-advance + // a URL track; playNext()'s isAdvancing guard covers any residual race. + this.spotifyController.on("trackEnded", (_e: SpotifyTrackEndedEvent) => { + if (this.queue.current()?.platform !== "spotify") return; + this.logger.debug("Spotify track ended, advancing queue"); + this.playNext().catch((err) => { + this.logger.error({ err }, "playNext failed after spotify trackEnded"); + }); + }); + ``` + +- [ ] **Step 5 (GREEN): Delegate transport in `cmdPause`/`cmdResume`/`cmdStop` and add sidecar teardown to the stop/clear/disconnect paths. Existing behavior preserved for non-spotify.** + + In `src/bot/instance.ts`, update `cmdPause` (768) and `cmdResume` (776) to also drive the sidecar when the current song is Spotify (still synchronous — the REST calls are fire-and-forget): + + ```ts + private cmdPause(): string { + this.player.pause(); + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.pause().catch((err) => + this.logger.warn({ err }, "Spotify pause failed")); + } + // User-initiated pause — clear auto-pause so occupancy won't auto-resume it. + this.autoPaused = false; + this.emit("stateChange"); + return "Paused"; + } + + private cmdResume(): string { + this.player.resume(); + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.resume().catch((err) => + this.logger.warn({ err }, "Spotify resume failed")); + } + // User-initiated resume — drop any auto-pause flag. + this.autoPaused = false; + this.emit("stateChange"); + return "Resumed"; + } + ``` + + Update `cmdStop` (784) — read `queue.current()` BEFORE `queue.clear()`, stop the sidecar, and clear the flag: + + ```ts + private cmdStop(): string { + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.stop(); + } + this.currentSourceIsSpotify = false; + this.player.stop(); + this.autoPaused = false; + this.queue.clear(); + this.sweepLocalAudio("stopped"); + this.disableFmMode(); + this.profileManager.onSongChange(null).catch((err) => { + this.logger.warn({ err }, "Profile restore failed on stop"); + }); + this.emit("stateChange"); + return "Stopped and queue cleared"; + } + ``` + + Add the same detach to the other `player.stop()` teardown paths so the sidecar can't stream into a dead player (`stop()` is a safe no-op when the backend was never started). In `cmdClear` (844), the `disconnected` handler (208), and `disconnect()` (291), insert `this.spotifyController.stop();` and `this.currentSourceIsSpotify = false;` immediately before the existing `this.player.stop();`. For example in `disconnect()`: + + ```ts + disconnect(): void { + this._cancelIdleTimer(); + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; + this.player.stop(); + this.queue.clear(); + // ...unchanged... + ``` + + Verify (implementation complete ⇒ GREEN): + - `npx vitest run src/bot/instance.test.ts` → PASS (all Spotify suites green; the pre-existing `runExclusive`, permission-gate, and `getProviderFor` suites remain green) + - `npx tsc --noEmit` → PASS + +- [ ] **Step 6: Thread `spotifyDataDir` through `BotManager` into all three `BotInstance` sites.** + + In `src/bot/manager.ts`, add the `node:path` import at the top (after the existing `node:crypto`/`node:events` imports): + + ```ts + import path from "node:path"; + ``` + + Add a field and a trailing ctor param. Add to the field list (after `private spotifyProvider: MusicProvider;` ~line 79): + + ```ts + private spotifyDataDir: string; + ``` + + Extend the constructor signature — append after `spotifyProvider?: MusicProvider` (line 99) and initialize in the body (after `this.spotifyProvider = ...` line 108): + + ```ts + spotifyProvider?: MusicProvider, + spotifyDataDir?: string + ) { + ``` + + ```ts + this.spotifyProvider = spotifyProvider ?? neteaseProvider; + this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); + ``` + + Then add `spotifyDataDir: this.spotifyDataDir,` to each of the THREE `new BotInstance({ ... })` option objects — in `createBot` (after `avatarStore: this.avatarStore,` ~line 151), in `startBot` (~line 292), and in `loadSavedBots` (~line 347): + + ```ts + avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, + }); + ``` + + Verify: + - `npx tsc --noEmit` → PASS + +- [ ] **Step 7: Wire `src/index.ts` — pass the per-install Spotify data dir into `BotManager`.** + + In `src/index.ts`, add the dir constant alongside the other `DATA_DIR`-derived paths (after `const LOCAL_AUDIO_DIR = ...` ~line 31): + + ```ts + const SPOTIFY_DATA_DIR = path.join(DATA_DIR, "spotify"); + ``` + + Pass it as the new trailing argument to the `BotManager` construction (after `spotifyProvider` at line 97): + + ```ts + const botManager = new BotManager( + neteaseProvider, + qqProvider, + bilibiliProvider, + db, + config, + logger, + avatarStore, + permissions, + CONFIG_PATH, + localProvider, + kugouProvider, + spotifyProvider, + SPOTIFY_DATA_DIR + ); + ``` + + Verify (full project type-check + full test run): + - `npx tsc --noEmit` → PASS + - `npx vitest run src/bot/instance.test.ts` → PASS + +- [ ] **Step 8: Commit.** + + ```bash + git add src/bot/instance.ts src/bot/manager.ts src/index.ts src/bot/instance.test.ts + git commit -m "$(cat <<'EOF' + feat(spotify): orchestrate go-librespot backend from BotInstance (Stage 2 Task 7) + + Construct one SpotifyController per bot (config.spotify + per-bot work/config + dirs under DATA_DIR, threaded via BotManager + index). resolveAndPlay now + routes spotify: sentinels through controller.ensureStarted/playTrack + + player.playPcmStream (falling back to the Stage-1 message when unavailable), + fences/pauses the sidecar on source transitions, advances via controller + "trackEnded", and delegates pause/resume/stop transport. + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +--- + +### Task 8: Whole-stage verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full backend suite** + +Run: `npx vitest run` +Expected: all tests pass (existing + new Stage-2 unit tests; no regressions). + +- [ ] **Step 2: Typecheck + frontend build** + +Run: `npx tsc --noEmit && cd web && npm run build` +Expected: zero type errors; frontend builds (unchanged in Stage 2, but confirm no breakage). + +- [ ] **Step 3: Gating sanity (documented)** + +Confirm by reading (not running a live sidecar): on a non-Linux host `isGoLibrespotSupported()` is false, so `SpotifyController.ensureStarted()` returns false and `resolveAndPlay` uses the Stage-1 sentinel message — i.e. Stage 1's behavior is preserved everywhere the backend can't run. State in the commit/report that live audio playback was NOT verified here (requires Premium + Linux + a real go-librespot sidecar) and list exactly what WAS verified (unit tests with mocked process/HTTP/WS/FS, tsc, build). + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "chore(spotify): stage 2 verification pass" --allow-empty +``` From 32718f01180d79c02178a12e442e1d89fc627612 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 20:33:42 +0800 Subject: [PATCH 11/59] feat(spotify): add go-librespot binary resolver + Linux support gate Mirror youtube.ts findYtDlp/checkYtDlpAvailable (bin/ then PATH, cache-positive-only availability, reset test hook) and add isGoLibrespotSupported() Linux gate for the Stage 2 audio backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/binary.test.ts | 109 +++++++++++++++++++++++++++++++ src/music/spotify/binary.ts | 93 ++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/music/spotify/binary.test.ts create mode 100644 src/music/spotify/binary.ts diff --git a/src/music/spotify/binary.test.ts b/src/music/spotify/binary.test.ts new file mode 100644 index 0000000..47a8231 --- /dev/null +++ b/src/music/spotify/binary.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { join } from "node:path"; +import { + isGoLibrespotSupported, + pickGoLibrespotPath, + findGoLibrespot, + checkGoLibrespotAvailable, + resetGoLibrespotBinaryCache, + __setGoLibrespotVersionProbe, +} from "./binary.js"; + +const origPlatform = process.platform; +function setPlatform(p: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: p, configurable: true }); +} + +afterEach(() => { + setPlatform(origPlatform); + __setGoLibrespotVersionProbe(null); + resetGoLibrespotBinaryCache(); +}); + +describe("isGoLibrespotSupported", () => { + it("is true only on linux", () => { + setPlatform("linux"); + expect(isGoLibrespotSupported()).toBe(true); + setPlatform("win32"); + expect(isGoLibrespotSupported()).toBe(false); + setPlatform("darwin"); + expect(isGoLibrespotSupported()).toBe(false); + }); +}); + +describe("pickGoLibrespotPath (bin/ then PATH ordering)", () => { + const binPath = join("some", "root", "bin", "go-librespot"); + + it("prefers the bin/ path when the file exists", () => { + expect( + pickGoLibrespotPath([binPath, "go-librespot"], (p) => p === binPath), + ).toBe(binPath); + }); + + it("falls through to the bare PATH name when the bin/ file is missing", () => { + expect(pickGoLibrespotPath([binPath, "go-librespot"], () => false)).toBe( + "go-librespot", + ); + }); + + it("returns bare command names without touching the filesystem", () => { + const exists = vi.fn(() => false); + expect(pickGoLibrespotPath(["go-librespot"], exists)).toBe("go-librespot"); + expect(exists).not.toHaveBeenCalled(); + }); +}); + +describe("findGoLibrespot", () => { + it("returns the bare command name when bin/go-librespot is absent", () => { + // No go-librespot binary is committed under bin/, so resolution must + // fall back to the bare PATH name (execFile resolves it at run time). + expect(findGoLibrespot()).toBe("go-librespot"); + }); +}); + +describe("checkGoLibrespotAvailable", () => { + it("returns false immediately on unsupported platforms without probing", async () => { + setPlatform("darwin"); + const probe = vi.fn(async () => {}); + __setGoLibrespotVersionProbe(probe); + expect(await checkGoLibrespotAvailable()).toBe(false); + expect(probe).not.toHaveBeenCalled(); + }); + + it("returns true when the binary responds to --version on linux", async () => { + setPlatform("linux"); + __setGoLibrespotVersionProbe(async () => {}); + expect(await checkGoLibrespotAvailable()).toBe(true); + }); + + it("caches only positive results and probes once", async () => { + setPlatform("linux"); + const probe = vi.fn(async () => {}); + __setGoLibrespotVersionProbe(probe); + expect(await checkGoLibrespotAvailable()).toBe(true); + expect(await checkGoLibrespotAvailable()).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("does not cache a failed probe (retries on the next call)", async () => { + setPlatform("linux"); + __setGoLibrespotVersionProbe(async () => { + throw new Error("ENOENT"); + }); + expect(await checkGoLibrespotAvailable()).toBe(false); + // A later successful probe must now succeed — negatives are not cached. + __setGoLibrespotVersionProbe(async () => {}); + expect(await checkGoLibrespotAvailable()).toBe(true); + }); + + it("resetGoLibrespotBinaryCache clears a cached positive", async () => { + setPlatform("linux"); + __setGoLibrespotVersionProbe(async () => {}); + expect(await checkGoLibrespotAvailable()).toBe(true); + resetGoLibrespotBinaryCache(); + __setGoLibrespotVersionProbe(async () => { + throw new Error("gone"); + }); + expect(await checkGoLibrespotAvailable()).toBe(false); + }); +}); diff --git a/src/music/spotify/binary.ts b/src/music/spotify/binary.ts new file mode 100644 index 0000000..c20e05b --- /dev/null +++ b/src/music/spotify/binary.ts @@ -0,0 +1,93 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const execFileAsync = promisify(execFile); + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * True only on Linux. go-librespot ships Linux-only release binaries and the + * sidecar relies on a POSIX FIFO (mkfifo), so the Spotify audio backend is + * gated to Linux/Docker. Everywhere else the caller falls back to the Stage-1 + * sentinel-skip message. + */ +export function isGoLibrespotSupported(): boolean { + return process.platform === "linux"; +} + +/** + * Pure resolver core behind findGoLibrespot(). Returns the first candidate + * that is either a bare command name (left for execFile to resolve via PATH) + * or an existing bin/ file. Exported so tests can inject candidates + a fake + * existence predicate and need no real binary on disk. + */ +export function pickGoLibrespotPath( + candidates: string[], + exists: (p: string) => boolean, +): string { + for (const c of candidates) { + // bin/ paths only count when the file is actually present; bare names are + // returned unconditionally and resolved later via PATH. + const isBinPath = c.includes(join("bin", "go-librespot")); + if (!isBinPath || exists(c)) return c; + } + return "go-librespot"; +} + +/** Resolve the go-librespot binary path: project bin/ dir first, then PATH. */ +export function findGoLibrespot(): string { + // src/music/spotify -> ../../../bin (one level deeper than youtube.ts). + const binPath = join(__dirname, "..", "..", "..", "bin", "go-librespot"); + return pickGoLibrespotPath([binPath, "go-librespot"], existsSync); +} + +// Injectable `--version` probe. Defaults to the real execFile call; tests +// override it so checkGoLibrespotAvailable() needs no real binary. Keeps the +// public checkGoLibrespotAvailable() signature param-free per the contract. +type VersionProbe = (bin: string) => Promise; +const realProbe: VersionProbe = async (bin) => { + await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 }); +}; +let versionProbe: VersionProbe = realProbe; + +/** Test hook: override the `--version` probe, or restore the default with null. */ +export function __setGoLibrespotVersionProbe( + probe: VersionProbe | null, +): void { + versionProbe = probe ?? realProbe; +} + +/** + * Availability check for go-librespot. Returns false immediately on non-Linux + * platforms (unsupported). Otherwise runs `go-librespot --version` (5s timeout) + * and caches ONLY the positive result — a missing binary is retried on the + * next call so the operator can install it without restarting the server. + */ +let cachedAvailable = false; +let pendingCheck: Promise | null = null; +export async function checkGoLibrespotAvailable(): Promise { + if (!isGoLibrespotSupported()) return false; + if (cachedAvailable) return true; + if (pendingCheck) return pendingCheck; + pendingCheck = (async () => { + try { + await versionProbe(findGoLibrespot()); + cachedAvailable = true; + return true; + } catch { + return false; + } finally { + pendingCheck = null; + } + })(); + return pendingCheck; +} + +/** Force re-detection on the next call (for tests). */ +export function resetGoLibrespotBinaryCache(): void { + cachedAvailable = false; + pendingCheck = null; +} From 470a62129a8218593ad5730978e3e196b96ca339 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 20:40:15 +0800 Subject: [PATCH 12/59] feat(spotify): add SpotifyAudioBackend interface + go-librespot config.yml renderer - backend.ts: type-only SpotifyAudioBackend contract + track/metadata DTOs - go-librespot-config.ts: renderConfigYml() hand-built config (pipe/s16le, server enabled, interactive OAuth), no yaml dependency - tests assert exact keys/values and round-trip via a tiny structural parser Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/backend.ts | 41 ++++++ src/music/spotify/go-librespot-config.test.ts | 119 ++++++++++++++++++ src/music/spotify/go-librespot-config.ts | 42 +++++++ 3 files changed, 202 insertions(+) create mode 100644 src/music/spotify/backend.ts create mode 100644 src/music/spotify/go-librespot-config.test.ts create mode 100644 src/music/spotify/go-librespot-config.ts diff --git a/src/music/spotify/backend.ts b/src/music/spotify/backend.ts new file mode 100644 index 0000000..e038638 --- /dev/null +++ b/src/music/spotify/backend.ts @@ -0,0 +1,41 @@ +// src/music/spotify/backend.ts +// Type contract for the Spotify audio backend (go-librespot sidecar). +// Interface-only: this module intentionally contains NO runtime code so it +// can be imported for types by go-librespot.ts and controller.ts without +// pulling in child_process/ws/ffmpeg at type-check time. + +/** Emitted when the currently playing Spotify track finishes or is stopped. */ +export interface SpotifyTrackEndedEvent { + uri: string; + reason: "ended" | "stopped" | "error"; +} + +/** Now-playing metadata surfaced from the go-librespot "metadata" event. */ +export interface SpotifyNowPlaying { + uri: string; + name: string; + artist: string; + album: string; + coverUrl: string; + durationMs: number; +} + +/** + * Long-lived Spotify audio source: owns the go-librespot sidecar + FIFO->ffmpeg + * PCM pipe and exposes transport control plus a continuous 48kHz s16le stereo + * PCM stream to feed AudioPlayer.playPcmStream(). + */ +export interface SpotifyAudioBackend { + start(): Promise; + stop(): void; + isReady(): boolean; + playTrack(uri: string): Promise; + pause(): Promise; + resume(): Promise; + seek(ms: number): Promise; + getPcmStream(): import("node:stream").Readable; + getPositionMs(): number; + on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void; + on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void; + on(event: "ready" | "error", cb: (arg?: unknown) => void): void; +} diff --git a/src/music/spotify/go-librespot-config.test.ts b/src/music/spotify/go-librespot-config.test.ts new file mode 100644 index 0000000..e9d9ae7 --- /dev/null +++ b/src/music/spotify/go-librespot-config.test.ts @@ -0,0 +1,119 @@ +// src/music/spotify/go-librespot-config.test.ts +import { describe, it, expect } from "vitest"; +import { renderConfigYml, type GoLibrespotConfigOptions } from "./go-librespot-config.js"; + +const OPTS: GoLibrespotConfigOptions = { + deviceName: "TeamSpeak Music Bot", + bitrate: 320, + fifoPath: "/tmp/go-librespot.fifo", + apiAddress: "0.0.0.0", + apiPort: 3678, + callbackPort: 8080, +}; + +/** + * Minimal 2-level YAML reader for the exact shape renderConfigYml emits + * (flat scalars + one level of nesting under `server:` / `credentials:`). + * Avoids adding a yaml dependency while still proving the output round-trips. + */ +function parseTinyYaml(src: string): Record { + const root: Record = {}; + const stack: Array<{ indent: number; obj: Record }> = [ + { indent: -1, obj: root }, + ]; + for (const rawLine of src.split("\n")) { + if (rawLine.trim() === "") continue; + const indent = rawLine.length - rawLine.trimStart().length; + const line = rawLine.trim(); + const idx = line.indexOf(":"); + const key = line.slice(0, idx).trim(); + let valRaw = line.slice(idx + 1).trim(); + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + const parent = stack[stack.length - 1].obj; + if (valRaw === "") { + const child: Record = {}; + parent[key] = child; + stack.push({ indent, obj: child }); + continue; + } + let val: unknown = valRaw; + if (valRaw.startsWith('"') && valRaw.endsWith('"')) val = JSON.parse(valRaw); + else if (valRaw === "true") val = true; + else if (valRaw === "false") val = false; + else if (/^-?\d+$/.test(valRaw)) val = Number(valRaw); + parent[key] = val; + } + return root; +} + +describe("renderConfigYml", () => { + it("emits the exact top-level go-librespot keys/values", () => { + const lines = renderConfigYml(OPTS).split("\n"); + expect(lines).toContain('device_name: "TeamSpeak Music Bot"'); + expect(lines).toContain("device_type: computer"); + expect(lines).toContain("bitrate: 320"); + expect(lines).toContain("audio_backend: pipe"); + expect(lines).toContain("audio_output_pipe: /tmp/go-librespot.fifo"); + expect(lines).toContain("audio_output_pipe_format: s16le"); + }); + + it("emits a server block with enabled/address/port set explicitly", () => { + const parsed = parseTinyYaml(renderConfigYml(OPTS)); + expect(parsed.server).toEqual({ + enabled: true, + address: "0.0.0.0", + port: 3678, + }); + }); + + it("emits interactive OAuth credentials with the callback port", () => { + const parsed = parseTinyYaml(renderConfigYml(OPTS)); + expect(parsed.credentials).toEqual({ + type: "interactive", + interactive: { callback_port: 8080 }, + }); + }); + + it("full round-trip reflects every provided option", () => { + const parsed = parseTinyYaml(renderConfigYml(OPTS)); + expect(parsed).toEqual({ + device_name: "TeamSpeak Music Bot", + device_type: "computer", + bitrate: 320, + audio_backend: "pipe", + audio_output_pipe: "/tmp/go-librespot.fifo", + audio_output_pipe_format: "s16le", + server: { enabled: true, address: "0.0.0.0", port: 3678 }, + credentials: { type: "interactive", interactive: { callback_port: 8080 } }, + }); + }); + + it("threads distinct option values through unchanged (no hard-coded ports)", () => { + const parsed = parseTinyYaml( + renderConfigYml({ + deviceName: "Other Bot", + bitrate: 160, + fifoPath: "/run/librespot/pipe", + apiAddress: "127.0.0.1", + apiPort: 4000, + callbackPort: 9099, + }), + ); + expect(parsed).toMatchObject({ + device_name: "Other Bot", + bitrate: 160, + audio_output_pipe: "/run/librespot/pipe", + server: { address: "127.0.0.1", port: 4000 }, + credentials: { interactive: { callback_port: 9099 } }, + }); + }); + + it("safely quotes device names containing special characters", () => { + const yml = renderConfigYml({ ...OPTS, deviceName: 'My "Cool" Bot' }); + expect(yml.split("\n")).toContain('device_name: "My \\"Cool\\" Bot"'); + // and still round-trips back to the original string + expect(parseTinyYaml(yml).device_name).toBe('My "Cool" Bot'); + }); +}); diff --git a/src/music/spotify/go-librespot-config.ts b/src/music/spotify/go-librespot-config.ts new file mode 100644 index 0000000..c5605b0 --- /dev/null +++ b/src/music/spotify/go-librespot-config.ts @@ -0,0 +1,42 @@ +// src/music/spotify/go-librespot-config.ts +// Hand-built go-librespot config.yml. Keys/values verified against +// devgianlu/go-librespot cmd/daemon/cli_config.go koanf tags. No yaml +// dependency is used; the file is a small, fixed-shape document. + +export interface GoLibrespotConfigOptions { + deviceName: string; + bitrate: number; + fifoPath: string; + apiAddress: string; + apiPort: number; + callbackPort: number; +} + +/** + * Render a headless go-librespot config.yml: + * - pipe audio backend writing raw 44.1kHz/s16le stereo PCM to a FIFO, + * - HTTP+WebSocket control server enabled (port has NO built-in default, + * so it is always written explicitly), + * - interactive OAuth credentials (persisted automatically to + * /credentials.json after first login). + */ +export function renderConfigYml(o: GoLibrespotConfigOptions): string { + return ( + [ + `device_name: ${JSON.stringify(o.deviceName)}`, + `device_type: computer`, + `bitrate: ${o.bitrate}`, + `audio_backend: pipe`, + `audio_output_pipe: ${o.fifoPath}`, + `audio_output_pipe_format: s16le`, + `server:`, + ` enabled: true`, + ` address: ${o.apiAddress}`, + ` port: ${o.apiPort}`, + `credentials:`, + ` type: interactive`, + ` interactive:`, + ` callback_port: ${o.callbackPort}`, + ].join("\n") + "\n" + ); +} From f2b14b5f00e2c565f7d761fae4ed5b78849188ee Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 20:47:44 +0800 Subject: [PATCH 13/59] feat(spotify): add go-librespot REST client + WS event client (Stage 2) GoLibrespotRestClient wraps axios (injectable via deps.http) for /player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus swallow errors to false/null, mutating ops reject. GoLibrespotEventClient (EventEmitter) parses {type,data} /events frames and re-emits type with data, reconnects on close with capped backoff, stop() tears down. TDD with a mock AxiosInstance and a fake WebSocket (no real binary/network). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/go-librespot-api.test.ts | 218 +++++++++++++++++++++ src/music/spotify/go-librespot-api.ts | 187 ++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 src/music/spotify/go-librespot-api.test.ts create mode 100644 src/music/spotify/go-librespot-api.ts diff --git a/src/music/spotify/go-librespot-api.test.ts b/src/music/spotify/go-librespot-api.test.ts new file mode 100644 index 0000000..47247ae --- /dev/null +++ b/src/music/spotify/go-librespot-api.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { AxiosInstance } from "axios"; +import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js"; + +/** Minimal axios stub: only get/post are exercised by the client. */ +function makeHttp(overrides?: Partial>) { + return { + get: vi.fn().mockResolvedValue({ status: 200, data: {} }), + post: vi.fn().mockResolvedValue({ status: 200, data: {} }), + ...overrides, + } as unknown as AxiosInstance; +} + +describe("GoLibrespotRestClient", () => { + it("ping() returns true on GET / -> 200", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.ping()).resolves.toBe(true); + expect(http.get).toHaveBeenCalledWith("/"); + }); + + it("ping() returns false when GET / rejects (daemon not up)", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.ping()).resolves.toBe(false); + }); + + it("playTrack() POSTs /player/play with the uri body", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await client.playTrack("spotify:track:abc123"); + expect(http.post).toHaveBeenCalledWith("/player/play", { uri: "spotify:track:abc123" }); + }); + + it("pause/resume/stop POST their bodyless endpoints", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await client.pause(); + await client.resume(); + await client.stop(); + expect(http.post).toHaveBeenNthCalledWith(1, "/player/pause"); + expect(http.post).toHaveBeenNthCalledWith(2, "/player/resume"); + expect(http.post).toHaveBeenNthCalledWith(3, "/player/stop"); + }); + + it("seek() POSTs /player/seek with position(ms) and relative:false", async () => { + const http = makeHttp(); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await client.seek(42000); + expect(http.post).toHaveBeenCalledWith("/player/seek", { position: 42000, relative: false }); + }); + + it("playTrack() rejects when the POST fails (surfaced to caller)", async () => { + const http = makeHttp({ post: vi.fn().mockRejectedValue(new Error("boom")) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.playTrack("spotify:track:x")).rejects.toThrow("boom"); + }); + + it("getStatus() normalizes the /status shape (ms position/duration)", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + stopped: false, + paused: false, + buffering: false, + track: { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "https://i.scdn.co/c.jpg", + position: 12345, + duration: 200000, + }, + }, + }), + }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + const status = await client.getStatus(); + expect(http.get).toHaveBeenCalledWith("/status"); + expect(status).toEqual({ + stopped: false, + paused: false, + buffering: false, + track: { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "https://i.scdn.co/c.jpg", + position: 12345, + duration: 200000, + }, + }); + }); + + it("getStatus() returns null with a null track when nothing is loaded", async () => { + const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 200, data: { stopped: true, paused: false, buffering: false, track: null } }) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + const status = await client.getStatus(); + expect(status).toEqual({ stopped: true, paused: false, buffering: false, track: null }); + }); + + it("getStatus() returns null when GET /status rejects", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("down")) }); + const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http }); + await expect(client.getStatus()).resolves.toBeNull(); + }); +}); + +/** Fake ws: records instances, lets tests drive open/message/close/error. */ +class FakeWebSocket extends EventEmitter { + static instances: FakeWebSocket[] = []; + closed = false; + constructor(public url: string) { + super(); + FakeWebSocket.instances.push(this); + } + close() { + this.closed = true; + this.emit("close"); + } +} + +function frame(type: string, data: unknown): Buffer { + return Buffer.from(JSON.stringify({ type, data })); +} + +describe("GoLibrespotEventClient", () => { + beforeEach(() => { + FakeWebSocket.instances = []; + }); + + it("emits 'not_playing' (track-end) with its data payload", () => { + const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", { + WebSocketCtor: FakeWebSocket as any, + }); + const onEnded = vi.fn(); + client.on("not_playing", onEnded); + client.start(); + + const ws = FakeWebSocket.instances[0]; + expect(ws.url).toBe("ws://127.0.0.1:3678/events"); + ws.emit("message", frame("not_playing", { uri: "spotify:track:abc", play_origin: "go-librespot" })); + + expect(onEnded).toHaveBeenCalledTimes(1); + expect(onEnded).toHaveBeenCalledWith({ uri: "spotify:track:abc", play_origin: "go-librespot" }); + client.stop(); + }); + + it("emits 'metadata' with the now-playing object", () => { + const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", { + WebSocketCtor: FakeWebSocket as any, + }); + const onMeta = vi.fn(); + client.on("metadata", onMeta); + client.start(); + + FakeWebSocket.instances[0].emit( + "message", + frame("metadata", { uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }), + ); + + expect(onMeta).toHaveBeenCalledWith({ uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }); + client.stop(); + }); + + it("ignores non-JSON frames without throwing", () => { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + const onAny = vi.fn(); + client.on("metadata", onAny); + client.start(); + expect(() => FakeWebSocket.instances[0].emit("message", Buffer.from("not json"))).not.toThrow(); + expect(onAny).not.toHaveBeenCalled(); + client.stop(); + }); + + it("reconnects with backoff after the socket closes", () => { + vi.useFakeTimers(); + try { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + client.start(); + expect(FakeWebSocket.instances).toHaveLength(1); + + FakeWebSocket.instances[0].emit("close"); + expect(FakeWebSocket.instances).toHaveLength(1); // not immediate + vi.advanceTimersByTime(500); + expect(FakeWebSocket.instances).toHaveLength(2); // reconnected + client.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it("stop() closes the socket and prevents reconnect", () => { + vi.useFakeTimers(); + try { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + client.start(); + const ws = FakeWebSocket.instances[0]; + client.stop(); + expect(ws.closed).toBe(true); + vi.advanceTimersByTime(60000); + expect(FakeWebSocket.instances).toHaveLength(1); // no new socket + } finally { + vi.useRealTimers(); + } + }); + + it("does not throw on socket 'error' when no error listener is attached", () => { + const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any }); + client.start(); + expect(() => FakeWebSocket.instances[0].emit("error", new Error("net"))).not.toThrow(); + client.stop(); + }); +}); diff --git a/src/music/spotify/go-librespot-api.ts b/src/music/spotify/go-librespot-api.ts new file mode 100644 index 0000000..d516644 --- /dev/null +++ b/src/music/spotify/go-librespot-api.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from "node:events"; +import axios, { type AxiosInstance } from "axios"; +import WebSocket from "ws"; + +export interface GoLibrespotStatusTrack { + uri: string; + name: string; + artist_names: string[]; + album_name: string; + album_cover_url: string | null; + position: number; + duration: number; +} + +export interface GoLibrespotStatus { + stopped: boolean; + paused: boolean; + buffering: boolean; + track: GoLibrespotStatusTrack | null; +} + +export class GoLibrespotRestClient { + private http: AxiosInstance; + + constructor(baseUrl: string, deps?: { http?: AxiosInstance }) { + this.http = + deps?.http ?? + axios.create({ + baseURL: baseUrl, + timeout: 10000, + headers: { "Content-Type": "application/json" }, + }); + } + + async ping(): Promise { + try { + const res = await this.http.get("/"); + return res.status === 200; + } catch { + return false; + } + } + + async playTrack(uri: string): Promise { + await this.http.post("/player/play", { uri }); + } + + async pause(): Promise { + await this.http.post("/player/pause"); + } + + async resume(): Promise { + await this.http.post("/player/resume"); + } + + async stop(): Promise { + await this.http.post("/player/stop"); + } + + async seek(ms: number): Promise { + await this.http.post("/player/seek", { position: ms, relative: false }); + } + + async getStatus(): Promise { + try { + const res = await this.http.get("/status"); + const d = res.data ?? {}; + const t = d.track; + return { + stopped: Boolean(d.stopped), + paused: Boolean(d.paused), + buffering: Boolean(d.buffering), + track: t + ? { + uri: t.uri ?? "", + name: t.name ?? "", + artist_names: Array.isArray(t.artist_names) ? t.artist_names : [], + album_name: t.album_name ?? "", + album_cover_url: t.album_cover_url ?? null, + position: t.position ?? 0, + duration: t.duration ?? 0, + } + : null, + }; + } catch { + return null; + } + } +} + +export type GoLibrespotEventType = + | "metadata" + | "playing" + | "paused" + | "not_playing" + | "stopped" + | "will_play" + | "seek" + | "active" + | "inactive" + | "volume" + | "playback_ready"; + +interface WsLike { + on(event: string, cb: (...args: any[]) => void): void; + close(): void; +} +type WebSocketCtor = new (url: string) => WsLike; + +const INITIAL_RECONNECT_MS = 500; +const MAX_RECONNECT_MS = 10000; + +export class GoLibrespotEventClient extends EventEmitter { + private wsUrl: string; + private WebSocketCtor: WebSocketCtor; + private ws: WsLike | null = null; + private stopped = false; + private reconnectDelay = INITIAL_RECONNECT_MS; + private reconnectTimer: ReturnType | null = null; + + constructor(wsUrl: string, deps?: { WebSocketCtor?: WebSocketCtor }) { + super(); + this.wsUrl = wsUrl; + this.WebSocketCtor = deps?.WebSocketCtor ?? (WebSocket as unknown as WebSocketCtor); + } + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private connect(): void { + if (this.stopped) return; + const ws = new this.WebSocketCtor(this.wsUrl); + this.ws = ws; + ws.on("open", () => { + this.reconnectDelay = INITIAL_RECONNECT_MS; + }); + ws.on("message", (buf: unknown) => this.handleMessage(buf)); + ws.on("close", () => { + this.ws = null; + this.scheduleReconnect(); + }); + ws.on("error", (err: unknown) => { + if (this.listenerCount("error") > 0) this.emit("error", err); + }); + } + + private handleMessage(buf: unknown): void { + let parsed: unknown; + try { + const text = Buffer.isBuffer(buf) + ? buf.toString("utf8") + : typeof buf === "string" + ? buf + : String(buf); + parsed = JSON.parse(text); + } catch { + return; + } + if (parsed && typeof (parsed as any).type === "string") { + this.emit((parsed as any).type, (parsed as any).data ?? {}); + } + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_MS); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } +} From 9d55bea240c996d717025c6256c970a5eb6143d3 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 20:58:13 +0800 Subject: [PATCH 14/59] feat(spotify): GoLibrespotBackend sidecar (FIFO + ffmpeg PCM + REST/WS) Implements SpotifyAudioBackend over a go-librespot sidecar: start() mkfifos the pipe, spawns the FIFO->48k s16le ffmpeg reader BEFORE go-librespot, writes config.yml, polls the REST / until ready, then connects the WS event stream. Maps not_playing/stopped -> trackEnded and metadata -> SpotifyNowPlaying; play/pause/resume/seek delegate to the REST client. All child_process/fs/REST/WS seams are injectable so the lifecycle is fully unit-tested without a real binary. Correction C1: ffmpeg is resolved via getFfmpegCommand() (now exported from src/audio/player.ts) so the bundled ffmpeg-static fallback is honored in Docker; the ffmpeg command is overridable via deps.ffmpegCommand for tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/player.ts | 2 +- src/music/spotify/go-librespot.test.ts | 221 ++++++++++++++++++++ src/music/spotify/go-librespot.ts | 273 +++++++++++++++++++++++++ 3 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/music/spotify/go-librespot.test.ts create mode 100644 src/music/spotify/go-librespot.ts diff --git a/src/audio/player.ts b/src/audio/player.ts index 8d492ed..b34b819 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -47,7 +47,7 @@ const resolvedFfmpeg: string = (() => { return "ffmpeg"; })(); -function getFfmpegCommand(): string { +export function getFfmpegCommand(): string { return resolvedFfmpeg; } diff --git a/src/music/spotify/go-librespot.test.ts b/src/music/spotify/go-librespot.test.ts new file mode 100644 index 0000000..aaa6ae1 --- /dev/null +++ b/src/music/spotify/go-librespot.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import pino from "pino"; +import { GoLibrespotBackend } from "./go-librespot.js"; + +const log = pino({ level: "silent" }); + +/** A minimal stand-in for a spawned ChildProcess with real Readable stdout/stderr. */ +function makeFakeChild() { + const child: any = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +function makeHarness() { + const calls: string[] = []; + const ffmpegChild = makeFakeChild(); + const gliChild = makeFakeChild(); + + const spawn = vi.fn((cmd: string, ..._rest: any[]) => { + const isGli = cmd.includes("go-librespot"); + calls.push(`spawn:${isGli ? "go-librespot" : cmd}`); + return isGli ? gliChild : ffmpegChild; + }); + const execFileSync = vi.fn((cmd: string) => { + calls.push(`exec:${cmd}`); + return Buffer.from(""); + }); + const writeFileSync = vi.fn(() => calls.push("write:config")); + const mkdirSync = vi.fn(); + const unlinkSync = vi.fn(() => calls.push("unlink:fifo")); + const existsSync = vi.fn(() => false); + + const rest = { + ping: vi.fn(async () => true), + playTrack: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + getStatus: vi.fn(async () => null), + }; + const events: any = new EventEmitter(); + events.start = vi.fn(() => calls.push("ws:start")); + events.stop = vi.fn(); + + const backend = new GoLibrespotBackend({ + deviceName: "Test Bot", + bitrate: 320, + workDir: "/tmp/work", + configDir: "/tmp/cfg", + apiPort: 3678, + logger: log, + deps: { + spawn, + execFileSync, + writeFileSync, + mkdirSync, + unlinkSync, + existsSync, + // C1: pin the ffmpeg command so the arg-array/order assertions below stay + // stable while production resolves ffmpeg via getFfmpegCommand() (which + // falls back to bundled ffmpeg-static when `ffmpeg` isn't on PATH). + ffmpegCommand: "ffmpeg", + findBinary: () => "/bin/go-librespot", + makeRest: () => rest, + makeEvents: () => events, + sleep: async () => {}, + pollIntervalMs: 1, + pollTimeoutMs: 100, + } as any, + }); + + return { backend, calls, spawn, execFileSync, writeFileSync, existsSync, unlinkSync, rest, events, ffmpegChild, gliChild }; +} + +describe("GoLibrespotBackend.start", () => { + it("creates the FIFO with mkfifo before spawning ffmpeg, and spawns ffmpeg BEFORE go-librespot", async () => { + const h = makeHarness(); + await h.backend.start(); + + expect(h.execFileSync).toHaveBeenCalledWith("mkfifo", ["/tmp/work/go-librespot.fifo"]); + expect(h.writeFileSync).toHaveBeenCalled(); + + const mkfifoIdx = h.calls.indexOf("exec:mkfifo"); + const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg"); + const gliIdx = h.calls.indexOf("spawn:go-librespot"); + expect(mkfifoIdx).toBeGreaterThanOrEqual(0); + expect(ffmpegIdx).toBeGreaterThan(mkfifoIdx); // ffmpeg attaches to the FIFO first + expect(gliIdx).toBeGreaterThan(ffmpegIdx); // then the writer (go-librespot) + expect(h.calls.indexOf("ws:start")).toBeGreaterThan(gliIdx); // WS connects last + }); + + it("passes --config_dir to go-librespot using the resolved binary path", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.spawn).toHaveBeenCalledWith( + "/bin/go-librespot", + ["--config_dir", "/tmp/cfg"], + expect.anything(), + ); + }); + + it("uses the 44100->48000 s16le ffmpeg command reading the FIFO", async () => { + const h = makeHarness(); + await h.backend.start(); + const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[]; + expect(ffmpegArgs).toEqual([ + "-hide_banner", "-loglevel", "error", + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "/tmp/work/go-librespot.fifo", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ]); + }); + + it("emits 'ready' and reports isReady() true once the REST ping succeeds", async () => { + const h = makeHarness(); + const ready = vi.fn(); + h.backend.on("ready", ready); + await h.backend.start(); + expect(h.rest.ping).toHaveBeenCalled(); + expect(ready).toHaveBeenCalledTimes(1); + expect(h.backend.isReady()).toBe(true); + }); + + it("keeps polling ping() until it returns true", async () => { + const h = makeHarness(); + h.rest.ping.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); + await h.backend.start(); + expect(h.rest.ping).toHaveBeenCalledTimes(3); + expect(h.backend.isReady()).toBe(true); + }); +}); + +describe("GoLibrespotBackend WebSocket event mapping", () => { + it("maps a not_playing event to trackEnded{reason:'ended'}", async () => { + const h = makeHarness(); + await h.backend.start(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.events.emit("not_playing", { uri: "spotify:track:abc" }); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:abc", reason: "ended" }); + }); + + it("maps a stopped event to trackEnded{reason:'stopped'}", async () => { + const h = makeHarness(); + await h.backend.start(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.events.emit("stopped", { uri: "spotify:track:xyz" }); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:xyz", reason: "stopped" }); + }); + + it("maps a metadata event to a SpotifyNowPlaying and updates getPositionMs()", async () => { + const h = makeHarness(); + await h.backend.start(); + const meta = vi.fn(); + h.backend.on("metadata", meta); + h.events.emit("metadata", { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "http://x/y.jpg", + position: 1234, + duration: 200000, + }); + expect(meta).toHaveBeenCalledWith({ + uri: "spotify:track:abc", + name: "Song", + artist: "A, B", + album: "Alb", + coverUrl: "http://x/y.jpg", + durationMs: 200000, + }); + expect(h.backend.getPositionMs()).toBe(1234); + }); +}); + +describe("GoLibrespotBackend transport delegation + PCM", () => { + it("playTrack delegates to the REST client", async () => { + const h = makeHarness(); + await h.backend.start(); + await h.backend.playTrack("spotify:track:go"); + expect(h.rest.playTrack).toHaveBeenCalledWith("spotify:track:go"); + }); + + it("pause/resume/seek delegate to the REST client and seek updates position", async () => { + const h = makeHarness(); + await h.backend.start(); + await h.backend.pause(); + await h.backend.resume(); + await h.backend.seek(5000); + expect(h.rest.pause).toHaveBeenCalled(); + expect(h.rest.resume).toHaveBeenCalled(); + expect(h.rest.seek).toHaveBeenCalledWith(5000); + expect(h.backend.getPositionMs()).toBe(5000); + }); + + it("getPcmStream() returns the ffmpeg stdout Readable", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout); + }); +}); + +describe("GoLibrespotBackend.stop", () => { + it("kills ffmpeg + go-librespot, stops the WS, removes the FIFO, and clears ready", async () => { + const h = makeHarness(); + await h.backend.start(); + h.existsSync.mockReturnValue(true); // FIFO now present, so stop() unlinks it + h.backend.stop(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.gliChild.kill).toHaveBeenCalled(); + expect(h.events.stop).toHaveBeenCalled(); + expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo"); + expect(h.backend.isReady()).toBe(false); + }); +}); diff --git a/src/music/spotify/go-librespot.ts b/src/music/spotify/go-librespot.ts new file mode 100644 index 0000000..c2f35df --- /dev/null +++ b/src/music/spotify/go-librespot.ts @@ -0,0 +1,273 @@ +import { EventEmitter } from "node:events"; +// The go-librespot sidecar is Linux/Docker-gated (mkfifo + Linux-only binary), +// so the FIFO and config-dir paths are ALWAYS POSIX. Use posix.join so the +// separators are correct on the Linux target regardless of the host OS. +import { posix as posixPath } from "node:path"; +import type { Readable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { + execFileSync as realExecFileSync, + spawn as realSpawn, +} from "node:child_process"; +import { + existsSync as realExistsSync, + mkdirSync as realMkdirSync, + unlinkSync as realUnlinkSync, + writeFileSync as realWriteFileSync, +} from "node:fs"; +import type { Logger } from "pino"; +import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, +} from "./backend.js"; +import { findGoLibrespot } from "./binary.js"; +import { renderConfigYml } from "./go-librespot-config.js"; +import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js"; +import { getFfmpegCommand } from "../../audio/player.js"; + +export interface GoLibrespotBackendOptions { + deviceName: string; + bitrate: number; + workDir: string; + configDir: string; + apiPort?: number; + logger: Logger; + deps?: GoLibrespotBackendDeps; +} + +/** Injectable seams so the whole lifecycle is testable without a real binary/FIFO/network. */ +export interface GoLibrespotBackendDeps { + spawn?: typeof realSpawn; + execFileSync?: typeof realExecFileSync; + existsSync?: typeof realExistsSync; + mkdirSync?: typeof realMkdirSync; + unlinkSync?: typeof realUnlinkSync; + writeFileSync?: typeof realWriteFileSync; + findBinary?: () => string; + /** + * C1: override the ffmpeg command. Production resolves it via + * getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on + * PATH, the Docker case); tests pin it to "ffmpeg" for stable arg assertions. + */ + ffmpegCommand?: string; + makeRest?: (baseUrl: string) => GoLibrespotRestClient; + makeEvents?: (wsUrl: string) => GoLibrespotEventClient; + sleep?: (ms: number) => Promise; + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +const DEFAULT_API_PORT = 3678; +const DEFAULT_CALLBACK_PORT = 8080; +const FIFO_NAME = "go-librespot.fifo"; + +export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBackend { + private readonly opts: GoLibrespotBackendOptions; + private readonly log: Logger; + private readonly deps: GoLibrespotBackendDeps; + private readonly apiPort: number; + private readonly fifoPath: string; + + private ffmpeg: ChildProcess | null = null; + private proc: ChildProcess | null = null; + private rest: GoLibrespotRestClient | null = null; + private events: GoLibrespotEventClient | null = null; + private ready = false; + private positionMs = 0; + + constructor(o: GoLibrespotBackendOptions) { + super(); + this.opts = o; + this.log = o.logger; + this.deps = o.deps ?? {}; + this.apiPort = o.apiPort ?? DEFAULT_API_PORT; + this.fifoPath = posixPath.join(o.workDir, FIFO_NAME); + } + + async start(): Promise { + const spawn = this.deps.spawn ?? realSpawn; + const execFileSync = this.deps.execFileSync ?? realExecFileSync; + const existsSync = this.deps.existsSync ?? realExistsSync; + const mkdirSync = this.deps.mkdirSync ?? realMkdirSync; + const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync; + const writeFileSync = this.deps.writeFileSync ?? realWriteFileSync; + const findBinary = this.deps.findBinary ?? findGoLibrespot; + // C1: resolve ffmpeg via the repo's getFfmpegCommand() (ffmpeg-static + // fallback) unless a command is injected for tests. + const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand(); + + // 1. Ensure work + config directories exist. + mkdirSync(this.opts.workDir, { recursive: true }); + mkdirSync(this.opts.configDir, { recursive: true }); + + // 2. (Re)create the FIFO — mkfifo fails if the path already exists. + if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath); + execFileSync("mkfifo", [this.fifoPath]); + + // 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before + // go-librespot (the writer) starts pushing raw 44.1k s16le into it. + // Opening the FIFO for writing before a reader exists errors with ENXIO. + this.ffmpeg = spawn( + ffmpegCommand, + [ + "-hide_banner", "-loglevel", "error", + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath, + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + this.ffmpeg.stderr?.on("data", (b: Buffer) => + this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"), + ); + this.ffmpeg.on("error", (err) => this.emit("error", err)); + + // 4. Render + write config.yml into the config dir. + const yml = renderConfigYml({ + deviceName: this.opts.deviceName, + bitrate: this.opts.bitrate, + fifoPath: this.fifoPath, + apiAddress: "0.0.0.0", + apiPort: this.apiPort, + callbackPort: DEFAULT_CALLBACK_PORT, + }); + writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8"); + + // 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/ + // stderr carry the interactive OAuth URL on first run — surface via logger. + const bin = findBinary(); + this.proc = spawn(bin, ["--config_dir", this.opts.configDir], { + stdio: ["ignore", "pipe", "pipe"], + }); + const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot"); + this.proc.stdout?.on("data", onLog); + this.proc.stderr?.on("data", onLog); + this.proc.on("error", (err) => this.emit("error", err)); + this.proc.on("exit", (code, signal) => { + this.ready = false; + this.log.warn({ code, signal }, "go-librespot exited"); + }); + + // 6. REST client, then poll GET / until the HTTP server answers. + const baseUrl = `http://127.0.0.1:${this.apiPort}`; + this.rest = this.deps.makeRest + ? this.deps.makeRest(baseUrl) + : new GoLibrespotRestClient(baseUrl); + await this.waitUntilReady(); + + // 7. Connect the WebSocket event stream and wire event mapping. + const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`; + this.events = this.deps.makeEvents + ? this.deps.makeEvents(wsUrl) + : new GoLibrespotEventClient(wsUrl); + this.wireEvents(this.events); + this.events.start(); + + this.ready = true; + this.emit("ready"); + } + + private async waitUntilReady(): Promise { + const sleep = this.deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const interval = this.deps.pollIntervalMs ?? 200; + const timeout = this.deps.pollTimeoutMs ?? 15_000; + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (this.rest && (await this.rest.ping())) return; + await sleep(interval); + } + throw new Error("go-librespot API did not become ready within timeout"); + } + + private wireEvents(ev: GoLibrespotEventClient): void { + ev.on("metadata", (d: any) => { + const np: SpotifyNowPlaying = { + uri: typeof d?.uri === "string" ? d.uri : "", + name: typeof d?.name === "string" ? d.name : "", + artist: Array.isArray(d?.artist_names) ? d.artist_names.join(", ") : "", + album: typeof d?.album_name === "string" ? d.album_name : "", + coverUrl: typeof d?.album_cover_url === "string" ? d.album_cover_url : "", + durationMs: typeof d?.duration === "number" ? d.duration : 0, + }; + if (typeof d?.position === "number") this.positionMs = d.position; + this.emit("metadata", np); + }); + ev.on("seek", (d: any) => { + if (typeof d?.position === "number") this.positionMs = d.position; + }); + ev.on("not_playing", (d: any) => { + const e: SpotifyTrackEndedEvent = { uri: typeof d?.uri === "string" ? d.uri : "", reason: "ended" }; + this.emit("trackEnded", e); + }); + ev.on("stopped", (d: any) => { + const e: SpotifyTrackEndedEvent = { uri: typeof d?.uri === "string" ? d.uri : "", reason: "stopped" }; + this.emit("trackEnded", e); + }); + } + + isReady(): boolean { + return this.ready; + } + + async playTrack(uri: string): Promise { + if (!this.rest) throw new Error("go-librespot backend not started"); + await this.rest.playTrack(uri); + } + + async pause(): Promise { + if (this.rest) await this.rest.pause(); + } + + async resume(): Promise { + if (this.rest) await this.rest.resume(); + } + + async seek(ms: number): Promise { + if (this.rest) await this.rest.seek(ms); + this.positionMs = ms; + } + + getPcmStream(): Readable { + const out = this.ffmpeg?.stdout; + if (!out) throw new Error("PCM stream unavailable (go-librespot backend not started)"); + return out; + } + + getPositionMs(): number { + return this.positionMs; + } + + stop(): void { + this.ready = false; + try { + this.events?.stop(); + } catch { + /* ignore */ + } + this.events = null; + this.rest = null; + if (this.proc) { + try { + this.proc.kill("SIGTERM"); + } catch { + /* ignore */ + } + this.proc = null; + } + if (this.ffmpeg) { + try { + this.ffmpeg.kill("SIGTERM"); + } catch { + /* ignore */ + } + this.ffmpeg = null; + } + const existsSync = this.deps.existsSync ?? realExistsSync; + const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync; + try { + if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath); + } catch { + /* ignore */ + } + } +} From 641da086e5b9572fec41760494c41353b5dd6d69 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 21:04:42 +0800 Subject: [PATCH 15/59] fix(spotify): GoLibrespotBackend start() cleanup on failure + unhandled-error guard Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/go-librespot.test.ts | 40 ++++++++ src/music/spotify/go-librespot.ts | 133 +++++++++++++++---------- 2 files changed, 118 insertions(+), 55 deletions(-) diff --git a/src/music/spotify/go-librespot.test.ts b/src/music/spotify/go-librespot.test.ts index aaa6ae1..0d198d0 100644 --- a/src/music/spotify/go-librespot.test.ts +++ b/src/music/spotify/go-librespot.test.ts @@ -219,3 +219,43 @@ describe("GoLibrespotBackend.stop", () => { expect(h.backend.isReady()).toBe(false); }); }); + +describe("GoLibrespotBackend.start failure cleanup", () => { + it("tears down ffmpeg, go-librespot, and the FIFO when readiness polling never succeeds", async () => { + const h = makeHarness(); + // ping() never returns true → waitUntilReady() times out → start() rejects + // AFTER both processes were spawned and the FIFO was created. + h.rest.ping.mockResolvedValue(false); + // FIFO present so the cleanup path unlinks it. + h.existsSync.mockReturnValue(true); + + await expect(h.backend.start()).rejects.toThrow(/did not become ready/); + + expect(h.ffmpegChild.kill).toHaveBeenCalled(); // ffmpeg killed on failed startup + expect(h.gliChild.kill).toHaveBeenCalled(); // go-librespot killed on failed startup + expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo"); // FIFO removed + expect(h.backend.isReady()).toBe(false); + }); +}); + +describe("GoLibrespotBackend child-process error handling", () => { + it("does not throw when a child 'error' is emitted with no backend 'error' listener attached", async () => { + const h = makeHarness(); + await h.backend.start(); + // No "error" listener on the backend: an unhandled 'error' event would crash + // Node, so the backend must swallow+log it instead of re-emitting. + expect(h.backend.listenerCount("error")).toBe(0); + expect(() => h.ffmpegChild.emit("error", new Error("ffmpeg boom"))).not.toThrow(); + expect(() => h.gliChild.emit("error", new Error("gli boom"))).not.toThrow(); + }); + + it("re-emits a child 'error' to an attached backend 'error' listener", async () => { + const h = makeHarness(); + await h.backend.start(); + const onErr = vi.fn(); + h.backend.on("error", onErr); + const err = new Error("ffmpeg boom"); + h.ffmpegChild.emit("error", err); + expect(onErr).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/music/spotify/go-librespot.ts b/src/music/spotify/go-librespot.ts index c2f35df..cdc1f0c 100644 --- a/src/music/spotify/go-librespot.ts +++ b/src/music/spotify/go-librespot.ts @@ -105,66 +105,89 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath); execFileSync("mkfifo", [this.fifoPath]); - // 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before - // go-librespot (the writer) starts pushing raw 44.1k s16le into it. - // Opening the FIFO for writing before a reader exists errors with ENXIO. - this.ffmpeg = spawn( - ffmpegCommand, - [ - "-hide_banner", "-loglevel", "error", - "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath, - "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - this.ffmpeg.stderr?.on("data", (b: Buffer) => - this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"), - ); - this.ffmpeg.on("error", (err) => this.emit("error", err)); + // Everything past this point spawns processes / opens sockets. If any step + // throws (e.g. the readiness poll times out), tear down whatever was already + // created via stop() (kill ffmpeg + go-librespot, close WS, remove FIFO) so + // we don't leak child processes or leave the FIFO on disk, then rethrow. + try { + // 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before + // go-librespot (the writer) starts pushing raw 44.1k s16le into it. + // Opening the FIFO for writing before a reader exists errors with ENXIO. + this.ffmpeg = spawn( + ffmpegCommand, + [ + "-hide_banner", "-loglevel", "error", + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath, + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + this.ffmpeg.stderr?.on("data", (b: Buffer) => + this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"), + ); + this.ffmpeg.on("error", (err) => this.emitError(err)); - // 4. Render + write config.yml into the config dir. - const yml = renderConfigYml({ - deviceName: this.opts.deviceName, - bitrate: this.opts.bitrate, - fifoPath: this.fifoPath, - apiAddress: "0.0.0.0", - apiPort: this.apiPort, - callbackPort: DEFAULT_CALLBACK_PORT, - }); - writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8"); + // 4. Render + write config.yml into the config dir. + const yml = renderConfigYml({ + deviceName: this.opts.deviceName, + bitrate: this.opts.bitrate, + fifoPath: this.fifoPath, + apiAddress: "0.0.0.0", + apiPort: this.apiPort, + callbackPort: DEFAULT_CALLBACK_PORT, + }); + writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8"); - // 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/ - // stderr carry the interactive OAuth URL on first run — surface via logger. - const bin = findBinary(); - this.proc = spawn(bin, ["--config_dir", this.opts.configDir], { - stdio: ["ignore", "pipe", "pipe"], - }); - const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot"); - this.proc.stdout?.on("data", onLog); - this.proc.stderr?.on("data", onLog); - this.proc.on("error", (err) => this.emit("error", err)); - this.proc.on("exit", (code, signal) => { - this.ready = false; - this.log.warn({ code, signal }, "go-librespot exited"); - }); + // 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/ + // stderr carry the interactive OAuth URL on first run — surface via logger. + const bin = findBinary(); + this.proc = spawn(bin, ["--config_dir", this.opts.configDir], { + stdio: ["ignore", "pipe", "pipe"], + }); + const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot"); + this.proc.stdout?.on("data", onLog); + this.proc.stderr?.on("data", onLog); + this.proc.on("error", (err) => this.emitError(err)); + this.proc.on("exit", (code, signal) => { + this.ready = false; + this.log.warn({ code, signal }, "go-librespot exited"); + }); - // 6. REST client, then poll GET / until the HTTP server answers. - const baseUrl = `http://127.0.0.1:${this.apiPort}`; - this.rest = this.deps.makeRest - ? this.deps.makeRest(baseUrl) - : new GoLibrespotRestClient(baseUrl); - await this.waitUntilReady(); + // 6. REST client, then poll GET / until the HTTP server answers. + const baseUrl = `http://127.0.0.1:${this.apiPort}`; + this.rest = this.deps.makeRest + ? this.deps.makeRest(baseUrl) + : new GoLibrespotRestClient(baseUrl); + await this.waitUntilReady(); - // 7. Connect the WebSocket event stream and wire event mapping. - const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`; - this.events = this.deps.makeEvents - ? this.deps.makeEvents(wsUrl) - : new GoLibrespotEventClient(wsUrl); - this.wireEvents(this.events); - this.events.start(); + // 7. Connect the WebSocket event stream and wire event mapping. + const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`; + this.events = this.deps.makeEvents + ? this.deps.makeEvents(wsUrl) + : new GoLibrespotEventClient(wsUrl); + this.wireEvents(this.events); + this.events.start(); - this.ready = true; - this.emit("ready"); + this.ready = true; + this.emit("ready"); + } catch (e) { + this.stop(); + throw e; + } + } + + /** + * Re-emit a child-process "error" only when a consumer is listening; Node + * throws on an unhandled "error" event (can crash the process), so with no + * listener we log via the injected logger instead. Mirrors the WS client's + * listenerCount("error") gate in go-librespot-api.ts. + */ + private emitError(err: unknown): void { + if (this.listenerCount("error") > 0) { + this.emit("error", err); + } else { + this.log.error({ err }, "go-librespot backend error (no listener)"); + } } private async waitUntilReady(): Promise { From 6debe23034a78a8fceb558d58c2cdc43b1239cc0 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 21:14:15 +0800 Subject: [PATCH 16/59] feat(audio): add external-PCM mode (playPcmStream) for Spotify sidecar Adds AudioPlayer.playPcmStream(readable, {onExternalEnd}) that feeds a long-lived external 48kHz/s16le/stereo Readable into the existing pcmBuffer + 20ms frame loop + Opus encoder without spawning a per-URL ffmpeg. Reuses the same high/low-water backpressure (pausing/resuming the Readable), suppresses the underrun trackEnd drain/stall branches while external (emitting a silence frame to keep the 20ms timeline), tears down externalMode in stop() by DETACHING the shared readable (remove our data/end/error listeners + pause, never destroy the sidecar stream) and clearing onExternalEnd, and makes seek() a local no-op in external mode. The url play() path and all exported pure functions are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/player.test.ts | 193 ++++++++++++++++++++++++++++++++++++++- src/audio/player.ts | 170 ++++++++++++++++++++++++++++++++-- 2 files changed, 355 insertions(+), 8 deletions(-) diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index 360e510..fe0cb3d 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest"; import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js"; +import { Readable } from "node:stream"; +import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor, AudioPlayer } from "./player.js"; +import type { Logger } from "../logger.js"; function getHeadersArg(args: string[]): string { const idx = args.indexOf("-headers"); @@ -200,3 +202,192 @@ describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => { expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false); }); }); + +// Minimal stub: AudioPlayer only calls debug/info/warn/error; child() returns self. +const silentLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + fatal() {}, + trace() {}, + child() { + return silentLogger; + }, +} as unknown as Logger; + +// A readable we fully control: no underlying source; we push PCM manually and +// keep it open (never push(null)) to model the long-lived go-librespot sidecar. +function openPcmReadable(): Readable { + return new Readable({ read() {} }); +} + +const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +const FRAME_BYTES = 3840; // PCM_FRAME_BYTES: 960 samples * 2ch * 2 bytes @48k s16le + +describe("AudioPlayer external-PCM mode (playPcmStream)", () => { + it("emits Opus 'frame' events from the external PCM stream without spawning ffmpeg", async () => { + const player = new AudioPlayer(silentLogger); + const frames: Buffer[] = []; + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 10)); // ~10 frames of PCM + + await wait(150); // ~7 frame ticks at 20ms + + expect(player.getState()).toBe("playing"); + expect(frames.length).toBeGreaterThan(0); + expect(Buffer.isBuffer(frames[0])).toBe(true); + player.stop(); + }); + + it("does NOT emit 'trackEnd' on underrun while external (stream stays open)", async () => { + const player = new AudioPlayer(silentLogger); + let ended = 0; + const frames: Buffer[] = []; + player.on("trackEnd", () => ended++); + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 2)); // only 2 frames, then underrun + + await wait(200); // long after those 2 frames have drained + + // In the url path, ffmpeg===null + empty buffer would fire trackEnd; here it must not. + expect(ended).toBe(0); + // Silence frames keep the 20ms timeline alive -> more than the 2 fed frames emitted. + expect(frames.length).toBeGreaterThan(2); + expect(player.getState()).toBe("playing"); + player.stop(); + }); + + // CORRECTION C2 (c): stop() DETACHES the shared readable — it must NOT be destroyed + // (destroying the sidecar's long-lived ffmpeg stdout would kill it for every future + // track). The sessionId bump + listener removal fence stale PCM out of pcmBuffer. + it("stop() detaches external mode without destroying the readable, and fences via sessionId", async () => { + const player = new AudioPlayer(silentLogger); + const frames: Buffer[] = []; + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + expect(stream.listenerCount("data")).toBe(1); + stream.push(Buffer.alloc(FRAME_BYTES * 5)); + await wait(80); + + player.stop(); + expect(player.getState()).toBe("idle"); + // C2: the shared sidecar stream must NOT be destroyed by teardown. + expect(stream.destroyed).toBe(false); + // Player's listeners are removed on detach (data/end/error). + expect(stream.listenerCount("data")).toBe(0); + expect(stream.listenerCount("end")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + + const countAtStop = frames.length; + // sessionId fence + detached listeners: PCM pushed after stop must not + // resurrect the timeline or re-feed pcmBuffer. + stream.push(Buffer.alloc(FRAME_BYTES * 5)); + await wait(80); + expect(frames.length).toBe(countAtStop); + }); + + // CORRECTION C2 (a): a gapless track change is driven by the sidecar pushing LATER + // PCM over the SAME already-attached stream. The player must NOT detach/re-attach + // (no second playPcmStream) — one persistent data listener serves every track. + it("(C2-a) feeds a later chunk over the SAME single attachment — gapless track change, no re-attach", async () => { + const player = new AudioPlayer(silentLogger); + const frames: Buffer[] = []; + player.on("frame", (f) => frames.push(f)); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + expect(stream.listenerCount("data")).toBe(1); // attached exactly once + + stream.push(Buffer.alloc(FRAME_BYTES * 4)); // "track 1" PCM + await wait(120); + const afterFirst = frames.length; + expect(afterFirst).toBeGreaterThan(0); + + stream.push(Buffer.alloc(FRAME_BYTES * 4)); // sidecar seamlessly rolls into "track 2" + await wait(120); + expect(frames.length).toBeGreaterThan(afterFirst); + + // Still exactly ONE listener — no detach/re-attach across the handoff. + expect(stream.listenerCount("data")).toBe(1); + expect(player.getState()).toBe("playing"); + player.stop(); + }); + + // CORRECTION C2 (b): a second playPcmStream detaches the first (NOT destroyed, and it + // stops feeding pcmBuffer) and attaches the second. + it("(C2-b) a second playPcmStream detaches the first (not destroyed, stops feeding) and attaches the second", async () => { + const player = new AudioPlayer(silentLogger); + const frames: Buffer[] = []; + player.on("frame", (f) => frames.push(f)); + + const first = openPcmReadable(); + player.playPcmStream(first, {}); + first.push(Buffer.alloc(FRAME_BYTES * 4)); + await wait(120); + expect(frames.length).toBeGreaterThan(0); + expect(first.listenerCount("data")).toBe(1); + + const second = openPcmReadable(); + player.playPcmStream(second, {}); // fences + detaches `first`, attaches `second` + + // C2: `first` is DETACHED, not destroyed. + expect(first.destroyed).toBe(false); + // `first` no longer feeds pcmBuffer — its data listener was removed. + expect(first.listenerCount("data")).toBe(0); + // `second` is now the attached source. + expect(second.listenerCount("data")).toBe(1); + expect(player.getState()).toBe("playing"); + player.stop(); + }); + + it("fires onExternalEnd when the readable ends (drives controller-based advance)", async () => { + const player = new AudioPlayer(silentLogger); + let endedCb = 0; + + const stream = openPcmReadable(); + player.playPcmStream(stream, { onExternalEnd: () => endedCb++ }); + stream.push(Buffer.alloc(FRAME_BYTES)); + await wait(40); + stream.push(null); // end-of-stream + await wait(40); + + expect(endedCb).toBe(1); + player.stop(); + }); + + it("seek() is a local no-op in external mode (never respawns ffmpeg on a spotify sentinel)", async () => { + const player = new AudioPlayer(silentLogger); + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 3)); + await wait(40); + + expect(() => player.seek(30)).not.toThrow(); + // Still external, still playing — no url-ffmpeg respawn, state unchanged. + expect(player.getState()).toBe("playing"); + player.stop(); + }); + + it("pause()/resume() still gate local emission in external mode (unchanged semantics)", async () => { + const player = new AudioPlayer(silentLogger); + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + stream.push(Buffer.alloc(FRAME_BYTES * 3)); + await wait(40); + + player.pause(); + expect(player.getState()).toBe("paused"); + player.resume(); + expect(player.getState()).toBe("playing"); + player.stop(); + }); +}); diff --git a/src/audio/player.ts b/src/audio/player.ts index b34b819..33ba356 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -5,6 +5,7 @@ import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js"; +import type { Readable } from "node:stream"; import type { Logger } from "../logger.js"; const require = createRequire(import.meta.url); @@ -187,6 +188,22 @@ export class AudioPlayer extends EventEmitter { private static readonly MAX_STALL_ATTEMPTS = 3000; private currentSongDuration = 0; // 当前歌曲总时长(秒) + // --- External PCM mode (Stage 2: go-librespot Spotify sidecar) --- + // When true, PCM arrives from a long-lived external Readable instead of a + // per-URL ffmpeg: this.ffmpeg stays null, and the underrun-driven trackEnd + // branches are suppressed (advance is driven by the controller, not EOF). + // + // CORRECTION C2: externalStream is the backend's LONG-LIVED, SHARED ffmpeg + // stdout (one stream reused across every track). Teardown must DETACH (remove + // the listeners we added + pause), never destroy it. We keep references to the + // exact handler functions so detach can removeListener precisely. + private externalMode = false; + private externalStream: Readable | null = null; + private onExternalEnd: (() => void) | null = null; + private externalDataHandler: ((chunk: Buffer) => void) | null = null; + private externalEndHandler: (() => void) | null = null; + private externalErrorHandler: ((err: Error) => void) | null = null; + constructor(logger: Logger) { super(); this.encoder = createOpusEncoder(); @@ -390,6 +407,108 @@ export class AudioPlayer extends EventEmitter { this.startFrameLoop(); } + /** + * External-PCM mode (Stage 2 go-librespot Spotify sidecar). + * + * Feeds an already-normalized 48kHz/s16le/stereo PCM Readable (the + * go-librespot FIFO -> ffmpeg output) straight into the existing pcmBuffer + + * 20ms frame loop + Opus encoder + "frame" emission, WITHOUT spawning a + * per-URL ffmpeg. The url play() path is left completely untouched. + * + * Track advance is NOT driven by buffer underrun here (the sidecar stream is + * continuous and never EOFs per song); the caller drives advance via the + * SpotifyController "trackEnded" WebSocket event. onExternalEnd fires only if + * the underlying readable itself ends or errors. + * + * CORRECTION C2: the readable is the backend's long-lived, SHARED ffmpeg + * stdout reused across every track — a gapless track change is just LATER PCM + * on this SAME already-attached stream (no re-attach). Teardown DETACHES + * (removes our listeners + pauses); it never destroys the shared stream. + */ + playPcmStream(readable: Readable, opts: { onExternalEnd?: () => void } = {}): void { + // 1. Fence current playback: stop() bumps sessionId, clears pcmBuffer, kills + // any ffmpeg, and DETACHES (never destroys) any prior external stream. + this.stop(); + + const currentSessionId = this.sessionId; + this.externalMode = true; + this.externalStream = readable; + this.onExternalEnd = opts.onExternalEnd ?? null; + // Leave this.ffmpeg = null; clear currentUrl so seek() cannot respawn ffmpeg. + this.currentUrl = ""; + this.seekOffset = 0; + this.framesPlayed = 0; + this.healthyFrames = 0; + this.ffmpegPaused = false; + this.spawnFailed = false; + this.emptyFrameAttempts = 0; + this.currentSongDuration = 0; + + // Same ingestion + high-water backpressure as the ffmpeg.stdout handler, + // but pausing the Readable instead of ffmpeg.stdout. sessionId-guarded so + // stale sidecar PCM can't leak into a new track after stop()/skip. Handler + // refs are stored so detach can remove exactly these listeners (C2). + const onData = (chunk: Buffer): void => { + if (this.sessionId !== currentSessionId) return; + this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]); + if ( + this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && + !this.ffmpegPaused && + this.externalStream === readable + ) { + readable.pause(); + this.ffmpegPaused = true; + } + }; + const onEnd = (): void => { + if (this.sessionId !== currentSessionId) return; + this.onExternalEnd?.(); + }; + const onError = (err: Error): void => { + if (this.sessionId !== currentSessionId) return; + this.logger.warn({ err }, "External PCM stream error"); + this.onExternalEnd?.(); + }; + + this.externalDataHandler = onData; + this.externalEndHandler = onEnd; + this.externalErrorHandler = onError; + + readable.on("data", onData); + readable.on("end", onEnd); + readable.on("error", onError); + + this.state = "playing"; + this.startFrameLoop(); + } + + /** + * CORRECTION C2: DETACH, never destroy. The external readable is the backend's + * long-lived, SHARED ffmpeg stdout reused across every track; destroying it + * would kill the sidecar pipe for all future tracks. Remove only the listeners + * WE added and pause the flow so stale PCM stops landing in pcmBuffer, then + * clear the external-mode state. + */ + private detachExternalStream(): void { + const stream = this.externalStream; + if (stream) { + if (this.externalDataHandler) stream.off("data", this.externalDataHandler); + if (this.externalEndHandler) stream.off("end", this.externalEndHandler); + if (this.externalErrorHandler) stream.off("error", this.externalErrorHandler); + try { + stream.pause(); + } catch { + /* best-effort: never destroy the shared sidecar stream */ + } + } + this.externalDataHandler = null; + this.externalEndHandler = null; + this.externalErrorHandler = null; + this.externalStream = null; + this.externalMode = false; + this.onExternalEnd = null; + } + stop(): void { // 3. 递增 ID 是最有效的逻辑“隔离墙” this.sessionId++; @@ -419,6 +538,11 @@ export class AudioPlayer extends EventEmitter { this.currentTempDir = null; } + // CORRECTION C2: tear down external mode by DETACHING (remove our listeners + + // pause) — never destroy the shared, long-lived sidecar stream. The + // sessionId++ above already fences the external data/end/error handlers. + this.detachExternalStream(); + this.ffmpegPaused = false; this.spawnFailed = false; this.state = "idle"; @@ -480,7 +604,10 @@ export class AudioPlayer extends EventEmitter { ? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒 : true; // 未知时长时保守处理 - if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) { + // External mode: the sidecar PCM stream is continuous and never EOFs per + // song; a transient underrun must NOT end the track (advance is driven by + // the controller). Skip BOTH drain/stall branches while externalMode. + if (!this.externalMode && this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) { this.emptyFrameAttempts++; // End the track when FFmpeg has gone silent: quickly if we're near the @@ -526,7 +653,7 @@ export class AudioPlayer extends EventEmitter { this.emptyFrameAttempts = 0; } - if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { + if (!this.externalMode && !this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { this.frameLoopRunning = false; if (this.state !== "idle") { this.state = "idle"; @@ -542,13 +669,24 @@ export class AudioPlayer extends EventEmitter { } private sendNextFrame(): void { - if (this.pcmBuffer.length < PCM_FRAME_BYTES) return; + if (this.pcmBuffer.length < PCM_FRAME_BYTES) { + // External mode: the sidecar PCM stream is long-lived and must NOT end on + // a transient underrun. Emit an encoded silence frame so the 20ms voice + // timeline stays continuous instead of returning (which would desync TS). + if (this.externalMode) this.emitSilenceFrame(); + return; + } const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES); this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES); - if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) { - this.ffmpeg.stdout.resume(); - this.ffmpegPaused = false; + if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER) { + if (this.externalMode && this.externalStream) { + this.externalStream.resume(); + this.ffmpegPaused = false; + } else if (this.ffmpeg?.stdout) { + this.ffmpeg.stdout.resume(); + this.ffmpegPaused = false; + } } try { @@ -566,6 +704,16 @@ export class AudioPlayer extends EventEmitter { } } + private emitSilenceFrame(): void { + try { + const opusFrame = this.encoder.encode(Buffer.alloc(PCM_FRAME_BYTES)); + this.emit("frame", opusFrame); + this.framesPlayed++; + } catch (err) { + this.emit("error", err as Error); + } + } + private applyVolume(pcm: Buffer): Buffer { const factor = volumeToFactor(this.volume); // factor === 1 only at volume 100; skip the per-sample loop at full loudness. @@ -578,8 +726,16 @@ export class AudioPlayer extends EventEmitter { return out; } + // NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based + // (framesPlayed includes silence frames emitted on underrun) and therefore + // only APPROXIMATE — the authoritative position is the controller's live + // status.track.position. This approximation is acceptable for Spotify. getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; } - seek(seconds: number): void { + seek(seconds: number): void { + // External (Spotify sidecar) mode: local seek is a no-op. Respawning ffmpeg + // on the spotify: sentinel would collide with the continuous PCM source; + // transport is delegated to the SpotifyController by the caller (Task 7). + if (this.externalMode) return; if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) { this.play(this.currentUrl, seconds, this.currentSongDuration); } From 3a9504500b855fd6b02a45e800aa3bcb8e7707d7 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 21:22:12 +0800 Subject: [PATCH 17/59] feat(spotify): SpotifyController backend lifecycle, gating, and event re-emission Per-bot orchestrator: isAvailable() gates on config.enabled + platform + binary presence; ensureStarted() starts the backend once (idempotent, retries on failure); playTrack/pause/resume/seek/stop delegate; getPcmStream() proxies the backend PCM; re-emits backend trackEnded/metadata. backendFactory injected for tests (fake backend, no real binary/network). Correction C3: the controller does not re-emit a raw "error" event (Node's EventEmitter throws on an unhandled "error"); it logs the backend error and marks itself not-ready so the next ensureStarted() relaunches. getPcmStream() returns the backend's single persistent stream (no per-attach PassThrough). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/controller.test.ts | 335 +++++++++++++++++++++++++++ src/music/spotify/controller.ts | 162 +++++++++++++ 2 files changed, 497 insertions(+) create mode 100644 src/music/spotify/controller.test.ts create mode 100644 src/music/spotify/controller.ts diff --git a/src/music/spotify/controller.test.ts b/src/music/spotify/controller.test.ts new file mode 100644 index 0000000..901fc1c --- /dev/null +++ b/src/music/spotify/controller.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { Logger } from "pino"; +import type { SpotifyConfig } from "../../data/config.js"; +import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, +} from "./backend.js"; + +// Controllable, hoisted so the vi.mock factory can close over it. +const bin = vi.hoisted(() => ({ supported: true, path: "" })); +vi.mock("./binary.js", () => ({ + isGoLibrespotSupported: () => bin.supported, + findGoLibrespot: () => bin.path, + resetGoLibrespotBinaryCache: () => {}, + checkGoLibrespotAvailable: async () => bin.supported && !!bin.path, +})); + +// Import AFTER vi.mock so the mocked binary module is used. +const { SpotifyController } = await import("./controller.js"); + +const existingBin = join(tmpdir(), `tsmb-golibrespot-${process.pid}`); +const missingBin = join(tmpdir(), `tsmb-golibrespot-missing-${process.pid}`); + +beforeAll(() => { + writeFileSync(existingBin, "#!/bin/sh\n"); +}); +afterAll(() => { + try { + rmSync(existingBin); + } catch { + /* ignore */ + } +}); +beforeEach(() => { + bin.supported = true; + bin.path = existingBin; +}); + +class FakeBackend extends EventEmitter implements SpotifyAudioBackend { + startCalls = 0; + stopCalls = 0; + playCalls: string[] = []; + pauseCalls = 0; + resumeCalls = 0; + seekCalls: number[] = []; + ready = false; + startShouldReject = false; + playShouldReject = false; + readonly pcm = Readable.from([Buffer.alloc(0)]); + + async start(): Promise { + this.startCalls++; + if (this.startShouldReject) throw new Error("start boom"); + this.ready = true; + } + stop(): void { + this.stopCalls++; + this.ready = false; + } + isReady(): boolean { + return this.ready; + } + async playTrack(uri: string): Promise { + this.playCalls.push(uri); + if (this.playShouldReject) throw new Error("play boom"); + } + async pause(): Promise { + this.pauseCalls++; + } + async resume(): Promise { + this.resumeCalls++; + } + async seek(ms: number): Promise { + this.seekCalls.push(ms); + } + getPcmStream(): Readable { + return this.pcm; + } + getPositionMs(): number { + return 0; + } +} + +const silentLogger = { + info() {}, + error() {}, + warn() {}, + debug() {}, + trace() {}, + fatal() {}, + child() { + return silentLogger; + }, +} as unknown as Logger; + +function cfg(over: Partial = {}): SpotifyConfig { + return { + enabled: true, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + ...over, + }; +} + +function makeCtrl(over: { + config?: Partial; + backendFactory?: () => SpotifyAudioBackend; +} = {}) { + const be = new FakeBackend(); + const ctrl = new SpotifyController({ + config: cfg(over.config), + workDir: "/tmp/work", + configDir: "/tmp/cfg", + logger: silentLogger, + backendFactory: over.backendFactory ?? (() => be), + }); + return { ctrl, be }; +} + +describe("SpotifyController.isAvailable", () => { + it("true when enabled + supported + binary present", () => { + const { ctrl } = makeCtrl(); + expect(ctrl.isAvailable()).toBe(true); + }); + it("false when config disabled", () => { + const { ctrl } = makeCtrl({ config: { enabled: false } }); + expect(ctrl.isAvailable()).toBe(false); + }); + it("false when platform unsupported", () => { + bin.supported = false; + const { ctrl } = makeCtrl(); + expect(ctrl.isAvailable()).toBe(false); + }); + it("false when binary file is absent", () => { + bin.path = missingBin; + const { ctrl } = makeCtrl(); + expect(ctrl.isAvailable()).toBe(false); + }); +}); + +describe("SpotifyController.ensureStarted", () => { + it("starts the backend exactly once across repeated calls", async () => { + let built = 0; + const be = new FakeBackend(); + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return be; + }, + }); + expect(await ctrl.ensureStarted()).toBe(true); + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(1); + expect(be.startCalls).toBe(1); + }); + + it("is idempotent under concurrent calls (single start)", async () => { + let built = 0; + const be = new FakeBackend(); + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return be; + }, + }); + const [a, b] = await Promise.all([ctrl.ensureStarted(), ctrl.ensureStarted()]); + expect(a).toBe(true); + expect(b).toBe(true); + expect(built).toBe(1); + expect(be.startCalls).toBe(1); + }); + + it("returns false and does not build a backend when unavailable", async () => { + let built = 0; + const { ctrl } = makeCtrl({ + config: { enabled: false }, + backendFactory: () => { + built++; + return new FakeBackend(); + }, + }); + expect(await ctrl.ensureStarted()).toBe(false); + expect(built).toBe(0); + }); + + it("returns false when backend.start() throws, and allows a later retry", async () => { + const be = new FakeBackend(); + be.startShouldReject = true; + let built = 0; + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return be; + }, + }); + expect(await ctrl.ensureStarted()).toBe(false); + // start failure clears the cached promise so a subsequent call retries. + be.startShouldReject = false; + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(2); + expect(be.startCalls).toBe(2); + }); +}); + +describe("SpotifyController.playTrack", () => { + it("ensures started then delegates the uri, returning true", async () => { + const { ctrl, be } = makeCtrl(); + expect(await ctrl.playTrack("spotify:track:abc")).toBe(true); + expect(be.startCalls).toBe(1); + expect(be.playCalls).toEqual(["spotify:track:abc"]); + }); + it("returns false when the controller is unavailable", async () => { + const { ctrl, be } = makeCtrl({ config: { enabled: false } }); + expect(await ctrl.playTrack("spotify:track:abc")).toBe(false); + expect(be.playCalls).toEqual([]); + }); + it("returns false when backend.playTrack rejects", async () => { + const be = new FakeBackend(); + be.playShouldReject = true; + const { ctrl } = makeCtrl({ backendFactory: () => be }); + expect(await ctrl.playTrack("spotify:track:abc")).toBe(false); + }); +}); + +describe("SpotifyController transport delegation", () => { + it("pause/resume/seek forward to the backend after start", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + await ctrl.pause(); + await ctrl.resume(); + await ctrl.seek(4200); + expect(be.pauseCalls).toBe(1); + expect(be.resumeCalls).toBe(1); + expect(be.seekCalls).toEqual([4200]); + }); + it("pause/resume/seek are safe no-ops before start", async () => { + const { ctrl, be } = makeCtrl(); + await expect(ctrl.pause()).resolves.toBeUndefined(); + await expect(ctrl.resume()).resolves.toBeUndefined(); + await expect(ctrl.seek(10)).resolves.toBeUndefined(); + expect(be.pauseCalls).toBe(0); + }); + it("getPcmStream returns the backend stream", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + expect(ctrl.getPcmStream()).toBe(be.pcm); + }); + it("getPcmStream throws before the backend is started", () => { + const { ctrl } = makeCtrl(); + expect(() => ctrl.getPcmStream()).toThrow(); + }); +}); + +describe("SpotifyController event re-emission", () => { + it("re-emits backend trackEnded with the same payload", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + const got: SpotifyTrackEndedEvent[] = []; + ctrl.on("trackEnded", (e) => got.push(e)); + const evt: SpotifyTrackEndedEvent = { uri: "spotify:track:x", reason: "ended" }; + be.emit("trackEnded", evt); + expect(got).toEqual([evt]); + }); + it("re-emits backend metadata with the same payload", async () => { + const { ctrl, be } = makeCtrl(); + await ctrl.ensureStarted(); + const got: SpotifyNowPlaying[] = []; + ctrl.on("metadata", (m) => got.push(m)); + const meta: SpotifyNowPlaying = { + uri: "spotify:track:x", + name: "Song", + artist: "Artist", + album: "Album", + coverUrl: "http://img", + durationMs: 1000, + }; + be.emit("metadata", meta); + expect(got).toEqual([meta]); + }); +}); + +describe("SpotifyController backend error handling (C3)", () => { + it("does NOT throw on a backend 'error' with no controller listener, and marks not-ready", async () => { + const be1 = new FakeBackend(); + const be2 = new FakeBackend(); + const backends = [be1, be2]; + let built = 0; + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return backends.shift()!; + }, + }); + await ctrl.ensureStarted(); + expect(built).toBe(1); + // The controller itself has NO "error" listener. A raw re-emit would make + // Node throw here; the controller must swallow+log instead. + expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow(); + // Marked not-ready: a fresh ensureStarted relaunches a new backend. + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(2); + expect(be2.startCalls).toBe(1); + }); +}); + +describe("SpotifyController.stop", () => { + it("stops the backend and tears down state so a later start rebuilds", async () => { + const be1 = new FakeBackend(); + const be2 = new FakeBackend(); + const backends = [be1, be2]; + const { ctrl } = makeCtrl({ backendFactory: () => backends.shift()! }); + await ctrl.ensureStarted(); + ctrl.stop(); + expect(be1.stopCalls).toBe(1); + // After teardown getPcmStream is invalid again until re-started. + expect(() => ctrl.getPcmStream()).toThrow(); + // A fresh ensureStarted builds a new backend. + expect(await ctrl.ensureStarted()).toBe(true); + expect(be2.startCalls).toBe(1); + }); + it("stop before start is a safe no-op", () => { + const { ctrl, be } = makeCtrl(); + expect(() => ctrl.stop()).not.toThrow(); + expect(be.stopCalls).toBe(0); + }); +}); diff --git a/src/music/spotify/controller.ts b/src/music/spotify/controller.ts new file mode 100644 index 0000000..3005244 --- /dev/null +++ b/src/music/spotify/controller.ts @@ -0,0 +1,162 @@ +import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; +import type { Readable } from "node:stream"; +import type { Logger } from "pino"; +import type { SpotifyConfig } from "../../data/config.js"; +import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, +} from "./backend.js"; +import { isGoLibrespotSupported, findGoLibrespot } from "./binary.js"; +import { GoLibrespotBackend } from "./go-librespot.js"; + +export interface SpotifyControllerOptions { + config: SpotifyConfig; + workDir: string; + configDir: string; + logger: Logger; + /** Injected for tests; defaults to constructing a real GoLibrespotBackend. */ + backendFactory?: () => SpotifyAudioBackend; +} + +/** + * Per-bot orchestrator for the go-librespot Spotify sidecar. Owns backend + * lifecycle, gates on availability (config + platform + binary), delegates + * transport, and re-emits the backend's "trackEnded"/"metadata" events so + * BotInstance can advance the queue exactly as it does for the ffmpeg path. + * + * Correction C3: this controller does NOT re-emit a raw "error" event (Node's + * EventEmitter throws on an unhandled "error"). It subscribes to the backend's + * "error", logs it, and marks itself not-ready so the next ensureStarted() + * relaunches the backend. Only the safe "trackEnded"/"metadata" events are + * re-emitted. getPcmStream() proxies the backend's SINGLE persistent stream + * (no per-attach PassThrough) to pair with the AudioPlayer detach-not-destroy + * behaviour and BotInstance's no-re-attach on spotify->spotify transitions. + */ +export class SpotifyController extends EventEmitter { + private readonly config: SpotifyConfig; + private readonly workDir: string; + private readonly configDir: string; + private readonly logger: Logger; + private readonly backendFactory: () => SpotifyAudioBackend; + + private backend: SpotifyAudioBackend | null = null; + private started = false; + private startPromise: Promise | null = null; + + constructor(o: SpotifyControllerOptions) { + super(); + this.config = o.config; + this.workDir = o.workDir; + this.configDir = o.configDir; + this.logger = o.logger; + this.backendFactory = + o.backendFactory ?? + (() => + new GoLibrespotBackend({ + deviceName: this.config.deviceName, + bitrate: this.config.bitrate, + workDir: this.workDir, + configDir: this.configDir, + logger: this.logger, + })); + } + + /** enabled in config AND on a supported OS AND the binary is present on disk. */ + isAvailable(): boolean { + return ( + this.config.enabled && + isGoLibrespotSupported() && + existsSync(findGoLibrespot()) + ); + } + + /** + * Idempotently start the backend. Returns false (without building a backend) + * when unavailable, so callers fall back to the Stage-1 sentinel message. + * A failed start clears the cached promise so a later call can retry. + */ + async ensureStarted(): Promise { + if (!this.isAvailable()) return false; + if (this.started) return true; + if (this.startPromise) return this.startPromise; + + this.startPromise = (async () => { + try { + const backend = this.backendFactory(); + backend.on("trackEnded", (e: SpotifyTrackEndedEvent) => + this.emit("trackEnded", e), + ); + backend.on("metadata", (m: SpotifyNowPlaying) => + this.emit("metadata", m), + ); + // C3: do NOT re-emit "error". Log and mark not-ready so the next + // ensureStarted() relaunches a fresh backend. + backend.on("error", (err?: unknown) => this.handleBackendError(err)); + await backend.start(); + this.backend = backend; + this.started = true; + return true; + } catch (err) { + this.logger.error({ err }, "Spotify backend failed to start"); + this.startPromise = null; + return false; + } + })(); + return this.startPromise; + } + + /** + * C3 backend-error handler. Never re-emits "error" (an unhandled "error" on + * an EventEmitter throws). Logs and marks the controller not-ready so the + * next ensureStarted() relaunches the backend. + */ + private handleBackendError(err: unknown): void { + this.logger.error({ err }, "Spotify backend error; marking not-ready"); + this.started = false; + this.startPromise = null; + } + + /** Ensure started, then play the spotify: URI. False on any failure. */ + async playTrack(uri: string): Promise { + const ok = await this.ensureStarted(); + if (!ok || !this.backend) return false; + try { + await this.backend.playTrack(uri); + return true; + } catch (err) { + this.logger.error({ err, uri }, "Spotify playTrack failed"); + return false; + } + } + + async pause(): Promise { + if (this.backend) await this.backend.pause(); + } + + async resume(): Promise { + if (this.backend) await this.backend.resume(); + } + + async seek(ms: number): Promise { + if (this.backend) await this.backend.seek(ms); + } + + getPcmStream(): Readable { + if (!this.backend) { + throw new Error("Spotify backend not started"); + } + return this.backend.getPcmStream(); + } + + /** Tear down the backend and reset lifecycle state (safe before start). */ + stop(): void { + if (this.backend) { + this.backend.stop(); + this.backend = null; + } + this.started = false; + this.startPromise = null; + } +} From 179e7c248a72e168b67d3217296912aa55ee3b02 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 21:30:13 +0800 Subject: [PATCH 18/59] fix(spotify): tear down errored backend in SpotifyController (no leak/cross-talk) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/controller.test.ts | 68 ++++++++++++++++++++++++++++ src/music/spotify/controller.ts | 19 ++++++++ 2 files changed, 87 insertions(+) diff --git a/src/music/spotify/controller.test.ts b/src/music/spotify/controller.test.ts index 901fc1c..dfe897e 100644 --- a/src/music/spotify/controller.test.ts +++ b/src/music/spotify/controller.test.ts @@ -310,6 +310,74 @@ describe("SpotifyController backend error handling (C3)", () => { expect(built).toBe(2); expect(be2.startCalls).toBe(1); }); + + it("tears down the errored backend (stop + detach + null) so it is not orphaned", async () => { + const be1 = new FakeBackend(); + const be2 = new FakeBackend(); + const backends = [be1, be2]; + let built = 0; + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return backends.shift()!; + }, + }); + await ctrl.ensureStarted(); + expect(built).toBe(1); + + // On "error" the controller must stop() the errored backend (cleans its + // ffmpeg/go-librespot children + FIFO) and detach ALL its listeners. + expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow(); + expect(be1.stopCalls).toBe(1); + expect(be1.listenerCount("error")).toBe(0); + expect(be1.listenerCount("trackEnded")).toBe(0); + expect(be1.listenerCount("metadata")).toBe(0); + + // Internal backend is null: a transport call no-ops (delegates to nothing) + // and getPcmStream throws until a rebuild. + await ctrl.pause(); + expect(be1.pauseCalls).toBe(0); + expect(() => ctrl.getPcmStream()).toThrow(); + + // A fresh ensureStarted builds a NEW backend instance. + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(2); + expect(be2.startCalls).toBe(1); + expect(ctrl.getPcmStream()).toBe(be2.pcm); + }); + + it("does not cross-talk: a later error from the ORPHANED backend leaves the healthy rebuilt controller ready", async () => { + const be1 = new FakeBackend(); + const be2 = new FakeBackend(); + const backends = [be1, be2]; + let built = 0; + const { ctrl } = makeCtrl({ + backendFactory: () => { + built++; + return backends.shift()!; + }, + }); + await ctrl.ensureStarted(); + + // First error tears down be1 and the controller rebuilds onto healthy be2. + be1.emit("error", new Error("sidecar boom")); + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(2); + await ctrl.pause(); + expect(be2.pauseCalls).toBe(1); + + // A SECOND error emitted by the OLD (orphaned) backend must NOT reach the + // controller: the healthy be2 stays owned, ready, and untouched. + be1.on("error", () => {}); // controller detached; re-arm to avoid unhandled throw + expect(() => be1.emit("error", new Error("orphan boom"))).not.toThrow(); + expect(be2.stopCalls).toBe(0); // healthy backend not torn down + // Still ready: ensureStarted returns true without rebuilding a third time. + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(2); + await ctrl.pause(); + expect(be2.pauseCalls).toBe(2); + expect(ctrl.getPcmStream()).toBe(be2.pcm); + }); }); describe("SpotifyController.stop", () => { diff --git a/src/music/spotify/controller.ts b/src/music/spotify/controller.ts index 3005244..f378cf2 100644 --- a/src/music/spotify/controller.ts +++ b/src/music/spotify/controller.ts @@ -114,6 +114,25 @@ export class SpotifyController extends EventEmitter { */ private handleBackendError(err: unknown): void { this.logger.error({ err }, "Spotify backend error; marking not-ready"); + // Tear down the errored backend BEFORE resetting flags so ensureStarted() + // does not orphan it: stop() cleans its ffmpeg/go-librespot children + FIFO, + // removeAllListeners() detaches its "error" handler so a later error from + // this now-orphaned backend cannot flip a healthy rebuilt controller + // back to not-ready (state cross-talk). Teardown must never mask the + // original error, so guard stop() which may throw. + try { + this.backend?.stop(); + } catch (stopErr) { + this.logger.error( + { err: stopErr }, + "Spotify backend stop() threw during error teardown", + ); + } + // SpotifyAudioBackend's type contract exposes on() but not + // removeAllListeners(); every concrete backend extends EventEmitter, so + // detach through it to drop this controller's listeners from the orphan. + (this.backend as unknown as EventEmitter | null)?.removeAllListeners(); + this.backend = null; this.started = false; this.startPromise = null; } From b5b3585e77dd4c80ff5c5085890168a98228d0e7 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 21:44:39 +0800 Subject: [PATCH 19/59] feat(spotify): orchestrate go-librespot backend from BotInstance (Stage 2 Task 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct one SpotifyController per bot (config.spotify + per-bot work/config dirs under DATA_DIR, threaded via BotManager + index). resolveAndPlay now routes spotify: sentinels through controller.ensureStarted/playTrack + player.playPcmStream (falling back to the Stage-1 message when unavailable), fences/pauses the sidecar on source transitions, advances via controller "trackEnded", and delegates pause/resume/stop transport. Correction C4: no re-attach on a spotify->spotify handoff (playPcmStream once across tracks, no player.stop() — playPcmStream fences the prior ffmpeg internally); occupancy auto-pause/resume + updateAutoPause + a new BotInstance.seek() (web seek route) also delegate to the sidecar. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.test.ts | 319 +++++++++++++++++++++++++++++++++++++++ src/bot/instance.ts | 159 +++++++++++++++++-- src/bot/manager.ts | 9 +- src/index.ts | 4 +- src/web/api/player.ts | 2 +- 5 files changed, 480 insertions(+), 13 deletions(-) diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 7011741..4d0dc3b 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -218,3 +218,322 @@ describe("BotInstance.getProviderFor — spotify routing", () => { expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify); }); }); + +// --- Spotify orchestration (Task 7 + Correction C4) ------------------------ +// These drive the REAL prototype methods on a hand-built ctx (the file's +// established `.call(ctx)` style) and assert the routing DECISIONS. Live audio +// is not testable here. C4 supersedes the brief where they conflict: switching +// a URL track -> spotify does NOT call player.stop() (playPcmStream fences the +// prior ffmpeg internally), and a spotify -> spotify handoff does NOT re-attach +// the persistent PCM stream (playPcmStream is called ONCE across both tracks). + +const resolveAndPlay = BotInstance.prototype.resolveAndPlay as ( + this: unknown, + song: any, +) => Promise; +const setupPlayerEvents = (BotInstance.prototype as any).setupPlayerEvents as ( + this: unknown, +) => void; +const cmdPause = (BotInstance.prototype as any).cmdPause as (this: unknown) => string; +const cmdResume = (BotInstance.prototype as any).cmdResume as (this: unknown) => string; +const cmdStop = (BotInstance.prototype as any).cmdStop as (this: unknown) => string; +const handleOccupancy = (BotInstance.prototype as any).handleOccupancy as ( + this: unknown, + userCount: number, +) => void; +const seek = (BotInstance.prototype as any).seek as (this: unknown, ms: number) => void; + +function makeController() { + return { + ensureStarted: vi.fn(async () => true), + playTrack: vi.fn(async () => true), + getPcmStream: vi.fn(() => ({ kind: "pcm" } as any)), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + stop: vi.fn(() => {}), + on: vi.fn(), + }; +} +function makePlayer() { + return { + play: vi.fn(), + stop: vi.fn(), + playPcmStream: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + seek: vi.fn(), + }; +} +function makeResolveCtx(opts: { + controller: ReturnType; + player: ReturnType; + url: string; + currentSourceIsSpotify?: boolean; +}) { + return { + connected: true, + config: {}, + id: "bot1", + voteSkipUsers: new Set(), + autoPaused: false, + currentSourceIsSpotify: opts.currentSourceIsSpotify ?? false, + effectiveDuration: undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + tsClient: { sendTextMessage: vi.fn(async () => {}) }, + database: { addPlayHistory: vi.fn() }, + spotifyController: opts.controller, + player: opts.player, + getProviderFor: vi.fn(() => ({ getSongUrl: async () => ({ url: opts.url }) })), + syncProfileToSong: vi.fn(async () => {}), + emit: vi.fn(), + } as any; +} +function spotifySong() { + return { + id: "abc", + name: "Song", + artist: "Artist", + album: "Album", + platform: "spotify", + coverUrl: "c", + duration: 200, + url: "", + }; +} + +describe("BotInstance.resolveAndPlay — Spotify routing (C4)", () => { + it("routes a spotify song to controller.playTrack + player.playPcmStream, not player.play", async () => { + const controller = makeController(); + const player = makePlayer(); + const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); + + const ok = await resolveAndPlay.call(ctx, spotifySong()); + + expect(ok).toBe(true); + expect(controller.ensureStarted).toHaveBeenCalledTimes(1); + expect(controller.playTrack).toHaveBeenCalledWith("spotify:track:abc"); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + expect(player.playPcmStream.mock.calls[0][0]).toEqual({ kind: "pcm" }); + expect(player.play).not.toHaveBeenCalled(); + // C4: playPcmStream fences the prior url-ffmpeg internally — no player.stop(). + expect(player.stop).not.toHaveBeenCalled(); + expect(ctx.currentSourceIsSpotify).toBe(true); + expect(ctx.database.addPlayHistory).toHaveBeenCalledTimes(1); + expect(ctx.emit).toHaveBeenCalledWith("stateChange"); + }); + + it("returns false + sends the Stage-1 fallback when the backend is unavailable", async () => { + const controller = makeController(); + controller.ensureStarted = vi.fn(async () => false); + const player = makePlayer(); + const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); + + const ok = await resolveAndPlay.call(ctx, spotifySong()); + + expect(ok).toBe(false); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); + expect(controller.playTrack).not.toHaveBeenCalled(); + expect(player.playPcmStream).not.toHaveBeenCalled(); + expect(player.play).not.toHaveBeenCalled(); + }); + + it("attaches the PCM stream once (no player.stop) when switching URL -> spotify", async () => { + const controller = makeController(); + const player = makePlayer(); + const ctx = makeResolveCtx({ + controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, + }); + + await resolveAndPlay.call(ctx, spotifySong()); + + // C4: NO player.stop() on the URL -> spotify transition. + expect(player.stop).not.toHaveBeenCalled(); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + }); + + it("does NOT re-attach the stream on a spotify -> spotify handoff (playPcmStream called once across two tracks)", async () => { + const controller = makeController(); + const player = makePlayer(); + const ctx = makeResolveCtx({ + controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, + }); + + // First spotify track: coming from a URL/idle source -> attach. + await resolveAndPlay.call(ctx, spotifySong()); + expect(ctx.currentSourceIsSpotify).toBe(true); + // Second spotify track: go-librespot changes tracks into the SAME FIFO. + await resolveAndPlay.call(ctx, spotifySong()); + + expect(player.playPcmStream).toHaveBeenCalledTimes(1); // NOT re-attached + expect(controller.playTrack).toHaveBeenCalledTimes(2); // both tracks played + expect(player.stop).not.toHaveBeenCalled(); + }); + + it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => { + const controller = makeController(); + const player = makePlayer(); + const song = { ...spotifySong(), platform: "netease" }; + const ctx = makeResolveCtx({ + controller, player, url: "http://cdn/x.mp3", currentSourceIsSpotify: true, + }); + + const ok = await resolveAndPlay.call(ctx, song); + + expect(ok).toBe(true); + expect(controller.pause).toHaveBeenCalledTimes(1); + expect(ctx.currentSourceIsSpotify).toBe(false); + expect(player.play).toHaveBeenCalledWith("http://cdn/x.mp3", 0, 200); + expect(player.playPcmStream).not.toHaveBeenCalled(); + }); +}); + +describe("BotInstance.setupPlayerEvents — controller trackEnded wiring", () => { + function makeEventCtx(currentPlatform: string) { + return { + spotifyController: { on: vi.fn() }, + player: { on: vi.fn() }, + queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, + logger: { debug: vi.fn(), error: vi.fn() }, + playNext: vi.fn(async () => true), + } as any; + } + function trackEndedHandler(ctx: any) { + const call = ctx.spotifyController.on.mock.calls.find( + (c: any[]) => c[0] === "trackEnded", + ); + expect(call).toBeDefined(); + return call[1] as (e: any) => void; + } + + it("advances via playNext when the current song is spotify", () => { + const ctx = makeEventCtx("spotify"); + setupPlayerEvents.call(ctx); + trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" }); + expect(ctx.playNext).toHaveBeenCalledTimes(1); + }); + + it("ignores controller trackEnded when the current song is not spotify", () => { + const ctx = makeEventCtx("netease"); + setupPlayerEvents.call(ctx); + trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" }); + expect(ctx.playNext).not.toHaveBeenCalled(); + }); +}); + +describe("BotInstance transport delegation — spotify current song", () => { + function makeCmdCtx(currentPlatform: string) { + return { + player: { pause: vi.fn(), resume: vi.fn(), stop: vi.fn() }, + spotifyController: { + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(() => {}), + }, + queue: { current: vi.fn(() => ({ platform: currentPlatform })), clear: vi.fn() }, + logger: { warn: vi.fn() }, + emit: vi.fn(), + autoPaused: true, + currentSourceIsSpotify: true, + sweepLocalAudio: vi.fn(), + disableFmMode: vi.fn(), + profileManager: { onSongChange: vi.fn(async () => {}) }, + } as any; + } + + it("cmdPause delegates to controller.pause when current is spotify", () => { + const ctx = makeCmdCtx("spotify"); + cmdPause.call(ctx); + expect(ctx.player.pause).toHaveBeenCalled(); + expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1); + }); + + it("cmdResume delegates to controller.resume when current is spotify", () => { + const ctx = makeCmdCtx("spotify"); + cmdResume.call(ctx); + expect(ctx.player.resume).toHaveBeenCalled(); + expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1); + }); + + it("cmdStop stops the sidecar + player and clears the spotify flag", () => { + const ctx = makeCmdCtx("spotify"); + cmdStop.call(ctx); + expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1); + expect(ctx.player.stop).toHaveBeenCalledTimes(1); + expect(ctx.queue.clear).toHaveBeenCalledTimes(1); + expect(ctx.currentSourceIsSpotify).toBe(false); + }); + + it("does NOT touch the controller when current is not spotify", () => { + const ctx = makeCmdCtx("netease"); + cmdPause.call(ctx); + cmdResume.call(ctx); + expect(ctx.spotifyController.pause).not.toHaveBeenCalled(); + expect(ctx.spotifyController.resume).not.toHaveBeenCalled(); + }); +}); + +describe("BotInstance.handleOccupancy — spotify auto-pause delegation (C4)", () => { + function makeOccupancyCtx(currentPlatform: string, state: string) { + return { + player: { getState: () => state, pause: vi.fn(), resume: vi.fn() }, + spotifyController: { pause: vi.fn(async () => {}), resume: vi.fn(async () => {}) }, + queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, + config: { autoPauseOnEmpty: true }, + autoPaused: false, + logger: { warn: vi.fn() }, + emit: vi.fn(), + _scheduleIdleCheck: vi.fn(), + _cancelIdleTimer: vi.fn(), + } as any; + } + + it("delegates pause to the controller when auto-pausing a spotify track (empty channel)", () => { + const ctx = makeOccupancyCtx("spotify", "playing"); + handleOccupancy.call(ctx, 0); + expect(ctx.player.pause).toHaveBeenCalledTimes(1); + expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1); + expect(ctx.autoPaused).toBe(true); + }); + + it("delegates resume to the controller when a listener returns to a spotify track", () => { + const ctx = makeOccupancyCtx("spotify", "paused"); + ctx.autoPaused = true; + handleOccupancy.call(ctx, 1); + expect(ctx.player.resume).toHaveBeenCalledTimes(1); + expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1); + expect(ctx.autoPaused).toBe(false); + }); + + it("does NOT touch the controller when auto-pausing a non-spotify track", () => { + const ctx = makeOccupancyCtx("netease", "playing"); + handleOccupancy.call(ctx, 0); + expect(ctx.player.pause).toHaveBeenCalledTimes(1); + expect(ctx.spotifyController.pause).not.toHaveBeenCalled(); + }); +}); + +describe("BotInstance.seek — spotify routing (C4)", () => { + function makeSeekCtx(currentPlatform: string) { + return { + queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, + spotifyController: { seek: vi.fn(async () => {}) }, + player: { seek: vi.fn() }, + logger: { warn: vi.fn() }, + } as any; + } + + it("routes seek to the controller for a spotify track", () => { + const ctx = makeSeekCtx("spotify"); + seek.call(ctx, 30); + expect(ctx.spotifyController.seek).toHaveBeenCalledWith(30); + expect(ctx.player.seek).not.toHaveBeenCalled(); + }); + + it("routes seek to the player for a non-spotify track", () => { + const ctx = makeSeekCtx("netease"); + seek.call(ctx, 30); + expect(ctx.player.seek).toHaveBeenCalledWith(30); + expect(ctx.spotifyController.seek).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index c96f895..c5b3481 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -15,7 +15,7 @@ import { import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import type { Logger } from "../logger.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js"; -import type { BotConfig } from "../data/config.js"; +import type { BotConfig, SpotifyConfig } from "../data/config.js"; import { BotProfileManager } from "./profile.js"; import type { AvatarStore } from "../data/avatars.js"; import { @@ -24,6 +24,9 @@ import { shouldResumeOnReturn, } from "./auto-pause.js"; import { isSpotifyUri } from "../music/spotify/webapi.js"; +import path from "node:path"; +import { SpotifyController } from "../music/spotify/controller.js"; +import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js"; /** Reply sent when a non-admin invokes an admin-only chat command. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; @@ -43,6 +46,15 @@ export interface BotInstanceOptions { config: BotConfig; logger: Logger; avatarStore: AvatarStore; + /** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */ + spotifyDataDir?: string; + /** Test seam: build a fake controller instead of a real go-librespot one. */ + spotifyControllerFactory?: (o: { + config: SpotifyConfig; + workDir: string; + configDir: string; + logger: Logger; + }) => SpotifyController; } export interface BotStatus { @@ -66,6 +78,7 @@ export class BotInstance extends EventEmitter { private tsClient: TS3Client; private player: AudioPlayer; + private spotifyController: SpotifyController; private queue: PlayQueue; private neteaseProvider: MusicProvider; private qqProvider: MusicProvider; @@ -85,6 +98,9 @@ export class BotInstance extends EventEmitter { private idleTimer: ReturnType | null = null; private channelUserCount = 0; private autoPaused = false; + /** True while the audible track is served by the Spotify sidecar (external + * PCM mode) — drives fence/handoff decisions in resolveAndPlay + cmdStop. */ + private currentSourceIsSpotify = false; private profileManager: BotProfileManager; private isFmMode = false; private fmProvider: MusicProvider | null = null; @@ -114,6 +130,22 @@ export class BotInstance extends EventEmitter { this.player = new AudioPlayer(this.logger); this.queue = new PlayQueue(); + // One long-lived Spotify sidecar controller per bot. Construction is + // cheap and side-effect-free — nothing spawns until ensureStarted(). + const spotifyBase = + options.spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); + const spotifyWorkDir = path.join(spotifyBase, this.id, "work"); + const spotifyConfigDir = path.join(spotifyBase, this.id, "config"); + const buildController = + options.spotifyControllerFactory ?? + ((o) => new SpotifyController({ ...o })); + this.spotifyController = buildController({ + config: this.config.spotify, + workDir: spotifyWorkDir, + configDir: spotifyConfigDir, + logger: this.logger, + }); + const profileConfig = this.database.getProfileConfig(this.id); this.profileManager = new BotProfileManager( this.tsClient, @@ -155,6 +187,19 @@ export class BotInstance extends EventEmitter { this.logger.error({ err: err2 }, "playNext failed after player error"); }); }); + + // Spotify advances exclusively via the sidecar's WebSocket "trackEnded" + // (the continuous go-librespot→ffmpeg pipe never EOFs per track, so the + // player's own underrun "trackEnd" is suppressed in external mode). Guard + // on the current song being spotify so a stray event can't double-advance + // a URL track; playNext()'s isAdvancing guard covers any residual race. + this.spotifyController.on("trackEnded", (_e: SpotifyTrackEndedEvent) => { + if (this.queue.current()?.platform !== "spotify") return; + this.logger.debug("Spotify track ended, advancing queue"); + this.playNext().catch((err) => { + this.logger.error({ err }, "playNext failed after spotify trackEnded"); + }); + }); } isLocalAudioEnabled(): boolean { @@ -205,6 +250,8 @@ export class BotInstance extends EventEmitter { // this.connected was never flipped to true. Previously this handler // short-circuited on !this.connected, leaving player stuck as "playing". this.connected = false; + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; this.player.stop(); this.queue.clear(); this.sweepLocalAudio("disconnected"); @@ -288,6 +335,8 @@ export class BotInstance extends EventEmitter { disconnect(): void { this._cancelIdleTimer(); + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; this.player.stop(); this.queue.clear(); this.sweepLocalAudio("disconnected"); @@ -310,6 +359,10 @@ export class BotInstance extends EventEmitter { this.config.autoPauseOnEmpty = enabled; if (!enabled && this.autoPaused && this.player.getState() === "paused") { this.player.resume(); + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.resume().catch((err) => + this.logger.warn({ err }, "Spotify resume failed (auto-pause disabled)")); + } this.autoPaused = false; this.emit("stateChange"); } @@ -343,10 +396,21 @@ export class BotInstance extends EventEmitter { ); if (action === "pause") { this.player.pause(); + // Occupancy paths drive player.pause()/resume() DIRECTLY (bypassing the + // cmd handlers), so they must ALSO stop/resume the sidecar — else it + // keeps decoding into an empty channel. + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.pause().catch((err) => + this.logger.warn({ err }, "Spotify pause failed (occupancy)")); + } this.autoPaused = true; this.emit("stateChange"); } else if (action === "resume") { this.player.resume(); + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.resume().catch((err) => + this.logger.warn({ err }, "Spotify resume failed (occupancy)")); + } this.autoPaused = false; this.emit("stateChange"); } @@ -587,16 +651,61 @@ export class BotInstance extends EventEmitter { ); return false; } - // Stage 1: Spotify metadata works but audio is not wired yet. getSongUrl - // returns a `spotify:` sentinel — never hand it to ffmpeg. Tell the user - // and skip so the queue keeps moving. `sendTextMessage` is the same - // channel-message helper the command handlers use elsewhere in this file. + // Stage 2: a `spotify:` sentinel URI means the go-librespot sidecar + // serves the audio, NOT ffmpeg. Start the per-bot sidecar on demand; if + // it can't run (disabled / non-Linux / binary missing) keep the Stage-1 + // fallback message + skip so the queue keeps moving. if (isSpotifyUri(result.url)) { - this.logger.info({ songId: song.id, name: song.name }, "Spotify playback not enabled yet — skipping"); - await this.tsClient.sendTextMessage( - "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" - ); - return false; + const ready = await this.spotifyController.ensureStarted(); + if (!ready) { + this.logger.info({ songId: song.id, name: song.name }, "Spotify backend unavailable — skipping"); + await this.tsClient.sendTextMessage( + "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" + ); + return false; + } + // `spotify:track:` is the URI. go-librespot decodes into a SINGLE + // continuous FIFO/PCM stream, so per-track playback is just a REST + // playTrack — the stream keeps flowing. + await this.spotifyController.playTrack(result.url); + // C4: only ATTACH the persistent PCM stream when coming from a + // non-spotify source. playPcmStream internally fences the prior + // url-ffmpeg (so NO player.stop() here). On a spotify→spotify handoff + // the sidecar changes tracks into the SAME FIFO — re-attaching would + // tear down and re-subscribe the shared stream and silence playback. + if (!this.currentSourceIsSpotify) { + this.player.playPcmStream(this.spotifyController.getPcmStream(), { + // The sidecar PCM pipe is long-lived; per-track end arrives via the + // controller "trackEnded" WS event, not stream EOF. A real EOF here + // means the sidecar died — recovery is the controller's job. + onExternalEnd: () => {}, + }); + } + this.currentSourceIsSpotify = true; + song.url = result.url; + // No trial clip for Spotify — full-track duration only (the near-end + // stall watchdog is disabled for the external stream anyway). + this.effectiveDuration = song.duration; + this.autoPaused = false; + this.database.addPlayHistory({ + botId: this.id, + songId: song.id, + songName: song.name, + artist: song.artist, + album: song.album, + platform: song.platform, + coverUrl: song.coverUrl, + }); + await this.syncProfileToSong(song); + this.emit("stateChange"); + return true; + } + // Non-Spotify track: if we were on Spotify, pause the sidecar so it stops + // decoding ahead before the URL ffmpeg reclaims the PCM buffer. + if (this.currentSourceIsSpotify) { + this.spotifyController.pause().catch((err) => + this.logger.warn({ err }, "Failed to pause Spotify sidecar on source switch")); + this.currentSourceIsSpotify = false; } song.url = result.url; // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration @@ -767,6 +876,10 @@ export class BotInstance extends EventEmitter { private cmdPause(): string { this.player.pause(); + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.pause().catch((err) => + this.logger.warn({ err }, "Spotify pause failed")); + } // User-initiated pause — clear auto-pause so occupancy won't auto-resume it. this.autoPaused = false; this.emit("stateChange"); @@ -775,6 +888,10 @@ export class BotInstance extends EventEmitter { private cmdResume(): string { this.player.resume(); + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.resume().catch((err) => + this.logger.warn({ err }, "Spotify resume failed")); + } // User-initiated resume — drop any auto-pause flag. this.autoPaused = false; this.emit("stateChange"); @@ -782,6 +899,12 @@ export class BotInstance extends EventEmitter { } private cmdStop(): string { + // Read the current song BEFORE queue.clear() so we can tell whether the + // sidecar needs stopping. + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.stop(); + } + this.currentSourceIsSpotify = false; this.player.stop(); this.autoPaused = false; this.queue.clear(); @@ -842,6 +965,8 @@ export class BotInstance extends EventEmitter { } private cmdClear(): string { + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; this.player.stop(); this.queue.clear(); this.sweepLocalAudio("queue_cleared"); @@ -1231,6 +1356,20 @@ export class BotInstance extends EventEmitter { return this.player; } + /** + * Route a seek to the Spotify sidecar for a spotify track (its PCM stream is + * external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel + * and collide with the running stream), otherwise to the URL player. + */ + seek(ms: number): void { + if (this.queue.current()?.platform === "spotify") { + this.spotifyController.seek(ms).catch((err) => + this.logger.warn({ err }, "Spotify seek failed")); + return; + } + this.player.seek(ms); + } + getQueueManager(): PlayQueue { return this.queue; } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 15daa04..682ed6e 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { EventEmitter } from "node:events"; +import path from "node:path"; import { BotInstance, type BotInstanceOptions, @@ -77,6 +78,7 @@ export class BotManager extends EventEmitter { private localProvider: MusicProvider; private kugouProvider: MusicProvider; private spotifyProvider: MusicProvider; + private spotifyDataDir: string; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -96,7 +98,8 @@ export class BotManager extends EventEmitter { configPath: string, localProvider?: MusicProvider, kugouProvider?: MusicProvider, - spotifyProvider?: MusicProvider + spotifyProvider?: MusicProvider, + spotifyDataDir?: string ) { super(); this.neteaseProvider = neteaseProvider; @@ -106,6 +109,7 @@ export class BotManager extends EventEmitter { this.localProvider = localProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider; this.spotifyProvider = spotifyProvider ?? neteaseProvider; + this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); // Let the local provider see which uploads are still referenced by any // bot's queue, so it never deletes a file another queue/bot still needs. const referenceable = this.localProvider as Partial<{ @@ -149,6 +153,7 @@ export class BotManager extends EventEmitter { config: this.config, logger: this.logger, avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, }); this.bots.set(id, bot); @@ -290,6 +295,7 @@ export class BotManager extends EventEmitter { config: this.config, logger: this.logger, avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, }); this.bots.set(id, bot); this.emit("botInstance", bot); @@ -345,6 +351,7 @@ export class BotManager extends EventEmitter { config: this.config, logger: this.logger, avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, }); this.bots.set(saved.id, bot); diff --git a/src/index.ts b/src/index.ts index 5c206e8..cfcbdb1 100755 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ const LOG_DIR = path.join(DATA_DIR, "logs"); const COOKIE_DIR = path.join(DATA_DIR, "cookies"); const AVATAR_DIR = path.join(DATA_DIR, "avatars"); const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio"); +const SPOTIFY_DATA_DIR = path.join(DATA_DIR, "spotify"); const STATIC_DIR = path.join(ROOT_DIR, "web", "dist"); async function main() { @@ -94,7 +95,8 @@ async function main() { CONFIG_PATH, localProvider, kugouProvider, - spotifyProvider + spotifyProvider, + SPOTIFY_DATA_DIR ); await botManager.loadSavedBots(); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 0da271e..673fd4a 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -198,7 +198,7 @@ export function createPlayerRouter( .json({ error: "position must be a finite non-negative number" }); return; } - bot.getPlayer().seek(position); + bot.seek(position); res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position }); } catch (err) { res.status(500).json({ error: (err as Error).message }); From 9c796ec05c251e8ccdfcf9c809770e98994dc474 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 22:00:56 +0800 Subject: [PATCH 20/59] =?UTF-8?q?fix(spotify):=20correct=20Spotify=20seek?= =?UTF-8?q?=20units=20(s=E2=86=92ms)=20+=20gate=20re-attach=20on=20player?= =?UTF-8?q?=20external=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/player.test.ts | 15 ++++++++++++ src/audio/player.ts | 4 ++++ src/bot/instance.test.ts | 51 +++++++++++++++++++++++++++++++++------- src/bot/instance.ts | 28 +++++++++++++--------- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index fe0cb3d..7b9384c 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -377,6 +377,21 @@ describe("AudioPlayer external-PCM mode (playPcmStream)", () => { player.stop(); }); + it("isExternalActive() is false initially, true after playPcmStream, false after stop()", () => { + const player = new AudioPlayer(silentLogger); + // Idle: never attached. + expect(player.isExternalActive()).toBe(false); + + const stream = openPcmReadable(); + player.playPcmStream(stream, {}); + // Attached to the external sidecar stream. + expect(player.isExternalActive()).toBe(true); + + player.stop(); + // Detached again — the orchestrator uses this to know it must re-attach. + expect(player.isExternalActive()).toBe(false); + }); + it("pause()/resume() still gate local emission in external mode (unchanged semantics)", async () => { const player = new AudioPlayer(silentLogger); const stream = openPcmReadable(); diff --git a/src/audio/player.ts b/src/audio/player.ts index 33ba356..4211dd9 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -746,4 +746,8 @@ export class AudioPlayer extends EventEmitter { setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); } getVolume(): number { return this.volume; } getState(): PlayerState { return this.state; } + // True only while attached to an external (Spotify sidecar) PCM stream. Used + // by the orchestrator to decide whether to re-attach: stop() detaches (sets + // externalMode=false) so this is false after any player.stop(). + isExternalActive(): boolean { return this.externalMode; } } \ No newline at end of file diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 4d0dc3b..3510cb9 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -241,7 +241,7 @@ const handleOccupancy = (BotInstance.prototype as any).handleOccupancy as ( this: unknown, userCount: number, ) => void; -const seek = (BotInstance.prototype as any).seek as (this: unknown, ms: number) => void; +const seek = (BotInstance.prototype as any).seek as (this: unknown, seconds: number) => void; function makeController() { return { @@ -256,13 +256,18 @@ function makeController() { }; } function makePlayer() { + // `externalActive` mirrors the real AudioPlayer: playPcmStream attaches the + // external stream (true), and both stop() and play() detach it (false). The + // re-attach guard reads isExternalActive(), so this must track that state. + let externalActive = false; return { - play: vi.fn(), - stop: vi.fn(), - playPcmStream: vi.fn(), + play: vi.fn((..._args: any[]) => { externalActive = false; }), + stop: vi.fn(() => { externalActive = false; }), + playPcmStream: vi.fn((..._args: any[]) => { externalActive = true; }), pause: vi.fn(), resume: vi.fn(), seek: vi.fn(), + isExternalActive: vi.fn(() => externalActive), }; } function makeResolveCtx(opts: { @@ -370,6 +375,35 @@ describe("BotInstance.resolveAndPlay — Spotify routing (C4)", () => { expect(player.stop).not.toHaveBeenCalled(); }); + it("RE-attaches on a spotify -> (command player.stop) -> spotify sequence (does not stay silent)", async () => { + // Regression: command paths (cmdPlay/cmdPlaylist/cmdAlbum/cmdFm) call + // player.stop() — which DETACHES the external stream — WITHOUT clearing the + // currentSourceIsSpotify flag. Gating re-attach on the stale flag skipped + // playPcmStream, silencing the next spotify track. We now gate on the + // player's actual external state, so the re-attach happens. + const controller = makeController(); + const player = makePlayer(); + const ctx = makeResolveCtx({ + controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, + }); + + // First spotify track attaches the persistent PCM stream. + await resolveAndPlay.call(ctx, spotifySong()); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + expect(player.isExternalActive()).toBe(true); + + // A command path stops the player (detaches the stream) but leaves the + // spotify flag stale-true — exactly the state that used to cause silence. + player.stop(); + expect(player.isExternalActive()).toBe(false); + expect(ctx.currentSourceIsSpotify).toBe(true); // flag NOT cleared by stop() + + // Next spotify track MUST re-attach (gate on player external state, not flag). + await resolveAndPlay.call(ctx, spotifySong()); + expect(player.playPcmStream).toHaveBeenCalledTimes(2); + expect(player.isExternalActive()).toBe(true); + }); + it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => { const controller = makeController(); const player = makePlayer(); @@ -523,14 +557,15 @@ describe("BotInstance.seek — spotify routing (C4)", () => { } as any; } - it("routes seek to the controller for a spotify track", () => { + it("routes seek to the controller for a spotify track, converting seconds -> ms", () => { const ctx = makeSeekCtx("spotify"); - seek.call(ctx, 30); - expect(ctx.spotifyController.seek).toHaveBeenCalledWith(30); + seek.call(ctx, 30); // 30 seconds + // SpotifyController.seek is millisecond-based: 30s -> 30000ms (not 30). + expect(ctx.spotifyController.seek).toHaveBeenCalledWith(30000); expect(ctx.player.seek).not.toHaveBeenCalled(); }); - it("routes seek to the player for a non-spotify track", () => { + it("routes seek to the player (seconds-based) for a non-spotify track", () => { const ctx = makeSeekCtx("netease"); seek.call(ctx, 30); expect(ctx.player.seek).toHaveBeenCalledWith(30); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index c5b3481..4555c90 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -668,17 +668,21 @@ export class BotInstance extends EventEmitter { // continuous FIFO/PCM stream, so per-track playback is just a REST // playTrack — the stream keeps flowing. await this.spotifyController.playTrack(result.url); - // C4: only ATTACH the persistent PCM stream when coming from a - // non-spotify source. playPcmStream internally fences the prior - // url-ffmpeg (so NO player.stop() here). On a spotify→spotify handoff - // the sidecar changes tracks into the SAME FIFO — re-attaching would - // tear down and re-subscribe the shared stream and silence playback. - if (!this.currentSourceIsSpotify) { + // Only ATTACH the persistent PCM stream when the player is NOT already + // attached to it. Gate on the player's ACTUAL external state, not the + // currentSourceIsSpotify flag: command paths (cmdPlay/cmdPlaylist/…) + // call player.stop() (which detaches the external stream) WITHOUT + // clearing the flag, so a stale-true flag would skip the re-attach and + // silence playback. playPcmStream internally fences the prior url-ffmpeg + // (so NO player.stop() here). On the gapless auto-advance path the + // player is still attached (isExternalActive() === true) so we do NOT + // re-attach — the sidecar rolls the SAME FIFO into the next track. + if (!this.player.isExternalActive()) { this.player.playPcmStream(this.spotifyController.getPcmStream(), { // The sidecar PCM pipe is long-lived; per-track end arrives via the // controller "trackEnded" WS event, not stream EOF. A real EOF here - // means the sidecar died — recovery is the controller's job. - onExternalEnd: () => {}, + // means the sidecar died — surface it rather than silently swallow. + onExternalEnd: () => this.logger.warn("Spotify PCM stream ended unexpectedly"), }); } this.currentSourceIsSpotify = true; @@ -1361,13 +1365,15 @@ export class BotInstance extends EventEmitter { * external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel * and collide with the running stream), otherwise to the URL player. */ - seek(ms: number): void { + seek(seconds: number): void { if (this.queue.current()?.platform === "spotify") { - this.spotifyController.seek(ms).catch((err) => + // The web route + AudioPlayer.seek are seconds-based, but + // SpotifyController.seek expects milliseconds — convert here. + this.spotifyController.seek(seconds * 1000).catch((err) => this.logger.warn({ err }, "Spotify seek failed")); return; } - this.player.seek(ms); + this.player.seek(seconds); } getQueueManager(): PlayQueue { From 8bd0aae7c3654484d707cafbe39240cfc5632a0a Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 22:24:51 +0800 Subject: [PATCH 21/59] fix(spotify): loopback-bind sidecar API, recover on sidecar death, per-bot go-librespot ports Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.test.ts | 67 +++++++++++++++++++++++++- src/bot/instance.ts | 66 ++++++++++++++++++++++--- src/music/spotify/controller.test.ts | 50 +++++++++++++++++++ src/music/spotify/controller.ts | 37 ++++++++++++-- src/music/spotify/go-librespot.test.ts | 36 +++++++++++++- src/music/spotify/go-librespot.ts | 10 +++- 6 files changed, 249 insertions(+), 17 deletions(-) diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 3510cb9..cfc8eae 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { BotInstance, COMMAND_DENIED_MESSAGE } from "./instance.js"; +import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; import type { TS3TextMessage } from "../ts-protocol/client.js"; // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer, @@ -404,6 +404,44 @@ describe("BotInstance.resolveAndPlay — Spotify routing (C4)", () => { expect(player.isExternalActive()).toBe(true); }); + it("returns false + sends the fallback when playTrack resolves false (dead/failed sidecar)", async () => { + const controller = makeController(); + controller.playTrack = vi.fn(async () => false); + const player = makePlayer(); + const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); + + const ok = await resolveAndPlay.call(ctx, spotifySong()); + + expect(ok).toBe(false); + expect(controller.playTrack).toHaveBeenCalledTimes(1); + // Same Stage-1 fallback message as the backend-unavailable path. + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); + // Never attach the player to a dead stream. + expect(player.playPcmStream).not.toHaveBeenCalled(); + expect(ctx.currentSourceIsSpotify).toBe(false); + }); + + it("recovers on mid-session sidecar death: onExternalEnd stops controller+player and clears the flag", async () => { + const controller = makeController(); + const player = makePlayer(); + const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); + + await resolveAndPlay.call(ctx, spotifySong()); + expect(ctx.currentSourceIsSpotify).toBe(true); + expect(player.playPcmStream).toHaveBeenCalledTimes(1); + + // The sidecar PCM stream EOFs mid-session → fire the wired onExternalEnd. + const opts = player.playPcmStream.mock.calls[0][1] as { onExternalEnd?: () => void }; + expect(typeof opts.onExternalEnd).toBe("function"); + opts.onExternalEnd!(); + + // Recovery: controller torn down (next track rebuilds), player stopped + // (drops external mode so the next track re-attaches), flag cleared. + expect(controller.stop).toHaveBeenCalledTimes(1); + expect(player.stop).toHaveBeenCalledTimes(1); + expect(ctx.currentSourceIsSpotify).toBe(false); + }); + it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => { const controller = makeController(); const player = makePlayer(); @@ -572,3 +610,30 @@ describe("BotInstance.seek — spotify routing (C4)", () => { expect(ctx.spotifyController.seek).not.toHaveBeenCalled(); }); }); + +describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => { + it("yields the SAME ports for the same bot id (stable across restarts)", () => { + const a = spotifyPortsForBotId("bot-alpha"); + const b = spotifyPortsForBotId("bot-alpha"); + expect(a).toEqual(b); + }); + + it("yields DIFFERENT ports for different bot ids", () => { + const a = spotifyPortsForBotId("bot-alpha"); + const b = spotifyPortsForBotId("bot-beta"); + expect(a.apiPort).not.toBe(b.apiPort); + expect(a.callbackPort).not.toBe(b.callbackPort); + }); + + it("keeps apiPort and callbackPort in disjoint ranges", () => { + for (const id of ["bot-alpha", "bot-beta", "x", "a-very-long-bot-identifier-123"]) { + const { apiPort, callbackPort } = spotifyPortsForBotId(id); + expect(apiPort).toBeGreaterThanOrEqual(3700); + expect(apiPort).toBeLessThan(4700); + expect(callbackPort).toBeGreaterThanOrEqual(8700); + expect(callbackPort).toBeLessThan(9700); + // Same offset within each range → the two never collide with each other. + expect(callbackPort - apiPort).toBe(5000); + } + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 4555c90..c667b27 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -31,6 +31,35 @@ import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js"; /** Reply sent when a non-admin invokes an admin-only chat command. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; +/** Fallback message when Spotify audio can't be served (backend unavailable + * OR a per-track playTrack failure against a dead/failed sidecar). */ +const SPOTIFY_UNAVAILABLE_MESSAGE = + "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。"; + +/** FNV-1a deterministic string hash (unsigned 32-bit). Stable across restarts + * and processes, unlike a random/insertion-order value — used to derive + * per-bot go-librespot ports. */ +function stableHash(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** + * STABLE per-bot go-librespot ports derived from the bot id. A second + * Spotify-enabled bot's sidecar must not collide on the control-API or OAuth + * callback ports; deriving them from the id keeps them fixed across restarts. + * The two ranges (37xx / 87xx) are disjoint so the API and callback ports for + * a given bot never clash with each other. + */ +export function spotifyPortsForBotId(id: string): { apiPort: number; callbackPort: number } { + const off = stableHash(id) % 1000; + return { apiPort: 3700 + off, callbackPort: 8700 + off }; +} + export interface BotInstanceOptions { id: string; name: string; @@ -54,6 +83,8 @@ export interface BotInstanceOptions { workDir: string; configDir: string; logger: Logger; + apiPort: number; + callbackPort: number; }) => SpotifyController; } @@ -136,6 +167,10 @@ export class BotInstance extends EventEmitter { options.spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); const spotifyWorkDir = path.join(spotifyBase, this.id, "work"); const spotifyConfigDir = path.join(spotifyBase, this.id, "config"); + // Distinct, stable per-bot ports so a second Spotify-enabled bot's sidecar + // doesn't fail to bind the go-librespot control API / OAuth callback. + const { apiPort: spotifyApiPort, callbackPort: spotifyCallbackPort } = + spotifyPortsForBotId(this.id); const buildController = options.spotifyControllerFactory ?? ((o) => new SpotifyController({ ...o })); @@ -144,6 +179,8 @@ export class BotInstance extends EventEmitter { workDir: spotifyWorkDir, configDir: spotifyConfigDir, logger: this.logger, + apiPort: spotifyApiPort, + callbackPort: spotifyCallbackPort, }); const profileConfig = this.database.getProfileConfig(this.id); @@ -659,15 +696,20 @@ export class BotInstance extends EventEmitter { const ready = await this.spotifyController.ensureStarted(); if (!ready) { this.logger.info({ songId: song.id, name: song.name }, "Spotify backend unavailable — skipping"); - await this.tsClient.sendTextMessage( - "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" - ); + await this.tsClient.sendTextMessage(SPOTIFY_UNAVAILABLE_MESSAGE); return false; } // `spotify:track:` is the URI. go-librespot decodes into a SINGLE // continuous FIFO/PCM stream, so per-track playback is just a REST - // playTrack — the stream keeps flowing. - await this.spotifyController.playTrack(result.url); + // playTrack — the stream keeps flowing. A false result means the + // sidecar failed the play (dead/errored backend): never attach the + // player to a dead stream — send the same fallback and skip. + const played = await this.spotifyController.playTrack(result.url); + if (!played) { + this.logger.info({ songId: song.id, name: song.name }, "Spotify playTrack failed — skipping"); + await this.tsClient.sendTextMessage(SPOTIFY_UNAVAILABLE_MESSAGE); + return false; + } // Only ATTACH the persistent PCM stream when the player is NOT already // attached to it. Gate on the player's ACTUAL external state, not the // currentSourceIsSpotify flag: command paths (cmdPlay/cmdPlaylist/…) @@ -681,8 +723,18 @@ export class BotInstance extends EventEmitter { this.player.playPcmStream(this.spotifyController.getPcmStream(), { // The sidecar PCM pipe is long-lived; per-track end arrives via the // controller "trackEnded" WS event, not stream EOF. A real EOF here - // means the sidecar died — surface it rather than silently swallow. - onExternalEnd: () => this.logger.warn("Spotify PCM stream ended unexpectedly"), + // means the sidecar died mid-session — RECOVER instead of emitting + // silence forever (which would also leave the player stuck in + // externalMode, so the re-attach gate skips every future track). + // Tear the controller down (next ensureStarted() rebuilds a fresh + // backend), stop the player (drop external mode so it re-attaches), + // and clear the flag so a non-spotify track isn't mis-handled. + onExternalEnd: () => { + this.logger.warn("Spotify PCM stream ended unexpectedly — recovering"); + this.spotifyController.stop(); + this.player.stop(); + this.currentSourceIsSpotify = false; + }, }); } this.currentSourceIsSpotify = true; diff --git a/src/music/spotify/controller.test.ts b/src/music/spotify/controller.test.ts index dfe897e..fb9ced5 100644 --- a/src/music/spotify/controller.test.ts +++ b/src/music/spotify/controller.test.ts @@ -21,6 +21,37 @@ vi.mock("./binary.js", () => ({ checkGoLibrespotAvailable: async () => bin.supported && !!bin.path, })); +// Capture options the DEFAULT factory hands to the real GoLibrespotBackend so +// we can assert per-bot ports (Fix 3) are threaded through. Every other test +// injects its own backendFactory, so this mock is inert for them. +const goLibrespotCtor = vi.hoisted(() => vi.fn()); +vi.mock("./go-librespot.js", async () => { + const { EventEmitter } = await import("node:events"); + return { + GoLibrespotBackend: class extends EventEmitter { + constructor(opts: any) { + super(); + goLibrespotCtor(opts); + } + async start(): Promise {} + isReady(): boolean { + return true; + } + stop(): void {} + async playTrack(): Promise {} + async pause(): Promise {} + async resume(): Promise {} + async seek(): Promise {} + getPcmStream(): any { + return null; + } + getPositionMs(): number { + return 0; + } + }, + }; +}); + // Import AFTER vi.mock so the mocked binary module is used. const { SpotifyController } = await import("./controller.js"); @@ -401,3 +432,22 @@ describe("SpotifyController.stop", () => { expect(be.stopCalls).toBe(0); }); }); + +describe("SpotifyController per-bot ports (Fix 3)", () => { + it("threads apiPort + callbackPort into the default GoLibrespotBackend", async () => { + goLibrespotCtor.mockClear(); + // No injected backendFactory → the controller builds the (mocked) real backend. + const ctrl = new SpotifyController({ + config: cfg(), + workDir: "/tmp/work", + configDir: "/tmp/cfg", + logger: silentLogger, + apiPort: 3712, + callbackPort: 8712, + }); + expect(await ctrl.ensureStarted()).toBe(true); + expect(goLibrespotCtor).toHaveBeenCalledWith( + expect.objectContaining({ apiPort: 3712, callbackPort: 8712 }), + ); + }); +}); diff --git a/src/music/spotify/controller.ts b/src/music/spotify/controller.ts index f378cf2..32117bd 100644 --- a/src/music/spotify/controller.ts +++ b/src/music/spotify/controller.ts @@ -16,6 +16,10 @@ export interface SpotifyControllerOptions { workDir: string; configDir: string; logger: Logger; + /** Per-bot go-librespot control-API port (distinct per bot to avoid binds). */ + apiPort?: number; + /** Per-bot go-librespot OAuth callback port (distinct per bot). */ + callbackPort?: number; /** Injected for tests; defaults to constructing a real GoLibrespotBackend. */ backendFactory?: () => SpotifyAudioBackend; } @@ -39,6 +43,8 @@ export class SpotifyController extends EventEmitter { private readonly workDir: string; private readonly configDir: string; private readonly logger: Logger; + private readonly apiPort?: number; + private readonly callbackPort?: number; private readonly backendFactory: () => SpotifyAudioBackend; private backend: SpotifyAudioBackend | null = null; @@ -51,6 +57,8 @@ export class SpotifyController extends EventEmitter { this.workDir = o.workDir; this.configDir = o.configDir; this.logger = o.logger; + this.apiPort = o.apiPort; + this.callbackPort = o.callbackPort; this.backendFactory = o.backendFactory ?? (() => @@ -59,6 +67,8 @@ export class SpotifyController extends EventEmitter { bitrate: this.config.bitrate, workDir: this.workDir, configDir: this.configDir, + apiPort: this.apiPort, + callbackPort: this.callbackPort, logger: this.logger, })); } @@ -79,7 +89,14 @@ export class SpotifyController extends EventEmitter { */ async ensureStarted(): Promise { if (!this.isAvailable()) return false; - if (this.started) return true; + if (this.started) { + // A previously-started backend still counts as ready only if its process + // is alive. If the sidecar died (isReady()===false) — e.g. the go-librespot + // child exited — tear it down here so the code below rebuilds a fresh one + // instead of handing callers a dead backend. + if (this.backend?.isReady()) return true; + this.stop(); + } if (this.startPromise) return this.startPromise; this.startPromise = (async () => { @@ -169,12 +186,22 @@ export class SpotifyController extends EventEmitter { return this.backend.getPcmStream(); } - /** Tear down the backend and reset lifecycle state (safe before start). */ + /** + * Tear down the backend and reset lifecycle state (safe before start). + * Mirrors handleBackendError's teardown so the NEXT ensureStarted() rebuilds + * a fresh backend: stop() cleans the ffmpeg/go-librespot children + FIFO, + * removeAllListeners() detaches this controller's handlers so a late event + * from the now-orphaned backend can't disturb a rebuilt one. Guard stop() + * (it may throw) so teardown always completes. + */ stop(): void { - if (this.backend) { - this.backend.stop(); - this.backend = null; + try { + this.backend?.stop(); + } catch (stopErr) { + this.logger.error({ err: stopErr }, "Spotify backend stop() threw during teardown"); } + (this.backend as unknown as EventEmitter | null)?.removeAllListeners(); + this.backend = null; this.started = false; this.startPromise = null; } diff --git a/src/music/spotify/go-librespot.test.ts b/src/music/spotify/go-librespot.test.ts index 0d198d0..7fd6ab0 100644 --- a/src/music/spotify/go-librespot.test.ts +++ b/src/music/spotify/go-librespot.test.ts @@ -15,7 +15,7 @@ function makeFakeChild() { return child; } -function makeHarness() { +function makeHarness(portOpts: { apiPort?: number; callbackPort?: number } = {}) { const calls: string[] = []; const ffmpegChild = makeFakeChild(); const gliChild = makeFakeChild(); @@ -52,7 +52,8 @@ function makeHarness() { bitrate: 320, workDir: "/tmp/work", configDir: "/tmp/cfg", - apiPort: 3678, + apiPort: portOpts.apiPort ?? 3678, + callbackPort: portOpts.callbackPort, logger: log, deps: { spawn, @@ -134,6 +135,37 @@ describe("GoLibrespotBackend.start", () => { }); }); +describe("GoLibrespotBackend config binding (Fix 1 loopback + Fix 3 ports)", () => { + function writtenYml(h: ReturnType): string { + const calls = h.writeFileSync.mock.calls as unknown as any[][]; + const call = calls.find((c) => String(c[0]).endsWith("config.yml")); + expect(call).toBeDefined(); + return call![1] as string; + } + + it("binds the control API to loopback (127.0.0.1), never 0.0.0.0", async () => { + const h = makeHarness(); + await h.backend.start(); + const yml = writtenYml(h); + expect(yml).toContain("address: 127.0.0.1"); + expect(yml).not.toContain("0.0.0.0"); + }); + + it("threads apiPort + callbackPort into the rendered config", async () => { + const h = makeHarness({ apiPort: 3712, callbackPort: 8712 }); + await h.backend.start(); + const yml = writtenYml(h); + expect(yml).toContain("port: 3712"); + expect(yml).toContain("callback_port: 8712"); + }); + + it("defaults callbackPort to 8080 when unset", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(writtenYml(h)).toContain("callback_port: 8080"); + }); +}); + describe("GoLibrespotBackend WebSocket event mapping", () => { it("maps a not_playing event to trackEnded{reason:'ended'}", async () => { const h = makeHarness(); diff --git a/src/music/spotify/go-librespot.ts b/src/music/spotify/go-librespot.ts index cdc1f0c..bec93f6 100644 --- a/src/music/spotify/go-librespot.ts +++ b/src/music/spotify/go-librespot.ts @@ -32,6 +32,7 @@ export interface GoLibrespotBackendOptions { workDir: string; configDir: string; apiPort?: number; + callbackPort?: number; logger: Logger; deps?: GoLibrespotBackendDeps; } @@ -67,6 +68,7 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack private readonly log: Logger; private readonly deps: GoLibrespotBackendDeps; private readonly apiPort: number; + private readonly callbackPort: number; private readonly fifoPath: string; private ffmpeg: ChildProcess | null = null; @@ -82,6 +84,7 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack this.log = o.logger; this.deps = o.deps ?? {}; this.apiPort = o.apiPort ?? DEFAULT_API_PORT; + this.callbackPort = o.callbackPort ?? DEFAULT_CALLBACK_PORT; this.fifoPath = posixPath.join(o.workDir, FIFO_NAME); } @@ -132,9 +135,12 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack deviceName: this.opts.deviceName, bitrate: this.opts.bitrate, fifoPath: this.fifoPath, - apiAddress: "0.0.0.0", + // The go-librespot control API is UNAUTHENTICATED and the client only + // ever connects via 127.0.0.1; the sidecar runs in the SAME container + // as the bot, so bind to loopback rather than exposing it on 0.0.0.0. + apiAddress: "127.0.0.1", apiPort: this.apiPort, - callbackPort: DEFAULT_CALLBACK_PORT, + callbackPort: this.callbackPort, }); writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8"); From 3277736f5de7cb5fd360895df0b916d97661ff77 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:00:08 +0800 Subject: [PATCH 22/59] =?UTF-8?q?docs(spotify):=20stage=203=20plan=20?= =?UTF-8?q?=E2=80=94=20Rust=20librespot=20Windows=20backend=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafted from a locked contract + research map, adversarially verified (3 critics). REQUIRED CORRECTIONS: single shared OAuth threaded to web+controller; auth via the user's own Developer app + web callback (drop ToS-gray librespot-client + :5588); resolved-backend status; poll hasPlayed-gating + 204 track-end; Connect error guarding; verifier-map cleanup. Cross-platform, gated, not e2e-testable (Premium). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-02-spotify-stage3-rust-librespot.md | 3045 +++++++++++++++++ 1 file changed, 3045 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-spotify-stage3-rust-librespot.md diff --git a/docs/superpowers/plans/2026-07-02-spotify-stage3-rust-librespot.md b/docs/superpowers/plans/2026-07-02-spotify-stage3-rust-librespot.md new file mode 100644 index 0000000..0226ac3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-spotify-stage3-rust-librespot.md @@ -0,0 +1,3045 @@ +# Spotify Source — Stage 3 (Rust librespot Backend, native Windows/cross-platform) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Real Spotify playback on native Windows (and any platform) via a Rust librespot sidecar whose stdout PCM feeds the existing external-PCM player path, controlled as a Spotify Connect controller through the Web API — behind the SAME `SpotifyAudioBackend` seam Stage 2 defined, so `SpotifyController`/`instance.ts` are largely unchanged. + +**Architecture:** `RustLibrespotBackend` spawns `librespot --backend pipe` (stdout PCM, cross-platform) → ffmpeg 44.1k→48k → the Stage-2 `playPcmStream` path. Since Rust librespot is a passive Connect receiver with no play-by-URI CLI, the bot drives playback via the Spotify Web API Connect endpoints (find device → transfer → play URI → pause/seek), authorized by a user OAuth (Authorization Code + PKCE) token that also bootstraps librespot via `--access-token`. Track-end is detected by polling `GET /v1/me/player`. `SpotifyController.chooseBackend()` selects go-librespot on Linux and Rust librespot elsewhere (or per `config.spotify.backend`). + +**Tech Stack:** TypeScript (ESM, `.js`), Node 25, `axios` (already a dep), `node:crypto` (PKCE), `vitest`. External binary: Rust librespot (any platform; no prebuilt — user installs). + +## Global Constraints + +- ESM: all relative imports use the `.js` extension. +- **Reuses the Stage-2 `SpotifyAudioBackend` interface verbatim** — do NOT change it. `RustLibrespotBackend` is a second implementation behind the same seam; `SpotifyController`/`instance.ts` orchestration is unchanged except `chooseBackend()`. +- **Gated & additive:** the Rust backend activates only when `config.spotify.enabled` && a `librespot` binary is resolvable && OAuth is authorized. Otherwise `ensureStarted()` returns false → the Stage-1 sentinel fallback message (queue keeps moving). Do NOT break the Stage-2 go-librespot path or ANY existing test. +- **Backend selection:** `config.spotify.backend` ("auto"|"go-librespot"|"librespot"); `auto` → go-librespot on Linux (if its binary is present), else Rust librespot (if present), else none. +- **Not e2e-testable here:** real audio needs Spotify Premium + a real librespot + a real account. "Done" = code complete, unit-tested with INJECTED/mocked `child_process`/HTTP (no real binary/network), `tsc --noEmit` clean, full `vitest run` green. Never claim audio "works". +- **OAuth (PKCE):** default client = librespot's public client `65b708073fc0480ea92a077233ca87bd` (redirect `http://127.0.0.1:5588/login`); if the user set `config.spotify.clientId`, use their own Developer app with a loopback `http://127.0.0.1:/callback` redirect. Access token ~1h; refresh token ROTATES (persist the newest) and expires ~6 months from original auth (re-auth on `invalid_grant`). Never store a client secret for PKCE. Token store persists under the data dir. ⚠️ Reusing librespot's first-party client is ToS-gray — this is already an experimental, opt-in, Premium-only feature. +- **Connect control endpoints (exact):** `GET /v1/me/player/devices`; `PUT /v1/me/player {device_ids,[play]}`; `PUT /v1/me/player/play?device_id= {uris:[uri]}`; `PUT /v1/me/player/pause`; `PUT /v1/me/player/play` (resume); `PUT /v1/me/player/seek?position_ms=`; `GET /v1/me/player` (204 = no active device). Scopes: `user-modify-playback-state`, `user-read-playback-state`. +- Resolve ffmpeg via `getFfmpegCommand()` (exported from `src/audio/player.js` in Stage 2), never a literal `"ffmpeg"`. +- No new npm dependencies. Run tests: `npx vitest run `; typecheck `npx tsc --noEmit`; full suite `npx vitest run --no-file-parallelism` (avoids pre-existing users.test.ts bcrypt LOAD-timeouts). Research facts: `.superpowers/sdd/stage3-research-map.md`. + +## REQUIRED CORRECTIONS (post-review — OVERRIDE the task sections below where they conflict) + +**C3.1 (blocker) — ONE shared `SpotifyOAuth` + `SpotifyConnectApi`, threaded to BOTH the web layer AND every controller.** The drafts wrongly build TWO separate OAuth stores (a per-bot one in the controller and a process-wide one in the web server), so a browser login never reaches the backend. Fix: +- In `src/index.ts` build a single instance of each (after config load): + ```ts + const spotifyRedirectUri = + (config.publicUrl.trim().replace(/\/+$/, "") || `http://127.0.0.1:${config.webPort}`) + "/api/spotify/callback"; + const spotifyTokenStore = createSpotifyTokenStore(SPOTIFY_DATA_DIR); // createSpotifyTokenStore takes a DIRECTORY and writes /oauth-tokens.json — pass the dir, not the filename + const spotifyOAuth = new SpotifyOAuth({ clientId: config.spotify.clientId || undefined, redirectUri: spotifyRedirectUri, store: spotifyTokenStore }); + const spotifyConnect = new SpotifyConnectApi(() => spotifyOAuth.getAccessToken()); + ``` +- Thread the SAME `spotifyOAuth` + `spotifyConnect` into `new BotManager(...)` (add trailing optional params, mirroring `SPOTIFY_DATA_DIR`) AND into `createWebServer({ ..., spotifyOAuth })`. +- `BotManager`: accept `spotifyOAuth?`/`spotifyConnect?`, pass them into ALL THREE `new BotInstance({...})` sites. +- `BotInstance` (`instance.ts:79-88,167-176`): add `spotifyOAuth?`/`spotifyConnect?` to its options and forward into the controller: `new SpotifyController({ ...o, oauth: options.spotifyOAuth, connect: options.spotifyConnect })`. +- `SpotifyController` (Task 5): add `oauth?: SpotifyOAuth` and `connect?: SpotifyConnectApi` to `SpotifyControllerOptions`; store and use them; `chooseBackend()` passes them to `RustLibrespotBackend`. The controller MUST NOT construct its own OAuth/store. If `oauth`/`connect` are absent, the Rust backend is simply unavailable. + +**C3.2 (major) — auth model = the user's OWN Spotify Developer app + the web `/callback` redirect; DROP the librespot-public-client default and the orphaned `:5588` listener.** Reusing librespot's first-party client forces the fixed `127.0.0.1:5588/login` redirect, which no task handles (the code is dropped). Instead: +- The OAuth control flow REQUIRES `config.spotify.clientId` (the user's own Developer app — they already need it for Stage-1 metadata). `redirect_uri` = the web callback computed above (`…/api/spotify/callback`). Requested scopes = `SPOTIFY_CONTROL_SCOPES` (includes `streaming`, so the SAME token also bootstraps librespot via `--access-token`). +- `SpotifyOAuth`: if `clientId` is empty, `isAuthorized()` is false and `buildAuthorizeUrl()` throws a clear "set Spotify Client ID first" error. Remove the `LIBRESPOT_PUBLIC_CLIENT_ID`/`LIBRESPOT_REDIRECT_URI` default path and any `:5588` listener from all tasks (Task 6 does NOT stand one up). +- `SpotifyController.ensureStarted()` Rust branch requires `config.spotify.clientId` set AND `oauth.isAuthorized()`; else returns false → the Stage-1 fallback message (hint: "set Spotify Client ID and log in from Settings"). +- README/docs (Stage 4) must tell the user to register `http://127.0.0.1:/api/spotify/callback` (and their `publicUrl` variant) as a Redirect URI in their Spotify app. + +**C3.3 (minor) — `/api/spotify/status` reports the RESOLVED backend, not the raw selector.** Task 5 MUST add and export a pure `export function chooseBackendKind(config: SpotifyConfig): "go-librespot" | "librespot" | "none"` in `controller.ts` (same logic as the instance `chooseBackend()`: `config.spotify.backend` override, else `auto` → `isGoLibrespotSupported() && existsSync(findGoLibrespot())` ? "go-librespot" : `isRustLibrespotSupported() && existsSync(findLibrespot())` ? "librespot" : "none"). The web `status` route (Task 6) imports and calls it, instead of returning `config.spotify.backend`. (The instance `chooseBackend()` should delegate to this pure function so there is one source of truth.) + +**C3.4 (major) — Task 4 track-end poll must NOT fire at startup.** In `rust-librespot.ts` `pollState()`, gate the progress-based end condition on `this.hasPlayed` like the other two: `const finishedByProgress = this.hasPlayed && state.durationMs > 0 && state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS;`. Otherwise the first poll (before the bot ever calls `playTrack`) can observe the account's own stale/paused track already near its end and spuriously emit `trackEnded`, wrongly advancing the queue. Only emit any end signal after the bot's own track has been seen playing (`hasPlayed`). + +**C3.5 (minor) — Task 4 poll: treat a post-playback 204 as track-end.** `pollState()` currently early-returns on a null state (204 / no active device). After the bot's track has played, librespot going idle returns 204, so the queue would stall (no `trackEnded`). Fix: when `!state && this.hasPlayed && this.currentUri && !this.endedForCurrent`, emit `trackEnded` for `this.currentUri` (reason "ended") and reset, instead of an unconditional early return. (Before any play, a null state still just returns.) + +**C3.6 (minor) — Task 3 Connect API: mutating calls must not throw up the queue path.** `transfer/play/pause/resume/seek` currently have no try/catch, so a 403 (non-Premium)/404 (no device)/429 (rate-limit) rejects and propagates, making `playTrack` throw an unhandled rejection. Wrap each mutating PUT in try/catch that logs and either swallows or throws a typed error the controller/backend can classify — so a transient failure degrades gracefully (the backend can retry/fallback) rather than crashing the advance path. + +**C3.7 (minor) — Task 2 OAuth: always clear the PKCE verifier for a state.** In `handleCallback`, `pendingVerifiers.delete(state)` runs only on success, so failed logins leak Map entries. Use `try { … } finally { this.pendingVerifiers.delete(state); }` around the token exchange so the verifier is removed on every terminal path. + +## File structure + +**New:** `src/music/spotify/{spotify-oauth,connect-api,rust-librespot}.ts` (+ tests); `src/web/api/spotify.ts` (+ test). +**Modified:** `src/music/spotify/binary.ts` (Rust resolver fns), `src/music/spotify/controller.ts` (chooseBackend + OAuth/Connect ownership + rust-needs-auth gate), `src/web/server.ts` (mount router), `src/index.ts` (+ maybe `src/bot/manager.ts`) to construct/expose the controller's OAuth, and possibly `src/data/config.ts` (only if a new field is truly needed — prefer reusing `spotify.clientId`). + +--- + +### Task 1: Rust librespot binary resolver + +Append the Rust-librespot resolver functions to the existing Stage-2 `binary.ts`, mirroring the go-librespot functions already in that file. Unlike go-librespot (Linux-only release binaries + POSIX FIFO), Rust librespot's `--backend pipe` writes PCM to **stdout** on every platform, so `isRustLibrespotSupported()` is unconditionally `true` and `findLibrespot()` must resolve `librespot.exe` on win32. + +**Files:** +- MODIFY `src/music/spotify/binary.ts` — append `isRustLibrespotSupported`, `pickLibrespotPath`, `findLibrespot`, `checkLibrespotAvailable`, `resetLibrespotBinaryCache`, `__setLibrespotVersionProbe` (test hook). Do NOT touch the existing go-librespot exports. +- MODIFY `src/music/spotify/binary.test.ts` — append a `describe` block per new function (mocked/unit-level; no real binary). + +**Interfaces:** + +Consumes (existing module internals, reuse verbatim — already imported at the top of `binary.ts`): +```ts +import { execFile } from "node:child_process"; // via execFileAsync = promisify(execFile) +import { existsSync } from "node:fs"; +import { join } from "node:path"; +// __dirname = dirname(fileURLToPath(import.meta.url)) — already defined in the file +``` + +Produces (append to `binary.ts`; these exact signatures are the LOCKED CONTRACT): +```ts +export function isRustLibrespotSupported(): boolean // true on ALL platforms +export function pickLibrespotPath(candidates: string[], exists: (p: string) => boolean): string // pure resolver core (mirrors pickGoLibrespotPath) +export function findLibrespot(): string // bin/librespot(.exe) then PATH +export function resetLibrespotBinaryCache(): void +export async function checkLibrespotAvailable(): Promise // `librespot --version` runs +export function __setLibrespotVersionProbe(probe: ((bin: string) => Promise) | null): void // test hook +``` + +Note: `pickLibrespotPath` is not named in the contract but is added (exported) to mirror the existing `pickGoLibrespotPath` seam so the win32-exe path-ordering is unit-testable without a real binary — consistent with the file's established convention. + +--- + +Bite-sized steps (TDD — write the failing test, then the code, per checkbox): + +- [ ] **Step 1 — RED: platform-support test.** Append to `src/music/spotify/binary.test.ts`. First extend the import at the top of the file to also pull the new symbols: + + ```ts + import { + isRustLibrespotSupported, + pickLibrespotPath, + findLibrespot, + checkLibrespotAvailable, + resetLibrespotBinaryCache, + __setLibrespotVersionProbe, + } from "./binary.js"; + ``` + + Extend the existing `afterEach` to also reset the Rust state (add these two lines inside the existing `afterEach` body): + + ```ts + __setLibrespotVersionProbe(null); + resetLibrespotBinaryCache(); + ``` + + Then append the block: + + ```ts + describe("isRustLibrespotSupported", () => { + it("is true on every platform (pipe->stdout works everywhere)", () => { + setPlatform("linux"); + expect(isRustLibrespotSupported()).toBe(true); + setPlatform("win32"); + expect(isRustLibrespotSupported()).toBe(true); + setPlatform("darwin"); + expect(isRustLibrespotSupported()).toBe(true); + }); + }); + ``` + + Run `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: fails to compile / import error (symbols not exported yet). + +- [ ] **Step 2 — GREEN: append `isRustLibrespotSupported` + `pickLibrespotPath` to `binary.ts`.** Append at the end of `src/music/spotify/binary.ts` (after `resetGoLibrespotBinaryCache`): + + ```ts + // --------------------------------------------------------------------------- + // Rust librespot (librespot-org) resolver — mirrors the go-librespot fns + // above. Unlike go-librespot, Rust librespot's `--backend pipe` writes PCM to + // *stdout* on every platform (no FIFO, no audio device), so it is supported + // on Windows/macOS/Linux alike and the binary is named librespot.exe on win32. + // --------------------------------------------------------------------------- + + /** + * True on ALL platforms. The Rust librespot pipe backend writes raw bytes to + * process stdout, which Node's spawned child.stdout receives unmodified on + * Windows too — so there is no platform gate here (contrast + * isGoLibrespotSupported, which is Linux-only). + */ + export function isRustLibrespotSupported(): boolean { + return true; + } + + /** + * Pure resolver core behind findLibrespot(). Returns the first candidate that + * is either a bare command name (left for execFile to resolve via PATH) or an + * existing bin/ file. Exported so tests can inject candidates + a fake + * existence predicate and need no real binary on disk. Mirrors + * pickGoLibrespotPath but keys off the win32 exe name. + */ + export function pickLibrespotPath( + candidates: string[], + exists: (p: string) => boolean, + ): string { + const exe = process.platform === "win32" ? "librespot.exe" : "librespot"; + for (const c of candidates) { + // bin/ paths only count when the file is actually present; bare names are + // returned unconditionally and resolved later via PATH. + const isBinPath = c.includes(join("bin", "librespot")); + if (!isBinPath || exists(c)) return c; + } + return exe; + } + ``` + + Run `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: the `isRustLibrespotSupported` block passes (the rest of the new blocks don't exist yet). + +- [ ] **Step 3 — RED: path-ordering + win32 exe-name tests.** Append to `binary.test.ts`: + + ```ts + describe("pickLibrespotPath (bin/ then PATH ordering, win32 exe)", () => { + it("prefers the bin/ path when the file exists", () => { + const binPath = join("some", "root", "bin", "librespot"); + expect( + pickLibrespotPath([binPath, "librespot"], (p) => p === binPath), + ).toBe(binPath); + }); + + it("prefers the bin/librespot.exe path on win32 when it exists", () => { + setPlatform("win32"); + const binExe = join("some", "root", "bin", "librespot.exe"); + expect( + pickLibrespotPath([binExe, "librespot.exe"], (p) => p === binExe), + ).toBe(binExe); + }); + + it("falls through to the bare PATH name (librespot) on posix when bin/ is missing", () => { + setPlatform("linux"); + const binPath = join("some", "root", "bin", "librespot"); + expect(pickLibrespotPath([binPath, "librespot"], () => false)).toBe( + "librespot", + ); + }); + + it("falls through to librespot.exe on win32 when bin/ is missing", () => { + setPlatform("win32"); + const binExe = join("some", "root", "bin", "librespot.exe"); + expect(pickLibrespotPath([binExe], () => false)).toBe("librespot.exe"); + }); + + it("returns bare command names without touching the filesystem", () => { + const exists = vi.fn(() => false); + expect(pickLibrespotPath(["librespot"], exists)).toBe("librespot"); + expect(exists).not.toHaveBeenCalled(); + }); + }); + + describe("findLibrespot", () => { + it("returns the bare command name when bin/librespot is absent", () => { + // No librespot binary is committed under bin/, so resolution must fall + // back to the bare PATH name (execFile resolves it at run time). + setPlatform("linux"); + expect(findLibrespot()).toBe("librespot"); + }); + + it("returns librespot.exe on win32 when bin/librespot.exe is absent", () => { + setPlatform("win32"); + expect(findLibrespot()).toBe("librespot.exe"); + }); + }); + ``` + + Run `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: the `pickLibrespotPath`/`findLibrespot` blocks fail (`findLibrespot` not exported yet). + +- [ ] **Step 4 — GREEN: append `findLibrespot` to `binary.ts`.** Append after `pickLibrespotPath`: + + ```ts + /** + * Resolve the Rust librespot binary path: project bin/ dir first, then PATH. + * On win32 both the bin/librespot.exe candidate and the bare "librespot.exe" + * fallback are used so a PATH-installed librespot.exe (scoop/choco) resolves. + */ + export function findLibrespot(): string { + const exe = process.platform === "win32" ? "librespot.exe" : "librespot"; + // src/music/spotify -> ../../../bin (same depth as findGoLibrespot). + const binExe = join(__dirname, "..", "..", "..", "bin", exe); + const binBare = join(__dirname, "..", "..", "..", "bin", "librespot"); + return pickLibrespotPath([binExe, binBare, exe], existsSync); + } + ``` + + Run `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: all `pickLibrespotPath` + `findLibrespot` tests pass. + +- [ ] **Step 5 — RED: availability-cache tests.** Append to `binary.test.ts`: + + ```ts + describe("checkLibrespotAvailable", () => { + it("returns true when the binary responds to --version (any platform)", async () => { + setPlatform("win32"); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + }); + + it("returns true on darwin too (no platform gate)", async () => { + setPlatform("darwin"); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + }); + + it("caches only positive results and probes once", async () => { + setPlatform("linux"); + const probe = vi.fn(async () => {}); + __setLibrespotVersionProbe(probe); + expect(await checkLibrespotAvailable()).toBe(true); + expect(await checkLibrespotAvailable()).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("does not cache a failed probe (retries on the next call)", async () => { + setPlatform("win32"); + __setLibrespotVersionProbe(async () => { + throw new Error("ENOENT"); + }); + expect(await checkLibrespotAvailable()).toBe(false); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + }); + + it("resetLibrespotBinaryCache clears a cached positive", async () => { + setPlatform("linux"); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + resetLibrespotBinaryCache(); + __setLibrespotVersionProbe(async () => { + throw new Error("gone"); + }); + expect(await checkLibrespotAvailable()).toBe(false); + }); + + it("de-dupes concurrent in-flight probes", async () => { + setPlatform("linux"); + const probe = vi.fn(async () => {}); + __setLibrespotVersionProbe(probe); + const [a, b] = await Promise.all([ + checkLibrespotAvailable(), + checkLibrespotAvailable(), + ]); + expect(a).toBe(true); + expect(b).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); + }); + }); + ``` + + Run `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: the `checkLibrespotAvailable` block fails (not exported yet). + +- [ ] **Step 6 — GREEN: append the probe + cache to `binary.ts`.** Append after `findLibrespot`. Note: use module-scoped state names distinct from the go-librespot ones (`rustCachedAvailable`, `rustPendingCheck`) so the two caches never collide: + + ```ts + // Injectable `--version` probe. Defaults to the real execFile call; tests + // override it so checkLibrespotAvailable() needs no real binary. Keeps the + // public checkLibrespotAvailable() signature param-free per the contract. + type LibrespotVersionProbe = (bin: string) => Promise; + const realLibrespotProbe: LibrespotVersionProbe = async (bin) => { + await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 }); + }; + let librespotVersionProbe: LibrespotVersionProbe = realLibrespotProbe; + + /** Test hook: override the `--version` probe, or restore the default with null. */ + export function __setLibrespotVersionProbe( + probe: LibrespotVersionProbe | null, + ): void { + librespotVersionProbe = probe ?? realLibrespotProbe; + } + + /** + * Availability check for Rust librespot. No platform gate (supported + * everywhere). Runs `librespot --version` (5s timeout) and caches ONLY the + * positive result — a missing binary is retried on the next call so the + * operator can install it (cargo/scoop/choco) without restarting the server. + */ + let rustCachedAvailable = false; + let rustPendingCheck: Promise | null = null; + export async function checkLibrespotAvailable(): Promise { + if (!isRustLibrespotSupported()) return false; + if (rustCachedAvailable) return true; + if (rustPendingCheck) return rustPendingCheck; + rustPendingCheck = (async () => { + try { + await librespotVersionProbe(findLibrespot()); + rustCachedAvailable = true; + return true; + } catch { + return false; + } finally { + rustPendingCheck = null; + } + })(); + return rustPendingCheck; + } + + /** Force re-detection on the next call (for tests). */ + export function resetLibrespotBinaryCache(): void { + rustCachedAvailable = false; + rustPendingCheck = null; + } + ``` + + Run `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: all blocks pass (both the pre-existing go-librespot tests and the new Rust ones). + +- [ ] **Step 7 — Full verify.** + - `npx vitest run src/music/spotify/binary.test.ts` — EXPECT: all tests green (existing go-librespot suite + new Rust suite), 0 failures. + - `npx tsc --noEmit` — EXPECT: no errors (clean exit 0). Confirms the appended ESM `.js`-import module type-checks and no symbol/name collisions with the go-librespot exports. + - Optionally `npx vitest run` to confirm no sibling Stage-2 spotify tests regressed. + +- [ ] **Step 8 — Commit.** + ```bash + git add src/music/spotify/binary.ts src/music/spotify/binary.test.ts + git commit -m "$(cat <<'EOF' + feat(spotify): add Rust librespot binary resolver (Stage 3 Task 1) + + Append isRustLibrespotSupported/pickLibrespotPath/findLibrespot/ + checkLibrespotAvailable/resetLibrespotBinaryCache to binary.ts, mirroring + the go-librespot resolver. Supported on all platforms (pipe->stdout), + resolves librespot.exe on win32, caches only positive --version probes. + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +Notes / guardrails for the implementer: +- This is Windows-targeted and NOT e2e-testable (no Premium account, no real binary/network) — every test injects the `__setLibrespotVersionProbe` hook and `setPlatform`, so no `librespot` binary or network is ever touched. +- Do NOT modify or reuse the go-librespot module state (`cachedAvailable`/`pendingCheck`) — the Rust cache uses its own `rustCachedAvailable`/`rustPendingCheck` to avoid cross-contaminating the two availability checks. +- Keep the append purely additive: the existing Stage-2 exports and their tests must remain byte-for-byte unchanged (the only edit to the existing test file is extending the shared import statement and the shared `afterEach`). + +--- + +### Task 2: Spotify OAuth (Authorization Code + PKCE) + +**Files:** +- CREATE `src/music/spotify/spotify-oauth.ts` +- CREATE `src/music/spotify/spotify-oauth.test.ts` + +**Interfaces:** + +*Consumes:* +- `axios` → `import axios, { type AxiosInstance } from "axios"` — injected via `deps.http` (mirrors `webapi.ts` line 1), default `axios.create({ baseURL: "https://accounts.spotify.com", timeout: 15_000 })`. +- `node:crypto` → `createHash`, `randomBytes` (PKCE S256 + state/verifier generation; no network). +- `node:fs` / `node:path` → for the concrete `createFileOAuthTokenStore` only. + +*Produces (exact, per LOCKED CONTRACT):* +```ts +export const LIBRESPOT_PUBLIC_CLIENT_ID = "65b708073fc0480ea92a077233ca87bd" +export const LIBRESPOT_REDIRECT_URI = "http://127.0.0.1:5588/login" +export const SPOTIFY_CONTROL_SCOPES = "streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private" +export interface OAuthTokens { accessToken: string; refreshToken: string; expiresAt: number; scope: string } +export interface OAuthTokenStore { load(): OAuthTokens | null; save(t: OAuthTokens): void; clear(): void } +export interface SpotifyOAuthOptions { clientId?: string; redirectUri?: string; store: OAuthTokenStore; deps?: { http?: import("axios").AxiosInstance } } +export function generateCodeVerifier(): string // helper — 64 url-safe chars +export function codeChallengeS256(verifier: string): string // helper — base64url(sha256), no padding +export function createFileOAuthTokenStore(filePath: string): OAuthTokenStore +export class SpotifyOAuth { + constructor(o: SpotifyOAuthOptions) + getClientId(): string + getRedirectUri(): string + isAuthorized(): boolean + buildAuthorizeUrl(): { url: string; state: string } + handleCallback(code: string, state: string): Promise + getAccessToken(): Promise +} +``` + +*Contract binding rules (from research map §OAuth):* PKCE verifier 43–128 chars from `[A-Za-z0-9-._~]` (we use 64); `code_challenge = base64url(SHA256(verifier))` no padding, `code_challenge_method=S256`; loopback redirect `http://127.0.0.1:5588/login` (NOT `localhost`); token endpoint is form-encoded, public client (no `client_secret`); refresh **rotates** the refresh token → always persist the newest; HTTP 400 `error=invalid_grant` → discard stored token (clear store) and return `null`. + +--- + +- [ ] **Step 1 — Write the failing test first** (`src/music/spotify/spotify-oauth.test.ts`). Mocks the injected `http` (axios) — **no real network** — and independently recomputes the S256 challenge with `node:crypto` to prove the verifier→challenge binding end-to-end. + +```ts +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SpotifyOAuth, + LIBRESPOT_PUBLIC_CLIENT_ID, + LIBRESPOT_REDIRECT_URI, + SPOTIFY_CONTROL_SCOPES, + generateCodeVerifier, + codeChallengeS256, + createFileOAuthTokenStore, + type OAuthTokens, + type OAuthTokenStore, +} from "./spotify-oauth.js"; + +/** In-memory store exposing `.value` so tests can assert persistence. */ +function memStore( + initial: OAuthTokens | null = null, +): OAuthTokenStore & { value: OAuthTokens | null } { + const s = { + value: initial, + load() { + return s.value; + }, + save(t: OAuthTokens) { + s.value = t; + }, + clear() { + s.value = null; + }, + }; + return s; +} + +describe("PKCE helpers", () => { + it("generateCodeVerifier returns 64 chars from the unreserved set", () => { + const v = generateCodeVerifier(); + expect(v).toHaveLength(64); + expect(v).toMatch(/^[A-Za-z0-9\-._~]{64}$/); + expect(generateCodeVerifier()).not.toBe(v); // random + }); + + it("codeChallengeS256 is base64url(sha256) with no padding (43 chars)", () => { + const c = codeChallengeS256("abc123"); + expect(c).toHaveLength(43); // 32-byte digest -> 43 base64url chars + expect(c).not.toContain("="); + expect(c).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); + +describe("SpotifyOAuth.buildAuthorizeUrl", () => { + it("builds accounts.spotify.com/authorize with the librespot defaults + S256", () => { + const oauth = new SpotifyOAuth({ store: memStore() }); + const { url, state } = oauth.buildAuthorizeUrl(); + const u = new URL(url); + expect(u.origin + u.pathname).toBe("https://accounts.spotify.com/authorize"); + const p = u.searchParams; + expect(p.get("client_id")).toBe(LIBRESPOT_PUBLIC_CLIENT_ID); + expect(p.get("response_type")).toBe("code"); + expect(p.get("redirect_uri")).toBe(LIBRESPOT_REDIRECT_URI); + expect(p.get("code_challenge_method")).toBe("S256"); + expect(p.get("code_challenge")).toHaveLength(43); + expect(p.get("scope")).toBe(SPOTIFY_CONTROL_SCOPES); + expect(p.get("state")).toBe(state); + expect(state).toMatch(/^[0-9a-f]{32}$/); + expect(oauth.getClientId()).toBe(LIBRESPOT_PUBLIC_CLIENT_ID); + expect(oauth.getRedirectUri()).toBe(LIBRESPOT_REDIRECT_URI); + }); + + it("honors a custom clientId + redirectUri", () => { + const oauth = new SpotifyOAuth({ + clientId: "myapp", + redirectUri: "http://127.0.0.1:9000/callback", + store: memStore(), + }); + const { url } = oauth.buildAuthorizeUrl(); + const p = new URL(url).searchParams; + expect(p.get("client_id")).toBe("myapp"); + expect(p.get("redirect_uri")).toBe("http://127.0.0.1:9000/callback"); + }); +}); + +describe("SpotifyOAuth.handleCallback", () => { + it("exchanges the code (PKCE verifier matches the authorize challenge) and persists tokens", async () => { + const store = memStore(); + const http = { + post: vi.fn().mockResolvedValue({ + data: { + access_token: "a1", + refresh_token: "r1", + expires_in: 3600, + scope: SPOTIFY_CONTROL_SCOPES, + }, + }), + } as any; + const oauth = new SpotifyOAuth({ store, deps: { http } }); + + const { url, state } = oauth.buildAuthorizeUrl(); + const challenge = new URL(url).searchParams.get("code_challenge")!; + + const ok = await oauth.handleCallback("CODE123", state); + expect(ok).toBe(true); + + const [path, bodyStr, cfg] = http.post.mock.calls[0]; + expect(path).toBe("/api/token"); + expect(cfg.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(bodyStr as string); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("CODE123"); + expect(body.get("redirect_uri")).toBe(LIBRESPOT_REDIRECT_URI); + expect(body.get("client_id")).toBe(LIBRESPOT_PUBLIC_CLIENT_ID); + // The verifier sent MUST hash to the challenge advertised in the authorize URL. + const verifier = body.get("code_verifier")!; + expect(codeChallengeS256(verifier)).toBe(challenge); + + expect(store.value?.accessToken).toBe("a1"); + expect(store.value?.refreshToken).toBe("r1"); + expect(store.value?.expiresAt).toBeGreaterThan(Date.now()); + expect(oauth.isAuthorized()).toBe(true); + }); + + it("rejects an unknown state without calling the token endpoint (CSRF guard)", async () => { + const http = { post: vi.fn() } as any; + const oauth = new SpotifyOAuth({ store: memStore(), deps: { http } }); + expect(await oauth.handleCallback("CODE", "not-a-real-state")).toBe(false); + expect(http.post).not.toHaveBeenCalled(); + }); +}); + +describe("SpotifyOAuth.getAccessToken", () => { + it("returns the cached token without refreshing when still valid", async () => { + const http = { post: vi.fn() } as any; + const store = memStore({ + accessToken: "cached", + refreshToken: "r1", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ store, deps: { http } }); + expect(await oauth.getAccessToken()).toBe("cached"); + expect(http.post).not.toHaveBeenCalled(); + }); + + it("refreshes when expired and persists the ROTATED refresh token", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockResolvedValue({ + data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 }, + }), + } as any; + const oauth = new SpotifyOAuth({ store, deps: { http } }); + + expect(await oauth.getAccessToken()).toBe("a2"); + const body = new URLSearchParams(http.post.mock.calls[0][1] as string); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("r1"); + expect(body.get("client_id")).toBe(LIBRESPOT_PUBLIC_CLIENT_ID); + expect(store.value?.refreshToken).toBe("r2"); // rotated + persisted + expect(store.value?.accessToken).toBe("a2"); + }); + + it("keeps the old refresh token when the refresh response omits a new one", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockResolvedValue({ data: { access_token: "a2", expires_in: 3600 } }), + } as any; + const oauth = new SpotifyOAuth({ store, deps: { http } }); + expect(await oauth.getAccessToken()).toBe("a2"); + expect(store.value?.refreshToken).toBe("r1"); + }); + + it("clears the store and returns null on invalid_grant (expired refresh token)", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockRejectedValue({ + response: { status: 400, data: { error: "invalid_grant" } }, + }), + } as any; + const oauth = new SpotifyOAuth({ store, deps: { http } }); + expect(await oauth.getAccessToken()).toBeNull(); + expect(store.value).toBeNull(); + expect(oauth.isAuthorized()).toBe(false); + }); + + it("returns null when unauthorized (no stored refresh token)", async () => { + const http = { post: vi.fn() } as any; + const oauth = new SpotifyOAuth({ store: memStore(), deps: { http } }); + expect(await oauth.getAccessToken()).toBeNull(); + expect(oauth.isAuthorized()).toBe(false); + expect(http.post).not.toHaveBeenCalled(); + }); +}); + +describe("createFileOAuthTokenStore", () => { + it("round-trips save/load and clear() removes it", () => { + const dir = mkdtempSync(join(tmpdir(), "sp-oauth-")); + const file = join(dir, "nested", "tokens.json"); + try { + const store = createFileOAuthTokenStore(file); + expect(store.load()).toBeNull(); // missing file + const t: OAuthTokens = { + accessToken: "a", + refreshToken: "r", + expiresAt: 123, + scope: "s", + }; + store.save(t); + expect(store.load()).toEqual(t); + store.clear(); + expect(store.load()).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); +``` + +Run it and confirm it fails (module not found / red): +``` +npx vitest run src/music/spotify/spotify-oauth.test.ts +``` +Expected: FAIL — `Failed to resolve import "./spotify-oauth.js"` (implementation not written yet). + +- [ ] **Step 2 — Implement `src/music/spotify/spotify-oauth.ts`** (complete, ESM `.js`-import-correct file). Mirrors `webapi.ts` axios-injection conventions; PKCE via `node:crypto`. + +```ts +import axios, { type AxiosInstance } from "axios"; +import { createHash, randomBytes } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; + +export const LIBRESPOT_PUBLIC_CLIENT_ID = "65b708073fc0480ea92a077233ca87bd"; +export const LIBRESPOT_REDIRECT_URI = "http://127.0.0.1:5588/login"; +export const SPOTIFY_CONTROL_SCOPES = + "streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private"; + +const ACCOUNTS_BASE = "https://accounts.spotify.com"; +// Hand a token back only if it survives ~30s, matching webapi.ts's skew. +const EXPIRY_SKEW_MS = 30_000; +// RFC 7636 §4.1 unreserved set: [A-Za-z0-9-._~]. +const PKCE_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; +const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }; + +export interface OAuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope: string; +} + +export interface OAuthTokenStore { + load(): OAuthTokens | null; + save(t: OAuthTokens): void; + clear(): void; +} + +export interface SpotifyOAuthOptions { + clientId?: string; + redirectUri?: string; + store: OAuthTokenStore; + deps?: { http?: AxiosInstance }; +} + +/** 64 random chars from the PKCE unreserved set (43-128 allowed by the spec). */ +export function generateCodeVerifier(): string { + const bytes = randomBytes(64); + let out = ""; + for (let i = 0; i < 64; i++) out += PKCE_CHARS[bytes[i] % PKCE_CHARS.length]; + return out; +} + +/** base64url(SHA256(verifier)) with no padding — the S256 code challenge. */ +export function codeChallengeS256(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +/** Persist OAuth tokens as a 0600 JSON file (used by the controller). */ +export function createFileOAuthTokenStore(filePath: string): OAuthTokenStore { + return { + load() { + try { + if (!existsSync(filePath)) return null; + const parsed = JSON.parse(readFileSync(filePath, "utf8")); + return parsed?.refreshToken ? (parsed as OAuthTokens) : null; + } catch { + return null; // missing/corrupt -> treat as unauthorized + } + }, + save(t: OAuthTokens) { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(t, null, 2), { mode: 0o600 }); + }, + clear() { + try { + rmSync(filePath, { force: true }); + } catch { + /* already gone */ + } + }, + }; +} + +/** + * Authorization Code + PKCE flow for the USER player-control token. Public + * client (no secret): the librespot keymaster client_id + loopback redirect by + * default, or a caller-registered app. Refresh rotates the refresh token, so + * the newest is always persisted; invalid_grant clears the store (re-login). + */ +export class SpotifyOAuth { + private clientId: string; + private redirectUri: string; + private store: OAuthTokenStore; + private http: AxiosInstance; + // Pending PKCE verifiers keyed by state, awaiting the loopback redirect back. + private pendingVerifiers = new Map(); + + constructor(o: SpotifyOAuthOptions) { + this.clientId = o.clientId ?? LIBRESPOT_PUBLIC_CLIENT_ID; + this.redirectUri = o.redirectUri ?? LIBRESPOT_REDIRECT_URI; + this.store = o.store; + this.http = + o.deps?.http ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 }); + } + + getClientId(): string { + return this.clientId; + } + + getRedirectUri(): string { + return this.redirectUri; + } + + isAuthorized(): boolean { + return !!this.store.load()?.refreshToken; + } + + buildAuthorizeUrl(): { url: string; state: string } { + const state = randomBytes(16).toString("hex"); + const verifier = generateCodeVerifier(); + this.pendingVerifiers.set(state, verifier); + const params = new URLSearchParams({ + client_id: this.clientId, + response_type: "code", + redirect_uri: this.redirectUri, + code_challenge: codeChallengeS256(verifier), + code_challenge_method: "S256", + scope: SPOTIFY_CONTROL_SCOPES, + state, + }); + return { url: `${ACCOUNTS_BASE}/authorize?${params.toString()}`, state }; + } + + async handleCallback(code: string, state: string): Promise { + const verifier = this.pendingVerifiers.get(state); + if (!verifier) return false; // unknown/expired state -> CSRF guard + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + code_verifier: verifier, + }); + try { + const { data } = await this.http.post("/api/token", body.toString(), { + headers: FORM_HEADERS, + }); + if (!data?.access_token || !data?.refresh_token) return false; + this.store.save(this.toTokens(data, data.refresh_token, data.scope)); + this.pendingVerifiers.delete(state); + return true; + } catch { + return false; + } + } + + async getAccessToken(): Promise { + const tokens = this.store.load(); + if (!tokens?.refreshToken) return null; // unauthorized + if (tokens.accessToken && Date.now() < tokens.expiresAt) { + return tokens.accessToken; + } + return this.refresh(tokens); + } + + private async refresh(current: OAuthTokens): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: current.refreshToken, + client_id: this.clientId, + }); + try { + const { data } = await this.http.post("/api/token", body.toString(), { + headers: FORM_HEADERS, + }); + if (!data?.access_token) return null; + // PKCE rotates the refresh token; fall back to the current one if omitted. + const rotated = data.refresh_token || current.refreshToken; + const saved = this.toTokens(data, rotated, data.scope ?? current.scope); + this.store.save(saved); + return saved.accessToken; + } catch (err: any) { + // invalid_grant => refresh token revoked/expired: discard, force re-login. + if (err?.response?.data?.error === "invalid_grant") this.store.clear(); + return null; + } + } + + private toTokens(data: any, refreshToken: string, scope: string): OAuthTokens { + return { + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000 - EXPIRY_SKEW_MS, + scope: scope ?? SPOTIFY_CONTROL_SCOPES, + }; + } +} +``` + +- [ ] **Step 3 — Run the test suite to green.** +``` +npx vitest run src/music/spotify/spotify-oauth.test.ts +``` +Expected: PASS — all describe blocks green (PKCE helpers, buildAuthorizeUrl defaults + custom, handleCallback exchange/CSRF, getAccessToken cache/rotate/keep-old/invalid_grant/unauthorized, file store round-trip). + +- [ ] **Step 4 — Type-check the whole project (no emit).** +``` +npx tsc --noEmit +``` +Expected: exit 0, no errors (ESM `.js` import specifiers resolve; `axios`/`node:crypto`/`node:fs` types clean). + +- [ ] **Step 5 — Commit.** +``` +git add src/music/spotify/spotify-oauth.ts src/music/spotify/spotify-oauth.test.ts +git commit -m "$(cat <<'EOF' +feat(spotify): add SpotifyOAuth Authorization Code + PKCE control-token flow + +Stage 3 Task 2. PKCE (S256) authorize URL, code exchange, and refresh with +rotated-refresh-token persistence + invalid_grant store-clear. axios/http and +token store injected for fully mocked, network-free unit tests. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +*Note: Windows-targeted, not e2e-testable (no Premium account / no live Spotify auth) — all tests are mocked unit-level, injecting `http` (axios) and using `node:crypto` for real PKCE math with zero network calls.* + +--- + +### Task 3: Spotify Connect control API client + +**Files:** +- CREATE `src/music/spotify/connect-api.ts` — `SpotifyConnectApi` wrapping an injected `AxiosInstance` with a Bearer token from `getToken()`. +- CREATE `src/music/spotify/connect-api.test.ts` — Vitest unit tests with a mocked axios instance (no network); asserts method + path + params + body + `Authorization` header for every call. + +**Interfaces:** + +_Consumes:_ +- `getToken: () => Promise` — supplied by the controller-owned `SpotifyOAuth` (Task 2). Returns a valid user access token or `null` when unauthorized. +- `import("axios").AxiosInstance` — injected via `deps.http` for tests; defaults to `axios.create({ baseURL: "https://api.spotify.com", timeout: 15_000 })` (mirrors `webapi.ts`). + +_Produces (exact signatures — verbatim from the locked contract):_ +```ts +export interface SpotifyDevice { id: string; name: string; is_active: boolean } +export interface PlaybackState { isPlaying: boolean; progressMs: number; trackUri: string | null; durationMs: number } +export class SpotifyConnectApi { + constructor(getToken: () => Promise, deps?: { http?: import("axios").AxiosInstance }) + getDevices(): Promise // GET /v1/me/player/devices + findDeviceByName(name: string): Promise // matching device id or null + transfer(deviceId: string, play?: boolean): Promise // PUT /v1/me/player {device_ids:[id], play} + play(deviceId: string, trackUri: string): Promise // PUT /v1/me/player/play?device_id={id} {uris:[uri]} + pause(deviceId?: string): Promise // PUT /v1/me/player/pause + resume(deviceId?: string): Promise // PUT /v1/me/player/play + seek(ms: number, deviceId?: string): Promise // PUT /v1/me/player/seek?position_ms={ms} + getPlaybackState(): Promise // GET /v1/me/player (null on 204 = no active device) +} +``` +Consumed downstream by `RustLibrespotBackend` (Task 4/5) and `controller.ts` (Task 7). + +--- + +**Steps (TDD — write the test file first, watch it fail, then implement):** + +- [ ] **Write the failing test file** `src/music/spotify/connect-api.test.ts`. Note the ESM `.js` import specifier and the `makeHttp` axios stub style copied from `go-librespot-api.test.ts` (only `get`/`put` are exercised): + +```ts +import { describe, it, expect, vi } from "vitest"; +import type { AxiosInstance } from "axios"; +import { SpotifyConnectApi } from "./connect-api.js"; + +/** Minimal axios stub: only get/put are exercised by the Connect client. */ +function makeHttp(overrides?: Partial>) { + return { + get: vi.fn().mockResolvedValue({ status: 200, data: {} }), + put: vi.fn().mockResolvedValue({ status: 200, data: {} }), + ...overrides, + } as unknown as AxiosInstance; +} + +const AUTH = { headers: { Authorization: "Bearer tok123" } }; +const token = () => vi.fn<[], Promise>().mockResolvedValue("tok123"); + +describe("SpotifyConnectApi.getDevices", () => { + it("GETs /v1/me/player/devices with the bearer header and maps the list", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + devices: [ + { id: "dev-1", name: "TS Bot", is_active: true, type: "Speaker" }, + { id: "dev-2", name: "Phone", is_active: false }, + ], + }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + const devices = await api.getDevices(); + expect(http.get).toHaveBeenCalledWith("/v1/me/player/devices", AUTH); + expect(devices).toEqual([ + { id: "dev-1", name: "TS Bot", is_active: true }, + { id: "dev-2", name: "Phone", is_active: false }, + ]); + }); + + it("returns [] when getToken() is null (unauthorized) without calling http", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await expect(api.getDevices()).resolves.toEqual([]); + expect(http.get).not.toHaveBeenCalled(); + }); + + it("returns [] on a 401/network rejection (graceful)", async () => { + const err: any = new Error("unauthorized"); + err.response = { status: 401 }; + const http = makeHttp({ get: vi.fn().mockRejectedValue(err) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getDevices()).resolves.toEqual([]); + }); +}); + +describe("SpotifyConnectApi.findDeviceByName", () => { + it("returns the matching device id", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { devices: [{ id: "dev-1", name: "TS Bot", is_active: false }] }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.findDeviceByName("TS Bot")).resolves.toBe("dev-1"); + }); + + it("returns null when no device name matches", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { devices: [{ id: "dev-1", name: "Other", is_active: false }] }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.findDeviceByName("TS Bot")).resolves.toBeNull(); + }); +}); + +describe("SpotifyConnectApi mutating calls", () => { + it("transfer() PUTs /v1/me/player with device_ids + play=false default", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.transfer("dev-1"); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player", + { device_ids: ["dev-1"], play: false }, + AUTH, + ); + }); + + it("transfer(id, true) forwards play=true", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.transfer("dev-1", true); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player", + { device_ids: ["dev-1"], play: true }, + AUTH, + ); + }); + + it("play() PUTs /v1/me/player/play?device_id= with the uris body", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.play("dev-1", "spotify:track:abc"); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player/play", + { uris: ["spotify:track:abc"] }, + { headers: { Authorization: "Bearer tok123" }, params: { device_id: "dev-1" } }, + ); + }); + + it("pause() PUTs /v1/me/player/pause (no params) with no body", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.pause(); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: undefined, + }); + }); + + it("pause(id) forwards device_id param", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.pause("dev-1"); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { device_id: "dev-1" }, + }); + }); + + it("resume() PUTs /v1/me/player/play with no uris body (resume)", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.resume(); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: undefined, + }); + }); + + it("seek() PUTs /v1/me/player/seek?position_ms=", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.seek(42000); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { position_ms: 42000 }, + }); + }); + + it("seek(ms, id) adds device_id param", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.seek(1000, "dev-1"); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { position_ms: 1000, device_id: "dev-1" }, + }); + }); + + it("mutating calls no-op (no http.put) when unauthorized", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await api.transfer("dev-1"); + await api.play("dev-1", "spotify:track:x"); + await api.pause(); + expect(http.put).not.toHaveBeenCalled(); + }); +}); + +describe("SpotifyConnectApi.getPlaybackState", () => { + it("GETs /v1/me/player and maps is_playing/progress/item", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + is_playing: true, + progress_ms: 12345, + item: { uri: "spotify:track:abc", duration_ms: 200000 }, + }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + const state = await api.getPlaybackState(); + expect(http.get).toHaveBeenCalledWith("/v1/me/player", AUTH); + expect(state).toEqual({ + isPlaying: true, + progressMs: 12345, + trackUri: "spotify:track:abc", + durationMs: 200000, + }); + }); + + it("returns null on 204 (no active device)", async () => { + const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 204, data: "" }) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + }); + + it("returns null when item is missing / trackUri null", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ status: 200, data: { is_playing: false, progress_ms: 0, item: null } }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toEqual({ + isPlaying: false, + progressMs: 0, + trackUri: null, + durationMs: 0, + }); + }); + + it("returns null on rejection (e.g. 401) instead of throwing", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("boom")) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + }); +}); +``` + +- [ ] **Run the test — expect failure** (module not found / class undefined): + - `npx vitest run src/music/spotify/connect-api.test.ts` + - Expected: FAIL — `Failed to resolve import "./connect-api.js"` (implementation does not exist yet). This confirms the tests execute and are red before implementation. + +- [ ] **Implement** `src/music/spotify/connect-api.ts` to make the tests pass. Mirrors the injected-axios + `getToken` conventions of `webapi.ts`/`go-librespot-api.ts`; read-only calls (`getDevices`, `getPlaybackState`) swallow errors and return `[]`/`null`, mutating calls no-op when unauthorized: + +```ts +import axios, { type AxiosInstance } from "axios"; + +const API_BASE = "https://api.spotify.com"; + +export interface SpotifyDevice { + id: string; + name: string; + is_active: boolean; +} + +export interface PlaybackState { + isPlaying: boolean; + progressMs: number; + trackUri: string | null; + durationMs: number; +} + +/** + * Spotify Web API "Connect" remote-control client. Wraps an axios instance and + * attaches a live user Bearer token from getToken() to every request. Read-only + * calls degrade to []/null on error; mutating calls no-op when unauthorized. + */ +export class SpotifyConnectApi { + private getToken: () => Promise; + private http: AxiosInstance; + + constructor(getToken: () => Promise, deps?: { http?: AxiosInstance }) { + this.getToken = getToken; + this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 }); + } + + /** Bearer auth headers, or null when no valid user token is available. */ + private async authHeaders(): Promise<{ Authorization: string } | null> { + const token = await this.getToken(); + if (!token) return null; + return { Authorization: `Bearer ${token}` }; + } + + async getDevices(): Promise { + const headers = await this.authHeaders(); + if (!headers) return []; + try { + const { data } = await this.http.get("/v1/me/player/devices", { headers }); + const list = Array.isArray(data?.devices) ? data.devices : []; + return list.map((d: any) => ({ + id: d?.id ?? "", + name: d?.name ?? "", + is_active: Boolean(d?.is_active), + })); + } catch { + return []; + } + } + + async findDeviceByName(name: string): Promise { + const devices = await this.getDevices(); + const match = devices.find((d) => d.name === name); + return match ? match.id : null; + } + + async transfer(deviceId: string, play = false): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + await this.http.put("/v1/me/player", { device_ids: [deviceId], play }, { headers }); + } + + async play(deviceId: string, trackUri: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + await this.http.put( + "/v1/me/player/play", + { uris: [trackUri] }, + { headers, params: { device_id: deviceId } }, + ); + } + + async pause(deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + await this.http.put("/v1/me/player/pause", undefined, { + headers, + params: deviceId ? { device_id: deviceId } : undefined, + }); + } + + async resume(deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + await this.http.put("/v1/me/player/play", undefined, { + headers, + params: deviceId ? { device_id: deviceId } : undefined, + }); + } + + async seek(ms: number, deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + const params: Record = { position_ms: ms }; + if (deviceId) params.device_id = deviceId; + await this.http.put("/v1/me/player/seek", undefined, { headers, params }); + } + + async getPlaybackState(): Promise { + const headers = await this.authHeaders(); + if (!headers) return null; + try { + const res = await this.http.get("/v1/me/player", { headers }); + // 204 = no active device / playback; body is empty. + if (res.status === 204 || !res.data) return null; + const d = res.data; + return { + isPlaying: Boolean(d.is_playing), + progressMs: Number(d.progress_ms ?? 0), + trackUri: d.item?.uri ?? null, + durationMs: Number(d.item?.duration_ms ?? 0), + }; + } catch { + return null; + } + } +} +``` + +- [ ] **Run the test — expect pass:** + - `npx vitest run src/music/spotify/connect-api.test.ts` + - Expected: PASS — all describe blocks green (`getDevices`, `findDeviceByName`, mutating calls, `getPlaybackState`), ~16 tests passing, 0 failing. + +- [ ] **Typecheck the whole project (no emit):** + - `npx tsc --noEmit` + - Expected: exits 0, no errors. Confirms the new `SpotifyDevice`/`PlaybackState`/`SpotifyConnectApi` exports and the ESM `.js` import in the test compile cleanly. + +- [ ] **Commit:** + - `git add src/music/spotify/connect-api.ts src/music/spotify/connect-api.test.ts` + - `git commit -m "$(cat <<'EOF' +feat(spotify): add SpotifyConnectApi Web API Connect control client + +Wraps an injected axios instance with a live user Bearer token from +getToken(): getDevices/findDeviceByName, transfer/play/pause/resume/seek, +and getPlaybackState (null on 204). Read-only calls degrade gracefully; +mutating calls no-op when unauthorized. Fully unit-tested with a mocked +AxiosInstance (no network) — Windows-targeted, not e2e-testable (no Premium). + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)"` + +**Notes / gotchas:** +- Source files are `.ts` in this repo but imports use the ESM `.js` specifier (`./connect-api.js`) — match `go-librespot-api.test.ts` exactly. +- Graceful error handling is verified by the "401 rejection → []/null" and "unauthorized → no http call" tests, covering the map's 401 (token) / 404 (no device) / 204 (no active device) cases without throwing. +- `getPlaybackState` returns `null` on HTTP 204 (no active device) — the poll loop in Task 5 relies on this exact contract for track-end detection. + +--- + +### Task 4: RustLibrespotBackend + +**Files:** +- CREATE `src/music/spotify/rust-librespot.ts` +- CREATE `src/music/spotify/rust-librespot.test.ts` + +**Interfaces:** + +_Consumes:_ +```ts +// ./backend.js (Stage-2, verbatim) +interface SpotifyAudioBackend { + start(): Promise; stop(): void; isReady(): boolean; + playTrack(uri: string): Promise; + pause(): Promise; resume(): Promise; seek(ms: number): Promise; + getPcmStream(): import("node:stream").Readable; getPositionMs(): number; + on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void; + on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void; + on(event: "ready" | "error", cb: (arg?: unknown) => void): void; +} +interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error"; } +interface SpotifyNowPlaying { uri: string; name: string; artist: string; album: string; coverUrl: string; durationMs: number; } + +// ./binary.js (Task 1) +function findLibrespot(): string; + +// ./spotify-oauth.js (Task 2) +class SpotifyOAuth { getAccessToken(): Promise; isAuthorized(): boolean; /* ... */ } + +// ./connect-api.js (Task 3) +interface SpotifyDevice { id: string; name: string; is_active: boolean; } +interface PlaybackState { isPlaying: boolean; progressMs: number; trackUri: string | null; durationMs: number; } +class SpotifyConnectApi { + getDevices(): Promise; + findDeviceByName(name: string): Promise; + transfer(deviceId: string, play?: boolean): Promise; + play(deviceId: string, trackUri: string): Promise; + pause(deviceId?: string): Promise; + resume(deviceId?: string): Promise; + seek(ms: number, deviceId?: string): Promise; + getPlaybackState(): Promise; +} + +// ../../audio/player.js (Stage-2) +function getFfmpegCommand(): string; +``` + +_Produces:_ +```ts +export interface RustLibrespotBackendOptions { + deviceName: string; bitrate: number; cacheDir: string; + oauth: SpotifyOAuth; connect?: SpotifyConnectApi; + logger: import("pino").Logger; deps?: RustLibrespotBackendDeps; +} +export interface RustLibrespotBackendDeps { + spawn?: typeof import("node:child_process").spawn; + mkdirSync?: typeof import("node:fs").mkdirSync; + findBinary?: () => string; + ffmpegCommand?: string; // C1: pinned in tests, getFfmpegCommand() in prod + sleep?: (ms: number) => Promise; + readyPollIntervalMs?: number; readyTimeoutMs?: number; statePollIntervalMs?: number; +} +export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBackend { /* full interface */ } +``` + +--- + +#### Steps + +- [ ] **1. Write the failing test file** `src/music/spotify/rust-librespot.test.ts` (mirrors `go-librespot.test.ts`; injects `child_process`/`connect`/`oauth`/`ffmpegCommand`; NO real binary, NO network — `SpotifyConnectApi`/`SpotifyOAuth` are stubbed plain objects): + +```ts +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import pino from "pino"; +import { RustLibrespotBackend } from "./rust-librespot.js"; + +const log = pino({ level: "silent" }); + +/** ChildProcess stand-in with real Readable/Writable pipes so stdout->stdin piping works. */ +function makeFakeChild() { + const child: any = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +function makeConnect() { + return { + getDevices: vi.fn(async () => [{ id: "dev1", name: "Test Bot", is_active: false }]), + findDeviceByName: vi.fn(async () => "dev1"), + transfer: vi.fn(async () => {}), + play: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + getPlaybackState: vi.fn(async () => null as any), + }; +} + +function makeOAuth() { + return { + getAccessToken: vi.fn(async () => "tok-123" as string | null), + isAuthorized: () => true, + }; +} + +function makeHarness(over: { connect?: any; oauth?: any } = {}) { + const calls: string[] = []; + const librespotChild = makeFakeChild(); + const ffmpegChild = makeFakeChild(); + + const spawn = vi.fn((cmd: string, ..._rest: any[]) => { + const isLibrespot = cmd.includes("librespot"); + calls.push(`spawn:${isLibrespot ? "librespot" : cmd}`); + return isLibrespot ? librespotChild : ffmpegChild; + }); + const mkdirSync = vi.fn(); + const connect = over.connect ?? makeConnect(); + const oauth = over.oauth ?? makeOAuth(); + + const backend = new RustLibrespotBackend({ + deviceName: "Test Bot", + bitrate: 320, + cacheDir: "/tmp/cache", + oauth: oauth as any, + connect: connect as any, + logger: log, + deps: { + spawn: spawn as any, + mkdirSync: mkdirSync as any, + findBinary: () => "/bin/librespot", + // C1: pin ffmpeg so arg-array assertions stay stable while prod uses getFfmpegCommand(). + ffmpegCommand: "ffmpeg", + sleep: async () => {}, + readyPollIntervalMs: 1, + readyTimeoutMs: 100, + // huge so the background setInterval never fires; tests drive pollState() directly. + statePollIntervalMs: 10_000_000, + }, + }); + + return { backend, calls, spawn, mkdirSync, connect, oauth, librespotChild, ffmpegChild }; +} + +describe("RustLibrespotBackend.start", () => { + it("spawns librespot with the pipe/stdout arg set and the OAuth access token", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.spawn).toHaveBeenCalledWith( + "/bin/librespot", + [ + "--name", "Test Bot", + "--backend", "pipe", + "--bitrate", "320", + "--format", "S16", + "--cache", "/tmp/cache", + "--device-type", "speaker", + "--access-token", "tok-123", + ], + expect.anything(), + ); + // NO --device (=> stdout) and NO --passthrough (=> decoded PCM, not Ogg). + const args = h.spawn.mock.calls.find((c) => String(c[0]).includes("librespot"))![1] as string[]; + expect(args).not.toContain("--device"); + expect(args).not.toContain("--passthrough"); + h.backend.stop(); + }); + + it("spawns ffmpeg (reader) before librespot (writer) with the exact 44100->48000 s16le args", async () => { + const h = makeHarness(); + await h.backend.start(); + const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[]; + expect(ffmpegArgs).toEqual([ + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ]); + const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg"); + const librespotIdx = h.calls.indexOf("spawn:librespot"); + expect(ffmpegIdx).toBeGreaterThanOrEqual(0); + expect(librespotIdx).toBeGreaterThan(ffmpegIdx); + h.backend.stop(); + }); + + it("getPcmStream() returns the ffmpeg stdout Readable", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout); + h.backend.stop(); + }); + + it("emits 'ready' and reports isReady() true once our device appears in getDevices()", async () => { + const h = makeHarness(); + const ready = vi.fn(); + h.backend.on("ready", ready); + await h.backend.start(); + expect(h.connect.getDevices).toHaveBeenCalled(); + expect(ready).toHaveBeenCalledTimes(1); + expect(h.backend.isReady()).toBe(true); + h.backend.stop(); + }); + + it("keeps polling getDevices() until the device name appears", async () => { + const h = makeHarness(); + h.connect.getDevices + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: "other", name: "Someone else", is_active: true }]) + .mockResolvedValue([{ id: "dev1", name: "Test Bot", is_active: false }]); + await h.backend.start(); + expect(h.connect.getDevices).toHaveBeenCalledTimes(3); + expect(h.backend.isReady()).toBe(true); + h.backend.stop(); + }); + + it("throws (and does not spawn) when the OAuth token is null", async () => { + const oauth = makeOAuth(); + oauth.getAccessToken.mockResolvedValue(null); + const h = makeHarness({ oauth }); + await expect(h.backend.start()).rejects.toThrow(/authorized|token/i); + expect(h.spawn).not.toHaveBeenCalled(); + }); +}); + +describe("RustLibrespotBackend transport delegation (Connect API)", () => { + it("playTrack resolves the device then transfer(false) then play(uri)", async () => { + const h = makeHarness(); + await h.backend.playTrack("spotify:track:go"); + expect(h.connect.findDeviceByName).toHaveBeenCalledWith("Test Bot"); + expect(h.connect.transfer).toHaveBeenCalledWith("dev1", false); + expect(h.connect.play).toHaveBeenCalledWith("dev1", "spotify:track:go"); + // ordering: transfer before play + expect(h.connect.transfer.mock.invocationCallOrder[0]) + .toBeLessThan(h.connect.play.mock.invocationCallOrder[0]); + }); + + it("playTrack throws when the device cannot be found", async () => { + const h = makeHarness(); + h.connect.findDeviceByName.mockResolvedValue(null); + await expect(h.backend.playTrack("spotify:track:x")).rejects.toThrow(/device/i); + }); + + it("pause/resume/seek delegate to the Connect API and seek updates position", async () => { + const h = makeHarness(); + await h.backend.pause(); + await h.backend.resume(); + await h.backend.seek(5000); + expect(h.connect.pause).toHaveBeenCalled(); + expect(h.connect.resume).toHaveBeenCalled(); + expect(h.connect.seek).toHaveBeenCalledWith(5000); + expect(h.backend.getPositionMs()).toBe(5000); + }); +}); + +describe("RustLibrespotBackend track-end poll loop", () => { + it("emits trackEnded when progress reaches the end-of-track window", async () => { + const h = makeHarness(); + const ended = vi.fn(); + const meta = vi.fn(); + h.backend.on("trackEnded", ended); + h.backend.on("metadata", meta); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 }); + await (h.backend as any).pollState(); + expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:A", durationMs: 200000 })); + expect(h.backend.getPositionMs()).toBe(1000); + expect(ended).not.toHaveBeenCalled(); + await (h.backend as any).pollState(); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); + }); + + it("emits trackEnded once when playback stops (!isPlaying) after having played", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }); + await (h.backend as any).pollState(); + await (h.backend as any).pollState(); + await (h.backend as any).pollState(); // idempotent: no second emit for same track + expect(ended).toHaveBeenCalledTimes(1); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); + }); + + it("emits trackEnded when the track uri transitions to null after playing", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 }); + await (h.backend as any).pollState(); + await (h.backend as any).pollState(); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); + }); + + it("ignores a null playback state (no active device) without emitting", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.connect.getPlaybackState.mockResolvedValue(null); + await (h.backend as any).pollState(); + expect(ended).not.toHaveBeenCalled(); + }); +}); + +describe("RustLibrespotBackend.stop", () => { + it("kills librespot + ffmpeg, clears ready, and is idempotent", async () => { + const h = makeHarness(); + await h.backend.start(); + h.backend.stop(); + h.backend.stop(); // second call must not throw + expect(h.librespotChild.kill).toHaveBeenCalled(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.backend.isReady()).toBe(false); + }); +}); + +describe("RustLibrespotBackend.start failure cleanup", () => { + it("tears down librespot + ffmpeg when the device never appears", async () => { + const h = makeHarness(); + h.connect.getDevices.mockResolvedValue([]); // device never shows up -> waitForDevice times out + await expect(h.backend.start()).rejects.toThrow(/did not appear/i); + expect(h.librespotChild.kill).toHaveBeenCalled(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.backend.isReady()).toBe(false); + }); +}); + +describe("RustLibrespotBackend child-process error handling", () => { + it("swallows+logs a child 'error' when no backend 'error' listener is attached", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.listenerCount("error")).toBe(0); + expect(() => h.librespotChild.emit("error", new Error("boom"))).not.toThrow(); + expect(() => h.ffmpegChild.emit("error", new Error("boom"))).not.toThrow(); + h.backend.stop(); + }); + + it("re-emits a child 'error' to an attached backend 'error' listener", async () => { + const h = makeHarness(); + await h.backend.start(); + const onErr = vi.fn(); + h.backend.on("error", onErr); + const err = new Error("ffmpeg boom"); + h.ffmpegChild.emit("error", err); + expect(onErr).toHaveBeenCalledWith(err); + h.backend.stop(); + }); +}); +``` + +- [ ] **2. Verify RED** — the test must fail because the module does not exist yet: + +``` +npx vitest run src/music/spotify/rust-librespot.test.ts +``` +Expected: fails to import / all suites error (`Cannot find module './rust-librespot.js'`). + +- [ ] **3. Implement** `src/music/spotify/rust-librespot.ts` (mirrors `go-librespot.ts`: C1 ffmpeg resolution, start-cleanup try/catch, `emitError` listener guard). Note: stdout-pipe transport means NO FIFO/mkfifo and NO config.yml — control is entirely via the injected `SpotifyConnectApi`: + +```ts +import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { spawn as realSpawn } from "node:child_process"; +import { mkdirSync as realMkdirSync } from "node:fs"; +import type { Logger } from "pino"; +import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, +} from "./backend.js"; +import { findLibrespot } from "./binary.js"; +import { SpotifyConnectApi } from "./connect-api.js"; +import type { PlaybackState, SpotifyDevice } from "./connect-api.js"; +import type { SpotifyOAuth } from "./spotify-oauth.js"; +import { getFfmpegCommand } from "../../audio/player.js"; + +export interface RustLibrespotBackendOptions { + deviceName: string; + bitrate: number; + cacheDir: string; + oauth: SpotifyOAuth; + connect?: SpotifyConnectApi; + logger: Logger; + deps?: RustLibrespotBackendDeps; +} + +/** Injectable seams so the whole lifecycle is testable without a real binary/network. */ +export interface RustLibrespotBackendDeps { + spawn?: typeof realSpawn; + mkdirSync?: typeof realMkdirSync; + findBinary?: () => string; + /** + * C1: override the ffmpeg command. Production resolves it via + * getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on + * PATH); tests pin it to "ffmpeg" for stable arg assertions. + */ + ffmpegCommand?: string; + sleep?: (ms: number) => Promise; + readyPollIntervalMs?: number; + readyTimeoutMs?: number; + statePollIntervalMs?: number; +} + +const DEFAULT_READY_POLL_MS = 500; +const DEFAULT_READY_TIMEOUT_MS = 20_000; +const DEFAULT_STATE_POLL_MS = 2_000; +/** How close to the end (ms) counts as "track finished" when polling player state. */ +const END_OF_TRACK_WINDOW_MS = 1_500; + +const defaultSleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBackend { + private readonly opts: RustLibrespotBackendOptions; + private readonly log: Logger; + private readonly deps: RustLibrespotBackendDeps; + private readonly oauth: SpotifyOAuth; + private readonly connect: SpotifyConnectApi; + + private proc: ChildProcess | null = null; + private ffmpeg: ChildProcess | null = null; + private pollTimer: ReturnType | null = null; + private ready = false; + private positionMs = 0; + + // track-end poll state machine + private currentUri: string | null = null; + private hasPlayed = false; + private endedForCurrent = false; + + constructor(o: RustLibrespotBackendOptions) { + super(); + this.opts = o; + this.log = o.logger; + this.deps = o.deps ?? {}; + this.oauth = o.oauth; + // The Connect API shares the backend's OAuth token source. Reuse the + // injected instance in tests; otherwise build one over oauth.getAccessToken(). + this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken()); + } + + async start(): Promise { + const spawn = this.deps.spawn ?? realSpawn; + const mkdirSync = this.deps.mkdirSync ?? realMkdirSync; + const findBinary = this.deps.findBinary ?? findLibrespot; + // C1: resolve ffmpeg via getFfmpegCommand() unless injected for tests. + const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand(); + + // A valid USER control token is required before we spawn anything. + const token = await this.oauth.getAccessToken(); + if (!token) { + throw new Error("Spotify not authorized (no access token) — sign in first"); + } + + mkdirSync(this.opts.cacheDir, { recursive: true }); + + // Everything past here spawns children / opens the state poll. On any + // failure (e.g. the device never appears), tear it all down via stop(). + try { + // 1. Spawn ffmpeg FIRST (the reader) so its stdin pipe is ready before + // librespot starts pushing raw 44.1k s16le PCM into it. + this.ffmpeg = spawn( + ffmpegCommand, + [ + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + this.ffmpeg.stderr?.on("data", (b: Buffer) => + this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"), + ); + this.ffmpeg.on("error", (err) => this.emitError(err)); + + // 2. Spawn librespot: --backend pipe with NO --device => raw s16le/44100/2 + // on stdout, NO --passthrough (that would emit raw Ogg). --access-token + // authenticates it as a Connect device controllable via the Web API. + const bin = findBinary(); + this.proc = spawn( + bin, + [ + "--name", this.opts.deviceName, + "--backend", "pipe", + "--bitrate", String(this.opts.bitrate), + "--format", "S16", + "--cache", this.opts.cacheDir, + "--device-type", "speaker", + "--access-token", token, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + // stdout carries PCM — pipe it, never attach a data listener that consumes it. + if (this.proc.stdout && this.ffmpeg.stdin) { + this.proc.stdout.pipe(this.ffmpeg.stdin); + } + this.proc.stderr?.on("data", (b: Buffer) => + this.log.info({ librespot: b.toString().trim() }, "librespot"), + ); + this.proc.on("error", (err) => this.emitError(err)); + this.proc.on("exit", (code, signal) => { + this.ready = false; + this.log.warn({ code, signal }, "librespot exited"); + }); + + // 3. Poll the Connect device list until our device registers. + await this.waitForDevice(); + + // 4. Begin the player-state poll loop (track-end / position / metadata). + this.startPollLoop(); + + this.ready = true; + this.emit("ready"); + } catch (e) { + this.stop(); + throw e; + } + } + + /** + * Re-emit a child "error" only when a consumer is listening; Node throws on an + * unhandled "error" event, so with no listener we log via the injected logger. + */ + private emitError(err: unknown): void { + if (this.listenerCount("error") > 0) { + this.emit("error", err); + } else { + this.log.error({ err }, "rust-librespot backend error (no listener)"); + } + } + + private async waitForDevice(): Promise { + const sleep = this.deps.sleep ?? defaultSleep; + const interval = this.deps.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS; + const timeout = this.deps.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + let devices: SpotifyDevice[] = []; + try { + devices = await this.connect.getDevices(); + } catch (err) { + this.log.debug({ err }, "getDevices failed during readiness poll"); + } + if (devices.some((d) => d.name === this.opts.deviceName)) return; + await sleep(interval); + } + throw new Error(`librespot device "${this.opts.deviceName}" did not appear within timeout`); + } + + private startPollLoop(): void { + const interval = this.deps.statePollIntervalMs ?? DEFAULT_STATE_POLL_MS; + this.pollTimer = setInterval(() => { + void this.pollState(); + }, interval); + // Don't keep the event loop / test process alive on account of the poll timer. + this.pollTimer.unref?.(); + } + + /** One player-state poll iteration: updates position/metadata and detects track end. */ + private async pollState(): Promise { + let state: PlaybackState | null; + try { + state = await this.connect.getPlaybackState(); + } catch (err) { + this.log.debug({ err }, "getPlaybackState failed"); + return; + } + if (!state) return; // 204 / no active device + + this.positionMs = state.progressMs; + + // Track change -> reset the end-detection state and surface best-effort metadata. + if (state.trackUri && state.trackUri !== this.currentUri) { + this.currentUri = state.trackUri; + this.hasPlayed = false; + this.endedForCurrent = false; + const np: SpotifyNowPlaying = { + uri: state.trackUri, + name: "", + artist: "", + album: "", + coverUrl: "", + durationMs: state.durationMs, + }; + this.emit("metadata", np); + } + + if (state.isPlaying) this.hasPlayed = true; + if (!this.currentUri || this.endedForCurrent) return; + + const finishedByProgress = + state.durationMs > 0 && state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS; + const finishedByStop = this.hasPlayed && !state.isPlaying; + const finishedByNull = this.hasPlayed && state.trackUri === null; + + if (finishedByProgress || finishedByStop || finishedByNull) { + this.endedForCurrent = true; + const endedUri = this.currentUri; + this.currentUri = null; + const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" }; + this.emit("trackEnded", e); + } + } + + isReady(): boolean { + return this.ready; + } + + async playTrack(uri: string): Promise { + const deviceId = await this.connect.findDeviceByName(this.opts.deviceName); + if (!deviceId) throw new Error(`Connect device "${this.opts.deviceName}" not found`); + await this.connect.transfer(deviceId, false); + await this.connect.play(deviceId, uri); + } + + async pause(): Promise { + await this.connect.pause(); + } + + async resume(): Promise { + await this.connect.resume(); + } + + async seek(ms: number): Promise { + await this.connect.seek(ms); + this.positionMs = ms; + } + + getPcmStream(): Readable { + const out = this.ffmpeg?.stdout; + if (!out) throw new Error("PCM stream unavailable (rust-librespot backend not started)"); + return out; + } + + getPositionMs(): number { + return this.positionMs; + } + + stop(): void { + this.ready = false; + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.proc) { + try { + this.proc.kill(); + } catch { + /* ignore */ + } + this.proc = null; + } + if (this.ffmpeg) { + try { + this.ffmpeg.kill(); + } catch { + /* ignore */ + } + this.ffmpeg = null; + } + } +} +``` + +- [ ] **4. Verify GREEN**: + +``` +npx vitest run src/music/spotify/rust-librespot.test.ts +``` +Expected: all suites pass (start args/order/ready, transport delegation, three track-end transitions + null-state ignore, stop idempotency, start-failure cleanup, error-guard both branches). + +- [ ] **5. Type-check the whole project**: + +``` +npx tsc --noEmit +``` +Expected: exits 0, no errors. (If `./binary.js`, `./connect-api.js`, or `./spotify-oauth.js` are not yet implemented from Tasks 1-3, this fails on those imports — land this task after them.) + +- [ ] **6. Commit**: + +``` +git add src/music/spotify/rust-librespot.ts src/music/spotify/rust-librespot.test.ts +git commit -m "$(cat <<'EOF' +feat(spotify): add RustLibrespotBackend (stdout-pipe -> ffmpeg, Connect-API control) + +Implements the Stage-2 SpotifyAudioBackend over Rust librespot: spawns +librespot with --backend pipe (no --device => s16le/44100/2 on stdout, no +--passthrough), pipes stdout -> ffmpeg (44100->48000 s16le), waits for the +Connect device to register before emitting "ready", and controls playback +(transfer/play/pause/resume/seek) plus track-end/position/metadata via a +polled SpotifyConnectApi. child_process/connect/oauth/ffmpeg injected for +fully mocked, no-network unit tests (Windows-targeted; not e2e without Premium). + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +**Notes / rationale:** +- **No FIFO/config.yml** (unlike `go-librespot.ts`): Rust librespot's pipe backend writes PCM straight to `stdout`, so transport is `librespot.stdout -> ffmpeg.stdin` and all control goes through the injected `SpotifyConnectApi` (Web API), not a local REST server. +- **ffmpeg args match the contract exactly** (`-i pipe:0 ... pipe:1`) — no `-hide_banner/-loglevel` prefix, since librespot stdout (not a FIFO path) is the input. +- **`pollState()` is a private method driven by `setInterval`** but tested directly (no fake timers) for deterministic transition assertions; the interval is `unref()`'d and cleared in `stop()`. +- **`stop()` is idempotent** and is also called from `start()`'s `catch` for start-failure cleanup, mirroring the go backend. +- **`emitError` listener guard** is identical to the go backend to avoid crashing Node on an unhandled `"error"`. +- This is Windows-targeted and **not e2e-testable** (no Premium account / no real binary) — all coverage is mocked/unit-level per the injected seams. + +--- + +### Task 5: Backend selection in SpotifyController + +Add `chooseBackend()` to `SpotifyController` so it honors `config.spotify.backend` (`"go-librespot" | "librespot" | "auto"`) against platform + binary availability, constructs a shared `SpotifyOAuth` + `SpotifyConnectApi` it hands to the Rust backend, gates `isAvailable()` on a selectable backend, and gates the Rust path's `ensureStarted()` on `oauth.isAuthorized()`. The go-librespot path and every existing Stage-2 controller test stay behavior-unchanged. Windows-targeted, unit-level only (no real binary/network; PKCE-adjacent deps injected). + +**Files:** +- MODIFY `src/music/spotify/controller.ts` +- MODIFY `src/music/spotify/controller.test.ts` + +**Interfaces:** + +_Consumes (verbatim from prior tasks — do not redeclare):_ +```ts +// ./binary.js +export function isGoLibrespotSupported(): boolean +export function findGoLibrespot(): string +export function isRustLibrespotSupported(): boolean // Task 1 — true on all platforms +export function findLibrespot(): string // Task 1 — bin/librespot(.exe) then PATH +// ./spotify-oauth.js (Task 2) +export interface OAuthTokens { accessToken: string; refreshToken: string; expiresAt: number; scope: string } +export interface OAuthTokenStore { load(): OAuthTokens | null; save(t: OAuthTokens): void; clear(): void } +export class SpotifyOAuth { + constructor(o: { clientId?: string; redirectUri?: string; store: OAuthTokenStore; deps?: { http?: import("axios").AxiosInstance } }) + isAuthorized(): boolean + getAccessToken(): Promise +} +// ./connect-api.js (Task 3) +export class SpotifyConnectApi { + constructor(getToken: () => Promise, deps?: { http?: import("axios").AxiosInstance }) +} +// ./rust-librespot.js (Task 4) +export interface RustLibrespotBackendOptions { deviceName: string; bitrate: number; cacheDir: string; oauth: SpotifyOAuth; connect?: SpotifyConnectApi; logger: import("pino").Logger; deps?: any } +export class RustLibrespotBackend implements SpotifyAudioBackend { constructor(o: RustLibrespotBackendOptions) } +// ./backend.js +export interface SpotifyAudioBackend { /* start/stop/isReady/playTrack/pause/resume/seek/getPcmStream/getPositionMs/on */ } +``` + +_Produces (new/changed public surface on `SpotifyController`):_ +```ts +export type SpotifyBackendKind = "go-librespot" | "librespot"; +export interface SpotifyControllerOptions { + config: SpotifyConfig; workDir: string; configDir: string; logger: import("pino").Logger; + apiPort?: number; callbackPort?: number; + backendFactory?: () => SpotifyAudioBackend; // test override (unchanged) + oauth?: SpotifyOAuth; // NEW — injected in tests; default file-backed + connect?: SpotifyConnectApi; // NEW — injected in tests; default wired to oauth +} +class SpotifyController { + getOAuth(): SpotifyOAuth // NEW — shared with web router (Task 6) + Rust backend + getConnect(): SpotifyConnectApi // NEW + chooseBackend(): SpotifyBackendKind | null // NEW + isAvailable(): boolean // CHANGED: enabled && chooseBackend() !== null + ensureStarted(): Promise // CHANGED: rust kind also requires oauth.isAuthorized() +} +``` + +--- + +- [ ] **Step 5.1 — Red: write the failing tests.** Update `src/music/spotify/controller.test.ts`. Extend the hoisted `bin` mock + `vi.mock("./binary.js")` to expose the Rust binary probes, add Rust defaults to `beforeEach`, teach `makeCtrl` to inject `oauth`, add a `fakeOAuth` helper, then append the two new `describe` blocks. All existing lines/assertions stay as-is. + + Replace the existing hoisted-`bin` + `vi.mock("./binary.js")` block (top of file) with: + ```ts + // Controllable, hoisted so the vi.mock factory can close over it. + // go-* keys keep their Stage-2 names (`supported`/`path`) so existing tests are + // untouched; rust* keys drive the new librespot selection paths. + const bin = vi.hoisted(() => ({ + supported: true, + path: "", + rustSupported: true, + rustPath: "", + })); + vi.mock("./binary.js", () => ({ + isGoLibrespotSupported: () => bin.supported, + findGoLibrespot: () => bin.path, + resetGoLibrespotBinaryCache: () => {}, + checkGoLibrespotAvailable: async () => bin.supported && !!bin.path, + isRustLibrespotSupported: () => bin.rustSupported, + findLibrespot: () => bin.rustPath, + resetLibrespotBinaryCache: () => {}, + checkLibrespotAvailable: async () => bin.rustSupported && !!bin.rustPath, + })); + ``` + + Extend the existing `beforeEach` so the Rust binary defaults to "supported but absent" — this keeps every Stage-2 `auto` test selecting go-librespot exactly as before: + ```ts + beforeEach(() => { + bin.supported = true; + bin.path = existingBin; + bin.rustSupported = true; + bin.rustPath = missingBin; + }); + ``` + + Teach `makeCtrl` to forward an injected `oauth` (default: none → controller builds its own file-backed one, which is inert for the go path): + ```ts + function makeCtrl(over: { + config?: Partial; + backendFactory?: () => SpotifyAudioBackend; + oauth?: import("./spotify-oauth.js").SpotifyOAuth; + } = {}) { + const be = new FakeBackend(); + const ctrl = new SpotifyController({ + config: cfg(over.config), + workDir: "/tmp/work", + configDir: "/tmp/cfg", + logger: silentLogger, + backendFactory: over.backendFactory ?? (() => be), + oauth: over.oauth, + }); + return { ctrl, be }; + } + ``` + + Add a `fakeOAuth` helper next to `makeCtrl` (typed enough for the controller; no network): + ```ts + import type { SpotifyOAuth } from "./spotify-oauth.js"; + function fakeOAuth( + authorized: boolean, + hooks: { onIsAuthorized?: () => void } = {}, + ): SpotifyOAuth { + return { + isAuthorized: () => { + hooks.onIsAuthorized?.(); + return authorized; + }, + getAccessToken: async () => (authorized ? "tok" : null), + getClientId: () => "cid", + getRedirectUri: () => "http://127.0.0.1:5588/login", + buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize", state: "s" }), + handleCallback: async () => true, + } as unknown as SpotifyOAuth; + } + ``` + + Append the two new `describe` blocks at the end of the file: + ```ts + describe("SpotifyController.chooseBackend (platform x config matrix)", () => { + function pick( + backend: SpotifyConfig["backend"], + opts: { go: boolean; goSupported?: boolean; rust: boolean; rustSupported?: boolean }, + ) { + bin.supported = opts.goSupported ?? true; + bin.path = opts.go ? existingBin : missingBin; + bin.rustSupported = opts.rustSupported ?? true; + bin.rustPath = opts.rust ? existingBin : missingBin; + const { ctrl } = makeCtrl({ config: { backend } }); + return ctrl.chooseBackend(); + } + + it("auto: prefers go-librespot when linux + go binary present", () => { + expect(pick("auto", { go: true, rust: true })).toBe("go-librespot"); + }); + it("auto: falls back to librespot when go unsupported (e.g. Windows) but librespot present", () => { + expect(pick("auto", { go: true, goSupported: false, rust: true })).toBe("librespot"); + }); + it("auto: falls back to librespot when go binary is absent", () => { + expect(pick("auto", { go: false, rust: true })).toBe("librespot"); + }); + it("auto: null when neither backend is usable", () => { + expect(pick("auto", { go: false, goSupported: false, rust: false })).toBeNull(); + }); + it("go-librespot: selected when supported + present", () => { + expect(pick("go-librespot", { go: true, rust: true })).toBe("go-librespot"); + }); + it("go-librespot: null when unsupported, even if librespot is present", () => { + expect(pick("go-librespot", { go: true, goSupported: false, rust: true })).toBeNull(); + }); + it("librespot: selected when the librespot binary is present", () => { + expect(pick("librespot", { go: true, rust: true })).toBe("librespot"); + }); + it("librespot: null when the librespot binary is absent, even if go is present", () => { + expect(pick("librespot", { go: true, rust: false })).toBeNull(); + }); + }); + + describe("SpotifyController Rust-backend auth gate", () => { + it("isAvailable is true for a present librespot binary regardless of auth", () => { + bin.rustPath = existingBin; + const { ctrl } = makeCtrl({ + config: { backend: "librespot" }, + oauth: fakeOAuth(false), + }); + expect(ctrl.isAvailable()).toBe(true); + }); + + it("ensureStarted returns false (no backend built) when Rust chosen but unauthorized", async () => { + bin.rustPath = existingBin; + let built = 0; + const { ctrl } = makeCtrl({ + config: { backend: "librespot" }, + oauth: fakeOAuth(false), + backendFactory: () => { + built++; + return new FakeBackend(); + }, + }); + expect(await ctrl.ensureStarted()).toBe(false); + expect(built).toBe(0); + }); + + it("ensureStarted starts the Rust backend once authorized", async () => { + bin.rustPath = existingBin; + const be = new FakeBackend(); + let built = 0; + const { ctrl } = makeCtrl({ + config: { backend: "librespot" }, + oauth: fakeOAuth(true), + backendFactory: () => { + built++; + return be; + }, + }); + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(1); + expect(be.startCalls).toBe(1); + }); + + it("go-librespot path never consults oauth.isAuthorized()", async () => { + // auto + go present -> go-librespot; the auth gate must be skipped so a + // throwing isAuthorized() is never reached. + const oauth = fakeOAuth(false, { + onIsAuthorized: () => { + throw new Error("isAuthorized must not be called on the go path"); + }, + }); + const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth }); + expect(await ctrl.ensureStarted()).toBe(true); + }); + + it("exposes the shared oauth + connect instances", () => { + const oauth = fakeOAuth(true); + const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth }); + expect(ctrl.getOAuth()).toBe(oauth); + expect(ctrl.getConnect()).toBeDefined(); + }); + }); + ``` + +- [ ] **Step 5.2 — Confirm Red.** Run: + ``` + npx vitest run src/music/spotify/controller.test.ts + ``` + Expected: the new `chooseBackend` and auth-gate specs FAIL (e.g. `ctrl.chooseBackend is not a function`, `getOAuth`/`getConnect` undefined, Rust unauthorized start returns `true`). Existing Stage-2 specs may also error on `ctrl` construction until 5.3 lands — that's expected Red. + +- [ ] **Step 5.3 — Green: rewrite `src/music/spotify/controller.ts`.** Replace the whole file with the version below. It adds the Rust imports, a lazy/guarded `FileOAuthTokenStore`, owns `oauth`/`connect`, `chooseBackend()`, `buildBackend()`, the widened `isAvailable()`, and the Rust auth gate in `ensureStarted()`. `handleBackendError`, `playTrack`, `pause`, `resume`, `seek`, `getPcmStream`, and `stop` are carried over verbatim. + ```ts + import { EventEmitter } from "node:events"; + import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + rmSync, + } from "node:fs"; + import { dirname, join } from "node:path"; + import type { Readable } from "node:stream"; + import type { Logger } from "pino"; + import type { SpotifyConfig } from "../../data/config.js"; + import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, + } from "./backend.js"; + import { + isGoLibrespotSupported, + findGoLibrespot, + isRustLibrespotSupported, + findLibrespot, + } from "./binary.js"; + import { GoLibrespotBackend } from "./go-librespot.js"; + import { RustLibrespotBackend } from "./rust-librespot.js"; + import { + SpotifyOAuth, + type OAuthTokens, + type OAuthTokenStore, + } from "./spotify-oauth.js"; + import { SpotifyConnectApi } from "./connect-api.js"; + + /** Which concrete backend the controller will run for this host + config. */ + export type SpotifyBackendKind = "go-librespot" | "librespot"; + + /** + * Minimal file-backed OAuth token store used when the caller does not inject a + * SpotifyOAuth. Persists the rotating refresh-token JSON next to the bot config + * (0600). All IO is lazy + guarded so construction never throws and a + * missing/corrupt file simply reads as "unauthorized". + */ + class FileOAuthTokenStore implements OAuthTokenStore { + constructor(private readonly file: string) {} + load(): OAuthTokens | null { + try { + if (!existsSync(this.file)) return null; + return JSON.parse(readFileSync(this.file, "utf8")) as OAuthTokens; + } catch { + return null; + } + } + save(t: OAuthTokens): void { + mkdirSync(dirname(this.file), { recursive: true }); + writeFileSync(this.file, JSON.stringify(t), { mode: 0o600 }); + } + clear(): void { + try { + rmSync(this.file, { force: true }); + } catch { + /* ignore */ + } + } + } + + export interface SpotifyControllerOptions { + config: SpotifyConfig; + workDir: string; + configDir: string; + logger: Logger; + /** Per-bot go-librespot control-API port (distinct per bot to avoid binds). */ + apiPort?: number; + /** Per-bot go-librespot OAuth callback port (distinct per bot). */ + callbackPort?: number; + /** Injected for tests; when set it overrides the per-kind default builders. */ + backendFactory?: () => SpotifyAudioBackend; + /** Injected for tests; defaults to a file-backed SpotifyOAuth in configDir. */ + oauth?: SpotifyOAuth; + /** Injected for tests; defaults to a SpotifyConnectApi wired to oauth. */ + connect?: SpotifyConnectApi; + } + + /** + * Per-bot orchestrator for the Spotify sidecar. Selects a backend for this + * host+config (chooseBackend), owns backend lifecycle, gates on availability + * (config + platform + binary) plus — for the Rust librespot backend — OAuth + * authorization, delegates transport, and re-emits "trackEnded"/"metadata" so + * BotInstance can advance the queue exactly as for the ffmpeg path. + * + * Correction C3 (unchanged): this controller does NOT re-emit a raw "error" + * event. It subscribes to the backend's "error", logs it, tears the backend + * down, and marks itself not-ready so the next ensureStarted() relaunches a + * fresh backend. getPcmStream() proxies the backend's SINGLE persistent stream. + */ + export class SpotifyController extends EventEmitter { + private readonly config: SpotifyConfig; + private readonly workDir: string; + private readonly configDir: string; + private readonly logger: Logger; + private readonly apiPort?: number; + private readonly callbackPort?: number; + private readonly injectedFactory?: () => SpotifyAudioBackend; + private readonly oauth: SpotifyOAuth; + private readonly connect: SpotifyConnectApi; + + private backend: SpotifyAudioBackend | null = null; + private started = false; + private startPromise: Promise | null = null; + + constructor(o: SpotifyControllerOptions) { + super(); + this.config = o.config; + this.workDir = o.workDir; + this.configDir = o.configDir; + this.logger = o.logger; + this.apiPort = o.apiPort; + this.callbackPort = o.callbackPort; + this.injectedFactory = o.backendFactory; + // The controller OWNS a shared OAuth + Connect pair (Task 6 web router and + // the Rust backend reuse these exact instances). Constructing the defaults + // performs no IO/network — the file store loads lazily on first use. + this.oauth = + o.oauth ?? + new SpotifyOAuth({ + store: new FileOAuthTokenStore( + join(this.configDir, "spotify-oauth.json"), + ), + }); + this.connect = + o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken()); + } + + /** Shared OAuth client (web router + Rust backend reuse this instance). */ + getOAuth(): SpotifyOAuth { + return this.oauth; + } + + /** Shared Connect API client (Rust backend reuses this instance). */ + getConnect(): SpotifyConnectApi { + return this.connect; + } + + private goPresent(): boolean { + return isGoLibrespotSupported() && existsSync(findGoLibrespot()); + } + + private rustPresent(): boolean { + return isRustLibrespotSupported() && existsSync(findLibrespot()); + } + + /** + * Resolve which backend to run for this platform + config, or null when none + * is usable (caller falls back to the Stage-1 sentinel message): + * "go-librespot" -> GoLibrespot iff supported (linux) + binary present + * "librespot" -> Rust iff librespot(.exe) present (all platforms) + * "auto" -> GoLibrespot when (linux + go binary), else Rust when + * librespot present, else null. + */ + chooseBackend(): SpotifyBackendKind | null { + switch (this.config.backend) { + case "go-librespot": + return this.goPresent() ? "go-librespot" : null; + case "librespot": + return this.rustPresent() ? "librespot" : null; + case "auto": + default: + if (this.goPresent()) return "go-librespot"; + if (this.rustPresent()) return "librespot"; + return null; + } + } + + /** enabled in config AND a backend is selectable (platform + binary present). */ + isAvailable(): boolean { + return this.config.enabled && this.chooseBackend() !== null; + } + + /** Build the concrete backend for the chosen kind (or the injected fake). */ + private buildBackend(kind: SpotifyBackendKind): SpotifyAudioBackend { + if (this.injectedFactory) return this.injectedFactory(); + if (kind === "librespot") { + return new RustLibrespotBackend({ + deviceName: this.config.deviceName, + bitrate: this.config.bitrate, + cacheDir: join(this.workDir, "librespot-cache"), + oauth: this.oauth, + connect: this.connect, + logger: this.logger, + }); + } + return new GoLibrespotBackend({ + deviceName: this.config.deviceName, + bitrate: this.config.bitrate, + workDir: this.workDir, + configDir: this.configDir, + apiPort: this.apiPort, + callbackPort: this.callbackPort, + logger: this.logger, + }); + } + + /** + * Idempotently start the selected backend. Returns false (without building a + * backend) when unavailable, or — for the Rust backend — when OAuth is not + * yet authorized, so callers show the login-needed / fallback message. A + * failed start clears the cached promise so a later call can retry. + */ + async ensureStarted(): Promise { + if (!this.isAvailable()) return false; + const kind = this.chooseBackend(); + if (!kind) return false; + // The Rust librespot device only appears in Spotify Connect once the user + // has authorized OAuth; without it, do not spawn a dead sidecar. + if (kind === "librespot" && !this.oauth.isAuthorized()) return false; + + if (this.started) { + if (this.backend?.isReady()) return true; + this.stop(); + } + if (this.startPromise) return this.startPromise; + + this.startPromise = (async () => { + try { + const backend = this.buildBackend(kind); + backend.on("trackEnded", (e: SpotifyTrackEndedEvent) => + this.emit("trackEnded", e), + ); + backend.on("metadata", (m: SpotifyNowPlaying) => + this.emit("metadata", m), + ); + // C3: do NOT re-emit "error". Log and mark not-ready so the next + // ensureStarted() relaunches a fresh backend. + backend.on("error", (err?: unknown) => this.handleBackendError(err)); + await backend.start(); + this.backend = backend; + this.started = true; + return true; + } catch (err) { + this.logger.error({ err }, "Spotify backend failed to start"); + this.startPromise = null; + return false; + } + })(); + return this.startPromise; + } + + /** + * C3 backend-error handler. Never re-emits "error" (an unhandled "error" on + * an EventEmitter throws). Logs, tears the errored backend down, and marks + * the controller not-ready so the next ensureStarted() relaunches it. + */ + private handleBackendError(err: unknown): void { + this.logger.error({ err }, "Spotify backend error; marking not-ready"); + try { + this.backend?.stop(); + } catch (stopErr) { + this.logger.error( + { err: stopErr }, + "Spotify backend stop() threw during error teardown", + ); + } + (this.backend as unknown as EventEmitter | null)?.removeAllListeners(); + this.backend = null; + this.started = false; + this.startPromise = null; + } + + /** Ensure started, then play the spotify: URI. False on any failure. */ + async playTrack(uri: string): Promise { + const ok = await this.ensureStarted(); + if (!ok || !this.backend) return false; + try { + await this.backend.playTrack(uri); + return true; + } catch (err) { + this.logger.error({ err, uri }, "Spotify playTrack failed"); + return false; + } + } + + async pause(): Promise { + if (this.backend) await this.backend.pause(); + } + + async resume(): Promise { + if (this.backend) await this.backend.resume(); + } + + async seek(ms: number): Promise { + if (this.backend) await this.backend.seek(ms); + } + + getPcmStream(): Readable { + if (!this.backend) { + throw new Error("Spotify backend not started"); + } + return this.backend.getPcmStream(); + } + + /** + * Tear down the backend and reset lifecycle state (safe before start). + * Mirrors handleBackendError's teardown so the NEXT ensureStarted() rebuilds + * a fresh backend. + */ + stop(): void { + try { + this.backend?.stop(); + } catch (stopErr) { + this.logger.error( + { err: stopErr }, + "Spotify backend stop() threw during teardown", + ); + } + (this.backend as unknown as EventEmitter | null)?.removeAllListeners(); + this.backend = null; + this.started = false; + this.startPromise = null; + } + } + ``` + +- [ ] **Step 5.4 — Confirm Green.** Run: + ``` + npx vitest run src/music/spotify/controller.test.ts + ``` + Expected: ALL specs pass — every Stage-2 spec (isAvailable, ensureStarted idempotency, playTrack, transport delegation, event re-emission, C3 error handling, stop, per-bot ports) plus the new `chooseBackend` matrix (8 cases) and Rust auth-gate block (5 cases). Zero failures. + +- [ ] **Step 5.5 — Typecheck.** Run: + ``` + npx tsc --noEmit + ``` + Expected: no output, exit 0. (Confirms the new `./rust-librespot.js`, `./spotify-oauth.js`, `./connect-api.js`, and Rust `./binary.js` imports resolve, `SpotifyBackendKind` narrows correctly in `buildBackend`, and `OAuthTokenStore`/`OAuthTokens` type-only imports are correct under ESM `.js` specifiers.) + +- [ ] **Step 5.6 — Commit.** Run: + ``` + git add src/music/spotify/controller.ts src/music/spotify/controller.test.ts + git commit -m "$(cat <<'EOF' + feat(spotify): backend selection + Rust librespot wiring in SpotifyController + + Add chooseBackend() honoring config.spotify.backend (go-librespot|librespot| + auto) against platform + binary availability (auto: linux+go binary -> go; + else librespot present -> Rust; else null). Controller now owns a shared + SpotifyOAuth + SpotifyConnectApi passed to the Rust backend; isAvailable() = + enabled && a backend is selectable; the Rust path additionally gates + ensureStarted() on oauth.isAuthorized(). go-librespot path unchanged. + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + )" + ``` + +**Notes / guardrails:** +- Not e2e-testable (no Premium account, Windows target). All tests are mocked/unit-level: `./binary.js` probes via the hoisted `bin` object, `SpotifyOAuth`/`SpotifyConnectApi` injected as fakes, and `backendFactory` prevents any real `RustLibrespotBackend`/`GoLibrespotBackend` spawn. +- `chooseBackend()` uses synchronous `existsSync(findLibrespot())` (mirroring the Stage-2 `existsSync(findGoLibrespot())` check) rather than the async `checkLibrespotAvailable()`, so `isAvailable()` stays synchronous exactly like Stage-2. The async `--version` probe belongs to first-run detection, not per-call gating. +- Existing Stage-2 tests stay behavior-unchanged; the only test edits are additive (Rust mock exports defaulting to an absent librespot binary, an `oauth` inject hook, and two new `describe` blocks). No existing assertion is modified. + +--- + +### Task 6: Web OAuth endpoints + wiring + +**Files:** +- CREATE `src/web/api/spotify.ts` — Express router (login / callback / status), DI-friendly (`SpotifyOAuthLike` seam so it needs no real network/crypto). +- CREATE `src/music/spotify/token-store.ts` — file-backed `OAuthTokenStore` under the data dir (mirrors `createCookieStore` in `src/music/auth.ts`, `mode: 0o600`). *(Not in the original Files list but required — the router needs a persisted store; keep it tiny and reuse the cookie-store pattern. If the spotify-oauth.ts task already ships a file store, delete this and import that instead.)* +- CREATE `src/web/api/spotify.test.ts` + `src/music/spotify/token-store.test.ts` — supertest + fs tests. +- MODIFY `src/web/server.ts` — add `spotifyOAuth?` to `WebServerOptions`, mount `/api/spotify` after `requireAuth`. +- MODIFY `src/index.ts` — build the process-wide token store + `SpotifyOAuth` (single Premium account), pass into `createWebServer`. +- `src/data/config.ts` — **no change** (reuse `spotify.clientId`/`spotify.deviceName`; own-app clientId → redirect at the web `/api/spotify/callback`, empty → librespot public client + its fixed `:5588/login` loopback listener handled by the backend task, not this router). + +**Interfaces:** + +Consumes (from the spotify-oauth.ts task — LOCKED CONTRACT): +```ts +class SpotifyOAuth { + buildAuthorizeUrl(): { url: string; state: string } + handleCallback(code: string, state: string): Promise + isAuthorized(): boolean +} +interface OAuthTokens { accessToken: string; refreshToken: string; expiresAt: number; scope: string } +interface OAuthTokenStore { load(): OAuthTokens | null; save(t: OAuthTokens): void; clear(): void } +``` +Consumes (middleware): `requirePermission("platform.auth")`, `requireNotGuest` (both read `req.user.role` / `req.user.capabilities: Set`, populated by the global `requireAuth`). + +Produces: +```ts +// src/web/api/spotify.ts +export interface SpotifyOAuthLike { + buildAuthorizeUrl(): { url: string; state: string }; + handleCallback(code: string, state: string): Promise; + isAuthorized(): boolean; +} +export interface SpotifyRouterOptions { + oauth: SpotifyOAuthLike; + logger: import("pino").Logger; + getBackendInfo: () => { backend: string; deviceName: string }; + webUiRedirect?: string; // default "/" +} +export function createSpotifyRouter(opts: SpotifyRouterOptions): import("express").Router; + +// src/music/spotify/token-store.ts +export function createSpotifyTokenStore(dir: string): import("./spotify-oauth.js").OAuthTokenStore; +``` +`SpotifyOAuth` structurally satisfies `SpotifyOAuthLike`, so the real instance passes verbatim; tests inject a fake. + +--- + +- [ ] **Step 1 — Write the failing router test** `src/web/api/spotify.test.ts` (supertest, fake oauth, no network): +```ts +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createSpotifyRouter, type SpotifyOAuthLike } from "./spotify.js"; + +type Role = "admin" | "member" | "guest"; +function makeApp(oauth: SpotifyOAuthLike, role: Role = "admin", caps: string[] = []) { + const app = express(); + app.use(express.json()); + // Stand in for the global requireAuth that populates req.user. + app.use((req, _res, next) => { + (req as any).user = { role, capabilities: new Set(caps) }; + next(); + }); + app.use( + "/api/spotify", + createSpotifyRouter({ + oauth, + logger: pino({ level: "silent" }), + getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot" }), + webUiRedirect: "/", + }), + ); + return app; +} + +function fakeOauth(over: Partial = {}): SpotifyOAuthLike { + return { + buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize?x=1", state: "st" }), + handleCallback: async () => true, + isAuthorized: () => false, + ...over, + }; +} + +describe("spotify OAuth router", () => { + it("GET /login returns the authorize url for a permitted user", async () => { + const app = makeApp(fakeOauth()); + const res = await request(app).get("/api/spotify/login"); + expect(res.status).toBe(200); + expect(res.body.url).toContain("accounts.spotify.com/authorize"); + }); + + it("GET /login is 403 for a member lacking platform.auth", async () => { + const app = makeApp(fakeOauth(), "member", []); + const res = await request(app).get("/api/spotify/login"); + expect(res.status).toBe(403); + }); + + it("GET /callback with a good code+state redirects to success", async () => { + const handleCallback = vi.fn(async () => true); + const app = makeApp(fakeOauth({ handleCallback })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=success"); + expect(handleCallback).toHaveBeenCalledWith("abc", "st"); + }); + + it("GET /callback with a bad state (handleCallback false) redirects to error", async () => { + const app = makeApp(fakeOauth({ handleCallback: async () => false })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=WRONG"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + }); + + it("GET /callback with missing code does not call oauth and redirects to error", async () => { + const handleCallback = vi.fn(async () => true); + const app = makeApp(fakeOauth({ handleCallback })); + const res = await request(app).get("/api/spotify/callback?state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + expect(handleCallback).not.toHaveBeenCalled(); + }); + + it("GET /callback swallows a throwing handleCallback and redirects to error", async () => { + const app = makeApp(fakeOauth({ handleCallback: async () => { throw new Error("boom"); } })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + }); + + it("GET /status reflects authorized + backend + deviceName", async () => { + const app = makeApp(fakeOauth({ isAuthorized: () => true })); + const res = await request(app).get("/api/spotify/status"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot" }); + }); + + it("GET /status is 403 for a guest", async () => { + const app = makeApp(fakeOauth(), "guest"); + const res = await request(app).get("/api/spotify/status"); + expect(res.status).toBe(403); + }); +}); +``` +Run it — it MUST fail to compile/import (router does not exist yet): +`npx vitest run src/web/api/spotify.test.ts` → **expected: fails (Cannot find module './spotify.js')**. + +- [ ] **Step 2 — Create the router** `src/web/api/spotify.ts` to make Step 1 pass: +```ts +import { Router } from "express"; +import type { Logger } from "pino"; +import { requirePermission } from "../middleware/requirePermission.js"; +import { requireNotGuest } from "../middleware/requireNotGuest.js"; + +/** Minimal structural seam over SpotifyOAuth so this router needs no real + * network/crypto in tests. The concrete SpotifyOAuth satisfies it verbatim. */ +export interface SpotifyOAuthLike { + buildAuthorizeUrl(): { url: string; state: string }; + handleCallback(code: string, state: string): Promise; + isAuthorized(): boolean; +} + +export interface SpotifyRouterOptions { + oauth: SpotifyOAuthLike; + logger: Logger; + /** Process-wide backend info for /status (single Premium account, Stage 3). */ + getBackendInfo: () => { backend: string; deviceName: string }; + /** Web UI page to bounce the browser back to after the OAuth callback. */ + webUiRedirect?: string; +} + +export function createSpotifyRouter(opts: SpotifyRouterOptions): Router { + const { oauth, logger } = opts; + const redirectBase = opts.webUiRedirect ?? "/"; + const sep = redirectBase.includes("?") ? "&" : "?"; + const router = Router(); + + // Start the Authorization Code + PKCE flow: hand the WebUI the accounts.spotify.com + // authorize URL (verifier is stashed by state inside SpotifyOAuth). Gated like the + // other platform logins in auth.ts. + router.get("/login", requirePermission("platform.auth"), (_req, res) => { + try { + const { url } = oauth.buildAuthorizeUrl(); + res.json({ url }); + } catch (err) { + logger.error({ err }, "Spotify authorize URL build failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + // OAuth redirect target (own-app clientId => redirect_uri points here). This is a + // top-level browser navigation carrying the SameSite=Lax session cookie, so the + // global requireAuth passes; state is the CSRF guard for the flow itself. Always + // redirect (never JSON) so the user lands back in the UI. + router.get("/callback", async (req, res) => { + const code = typeof req.query.code === "string" ? req.query.code : ""; + const state = typeof req.query.state === "string" ? req.query.state : ""; + if (!code || !state) { + res.redirect(`${redirectBase}${sep}spotify=error`); + return; + } + try { + const ok = await oauth.handleCallback(code, state); + res.redirect(`${redirectBase}${sep}spotify=${ok ? "success" : "error"}`); + } catch (err) { + logger.error({ err }, "Spotify OAuth callback failed"); + res.redirect(`${redirectBase}${sep}spotify=error`); + } + }); + + // Whether the (single, process-wide) account is authorized, plus which backend + // + device name are configured — used by the WebUI to show login-needed state. + router.get("/status", requireNotGuest, (_req, res) => { + const info = opts.getBackendInfo(); + res.json({ + authorized: oauth.isAuthorized(), + backend: info.backend, + deviceName: info.deviceName, + }); + }); + + return router; +} +``` +`npx vitest run src/web/api/spotify.test.ts` → **expected: 8 passed**. + +- [ ] **Step 3 — Write the failing token-store test** `src/music/spotify/token-store.test.ts` (real fs, tmp dir under the scratch/OS temp — no network): +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createSpotifyTokenStore } from "./token-store.js"; + +describe("spotify token store", () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sp-tok-")); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const sample = { accessToken: "at", refreshToken: "rt", expiresAt: 123, scope: "streaming" }; + + it("returns null before anything is saved", () => { + expect(createSpotifyTokenStore(dir).load()).toBeNull(); + }); + + it("round-trips saved tokens", () => { + const store = createSpotifyTokenStore(dir); + store.save(sample); + expect(createSpotifyTokenStore(dir).load()).toEqual(sample); + }); + + it("clear() removes the persisted tokens", () => { + const store = createSpotifyTokenStore(dir); + store.save(sample); + store.clear(); + expect(store.load()).toBeNull(); + }); + + it("load() returns null on a corrupt / partial file", () => { + fs.writeFileSync(path.join(dir, "oauth-tokens.json"), "{not json"); + expect(createSpotifyTokenStore(dir).load()).toBeNull(); + fs.writeFileSync(path.join(dir, "oauth-tokens.json"), JSON.stringify({ accessToken: "x" })); + expect(createSpotifyTokenStore(dir).load()).toBeNull(); // no refreshToken + }); +}); +``` +`npx vitest run src/music/spotify/token-store.test.ts` → **expected: fails (module not found)**. + +- [ ] **Step 4 — Create the token store** `src/music/spotify/token-store.ts`: +```ts +import fs from "node:fs"; +import path from "node:path"; +import type { OAuthTokens, OAuthTokenStore } from "./spotify-oauth.js"; + +const FILE = "oauth-tokens.json"; + +/** File-backed OAuthTokenStore under the data dir, mirroring createCookieStore + * (0o600 perms). Process-wide single account for Stage 3. */ +export function createSpotifyTokenStore(dir: string): OAuthTokenStore { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, FILE); + return { + load(): OAuthTokens | null { + if (!fs.existsSync(filePath)) return null; + try { + const data = JSON.parse(fs.readFileSync(filePath, "utf-8")); + // A stored token is only usable if it carries a refresh token — anything + // else is treated as "not authorized" so getAccessToken() re-runs sign-in. + if (!data || typeof data.refreshToken !== "string") return null; + return data as OAuthTokens; + } catch { + return null; + } + }, + save(t: OAuthTokens): void { + fs.writeFileSync(filePath, JSON.stringify(t), { encoding: "utf-8", mode: 0o600 }); + }, + clear(): void { + try { + fs.rmSync(filePath, { force: true }); + } catch { + /* already gone */ + } + }, + }; +} +``` +`npx vitest run src/music/spotify/token-store.test.ts` → **expected: 4 passed**. + +- [ ] **Step 5 — Mount in `src/web/server.ts`.** Add the import, extend options, mount after `requireAuth` (so `/login` + `/status` are gated and the callback carries the session cookie): +```ts +// with the other api imports: +import { createSpotifyRouter } from "./api/spotify.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; +``` +```ts +// in WebServerOptions (optional so existing call sites/tests keep compiling): + spotifyOAuth?: SpotifyOAuth; +``` +```ts +// after: app.use("/api/auth", createAuthRouter(...)); + if (options.spotifyOAuth) { + app.use( + "/api/spotify", + createSpotifyRouter({ + oauth: options.spotifyOAuth, + logger, + getBackendInfo: () => ({ + backend: options.config.spotify.backend, + deviceName: options.config.spotify.deviceName, + }), + webUiRedirect: "/", + }), + ); + } +``` +*(The GET `/callback` is a safe method, so `csrfOriginCheck` — which only guards state-changing verbs — lets the top-level redirect through.)* + +- [ ] **Step 6 — Wire `src/index.ts`.** Build the single process-wide token store + `SpotifyOAuth` and pass it to the web server. Own-app clientId (reused `spotify.clientId`) redirects to the web callback; empty clientId falls back to the librespot public client (its fixed `:5588/login` loopback listener belongs to the backend task): +```ts +// with the other spotify imports: +import { SpotifyOAuth } from "./music/spotify/spotify-oauth.js"; +import { createSpotifyTokenStore } from "./music/spotify/token-store.js"; +``` +```ts +// after spotifyProvider setup, before createWebServer(...): + const spotifyTokenStore = createSpotifyTokenStore(path.join(SPOTIFY_DATA_DIR, "oauth")); + const ownClientId = config.spotify.clientId.trim(); + const spotifyOAuth = new SpotifyOAuth({ + clientId: ownClientId || undefined, + redirectUri: ownClientId + ? `http://127.0.0.1:${config.webPort}/api/spotify/callback` + : undefined, + store: spotifyTokenStore, + }); +``` +```ts +// add to the createWebServer({ ... }) options object: + spotifyOAuth, +``` +*(This same `spotifyOAuth` instance is the one the Rust-backend/controller task hands to `BotManager` — it is the single shared authorization for the process. No `BotManager` change is required for Task 6; the status endpoint reads backend/device from config.)* + +- [ ] **Step 7 — Typecheck + full verify.** + - `npx tsc --noEmit` → **expected: no errors**. + - `npx vitest run src/web/api/spotify.test.ts src/music/spotify/token-store.test.ts` → **expected: 12 passed**. + - `npx vitest run src/web` → **expected: all web tests pass (no regression from the new mount/options)**. + +- [ ] **Step 8 — Commit.** +```bash +git checkout -b stage3-task6-web-oauth +git add src/web/api/spotify.ts src/web/api/spotify.test.ts \ + src/music/spotify/token-store.ts src/music/spotify/token-store.test.ts \ + src/web/server.ts src/index.ts +git commit -m "$(cat <<'EOF' +feat(spotify): web OAuth endpoints + PKCE token store wiring (Stage 3, Task 6) + +Add /api/spotify {login,callback,status} router behind the SpotifyOAuth seam, +a 0600 file-backed OAuthTokenStore under the data dir, and process-wide +SpotifyOAuth wiring in index.ts/server.ts. Router is DI-tested with supertest +(no network); store is fs-tested. Single Premium account for Stage 3. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +**Notes:** Not e2e-testable on Windows without a Premium account — all tests are unit/mock level (fake `SpotifyOAuthLike`, real fs tmp dir; no binary, no `accounts.spotify.com`). PKCE crypto (`node:crypto` verifier/challenge) is exercised in the spotify-oauth.ts task's own tests; this task only drives the already-built `SpotifyOAuth` through the HTTP surface. + +--- + +### Task 7: Whole-stage verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full backend suite** — `npx vitest run --no-file-parallelism` → all pass (existing + new Stage-3 unit tests; the go-librespot Stage-2 path still green). +- [ ] **Step 2: Typecheck + frontend build** — `npx tsc --noEmit && cd web && npm run build` → zero type errors; frontend builds. +- [ ] **Step 3: Gating sanity (documented, not live):** confirm by reading that when no `librespot` binary is present or OAuth is unauthorized, `SpotifyController.ensureStarted()` returns false → Stage-1 sentinel fallback (queue keeps moving), and the Stage-2 go-librespot path is unaffected on Linux. State in the report that live audio + the Spotify Connect control path were NOT verified here (need Premium + a real librespot + a real account) and list exactly what WAS verified (unit tests with mocked process/HTTP, PKCE crypto, tsc, build). +- [ ] **Step 4: Commit** — `git add -A && git commit -m "chore(spotify): stage 3 verification pass" --allow-empty` From 8c84090b63b8ea60e5abcbedece1aff62110ac27 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:04:16 +0800 Subject: [PATCH 23/59] feat(spotify): add Rust librespot binary resolver (Stage 3 Task 1) Append isRustLibrespotSupported/pickLibrespotPath/findLibrespot/ checkLibrespotAvailable/resetLibrespotBinaryCache to binary.ts, mirroring the go-librespot resolver. Supported on all platforms (pipe->stdout), resolves librespot.exe on win32, caches only positive --version probes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/binary.test.ts | 127 +++++++++++++++++++++++++++++++ src/music/spotify/binary.ts | 99 ++++++++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/src/music/spotify/binary.test.ts b/src/music/spotify/binary.test.ts index 47a8231..a19f51f 100644 --- a/src/music/spotify/binary.test.ts +++ b/src/music/spotify/binary.test.ts @@ -7,6 +7,12 @@ import { checkGoLibrespotAvailable, resetGoLibrespotBinaryCache, __setGoLibrespotVersionProbe, + isRustLibrespotSupported, + pickLibrespotPath, + findLibrespot, + checkLibrespotAvailable, + resetLibrespotBinaryCache, + __setLibrespotVersionProbe, } from "./binary.js"; const origPlatform = process.platform; @@ -18,6 +24,8 @@ afterEach(() => { setPlatform(origPlatform); __setGoLibrespotVersionProbe(null); resetGoLibrespotBinaryCache(); + __setLibrespotVersionProbe(null); + resetLibrespotBinaryCache(); }); describe("isGoLibrespotSupported", () => { @@ -107,3 +115,122 @@ describe("checkGoLibrespotAvailable", () => { expect(await checkGoLibrespotAvailable()).toBe(false); }); }); + +describe("isRustLibrespotSupported", () => { + it("is true on every platform (pipe->stdout works everywhere)", () => { + setPlatform("linux"); + expect(isRustLibrespotSupported()).toBe(true); + setPlatform("win32"); + expect(isRustLibrespotSupported()).toBe(true); + setPlatform("darwin"); + expect(isRustLibrespotSupported()).toBe(true); + }); +}); + +describe("pickLibrespotPath (bin/ then PATH ordering, win32 exe)", () => { + it("prefers the bin/ path when the file exists", () => { + const binPath = join("some", "root", "bin", "librespot"); + expect( + pickLibrespotPath([binPath, "librespot"], (p) => p === binPath), + ).toBe(binPath); + }); + + it("prefers the bin/librespot.exe path on win32 when it exists", () => { + setPlatform("win32"); + const binExe = join("some", "root", "bin", "librespot.exe"); + expect( + pickLibrespotPath([binExe, "librespot.exe"], (p) => p === binExe), + ).toBe(binExe); + }); + + it("falls through to the bare PATH name (librespot) on posix when bin/ is missing", () => { + setPlatform("linux"); + const binPath = join("some", "root", "bin", "librespot"); + expect(pickLibrespotPath([binPath, "librespot"], () => false)).toBe( + "librespot", + ); + }); + + it("falls through to librespot.exe on win32 when bin/ is missing", () => { + setPlatform("win32"); + const binExe = join("some", "root", "bin", "librespot.exe"); + expect(pickLibrespotPath([binExe], () => false)).toBe("librespot.exe"); + }); + + it("returns bare command names without touching the filesystem", () => { + const exists = vi.fn(() => false); + expect(pickLibrespotPath(["librespot"], exists)).toBe("librespot"); + expect(exists).not.toHaveBeenCalled(); + }); +}); + +describe("findLibrespot", () => { + it("returns the bare command name when bin/librespot is absent", () => { + // No librespot binary is committed under bin/, so resolution must fall + // back to the bare PATH name (execFile resolves it at run time). + setPlatform("linux"); + expect(findLibrespot()).toBe("librespot"); + }); + + it("returns librespot.exe on win32 when bin/librespot.exe is absent", () => { + setPlatform("win32"); + expect(findLibrespot()).toBe("librespot.exe"); + }); +}); + +describe("checkLibrespotAvailable", () => { + it("returns true when the binary responds to --version (any platform)", async () => { + setPlatform("win32"); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + }); + + it("returns true on darwin too (no platform gate)", async () => { + setPlatform("darwin"); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + }); + + it("caches only positive results and probes once", async () => { + setPlatform("linux"); + const probe = vi.fn(async () => {}); + __setLibrespotVersionProbe(probe); + expect(await checkLibrespotAvailable()).toBe(true); + expect(await checkLibrespotAvailable()).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("does not cache a failed probe (retries on the next call)", async () => { + setPlatform("win32"); + __setLibrespotVersionProbe(async () => { + throw new Error("ENOENT"); + }); + expect(await checkLibrespotAvailable()).toBe(false); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + }); + + it("resetLibrespotBinaryCache clears a cached positive", async () => { + setPlatform("linux"); + __setLibrespotVersionProbe(async () => {}); + expect(await checkLibrespotAvailable()).toBe(true); + resetLibrespotBinaryCache(); + __setLibrespotVersionProbe(async () => { + throw new Error("gone"); + }); + expect(await checkLibrespotAvailable()).toBe(false); + }); + + it("de-dupes concurrent in-flight probes", async () => { + setPlatform("linux"); + const probe = vi.fn(async () => {}); + __setLibrespotVersionProbe(probe); + const [a, b] = await Promise.all([ + checkLibrespotAvailable(), + checkLibrespotAvailable(), + ]); + expect(a).toBe(true); + expect(b).toBe(true); + expect(probe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/music/spotify/binary.ts b/src/music/spotify/binary.ts index c20e05b..c33c409 100644 --- a/src/music/spotify/binary.ts +++ b/src/music/spotify/binary.ts @@ -91,3 +91,102 @@ export function resetGoLibrespotBinaryCache(): void { cachedAvailable = false; pendingCheck = null; } + +// --------------------------------------------------------------------------- +// Rust librespot (librespot-org) resolver — mirrors the go-librespot fns +// above. Unlike go-librespot, Rust librespot's `--backend pipe` writes PCM to +// *stdout* on every platform (no FIFO, no audio device), so it is supported +// on Windows/macOS/Linux alike and the binary is named librespot.exe on win32. +// --------------------------------------------------------------------------- + +/** + * True on ALL platforms. The Rust librespot pipe backend writes raw bytes to + * process stdout, which Node's spawned child.stdout receives unmodified on + * Windows too — so there is no platform gate here (contrast + * isGoLibrespotSupported, which is Linux-only). + */ +export function isRustLibrespotSupported(): boolean { + return true; +} + +/** + * Pure resolver core behind findLibrespot(). Returns the first candidate that + * is either a bare command name (left for execFile to resolve via PATH) or an + * existing bin/ file. Exported so tests can inject candidates + a fake + * existence predicate and need no real binary on disk. Mirrors + * pickGoLibrespotPath but keys off the win32 exe name. + */ +export function pickLibrespotPath( + candidates: string[], + exists: (p: string) => boolean, +): string { + const exe = process.platform === "win32" ? "librespot.exe" : "librespot"; + for (const c of candidates) { + // bin/ paths only count when the file is actually present; bare names are + // returned unconditionally and resolved later via PATH. + const isBinPath = c.includes(join("bin", "librespot")); + if (!isBinPath || exists(c)) return c; + } + return exe; +} + +/** + * Resolve the Rust librespot binary path: project bin/ dir first, then PATH. + * On win32 both the bin/librespot.exe candidate and the bare "librespot.exe" + * fallback are used so a PATH-installed librespot.exe (scoop/choco) resolves. + */ +export function findLibrespot(): string { + const exe = process.platform === "win32" ? "librespot.exe" : "librespot"; + // src/music/spotify -> ../../../bin (same depth as findGoLibrespot). + const binExe = join(__dirname, "..", "..", "..", "bin", exe); + const binBare = join(__dirname, "..", "..", "..", "bin", "librespot"); + return pickLibrespotPath([binExe, binBare, exe], existsSync); +} + +// Injectable `--version` probe. Defaults to the real execFile call; tests +// override it so checkLibrespotAvailable() needs no real binary. Keeps the +// public checkLibrespotAvailable() signature param-free per the contract. +type LibrespotVersionProbe = (bin: string) => Promise; +const realLibrespotProbe: LibrespotVersionProbe = async (bin) => { + await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 }); +}; +let librespotVersionProbe: LibrespotVersionProbe = realLibrespotProbe; + +/** Test hook: override the `--version` probe, or restore the default with null. */ +export function __setLibrespotVersionProbe( + probe: LibrespotVersionProbe | null, +): void { + librespotVersionProbe = probe ?? realLibrespotProbe; +} + +/** + * Availability check for Rust librespot. No platform gate (supported + * everywhere). Runs `librespot --version` (5s timeout) and caches ONLY the + * positive result — a missing binary is retried on the next call so the + * operator can install it (cargo/scoop/choco) without restarting the server. + */ +let rustCachedAvailable = false; +let rustPendingCheck: Promise | null = null; +export async function checkLibrespotAvailable(): Promise { + if (!isRustLibrespotSupported()) return false; + if (rustCachedAvailable) return true; + if (rustPendingCheck) return rustPendingCheck; + rustPendingCheck = (async () => { + try { + await librespotVersionProbe(findLibrespot()); + rustCachedAvailable = true; + return true; + } catch { + return false; + } finally { + rustPendingCheck = null; + } + })(); + return rustPendingCheck; +} + +/** Force re-detection on the next call (for tests). */ +export function resetLibrespotBinaryCache(): void { + rustCachedAvailable = false; + rustPendingCheck = null; +} From 333f7606e4ca65623869d834b02fffbe9f812092 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:13:05 +0800 Subject: [PATCH 24/59] feat(spotify): add SpotifyOAuth Authorization Code + PKCE control-token flow Stage 3 Task 2. PKCE (S256) authorize URL, code exchange, and refresh with rotated-refresh-token persistence + invalid_grant store-clear. axios/http and token store injected for fully mocked, network-free unit tests. Corrections C3.2 (require the operator's own client_id; no librespot public client / :5588 default; buildAuthorizeUrl throws + isAuthorized false without it) and C3.7 (delete the state->verifier map entry in a finally on every terminal path) applied. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/spotify-oauth.test.ts | 297 ++++++++++++++++++++++++ src/music/spotify/spotify-oauth.ts | 225 ++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 src/music/spotify/spotify-oauth.test.ts create mode 100644 src/music/spotify/spotify-oauth.ts diff --git a/src/music/spotify/spotify-oauth.test.ts b/src/music/spotify/spotify-oauth.test.ts new file mode 100644 index 0000000..481d3e9 --- /dev/null +++ b/src/music/spotify/spotify-oauth.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SpotifyOAuth, + SPOTIFY_CONTROL_SCOPES, + generateCodeVerifier, + codeChallengeS256, + createFileOAuthTokenStore, + type OAuthTokens, + type OAuthTokenStore, +} from "./spotify-oauth.js"; + +// Correction C3.2: the control OAuth REQUIRES a user-provided client_id (their +// own Spotify Developer app) + caller-supplied loopback redirect. There is NO +// librespot public-client / :5588 default. +const CLIENT_ID = "test-client-id"; +const REDIRECT_URI = "http://127.0.0.1:8888/api/spotify/callback"; + +/** In-memory store exposing `.value` so tests can assert persistence. */ +function memStore( + initial: OAuthTokens | null = null, +): OAuthTokenStore & { value: OAuthTokens | null } { + const s = { + value: initial, + load() { + return s.value; + }, + save(t: OAuthTokens) { + s.value = t; + }, + clear() { + s.value = null; + }, + }; + return s; +} + +describe("PKCE helpers", () => { + it("generateCodeVerifier returns 64 chars from the unreserved set", () => { + const v = generateCodeVerifier(); + expect(v).toHaveLength(64); + expect(v).toMatch(/^[A-Za-z0-9\-._~]{64}$/); + expect(generateCodeVerifier()).not.toBe(v); // random + }); + + it("codeChallengeS256 is base64url(sha256) with no padding (43 chars)", () => { + const c = codeChallengeS256("abc123"); + expect(c).toHaveLength(43); // 32-byte digest -> 43 base64url chars + expect(c).not.toContain("="); + expect(c).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); + +describe("SpotifyOAuth.buildAuthorizeUrl", () => { + it("builds accounts.spotify.com/authorize with the caller's clientId + redirectUri + S256", () => { + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store: memStore(), + }); + const { url, state } = oauth.buildAuthorizeUrl(); + const u = new URL(url); + expect(u.origin + u.pathname).toBe("https://accounts.spotify.com/authorize"); + const p = u.searchParams; + expect(p.get("client_id")).toBe(CLIENT_ID); + expect(p.get("response_type")).toBe("code"); + expect(p.get("redirect_uri")).toBe(REDIRECT_URI); + expect(p.get("code_challenge_method")).toBe("S256"); + expect(p.get("code_challenge")).toHaveLength(43); + expect(p.get("scope")).toBe(SPOTIFY_CONTROL_SCOPES); + expect(p.get("state")).toBe(state); + expect(state).toMatch(/^[0-9a-f]{32}$/); + expect(oauth.getClientId()).toBe(CLIENT_ID); + expect(oauth.getRedirectUri()).toBe(REDIRECT_URI); + }); + + // Correction C3.2: no clientId => cannot start OAuth; throw a clear message. + it("throws a clear error when clientId is empty", () => { + const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store: memStore() }); + expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i); + }); +}); + +describe("SpotifyOAuth.isAuthorized (C3.2)", () => { + it("is false without a clientId even if a refresh token is stored", () => { + const store = memStore({ + accessToken: "a", + refreshToken: "r", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store }); + expect(oauth.isAuthorized()).toBe(false); + }); + + it("is true with a clientId and a stored refresh token", () => { + const store = memStore({ + accessToken: "a", + refreshToken: "r", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store }); + expect(oauth.isAuthorized()).toBe(true); + }); +}); + +describe("SpotifyOAuth.handleCallback", () => { + it("exchanges the code (PKCE verifier matches the authorize challenge) and persists tokens", async () => { + const store = memStore(); + const http = { + post: vi.fn().mockResolvedValue({ + data: { + access_token: "a1", + refresh_token: "r1", + expires_in: 3600, + scope: SPOTIFY_CONTROL_SCOPES, + }, + }), + } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store, + deps: { http }, + }); + + const { url, state } = oauth.buildAuthorizeUrl(); + const challenge = new URL(url).searchParams.get("code_challenge")!; + + const ok = await oauth.handleCallback("CODE123", state); + expect(ok).toBe(true); + + const [path, bodyStr, cfg] = http.post.mock.calls[0]; + expect(path).toBe("/api/token"); + expect(cfg.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(bodyStr as string); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("CODE123"); + expect(body.get("redirect_uri")).toBe(REDIRECT_URI); + expect(body.get("client_id")).toBe(CLIENT_ID); + // The verifier sent MUST hash to the challenge advertised in the authorize URL. + const verifier = body.get("code_verifier")!; + expect(codeChallengeS256(verifier)).toBe(challenge); + + expect(store.value?.accessToken).toBe("a1"); + expect(store.value?.refreshToken).toBe("r1"); + expect(store.value?.expiresAt).toBeGreaterThan(Date.now()); + expect(oauth.isAuthorized()).toBe(true); + }); + + it("rejects an unknown state without calling the token endpoint (CSRF guard)", async () => { + const http = { post: vi.fn() } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store: memStore(), + deps: { http }, + }); + expect(await oauth.handleCallback("CODE", "not-a-real-state")).toBe(false); + expect(http.post).not.toHaveBeenCalled(); + }); + + // Correction C3.7: the state->verifier entry is deleted on EVERY terminal + // path (finally), so a failed login never leaks it and cannot be replayed. + it("deletes the pending verifier even when the token exchange fails", async () => { + const http = { + post: vi.fn().mockRejectedValue({ + response: { status: 400, data: { error: "invalid_grant" } }, + }), + } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store: memStore(), + deps: { http }, + }); + const { state } = oauth.buildAuthorizeUrl(); + + // First attempt fails at the network/token step. + expect(await oauth.handleCallback("CODE", state)).toBe(false); + expect(http.post).toHaveBeenCalledTimes(1); + + // Replaying the same state now fails the CSRF guard (verifier was deleted), + // WITHOUT hitting the token endpoint again. + expect(await oauth.handleCallback("CODE", state)).toBe(false); + expect(http.post).toHaveBeenCalledTimes(1); + }); +}); + +describe("SpotifyOAuth.getAccessToken", () => { + it("returns the cached token without refreshing when still valid", async () => { + const http = { post: vi.fn() } as any; + const store = memStore({ + accessToken: "cached", + refreshToken: "r1", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + expect(await oauth.getAccessToken()).toBe("cached"); + expect(http.post).not.toHaveBeenCalled(); + }); + + it("refreshes when expired and persists the ROTATED refresh token", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockResolvedValue({ + data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 }, + }), + } as any; + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + + expect(await oauth.getAccessToken()).toBe("a2"); + const body = new URLSearchParams(http.post.mock.calls[0][1] as string); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("r1"); + expect(body.get("client_id")).toBe(CLIENT_ID); + expect(store.value?.refreshToken).toBe("r2"); // rotated + persisted + expect(store.value?.accessToken).toBe("a2"); + }); + + it("keeps the old refresh token when the refresh response omits a new one", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockResolvedValue({ data: { access_token: "a2", expires_in: 3600 } }), + } as any; + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + expect(await oauth.getAccessToken()).toBe("a2"); + expect(store.value?.refreshToken).toBe("r1"); + }); + + it("clears the store and returns null on invalid_grant (expired refresh token)", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockRejectedValue({ + response: { status: 400, data: { error: "invalid_grant" } }, + }), + } as any; + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + expect(await oauth.getAccessToken()).toBeNull(); + expect(store.value).toBeNull(); + expect(oauth.isAuthorized()).toBe(false); + }); + + it("returns null when unauthorized (no stored refresh token)", async () => { + const http = { post: vi.fn() } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + store: memStore(), + deps: { http }, + }); + expect(await oauth.getAccessToken()).toBeNull(); + expect(oauth.isAuthorized()).toBe(false); + expect(http.post).not.toHaveBeenCalled(); + }); +}); + +describe("createFileOAuthTokenStore", () => { + it("round-trips save/load and clear() removes it", () => { + const dir = mkdtempSync(join(tmpdir(), "sp-oauth-")); + const file = join(dir, "nested", "tokens.json"); + try { + const store = createFileOAuthTokenStore(file); + expect(store.load()).toBeNull(); // missing file + const t: OAuthTokens = { + accessToken: "a", + refreshToken: "r", + expiresAt: 123, + scope: "s", + }; + store.save(t); + expect(store.load()).toEqual(t); + store.clear(); + expect(store.load()).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/music/spotify/spotify-oauth.ts b/src/music/spotify/spotify-oauth.ts new file mode 100644 index 0000000..b77232c --- /dev/null +++ b/src/music/spotify/spotify-oauth.ts @@ -0,0 +1,225 @@ +import axios, { type AxiosInstance } from "axios"; +import { createHash, randomBytes } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; + +/** + * Player-control scopes requested for the USER token: streaming (drives + * librespot as a Connect device) + read/modify playback + currently-playing + + * private-playlist reads. + */ +export const SPOTIFY_CONTROL_SCOPES = + "streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private"; + +const ACCOUNTS_BASE = "https://accounts.spotify.com"; +// Hand a token back only if it survives ~30s, matching webapi.ts's skew. +const EXPIRY_SKEW_MS = 30_000; +// RFC 7636 §4.1 unreserved set: [A-Za-z0-9-._~]. +const PKCE_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; +const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }; + +export interface OAuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope: string; +} + +export interface OAuthTokenStore { + load(): OAuthTokens | null; + save(t: OAuthTokens): void; + clear(): void; +} + +export interface SpotifyOAuthOptions { + /** + * Correction C3.2: the caller's OWN Spotify Developer app client_id. There is + * no librespot-public-client fallback — an empty clientId disables OAuth. + */ + clientId?: string; + /** + * Loopback redirect registered on the caller's Spotify app, supplied by the + * bot's web layer (e.g. its `/api/spotify/callback`). Must exactly match the + * value used at both the authorize and token steps. + */ + redirectUri?: string; + store: OAuthTokenStore; + deps?: { http?: AxiosInstance }; +} + +/** 64 random chars from the PKCE unreserved set (43-128 allowed by the spec). */ +export function generateCodeVerifier(): string { + const bytes = randomBytes(64); + let out = ""; + for (let i = 0; i < 64; i++) out += PKCE_CHARS[bytes[i] % PKCE_CHARS.length]; + return out; +} + +/** base64url(SHA256(verifier)) with no padding — the S256 code challenge. */ +export function codeChallengeS256(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +/** Persist OAuth tokens as a 0600 JSON file (used by the controller). */ +export function createFileOAuthTokenStore(filePath: string): OAuthTokenStore { + return { + load() { + try { + if (!existsSync(filePath)) return null; + const parsed = JSON.parse(readFileSync(filePath, "utf8")); + return parsed?.refreshToken ? (parsed as OAuthTokens) : null; + } catch { + return null; // missing/corrupt -> treat as unauthorized + } + }, + save(t: OAuthTokens) { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(t, null, 2), { mode: 0o600 }); + }, + clear() { + try { + rmSync(filePath, { force: true }); + } catch { + /* already gone */ + } + }, + }; +} + +/** + * Authorization Code + PKCE flow for the USER player-control token. Public + * client (no secret). + * + * Correction C3.2: this REQUIRES the operator's own registered Spotify app — + * there is NO reuse of librespot's first-party keymaster client / fixed + * :5588 redirect. Without a clientId, `isAuthorized()` is false and + * `buildAuthorizeUrl()` throws. + * + * Refresh rotates the refresh token, so the newest is always persisted; + * invalid_grant clears the store (re-login required). Access/refresh tokens + * are never logged. + */ +export class SpotifyOAuth { + private clientId: string; + private redirectUri: string; + private store: OAuthTokenStore; + private http: AxiosInstance; + // Pending PKCE verifiers keyed by state, awaiting the loopback redirect back. + private pendingVerifiers = new Map(); + + constructor(o: SpotifyOAuthOptions) { + this.clientId = o.clientId ?? ""; + this.redirectUri = o.redirectUri ?? ""; + this.store = o.store; + this.http = + o.deps?.http ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 }); + } + + getClientId(): string { + return this.clientId; + } + + getRedirectUri(): string { + return this.redirectUri; + } + + isAuthorized(): boolean { + // C3.2: no client_id means we could never refresh, so treat as unauthorized. + return !!this.clientId && !!this.store.load()?.refreshToken; + } + + buildAuthorizeUrl(): { url: string; state: string } { + if (!this.clientId) { + // C3.2: cannot start OAuth against nobody's app. + throw new Error("Set your Spotify Client ID in settings first"); + } + const state = randomBytes(16).toString("hex"); + const verifier = generateCodeVerifier(); + this.pendingVerifiers.set(state, verifier); + const params = new URLSearchParams({ + client_id: this.clientId, + response_type: "code", + redirect_uri: this.redirectUri, + code_challenge: codeChallengeS256(verifier), + code_challenge_method: "S256", + scope: SPOTIFY_CONTROL_SCOPES, + state, + }); + return { url: `${ACCOUNTS_BASE}/authorize?${params.toString()}`, state }; + } + + async handleCallback(code: string, state: string): Promise { + const verifier = this.pendingVerifiers.get(state); + if (!verifier) return false; // unknown/expired state -> CSRF guard + // C3.7: drop the state->verifier entry on EVERY terminal path (success, + // rejected token exchange, or throw) so a failed login can't leak/replay it. + try { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + code_verifier: verifier, + }); + const { data } = await this.http.post("/api/token", body.toString(), { + headers: FORM_HEADERS, + }); + if (!data?.access_token || !data?.refresh_token) return false; + this.store.save(this.toTokens(data, data.refresh_token, data.scope)); + return true; + } catch { + return false; + } finally { + this.pendingVerifiers.delete(state); + } + } + + async getAccessToken(): Promise { + if (!this.clientId) return null; // C3.2: no app => nothing to mint against + const tokens = this.store.load(); + if (!tokens?.refreshToken) return null; // unauthorized + if (tokens.accessToken && Date.now() < tokens.expiresAt) { + return tokens.accessToken; + } + return this.refresh(tokens); + } + + private async refresh(current: OAuthTokens): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: current.refreshToken, + client_id: this.clientId, + }); + try { + const { data } = await this.http.post("/api/token", body.toString(), { + headers: FORM_HEADERS, + }); + if (!data?.access_token) return null; + // PKCE rotates the refresh token; fall back to the current one if omitted. + const rotated = data.refresh_token || current.refreshToken; + const saved = this.toTokens(data, rotated, data.scope ?? current.scope); + this.store.save(saved); + return saved.accessToken; + } catch (err: any) { + // invalid_grant => refresh token revoked/expired: discard, force re-login. + if (err?.response?.data?.error === "invalid_grant") this.store.clear(); + return null; + } + } + + private toTokens(data: any, refreshToken: string, scope: string): OAuthTokens { + return { + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000 - EXPIRY_SKEW_MS, + scope: scope ?? SPOTIFY_CONTROL_SCOPES, + }; + } +} From 540bf8c032c70b651edb42c91a94f1b7a9412737 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:22:54 +0800 Subject: [PATCH 25/59] feat(spotify): add SpotifyConnectApi Web API Connect control client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps an injected axios instance with a live user Bearer token from getToken(): getDevices/findDeviceByName, transfer/play/pause/resume/seek, and getPlaybackState (null on 204). Read-only calls degrade gracefully; mutating calls no-op when unauthorized. Fully unit-tested with a mocked AxiosInstance (no network) — Windows-targeted, not e2e-testable (no Premium). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/connect-api.test.ts | 271 ++++++++++++++++++++++++++ src/music/spotify/connect-api.ts | 150 ++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 src/music/spotify/connect-api.test.ts create mode 100644 src/music/spotify/connect-api.ts diff --git a/src/music/spotify/connect-api.test.ts b/src/music/spotify/connect-api.test.ts new file mode 100644 index 0000000..8aaee85 --- /dev/null +++ b/src/music/spotify/connect-api.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi } from "vitest"; +import type { AxiosInstance } from "axios"; +import { SpotifyConnectApi } from "./connect-api.js"; + +/** Minimal axios stub: only get/put are exercised by the Connect client. */ +function makeHttp(overrides?: Partial>) { + return { + get: vi.fn().mockResolvedValue({ status: 200, data: {} }), + put: vi.fn().mockResolvedValue({ status: 200, data: {} }), + ...overrides, + } as unknown as AxiosInstance; +} + +const AUTH = { headers: { Authorization: "Bearer tok123" } }; +const token = () => + vi.fn<() => Promise>().mockResolvedValue("tok123"); + +describe("SpotifyConnectApi.getDevices", () => { + it("GETs /v1/me/player/devices with the bearer header and maps the list", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + devices: [ + { id: "dev-1", name: "TS Bot", is_active: true, type: "Speaker" }, + { id: "dev-2", name: "Phone", is_active: false }, + ], + }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + const devices = await api.getDevices(); + expect(http.get).toHaveBeenCalledWith("/v1/me/player/devices", AUTH); + expect(devices).toEqual([ + { id: "dev-1", name: "TS Bot", is_active: true }, + { id: "dev-2", name: "Phone", is_active: false }, + ]); + }); + + it("returns [] when getToken() is null (unauthorized) without calling http", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await expect(api.getDevices()).resolves.toEqual([]); + expect(http.get).not.toHaveBeenCalled(); + }); + + it("returns [] on a 401/network rejection (graceful)", async () => { + const err: any = new Error("unauthorized"); + err.response = { status: 401 }; + const http = makeHttp({ get: vi.fn().mockRejectedValue(err) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getDevices()).resolves.toEqual([]); + }); +}); + +describe("SpotifyConnectApi.findDeviceByName", () => { + it("returns the matching device id", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { devices: [{ id: "dev-1", name: "TS Bot", is_active: false }] }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.findDeviceByName("TS Bot")).resolves.toBe("dev-1"); + }); + + it("returns null when no device name matches", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { devices: [{ id: "dev-1", name: "Other", is_active: false }] }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.findDeviceByName("TS Bot")).resolves.toBeNull(); + }); +}); + +describe("SpotifyConnectApi mutating calls", () => { + it("transfer() PUTs /v1/me/player with device_ids + play=false default", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.transfer("dev-1"); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player", + { device_ids: ["dev-1"], play: false }, + AUTH, + ); + }); + + it("transfer(id, true) forwards play=true", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.transfer("dev-1", true); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player", + { device_ids: ["dev-1"], play: true }, + AUTH, + ); + }); + + it("play() PUTs /v1/me/player/play?device_id= with the uris body", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.play("dev-1", "spotify:track:abc"); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player/play", + { uris: ["spotify:track:abc"] }, + { headers: { Authorization: "Bearer tok123" }, params: { device_id: "dev-1" } }, + ); + }); + + it("pause() PUTs /v1/me/player/pause (no params) with no body", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.pause(); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: undefined, + }); + }); + + it("pause(id) forwards device_id param", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.pause("dev-1"); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { device_id: "dev-1" }, + }); + }); + + it("resume() PUTs /v1/me/player/play with no uris body (resume)", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.resume(); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: undefined, + }); + }); + + it("seek() PUTs /v1/me/player/seek?position_ms=", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.seek(42000); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { position_ms: 42000 }, + }); + }); + + it("seek(ms, id) adds device_id param", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.seek(1000, "dev-1"); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { position_ms: 1000, device_id: "dev-1" }, + }); + }); + + it("mutating calls no-op (no http.put) when unauthorized", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await api.transfer("dev-1"); + await api.play("dev-1", "spotify:track:x"); + await api.pause(); + expect(http.put).not.toHaveBeenCalled(); + }); +}); + +/** + * REQUIRED CORRECTION C3.6: mutating calls must NOT reject up the queue-advance + * path. A transient 403 (non-Premium) / 404 (no active device) / 429 + * (rate-limited) from Spotify must be swallowed (resolve to void), never thrown, + * so a failed play() degrades to "couldn't play" instead of an unhandled + * rejection that crashes the backend. + */ +describe("SpotifyConnectApi C3.6 — mutating calls are resilient (no throw)", () => { + function rejectingHttp(status: number) { + const err: any = new Error(`http ${status}`); + err.response = { status }; + return makeHttp({ put: vi.fn().mockRejectedValue(err) }); + } + + it("play() does NOT throw on a 404 (no active device)", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(404) }); + await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined(); + }); + + it("play() does NOT throw on a 429 (rate-limited)", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(429) }); + await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined(); + }); + + it("transfer() does NOT throw on a 403 (non-Premium)", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(403) }); + await expect(api.transfer("dev-1", true)).resolves.toBeUndefined(); + }); + + it("pause/resume/seek do NOT throw on a rejection", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(404) }); + await expect(api.pause("dev-1")).resolves.toBeUndefined(); + await expect(api.resume("dev-1")).resolves.toBeUndefined(); + await expect(api.seek(1000, "dev-1")).resolves.toBeUndefined(); + }); + + it("play() does NOT throw on a raw network error (no response)", async () => { + const http = makeHttp({ put: vi.fn().mockRejectedValue(new Error("ECONNRESET")) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined(); + }); +}); + +describe("SpotifyConnectApi.getPlaybackState", () => { + it("GETs /v1/me/player and maps is_playing/progress/item", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + is_playing: true, + progress_ms: 12345, + item: { uri: "spotify:track:abc", duration_ms: 200000 }, + }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + const state = await api.getPlaybackState(); + expect(http.get).toHaveBeenCalledWith("/v1/me/player", AUTH); + expect(state).toEqual({ + isPlaying: true, + progressMs: 12345, + trackUri: "spotify:track:abc", + durationMs: 200000, + }); + }); + + it("returns null on 204 (no active device)", async () => { + const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 204, data: "" }) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + }); + + it("returns null when item is missing / trackUri null", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ status: 200, data: { is_playing: false, progress_ms: 0, item: null } }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toEqual({ + isPlaying: false, + progressMs: 0, + trackUri: null, + durationMs: 0, + }); + }); + + it("returns null on rejection (e.g. 401) instead of throwing", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("boom")) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + }); + + it("returns null when getToken() is null (unauthorized) without calling http", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + expect(http.get).not.toHaveBeenCalled(); + }); +}); diff --git a/src/music/spotify/connect-api.ts b/src/music/spotify/connect-api.ts new file mode 100644 index 0000000..6a05a22 --- /dev/null +++ b/src/music/spotify/connect-api.ts @@ -0,0 +1,150 @@ +import axios, { type AxiosInstance } from "axios"; + +const API_BASE = "https://api.spotify.com"; + +export interface SpotifyDevice { + id: string; + name: string; + is_active: boolean; +} + +export interface PlaybackState { + isPlaying: boolean; + progressMs: number; + trackUri: string | null; + durationMs: number; +} + +/** + * Spotify Web API "Connect" remote-control client. Wraps an axios instance and + * attaches a live user Bearer token from getToken() to every request. + * + * Error policy: + * - Read-only calls (getDevices/getPlaybackState) degrade to []/null on error. + * - Mutating calls (transfer/play/pause/resume/seek) no-op when unauthorized. + * - REQUIRED CORRECTION C3.6: mutating calls ALSO swallow transport errors + * (403 non-Premium / 404 no active device / 429 rate-limited / network) and + * resolve to void instead of rejecting. The contract keeps the Promise + * signatures, so the backend treats a failed play as "couldn't play" and + * falls back — a transient Spotify error can never surface as an unhandled + * rejection that crashes the queue-advance path. + */ +export class SpotifyConnectApi { + private getToken: () => Promise; + private http: AxiosInstance; + + constructor(getToken: () => Promise, deps?: { http?: AxiosInstance }) { + this.getToken = getToken; + this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 }); + } + + /** Bearer auth headers, or null when no valid user token is available. */ + private async authHeaders(): Promise<{ Authorization: string } | null> { + const token = await this.getToken(); + if (!token) return null; + return { Authorization: `Bearer ${token}` }; + } + + async getDevices(): Promise { + const headers = await this.authHeaders(); + if (!headers) return []; + try { + const { data } = await this.http.get("/v1/me/player/devices", { headers }); + const list = Array.isArray(data?.devices) ? data.devices : []; + return list.map((d: any) => ({ + id: d?.id ?? "", + name: d?.name ?? "", + is_active: Boolean(d?.is_active), + })); + } catch { + return []; + } + } + + async findDeviceByName(name: string): Promise { + const devices = await this.getDevices(); + const match = devices.find((d) => d.name === name); + return match ? match.id : null; + } + + async transfer(deviceId: string, play = false): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put("/v1/me/player", { device_ids: [deviceId], play }, { headers }); + } catch { + // C3.6: swallow (e.g. 403/404/429) — never reject up the queue path. + } + } + + async play(deviceId: string, trackUri: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put( + "/v1/me/player/play", + { uris: [trackUri] }, + { headers, params: { device_id: deviceId } }, + ); + } catch { + // C3.6: swallow — the backend treats a failed play as "couldn't play". + } + } + + async pause(deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put("/v1/me/player/pause", undefined, { + headers, + params: deviceId ? { device_id: deviceId } : undefined, + }); + } catch { + // C3.6: swallow. + } + } + + async resume(deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put("/v1/me/player/play", undefined, { + headers, + params: deviceId ? { device_id: deviceId } : undefined, + }); + } catch { + // C3.6: swallow. + } + } + + async seek(ms: number, deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + const params: Record = { position_ms: ms }; + if (deviceId) params.device_id = deviceId; + try { + await this.http.put("/v1/me/player/seek", undefined, { headers, params }); + } catch { + // C3.6: swallow. + } + } + + async getPlaybackState(): Promise { + const headers = await this.authHeaders(); + if (!headers) return null; + try { + const res = await this.http.get("/v1/me/player", { headers }); + // 204 = no active device / playback; body is empty. + if (res.status === 204 || !res.data) return null; + const d = res.data; + return { + isPlaying: Boolean(d.is_playing), + progressMs: Number(d.progress_ms ?? 0), + trackUri: d.item?.uri ?? null, + durationMs: Number(d.item?.duration_ms ?? 0), + }; + } catch { + return null; + } + } +} From 35bdd2a168dde54477519523c6f47381d7bb3301 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:34:58 +0800 Subject: [PATCH 26/59] feat(spotify): add RustLibrespotBackend (stdout-pipe -> ffmpeg, Connect-API control) Implements the Stage-2 SpotifyAudioBackend over Rust librespot: spawns librespot with --backend pipe (no --device => s16le/44100/2 on stdout, no --passthrough), pipes stdout -> ffmpeg (44100->48000 s16le), waits for the Connect device to register before emitting "ready", and controls playback (transfer/play/pause/resume/seek) plus track-end/position/metadata via a polled SpotifyConnectApi. child_process/connect/oauth/ffmpeg injected for fully mocked, no-network unit tests (Windows-targeted; not e2e without Premium). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/rust-librespot.test.ts | 283 +++++++++++++++++++ src/music/spotify/rust-librespot.ts | 332 +++++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 src/music/spotify/rust-librespot.test.ts create mode 100644 src/music/spotify/rust-librespot.ts diff --git a/src/music/spotify/rust-librespot.test.ts b/src/music/spotify/rust-librespot.test.ts new file mode 100644 index 0000000..c155030 --- /dev/null +++ b/src/music/spotify/rust-librespot.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import pino from "pino"; +import { RustLibrespotBackend } from "./rust-librespot.js"; + +const log = pino({ level: "silent" }); + +/** ChildProcess stand-in with real Readable/Writable pipes so stdout->stdin piping works. */ +function makeFakeChild() { + const child: any = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +function makeConnect() { + return { + getDevices: vi.fn(async () => [{ id: "dev1", name: "Test Bot", is_active: false }]), + findDeviceByName: vi.fn(async () => "dev1"), + transfer: vi.fn(async () => {}), + play: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + getPlaybackState: vi.fn(async () => null as any), + }; +} + +function makeOAuth() { + return { + getAccessToken: vi.fn(async () => "tok-123" as string | null), + isAuthorized: () => true, + }; +} + +function makeHarness(over: { connect?: any; oauth?: any } = {}) { + const calls: string[] = []; + const librespotChild = makeFakeChild(); + const ffmpegChild = makeFakeChild(); + + const spawn = vi.fn((cmd: string, ..._rest: any[]) => { + const isLibrespot = cmd.includes("librespot"); + calls.push(`spawn:${isLibrespot ? "librespot" : cmd}`); + return isLibrespot ? librespotChild : ffmpegChild; + }); + const mkdirSync = vi.fn(); + const connect = over.connect ?? makeConnect(); + const oauth = over.oauth ?? makeOAuth(); + + const backend = new RustLibrespotBackend({ + deviceName: "Test Bot", + bitrate: 320, + cacheDir: "/tmp/cache", + oauth: oauth as any, + connect: connect as any, + logger: log, + deps: { + spawn: spawn as any, + mkdirSync: mkdirSync as any, + findBinary: () => "/bin/librespot", + // C1: pin ffmpeg so arg-array assertions stay stable while prod uses getFfmpegCommand(). + ffmpegCommand: "ffmpeg", + sleep: async () => {}, + readyPollIntervalMs: 1, + readyTimeoutMs: 100, + // huge so the background setInterval never fires; tests drive pollState() directly. + statePollIntervalMs: 10_000_000, + }, + }); + + return { backend, calls, spawn, mkdirSync, connect, oauth, librespotChild, ffmpegChild }; +} + +describe("RustLibrespotBackend.start", () => { + it("spawns librespot with the pipe/stdout arg set and the OAuth access token", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.spawn).toHaveBeenCalledWith( + "/bin/librespot", + [ + "--name", "Test Bot", + "--backend", "pipe", + "--bitrate", "320", + "--format", "S16", + "--cache", "/tmp/cache", + "--device-type", "speaker", + "--access-token", "tok-123", + ], + expect.anything(), + ); + // NO --device (=> stdout) and NO --passthrough (=> decoded PCM, not Ogg). + const args = h.spawn.mock.calls.find((c) => String(c[0]).includes("librespot"))![1] as string[]; + expect(args).not.toContain("--device"); + expect(args).not.toContain("--passthrough"); + h.backend.stop(); + }); + + it("spawns ffmpeg (reader) before librespot (writer) with the exact 44100->48000 s16le args", async () => { + const h = makeHarness(); + await h.backend.start(); + const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[]; + expect(ffmpegArgs).toEqual([ + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ]); + const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg"); + const librespotIdx = h.calls.indexOf("spawn:librespot"); + expect(ffmpegIdx).toBeGreaterThanOrEqual(0); + expect(librespotIdx).toBeGreaterThan(ffmpegIdx); + h.backend.stop(); + }); + + it("getPcmStream() returns the ffmpeg stdout Readable", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout); + h.backend.stop(); + }); + + it("emits 'ready' and reports isReady() true once our device appears in getDevices()", async () => { + const h = makeHarness(); + const ready = vi.fn(); + h.backend.on("ready", ready); + await h.backend.start(); + expect(h.connect.getDevices).toHaveBeenCalled(); + expect(ready).toHaveBeenCalledTimes(1); + expect(h.backend.isReady()).toBe(true); + h.backend.stop(); + }); + + it("keeps polling getDevices() until the device name appears", async () => { + const h = makeHarness(); + h.connect.getDevices + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: "other", name: "Someone else", is_active: true }]) + .mockResolvedValue([{ id: "dev1", name: "Test Bot", is_active: false }]); + await h.backend.start(); + expect(h.connect.getDevices).toHaveBeenCalledTimes(3); + expect(h.backend.isReady()).toBe(true); + h.backend.stop(); + }); + + it("throws (and does not spawn) when the OAuth token is null", async () => { + const oauth = makeOAuth(); + oauth.getAccessToken.mockResolvedValue(null); + const h = makeHarness({ oauth }); + await expect(h.backend.start()).rejects.toThrow(/authorized|token/i); + expect(h.spawn).not.toHaveBeenCalled(); + }); +}); + +describe("RustLibrespotBackend transport delegation (Connect API)", () => { + it("playTrack resolves the device then transfer(false) then play(uri)", async () => { + const h = makeHarness(); + await h.backend.playTrack("spotify:track:go"); + expect(h.connect.findDeviceByName).toHaveBeenCalledWith("Test Bot"); + expect(h.connect.transfer).toHaveBeenCalledWith("dev1", false); + expect(h.connect.play).toHaveBeenCalledWith("dev1", "spotify:track:go"); + // ordering: transfer before play + expect(h.connect.transfer.mock.invocationCallOrder[0]) + .toBeLessThan(h.connect.play.mock.invocationCallOrder[0]); + }); + + it("playTrack throws when the device cannot be found", async () => { + const h = makeHarness(); + h.connect.findDeviceByName.mockResolvedValue(null); + await expect(h.backend.playTrack("spotify:track:x")).rejects.toThrow(/device/i); + }); + + it("pause/resume/seek delegate to the Connect API and seek updates position", async () => { + const h = makeHarness(); + await h.backend.pause(); + await h.backend.resume(); + await h.backend.seek(5000); + expect(h.connect.pause).toHaveBeenCalled(); + expect(h.connect.resume).toHaveBeenCalled(); + expect(h.connect.seek).toHaveBeenCalledWith(5000); + expect(h.backend.getPositionMs()).toBe(5000); + }); +}); + +describe("RustLibrespotBackend track-end poll loop", () => { + it("emits trackEnded when progress reaches the end-of-track window", async () => { + const h = makeHarness(); + const ended = vi.fn(); + const meta = vi.fn(); + h.backend.on("trackEnded", ended); + h.backend.on("metadata", meta); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 }); + await (h.backend as any).pollState(); + expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:A", durationMs: 200000 })); + expect(h.backend.getPositionMs()).toBe(1000); + expect(ended).not.toHaveBeenCalled(); + await (h.backend as any).pollState(); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); + }); + + it("emits trackEnded once when playback stops (!isPlaying) after having played", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }); + await (h.backend as any).pollState(); + await (h.backend as any).pollState(); + await (h.backend as any).pollState(); // idempotent: no second emit for same track + expect(ended).toHaveBeenCalledTimes(1); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); + }); + + it("emits trackEnded when the track uri transitions to null after playing", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 }); + await (h.backend as any).pollState(); + await (h.backend as any).pollState(); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); + }); + + it("ignores a null playback state (no active device) without emitting", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.connect.getPlaybackState.mockResolvedValue(null); + await (h.backend as any).pollState(); + expect(ended).not.toHaveBeenCalled(); + }); +}); + +describe("RustLibrespotBackend.stop", () => { + it("kills librespot + ffmpeg, clears ready, and is idempotent", async () => { + const h = makeHarness(); + await h.backend.start(); + h.backend.stop(); + h.backend.stop(); // second call must not throw + expect(h.librespotChild.kill).toHaveBeenCalled(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.backend.isReady()).toBe(false); + }); +}); + +describe("RustLibrespotBackend.start failure cleanup", () => { + it("tears down librespot + ffmpeg when the device never appears", async () => { + const h = makeHarness(); + h.connect.getDevices.mockResolvedValue([]); // device never shows up -> waitForDevice times out + await expect(h.backend.start()).rejects.toThrow(/did not appear/i); + expect(h.librespotChild.kill).toHaveBeenCalled(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.backend.isReady()).toBe(false); + }); +}); + +describe("RustLibrespotBackend child-process error handling", () => { + it("swallows+logs a child 'error' when no backend 'error' listener is attached", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.listenerCount("error")).toBe(0); + expect(() => h.librespotChild.emit("error", new Error("boom"))).not.toThrow(); + expect(() => h.ffmpegChild.emit("error", new Error("boom"))).not.toThrow(); + h.backend.stop(); + }); + + it("re-emits a child 'error' to an attached backend 'error' listener", async () => { + const h = makeHarness(); + await h.backend.start(); + const onErr = vi.fn(); + h.backend.on("error", onErr); + const err = new Error("ffmpeg boom"); + h.ffmpegChild.emit("error", err); + expect(onErr).toHaveBeenCalledWith(err); + h.backend.stop(); + }); +}); diff --git a/src/music/spotify/rust-librespot.ts b/src/music/spotify/rust-librespot.ts new file mode 100644 index 0000000..abbb544 --- /dev/null +++ b/src/music/spotify/rust-librespot.ts @@ -0,0 +1,332 @@ +import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { spawn as realSpawn } from "node:child_process"; +import { mkdirSync as realMkdirSync } from "node:fs"; +import type { Logger } from "pino"; +import type { + SpotifyAudioBackend, + SpotifyTrackEndedEvent, + SpotifyNowPlaying, +} from "./backend.js"; +import { findLibrespot } from "./binary.js"; +import { SpotifyConnectApi } from "./connect-api.js"; +import type { PlaybackState, SpotifyDevice } from "./connect-api.js"; +import type { SpotifyOAuth } from "./spotify-oauth.js"; +import { getFfmpegCommand } from "../../audio/player.js"; + +export interface RustLibrespotBackendOptions { + deviceName: string; + bitrate: number; + cacheDir: string; + oauth: SpotifyOAuth; + connect?: SpotifyConnectApi; + logger: Logger; + deps?: RustLibrespotBackendDeps; +} + +/** Injectable seams so the whole lifecycle is testable without a real binary/network. */ +export interface RustLibrespotBackendDeps { + spawn?: typeof realSpawn; + mkdirSync?: typeof realMkdirSync; + findBinary?: () => string; + /** + * C1: override the ffmpeg command. Production resolves it via + * getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on + * PATH); tests pin it to "ffmpeg" for stable arg assertions. + */ + ffmpegCommand?: string; + sleep?: (ms: number) => Promise; + readyPollIntervalMs?: number; + readyTimeoutMs?: number; + statePollIntervalMs?: number; +} + +const DEFAULT_READY_POLL_MS = 500; +const DEFAULT_READY_TIMEOUT_MS = 20_000; +const DEFAULT_STATE_POLL_MS = 2_000; +/** How close to the end (ms) counts as "track finished" when polling player state. */ +const END_OF_TRACK_WINDOW_MS = 1_500; + +const defaultSleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBackend { + private readonly opts: RustLibrespotBackendOptions; + private readonly log: Logger; + private readonly deps: RustLibrespotBackendDeps; + private readonly oauth: SpotifyOAuth; + private readonly connect: SpotifyConnectApi; + + private proc: ChildProcess | null = null; + private ffmpeg: ChildProcess | null = null; + private pollTimer: ReturnType | null = null; + private ready = false; + private positionMs = 0; + + // track-end poll state machine + private currentUri: string | null = null; + private hasPlayed = false; + private endedForCurrent = false; + + constructor(o: RustLibrespotBackendOptions) { + super(); + this.opts = o; + this.log = o.logger; + this.deps = o.deps ?? {}; + this.oauth = o.oauth; + // The Connect API shares the backend's OAuth token source. Reuse the + // injected instance in tests; otherwise build one over oauth.getAccessToken(). + this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken()); + } + + async start(): Promise { + const spawn = this.deps.spawn ?? realSpawn; + const mkdirSync = this.deps.mkdirSync ?? realMkdirSync; + const findBinary = this.deps.findBinary ?? findLibrespot; + // C1: resolve ffmpeg via getFfmpegCommand() unless injected for tests. + const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand(); + + // A valid USER control token is required before we spawn anything. + const token = await this.oauth.getAccessToken(); + if (!token) { + throw new Error("Spotify not authorized (no access token) — sign in first"); + } + + mkdirSync(this.opts.cacheDir, { recursive: true }); + + // Everything past here spawns children / opens the state poll. On any + // failure (e.g. the device never appears), tear it all down via stop(). + try { + // 1. Spawn ffmpeg FIRST (the reader) so its stdin pipe is ready before + // librespot starts pushing raw 44.1k s16le PCM into it. + this.ffmpeg = spawn( + ffmpegCommand, + [ + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + this.ffmpeg.stderr?.on("data", (b: Buffer) => + this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"), + ); + this.ffmpeg.on("error", (err) => this.emitError(err)); + + // 2. Spawn librespot: --backend pipe with NO --device => raw s16le/44100/2 + // on stdout, NO --passthrough (that would emit raw Ogg). --access-token + // authenticates it as a Connect device controllable via the Web API. + const bin = findBinary(); + this.proc = spawn( + bin, + [ + "--name", this.opts.deviceName, + "--backend", "pipe", + "--bitrate", String(this.opts.bitrate), + "--format", "S16", + "--cache", this.opts.cacheDir, + "--device-type", "speaker", + "--access-token", token, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + // stdout carries PCM — pipe it, never attach a data listener that consumes it. + if (this.proc.stdout && this.ffmpeg.stdin) { + this.proc.stdout.pipe(this.ffmpeg.stdin); + } + this.proc.stderr?.on("data", (b: Buffer) => + this.log.info({ librespot: b.toString().trim() }, "librespot"), + ); + this.proc.on("error", (err) => this.emitError(err)); + this.proc.on("exit", (code, signal) => { + this.ready = false; + this.log.warn({ code, signal }, "librespot exited"); + }); + + // 3. Poll the Connect device list until our device registers. + await this.waitForDevice(); + + // 4. Begin the player-state poll loop (track-end / position / metadata). + this.startPollLoop(); + + this.ready = true; + this.emit("ready"); + } catch (e) { + this.stop(); + throw e; + } + } + + /** + * Re-emit a child "error" only when a consumer is listening; Node throws on an + * unhandled "error" event, so with no listener we log via the injected logger. + */ + private emitError(err: unknown): void { + if (this.listenerCount("error") > 0) { + this.emit("error", err); + } else { + this.log.error({ err }, "rust-librespot backend error (no listener)"); + } + } + + private async waitForDevice(): Promise { + const sleep = this.deps.sleep ?? defaultSleep; + const interval = this.deps.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS; + const timeout = this.deps.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + let devices: SpotifyDevice[] = []; + try { + devices = await this.connect.getDevices(); + } catch (err) { + this.log.debug({ err }, "getDevices failed during readiness poll"); + } + if (devices.some((d) => d.name === this.opts.deviceName)) return; + await sleep(interval); + } + throw new Error(`librespot device "${this.opts.deviceName}" did not appear within timeout`); + } + + private startPollLoop(): void { + const interval = this.deps.statePollIntervalMs ?? DEFAULT_STATE_POLL_MS; + this.pollTimer = setInterval(() => { + void this.pollState(); + }, interval); + // Don't keep the event loop / test process alive on account of the poll timer. + this.pollTimer.unref?.(); + } + + /** One player-state poll iteration: updates position/metadata and detects track end. */ + private async pollState(): Promise { + let state: PlaybackState | null; + try { + state = await this.connect.getPlaybackState(); + } catch (err) { + this.log.debug({ err }, "getPlaybackState failed"); + return; + } + + if (!state) { + // C3.5: a 204 / no-active-device response. AFTER our own track has been + // seen playing, librespot going idle means the track ended — emit once so + // the queue advances instead of stalling. BEFORE any play (hasPlayed + // false), a null state is just "nothing active yet" and is ignored. + if (this.hasPlayed && this.currentUri && !this.endedForCurrent) { + this.endedForCurrent = true; + const endedUri = this.currentUri; + this.currentUri = null; + const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" }; + this.emit("trackEnded", e); + } + return; + } + + this.positionMs = state.progressMs; + + // Track change -> reset the end-detection state and surface best-effort metadata. + if (state.trackUri && state.trackUri !== this.currentUri) { + this.currentUri = state.trackUri; + this.hasPlayed = false; + this.endedForCurrent = false; + const np: SpotifyNowPlaying = { + uri: state.trackUri, + name: "", + artist: "", + album: "", + coverUrl: "", + durationMs: state.durationMs, + }; + this.emit("metadata", np); + } + + if (state.isPlaying) this.hasPlayed = true; + if (!this.currentUri || this.endedForCurrent) return; + + // C3.4: EVERY end condition is gated on hasPlayed so no end can fire until + // the bot's own track has actually been observed playing. Without this, the + // first poll (before playTrack) could observe the account's stale/paused + // track sitting near its end and spuriously emit "trackEnded". + const finishedByProgress = + this.hasPlayed && + state.durationMs > 0 && + state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS; + const finishedByStop = this.hasPlayed && !state.isPlaying; + const finishedByNull = this.hasPlayed && state.trackUri === null; + + if (finishedByProgress || finishedByStop || finishedByNull) { + this.endedForCurrent = true; // latch: emit at most once per track + const endedUri = this.currentUri; + this.currentUri = null; + const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" }; + this.emit("trackEnded", e); + } + } + + isReady(): boolean { + return this.ready; + } + + async playTrack(uri: string): Promise { + const deviceId = await this.connect.findDeviceByName(this.opts.deviceName); + if (!deviceId) throw new Error(`Connect device "${this.opts.deviceName}" not found`); + // Reset the track-end state machine for the new track: clear the once-only + // latch and drop hasPlayed so no end can fire until a poll re-confirms this + // uri playing. currentUri is cleared so the next poll re-detects the track + // (fresh metadata) rather than treating it as unchanged. + this.currentUri = null; + this.hasPlayed = false; + this.endedForCurrent = false; + // transfer(false) activates our device WITHOUT starting audio; play() then + // actually starts the uri. The two-step is required — transfer alone won't + // begin playback. + await this.connect.transfer(deviceId, false); + await this.connect.play(deviceId, uri); + } + + async pause(): Promise { + await this.connect.pause(); + } + + async resume(): Promise { + await this.connect.resume(); + } + + async seek(ms: number): Promise { + await this.connect.seek(ms); + this.positionMs = ms; + } + + getPcmStream(): Readable { + const out = this.ffmpeg?.stdout; + if (!out) throw new Error("PCM stream unavailable (rust-librespot backend not started)"); + return out; + } + + getPositionMs(): number { + return this.positionMs; + } + + stop(): void { + this.ready = false; + // Clear the state poll interval FIRST so no poll fires mid-teardown. + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.proc) { + try { + this.proc.kill(); + } catch { + /* ignore */ + } + this.proc = null; + } + if (this.ffmpeg) { + try { + this.ffmpeg.kill(); + } catch { + /* ignore */ + } + this.ffmpeg = null; + } + } +} From efe47c5bbc650a5e013637aac8e5145835ad3984 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:43:27 +0800 Subject: [PATCH 27/59] fix(spotify): arm RustLibrespot track-end detection only after playTrack (no spurious startup advance) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/rust-librespot.test.ts | 43 ++++++++++++++++++++++++ src/music/spotify/rust-librespot.ts | 20 ++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/music/spotify/rust-librespot.test.ts b/src/music/spotify/rust-librespot.test.ts index c155030..2e539c7 100644 --- a/src/music/spotify/rust-librespot.test.ts +++ b/src/music/spotify/rust-librespot.test.ts @@ -189,6 +189,8 @@ describe("RustLibrespotBackend track-end poll loop", () => { const meta = vi.fn(); h.backend.on("trackEnded", ended); h.backend.on("metadata", meta); + // Arm detection the way production does — via our own playTrack(). + await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 }); @@ -204,6 +206,7 @@ describe("RustLibrespotBackend track-end poll loop", () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); + await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) @@ -219,6 +222,7 @@ describe("RustLibrespotBackend track-end poll loop", () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); + await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 }); @@ -235,6 +239,45 @@ describe("RustLibrespotBackend track-end poll loop", () => { await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); }); + + it("C3.4: a startup poll before playTrack never emits (foreign track near its end)", async () => { + const h = makeHarness(); + const ended = vi.fn(); + const meta = vi.fn(); + h.backend.on("trackEnded", ended); + h.backend.on("metadata", meta); + // Backend started, but playTrack has NOT been called => detection disarmed. + await h.backend.start(); + // First poll observes a FOREIGN track that is actively playing near its end. + h.connect.getPlaybackState.mockResolvedValue({ + isPlaying: true, + progressMs: 199000, + trackUri: "spotify:foreign", + durationMs: 200000, + }); + await (h.backend as any).pollState(); + // No spurious end-of-track and no bogus metadata before the bot ever plays. + expect(ended).not.toHaveBeenCalled(); + expect(meta).not.toHaveBeenCalled(); + expect(h.backend.getPositionMs()).toBe(0); + h.backend.stop(); + }); + + it("after playTrack, a normal finish emits trackEnded exactly once for our uri", async () => { + const h = makeHarness(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + // Arm detection via our own play, then confirm-then-finish our uri. + await h.backend.playTrack("spotify:track:ours"); + h.connect.getPlaybackState + .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:ours", durationMs: 200000 }) + .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:ours", durationMs: 200000 }); + await (h.backend as any).pollState(); // confirms our uri playing + expect(ended).not.toHaveBeenCalled(); + await (h.backend as any).pollState(); // finishes + expect(ended).toHaveBeenCalledTimes(1); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" }); + }); }); describe("RustLibrespotBackend.stop", () => { diff --git a/src/music/spotify/rust-librespot.ts b/src/music/spotify/rust-librespot.ts index abbb544..7dc9f19 100644 --- a/src/music/spotify/rust-librespot.ts +++ b/src/music/spotify/rust-librespot.ts @@ -67,6 +67,11 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa private currentUri: string | null = null; private hasPlayed = false; private endedForCurrent = false; + // C3.4: end-detection is DISARMED until our own playTrack() runs. A poll that + // fires before the bot ever asks to play (e.g. at startup) must produce no + // side effects at all, so a foreign track already near its end can't emit a + // spurious trackEnded/metadata and wrongly advance the queue. + private armed = false; constructor(o: RustLibrespotBackendOptions) { super(); @@ -205,6 +210,15 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa return; } + // C3.4: detection is armed ONLY by our own playTrack(). Until then a poll + // must have NO side effects — no metadata/trackEnded emit and no mutation of + // currentUri/hasPlayed/positionMs. This single gate covers the null-state + // (C3.5 post-play-204) branch, the metadata-emit block, and every end + // heuristic below, so a foreign track sitting near its end at startup can + // never spuriously advance the queue. (Device-readiness polling via + // getDevices is separate and stays active regardless of this flag.) + if (!this.armed) return; + if (!state) { // C3.5: a 204 / no-active-device response. AFTER our own track has been // seen playing, librespot going idle means the track ended — emit once so @@ -214,6 +228,7 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa this.endedForCurrent = true; const endedUri = this.currentUri; this.currentUri = null; + this.positionMs = 0; const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" }; this.emit("trackEnded", e); } @@ -256,6 +271,7 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa this.endedForCurrent = true; // latch: emit at most once per track const endedUri = this.currentUri; this.currentUri = null; + this.positionMs = 0; const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" }; this.emit("trackEnded", e); } @@ -271,10 +287,12 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa // Reset the track-end state machine for the new track: clear the once-only // latch and drop hasPlayed so no end can fire until a poll re-confirms this // uri playing. currentUri is cleared so the next poll re-detects the track - // (fresh metadata) rather than treating it as unchanged. + // (fresh metadata) rather than treating it as unchanged. Arming here is the + // primary guarantee that no end/metadata can fire before the bot plays. this.currentUri = null; this.hasPlayed = false; this.endedForCurrent = false; + this.armed = true; // transfer(false) activates our device WITHOUT starting audio; play() then // actually starts the uri. The two-step is required — transfer alone won't // begin playback. From 322335dfc50612f70cfd14a7d6ce3d1fe8f9210c Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:50:03 +0800 Subject: [PATCH 28/59] feat(spotify): backend selection + Rust librespot wiring in SpotifyController Add chooseBackend() honoring config.spotify.backend (go-librespot|librespot| auto) against platform + binary availability (auto: linux+go binary -> go; else librespot present -> Rust; else null). Controller now owns a shared SpotifyOAuth + SpotifyConnectApi passed to the Rust backend; isAvailable() = enabled && a backend is selectable; the Rust path additionally gates ensureStarted() on oauth.isAuthorized(). go-librespot path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/controller.test.ts | 136 +++++++++++++++- src/music/spotify/controller.ts | 226 ++++++++++++++++++++------- 2 files changed, 303 insertions(+), 59 deletions(-) diff --git a/src/music/spotify/controller.test.ts b/src/music/spotify/controller.test.ts index fb9ced5..1bf4efc 100644 --- a/src/music/spotify/controller.test.ts +++ b/src/music/spotify/controller.test.ts @@ -11,14 +11,26 @@ import type { SpotifyTrackEndedEvent, SpotifyNowPlaying, } from "./backend.js"; +import type { SpotifyOAuth } from "./spotify-oauth.js"; // Controllable, hoisted so the vi.mock factory can close over it. -const bin = vi.hoisted(() => ({ supported: true, path: "" })); +// go-* keys keep their Stage-2 names (`supported`/`path`) so existing tests are +// untouched; rust* keys drive the new librespot selection paths. +const bin = vi.hoisted(() => ({ + supported: true, + path: "", + rustSupported: true, + rustPath: "", +})); vi.mock("./binary.js", () => ({ isGoLibrespotSupported: () => bin.supported, findGoLibrespot: () => bin.path, resetGoLibrespotBinaryCache: () => {}, checkGoLibrespotAvailable: async () => bin.supported && !!bin.path, + isRustLibrespotSupported: () => bin.rustSupported, + findLibrespot: () => bin.rustPath, + resetLibrespotBinaryCache: () => {}, + checkLibrespotAvailable: async () => bin.rustSupported && !!bin.rustPath, })); // Capture options the DEFAULT factory hands to the real GoLibrespotBackend so @@ -71,6 +83,8 @@ afterAll(() => { beforeEach(() => { bin.supported = true; bin.path = existingBin; + bin.rustSupported = true; + bin.rustPath = missingBin; }); class FakeBackend extends EventEmitter implements SpotifyAudioBackend { @@ -145,6 +159,7 @@ function cfg(over: Partial = {}): SpotifyConfig { function makeCtrl(over: { config?: Partial; backendFactory?: () => SpotifyAudioBackend; + oauth?: import("./spotify-oauth.js").SpotifyOAuth; } = {}) { const be = new FakeBackend(); const ctrl = new SpotifyController({ @@ -153,10 +168,28 @@ function makeCtrl(over: { configDir: "/tmp/cfg", logger: silentLogger, backendFactory: over.backendFactory ?? (() => be), + oauth: over.oauth, }); return { ctrl, be }; } +function fakeOAuth( + authorized: boolean, + hooks: { onIsAuthorized?: () => void } = {}, +): SpotifyOAuth { + return { + isAuthorized: () => { + hooks.onIsAuthorized?.(); + return authorized; + }, + getAccessToken: async () => (authorized ? "tok" : null), + getClientId: () => "cid", + getRedirectUri: () => "http://127.0.0.1:5588/login", + buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize", state: "s" }), + handleCallback: async () => true, + } as unknown as SpotifyOAuth; +} + describe("SpotifyController.isAvailable", () => { it("true when enabled + supported + binary present", () => { const { ctrl } = makeCtrl(); @@ -451,3 +484,104 @@ describe("SpotifyController per-bot ports (Fix 3)", () => { ); }); }); + +describe("SpotifyController.chooseBackend (platform x config matrix)", () => { + function pick( + backend: SpotifyConfig["backend"], + opts: { go: boolean; goSupported?: boolean; rust: boolean; rustSupported?: boolean }, + ) { + bin.supported = opts.goSupported ?? true; + bin.path = opts.go ? existingBin : missingBin; + bin.rustSupported = opts.rustSupported ?? true; + bin.rustPath = opts.rust ? existingBin : missingBin; + const { ctrl } = makeCtrl({ config: { backend } }); + return ctrl.chooseBackend(); + } + + it("auto: prefers go-librespot when linux + go binary present", () => { + expect(pick("auto", { go: true, rust: true })).toBe("go-librespot"); + }); + it("auto: falls back to librespot when go unsupported (e.g. Windows) but librespot present", () => { + expect(pick("auto", { go: true, goSupported: false, rust: true })).toBe("librespot"); + }); + it("auto: falls back to librespot when go binary is absent", () => { + expect(pick("auto", { go: false, rust: true })).toBe("librespot"); + }); + it("auto: null when neither backend is usable", () => { + expect(pick("auto", { go: false, goSupported: false, rust: false })).toBeNull(); + }); + it("go-librespot: selected when supported + present", () => { + expect(pick("go-librespot", { go: true, rust: true })).toBe("go-librespot"); + }); + it("go-librespot: null when unsupported, even if librespot is present", () => { + expect(pick("go-librespot", { go: true, goSupported: false, rust: true })).toBeNull(); + }); + it("librespot: selected when the librespot binary is present", () => { + expect(pick("librespot", { go: true, rust: true })).toBe("librespot"); + }); + it("librespot: null when the librespot binary is absent, even if go is present", () => { + expect(pick("librespot", { go: true, rust: false })).toBeNull(); + }); +}); + +describe("SpotifyController Rust-backend auth gate", () => { + it("isAvailable is true for a present librespot binary regardless of auth", () => { + bin.rustPath = existingBin; + const { ctrl } = makeCtrl({ + config: { backend: "librespot" }, + oauth: fakeOAuth(false), + }); + expect(ctrl.isAvailable()).toBe(true); + }); + + it("ensureStarted returns false (no backend built) when Rust chosen but unauthorized", async () => { + bin.rustPath = existingBin; + let built = 0; + const { ctrl } = makeCtrl({ + config: { backend: "librespot" }, + oauth: fakeOAuth(false), + backendFactory: () => { + built++; + return new FakeBackend(); + }, + }); + expect(await ctrl.ensureStarted()).toBe(false); + expect(built).toBe(0); + }); + + it("ensureStarted starts the Rust backend once authorized", async () => { + bin.rustPath = existingBin; + const be = new FakeBackend(); + let built = 0; + const { ctrl } = makeCtrl({ + config: { backend: "librespot" }, + oauth: fakeOAuth(true), + backendFactory: () => { + built++; + return be; + }, + }); + expect(await ctrl.ensureStarted()).toBe(true); + expect(built).toBe(1); + expect(be.startCalls).toBe(1); + }); + + it("go-librespot path never consults oauth.isAuthorized()", async () => { + // auto + go present -> go-librespot; the auth gate must be skipped so a + // throwing isAuthorized() is never reached. + const oauth = fakeOAuth(false, { + onIsAuthorized: () => { + throw new Error("isAuthorized must not be called on the go path"); + }, + }); + const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth }); + expect(await ctrl.ensureStarted()).toBe(true); + }); + + it("exposes the shared oauth + connect instances", () => { + const oauth = fakeOAuth(true); + const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth }); + expect(ctrl.getOAuth()).toBe(oauth); + expect(ctrl.getConnect()).toBeDefined(); + }); +}); diff --git a/src/music/spotify/controller.ts b/src/music/spotify/controller.ts index 32117bd..5b2d035 100644 --- a/src/music/spotify/controller.ts +++ b/src/music/spotify/controller.ts @@ -1,5 +1,12 @@ import { EventEmitter } from "node:events"; -import { existsSync } from "node:fs"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + rmSync, +} from "node:fs"; +import { dirname, join } from "node:path"; import type { Readable } from "node:stream"; import type { Logger } from "pino"; import type { SpotifyConfig } from "../../data/config.js"; @@ -8,8 +15,52 @@ import type { SpotifyTrackEndedEvent, SpotifyNowPlaying, } from "./backend.js"; -import { isGoLibrespotSupported, findGoLibrespot } from "./binary.js"; +import { + isGoLibrespotSupported, + findGoLibrespot, + isRustLibrespotSupported, + findLibrespot, +} from "./binary.js"; import { GoLibrespotBackend } from "./go-librespot.js"; +import { RustLibrespotBackend } from "./rust-librespot.js"; +import { + SpotifyOAuth, + type OAuthTokens, + type OAuthTokenStore, +} from "./spotify-oauth.js"; +import { SpotifyConnectApi } from "./connect-api.js"; + +/** Which concrete backend the controller will run for this host + config. */ +export type SpotifyBackendKind = "go-librespot" | "librespot"; + +/** + * Minimal file-backed OAuth token store used when the caller does not inject a + * SpotifyOAuth. Persists the rotating refresh-token JSON next to the bot config + * (0600). All IO is lazy + guarded so construction never throws and a + * missing/corrupt file simply reads as "unauthorized". + */ +class FileOAuthTokenStore implements OAuthTokenStore { + constructor(private readonly file: string) {} + load(): OAuthTokens | null { + try { + if (!existsSync(this.file)) return null; + return JSON.parse(readFileSync(this.file, "utf8")) as OAuthTokens; + } catch { + return null; + } + } + save(t: OAuthTokens): void { + mkdirSync(dirname(this.file), { recursive: true }); + writeFileSync(this.file, JSON.stringify(t), { mode: 0o600 }); + } + clear(): void { + try { + rmSync(this.file, { force: true }); + } catch { + /* ignore */ + } + } +} export interface SpotifyControllerOptions { config: SpotifyConfig; @@ -20,23 +71,25 @@ export interface SpotifyControllerOptions { apiPort?: number; /** Per-bot go-librespot OAuth callback port (distinct per bot). */ callbackPort?: number; - /** Injected for tests; defaults to constructing a real GoLibrespotBackend. */ + /** Injected for tests; when set it overrides the per-kind default builders. */ backendFactory?: () => SpotifyAudioBackend; + /** Injected for tests; defaults to a file-backed SpotifyOAuth in configDir. */ + oauth?: SpotifyOAuth; + /** Injected for tests; defaults to a SpotifyConnectApi wired to oauth. */ + connect?: SpotifyConnectApi; } /** - * Per-bot orchestrator for the go-librespot Spotify sidecar. Owns backend - * lifecycle, gates on availability (config + platform + binary), delegates - * transport, and re-emits the backend's "trackEnded"/"metadata" events so - * BotInstance can advance the queue exactly as it does for the ffmpeg path. + * Per-bot orchestrator for the Spotify sidecar. Selects a backend for this + * host+config (chooseBackend), owns backend lifecycle, gates on availability + * (config + platform + binary) plus — for the Rust librespot backend — OAuth + * authorization, delegates transport, and re-emits "trackEnded"/"metadata" so + * BotInstance can advance the queue exactly as for the ffmpeg path. * - * Correction C3: this controller does NOT re-emit a raw "error" event (Node's - * EventEmitter throws on an unhandled "error"). It subscribes to the backend's - * "error", logs it, and marks itself not-ready so the next ensureStarted() - * relaunches the backend. Only the safe "trackEnded"/"metadata" events are - * re-emitted. getPcmStream() proxies the backend's SINGLE persistent stream - * (no per-attach PassThrough) to pair with the AudioPlayer detach-not-destroy - * behaviour and BotInstance's no-re-attach on spotify->spotify transitions. + * Correction C3 (unchanged): this controller does NOT re-emit a raw "error" + * event. It subscribes to the backend's "error", logs it, tears the backend + * down, and marks itself not-ready so the next ensureStarted() relaunches a + * fresh backend. getPcmStream() proxies the backend's SINGLE persistent stream. */ export class SpotifyController extends EventEmitter { private readonly config: SpotifyConfig; @@ -45,7 +98,9 @@ export class SpotifyController extends EventEmitter { private readonly logger: Logger; private readonly apiPort?: number; private readonly callbackPort?: number; - private readonly backendFactory: () => SpotifyAudioBackend; + private readonly injectedFactory?: () => SpotifyAudioBackend; + private readonly oauth: SpotifyOAuth; + private readonly connect: SpotifyConnectApi; private backend: SpotifyAudioBackend | null = null; private started = false; @@ -59,41 +114,105 @@ export class SpotifyController extends EventEmitter { this.logger = o.logger; this.apiPort = o.apiPort; this.callbackPort = o.callbackPort; - this.backendFactory = - o.backendFactory ?? - (() => - new GoLibrespotBackend({ - deviceName: this.config.deviceName, - bitrate: this.config.bitrate, - workDir: this.workDir, - configDir: this.configDir, - apiPort: this.apiPort, - callbackPort: this.callbackPort, - logger: this.logger, - })); + this.injectedFactory = o.backendFactory; + // The controller OWNS a shared OAuth + Connect pair (Task 6 web router and + // the Rust backend reuse these exact instances). Constructing the defaults + // performs no IO/network — the file store loads lazily on first use. + this.oauth = + o.oauth ?? + new SpotifyOAuth({ + store: new FileOAuthTokenStore( + join(this.configDir, "spotify-oauth.json"), + ), + }); + this.connect = + o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken()); } - /** enabled in config AND on a supported OS AND the binary is present on disk. */ - isAvailable(): boolean { - return ( - this.config.enabled && - isGoLibrespotSupported() && - existsSync(findGoLibrespot()) - ); + /** Shared OAuth client (web router + Rust backend reuse this instance). */ + getOAuth(): SpotifyOAuth { + return this.oauth; + } + + /** Shared Connect API client (Rust backend reuses this instance). */ + getConnect(): SpotifyConnectApi { + return this.connect; + } + + private goPresent(): boolean { + return isGoLibrespotSupported() && existsSync(findGoLibrespot()); + } + + private rustPresent(): boolean { + return isRustLibrespotSupported() && existsSync(findLibrespot()); } /** - * Idempotently start the backend. Returns false (without building a backend) - * when unavailable, so callers fall back to the Stage-1 sentinel message. - * A failed start clears the cached promise so a later call can retry. + * Resolve which backend to run for this platform + config, or null when none + * is usable (caller falls back to the Stage-1 sentinel message): + * "go-librespot" -> GoLibrespot iff supported (linux) + binary present + * "librespot" -> Rust iff librespot(.exe) present (all platforms) + * "auto" -> GoLibrespot when (linux + go binary), else Rust when + * librespot present, else null. + */ + chooseBackend(): SpotifyBackendKind | null { + switch (this.config.backend) { + case "go-librespot": + return this.goPresent() ? "go-librespot" : null; + case "librespot": + return this.rustPresent() ? "librespot" : null; + case "auto": + default: + if (this.goPresent()) return "go-librespot"; + if (this.rustPresent()) return "librespot"; + return null; + } + } + + /** enabled in config AND a backend is selectable (platform + binary present). */ + isAvailable(): boolean { + return this.config.enabled && this.chooseBackend() !== null; + } + + /** Build the concrete backend for the chosen kind (or the injected fake). */ + private buildBackend(kind: SpotifyBackendKind): SpotifyAudioBackend { + if (this.injectedFactory) return this.injectedFactory(); + if (kind === "librespot") { + return new RustLibrespotBackend({ + deviceName: this.config.deviceName, + bitrate: this.config.bitrate, + cacheDir: join(this.workDir, "librespot-cache"), + oauth: this.oauth, + connect: this.connect, + logger: this.logger, + }); + } + return new GoLibrespotBackend({ + deviceName: this.config.deviceName, + bitrate: this.config.bitrate, + workDir: this.workDir, + configDir: this.configDir, + apiPort: this.apiPort, + callbackPort: this.callbackPort, + logger: this.logger, + }); + } + + /** + * Idempotently start the selected backend. Returns false (without building a + * backend) when unavailable, or — for the Rust backend — when OAuth is not + * yet authorized, so callers show the login-needed / fallback message. A + * failed start clears the cached promise so a later call can retry. */ async ensureStarted(): Promise { if (!this.isAvailable()) return false; + const kind = this.chooseBackend(); + if (!kind) return false; + // The Rust librespot device only appears in Spotify Connect once the user + // has authorized OAuth; without it, do not spawn a dead sidecar. + if (kind === "librespot" && !this.oauth.isAuthorized()) return false; + if (this.started) { - // A previously-started backend still counts as ready only if its process - // is alive. If the sidecar died (isReady()===false) — e.g. the go-librespot - // child exited — tear it down here so the code below rebuilds a fresh one - // instead of handing callers a dead backend. if (this.backend?.isReady()) return true; this.stop(); } @@ -101,7 +220,7 @@ export class SpotifyController extends EventEmitter { this.startPromise = (async () => { try { - const backend = this.backendFactory(); + const backend = this.buildBackend(kind); backend.on("trackEnded", (e: SpotifyTrackEndedEvent) => this.emit("trackEnded", e), ); @@ -126,17 +245,11 @@ export class SpotifyController extends EventEmitter { /** * C3 backend-error handler. Never re-emits "error" (an unhandled "error" on - * an EventEmitter throws). Logs and marks the controller not-ready so the - * next ensureStarted() relaunches the backend. + * an EventEmitter throws). Logs, tears the errored backend down, and marks + * the controller not-ready so the next ensureStarted() relaunches it. */ private handleBackendError(err: unknown): void { this.logger.error({ err }, "Spotify backend error; marking not-ready"); - // Tear down the errored backend BEFORE resetting flags so ensureStarted() - // does not orphan it: stop() cleans its ffmpeg/go-librespot children + FIFO, - // removeAllListeners() detaches its "error" handler so a later error from - // this now-orphaned backend cannot flip a healthy rebuilt controller - // back to not-ready (state cross-talk). Teardown must never mask the - // original error, so guard stop() which may throw. try { this.backend?.stop(); } catch (stopErr) { @@ -145,9 +258,6 @@ export class SpotifyController extends EventEmitter { "Spotify backend stop() threw during error teardown", ); } - // SpotifyAudioBackend's type contract exposes on() but not - // removeAllListeners(); every concrete backend extends EventEmitter, so - // detach through it to drop this controller's listeners from the orphan. (this.backend as unknown as EventEmitter | null)?.removeAllListeners(); this.backend = null; this.started = false; @@ -189,16 +299,16 @@ export class SpotifyController extends EventEmitter { /** * Tear down the backend and reset lifecycle state (safe before start). * Mirrors handleBackendError's teardown so the NEXT ensureStarted() rebuilds - * a fresh backend: stop() cleans the ffmpeg/go-librespot children + FIFO, - * removeAllListeners() detaches this controller's handlers so a late event - * from the now-orphaned backend can't disturb a rebuilt one. Guard stop() - * (it may throw) so teardown always completes. + * a fresh backend. */ stop(): void { try { this.backend?.stop(); } catch (stopErr) { - this.logger.error({ err: stopErr }, "Spotify backend stop() threw during teardown"); + this.logger.error( + { err: stopErr }, + "Spotify backend stop() threw during teardown", + ); } (this.backend as unknown as EventEmitter | null)?.removeAllListeners(); this.backend = null; From 8998b9623f5f53d31057036152a863e05b40d2f1 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 3 Jul 2026 00:09:12 +0800 Subject: [PATCH 29/59] feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6) Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth in index.ts (clientId/redirectUri from config; store via the already-exported createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes playback (C3.1). Reuses the existing file token store (no token-store.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.test.ts | 68 ++++++++++++++++++++++++++ src/bot/instance.ts | 13 +++++ src/bot/manager.test.ts | 66 ++++++++++++++++++++++++++ src/bot/manager.ts | 9 +++- src/index.ts | 21 +++++++- src/web/api/spotify.test.ts | 95 +++++++++++++++++++++++++++++++++++++ src/web/api/spotify.ts | 74 +++++++++++++++++++++++++++++ src/web/server.ts | 19 ++++++++ 8 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 src/web/api/spotify.test.ts create mode 100644 src/web/api/spotify.ts diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index cfc8eae..e92aa3f 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; +import type { BotInstanceOptions } from "./instance.js"; import type { TS3TextMessage } from "../ts-protocol/client.js"; +import type { SpotifyController } from "../music/spotify/controller.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; +import type { MusicProvider } from "../music/provider.js"; +import type { BotDatabase } from "../data/database.js"; +import type { AvatarStore } from "../data/avatars.js"; +import type { BotConfig } from "../data/config.js"; // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer, // reads avatars, etc.), and runExclusive only touches a single private field @@ -611,6 +618,67 @@ describe("BotInstance.seek — spotify routing (C4)", () => { }); }); +// --- Spotify OAuth threading (Task 6, C3.1) -------------------------------- +// The process-wide shared SpotifyOAuth must reach the SpotifyController via the +// controller factory. We drive the REAL BotInstance constructor with a fake +// controller factory that captures its param object, so the thread is observed +// end-to-end (options.spotifyOAuth -> buildController({ oauth })). +describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1)", () => { + function makeInstanceOptions(over: Partial = {}): { + options: BotInstanceOptions; + captured: { param?: { oauth?: SpotifyOAuth } }; + } { + const captured: { param?: { oauth?: SpotifyOAuth } } = {}; + const provider = { platform: "netease" } as unknown as MusicProvider; + const logger: any = { + info() {}, warn() {}, error() {}, debug() {}, + child() { return logger; }, + }; + const database = { + getProfileConfig: () => ({}), + getCustomAvatarPath: () => null, + } as unknown as BotDatabase; + const options: BotInstanceOptions = { + id: "bot-oauth-test", + name: "OAuthBot", + tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "OAuthBot" } as any, + neteaseProvider: provider, + qqProvider: provider, + bilibiliProvider: provider, + youtubeProvider: provider, + database, + config: { spotify: {} } as unknown as BotConfig, + logger, + avatarStore: { read: () => null } as unknown as AvatarStore, + spotifyControllerFactory: (o) => { + captured.param = o; + // Only `on` is touched during construction (setupPlayerEvents wires + // the "trackEnded" listener); return a minimal fake controller. + return { on: () => {} } as unknown as SpotifyController; + }, + ...over, + }; + return { options, captured }; + } + + it("forwards the injected spotifyOAuth to the controller factory as `oauth`", () => { + const sentinel = {} as unknown as SpotifyOAuth; + const { options, captured } = makeInstanceOptions({ spotifyOAuth: sentinel }); + // eslint-disable-next-line no-new + new BotInstance(options); + expect(captured.param).toBeDefined(); + expect(captured.param?.oauth).toBe(sentinel); + }); + + it("leaves the factory `oauth` undefined when no spotifyOAuth is supplied (behavior-unchanged)", () => { + const { options, captured } = makeInstanceOptions(); + // eslint-disable-next-line no-new + new BotInstance(options); + expect(captured.param).toBeDefined(); + expect(captured.param?.oauth).toBeUndefined(); + }); +}); + describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => { it("yields the SAME ports for the same bot id (stable across restarts)", () => { const a = spotifyPortsForBotId("bot-alpha"); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index c667b27..aed5988 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -27,6 +27,7 @@ import { isSpotifyUri } from "../music/spotify/webapi.js"; import path from "node:path"; import { SpotifyController } from "../music/spotify/controller.js"; import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; /** Reply sent when a non-admin invokes an admin-only chat command. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; @@ -77,6 +78,9 @@ export interface BotInstanceOptions { avatarStore: AvatarStore; /** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */ spotifyDataDir?: string; + /** Process-wide shared Spotify OAuth (single account); injected into the + * SpotifyController so web-login authorization is visible to playback (C3.1). */ + spotifyOAuth?: SpotifyOAuth; /** Test seam: build a fake controller instead of a real go-librespot one. */ spotifyControllerFactory?: (o: { config: SpotifyConfig; @@ -85,6 +89,7 @@ export interface BotInstanceOptions { logger: Logger; apiPort: number; callbackPort: number; + oauth?: SpotifyOAuth; }) => SpotifyController; } @@ -181,6 +186,7 @@ export class BotInstance extends EventEmitter { logger: this.logger, apiPort: spotifyApiPort, callbackPort: spotifyCallbackPort, + oauth: options.spotifyOAuth, }); const profileConfig = this.database.getProfileConfig(this.id); @@ -1412,6 +1418,13 @@ export class BotInstance extends EventEmitter { return this.player; } + /** The per-bot Spotify sidecar controller. Exposed like getPlayer()/ + * getQueueManager() so the shared, process-wide OAuth threaded in at + * construction (C3.1) is observable to callers/tests via getOAuth(). */ + getSpotifyController(): SpotifyController { + return this.spotifyController; + } + /** * Route a seek to the Spotify sidecar for a spotify track (its PCM stream is * external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel diff --git a/src/bot/manager.test.ts b/src/bot/manager.test.ts index 65e2c35..b3b6dfa 100644 --- a/src/bot/manager.test.ts +++ b/src/bot/manager.test.ts @@ -9,6 +9,7 @@ import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../dat import type { Logger } from "../logger.js"; import type { MusicProvider } from "../music/provider.js"; import type { AvatarStore } from "../data/avatars.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; // removeBot only calls logger.info; provide the full shape it could touch. const stubLogger = { @@ -85,3 +86,68 @@ describe("BotManager.removeBot — guest scope pruning", () => { expect(loadConfig(configPath).guestMode.bots).toBe("all"); }); }); + +// --- Spotify OAuth threading (Task 6, C3.1) -------------------------------- +// The single process-wide SpotifyOAuth built in index.ts must reach every bot's +// SpotifyController: index -> BotManager (trailing positional arg) -> BotInstance +// -> controller. createBot() builds a REAL (side-effect-free) SpotifyController, +// so we assert the shared instance surfaces via the controller's getOAuth(). +describe("BotManager — spotifyOAuth threading to bot controllers (C3.1)", () => { + const dirs: string[] = []; + let db: BotDatabase | undefined; + + afterEach(() => { + try { + db?.close(); + } catch { + /* ignore */ + } + db = undefined; + for (const d of dirs) { + rmSync(d, { recursive: true, force: true }); + } + dirs.length = 0; + }); + + it("forwards its shared SpotifyOAuth into a created bot's controller", async () => { + const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-oauth-thread-")); + dirs.push(dir); + const configPath = join(dir, "config.json"); + const config = getDefaultConfig(); + saveConfig(configPath, config); + db = createDatabase(":memory:"); + const permissions = createPermissionStore(db.db); + const provider = {} as unknown as MusicProvider; + const sentinel = {} as unknown as SpotifyOAuth; + + const manager = new BotManager( + provider, + provider, + provider, + db, + config, + stubLogger, + {} as unknown as AvatarStore, + permissions, + configPath, + undefined, // localProvider + undefined, // kugouProvider + undefined, // spotifyProvider + join(dir, "spotify"), // spotifyDataDir + sentinel, // spotifyOAuth (the single shared instance) + ); + + const bot = await manager.createBot({ + name: "b1", + serverAddress: "localhost", + serverPort: 9987, + nickname: "b1", + }); + + // Full chain observed: the manager's single shared instance is the exact + // one the per-bot controller now owns (getOAuth() returns it unchanged). + expect(bot.getSpotifyController().getOAuth()).toBe(sentinel); + + bot.disconnect(); + }); +}); diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 682ed6e..82ee79b 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -14,6 +14,7 @@ import type { Logger } from "../logger.js"; import type { ServerProtocol } from "../ts-protocol/client.js"; import type { AvatarStore } from "../data/avatars.js"; import type { PermissionStore } from "../data/permissions.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; /** * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the @@ -79,6 +80,7 @@ export class BotManager extends EventEmitter { private kugouProvider: MusicProvider; private spotifyProvider: MusicProvider; private spotifyDataDir: string; + private readonly spotifyOAuth?: SpotifyOAuth; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -99,7 +101,8 @@ export class BotManager extends EventEmitter { localProvider?: MusicProvider, kugouProvider?: MusicProvider, spotifyProvider?: MusicProvider, - spotifyDataDir?: string + spotifyDataDir?: string, + spotifyOAuth?: SpotifyOAuth ) { super(); this.neteaseProvider = neteaseProvider; @@ -110,6 +113,7 @@ export class BotManager extends EventEmitter { this.kugouProvider = kugouProvider ?? neteaseProvider; this.spotifyProvider = spotifyProvider ?? neteaseProvider; this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); + this.spotifyOAuth = spotifyOAuth; // Let the local provider see which uploads are still referenced by any // bot's queue, so it never deletes a file another queue/bot still needs. const referenceable = this.localProvider as Partial<{ @@ -154,6 +158,7 @@ export class BotManager extends EventEmitter { logger: this.logger, avatarStore: this.avatarStore, spotifyDataDir: this.spotifyDataDir, + spotifyOAuth: this.spotifyOAuth, }); this.bots.set(id, bot); @@ -296,6 +301,7 @@ export class BotManager extends EventEmitter { logger: this.logger, avatarStore: this.avatarStore, spotifyDataDir: this.spotifyDataDir, + spotifyOAuth: this.spotifyOAuth, }); this.bots.set(id, bot); this.emit("botInstance", bot); @@ -352,6 +358,7 @@ export class BotManager extends EventEmitter { logger: this.logger, avatarStore: this.avatarStore, spotifyDataDir: this.spotifyDataDir, + spotifyOAuth: this.spotifyOAuth, }); this.bots.set(saved.id, bot); diff --git a/src/index.ts b/src/index.ts index cfcbdb1..51a5d81 100755 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { BiliBiliProvider } from "./music/bilibili.js"; import { LocalMusicProvider } from "./music/local.js"; import { KugouProvider } from "./music/kugou.js"; import { SpotifyProvider } from "./music/spotify/provider.js"; +import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js"; import { createCookieStore } from "./music/auth.js"; import { createAvatarStore } from "./data/avatars.js"; import { createPermissionStore } from "./data/permissions.js"; @@ -83,6 +84,22 @@ async function main() { const permissions = createPermissionStore(db.db); + // Single process-wide Spotify authorization (one Premium account for Stage 3). + // Threaded into BOTH the web OAuth router and every bot's SpotifyController so + // a web login immediately authorizes playback (C3.1). Own-app clientId => the + // redirect points at this bot's web callback; empty clientId leaves OAuth + // disabled (isAuthorized() stays false and the Rust backend never starts). + const spotifyOAuthClientId = config.spotify.clientId.trim(); + const spotifyOAuth = new SpotifyOAuth({ + clientId: spotifyOAuthClientId || undefined, + redirectUri: spotifyOAuthClientId + ? `http://127.0.0.1:${config.webPort}/api/spotify/callback` + : undefined, + store: createFileOAuthTokenStore( + path.join(SPOTIFY_DATA_DIR, "oauth", "oauth-tokens.json"), + ), + }); + const botManager = new BotManager( neteaseProvider, qqProvider, @@ -96,7 +113,8 @@ async function main() { localProvider, kugouProvider, spotifyProvider, - SPOTIFY_DATA_DIR + SPOTIFY_DATA_DIR, + spotifyOAuth ); await botManager.loadSavedBots(); @@ -116,6 +134,7 @@ async function main() { logger, cookieStore, staticDir: STATIC_DIR, + spotifyOAuth, }); await webServer.start(); diff --git a/src/web/api/spotify.test.ts b/src/web/api/spotify.test.ts new file mode 100644 index 0000000..020467e --- /dev/null +++ b/src/web/api/spotify.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createSpotifyRouter, type SpotifyOAuthLike } from "./spotify.js"; + +type Role = "admin" | "member" | "guest"; +function makeApp(oauth: SpotifyOAuthLike, role: Role = "admin", caps: string[] = []) { + const app = express(); + app.use(express.json()); + // Stand in for the global requireAuth that populates req.user. + app.use((req, _res, next) => { + (req as any).user = { role, capabilities: new Set(caps) }; + next(); + }); + app.use( + "/api/spotify", + createSpotifyRouter({ + oauth, + logger: pino({ level: "silent" }), + getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot" }), + webUiRedirect: "/", + }), + ); + return app; +} + +function fakeOauth(over: Partial = {}): SpotifyOAuthLike { + return { + buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize?x=1", state: "st" }), + handleCallback: async () => true, + isAuthorized: () => false, + ...over, + }; +} + +describe("spotify OAuth router", () => { + it("GET /login returns the authorize url for a permitted user", async () => { + const app = makeApp(fakeOauth()); + const res = await request(app).get("/api/spotify/login"); + expect(res.status).toBe(200); + expect(res.body.url).toContain("accounts.spotify.com/authorize"); + }); + + it("GET /login is 403 for a member lacking platform.auth", async () => { + const app = makeApp(fakeOauth(), "member", []); + const res = await request(app).get("/api/spotify/login"); + expect(res.status).toBe(403); + }); + + it("GET /callback with a good code+state redirects to success", async () => { + const handleCallback = vi.fn(async () => true); + const app = makeApp(fakeOauth({ handleCallback })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=success"); + expect(handleCallback).toHaveBeenCalledWith("abc", "st"); + }); + + it("GET /callback with a bad state (handleCallback false) redirects to error", async () => { + const app = makeApp(fakeOauth({ handleCallback: async () => false })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=WRONG"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + }); + + it("GET /callback with missing code does not call oauth and redirects to error", async () => { + const handleCallback = vi.fn(async () => true); + const app = makeApp(fakeOauth({ handleCallback })); + const res = await request(app).get("/api/spotify/callback?state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + expect(handleCallback).not.toHaveBeenCalled(); + }); + + it("GET /callback swallows a throwing handleCallback and redirects to error", async () => { + const app = makeApp(fakeOauth({ handleCallback: async () => { throw new Error("boom"); } })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + }); + + it("GET /status reflects authorized + backend + deviceName", async () => { + const app = makeApp(fakeOauth({ isAuthorized: () => true })); + const res = await request(app).get("/api/spotify/status"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot" }); + }); + + it("GET /status is 403 for a guest", async () => { + const app = makeApp(fakeOauth(), "guest"); + const res = await request(app).get("/api/spotify/status"); + expect(res.status).toBe(403); + }); +}); diff --git a/src/web/api/spotify.ts b/src/web/api/spotify.ts new file mode 100644 index 0000000..2e4565f --- /dev/null +++ b/src/web/api/spotify.ts @@ -0,0 +1,74 @@ +import { Router } from "express"; +import type { Logger } from "pino"; +import { requirePermission } from "../middleware/requirePermission.js"; +import { requireNotGuest } from "../middleware/requireNotGuest.js"; + +/** Minimal structural seam over SpotifyOAuth so this router needs no real + * network/crypto in tests. The concrete SpotifyOAuth satisfies it verbatim. */ +export interface SpotifyOAuthLike { + buildAuthorizeUrl(): { url: string; state: string }; + handleCallback(code: string, state: string): Promise; + isAuthorized(): boolean; +} + +export interface SpotifyRouterOptions { + oauth: SpotifyOAuthLike; + logger: Logger; + /** Process-wide backend info for /status (single Premium account, Stage 3). */ + getBackendInfo: () => { backend: string; deviceName: string }; + /** Web UI page to bounce the browser back to after the OAuth callback. */ + webUiRedirect?: string; +} + +export function createSpotifyRouter(opts: SpotifyRouterOptions): Router { + const { oauth, logger } = opts; + const redirectBase = opts.webUiRedirect ?? "/"; + const sep = redirectBase.includes("?") ? "&" : "?"; + const router = Router(); + + // Start the Authorization Code + PKCE flow: hand the WebUI the accounts.spotify.com + // authorize URL (verifier is stashed by state inside SpotifyOAuth). Gated like the + // other platform logins in auth.ts. + router.get("/login", requirePermission("platform.auth"), (_req, res) => { + try { + const { url } = oauth.buildAuthorizeUrl(); + res.json({ url }); + } catch (err) { + logger.error({ err }, "Spotify authorize URL build failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + // OAuth redirect target (own-app clientId => redirect_uri points here). This is a + // top-level browser navigation carrying the SameSite=Lax session cookie, so the + // global requireAuth passes; state is the CSRF guard for the flow itself. Always + // redirect (never JSON) so the user lands back in the UI. + router.get("/callback", async (req, res) => { + const code = typeof req.query.code === "string" ? req.query.code : ""; + const state = typeof req.query.state === "string" ? req.query.state : ""; + if (!code || !state) { + res.redirect(`${redirectBase}${sep}spotify=error`); + return; + } + try { + const ok = await oauth.handleCallback(code, state); + res.redirect(`${redirectBase}${sep}spotify=${ok ? "success" : "error"}`); + } catch (err) { + logger.error({ err }, "Spotify OAuth callback failed"); + res.redirect(`${redirectBase}${sep}spotify=error`); + } + }); + + // Whether the (single, process-wide) account is authorized, plus which backend + // + device name are configured — used by the WebUI to show login-needed state. + router.get("/status", requireNotGuest, (_req, res) => { + const info = opts.getBackendInfo(); + res.json({ + authorized: oauth.isAuthorized(), + backend: info.backend, + deviceName: info.deviceName, + }); + }); + + return router; +} diff --git a/src/web/server.ts b/src/web/server.ts index 428db8c..fd47f71 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -19,6 +19,8 @@ import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; +import { createSpotifyRouter } from "./api/spotify.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; @@ -48,6 +50,9 @@ export interface WebServerOptions { cookieStore?: CookieStore; avatarStore: AvatarStore; staticDir?: string; + /** Process-wide shared Spotify OAuth (single account, Stage 3). When set, the + * /api/spotify {login,callback,status} router is mounted. */ + spotifyOAuth?: SpotifyOAuth; } export interface WebServer { @@ -136,6 +141,20 @@ export function createWebServer(options: WebServerOptions): WebServer { "/api/auth", createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider) ); + if (options.spotifyOAuth) { + app.use( + "/api/spotify", + createSpotifyRouter({ + oauth: options.spotifyOAuth, + logger, + getBackendInfo: () => ({ + backend: options.config.spotify.backend, + deviceName: options.config.spotify.deviceName, + }), + webUiRedirect: "/", + }), + ); + } app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); // admin-only routes From 6bde51c99133e154a957431bbb66c0c1f8c97aee Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 3 Jul 2026 00:17:20 +0800 Subject: [PATCH 30/59] chore(spotify): stage 3 verification pass From eb3523f374bff6c39338f1acfd31280447fdea54 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 3 Jul 2026 00:38:11 +0800 Subject: [PATCH 31/59] =?UTF-8?q?docs(spotify):=20stage=204=20plan=20?= =?UTF-8?q?=E2=80=94=20config=20UI,=20OAuth=20hardening,=20Connect=20watch?= =?UTF-8?q?dog,=20docs=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-03-spotify-stage4-polish-docs.md | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-spotify-stage4-polish-docs.md diff --git a/docs/superpowers/plans/2026-07-03-spotify-stage4-polish-docs.md b/docs/superpowers/plans/2026-07-03-spotify-stage4-polish-docs.md new file mode 100644 index 0000000..ebc16d0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-spotify-stage4-polish-docs.md @@ -0,0 +1,520 @@ +# Spotify Source — Stage 4 (Polish, Config UI, Docs) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the Spotify source's last mile — make it user-configurable and user-authorizable from the web UI, document it (with the required safety warnings), and land the deferred OAuth-robustness hardening. + +**Architecture:** The Stage-3 OAuth endpoints (`/api/spotify/{login,callback,status}`) and the single shared `SpotifyOAuth` already exist but are unreachable from the UI. Stage 4 adds: (1) a `spotify` block to the `/api/bot/settings` config API (secret masked); (2) a resolved-backend indicator on `/status`; (3) OAuth refresh/verifier hardening; (4) the approved Settings "Connect Spotify" card (spec §8); (5) the README Spotify section (spec §11/§12). Deferred (documented, NOT built this stage): runtime binary auto-download, connect play-not-reflected diagnostics, extra watchdog. + +**Tech Stack:** TypeScript (ESM, `.js` specifiers), Express, Vitest (root config also runs `web/src/**/*.test.ts` — stores/composables only; there is NO `.vue` component-test harness), Vue 3 + `