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>
55 KiB
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. savedQueuesEnabledis admin-controlled viaPOST /api/bot/settings(gatedrequirePermission("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
=== trueon load (likespotify.enabled) so a hand-edited/legacy/corruptconfig.jsoncan never silently enable a feature. - Songs are stored as a JSON
TEXTblob — an array ofOmit<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
.jssuffix even for.tsfiles. New source files get colocated*.test.ts. - Verification per task:
npx vitest run <file> --no-file-parallelism. Final gate (Task 13): fullnpx vitest run --no-file-parallelism,npx tsc --noEmit, andcd web && npm run build. - Branch: all work on
feat/issue-119-saved-queues(already created offmain). - 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-queuesrouter (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 newBotConfigflags + 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_OWNERusage.src/bot/instance.test.ts— command + playKeepsQueue + snapshot/restore tests.src/web/api/player.ts—/play-songfunnels throughbot.playSingleSong.src/data/database.ts—saved_queues+queue_statetables, migrations, methods, interfaces,SHARED_QUEUE_OWNERexport.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 defaultfalse). -
Step 1: Write the failing tests
Add to src/data/config.test.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):
/** 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):
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:
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
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:
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:
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
In the POST /settings handler, after the hasLocalAudioEnabled block, add:
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
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:
<!-- 保存/加载播放清单 + 重启恢复队列(管理员开关,默认关闭) -->
<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
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(cmdPlayat ~909; addplaySingleSong) - 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:
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):
/**
* 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:
this.queue.clear();
this.disableFmMode();
this.queue.add(this.withRequester(song0, requesterName));
this.queue.play();
// Reset failure counter on user-initiated play
...resolveAndPlay(...)
with:
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
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-songat ~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.
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:
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
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,createDatabasebody,BotDatabase) - Test:
src/data/database.test.ts
Interfaces:
- Produces (exports +
BotDatabasemethods):
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:
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:
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):
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:
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:
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
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:
BotDatabasesaved-queue methods (Task 6);BotManager(getBot); a() => booleanfeature-gate readingconfig.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:
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:
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):
/** 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:
import { createSavedQueuesRouter } from "./api/saved-queues.js";
Mount alongside favorites (both requireNotGuest):
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.
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(executeCommandswitch ~604-656, addcmdSave/cmdLoad/cmdQueues,cmdHelp~1361) - Test:
src/bot/instance.test.ts
Interfaces:
-
Consumes:
this.databasesaved-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:
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):
case "save":
return this.cmdSaveQueue(cmd);
case "load":
return this.cmdLoadQueue(cmd);
case "queues":
return this.cmdListQueues();
Add the methods:
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:
`${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
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/flagsavedQueuesEnabledfor nav gating. -
Step 1: Write the failing helper test
Create web/src/composables/savedQueues.test.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:
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
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:
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:
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:
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
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:
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:
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:
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:
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
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/connectedhandler) - 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:
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:
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:
this.on("stateChange", () => this.scheduleQueueSnapshot());
Wire the restore into the connect flow — after this.emit("connected") in connect():
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
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) andplayKeepsQueue(default off — single-song!playkeeps 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
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
git add README.md
git commit -m "docs(readme): document save/load queues + playKeepsQueue toggles (#119)"
- Step 4: Push + open PR
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
configobject is shared by reference fromindex.ts→createBotRouterAND → eachBotInstance(Task 2), so the toggles take effect without restart. - Confirm
WebServerOptionsexposesbotManager,config,databaseunder those names at theserver.tsmount site (Task 7). - Confirm/add
providerForPlatformonBotInstance(Task 12).