Compare commits

..
Author SHA1 Message Date
saopig1andClaude Opus 4.8 77d71fe418 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) <noreply@anthropic.com>
2026-07-02 00:32:08 +08:00
saopig1andClaude Opus 4.8 50bdaae26a feat(spotify): frontend plumbing (source tab, badge, auth status)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:22:45 +08:00
saopig1andClaude Opus 4.8 e1171dbcfe feat(spotify): expose provider through web music/auth routers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:19:22 +08:00
saopig1andClaude Opus 4.8 9a9f68c446 feat(spotify): wire provider through manager/instance; skip playback sentinel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:13:22 +08:00
saopig1andClaude Opus 4.8 848b3931b8 feat(spotify): config block (disabled by default) + sanitize
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:04:31 +08:00
saopig1 828fd21e29 feat(spotify): SpotifyProvider (search/browse; playback sentinel) 2026-07-01 23:59:41 +08:00
saopig1andClaude Opus 4.8 6c16e2d966 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) <noreply@anthropic.com>
2026-07-01 23:52:22 +08:00
saopig1andClaude Opus 4.8 b352d71528 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) <noreply@anthropic.com>
2026-07-01 23:04:35 +08:00
saopig1andClaude Opus 4.8 92bdfcb294 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) <noreply@anthropic.com>
2026-07-01 22:26:07 +08:00
23 changed files with 2025 additions and 24 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -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<void>; // launch sidecar + resampling ffmpeg
stop(): void; // tear everything down
isReady(): boolean; // device online & streamable
playTrack(uri: string): Promise<void>; // begin ONE track (spotify:track:...)
pause(): Promise<void>;
resume(): Promise<void>;
seek(ms: number): Promise<void>;
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: <p> }`, `audio_backend: 'pipe'`, `audio_output_pipe: '<fifo>'`, `audio_output_pipe_format: 's16le'`, `bitrate: 320`, credentials block.
- `start()`: `mkfifo <fifo>` → **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 <fifo> -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/<path>` 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 "<deviceName>" --bitrate 320 --cache <dir> [--access-token <tok> | --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:<id>" }`) — 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/<path>`. `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`.
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string; name: string;
artist: string; artist: string;
album: 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 url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
+8
View File
@@ -210,3 +210,11 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
expect(ctx.executeCommand).toHaveBeenCalledTimes(1); 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);
});
});
+18 -1
View File
@@ -23,6 +23,7 @@ import {
occupancyFromClientList, occupancyFromClientList,
shouldResumeOnReturn, shouldResumeOnReturn,
} from "./auto-pause.js"; } from "./auto-pause.js";
import { isSpotifyUri } from "../music/spotify/webapi.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */ /** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
@@ -37,6 +38,7 @@ export interface BotInstanceOptions {
youtubeProvider: MusicProvider; youtubeProvider: MusicProvider;
localProvider?: MusicProvider; localProvider?: MusicProvider;
kugouProvider?: MusicProvider; kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -71,6 +73,7 @@ export class BotInstance extends EventEmitter {
private youtubeProvider: MusicProvider; private youtubeProvider: MusicProvider;
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider; private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -101,6 +104,7 @@ export class BotInstance extends EventEmitter {
this.youtubeProvider = options.youtubeProvider; this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider; this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider; this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
this.database = options.database; this.database = options.database;
this.config = options.config; this.config = options.config;
this.logger = options.logger.child({ botId: this.id }); this.logger = options.logger.child({ botId: this.id });
@@ -527,11 +531,12 @@ 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 === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider; if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider; if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider; if (platform === "kugou") return this.kugouProvider;
if (platform === "spotify") return this.spotifyProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; 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("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider; if (flags.has("y")) return this.youtubeProvider;
if (flags.has("k")) return this.kugouProvider; if (flags.has("k")) return this.kugouProvider;
if (flags.has("s")) return this.spotifyProvider;
return this.neteaseProvider; return this.neteaseProvider;
} }
@@ -581,6 +587,17 @@ export class BotInstance extends EventEmitter {
); );
return false; 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; song.url = result.url;
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration; this.effectiveDuration = result.trialDuration ?? song.duration;
+7 -1
View File
@@ -76,6 +76,7 @@ export class BotManager extends EventEmitter {
private youtubeProvider: MusicProvider; private youtubeProvider: MusicProvider;
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider; private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -94,7 +95,8 @@ export class BotManager extends EventEmitter {
permissions: PermissionStore, permissions: PermissionStore,
configPath: string, configPath: string,
localProvider?: MusicProvider, localProvider?: MusicProvider,
kugouProvider?: MusicProvider kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -103,6 +105,7 @@ export class BotManager extends EventEmitter {
this.youtubeProvider = new YouTubeProvider(); this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider; this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any // 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. // bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{ const referenceable = this.localProvider as Partial<{
@@ -141,6 +144,7 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -281,6 +285,7 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -335,6 +340,7 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+58
View File
@@ -205,3 +205,61 @@ describe("adminGroups normalization", () => {
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]); 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,
});
});
});
+39
View File
@@ -9,6 +9,15 @@ export interface GuestModeConfig {
permissions: GuestPermissions; permissions: GuestPermissions;
} }
export interface SpotifyConfig {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string;
deviceName: string;
bitrate: number;
}
export interface BotConfig { export interface BotConfig {
webPort: number; webPort: number;
locale: "zh" | "en"; locale: "zh" | "en";
@@ -33,6 +42,7 @@ export interface BotConfig {
// behind HTTPS-terminating proxies. // behind HTTPS-terminating proxies.
trustProxy: boolean; trustProxy: boolean;
guestMode: GuestModeConfig; guestMode: GuestModeConfig;
spotify: SpotifyConfig;
} }
export function getDefaultConfig(): BotConfig { export function getDefaultConfig(): BotConfig {
@@ -69,6 +79,14 @@ export function getDefaultConfig(): BotConfig {
playCollection: false, 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; : defaults.adminGroups;
const partialSp = (partial.spotify ?? {}) as Partial<SpotifyConfig>;
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 { return {
...defaults, ...defaults,
...partial, ...partial,
adminGroups, adminGroups,
guestMode: gm, guestMode: gm,
spotify,
}; };
} catch { } catch {
return defaults; return defaults;
+1 -1
View File
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string; songName: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
coverUrl: string; coverUrl: string;
} }
+12 -1
View File
@@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js"; import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js"; import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js"; import { KugouProvider } from "./music/kugou.js";
import { SpotifyProvider } from "./music/spotify/provider.js";
import { createCookieStore } from "./music/auth.js"; import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js"; import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js"; import { createPermissionStore } from "./data/permissions.js";
@@ -59,6 +60,14 @@ async function main() {
const bilibiliProvider = new BiliBiliProvider(); const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR); const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const kugouProvider = new KugouProvider(); 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 cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR); const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -84,7 +93,8 @@ async function main() {
permissions, permissions,
CONFIG_PATH, CONFIG_PATH,
localProvider, localProvider,
kugouProvider kugouProvider,
spotifyProvider
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -96,6 +106,7 @@ async function main() {
bilibiliProvider, bilibiliProvider,
localProvider, localProvider,
kugouProvider, kugouProvider,
spotifyProvider,
database: db, database: db,
avatarStore, avatarStore,
config, config,
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string; album: string;
duration: number; // seconds duration: number; // seconds
coverUrl: string; 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 /** 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). */ * (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean; vip?: boolean;
@@ -27,7 +27,7 @@ export interface Playlist {
name: string; name: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
} }
export interface PlaylistDetail { export interface PlaylistDetail {
@@ -44,7 +44,7 @@ export interface Album {
artist: string; artist: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
} }
export interface LyricLine { export interface LyricLine {
@@ -72,7 +72,7 @@ export interface AuthStatus {
} }
export interface MusicProvider { 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<SearchResult>; search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>; getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
+63
View File
@@ -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> = {}): 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");
});
});
+86
View File
@@ -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<SearchResult> {
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<SongUrlResult | null> {
return { url: `spotify:track:${songId}` };
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
async getSongDetail(songId: string): Promise<Song | null> {
return this.api.getTrack(songId);
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
return this.api.getPlaylistTracks(playlistId);
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
return this.api.getAlbumTracks(albumId);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(
_key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return this.api.hasCreds()
? { loggedIn: true, nickname: "Spotify" }
: { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" };
}
}
+125
View File
@@ -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: [] });
});
});
+184
View File
@@ -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:<id>); 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<string | null> {
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<string, unknown>,
retryOn429 = true
): Promise<any | null> {
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<SearchResult> {
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<Song | null> {
const data = await this.get(`/v1/tracks/${id}`);
return data ? mapSpotifyTrack(data) : null;
}
async getAlbumTracks(albumId: string): Promise<Song[]> {
// 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<Song[]> {
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);
}
}
+3 -1
View File
@@ -12,7 +12,8 @@ export function createAuthRouter(
bilibiliProvider: MusicProvider, bilibiliProvider: MusicProvider,
logger: Logger, logger: Logger,
cookieStore?: CookieStore, cookieStore?: CookieStore,
kugouProvider?: MusicProvider kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
): Router { ): Router {
const router = Router(); const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can // 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 === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider; if (platform === "youtube") return youtubeProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider; if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
+12 -1
View File
@@ -14,7 +14,8 @@ export function createMusicRouter(
logger: Logger, logger: Logger,
localProvider?: MusicProvider, localProvider?: MusicProvider,
config?: BotConfig, config?: BotConfig,
kugouProvider?: MusicProvider kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
): Router { ): Router {
const router = Router(); const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider(); const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -28,6 +29,7 @@ export function createMusicRouter(
if (platform === "youtube") return youtubeProvider; if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider; if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider; if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -113,6 +115,11 @@ export function createMusicRouter(
return; return;
} }
const parsedLimit = parseInt(limit as string) || 20; 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([ const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit), neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit), qqProvider.search(q as string, parsedLimit),
@@ -291,6 +298,7 @@ export function createMusicRouter(
bilibili: bilibiliProvider.getQuality(), bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original", local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128", kugou: kugouProvider?.getQuality() ?? "128",
spotify: spotifyProvider?.getQuality() ?? "320",
}); });
}); });
@@ -313,6 +321,9 @@ export function createMusicRouter(
if ((!platform || platform === "kugou") && kugouProvider) { if ((!platform || platform === "kugou") && kugouProvider) {
kugouProvider.setQuality(quality); kugouProvider.setQuality(quality);
} }
if ((!platform || platform === "spotify") && spotifyProvider) {
spotifyProvider.setQuality(quality);
}
logger.info({ quality, platform }, "Audio quality changed"); logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality }); res.json({ success: true, quality });
}); });
+3 -2
View File
@@ -40,6 +40,7 @@ export interface WebServerOptions {
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
localProvider: MusicProvider; localProvider: MusicProvider;
kugouProvider: MusicProvider; kugouProvider: MusicProvider;
spotifyProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string; configPath: string;
@@ -125,7 +126,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
); );
app.use( app.use(
"/api/music", "/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( app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database, options.botManager, logger, options.database,
@@ -133,7 +134,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
)); ));
app.use( app.use(
"/api/auth", "/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)); app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span> <span class="song-name">{{ song.name }}</span>
<span <span
class="platform-badge" class="platform-badge"
: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' : 'badge-netease'" :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' ? '酷狗' : '网易云' }}</span> >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -145,6 +145,11 @@ function formatDuration(seconds: number): string {
color: var(--brand-kugou); color: var(--brand-kugou);
} }
.badge-spotify {
background: var(--brand-spotify-12);
color: var(--brand-spotify);
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+1
View File
@@ -25,6 +25,7 @@ const LABELS: Record<Source, string> = {
netease: '网易云', netease: '网易云',
qq: 'QQ', qq: 'QQ',
kugou: '酷狗', kugou: '酷狗',
spotify: 'Spotify',
}; };
defineProps<{ defineProps<{
+13 -8
View File
@@ -10,10 +10,10 @@ export interface Song {
album: string; album: string;
duration: number; duration: number;
coverUrl: string; 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 { export interface BotStatus {
id: string; id: string;
@@ -100,11 +100,11 @@ export const usePlayerStore = defineStore('player', {
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
// Home page cache, split by source // Home page cache, split by source
recommendPlaylists: { 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[] }, dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] }, userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false, kugou: false }, authStatus: { netease: false, qq: false, kugou: false, spotify: false },
lastFetchTime: 0, lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user) // 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.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq'); if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou'); if (this.authStatus.kugou) s.push('kugou');
if (this.authStatus.spotify) s.push('spotify');
return s; return s;
}, },
}, },
@@ -572,23 +573,27 @@ export const usePlayerStore = defineStore('player', {
// Always check auth status first — if it changed since the cached // Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the // fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch. // 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: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }), axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
]); ]);
const newAuth = { const newAuth = {
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn, kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
}; };
const authChanged = const authChanged =
newAuth.netease !== this.authStatus.netease || newAuth.netease !== this.authStatus.netease ||
newAuth.qq !== this.authStatus.qq || 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.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq; this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou; this.authStatus.kugou = newAuth.kugou;
this.authStatus.spotify = newAuth.spotify;
// Favorites are user-local and cheap; always refresh them, even on a // Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions. // home-data cache hit, so hearts stay correct across tabs/sessions.
+1 -1
View File
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source { export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll(); const all = readAll();
const v = all[key]; 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 { export function saveTabSource(key: TabKey, value: Source): void {
+2
View File
@@ -82,6 +82,8 @@
--brand-youtube-12: rgba(255, 0, 0, 0.12); --brand-youtube-12: rgba(255, 0, 0, 0.12);
--brand-kugou: #2ca2f9; --brand-kugou: #2ca2f9;
--brand-kugou-12: rgba(44, 162, 249, 0.12); --brand-kugou-12: rgba(44, 162, 249, 0.12);
--brand-spotify: #1DB954;
--brand-spotify-12: rgba(29, 185, 84, 0.12);
} }
// Dark theme (default) // Dark theme (default)