# 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` (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 --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 ``` (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` — 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 { 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; // 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; 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 { 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 { 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[]; 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 { 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).