mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d71fe418 | ||
|
|
50bdaae26a | ||
|
|
e1171dbcfe | ||
|
|
9a9f68c446 | ||
|
|
848b3931b8 | ||
|
|
828fd21e29 | ||
|
|
6c16e2d966 | ||
|
|
b352d71528 | ||
|
|
92bdfcb294 |
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
@@ -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
|
||||||
|
|||||||
@@ -211,63 +211,10 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
|
describe("BotInstance.getProviderFor — spotify routing", () => {
|
||||||
it("splits a long command response into multiple sends, each under the byte cap", async () => {
|
it("getProviderFor routes 'spotify' to the injected spotify provider", () => {
|
||||||
const ctx = makeGateCtx({ adminGroups: [] });
|
const spotify = { platform: "spotify" } as any;
|
||||||
const longResponse = Array.from(
|
const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any;
|
||||||
{ length: 200 },
|
expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify);
|
||||||
(_, i) => `歌词 line number ${i} with some content`,
|
|
||||||
).join("\n");
|
|
||||||
ctx.executeCommand = vi.fn(async () => longResponse);
|
|
||||||
|
|
||||||
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
|
|
||||||
|
|
||||||
const calls = ctx.tsClient.sendTextMessage.mock.calls;
|
|
||||||
expect(calls.length).toBeGreaterThan(1);
|
|
||||||
for (const [chunk] of calls) {
|
|
||||||
expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends a short command response as a single message", async () => {
|
|
||||||
const ctx = makeGateCtx({ adminGroups: [] });
|
|
||||||
ctx.executeCommand = vi.fn(async () => "short reply");
|
|
||||||
|
|
||||||
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
|
|
||||||
|
|
||||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as (
|
|
||||||
this: unknown,
|
|
||||||
) => Promise<string>;
|
|
||||||
|
|
||||||
describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
|
|
||||||
it("returns ALL lyric lines, not just the first 10", async () => {
|
|
||||||
const lyricLines = Array.from({ length: 30 }, (_, i) => ({
|
|
||||||
time: i,
|
|
||||||
text: `lyric line ${i}`,
|
|
||||||
}));
|
|
||||||
const ctx: any = {
|
|
||||||
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
|
|
||||||
getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const out = await cmdLyrics.call(ctx);
|
|
||||||
|
|
||||||
for (const l of lyricLines) {
|
|
||||||
expect(out).toContain(l.text);
|
|
||||||
}
|
|
||||||
expect(out.startsWith("Lyrics for Song:")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'No lyrics available' when the provider has none", async () => {
|
|
||||||
const ctx: any = {
|
|
||||||
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
|
|
||||||
getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }),
|
|
||||||
};
|
|
||||||
expect(await cmdLyrics.call(ctx)).toBe("No lyrics available");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+20
-11
@@ -13,7 +13,6 @@ import {
|
|||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
@@ -24,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 = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||||
@@ -38,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;
|
||||||
@@ -72,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;
|
||||||
@@ -102,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 });
|
||||||
@@ -396,11 +399,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const response = await this.executeCommand(parsed, msg);
|
const response = await this.executeCommand(parsed, msg);
|
||||||
if (response) {
|
if (response) {
|
||||||
// A single long reply (e.g. full lyrics) would exceed TeamSpeak's
|
await this.tsClient.sendTextMessage(response);
|
||||||
// per-message byte cap, so split it and send the chunks in order.
|
|
||||||
for (const chunk of splitTextIntoChunks(response)) {
|
|
||||||
await this.tsClient.sendTextMessage(chunk);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
||||||
@@ -532,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,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;
|
||||||
@@ -1069,10 +1081,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
const provider = this.getProviderFor(song.platform);
|
const provider = this.getProviderFor(song.platform);
|
||||||
const lyrics = await provider.getLyrics(song.id);
|
const lyrics = await provider.getLyrics(song.id);
|
||||||
if (lyrics.length === 0) return "No lyrics available";
|
if (lyrics.length === 0) return "No lyrics available";
|
||||||
// Include the FULL lyrics (the send path chunks them under the message
|
const lines = lyrics.slice(0, 10).map((l) => l.text);
|
||||||
// cap). Cap only to avoid pathological spam — far above any normal song.
|
|
||||||
const MAX_LYRIC_LINES = 200;
|
|
||||||
const lines = lyrics.slice(0, MAX_LYRIC_LINES).map((l) => l.text);
|
|
||||||
return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
|
return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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,
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
|
||||||
|
|
||||||
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
|
|
||||||
|
|
||||||
describe("splitTextIntoChunks", () => {
|
|
||||||
it("returns a single chunk for a short string", () => {
|
|
||||||
const chunks = splitTextIntoChunks("hello world", 900);
|
|
||||||
expect(chunks).toEqual(["hello world"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => {
|
|
||||||
const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`);
|
|
||||||
const text = lines.join("\n");
|
|
||||||
const chunks = splitTextIntoChunks(text, 60);
|
|
||||||
|
|
||||||
expect(chunks.length).toBeGreaterThan(1);
|
|
||||||
for (const c of chunks) {
|
|
||||||
expect(bytes(c)).toBeLessThanOrEqual(60);
|
|
||||||
}
|
|
||||||
// No hard-split of any line occurred, so rejoining with "\n" is lossless.
|
|
||||||
expect(chunks.join("\n")).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => {
|
|
||||||
// Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line.
|
|
||||||
const lines = Array.from({ length: 10 }, () => "歌词".repeat(20));
|
|
||||||
const text = lines.join("\n");
|
|
||||||
const chunks = splitTextIntoChunks(text, 150);
|
|
||||||
|
|
||||||
expect(chunks.length).toBeGreaterThan(1);
|
|
||||||
for (const c of chunks) {
|
|
||||||
expect(bytes(c)).toBeLessThanOrEqual(150);
|
|
||||||
}
|
|
||||||
expect(chunks.join("\n")).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hard-splits a single over-long line so no chunk exceeds the cap", () => {
|
|
||||||
const longLine = "a".repeat(500);
|
|
||||||
const chunks = splitTextIntoChunks(longLine, 100);
|
|
||||||
|
|
||||||
expect(chunks.length).toBeGreaterThan(1);
|
|
||||||
for (const c of chunks) {
|
|
||||||
expect(bytes(c)).toBeLessThanOrEqual(100);
|
|
||||||
}
|
|
||||||
// Content is preserved (hard-split introduces split points, not \n).
|
|
||||||
expect(chunks.join("")).toBe(longLine);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never splits a multibyte character across a hard-split boundary", () => {
|
|
||||||
// 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes.
|
|
||||||
const longLine = "歌".repeat(200);
|
|
||||||
const chunks = splitTextIntoChunks(longLine, 40);
|
|
||||||
|
|
||||||
for (const c of chunks) {
|
|
||||||
expect(bytes(c)).toBeLessThanOrEqual(40);
|
|
||||||
// A clean re-decode: every chunk is valid UTF-8 with no replacement char.
|
|
||||||
expect(c.includes("�")).toBe(false);
|
|
||||||
}
|
|
||||||
expect(chunks.join("")).toBe(longLine);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves blank lines within a single chunk", () => {
|
|
||||||
const text = "a\n\nb";
|
|
||||||
expect(splitTextIntoChunks(text, 900)).toEqual([text]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`.
|
|
||||||
*
|
|
||||||
* TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path
|
|
||||||
* does no chunking, so a long single reply (e.g. full song lyrics) would be
|
|
||||||
* truncated or rejected. This packs whole lines greedily, breaking BETWEEN
|
|
||||||
* lines. When a single line is itself longer than `maxBytes`, it is hard-split
|
|
||||||
* on UTF-8 character boundaries so no chunk ever exceeds the cap and no
|
|
||||||
* multibyte character is ever cut in half.
|
|
||||||
*
|
|
||||||
* Content is preserved on rejoin, modulo the split points: chunks split only on
|
|
||||||
* newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split
|
|
||||||
* long line rejoins with `chunks.join("")`.
|
|
||||||
*
|
|
||||||
* @param text The full message text.
|
|
||||||
* @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap
|
|
||||||
* with headroom for protocol framing/escaping).
|
|
||||||
*/
|
|
||||||
export function splitTextIntoChunks(text: string, maxBytes = 900): string[] {
|
|
||||||
const chunks: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
|
|
||||||
const flush = (): void => {
|
|
||||||
if (current !== "") {
|
|
||||||
chunks.push(current);
|
|
||||||
current = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const rawLine of text.split("\n")) {
|
|
||||||
const pieces =
|
|
||||||
Buffer.byteLength(rawLine, "utf8") > maxBytes
|
|
||||||
? hardSplitByBytes(rawLine, maxBytes)
|
|
||||||
: [rawLine];
|
|
||||||
|
|
||||||
for (const piece of pieces) {
|
|
||||||
const candidate = current === "" ? piece : `${current}\n${piece}`;
|
|
||||||
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
|
|
||||||
current = candidate;
|
|
||||||
} else {
|
|
||||||
// current is guaranteed non-empty here: pieces never exceed maxBytes,
|
|
||||||
// so an empty `current` always accepts the next piece above.
|
|
||||||
flush();
|
|
||||||
current = piece;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flush();
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting
|
|
||||||
* a character (iterates code points, so surrogate pairs stay intact).
|
|
||||||
*/
|
|
||||||
function hardSplitByBytes(line: string, maxBytes: number): string[] {
|
|
||||||
const pieces: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
let currentBytes = 0;
|
|
||||||
|
|
||||||
for (const ch of line) {
|
|
||||||
const chBytes = Buffer.byteLength(ch, "utf8");
|
|
||||||
if (currentBytes + chBytes > maxBytes && current !== "") {
|
|
||||||
pieces.push(current);
|
|
||||||
current = "";
|
|
||||||
currentBytes = 0;
|
|
||||||
}
|
|
||||||
current += ch;
|
|
||||||
currentBytes += chBytes;
|
|
||||||
}
|
|
||||||
if (current !== "") pieces.push(current);
|
|
||||||
return pieces;
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { BiliBiliProvider } from "./bilibili.js";
|
|
||||||
|
|
||||||
describe("BiliBiliProvider.search pagination", () => {
|
|
||||||
function mockProvider() {
|
|
||||||
const p = new BiliBiliProvider();
|
|
||||||
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
|
|
||||||
// Short-circuit the buvid + wbi bootstrap so search only issues the
|
|
||||||
// /search/type request we want to inspect.
|
|
||||||
(p as any).buvidInitialized = true;
|
|
||||||
(p as any).wbiMixinKey = "0".repeat(32);
|
|
||||||
(p as any).wbiKeyFetchedAt = Date.now();
|
|
||||||
(p as any).api = { get };
|
|
||||||
return { p, get };
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchParams(get: ReturnType<typeof vi.fn>) {
|
|
||||||
const call = get.mock.calls.find(
|
|
||||||
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
|
|
||||||
);
|
|
||||||
expect(call, "expected a /search/type call").toBeTruthy();
|
|
||||||
// signWbi stringifies every value.
|
|
||||||
return call![1].params as Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("adds page (offset/limit+1) alongside page_size", async () => {
|
|
||||||
const { p, get } = mockProvider();
|
|
||||||
await p.search("hello", 20, 20); // page 2
|
|
||||||
const params = searchParams(get);
|
|
||||||
expect(params.page).toBe("2");
|
|
||||||
expect(params.page_size).toBe("20");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
|
|
||||||
const { p, get } = mockProvider();
|
|
||||||
await p.search("hello", 20);
|
|
||||||
expect(searchParams(get).page).toBe("1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -147,16 +147,12 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
return fixed;
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
await this.ensureBuvidCookie();
|
await this.ensureBuvidCookie();
|
||||||
await this.ensureWbiKeys();
|
await this.ensureWbiKeys();
|
||||||
// /search/type is page-based; the web pages in limit-aligned steps so
|
|
||||||
// offset is a multiple of page_size.
|
|
||||||
const page = Math.floor(offset / limit) + 1;
|
|
||||||
const signed = this.signWbi({
|
const signed = this.signWbi({
|
||||||
search_type: "video",
|
search_type: "video",
|
||||||
keyword: query,
|
keyword: query,
|
||||||
page,
|
|
||||||
page_size: limit,
|
page_size: limit,
|
||||||
});
|
});
|
||||||
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||||
|
|||||||
+2
-31
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
|
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
||||||
import { parseLyrics } from "./netease.js";
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
describe("mapKugouSongs", () => {
|
describe("mapKugouSongs", () => {
|
||||||
@@ -193,32 +193,3 @@ describe("mapKugouPlaylists", () => {
|
|||||||
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("KugouProvider.search pagination", () => {
|
|
||||||
function mockProvider() {
|
|
||||||
const p = new KugouProvider();
|
|
||||||
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
|
|
||||||
(p as any).mobileHttp = { get };
|
|
||||||
return { p, get };
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchParams(get: ReturnType<typeof vi.fn>) {
|
|
||||||
const call = get.mock.calls[0];
|
|
||||||
expect(call, "expected a mobile search call").toBeTruthy();
|
|
||||||
return call[1].params as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
|
|
||||||
const { p, get } = mockProvider();
|
|
||||||
await p.search("hello", 20, 20); // page 2
|
|
||||||
const params = searchParams(get);
|
|
||||||
expect(params.page).toBe(2);
|
|
||||||
expect(params.pagesize).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
|
|
||||||
const { p, get } = mockProvider();
|
|
||||||
await p.search("hello", 20);
|
|
||||||
expect(searchParams(get).page).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-5
@@ -616,15 +616,12 @@ export class KugouProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
||||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return { songs: [], playlists: [], albums: [] };
|
if (!q) return { songs: [], playlists: [], albums: [] };
|
||||||
try {
|
try {
|
||||||
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
|
|
||||||
// steps so offset is a multiple of pagesize.
|
|
||||||
const page = Math.floor(offset / limit) + 1;
|
|
||||||
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
||||||
params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
|
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 },
|
||||||
});
|
});
|
||||||
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
||||||
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
||||||
|
|||||||
@@ -205,20 +205,6 @@ describe("LocalMusicProvider quota", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("LocalMusicProvider search pagination", () => {
|
|
||||||
it("slices [offset, offset+limit) instead of the first page", async () => {
|
|
||||||
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
|
|
||||||
seed(recs); // newest-first order preserved: a, b, c, d
|
|
||||||
const p = new LocalMusicProvider(dir);
|
|
||||||
|
|
||||||
const page1 = await p.search("", 2); // offset defaults to 0
|
|
||||||
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
|
|
||||||
|
|
||||||
const page2 = await p.search("", 2, 2);
|
|
||||||
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LocalMusicProvider filename handling", () => {
|
describe("LocalMusicProvider filename handling", () => {
|
||||||
it("accepts a long filename without dropping its extension", async () => {
|
it("accepts a long filename without dropping its extension", async () => {
|
||||||
const p = new LocalMusicProvider(dir);
|
const p = new LocalMusicProvider(dir);
|
||||||
|
|||||||
+2
-2
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
|
|||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const songs = this.records
|
const songs = this.records
|
||||||
.filter((r) => existsSync(r.filePath))
|
.filter((r) => existsSync(r.filePath))
|
||||||
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
||||||
.slice(offset, offset + limit)
|
.slice(0, limit)
|
||||||
.map((r) => this.toSong(r));
|
.map((r) => this.toSong(r));
|
||||||
return { songs, playlists: [], albums: [] };
|
return { songs, playlists: [], albums: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
|
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
|
||||||
|
|
||||||
describe("NetEase adapter", () => {
|
describe("NetEase adapter", () => {
|
||||||
it("parses LRC format lyrics", () => {
|
it("parses LRC format lyrics", () => {
|
||||||
@@ -93,49 +93,3 @@ describe("NetEase adapter", () => {
|
|||||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NeteaseProvider.search pagination", () => {
|
|
||||||
function mockProvider() {
|
|
||||||
const p = new NeteaseProvider("http://x");
|
|
||||||
const get = vi.fn().mockResolvedValue({
|
|
||||||
data: { result: { songs: [], playlists: [], albums: [] } },
|
|
||||||
});
|
|
||||||
(p as any).api = { get };
|
|
||||||
return { p, get };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find the /cloudsearch call whose params.type matches. */
|
|
||||||
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
|
|
||||||
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
|
|
||||||
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
|
|
||||||
return call![1].params as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
|
|
||||||
const { p, get } = mockProvider();
|
|
||||||
await p.search("hello", 20, 20);
|
|
||||||
|
|
||||||
// songs (type 1): offset forwarded, limit unchanged
|
|
||||||
const songs = callByType(get, 1);
|
|
||||||
expect(songs.limit).toBe(20);
|
|
||||||
expect(songs.offset).toBe(20);
|
|
||||||
|
|
||||||
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
|
|
||||||
const playlists = callByType(get, 1000);
|
|
||||||
expect(playlists.limit).toBe(20);
|
|
||||||
expect(playlists.offset).toBe(20);
|
|
||||||
|
|
||||||
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
|
|
||||||
const albums = callByType(get, 10);
|
|
||||||
expect(albums.limit).toBe(20);
|
|
||||||
expect(albums.offset).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults offset to 0 (backward compatible)", async () => {
|
|
||||||
const { p, get } = mockProvider();
|
|
||||||
await p.search("hello", 20);
|
|
||||||
expect(callByType(get, 1).offset).toBe(0);
|
|
||||||
expect(callByType(get, 1000).offset).toBe(0);
|
|
||||||
expect(callByType(get, 10).offset).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -131,25 +131,21 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
return this.cookie ? { cookie: this.cookie } : {};
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
|
|
||||||
// and albums (type 10) are all limit/offset-driven so the web can page past
|
|
||||||
// the first page (playlists/albums were previously hardcoded to limit: 10).
|
|
||||||
const [songRes, playlistRes, albumRes] = await Promise.all([
|
const [songRes, playlistRes, albumRes] = await Promise.all([
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
|
params: { keywords: query, type: 1, limit, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: {
|
params: {
|
||||||
keywords: query,
|
keywords: query,
|
||||||
type: 1000,
|
type: 1000,
|
||||||
limit,
|
limit: 10,
|
||||||
offset,
|
|
||||||
...this.cookieParams,
|
...this.cookieParams,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
|
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +72,9 @@ 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, offset?: 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>;
|
||||||
setQuality(quality: string): void;
|
setQuality(quality: string): void;
|
||||||
getQuality(): string;
|
getQuality(): string;
|
||||||
|
|||||||
+2
-82
@@ -1,14 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
|
||||||
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
|
|
||||||
// and the per-instance api) share this single mock so the search test can
|
|
||||||
// inspect the outgoing params/body regardless of which client issued them.
|
|
||||||
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
|
|
||||||
vi.mock("axios", () => ({
|
|
||||||
default: { create: () => ({ get: mockGet, post: mockPost }) },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
|
|
||||||
|
|
||||||
describe("QQ adapter", () => {
|
describe("QQ adapter", () => {
|
||||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||||
@@ -101,74 +92,3 @@ describe("QQ adapter", () => {
|
|||||||
expect(out[0].id).toBe("");
|
expect(out[0].id).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("QQMusicProvider.search pagination", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** musicu.fcg returns one song → primary path succeeds. */
|
|
||||||
function musicuOk() {
|
|
||||||
mockGet.mockImplementation(async (url: string) => {
|
|
||||||
if (url === "/cgi-bin/musicu.fcg") {
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
|
|
||||||
req_album: { data: { body: { album: { list: [] } } } },
|
|
||||||
req_playlist: { data: { body: { songlist: { list: [] } } } },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { data: {} };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function musicuReqData() {
|
|
||||||
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
|
|
||||||
expect(call, "expected a musicu.fcg call").toBeTruthy();
|
|
||||||
return JSON.parse(call![1].params.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
|
|
||||||
musicuOk();
|
|
||||||
const p = new QQMusicProvider("http://x");
|
|
||||||
await p.search("hello", 20, 20); // page 2
|
|
||||||
|
|
||||||
const d = musicuReqData();
|
|
||||||
expect(d.req_0.param.page_num).toBe(2);
|
|
||||||
expect(d.req_0.param.num_per_page).toBe(20);
|
|
||||||
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
|
|
||||||
expect(d.req_album.param.page_num).toBe(2);
|
|
||||||
expect(d.req_album.param.num_per_page).toBe(20);
|
|
||||||
expect(d.req_playlist.param.page_num).toBe(2);
|
|
||||||
expect(d.req_playlist.param.num_per_page).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
|
|
||||||
musicuOk();
|
|
||||||
const p = new QQMusicProvider("http://x");
|
|
||||||
await p.search("hello", 20);
|
|
||||||
const d = musicuReqData();
|
|
||||||
expect(d.req_0.param.page_num).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fallback client_search_cp sets p to the page cursor", async () => {
|
|
||||||
// musicu returns no songs → primary returns null → fallback runs.
|
|
||||||
mockGet.mockImplementation(async (url: string) => {
|
|
||||||
if (url === "/cgi-bin/musicu.fcg") {
|
|
||||||
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
|
|
||||||
}
|
|
||||||
// client_search_cp
|
|
||||||
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
|
|
||||||
});
|
|
||||||
const p = new QQMusicProvider("http://x");
|
|
||||||
await p.search("hello", 20, 20); // page 2
|
|
||||||
|
|
||||||
const songCall = mockGet.mock.calls.find(
|
|
||||||
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
|
|
||||||
);
|
|
||||||
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
|
|
||||||
expect(songCall![1].params.p).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+10
-20
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||||
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
||||||
const primary = await this.searchViaMusicuFcg(query, limit, offset);
|
const primary = await this.searchViaMusicuFcg(query, limit);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
|
|
||||||
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
||||||
// no playlist support). Kept as redundancy.
|
// no playlist support). Kept as redundancy.
|
||||||
return this.searchViaClientSearchCp(query, limit, offset);
|
return this.searchViaClientSearchCp(query, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
||||||
@@ -169,31 +169,25 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
||||||
private async searchViaMusicuFcg(
|
private async searchViaMusicuFcg(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number
|
||||||
offset = 0
|
|
||||||
): Promise<SearchResult | null> {
|
): Promise<SearchResult | null> {
|
||||||
try {
|
try {
|
||||||
// num_per_page must stay >= 10 (lower values return empty). It is now
|
|
||||||
// limit-driven for ALL three lists (albums/playlists were hardcoded to
|
|
||||||
// 10). page_num is the offset cursor; the web always requests in
|
|
||||||
// limit-aligned pages so offset is a multiple of limit.
|
|
||||||
const numPerPage = Math.max(10, Math.min(limit, 50));
|
const numPerPage = Math.max(10, Math.min(limit, 50));
|
||||||
const pageNum = Math.floor(offset / limit) + 1;
|
|
||||||
const reqData = JSON.stringify({
|
const reqData = JSON.stringify({
|
||||||
req_0: {
|
req_0: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
|
param: { query, num_per_page: numPerPage, search_type: 0 },
|
||||||
},
|
},
|
||||||
req_album: {
|
req_album: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
|
param: { query, num_per_page: 10, search_type: 2 },
|
||||||
},
|
},
|
||||||
req_playlist: {
|
req_playlist: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
|
param: { query, num_per_page: 10, search_type: 3 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
@@ -227,16 +221,12 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
||||||
private async searchViaClientSearchCp(
|
private async searchViaClientSearchCp(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number
|
||||||
offset = 0
|
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
|
|
||||||
// offset is a multiple of limit.
|
|
||||||
const page = Math.floor(offset / limit) + 1;
|
|
||||||
const songParams = {
|
const songParams = {
|
||||||
w: query,
|
w: query,
|
||||||
format: "json",
|
format: "json",
|
||||||
p: page,
|
p: 1,
|
||||||
n: Math.min(limit, 50),
|
n: Math.min(limit, 50),
|
||||||
type: 0,
|
type: 0,
|
||||||
cr: 1,
|
cr: 1,
|
||||||
@@ -244,7 +234,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const albumParams = {
|
const albumParams = {
|
||||||
w: query,
|
w: query,
|
||||||
format: "json",
|
format: "json",
|
||||||
p: page,
|
p: 1,
|
||||||
n: 5,
|
n: 5,
|
||||||
t: 8,
|
t: 8,
|
||||||
cr: 1,
|
cr: 1,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,23 +115,17 @@ export class YouTubeProvider implements MusicProvider {
|
|||||||
readonly platform = "youtube" as const;
|
readonly platform = "youtube" as const;
|
||||||
private quality = "bestaudio";
|
private quality = "bestaudio";
|
||||||
|
|
||||||
async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
|
async search(query: string, limit = 5): Promise<SearchResult> {
|
||||||
try {
|
try {
|
||||||
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
|
|
||||||
// N results. Best-effort paginate by fetching offset+limit and slicing
|
|
||||||
// locally. (offset 0 → identical to before.)
|
|
||||||
const total = offset + limit;
|
|
||||||
const raw = await runYtDlp([
|
const raw = await runYtDlp([
|
||||||
`ytsearch${total}:${query}`,
|
`ytsearch${limit}:${query}`,
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
]);
|
]);
|
||||||
const lines = raw.trim().split("\n").filter(Boolean);
|
const lines = raw.trim().split("\n").filter(Boolean);
|
||||||
const songs: Song[] = lines
|
const songs: Song[] = lines.map((line) => {
|
||||||
.slice(offset, offset + limit)
|
|
||||||
.map((line) => {
|
|
||||||
const entry = JSON.parse(line) as YtDlpEntry;
|
const entry = JSON.parse(line) as YtDlpEntry;
|
||||||
return entryToSong(entry);
|
return entryToSong(entry);
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import express from "express";
|
|
||||||
import request from "supertest";
|
|
||||||
import pino from "pino";
|
|
||||||
import type { MusicProvider, SearchResult } from "../../music/provider.js";
|
|
||||||
import { createMusicRouter } from "./music.js";
|
|
||||||
|
|
||||||
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
|
|
||||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
|
||||||
return {
|
|
||||||
platform,
|
|
||||||
search: vi.fn().mockResolvedValue(empty),
|
|
||||||
} as unknown as MusicProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("music router GET /search offset pagination", () => {
|
|
||||||
let app: express.Express;
|
|
||||||
let netease: MusicProvider;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
netease = fakeProvider("netease");
|
|
||||||
const router = createMusicRouter(
|
|
||||||
netease,
|
|
||||||
fakeProvider("qq"),
|
|
||||||
fakeProvider("bilibili"),
|
|
||||||
pino({ level: "silent" })
|
|
||||||
);
|
|
||||||
app = express();
|
|
||||||
app.use("/api/music", router);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
|
|
||||||
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults a missing offset to 0", async () => {
|
|
||||||
const res = await request(app).get("/api/music/search?q=hello&limit=20");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps a negative offset to 0", async () => {
|
|
||||||
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+14
-7
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/search", async (req, res) => {
|
router.get("/search", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q, platform, limit, offset } = req.query;
|
const { q, platform, limit } = req.query;
|
||||||
if (!q) {
|
if (!q) {
|
||||||
res.status(400).json({ error: "q (query) is required" });
|
res.status(400).json({ error: "q (query) is required" });
|
||||||
return;
|
return;
|
||||||
@@ -94,13 +96,9 @@ export function createMusicRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = getProvider(platform as string);
|
const provider = getProvider(platform as string);
|
||||||
// Server-side pagination: offset lets the web load past the first page.
|
|
||||||
// Clamp to >= 0 so a bad/negative value falls back to the first page.
|
|
||||||
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
|
|
||||||
const result = await provider.search(
|
const result = await provider.search(
|
||||||
q as string,
|
q as string,
|
||||||
parseInt(limit as string) || 20,
|
parseInt(limit as string) || 20
|
||||||
parsedOffset
|
|
||||||
);
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -117,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),
|
||||||
@@ -295,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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,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
@@ -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,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);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const LABELS: Record<Source, string> = {
|
|||||||
netease: '网易云',
|
netease: '网易云',
|
||||||
qq: 'QQ',
|
qq: 'QQ',
|
||||||
kugou: '酷狗',
|
kugou: '酷狗',
|
||||||
|
spotify: 'Spotify',
|
||||||
};
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+1
-134
@@ -165,13 +165,6 @@
|
|||||||
@add="store.addSong(song)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="showLoadMore" class="load-more-wrap">
|
|
||||||
<button class="load-more-btn" :disabled="currentLoadingMore" @click="loadMore">
|
|
||||||
<Icon v-if="currentLoadingMore" icon="mdi:loading" class="spin" />
|
|
||||||
{{ currentLoadingMore ? '加载中...' : '加载更多' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||||
@@ -187,9 +180,6 @@ import { usePlayerStore } from '../stores/player.js';
|
|||||||
import type { Song } from '../stores/player.js';
|
import type { Song } from '../stores/player.js';
|
||||||
import SongCard from '../components/SongCard.vue';
|
import SongCard from '../components/SongCard.vue';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -207,10 +197,8 @@ function loadSource(): SearchSource {
|
|||||||
return 'netease';
|
return 'netease';
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'songs' | 'albums' | 'playlists';
|
|
||||||
|
|
||||||
const query = ref((route.query.q as string) || '');
|
const query = ref((route.query.q as string) || '');
|
||||||
const activeTab = ref<TabType>('songs');
|
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||||
const selectedSource = ref<SearchSource>(loadSource());
|
const selectedSource = ref<SearchSource>(loadSource());
|
||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||||
@@ -219,9 +207,6 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
|
|||||||
const allSongs = ref<Song[]>([]);
|
const allSongs = ref<Song[]>([]);
|
||||||
const allAlbums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const allPlaylists = ref<Playlist[]>([]);
|
const allPlaylists = ref<Playlist[]>([]);
|
||||||
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
|
||||||
const hasMoreMap = ref<Record<string, boolean>>({});
|
|
||||||
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
@@ -245,81 +230,6 @@ const filteredPlaylists = computed(() =>
|
|||||||
|
|
||||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||||
|
|
||||||
// ---- 分页 / 加载更多 ----
|
|
||||||
function pageKey(type: TabType, source: string): string {
|
|
||||||
return `${type}:${source}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentItems = computed(() => {
|
|
||||||
if (activeTab.value === 'albums') return filteredAlbums.value;
|
|
||||||
if (activeTab.value === 'playlists') return filteredPlaylists.value;
|
|
||||||
return filteredSongs.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentLoadingMore = computed(() => loadingMore.value[activeTab.value]);
|
|
||||||
|
|
||||||
const currentHasMore = computed(
|
|
||||||
() => hasMoreMap.value[pageKey(activeTab.value, selectedSource.value)] ?? false
|
|
||||||
);
|
|
||||||
|
|
||||||
// 有结果、还有下一页时才显示按钮;加载中时按钮保留但禁用并显示 spinner。
|
|
||||||
const showLoadMore = computed(() => currentItems.value.length > 0 && currentHasMore.value);
|
|
||||||
|
|
||||||
function resetPagination() {
|
|
||||||
hasMoreMap.value = {};
|
|
||||||
loadingMore.value = { songs: false, albums: false, playlists: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录某个 (类型, 音源) 是否还有更多:返回条数 === PAGE_SIZE 视为还有下一页。
|
|
||||||
function setHasMore(type: TabType, source: string, returnedCount: number) {
|
|
||||||
hasMoreMap.value = {
|
|
||||||
...hasMoreMap.value,
|
|
||||||
[pageKey(type, source)]: hasMore(returnedCount, PAGE_SIZE),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始 /search/all 返回的是各音源合并的首页,按音源分组统计每种类型的条数。
|
|
||||||
function recordInitialHasMore(items: { platform: string }[], type: TabType) {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const it of items) counts[it.platform] = (counts[it.platform] ?? 0) + 1;
|
|
||||||
const next = { ...hasMoreMap.value };
|
|
||||||
for (const [source, count] of Object.entries(counts)) {
|
|
||||||
next[pageKey(type, source)] = hasMore(count, PAGE_SIZE);
|
|
||||||
}
|
|
||||||
hasMoreMap.value = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
const type = activeTab.value;
|
|
||||||
const source = selectedSource.value;
|
|
||||||
if (loadingMore.value[type]) return;
|
|
||||||
if (!currentHasMore.value) return;
|
|
||||||
const offset = nextOffset(currentItems.value.length, PAGE_SIZE);
|
|
||||||
loadingMore.value = { ...loadingMore.value, [type]: true };
|
|
||||||
try {
|
|
||||||
const res = await axios.get('/api/music/search', {
|
|
||||||
params: { q: query.value, platform: source, limit: PAGE_SIZE, offset },
|
|
||||||
});
|
|
||||||
if (type === 'albums') {
|
|
||||||
const incoming = (res.data.albums ?? []) as Album[];
|
|
||||||
allAlbums.value = mergeDedup(allAlbums.value, incoming);
|
|
||||||
setHasMore(type, source, incoming.length);
|
|
||||||
} else if (type === 'playlists') {
|
|
||||||
const incoming = (res.data.playlists ?? []) as Playlist[];
|
|
||||||
allPlaylists.value = mergeDedup(allPlaylists.value, incoming);
|
|
||||||
setHasMore(type, source, incoming.length);
|
|
||||||
} else {
|
|
||||||
const incoming = (res.data.songs ?? []) as Song[];
|
|
||||||
allSongs.value = mergeDedup(allSongs.value, incoming);
|
|
||||||
setHasMore(type, source, incoming.length);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 保留 hasMore 现状,允许用户重试。
|
|
||||||
} finally {
|
|
||||||
loadingMore.value = { ...loadingMore.value, [type]: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist source preference
|
// Persist source preference
|
||||||
watch(selectedSource, (src) => {
|
watch(selectedSource, (src) => {
|
||||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||||
@@ -356,16 +266,12 @@ async function doSearch() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
activeTab.value = 'songs';
|
activeTab.value = 'songs';
|
||||||
resetPagination();
|
|
||||||
router.replace({ query: { q: query.value } });
|
router.replace({ query: { q: query.value } });
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||||
allSongs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
allAlbums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
allPlaylists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
recordInitialHasMore(allSongs.value, 'songs');
|
|
||||||
recordInitialHasMore(allAlbums.value, 'albums');
|
|
||||||
recordInitialHasMore(allPlaylists.value, 'playlists');
|
|
||||||
} catch {
|
} catch {
|
||||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -695,45 +601,6 @@ onMounted(() => {
|
|||||||
.result-section {
|
.result-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 8px 0 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 9px 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: var(--fw-semi);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-card);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-fast), background var(--transition-fast);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: rgba(51, 94, 234, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin {
|
|
||||||
animation: load-more-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes load-more-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { itemKey, mergeDedup, hasMore, nextOffset, type Keyed } from "./searchPagination.js";
|
|
||||||
|
|
||||||
const item = (platform: string, id: string): Keyed & { label: string } => ({
|
|
||||||
platform,
|
|
||||||
id,
|
|
||||||
label: `${platform}:${id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("searchPagination helpers (#115)", () => {
|
|
||||||
describe("itemKey", () => {
|
|
||||||
it("builds a `${platform}:${id}` key", () => {
|
|
||||||
expect(itemKey({ platform: "netease", id: "42" })).toBe("netease:42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distinguishes same id across platforms", () => {
|
|
||||||
expect(itemKey({ platform: "qq", id: "1" })).not.toBe(itemKey({ platform: "netease", id: "1" }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mergeDedup", () => {
|
|
||||||
it("appends incoming items, existing first, order preserved", () => {
|
|
||||||
const existing = [item("netease", "1"), item("netease", "2")];
|
|
||||||
const incoming = [item("netease", "3"), item("netease", "4")];
|
|
||||||
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3", "4"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops incoming items already present in existing", () => {
|
|
||||||
const existing = [item("netease", "1"), item("netease", "2")];
|
|
||||||
const incoming = [item("netease", "2"), item("netease", "3")];
|
|
||||||
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops duplicates within the incoming batch", () => {
|
|
||||||
const existing = [item("netease", "1")];
|
|
||||||
const incoming = [item("netease", "2"), item("netease", "2"), item("netease", "3")];
|
|
||||||
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats same id on different platforms as distinct", () => {
|
|
||||||
const existing = [item("netease", "1")];
|
|
||||||
const incoming = [item("qq", "1")];
|
|
||||||
const merged = mergeDedup(existing, incoming);
|
|
||||||
expect(merged.map(itemKey)).toEqual(["netease:1", "qq:1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate the existing array", () => {
|
|
||||||
const existing = [item("netease", "1")];
|
|
||||||
const before = existing.slice();
|
|
||||||
mergeDedup(existing, [item("netease", "2")]);
|
|
||||||
expect(existing).toEqual(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty incoming", () => {
|
|
||||||
const existing = [item("netease", "1")];
|
|
||||||
expect(mergeDedup(existing, []).map((x) => x.id)).toEqual(["1"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasMore", () => {
|
|
||||||
it("is true when a full page came back", () => {
|
|
||||||
expect(hasMore(20, 20)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when a short page came back", () => {
|
|
||||||
expect(hasMore(7, 20)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when nothing came back", () => {
|
|
||||||
expect(hasMore(0, 20)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("nextOffset", () => {
|
|
||||||
it("returns the page-aligned offset for a full first page", () => {
|
|
||||||
expect(nextOffset(20, 20)).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 when nothing is shown yet", () => {
|
|
||||||
expect(nextOffset(0, 20)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rounds up to the next page boundary after dedup drops items", () => {
|
|
||||||
// page1 (20) + page2 minus 5 dupes -> 35 shown, next page cursor is 40.
|
|
||||||
expect(nextOffset(35, 20)).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays aligned across multiple full pages", () => {
|
|
||||||
expect(nextOffset(40, 20)).toBe(40);
|
|
||||||
expect(nextOffset(60, 20)).toBe(60);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// Pure pagination helpers for Search.vue "加载更多" (load-more) per source + tab.
|
|
||||||
// Kept framework-free so root vitest can unit-cover the logic (see searchPagination.test.ts).
|
|
||||||
|
|
||||||
/** Minimal shape shared by songs / albums / playlists: needs a stable dedup key. */
|
|
||||||
export interface Keyed {
|
|
||||||
id: string;
|
|
||||||
platform: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable dedup key for a result item: `${platform}:${id}`. */
|
|
||||||
export function itemKey(item: Keyed): string {
|
|
||||||
return `${item.platform}:${item.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge `incoming` into `existing`, deduped by `${platform}:${id}`.
|
|
||||||
* Order is preserved with existing items first; incoming items already present
|
|
||||||
* (or duplicated within the incoming batch) are dropped.
|
|
||||||
*/
|
|
||||||
export function mergeDedup<T extends Keyed>(existing: T[], incoming: T[]): T[] {
|
|
||||||
const seen = new Set<string>(existing.map(itemKey));
|
|
||||||
const result = existing.slice();
|
|
||||||
for (const item of incoming) {
|
|
||||||
const key = itemKey(item);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
result.push(item);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether another page might exist: a full page (=== pageSize) means keep the
|
|
||||||
* button; a short/empty page (< pageSize) means the source is exhausted.
|
|
||||||
*/
|
|
||||||
export function hasMore(returnedCount: number, pageSize: number): boolean {
|
|
||||||
return returnedCount >= pageSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offset for the next page request. Offsets are page-aligned, so this is simply
|
|
||||||
* the number of items already shown for that source+type.
|
|
||||||
*/
|
|
||||||
export function nextOffset(currentCountForSource: number, pageSize: number): number {
|
|
||||||
return Math.ceil(currentCountForSource / pageSize) * pageSize;
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user