mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
13-task TDD plan across 5 stages: config gates, playKeepsQueue seam, named save/load (DB + API + chat + web), live-queue snapshot/restore, and docs. Each task independently testable; all behaviors default off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1444 lines
55 KiB
Markdown
1444 lines
55 KiB
Markdown
# Save/Load Playlists + Queue Persistence Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Stop losing the play queue — add named save/load of queues (chat + web), auto-restore-and-resume the live queue across restarts, and an option to make single-song `!play` insert instead of clearing — all behind default-off toggles.
|
||
|
||
**Architecture:** New SQLite tables (`saved_queues`, `queue_state`) storing song lists as JSON blobs; a per-user + `__shared__` ownership model for saved queues; a new `/api/saved-queues` router; new chat commands; a `PlayQueue.snapshot/restore` pair driven off the existing `stateChange` emit and the `connected` lifecycle event; and a `BotInstance.playSingleSong` seam that both chat `cmdPlay` and the web `/play-song` route funnel through so the `playKeepsQueue` decision lives in one place.
|
||
|
||
**Tech Stack:** TypeScript (Node 20, ESM `.js` import suffixes), better-sqlite3, Express, Vitest, Vue 3 + Pinia + vue-tsc.
|
||
|
||
## Global Constraints
|
||
|
||
- **All three behaviors default OFF.** `savedQueuesEnabled: false`, `playKeepsQueue: false`. No behavior change until an operator opts in.
|
||
- **`savedQueuesEnabled` is admin-controlled** via `POST /api/bot/settings` (gated `requirePermission("bot.manage")`). When false: chat save/load/queues reply `"此功能未启用"`; `/api/saved-queues/*` returns 403; WebUI page hidden; NO snapshotting; NO auto-restore.
|
||
- **Config sanitization mirrors existing flags:** every new config field is coerced with `=== true` on load (like `spotify.enabled`) so a hand-edited/legacy/corrupt `config.json` can never silently enable a feature.
|
||
- **Songs are stored as a JSON `TEXT` blob** — an array of `Omit<QueuedSong, "url">` (URLs are re-resolved lazily). Corrupt blobs degrade to `[]`, never throw into a route or the restore path.
|
||
- **`SHARED_QUEUE_OWNER = "__shared__"`** — reserved owner id for chat-saved / shared queues; can never collide with a real user id.
|
||
- **Caps:** ≤ 50 saved queues per owner; ≤ 1000 songs per saved queue.
|
||
- **ESM imports** use the `.js` suffix even for `.ts` files. New source files get colocated `*.test.ts`.
|
||
- **Verification per task:** `npx vitest run <file> --no-file-parallelism`. Final gate (Task 13): full `npx vitest run --no-file-parallelism`, `npx tsc --noEmit`, and `cd web && npm run build`.
|
||
- **Branch:** all work on `feat/issue-119-saved-queues` (already created off `main`).
|
||
- **Play-history attribution:** loaded songs are tagged with the loader's `requestedBy` (integrates with #121).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Create:**
|
||
- `src/web/api/saved-queues.ts` — the `/api/saved-queues` router (Feature 1).
|
||
- `src/web/api/saved-queues.test.ts` — its tests.
|
||
- `web/src/views/SavedQueues.vue` — the WebUI page (Feature 1).
|
||
- `web/src/composables/useSavedQueues.ts` — API composable.
|
||
- `web/src/composables/savedQueues.ts` + `savedQueues.test.ts` — pure list/dedup helper (unit-tested).
|
||
|
||
**Modify:**
|
||
- `src/data/config.ts` — two new `BotConfig` flags + defaults + load sanitization.
|
||
- `src/data/config.test.ts` — sanitization tests.
|
||
- `src/web/api/bot.ts` — settings GET/POST echo the two flags.
|
||
- `src/web/api/bot.test.ts` — settings persistence tests.
|
||
- `web/src/views/Settings.vue` — two toggles in 行为设置; nav gating.
|
||
- `src/bot/instance.ts` — `playSingleSong`, chat `!save`/`!load`/`!queues`, help text, snapshot/restore wiring, `SHARED_QUEUE_OWNER` usage.
|
||
- `src/bot/instance.test.ts` — command + playKeepsQueue + snapshot/restore tests.
|
||
- `src/web/api/player.ts` — `/play-song` funnels through `bot.playSingleSong`.
|
||
- `src/data/database.ts` — `saved_queues` + `queue_state` tables, migrations, methods, interfaces, `SHARED_QUEUE_OWNER` export.
|
||
- `src/data/database.test.ts` — DB method tests.
|
||
- `src/audio/queue.ts` — `snapshot()` / `restore()`.
|
||
- `src/audio/queue.test.ts` — round-trip tests.
|
||
- `src/web/server.ts` — mount the saved-queues router.
|
||
- `README.md` — commands, toggles, caveats.
|
||
|
||
---
|
||
|
||
## Stage 1 — Config + gates + Settings UI
|
||
|
||
### Task 1: Config flags + load sanitization
|
||
|
||
**Files:**
|
||
- Modify: `src/data/config.ts` (`BotConfig`, `getDefaultConfig`, `loadConfig`)
|
||
- Test: `src/data/config.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `BotConfig.savedQueuesEnabled: boolean`, `BotConfig.playKeepsQueue: boolean` (both default `false`).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `src/data/config.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { getDefaultConfig, loadConfig } from "./config.js";
|
||
import { writeFileSync, mkdtempSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
|
||
describe("savedQueues/playKeepsQueue config", () => {
|
||
it("defaults both flags to false", () => {
|
||
const c = getDefaultConfig();
|
||
expect(c.savedQueuesEnabled).toBe(false);
|
||
expect(c.playKeepsQueue).toBe(false);
|
||
});
|
||
|
||
it("coerces non-boolean values to false on load", () => {
|
||
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
|
||
const p = join(dir, "config.json");
|
||
writeFileSync(p, JSON.stringify({ savedQueuesEnabled: "yes", playKeepsQueue: 1 }));
|
||
const c = loadConfig(p);
|
||
expect(c.savedQueuesEnabled).toBe(false);
|
||
expect(c.playKeepsQueue).toBe(false);
|
||
});
|
||
|
||
it("preserves true when explicitly enabled", () => {
|
||
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
|
||
const p = join(dir, "config.json");
|
||
writeFileSync(p, JSON.stringify({ savedQueuesEnabled: true, playKeepsQueue: true }));
|
||
const c = loadConfig(p);
|
||
expect(c.savedQueuesEnabled).toBe(true);
|
||
expect(c.playKeepsQueue).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/data/config.test.ts --no-file-parallelism`
|
||
Expected: FAIL — `savedQueuesEnabled` is `undefined`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `src/data/config.ts`, add to the `BotConfig` interface (near `localAudioEnabled`):
|
||
|
||
```ts
|
||
/** Enable named save/load of queues + auto-restore of the live queue across restart. Admin-controlled. */
|
||
savedQueuesEnabled: boolean;
|
||
/** When true, single-song !play inserts-and-plays instead of clearing the queue. */
|
||
playKeepsQueue: boolean;
|
||
```
|
||
|
||
In `getDefaultConfig()` return object (near `localAudioEnabled: true`):
|
||
|
||
```ts
|
||
savedQueuesEnabled: false,
|
||
playKeepsQueue: false,
|
||
```
|
||
|
||
In `loadConfig()`, inside the final `return { ...defaults, ...partial, ... }` block, override both with strict coercion so junk can't enable them. Add these before the `return`:
|
||
|
||
```ts
|
||
const savedQueuesEnabled = partial.savedQueuesEnabled === true;
|
||
const playKeepsQueue = partial.playKeepsQueue === true;
|
||
```
|
||
|
||
and add `savedQueuesEnabled, playKeepsQueue,` to the returned object (after `spotify,`).
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/data/config.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/data/config.ts src/data/config.test.ts
|
||
git commit -m "feat(config): add savedQueuesEnabled + playKeepsQueue flags (default off) (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Settings API round-trips the two flags
|
||
|
||
**Files:**
|
||
- Modify: `src/web/api/bot.ts` (GET `/settings`, POST `/settings`)
|
||
- Test: `src/web/api/bot.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BotConfig.savedQueuesEnabled`, `BotConfig.playKeepsQueue`.
|
||
- Produces: settings GET/POST include `savedQueuesEnabled`, `playKeepsQueue`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `src/web/api/bot.test.ts` (follow the existing settings-test setup in that file — reuse its `app`/`request` harness and admin auth). Add a test:
|
||
|
||
```ts
|
||
it("persists savedQueuesEnabled and playKeepsQueue", async () => {
|
||
const res = await adminRequest()
|
||
.post("/api/bot/settings")
|
||
.send({ savedQueuesEnabled: true, playKeepsQueue: true });
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.savedQueuesEnabled).toBe(true);
|
||
expect(res.body.playKeepsQueue).toBe(true);
|
||
|
||
const get = await adminRequest().get("/api/bot/settings");
|
||
expect(get.body.savedQueuesEnabled).toBe(true);
|
||
expect(get.body.playKeepsQueue).toBe(true);
|
||
});
|
||
|
||
it("ignores non-boolean flag values", async () => {
|
||
const res = await adminRequest()
|
||
.post("/api/bot/settings")
|
||
.send({ savedQueuesEnabled: "nope" });
|
||
expect(res.body.savedQueuesEnabled).toBe(false); // unchanged from default
|
||
});
|
||
```
|
||
|
||
(If `bot.test.ts` lacks an `adminRequest()` helper, mirror the auth setup already used by the existing `POST /settings` tests in that file.)
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/web/api/bot.test.ts --no-file-parallelism`
|
||
Expected: FAIL — response lacks the fields.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `src/web/api/bot.ts`:
|
||
|
||
In BOTH the GET `/settings` response and the POST `/settings` response objects, add:
|
||
|
||
```ts
|
||
savedQueuesEnabled: config.savedQueuesEnabled,
|
||
playKeepsQueue: config.playKeepsQueue,
|
||
```
|
||
|
||
In the POST `/settings` handler, after the `hasLocalAudioEnabled` block, add:
|
||
|
||
```ts
|
||
if (typeof req.body.savedQueuesEnabled === "boolean") {
|
||
config.savedQueuesEnabled = req.body.savedQueuesEnabled;
|
||
}
|
||
if (typeof req.body.playKeepsQueue === "boolean") {
|
||
config.playKeepsQueue = req.body.playKeepsQueue;
|
||
}
|
||
```
|
||
|
||
(No per-bot push needed: `BotInstance.this.config` is the same object reference as this `config` — see `instance.ts:171 this.config = options.config`. **Verify** during implementation that the config passed to `createBotRouter` and to each `BotInstance` is the same reference; it is threaded from `index.ts`. Both booleans are read live from `this.config`.)
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/web/api/bot.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/web/api/bot.ts src/web/api/bot.test.ts
|
||
git commit -m "feat(settings): round-trip savedQueuesEnabled + playKeepsQueue (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Settings.vue toggles
|
||
|
||
**Files:**
|
||
- Modify: `web/src/views/Settings.vue` (行为设置 section + the settings load/save payload)
|
||
|
||
**Interfaces:**
|
||
- Consumes: settings API fields `savedQueuesEnabled`, `playKeepsQueue`.
|
||
|
||
- [ ] **Step 1: Locate the 行为设置 toggles**
|
||
|
||
Find where `localAudioEnabled` / `autoPauseOnEmpty` are rendered (a labeled toggle bound to a reactive settings object) and where the settings object is loaded from `GET /api/bot/settings` and sent by `POST /api/bot/settings`.
|
||
|
||
- [ ] **Step 2: Add the reactive fields**
|
||
|
||
Add `savedQueuesEnabled` and `playKeepsQueue` to the settings reactive object's initial shape (default `false`), to the GET hydration, and to the POST payload — exactly mirroring how `localAudioEnabled` is wired.
|
||
|
||
- [ ] **Step 3: Add two toggle rows**
|
||
|
||
In the 行为设置 section, after the local-audio toggle, add two toggle rows mirroring the existing markup:
|
||
|
||
```html
|
||
<!-- 保存/加载播放清单 + 重启恢复队列(管理员开关,默认关闭) -->
|
||
<label class="setting-row">
|
||
<span>保存/加载播放清单(含重启后自动恢复队列)</span>
|
||
<input type="checkbox" v-model="settings.savedQueuesEnabled" />
|
||
</label>
|
||
<!-- 单曲直接播放不清空队列 -->
|
||
<label class="setting-row">
|
||
<span>直接播放单曲时不清空队列(播完继续原队列)</span>
|
||
<input type="checkbox" v-model="settings.playKeepsQueue" />
|
||
</label>
|
||
```
|
||
|
||
(Match the actual class names / toggle component used by neighboring rows.)
|
||
|
||
- [ ] **Step 4: Build check**
|
||
|
||
Run: `cd web && npm run build`
|
||
Expected: build succeeds (vue-tsc clean).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/src/views/Settings.vue
|
||
git commit -m "feat(web): saved-queues + play-keeps-queue toggles in 行为设置 (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
## Stage 2 — Feature 3: `playKeepsQueue`
|
||
|
||
### Task 4: `BotInstance.playSingleSong` + route `cmdPlay` through it
|
||
|
||
**Files:**
|
||
- Modify: `src/bot/instance.ts` (`cmdPlay` at ~909; add `playSingleSong`)
|
||
- Test: `src/bot/instance.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `this.config.playKeepsQueue`, `this.queue` (`PlayQueue`), `this.withRequester`, `this.disableFmMode`, `this.resolveAndPlay`, `this.cleanupQueuedLocalSongs`.
|
||
- Produces: `async playSingleSong(song: QueuedSong, requesterName?: string): Promise<boolean>` — public (called by the web route in Task 5).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `src/bot/instance.test.ts` (reuse the file's existing BotInstance harness that stubs the player/queue/providers). Two tests:
|
||
|
||
```ts
|
||
describe("playSingleSong / playKeepsQueue", () => {
|
||
it("clears the queue when playKeepsQueue is false (default)", async () => {
|
||
const bot = makeBot({ playKeepsQueue: false }); // helper sets config flag
|
||
const q = bot.getQueueManager();
|
||
q.add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 10 });
|
||
q.play();
|
||
await bot.playSingleSong(
|
||
{ id: "b", name: "B", artist: "", album: "", platform: "netease", coverUrl: "", duration: 10 },
|
||
"alice",
|
||
);
|
||
expect(q.list().map((s) => s.id)).toEqual(["b"]);
|
||
expect(q.current()?.id).toBe("b");
|
||
});
|
||
|
||
it("inserts-after-current and keeps the queue when playKeepsQueue is true", async () => {
|
||
const bot = makeBot({ playKeepsQueue: true });
|
||
const q = bot.getQueueManager();
|
||
q.add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 10 });
|
||
q.add({ id: "c", name: "C", artist: "", album: "", platform: "netease", coverUrl: "", duration: 10 });
|
||
q.play(); // current = a (index 0)
|
||
await bot.playSingleSong(
|
||
{ id: "b", name: "B", artist: "", album: "", platform: "netease", coverUrl: "", duration: 10 },
|
||
"alice",
|
||
);
|
||
expect(q.list().map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||
expect(q.current()?.id).toBe("b");
|
||
expect(q.current()?.requestedBy).toBe("alice");
|
||
});
|
||
});
|
||
```
|
||
|
||
(If `instance.test.ts` has no `makeBot` helper, add a minimal factory that constructs a `BotInstance` with a stub player whose `resolveAndPlay` succeeds — mirror the existing construction in that file's other describe blocks. `resolveAndPlay` can be spied to resolve `true` without real resolution.)
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/bot/instance.test.ts -t "playSingleSong" --no-file-parallelism`
|
||
Expected: FAIL — `playSingleSong` is not a function.
|
||
|
||
- [ ] **Step 3: Implement `playSingleSong`**
|
||
|
||
Add to `BotInstance` (public method, near `cmdPlay`):
|
||
|
||
```ts
|
||
/**
|
||
* Play a single resolved song immediately. Honors config.playKeepsQueue:
|
||
* when true (and the queue isn't empty), insert-after-current + jump so the
|
||
* rest of the queue survives and continues after this track; otherwise clear
|
||
* the queue and play only this song (legacy behavior). Shared by chat !play
|
||
* and the web /play-song route so the toggle lives in one place.
|
||
*/
|
||
async playSingleSong(song: QueuedSong, requesterName?: string): Promise<boolean> {
|
||
const s = this.withRequester(song, requesterName);
|
||
if (this.config.playKeepsQueue && !this.queue.isEmpty()) {
|
||
// Take manual control (stop FM auto-refill) but KEEP the queued songs.
|
||
const insertedAt =
|
||
this.queue.getCurrentIndex() < 0
|
||
? this.queue.size()
|
||
: this.queue.getCurrentIndex() + 1;
|
||
this.disableFmMode();
|
||
this.queue.addNext(s);
|
||
this.queue.playAt(insertedAt);
|
||
} else {
|
||
this.queue.clear();
|
||
this.disableFmMode();
|
||
this.queue.add(s);
|
||
this.queue.play();
|
||
// Only the clear path replaces the queue, so only it sweeps prior local uploads.
|
||
this.cleanupQueuedLocalSongs?.("queue_replaced");
|
||
}
|
||
this.player.resetFailures();
|
||
return this.resolveAndPlay(this.queue.current()!);
|
||
}
|
||
```
|
||
|
||
Then in `cmdPlay`, replace the clear+add+play block:
|
||
|
||
```ts
|
||
this.queue.clear();
|
||
this.disableFmMode();
|
||
this.queue.add(this.withRequester(song0, requesterName));
|
||
this.queue.play();
|
||
// Reset failure counter on user-initiated play
|
||
...resolveAndPlay(...)
|
||
```
|
||
|
||
with:
|
||
|
||
```ts
|
||
await this.playSingleSong(song0, requesterName);
|
||
```
|
||
|
||
(Preserve the surrounding `cmdPlay` return string, e.g. `Now playing: ...`, and any `resetFailures` already inside `playSingleSong`. Remove the now-duplicated `resolveAndPlay` call from `cmdPlay`.)
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/bot/instance.test.ts -t "playSingleSong" --no-file-parallelism`
|
||
Expected: PASS. Then run the whole file to catch regressions:
|
||
Run: `npx vitest run src/bot/instance.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/bot/instance.ts src/bot/instance.test.ts
|
||
git commit -m "feat(player): playSingleSong seam honoring playKeepsQueue (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Web `/play-song` funnels through `playSingleSong`
|
||
|
||
**Files:**
|
||
- Modify: `src/web/api/player.ts` (`/:botId/play-song` at ~461)
|
||
- Test: `src/web/api/player.test.ts` (or the existing player-route test file)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `bot.playSingleSong(song, requesterName)` from Task 4.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
In the player-route test file, add a test that with `playKeepsQueue = true` a `/play-song` call preserves an existing queued song. Mirror the existing route-test harness (a fake `bot` exposing `getQueueManager`, `playSingleSong`, `runExclusive`). Assert the fake `bot.playSingleSong` is invoked with the song + requester and the pre-existing queue entry is retained. If the route tests use a real `BotInstance` stub, assert queue contents as in Task 4.
|
||
|
||
```ts
|
||
it("/play-song routes through playSingleSong (keeps queue when enabled)", async () => {
|
||
const bot = makeRouteBot({ playKeepsQueue: true });
|
||
bot.getQueueManager().add({ id: "a", platform: "netease", name: "A", artist: "", album: "", coverUrl: "", duration: 5 });
|
||
bot.getQueueManager().play();
|
||
await request(app).post(`/api/player/${BOT}/play-song`).send({ song: { id: "b", platform: "netease", name: "B" } });
|
||
expect(bot.getQueueManager().list().map((s) => s.id)).toContain("a");
|
||
expect(bot.getQueueManager().current()?.id).toBe("b");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/web/api/player.test.ts --no-file-parallelism`
|
||
Expected: FAIL (route still clears).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Replace the body of `/:botId/play-song` (the `queue.clear(); queue.add(...); queue.play(); resolveAndPlay; cleanupQueuedLocalSongs` block) with a single call, wrapped in `runExclusive` for consistency with `/play-now-song`:
|
||
|
||
```ts
|
||
const body = await bot.runExclusive(async () => {
|
||
const ok = await bot.playSingleSong(
|
||
{ ...song },
|
||
requesterName(req),
|
||
);
|
||
return ok
|
||
? { ok: true, message: `正在播放:${song.name || "Unknown"} - ${song.artist || "Unknown"}` }
|
||
: { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
|
||
});
|
||
res.json(body);
|
||
```
|
||
|
||
(The `cleanupQueuedLocalSongs` sweep now lives inside `playSingleSong`'s clear branch — do NOT also call it here, or it would delete retained local uploads in keep-queue mode.)
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/web/api/player.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/web/api/player.ts src/web/api/player.test.ts
|
||
git commit -m "feat(web): /play-song honors playKeepsQueue via playSingleSong (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
## Stage 3 — Feature 1: named save/load
|
||
|
||
### Task 6: DB — `saved_queues` table + methods
|
||
|
||
**Files:**
|
||
- Modify: `src/data/database.ts` (interfaces, `migrateSchema`, `initTables`, `createDatabase` body, `BotDatabase`)
|
||
- Test: `src/data/database.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (exports + `BotDatabase` methods):
|
||
|
||
```ts
|
||
export const SHARED_QUEUE_OWNER = "__shared__";
|
||
export type StoredSong = Omit<QueuedSong, "url">; // imported from ../audio/queue.js
|
||
export interface SavedQueueMeta {
|
||
id: number; ownerId: string; name: string; songCount: number;
|
||
createdAt: string; updatedAt: string;
|
||
}
|
||
export interface SavedQueue extends SavedQueueMeta { songs: StoredSong[]; }
|
||
|
||
// on BotDatabase:
|
||
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; // upsert by (ownerId,name)
|
||
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
||
getSavedQueue(id: number): SavedQueue | null;
|
||
deleteSavedQueue(id: number): boolean;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `src/data/database.test.ts`:
|
||
|
||
```ts
|
||
import { SHARED_QUEUE_OWNER } from "./database.js";
|
||
|
||
const song = (id: string) => ({ id, name: id, artist: "", album: "", platform: "netease" as const, coverUrl: "", duration: 1 });
|
||
|
||
describe("saved_queues", () => {
|
||
it("upserts by (ownerId,name) and returns songs", () => {
|
||
const db = createDatabase(":memory:");
|
||
db.saveQueue("u1", "night", [song("a"), song("b")]);
|
||
const again = db.saveQueue("u1", "night", [song("c")]); // overwrite
|
||
expect(again.songCount).toBe(1);
|
||
const list = db.listSavedQueues("u1", false);
|
||
expect(list).toHaveLength(1);
|
||
const full = db.getSavedQueue(again.id)!;
|
||
expect(full.songs.map((s) => s.id)).toEqual(["c"]);
|
||
db.close();
|
||
});
|
||
|
||
it("lists own + shared when includeShared, own-only otherwise", () => {
|
||
const db = createDatabase(":memory:");
|
||
db.saveQueue("u1", "mine", [song("a")]);
|
||
db.saveQueue(SHARED_QUEUE_OWNER, "party", [song("b")]);
|
||
expect(db.listSavedQueues("u1", false).map((q) => q.name)).toEqual(["mine"]);
|
||
expect(db.listSavedQueues("u1", true).map((q) => q.name).sort()).toEqual(["mine", "party"]);
|
||
db.close();
|
||
});
|
||
|
||
it("caps songs at 1000 and queues at 50", () => {
|
||
const db = createDatabase(":memory:");
|
||
expect(() => db.saveQueue("u1", "big", Array.from({ length: 1001 }, (_, i) => song("s" + i)))).toThrow(/1000/);
|
||
for (let i = 0; i < 50; i++) db.saveQueue("u1", "q" + i, [song("a")]);
|
||
expect(() => db.saveQueue("u1", "q50", [song("a")])).toThrow(/50/);
|
||
db.close();
|
||
});
|
||
|
||
it("deletes and degrades a corrupt blob to empty", () => {
|
||
const db = createDatabase(":memory:");
|
||
const q = db.saveQueue("u1", "x", [song("a")]);
|
||
db.db.prepare("UPDATE saved_queues SET songs='not json' WHERE id=?").run(q.id);
|
||
expect(db.getSavedQueue(q.id)!.songs).toEqual([]);
|
||
expect(db.deleteSavedQueue(q.id)).toBe(true);
|
||
expect(db.getSavedQueue(q.id)).toBeNull();
|
||
db.close();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/data/database.test.ts --no-file-parallelism`
|
||
Expected: FAIL — `saveQueue` undefined / `SHARED_QUEUE_OWNER` undefined.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
At the top of `src/data/database.ts` add the import + exports:
|
||
|
||
```ts
|
||
import type { QueuedSong } from "../audio/queue.js";
|
||
|
||
export const SHARED_QUEUE_OWNER = "__shared__";
|
||
export const MAX_SAVED_QUEUES = 50;
|
||
export const MAX_QUEUE_SONGS = 1000;
|
||
export type StoredSong = Omit<QueuedSong, "url">;
|
||
export interface SavedQueueMeta {
|
||
id: number; ownerId: string; name: string; songCount: number;
|
||
createdAt: string; updatedAt: string;
|
||
}
|
||
export interface SavedQueue extends SavedQueueMeta { songs: StoredSong[]; }
|
||
```
|
||
|
||
Add the four methods to the `BotDatabase` interface (signatures above).
|
||
|
||
In `initTables`, add the table (inside the `db.exec` template):
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS saved_queues (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ownerId TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
songs TEXT NOT NULL,
|
||
songCount INTEGER NOT NULL DEFAULT 0,
|
||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE(ownerId, name)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId);
|
||
```
|
||
|
||
(No `migrateSchema` entry needed — `CREATE TABLE IF NOT EXISTS` handles fresh + existing DBs.)
|
||
|
||
In `createDatabase`, implement the methods (add prepared statements + return-object methods). A safe song-parse helper:
|
||
|
||
```ts
|
||
const parseSongs = (raw: string): StoredSong[] => {
|
||
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : []; }
|
||
catch { return []; }
|
||
};
|
||
const rowToMeta = (r: any): SavedQueueMeta => ({
|
||
id: r.id, ownerId: r.ownerId, name: r.name, songCount: r.songCount,
|
||
createdAt: r.createdAt, updatedAt: r.updatedAt,
|
||
});
|
||
```
|
||
|
||
Methods on the returned object:
|
||
|
||
```ts
|
||
saveQueue(ownerId, name, songs) {
|
||
if (songs.length > MAX_QUEUE_SONGS) throw new Error(`queue exceeds ${MAX_QUEUE_SONGS} songs`);
|
||
const stripped = songs.map(({ url: _url, ...s }: any) => s);
|
||
const json = JSON.stringify(stripped);
|
||
const existing = db.prepare("SELECT id FROM saved_queues WHERE ownerId=? AND name=?").get(ownerId, name) as { id: number } | undefined;
|
||
if (!existing) {
|
||
const count = (db.prepare("SELECT COUNT(*) c FROM saved_queues WHERE ownerId=?").get(ownerId) as { c: number }).c;
|
||
if (count >= MAX_SAVED_QUEUES) throw new Error(`owner exceeds ${MAX_SAVED_QUEUES} saved queues`);
|
||
}
|
||
db.prepare(`
|
||
INSERT INTO saved_queues (ownerId, name, songs, songCount)
|
||
VALUES (@ownerId, @name, @songs, @songCount)
|
||
ON CONFLICT(ownerId, name) DO UPDATE SET
|
||
songs=excluded.songs, songCount=excluded.songCount, updatedAt=datetime('now')
|
||
`).run({ ownerId, name, songs: json, songCount: stripped.length });
|
||
const row = db.prepare("SELECT * FROM saved_queues WHERE ownerId=? AND name=?").get(ownerId, name);
|
||
return { ...rowToMeta(row), songs: stripped };
|
||
},
|
||
listSavedQueues(ownerId, includeShared) {
|
||
const rows = includeShared
|
||
? db.prepare("SELECT id,ownerId,name,songCount,createdAt,updatedAt FROM saved_queues WHERE ownerId=? OR ownerId=? ORDER BY updatedAt DESC").all(ownerId, SHARED_QUEUE_OWNER)
|
||
: db.prepare("SELECT id,ownerId,name,songCount,createdAt,updatedAt FROM saved_queues WHERE ownerId=? ORDER BY updatedAt DESC").all(ownerId);
|
||
return (rows as any[]).map(rowToMeta);
|
||
},
|
||
getSavedQueue(id) {
|
||
const row = db.prepare("SELECT * FROM saved_queues WHERE id=?").get(id) as any;
|
||
if (!row) return null;
|
||
return { ...rowToMeta(row), songs: parseSongs(row.songs) };
|
||
},
|
||
deleteSavedQueue(id) {
|
||
return db.prepare("DELETE FROM saved_queues WHERE id=?").run(id).changes > 0;
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/data/database.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/data/database.ts src/data/database.test.ts
|
||
git commit -m "feat(db): saved_queues table + save/list/get/delete with caps (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `/api/saved-queues` router + mount + feature gate
|
||
|
||
**Files:**
|
||
- Create: `src/web/api/saved-queues.ts`, `src/web/api/saved-queues.test.ts`
|
||
- Modify: `src/web/server.ts` (import + mount at `/api/saved-queues`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BotDatabase` saved-queue methods (Task 6); `BotManager` (`getBot`); a `() => boolean` feature-gate reading `config.savedQueuesEnabled`; `bot.getQueueManager()`, `bot.playSingleSong`/queue load helpers.
|
||
- Produces: `createSavedQueuesRouter(database, botManager, isEnabled, logger): Router`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/web/api/saved-queues.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import express from "express";
|
||
import request from "supertest";
|
||
import { createDatabase } from "../../data/database.js";
|
||
import { createSavedQueuesRouter } from "./saved-queues.js";
|
||
|
||
function mount(enabled: boolean) {
|
||
const db = createDatabase(":memory:");
|
||
const bot = {
|
||
getQueueManager: () => ({ list: () => [{ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 1 }] }),
|
||
loadSavedQueue: (_songs: any[], _mode: string, _by?: string) => {},
|
||
};
|
||
const botManager = { getBot: (_id: string) => bot } as any;
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use((req, _res, next) => { (req as any).user = { id: "u1", username: "alice" }; next(); });
|
||
app.use("/api/saved-queues", createSavedQueuesRouter(db, botManager, () => enabled, console as any));
|
||
return { app, db };
|
||
}
|
||
|
||
describe("saved-queues router", () => {
|
||
it("403s all routes when feature disabled", async () => {
|
||
const { app } = mount(false);
|
||
expect((await request(app).get("/api/saved-queues")).status).toBe(403);
|
||
expect((await request(app).post("/api/saved-queues").send({ botId: "b", name: "x" })).status).toBe(403);
|
||
});
|
||
|
||
it("saves current queue (private) and lists it", async () => {
|
||
const { app } = mount(true);
|
||
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "night" });
|
||
expect(save.status).toBe(200);
|
||
const list = await request(app).get("/api/saved-queues");
|
||
expect(list.body.queues.map((q: any) => q.name)).toContain("night");
|
||
});
|
||
|
||
it("saves shared when shared:true and rejects loading another user's private queue", async () => {
|
||
const { app, db } = mount(true);
|
||
db.saveQueue("someoneElse", "private", [{ id: "z" } as any]);
|
||
const other = db.listSavedQueues("someoneElse", false)[0];
|
||
const load = await request(app).post(`/api/saved-queues/${other.id}/load`).send({ botId: "b", mode: "replace" });
|
||
expect(load.status).toBe(404);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/web/api/saved-queues.test.ts --no-file-parallelism`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement the router**
|
||
|
||
Create `src/web/api/saved-queues.ts`:
|
||
|
||
```ts
|
||
import { Router } from "express";
|
||
import type { BotDatabase } from "../../data/database.js";
|
||
import { SHARED_QUEUE_OWNER } from "../../data/database.js";
|
||
import type { BotManager } from "../../bot/manager.js";
|
||
import type { Logger } from "../../logger.js";
|
||
|
||
export function createSavedQueuesRouter(
|
||
database: BotDatabase,
|
||
botManager: BotManager,
|
||
isEnabled: () => boolean,
|
||
logger: Logger,
|
||
): Router {
|
||
const router = Router();
|
||
|
||
// Feature gate — inert when savedQueuesEnabled is false.
|
||
router.use((_req, res, next) => {
|
||
if (!isEnabled()) { res.status(403).json({ error: "此功能未启用" }); return; }
|
||
next();
|
||
});
|
||
|
||
// GET / — own + shared (meta only)
|
||
router.get("/", (req, res) => {
|
||
const userId = (req as any).user.id;
|
||
res.json({ queues: database.listSavedQueues(userId, true) });
|
||
});
|
||
|
||
// POST / — snapshot the bot's current queue, upsert
|
||
router.post("/", (req, res) => {
|
||
const userId = (req as any).user.id;
|
||
const { botId, name, shared } = req.body ?? {};
|
||
if (typeof name !== "string" || !name.trim() || !botId) {
|
||
res.status(400).json({ error: "botId and name are required" }); return;
|
||
}
|
||
const bot = botManager.getBot(botId);
|
||
if (!bot) { res.status(404).json({ error: "bot not found" }); return; }
|
||
const songs = bot.getQueueManager().list();
|
||
if (songs.length === 0) { res.status(400).json({ error: "队列为空,无法保存" }); return; }
|
||
const ownerId = shared === true ? SHARED_QUEUE_OWNER : userId;
|
||
try {
|
||
const saved = database.saveQueue(ownerId, name.trim(), songs as any);
|
||
res.json({ queue: { id: saved.id, ownerId, name: saved.name, songCount: saved.songCount } });
|
||
} catch (err) {
|
||
res.status(400).json({ error: (err as Error).message });
|
||
}
|
||
});
|
||
|
||
// POST /:id/load — replace | append into a bot
|
||
router.post("/:id/load", async (req, res) => {
|
||
const userId = (req as any).user.id;
|
||
const username = (req as any).user.username as string | undefined;
|
||
const id = parseInt(req.params.id, 10);
|
||
const { botId, mode } = req.body ?? {};
|
||
if (Number.isNaN(id) || !botId) { res.status(400).json({ error: "invalid id/botId" }); return; }
|
||
const sq = database.getSavedQueue(id);
|
||
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
|
||
res.status(404).json({ error: "not found" }); return;
|
||
}
|
||
const bot = botManager.getBot(botId);
|
||
if (!bot) { res.status(404).json({ error: "bot not found" }); return; }
|
||
const loadMode = mode === "append" ? "append" : "replace";
|
||
await bot.loadSavedQueue(sq.songs, loadMode, username || "游客");
|
||
res.json({ ok: true, loaded: sq.songs.length, mode: loadMode });
|
||
});
|
||
|
||
// DELETE /:id — own or shared only
|
||
router.delete("/:id", (req, res) => {
|
||
const userId = (req as any).user.id;
|
||
const id = parseInt(req.params.id, 10);
|
||
const sq = database.getSavedQueue(id);
|
||
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
|
||
res.status(404).json({ error: "not found" }); return;
|
||
}
|
||
database.deleteSavedQueue(id);
|
||
logger.info({ userId, id }, "saved queue deleted");
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
return router;
|
||
}
|
||
```
|
||
|
||
Add `loadSavedQueue` to `BotInstance` (used here + by chat in Task 8):
|
||
|
||
```ts
|
||
/** Load a saved list into this bot's queue. replace: clear + play; append: add to end. */
|
||
async loadSavedQueue(songs: StoredSong[], mode: "replace" | "append", requesterName?: string): Promise<void> {
|
||
const tagged = songs.map((s) => this.withRequester(s as QueuedSong, requesterName));
|
||
if (mode === "replace") {
|
||
this.queue.clear();
|
||
this.disableFmMode();
|
||
for (const s of tagged) this.queue.add(s);
|
||
this.cleanupQueuedLocalSongs?.("queue_replaced");
|
||
const first = this.queue.play();
|
||
this.player.resetFailures();
|
||
if (first) await this.resolveAndPlay(first);
|
||
} else {
|
||
const wasIdle = this.player.getState() === "idle";
|
||
const startAt = this.queue.size();
|
||
for (const s of tagged) this.queue.add(s);
|
||
if (wasIdle && this.queue.size() > 0) {
|
||
this.queue.playAt(startAt);
|
||
this.player.resetFailures();
|
||
await this.resolveAndPlay(this.queue.current()!);
|
||
}
|
||
}
|
||
this.emit("stateChange");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Mount in `server.ts`**
|
||
|
||
Add import near the other API imports:
|
||
|
||
```ts
|
||
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
||
```
|
||
|
||
Mount alongside favorites (both `requireNotGuest`):
|
||
|
||
```ts
|
||
app.use("/api/saved-queues", requireNotGuest, createSavedQueuesRouter(
|
||
options.database,
|
||
options.botManager,
|
||
() => options.config.savedQueuesEnabled,
|
||
logger,
|
||
));
|
||
```
|
||
|
||
(Confirm the exact names `options.botManager` / `options.config` / `options.database` from the surrounding `createPlayerRouter`/`createFavoritesRouter` mounts.)
|
||
|
||
- [ ] **Step 5: Run + commit**
|
||
|
||
Run: `npx vitest run src/web/api/saved-queues.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
```bash
|
||
git add src/web/api/saved-queues.ts src/web/api/saved-queues.test.ts src/web/server.ts src/bot/instance.ts
|
||
git commit -m "feat(web): /api/saved-queues router + loadSavedQueue (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Chat commands `!save` / `!load` / `!queues`
|
||
|
||
**Files:**
|
||
- Modify: `src/bot/instance.ts` (`executeCommand` switch ~604-656, add `cmdSave`/`cmdLoad`/`cmdQueues`, `cmdHelp` ~1361)
|
||
- Test: `src/bot/instance.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `this.database` saved-queue methods, `SHARED_QUEUE_OWNER`, `this.config.savedQueuesEnabled`, `this.loadSavedQueue` (Task 7), `cmd.flags.has("a")` for append.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `src/bot/instance.test.ts`:
|
||
|
||
```ts
|
||
describe("chat save/load/queues", () => {
|
||
it("replies feature-disabled when savedQueuesEnabled is false", async () => {
|
||
const bot = makeBot({ savedQueuesEnabled: false });
|
||
expect(await bot.executeCommand(parseCommand("!save night", "!")!)).toBe("此功能未启用");
|
||
});
|
||
|
||
it("saves the current queue to the shared bucket and loads it back", async () => {
|
||
const bot = makeBot({ savedQueuesEnabled: true });
|
||
const q = bot.getQueueManager();
|
||
q.add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 1 });
|
||
await bot.executeCommand(parseCommand("!save night", "!")!);
|
||
expect(bot.database.listSavedQueues(SHARED_QUEUE_OWNER, false).map((x) => x.name)).toContain("night");
|
||
const list = await bot.executeCommand(parseCommand("!queues", "!")!);
|
||
expect(list).toContain("night");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/bot/instance.test.ts -t "chat save/load" --no-file-parallelism`
|
||
Expected: FAIL — Unknown command.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In the `executeCommand` switch, add cases (before `default`):
|
||
|
||
```ts
|
||
case "save":
|
||
return this.cmdSaveQueue(cmd);
|
||
case "load":
|
||
return this.cmdLoadQueue(cmd);
|
||
case "queues":
|
||
return this.cmdListQueues();
|
||
```
|
||
|
||
Add the methods:
|
||
|
||
```ts
|
||
private savedQueuesGuard(): string | null {
|
||
return this.config.savedQueuesEnabled ? null : "此功能未启用";
|
||
}
|
||
|
||
private cmdSaveQueue(cmd: ParsedCommand): string {
|
||
const off = this.savedQueuesGuard(); if (off) return off;
|
||
const name = cmd.args.trim();
|
||
if (!name) return `Usage: ${this.config.commandPrefix}save <名称>`;
|
||
const songs = this.queue.list();
|
||
if (songs.length === 0) return "队列为空,无法保存";
|
||
try {
|
||
const saved = this.database.saveQueue(SHARED_QUEUE_OWNER, name, songs);
|
||
return `已保存队列「${name}」(${saved.songCount} 首)`;
|
||
} catch (err) {
|
||
return `保存失败:${(err as Error).message}`;
|
||
}
|
||
}
|
||
|
||
private async cmdLoadQueue(cmd: ParsedCommand): Promise<string> {
|
||
const off = this.savedQueuesGuard(); if (off) return off;
|
||
const name = cmd.args.trim();
|
||
if (!name) return `Usage: ${this.config.commandPrefix}load [-a] <名称>`;
|
||
const owned = this.database.listSavedQueues(SHARED_QUEUE_OWNER, false).find((q) => q.name === name);
|
||
if (!owned) return `找不到已保存队列「${name}」`;
|
||
const full = this.database.getSavedQueue(owned.id);
|
||
if (!full) return `找不到已保存队列「${name}」`;
|
||
const mode = cmd.flags.has("a") ? "append" : "replace";
|
||
await this.loadSavedQueue(full.songs, mode);
|
||
return mode === "append"
|
||
? `已追加「${name}」(${full.songs.length} 首)到队列`
|
||
: `已加载「${name}」(${full.songs.length} 首)`;
|
||
}
|
||
|
||
private cmdListQueues(): string {
|
||
const off = this.savedQueuesGuard(); if (off) return off;
|
||
const list = this.database.listSavedQueues(SHARED_QUEUE_OWNER, false);
|
||
if (list.length === 0) return "还没有已保存的队列";
|
||
return ["已保存队列:", ...list.map((q) => `• ${q.name}(${q.songCount} 首)`)].join("\n");
|
||
}
|
||
```
|
||
|
||
In `cmdHelp`, add before `${p}help`:
|
||
|
||
```ts
|
||
`${p}save <名称> — Save current queue`,
|
||
`${p}load [-a] <名称> — Load a saved queue (-a appends)`,
|
||
`${p}queues — List saved queues`,
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/bot/instance.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/bot/instance.ts src/bot/instance.test.ts
|
||
git commit -m "feat(chat): !save / !load [-a] / !queues commands (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: WebUI Saved Queues page
|
||
|
||
**Files:**
|
||
- Create: `web/src/composables/savedQueues.ts` + `savedQueues.test.ts` (pure helper), `web/src/composables/useSavedQueues.ts`, `web/src/views/SavedQueues.vue`
|
||
- Modify: `web/src/router` (route), the nav/menu component (entry, shown only when feature enabled)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `/api/saved-queues` (Task 7); the settings store/flag `savedQueuesEnabled` for nav gating.
|
||
|
||
- [ ] **Step 1: Write the failing helper test**
|
||
|
||
Create `web/src/composables/savedQueues.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { sortQueues, isShared } from "./savedQueues";
|
||
|
||
describe("savedQueues helper", () => {
|
||
it("flags shared owner", () => {
|
||
expect(isShared({ ownerId: "__shared__" } as any)).toBe(true);
|
||
expect(isShared({ ownerId: "u1" } as any)).toBe(false);
|
||
});
|
||
it("sorts by updatedAt desc", () => {
|
||
const out = sortQueues([
|
||
{ id: 1, updatedAt: "2026-01-01" }, { id: 2, updatedAt: "2026-02-01" },
|
||
] as any);
|
||
expect(out.map((q) => q.id)).toEqual([2, 1]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `cd web && npx vitest run src/composables/savedQueues.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement the helper**
|
||
|
||
Create `web/src/composables/savedQueues.ts`:
|
||
|
||
```ts
|
||
export interface SavedQueueMeta {
|
||
id: number; ownerId: string; name: string; songCount: number;
|
||
createdAt: string; updatedAt: string;
|
||
}
|
||
export const SHARED_OWNER = "__shared__";
|
||
export function isShared(q: SavedQueueMeta): boolean { return q.ownerId === SHARED_OWNER; }
|
||
export function sortQueues(qs: SavedQueueMeta[]): SavedQueueMeta[] {
|
||
return [...qs].sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run helper test**
|
||
|
||
Run: `cd web && npx vitest run src/composables/savedQueues.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Build the composable + page + nav**
|
||
|
||
Create `web/src/composables/useSavedQueues.ts` (axios calls mirroring `useSpotifySettings`/favorites): `list()`, `save(botId, name, shared)`, `load(id, botId, mode)`, `remove(id)`.
|
||
|
||
Create `web/src/views/SavedQueues.vue`: a page that on mount calls `list()`, shows a **Save current queue** control (text input + 共享 checkbox + bot selector reusing the app's current-bot scope), and a list of entries (name / song count / shared badge via `isShared`) with **Load** (replace), **Append**, **Delete** buttons. Reuse existing list/`SongCard`-style CSS.
|
||
|
||
Register the route (path `/saved-queues`, component `SavedQueues.vue`) in the router, and add a nav entry that is rendered only when the settings flag `savedQueuesEnabled` is true (read the same settings the Settings page loads — reuse the existing settings store/composable; if none, fetch `/api/bot/settings` once in the nav or an app-level store).
|
||
|
||
- [ ] **Step 6: Build check**
|
||
|
||
Run: `cd web && npm run build`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add web/src/composables/savedQueues.ts web/src/composables/savedQueues.test.ts web/src/composables/useSavedQueues.ts web/src/views/SavedQueues.vue web/src/router* web/src/**/*Nav* 2>/dev/null; git add -A web/src
|
||
git commit -m "feat(web): Saved Queues page + nav gated on savedQueuesEnabled (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
## Stage 4 — Feature 2: auto-restore live queue
|
||
|
||
### Task 10: `PlayQueue.snapshot()` / `restore()`
|
||
|
||
**Files:**
|
||
- Modify: `src/audio/queue.ts`
|
||
- Test: `src/audio/queue.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
|
||
```ts
|
||
export interface QueueSnapshot {
|
||
songs: Omit<QueuedSong, "url">[];
|
||
currentIndex: number;
|
||
mode: PlayMode;
|
||
}
|
||
// on PlayQueue:
|
||
snapshot(): QueueSnapshot;
|
||
restore(s: QueueSnapshot): void;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `src/audio/queue.test.ts`:
|
||
|
||
```ts
|
||
it("snapshot/restore round-trips songs, index, and mode", () => {
|
||
const q = new PlayQueue();
|
||
q.add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 1, url: "http://x" });
|
||
q.add({ id: "b", name: "B", artist: "", album: "", platform: "qq", coverUrl: "", duration: 2 });
|
||
q.setMode(PlayMode.Loop);
|
||
q.play(); q.next(); // current = index 1
|
||
const snap = q.snapshot();
|
||
expect(snap.currentIndex).toBe(1);
|
||
expect(snap.mode).toBe(PlayMode.Loop);
|
||
expect((snap.songs[0] as any).url).toBeUndefined(); // url stripped
|
||
|
||
const q2 = new PlayQueue();
|
||
q2.restore(snap);
|
||
expect(q2.list().map((s) => s.id)).toEqual(["a", "b"]);
|
||
expect(q2.getCurrentIndex()).toBe(1);
|
||
expect(q2.getMode()).toBe(PlayMode.Loop);
|
||
expect(q2.current()?.id).toBe("b");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/audio/queue.test.ts --no-file-parallelism`
|
||
Expected: FAIL — `snapshot` undefined.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Add the interface export near `QueuedSong`, and these methods to `PlayQueue`:
|
||
|
||
```ts
|
||
snapshot(): QueueSnapshot {
|
||
return {
|
||
songs: this.songs.map(({ url: _url, ...s }) => s),
|
||
currentIndex: this.currentIndex,
|
||
mode: this.mode,
|
||
};
|
||
}
|
||
|
||
restore(s: QueueSnapshot): void {
|
||
this.songs = s.songs.map((song) => ({ ...song }));
|
||
this.mode = s.mode;
|
||
this.currentIndex =
|
||
s.currentIndex >= 0 && s.currentIndex < this.songs.length ? s.currentIndex : -1;
|
||
// Rebuild derived state consistently for the restored position.
|
||
this.playedIndices = new Set(this.currentIndex >= 0 ? [this.currentIndex] : []);
|
||
this.history = [];
|
||
this.forwardStack = [];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/audio/queue.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/audio/queue.ts src/audio/queue.test.ts
|
||
git commit -m "feat(queue): snapshot/restore for cross-restart persistence (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: DB — `queue_state` table + methods
|
||
|
||
**Files:**
|
||
- Modify: `src/data/database.ts`
|
||
- Test: `src/data/database.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
|
||
```ts
|
||
export interface QueueStateRow {
|
||
botId: string; songs: StoredSong[]; currentIndex: number;
|
||
mode: string; isFmMode: boolean; fmPlatform: string;
|
||
}
|
||
// on BotDatabase:
|
||
saveQueueState(state: QueueStateRow): void; // upsert by botId
|
||
getQueueState(botId: string): QueueStateRow | null;
|
||
clearQueueState(botId: string): void;
|
||
```
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `src/data/database.test.ts`:
|
||
|
||
```ts
|
||
describe("queue_state", () => {
|
||
it("upserts, reads back, and clears per bot", () => {
|
||
const db = createDatabase(":memory:");
|
||
db.saveQueueState({ botId: "b1", songs: [{ id: "a" } as any], currentIndex: 0, mode: "loop", isFmMode: true, fmPlatform: "netease" });
|
||
db.saveQueueState({ botId: "b1", songs: [{ id: "a" } as any, { id: "b" } as any], currentIndex: 1, mode: "seq", isFmMode: false, fmPlatform: "" });
|
||
const st = db.getQueueState("b1")!;
|
||
expect(st.songs.map((s) => s.id)).toEqual(["a", "b"]);
|
||
expect(st.currentIndex).toBe(1);
|
||
expect(st.isFmMode).toBe(false);
|
||
db.clearQueueState("b1");
|
||
expect(db.getQueueState("b1")).toBeNull();
|
||
db.close();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/data/database.test.ts -t "queue_state" --no-file-parallelism`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Add `QueueStateRow` export + the three `BotDatabase` methods. Add the table to `initTables`:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS queue_state (
|
||
botId TEXT PRIMARY KEY,
|
||
songs TEXT NOT NULL,
|
||
currentIndex INTEGER NOT NULL,
|
||
mode TEXT NOT NULL,
|
||
isFmMode INTEGER NOT NULL DEFAULT 0,
|
||
fmPlatform TEXT NOT NULL DEFAULT '',
|
||
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
```
|
||
|
||
In `createDatabase`:
|
||
|
||
```ts
|
||
saveQueueState(state) {
|
||
db.prepare(`
|
||
INSERT INTO queue_state (botId, songs, currentIndex, mode, isFmMode, fmPlatform, updatedAt)
|
||
VALUES (@botId, @songs, @currentIndex, @mode, @isFmMode, @fmPlatform, datetime('now'))
|
||
ON CONFLICT(botId) DO UPDATE SET
|
||
songs=excluded.songs, currentIndex=excluded.currentIndex, mode=excluded.mode,
|
||
isFmMode=excluded.isFmMode, fmPlatform=excluded.fmPlatform, updatedAt=datetime('now')
|
||
`).run({
|
||
botId: state.botId, songs: JSON.stringify(state.songs), currentIndex: state.currentIndex,
|
||
mode: state.mode, isFmMode: state.isFmMode ? 1 : 0, fmPlatform: state.fmPlatform,
|
||
});
|
||
},
|
||
getQueueState(botId) {
|
||
const r = db.prepare("SELECT * FROM queue_state WHERE botId=?").get(botId) as any;
|
||
if (!r) return null;
|
||
return {
|
||
botId: r.botId, songs: parseSongs(r.songs), currentIndex: r.currentIndex,
|
||
mode: r.mode, isFmMode: r.isFmMode === 1, fmPlatform: r.fmPlatform,
|
||
};
|
||
},
|
||
clearQueueState(botId) {
|
||
db.prepare("DELETE FROM queue_state WHERE botId=?").run(botId);
|
||
},
|
||
```
|
||
|
||
(Reuse the `parseSongs` helper from Task 6.)
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/data/database.test.ts -t "queue_state" --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/data/database.ts src/data/database.test.ts
|
||
git commit -m "feat(db): queue_state table for live-queue persistence (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: BotInstance snapshot-on-change + restore-on-connect
|
||
|
||
**Files:**
|
||
- Modify: `src/bot/instance.ts` (constructor/emit path, `connect`/`connected` handler)
|
||
- Test: `src/bot/instance.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `this.database.saveQueueState/getQueueState/clearQueueState`, `this.queue.snapshot()/restore()`, `this.config.savedQueuesEnabled`, `this.isFmMode`, `this.fmProvider`, `this.resolveAndPlay`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `src/bot/instance.test.ts`:
|
||
|
||
```ts
|
||
describe("live-queue persistence", () => {
|
||
it("persists a snapshot on stateChange when enabled", async () => {
|
||
const bot = makeBot({ savedQueuesEnabled: true });
|
||
bot.getQueueManager().add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 1 });
|
||
bot.getQueueManager().play();
|
||
bot["persistQueueSnapshot"](); // call the (debounced) writer's inner sync fn directly
|
||
const st = bot.database.getQueueState(bot.id)!;
|
||
expect(st.songs.map((s: any) => s.id)).toEqual(["a"]);
|
||
});
|
||
|
||
it("does NOT persist when feature disabled", () => {
|
||
const bot = makeBot({ savedQueuesEnabled: false });
|
||
bot.getQueueManager().add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 1 });
|
||
bot["persistQueueSnapshot"]();
|
||
expect(bot.database.getQueueState(bot.id)).toBeNull();
|
||
});
|
||
|
||
it("restores and resumes on restore()", async () => {
|
||
const src = makeBot({ savedQueuesEnabled: true });
|
||
src.getQueueManager().add({ id: "a", name: "A", artist: "", album: "", platform: "netease", coverUrl: "", duration: 1 });
|
||
src.getQueueManager().play();
|
||
src["persistQueueSnapshot"]();
|
||
|
||
const bot = makeBot({ savedQueuesEnabled: true }, src.database); // share DB
|
||
const spy = vi.spyOn(bot, "resolveAndPlay").mockResolvedValue(true);
|
||
await bot["restoreQueueFromSnapshot"]();
|
||
expect(bot.getQueueManager().list().map((s) => s.id)).toEqual(["a"]);
|
||
expect(spy).toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
(Extend `makeBot` to accept an optional shared `database`. The two private methods are called via bracket access in tests.)
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/bot/instance.test.ts -t "live-queue persistence" --no-file-parallelism`
|
||
Expected: FAIL — methods undefined.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Add a debounced snapshot writer + a restore method to `BotInstance`:
|
||
|
||
```ts
|
||
private snapshotTimer: NodeJS.Timeout | null = null;
|
||
|
||
/** Synchronous snapshot writer (debounced via scheduleQueueSnapshot). */
|
||
private persistQueueSnapshot(): void {
|
||
if (!this.config.savedQueuesEnabled) return;
|
||
try {
|
||
const snap = this.queue.snapshot();
|
||
if (snap.songs.length === 0) {
|
||
this.database.clearQueueState(this.id);
|
||
return;
|
||
}
|
||
this.database.saveQueueState({
|
||
botId: this.id,
|
||
songs: snap.songs,
|
||
currentIndex: snap.currentIndex,
|
||
mode: snap.mode,
|
||
isFmMode: this.isFmMode,
|
||
fmPlatform: this.isFmMode && this.fmProvider ? this.fmProvider.platform : "",
|
||
});
|
||
} catch (err) {
|
||
this.logger.warn({ err }, "queue snapshot persist failed");
|
||
}
|
||
}
|
||
|
||
private scheduleQueueSnapshot(): void {
|
||
if (!this.config.savedQueuesEnabled) return;
|
||
if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
|
||
this.snapshotTimer = setTimeout(() => this.persistQueueSnapshot(), 1000);
|
||
}
|
||
|
||
/** Restore + resume the live queue after (re)connect. Best-effort. */
|
||
private async restoreQueueFromSnapshot(): Promise<void> {
|
||
if (!this.config.savedQueuesEnabled) return;
|
||
const st = this.database.getQueueState(this.id);
|
||
if (!st || st.songs.length === 0) return;
|
||
this.queue.restore({ songs: st.songs, currentIndex: st.currentIndex, mode: st.mode as PlayMode });
|
||
if (st.isFmMode && st.fmPlatform) {
|
||
this.isFmMode = true;
|
||
this.fmProvider = this.providerForPlatform(st.fmPlatform); // reuse existing platform→provider map
|
||
}
|
||
const current = this.queue.current();
|
||
if (current) {
|
||
this.player.resetFailures();
|
||
await this.resolveAndPlay(current); // resumes from track start
|
||
}
|
||
}
|
||
```
|
||
|
||
Wire the debounced writer into the existing `stateChange` emit path. The cleanest single hook: add a listener in the constructor:
|
||
|
||
```ts
|
||
this.on("stateChange", () => this.scheduleQueueSnapshot());
|
||
```
|
||
|
||
Wire the restore into the connect flow — after `this.emit("connected")` in `connect()`:
|
||
|
||
```ts
|
||
void this.restoreQueueFromSnapshot();
|
||
```
|
||
|
||
(Restore runs once per connect. `providerForPlatform` — reuse the existing platform→provider resolution used by `getProvider`/FM; if only a flags-based `getProvider` exists, add a small `providerForPlatform(platform: string): MusicProvider` switch that returns the matching provider, defaulting to netease.)
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/bot/instance.test.ts --no-file-parallelism`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/bot/instance.ts src/bot/instance.test.ts
|
||
git commit -m "feat(player): snapshot live queue on change + resume on reconnect (#119)"
|
||
```
|
||
|
||
---
|
||
|
||
## Stage 5 — Docs & final verification
|
||
|
||
### Task 13: README + full verification
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Update README**
|
||
|
||
- Commands table: add `!save <名称>`, `!load [-a] <名称>`, `!queues`.
|
||
- 行为设置 / feature docs: document `savedQueuesEnabled` (default off, admin — enables save/load + restart auto-resume) and `playKeepsQueue` (default off — single-song `!play` keeps the queue).
|
||
- Changelog "最新版本 → 功能增强": add `#119 保存/加载播放清单 + 重启恢复队列 + 单曲播放不清空队列(均默认关闭)` with the honest caveats: restart resumes the current track **from its start**; Spotify auto-resume is best-effort.
|
||
|
||
- [ ] **Step 2: Full verification**
|
||
|
||
```bash
|
||
npx vitest run --no-file-parallelism
|
||
npx tsc --noEmit
|
||
cd web && npm run build && cd ..
|
||
```
|
||
|
||
Expected: all tests pass; tsc exit 0; web build clean.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs(readme): document save/load queues + playKeepsQueue toggles (#119)"
|
||
```
|
||
|
||
- [ ] **Step 4: Push + open PR**
|
||
|
||
```bash
|
||
git push -u origin feat/issue-119-saved-queues
|
||
gh pr create --fill --base main
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
- Feature 1 (named save/load, per-user + shared, chat + web, replace/append) → Tasks 6, 7, 8, 9. ✓
|
||
- Feature 2 (auto-restore + resume) → Tasks 10, 11, 12. ✓
|
||
- Feature 3 (`playKeepsQueue`) → Tasks 4, 5. ✓
|
||
- Config gates (both default off, sanitized) → Task 1; admin settings → Tasks 2, 3. ✓
|
||
- Caps, corrupt-blob degradation, ownership 404 → Task 6/7 tests. ✓
|
||
- Caveats (track-start resume, Spotify best-effort) → Task 13 docs; behavior in Task 12. ✓
|
||
|
||
**Type consistency:** `StoredSong`, `SavedQueue(Meta)`, `QueueSnapshot`, `QueueStateRow`, `SHARED_QUEUE_OWNER`, `playSingleSong`, `loadSavedQueue`, `saveQueue/listSavedQueues/getSavedQueue/deleteSavedQueue`, `saveQueueState/getQueueState/clearQueueState`, `snapshot/restore`, `persistQueueSnapshot/scheduleQueueSnapshot/restoreQueueFromSnapshot` — names are used identically across the tasks that define and consume them.
|
||
|
||
**Open verification items for the implementer (flagged inline):**
|
||
- Confirm the `config` object is shared by reference from `index.ts` → `createBotRouter` AND → each `BotInstance` (Task 2), so the toggles take effect without restart.
|
||
- Confirm `WebServerOptions` exposes `botManager`, `config`, `database` under those names at the `server.ts` mount site (Task 7).
|
||
- Confirm/add `providerForPlatform` on `BotInstance` (Task 12).
|