diff --git a/README.md b/README.md index 1b80cab..a92c572 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 +- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明) - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) @@ -329,7 +330,8 @@ sudo systemctl start tsmusicbot | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **历史** | 播放历史记录 | -| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) | +| **已存队列** | 保存当前队列为清单、加载(替换)/ 追加 / 删除已保存清单(仅在管理员开启「保存/加载播放清单」后出现) | +| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停、保存/加载播放清单、单曲直接播放不清空队列) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) | ### TeamSpeak 文字命令 @@ -367,9 +369,14 @@ sudo systemctl start tsmusicbot | `!now` | 当前播放信息 | | `!vote` | 投票跳过当前歌曲 | | `!move <频道名>` | 移动到指定频道 | +| `!save <名称>` | 保存当前队列为一份已保存清单(需启用「保存/加载播放清单」,聊天保存进入共享清单) | +| `!load [-a] <名称>` | 加载已保存清单(默认替换当前队列并播放;加 `-a` 追加到队列末尾) | +| `!queues` | 列出已保存(共享)清单 | | `!help` | 显示帮助信息 | > 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next` +> +> `!save` / `!load` / `!queues` 仅在管理员开启「保存/加载播放清单」后可用(默认关闭),未启用时回复「此功能未启用」。 ### TeamSpeak 命令权限(管理类命令限制) @@ -869,7 +876,16 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部 > 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。 -### 最新版本 — Jellyfin 可选音源 +### 最新版本 — 保存/加载播放清单 + 重启恢复队列 + +**保存/加载播放清单 + 队列持久化([#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119))——三项开关均默认关闭,升级无行为变化** + +- **保存/加载播放清单(`savedQueuesEnabled`,默认关闭,管理员开关)**:开启后,可在网页「已存队列」页或聊天命令保存当前队列为清单、随时**替换**加载或**追加**到队列末尾。网页保存可选「共享」(否则私有到当前用户);聊天命令始终进入共享清单。新增聊天命令 `!save <名称>` / `!load [-a] <名称>` / `!queues`(未启用时回复「此功能未启用」)。上限:每个所有者 ≤ 50 份清单,每份 ≤ 1000 首。 +- **重启后自动恢复并继续播放队列**(同由 `savedQueuesEnabled` 门控):机器人连接后会恢复上次的队列并继续播放。**说明**:只能从当前曲目的**开头**恢复(不记忆播放进度,链接重新解析);**Spotify 恢复为尽力而为**(依赖 sidecar 重新可用),其他音源可靠。 +- **单曲直接播放不清空队列(`playKeepsQueue`,默认关闭,独立开关)**:开启后,直接播放单曲会插入到当前歌曲之后并立即播放、播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单 / 专辑 / 电台仍会替换队列。 +- 三项均在 设置 → 行为设置 中开关,保存即时生效,无需重启。 + +### Jellyfin 可选音源 **Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)** diff --git a/docs/superpowers/plans/2026-07-06-save-load-playlist.md b/docs/superpowers/plans/2026-07-06-save-load-playlist.md new file mode 100644 index 0000000..f625d7d --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-save-load-playlist.md @@ -0,0 +1,1443 @@ +# 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). diff --git a/docs/superpowers/specs/2026-07-06-save-load-playlist-design.md b/docs/superpowers/specs/2026-07-06-save-load-playlist-design.md new file mode 100644 index 0000000..79052c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-save-load-playlist-design.md @@ -0,0 +1,227 @@ +# Save/Load Playlists + Queue Persistence — Design + +**Issue:** [#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119) — 加入保存和加载播放清单功能 +**Date:** 2026-07-06 +**Status:** Approved (design), pending implementation plan + +## Problem + +The play queue lives only in memory (`PlayQueue` inside each `BotInstance`). It is lost in two situations the user calls out: + +1. **On restart** — the process stops, the in-memory queue is gone. +2. **On "直接播放"** — `!play ` (and the WebUI "play now" path) call `queue.clear()`, wiping the queue to play one song. + +The user wants to stop losing the queue. Two related-but-separate capabilities were agreed: + +- **Named save/load** of queues (manual), plus **auto-restore** of the live queue across restarts. +- An **independent** option to make single-song immediate-play *not* clear the queue. + +Everything ships **behind admin/independent toggles that default OFF**, so existing behavior is unchanged until an operator opts in. + +## Scope & agreed decisions + +| Decision | Choice | +| --- | --- | +| Core behavior | **Both** — named save/load **and** auto-restore live queue across restart | +| Saved-queue ownership | **Per-user** (like `favorite_playlists`), plus a reserved `__shared__` owner for chat + opt-in sharing | +| Trigger surface | **Web + chat** commands | +| Auto-restore on restart | **Restore and resume playing** (gated by `savedQueuesEnabled`) | +| Load semantics | **Replace (default) + append option** (`-a` flag / WebUI Append button) | +| Feature gate | `savedQueuesEnabled` — **default false, admin-controlled** | +| Single-play clear | Independent `playKeepsQueue` toggle — **default false** | + +### Out of scope (YAGNI) + +- Renaming a saved queue (delete + re-save instead). +- Mid-track resume on restart (resume from the current track's **start**; URLs are re-resolved). +- Normalized per-song storage (songs stored as a JSON blob). +- Sharing granularity beyond "private to me" vs "shared" (one boolean). + +## Storage approach + +A saved queue is an ordered list of songs that is only ever saved and loaded **whole** — never queried song-by-song. So songs are stored as a **JSON `TEXT` blob**, not a normalized child table. Each stored song is a `QueuedSong` **without `url`** (URLs are resolved lazily at play time, exactly as today). This mirrors how `QueuedSong` already flows and keeps the schema to a single row per saved queue. + +--- + +## Feature 1 — Named save/load (`savedQueuesEnabled`) + +### Data model + +New table: + +```sql +CREATE TABLE IF NOT EXISTS saved_queues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ownerId TEXT NOT NULL, -- WebUI user id, or the reserved SHARED owner + name TEXT NOT NULL, + songs TEXT NOT NULL, -- JSON array of stored songs (QueuedSong minus url) + 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); +``` + +- **`ownerId`** is either a real user id **or** a reserved constant `SHARED_QUEUE_OWNER = "__shared__"` (a value that can never collide with a real user id). +- **Ownership rule (reconciles per-user with chat):** + - **WebUI save** has a **"共享 (shared)" checkbox.** Off → `ownerId = req.user.id` (private to you). On → `ownerId = SHARED_QUEUE_OWNER`. + - **Chat `!save`** always writes `ownerId = SHARED_QUEUE_OWNER` (TeamSpeak users have no WebUI account). + - **WebUI list** shows **your own + shared** (labeled). **Chat `!queues`/`!load`** see **shared only**. +- **Overwrite:** `UNIQUE(ownerId, name)` → save is an **upsert** (same owner+name replaces `songs`, `songCount`, `updatedAt`). +- **Caps** (reject with a clear message): ≤ **50** saved queues per owner; ≤ **1000** songs per saved queue. + +### DB methods (added to `BotDatabase`) + +```ts +saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; // upsert +listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; // meta only (no songs blob) +getSavedQueue(id: number): SavedQueue | null; // full, with songs +deleteSavedQueue(id: number): boolean; +``` + +- `StoredSong` = `Omit`. +- `SavedQueueMeta` = row without the `songs` blob (id, ownerId, name, songCount, timestamps) — keeps list responses light. +- JSON (de)serialize at the DB boundary; parse failures on a corrupt blob degrade to an empty song list (never throw into a route). + +### Web API — `src/web/api/saved-queues.ts` + +All routes require auth + `player.queue` capability, and **404/403 when `savedQueuesEnabled` is false** (feature inert). + +| Method / path | Behavior | +| --- | --- | +| `GET /api/saved-queues` | list current user's own + shared (meta only) | +| `POST /api/saved-queues` | body `{ botId, name, shared? }` → snapshot that bot's **current queue** songs, upsert | +| `POST /api/saved-queues/:id/load` | body `{ botId, mode: "replace"\|"append" }` → load into that bot | +| `DELETE /api/saved-queues/:id` | delete (only own or shared; not another user's private) | + +Ownership check on load/delete: allow if `ownerId === req.user.id` or `ownerId === SHARED_QUEUE_OWNER`; else 404 (no existence leak, matching the favorites pattern). + +### Chat commands (new, in `BotInstance.executeCommand`) + +- `!save <名称>` — save current queue → shared bucket. +- `!load <名称>` — replace queue with a saved (shared) queue and play. +- `!load -a <名称>` — append a saved (shared) queue to the end. +- `!queues` — list shared saved queues (names + counts). + +All four reply **"此功能未启用"** when `savedQueuesEnabled` is false. Added to help text and the command table. + +### Load semantics (shared by web + chat) + +- **replace** — `queue.clear()`, add all stored songs, `queue.play()` + `resolveAndPlay(first)` (same shape as `cmdPlaylist`). Exits FM mode. +- **append** — add all stored songs to the end; if idle, start the first newly-added one; never interrupts a playing track. +- Loaded songs are re-tagged with a `requestedBy` of the loader (WebUI username / `游客` / chat `invokerName`) so play-history attribution stays correct (integrates with #121). + +### WebUI + +A new **"已保存队列 / Saved Queues"** page (nav entry visible only when `savedQueuesEnabled`): + +- **Save current queue**: name input + **共享** checkbox → `POST`. +- Per entry (reusing `SongCard`/list styles): **Load** (replace), **Append**, **Delete**, showing name / song count / shared badge / owner. +- Store/composable follows the existing `favorites` pattern. + +--- + +## Feature 2 — Auto-restore live queue across restart (`savedQueuesEnabled`) + +Gated by the **same** `savedQueuesEnabled` flag (part of the "saved queues" feature). + +### Data model + +One row per bot (the live snapshot, continuously overwritten): + +```sql +CREATE TABLE IF NOT EXISTS queue_state ( + botId TEXT PRIMARY KEY, + songs TEXT NOT NULL, -- JSON array of StoredSong + currentIndex INTEGER NOT NULL, + mode TEXT NOT NULL, -- PlayMode value + isFmMode INTEGER NOT NULL DEFAULT 0, + fmPlatform TEXT NOT NULL DEFAULT '', + updatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +DB methods: `saveQueueState(state)` (upsert), `getQueueState(botId)`, `clearQueueState(botId)`. + +### Snapshot (write path) + +- Add `PlayQueue.snapshot(): QueueSnapshot` and `PlayQueue.restore(snapshot)`. + - `snapshot` captures `songs` (minus url), `currentIndex`, `mode`. + - `restore` rebuilds `songs`, `currentIndex`, `mode`, and resets the derived `playedIndices`/`history`/`forwardStack` to a clean, consistent state for the restored index. +- `BotInstance` writes the snapshot **debounced (~1 s)** on `stateChange` (queue mutations, track changes, and mode changes already emit `stateChange`). FM mode + fm platform captured alongside. +- When the queue becomes empty (`clear()` with nothing re-added), the row is cleared via `clearQueueState`. + +### Restore (read path — "resume and play") + +When a bot reaches **connected/ready** (the same lifecycle point `autoStart` uses): + +1. If `savedQueuesEnabled` and a `queue_state` row exists → `PlayQueue.restore(...)`, restore FM mode/provider. +2. If there was a current track → `resolveAndPlay(current)` (re-resolves URL, plays **from the track's start**). + +**Honest caveats (documented in README + spec):** + +- Resumes the **current track from its start**, not the exact millisecond (URLs are re-resolved; no persisted elapsed/seek). +- **Spotify** auto-resume is **best-effort** — it depends on the sidecar/controller being back up; non-Spotify sources are reliable. +- Resume only happens for bots that reach the connected state (auto-started, or on next manual start). + +--- + +## Feature 3 — `playKeepsQueue` (independent single-play toggle) + +**Independent** config flag, **not** gated by `savedQueuesEnabled`. Default false → today's behavior. + +Affects **single-song immediate play only**: chat `!play | URL>` and the WebUI play-now / play-by-id path. + +| `playKeepsQueue` | `!play ` behavior | +| --- | --- | +| `false` (default) | `queue.clear()` → play only that song (today) | +| `true` | `addNext(song)` (insert after current) → `playAt(insertedAt)` (jump & play now) → `resolveAndPlay`; queue **kept**; on track-end, `next()` continues the queue | + +- Reuses existing `PlayQueue.addNext` + `playAt` — **no new queue logic.** +- **Not** applied to collection loads — `!playlist` / `!album` / `!artist` / `!fm` still replace the queue (loading a collection is meant to replace; the request is about 单曲/single songs). +- **Empty queue** → equivalent to a normal play (nothing to preserve). +- **FM mode** → `!play` still exits FM (manual takeover), but existing queued songs are preserved and continue after the single song (auto-refill stops because FM is off). Documented. + +--- + +## Config + +Add to `BotConfig` (in `src/data/config.ts`), both **default false**, both sanitized on load exactly like `localAudioEnabled` / `autoPauseOnEmpty` (so a hand-edited / legacy / corrupt `config.json` can never silently enable them): + +```ts +savedQueuesEnabled: boolean; // default false — gates Features 1 & 2 (admin-controlled) +playKeepsQueue: boolean; // default false — independent (Feature 3) +``` + +- Set via **Settings → 行为设置** (the existing admin behavior-settings surface, written through `POST /api/bot/settings`). +- When `savedQueuesEnabled` is false: chat save/load/queues reply "此功能未启用"; `/api/saved-queues/*` return 403/404; the WebUI page/nav is hidden; no snapshotting; no auto-restore. + +## Error handling & edge cases + +- Corrupt `songs` JSON blob → treated as empty list; never throws into a route or the restore path. +- Save with a duplicate name → upsert (overwrite), not an error. +- Load/delete of a non-owned private queue → 404. +- Caps exceeded → 4xx with a clear message (web) / friendly reply (chat). +- Snapshot writes are best-effort and debounced; a DB write failure logs and never interrupts playback. +- Restore of a Spotify-containing queue → best-effort per source; failures skip to next (existing `resolveAndPlay` skip behavior). + +## Testing (TDD) + +- **DB:** `saveQueue` upsert + caps; `listSavedQueues` own vs shared; `getSavedQueue`/`deleteSavedQueue`; ownership; `queue_state` upsert/get/clear; JSON round-trip + corrupt-blob degradation. +- **PlayQueue:** `snapshot`/`restore` round-trip (songs, index, mode; derived state consistent). +- **BotInstance:** `!save`/`!load`/`!load -a`/`!queues`; feature-disabled replies; snapshot-on-stateChange (debounced); resume-on-ready; `playKeepsQueue` insert-and-jump vs clear; collections still replace; FM interaction. +- **Web API:** auth + capability + feature-gate (403/404); save (own/shared); load replace/append; delete ownership; caps. +- **Config:** defaults false; load sanitization (legacy/corrupt/non-boolean → false). +- **Frontend:** Saved Queues page (save w/ shared toggle, load, append, delete, hidden when disabled); store/composable. +- Then: full suite (`npx vitest run --no-file-parallelism`) + `npx tsc --noEmit` + `cd web && npm run build`. + +## Rollout / staging + +Implement in stages (each independently valuable, all default-off): + +1. **Config + gates** — `savedQueuesEnabled`, `playKeepsQueue`, sanitization, Settings UI. +2. **Feature 3** — `playKeepsQueue` single-play behavior (small, self-contained). +3. **Feature 1** — named save/load (DB → API → chat → WebUI page). +4. **Feature 2** — live-queue snapshot + resume-on-restart. +5. **Docs** — README (commands, toggles, caveats). diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index be5f3fb..b6bd9f0 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -564,4 +564,45 @@ describe("PlayQueue", () => { } }); }); + + describe("snapshot / restore (#119)", () => { + it("round-trips songs, index, and mode; strips url", () => { + const q = new PlayQueue(); + q.add(makeSong("A")); + q.add(makeSong("B")); + 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 QueuedSong).url).toBeUndefined(); + expect(snap.songs.map((s) => s.id)).toEqual(["A", "B"]); + + 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"); + }); + + it("preserves requestedBy through a snapshot", () => { + const q = new PlayQueue(); + q.add({ ...makeSong("A"), requestedBy: "alice" }); + q.play(); + const q2 = new PlayQueue(); + q2.restore(q.snapshot()); + expect(q2.current()?.requestedBy).toBe("alice"); + }); + + it("degrades an out-of-range index to -1 (nothing current)", () => { + const q = new PlayQueue(); + const { url: _url, ...noUrl } = makeSong("A"); + q.restore({ songs: [noUrl], currentIndex: 5, mode: PlayMode.Sequential }); + expect(q.getCurrentIndex()).toBe(-1); + expect(q.current()).toBeNull(); + expect(q.list().map((s) => s.id)).toEqual(["A"]); + }); + }); }); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 7afec2c..6274a05 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -17,6 +17,18 @@ export interface QueuedSong { requestedBy?: string; } +/** + * A persistable view of a queue: its songs (minus the lazily-resolved `url`), + * the current index, and the play mode. Used to snapshot/restore the live queue + * across restarts (issue #119). Derived state (playedIndices/history/forward + * stack) is intentionally NOT captured — restore() rebuilds it consistently. + */ +export interface QueueSnapshot { + songs: Omit[]; + currentIndex: number; + mode: PlayMode; +} + export class PlayQueue { private songs: QueuedSong[] = []; private currentIndex = -1; @@ -273,4 +285,32 @@ export class PlayQueue { unplayedCount(): number { return this.songs.length - this.playedIndices.size; } + + /** + * Capture the queue as a persistable snapshot (songs minus `url`, current + * index, mode). Songs keep their `requestedBy` so restored play-history + * attribution stays correct. See restore(). + */ + snapshot(): QueueSnapshot { + return { + songs: this.songs.map(({ url: _url, ...s }) => s), + currentIndex: this.currentIndex, + mode: this.mode, + }; + } + + /** + * Replace the queue contents from a snapshot. Rebuilds the derived + * playedIndices/history/forwardStack to a clean, consistent state for the + * restored index (an out-of-range index degrades to -1 = "nothing current"). + */ + 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; + this.playedIndices = new Set(this.currentIndex >= 0 ? [this.currentIndex] : []); + this.history = []; + this.forwardStack = []; + } } diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 9f12883..834b253 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; import type { BotInstanceOptions } from "./instance.js"; +import { PlayQueue, PlayMode } from "../audio/queue.js"; +import { createDatabase, SHARED_QUEUE_OWNER } from "../data/database.js"; +import { parseCommand } from "./commands.js"; import type { TS3TextMessage } from "../ts-protocol/client.js"; import type { SpotifyController } from "../music/spotify/controller.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { MusicProvider } from "../music/provider.js"; import type { BotDatabase } from "../data/database.js"; -import { createDatabase } from "../data/database.js"; import type { AvatarStore } from "../data/avatars.js"; import type { BotConfig } from "../data/config.js"; @@ -1106,3 +1108,282 @@ describe("BotInstance.cmdLyrics — full lyrics (#116)", () => { expect(await cmdLyrics.call(ctx)).toBe("No lyrics available"); }); }); + +// ─── Saved queues + live-queue persistence + playKeepsQueue (#119) ───────── +// All exercise the ACTUAL shipped methods via their prototype, bound to a +// minimal ctx — the same lightweight pattern as the cmd* tests above. +const playSingleSong = BotInstance.prototype.playSingleSong as ( + this: unknown, + song: unknown, + requesterName?: string, +) => Promise; +const loadSavedQueue = BotInstance.prototype.loadSavedQueue as ( + this: unknown, + songs: unknown[], + mode: "replace" | "append", + requesterName?: string, +) => Promise; +const cmdSaveQueue = (BotInstance.prototype as any).cmdSaveQueue as (this: unknown, cmd: any) => string; +const cmdLoadQueue = (BotInstance.prototype as any).cmdLoadQueue as (this: unknown, cmd: any) => Promise; +const cmdListQueues = (BotInstance.prototype as any).cmdListQueues as (this: unknown) => string; +const persistQueueSnapshot = (BotInstance.prototype as any).persistQueueSnapshot as (this: unknown) => void; +const scheduleQueueSnapshot = (BotInstance.prototype as any).scheduleQueueSnapshot as (this: unknown) => void; +const restoreQueueFromSnapshot = (BotInstance.prototype as any).restoreQueueFromSnapshot as (this: unknown) => Promise; + +const withRequester = (BotInstance.prototype as any).withRequester; +const isSameSong = (BotInstance.prototype as any).isSameSong; +const savedQueuesGuard = (BotInstance.prototype as any).savedQueuesGuard; + +function song119(id: string) { + return { id, name: id, artist: "", album: "", platform: "netease" as const, coverUrl: "", duration: 1 }; +} +function makePlayer119() { + let state: "idle" | "playing" | "paused" = "idle"; + return { + stop: vi.fn(() => { state = "idle"; }), + resetFailures: vi.fn(), + getState: vi.fn(() => state), + _play: () => { state = "playing"; }, + }; +} + +describe("BotInstance.playSingleSong / playKeepsQueue (#119)", () => { + function makeCtx(playKeepsQueue: boolean) { + const queue = new PlayQueue(); + return { + config: { playKeepsQueue }, + queue, + player: makePlayer119(), + withRequester, + isSameSong, + disableFmMode: vi.fn(), + sweepLocalAudio: vi.fn(), + resolveAndPlay: vi.fn(async () => true), + } as any; + } + + it("clears the queue when playKeepsQueue is false (default)", async () => { + const ctx = makeCtx(false); + ctx.queue.add(song119("a")); + ctx.queue.play(); + const ok = await playSingleSong.call(ctx, song119("b"), "alice"); + expect(ok).toBe(true); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]); + expect(ctx.queue.current()?.id).toBe("b"); + expect(ctx.sweepLocalAudio).toHaveBeenCalled(); + }); + + it("inserts-after-current and keeps the queue when playKeepsQueue is true", async () => { + const ctx = makeCtx(true); + ctx.queue.add(song119("a")); + ctx.queue.add(song119("c")); + ctx.queue.play(); // current = a (index 0) + await playSingleSong.call(ctx, song119("b"), "alice"); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b", "c"]); + expect(ctx.queue.current()?.id).toBe("b"); + expect(ctx.queue.current()?.requestedBy).toBe("alice"); + // Keep-queue mode must not sweep local uploads (nothing was released). + expect(ctx.sweepLocalAudio).not.toHaveBeenCalled(); + }); + + it("falls back to clear-and-play when playKeepsQueue is true but the queue is empty", async () => { + const ctx = makeCtx(true); + await playSingleSong.call(ctx, song119("b"), "alice"); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]); + expect(ctx.queue.current()?.id).toBe("b"); + }); +}); + +describe("BotInstance.loadSavedQueue (#119)", () => { + function makeCtx() { + const player = makePlayer119(); + return { + queue: new PlayQueue(), + player, + withRequester, + disableFmMode: vi.fn(), + sweepLocalAudio: vi.fn(), + resolveAndPlay: vi.fn(async () => { player._play(); return true; }), + emit: vi.fn(), + } as any; + } + + it("replace clears + plays from the first track", async () => { + const ctx = makeCtx(); + ctx.queue.add(song119("old")); + ctx.queue.play(); + await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "replace", "bob"); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]); + expect(ctx.queue.current()?.id).toBe("a"); + expect(ctx.queue.current()?.requestedBy).toBe("bob"); + expect(ctx.disableFmMode).toHaveBeenCalled(); + expect(ctx.resolveAndPlay).toHaveBeenCalled(); + expect(ctx.emit).toHaveBeenCalledWith("stateChange"); + }); + + it("append adds to the end and starts playing only when idle", async () => { + const ctx = makeCtx(); + // Idle bot with an existing (not playing) queue entry. + ctx.queue.add(song119("x")); + await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "append"); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a", "b"]); + // wasIdle → start the first appended song (index 1). + expect(ctx.queue.current()?.id).toBe("a"); + expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1); + }); + + it("append does not interrupt a playing track", async () => { + const ctx = makeCtx(); + ctx.player._play(); // player is 'playing' + ctx.queue.add(song119("x")); + ctx.queue.play(); // current = x + await loadSavedQueue.call(ctx, [song119("a")], "append"); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a"]); + expect(ctx.queue.current()?.id).toBe("x"); + expect(ctx.resolveAndPlay).not.toHaveBeenCalled(); + }); +}); + +describe("BotInstance chat save/load/queues (#119)", () => { + function makeCtx(enabled: boolean, db = createDatabase(":memory:")) { + const queue = new PlayQueue(); + return { + config: { savedQueuesEnabled: enabled, commandPrefix: "!" }, + queue, + database: db, + savedQueuesGuard, + loadSavedQueue: vi.fn(async () => {}), + } as any; + } + + it("replies 此功能未启用 when the feature is disabled", () => { + const ctx = makeCtx(false); + expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("此功能未启用"); + expect(cmdListQueues.call(ctx)).toBe("此功能未启用"); + }); + + it("refuses saving an empty queue", () => { + const ctx = makeCtx(true); + expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("队列为空,无法保存"); + }); + + it("saves the current queue to the shared bucket and lists it", () => { + const ctx = makeCtx(true); + ctx.queue.add(song119("a")); + ctx.queue.add(song119("b")); + const reply = cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!); + expect(reply).toContain("已保存队列"); + expect(ctx.database.listSavedQueues(SHARED_QUEUE_OWNER, false).map((x: any) => x.name)).toContain("night"); + expect(cmdListQueues.call(ctx)).toContain("night"); + }); + + it("loads a saved queue by name (replace by default, -a appends)", async () => { + const db = createDatabase(":memory:"); + const ctx = makeCtx(true, db); + db.saveQueue(SHARED_QUEUE_OWNER, "night", [song119("a")]); + const rep = await cmdLoadQueue.call(ctx, parseCommand("!load night", "!")!); + expect(rep).toContain("已加载"); + expect(ctx.loadSavedQueue).toHaveBeenCalledWith(expect.any(Array), "replace"); + + const repA = await cmdLoadQueue.call(ctx, parseCommand("!load -a night", "!")!); + expect(repA).toContain("已追加"); + expect(ctx.loadSavedQueue).toHaveBeenLastCalledWith(expect.any(Array), "append"); + }); + + it("reports a missing saved queue", async () => { + const ctx = makeCtx(true); + expect(await cmdLoadQueue.call(ctx, parseCommand("!load nope", "!")!)).toContain("找不到"); + }); +}); + +describe("BotInstance live-queue persistence (#119)", () => { + function makeCtx(enabled: boolean, db = createDatabase(":memory:")) { + return { + id: "bot1", + config: { savedQueuesEnabled: enabled }, + queue: new PlayQueue(), + database: db, + isFmMode: false, + fmProvider: null, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + player: makePlayer119(), + resolveAndPlay: vi.fn(async () => true), + getProviderFor: vi.fn(() => ({ platform: "netease" })), + } as any; + } + + it("persists a snapshot when enabled", () => { + const ctx = makeCtx(true); + ctx.queue.add(song119("a")); + ctx.queue.play(); + persistQueueSnapshot.call(ctx); + const st = ctx.database.getQueueState("bot1")!; + expect(st.songs.map((s: any) => s.id)).toEqual(["a"]); + expect(st.currentIndex).toBe(0); + }); + + it("does NOT persist when the feature is disabled", () => { + const ctx = makeCtx(false); + ctx.queue.add(song119("a")); + ctx.queue.play(); + persistQueueSnapshot.call(ctx); + expect(ctx.database.getQueueState("bot1")).toBeNull(); + }); + + it("clears the persisted row when the queue is empty", () => { + const db = createDatabase(":memory:"); + db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" }); + const ctx = makeCtx(true, db); + persistQueueSnapshot.call(ctx); // queue is empty + expect(db.getQueueState("bot1")).toBeNull(); + }); + + it("restores and resumes the current track on restore", async () => { + const db = createDatabase(":memory:"); + db.saveQueueState({ botId: "bot1", songs: [song119("a"), song119("b")], currentIndex: 1, mode: "loop", isFmMode: false, fmPlatform: "" }); + const ctx = makeCtx(true, db); + await restoreQueueFromSnapshot.call(ctx); + expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]); + expect(ctx.queue.getCurrentIndex()).toBe(1); + expect(ctx.queue.getMode()).toBe(PlayMode.Loop); + expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1); + }); + + it("restores FM mode + provider from the snapshot", async () => { + const db = createDatabase(":memory:"); + db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" }); + const ctx = makeCtx(true, db); + await restoreQueueFromSnapshot.call(ctx); + expect(ctx.isFmMode).toBe(true); + expect(ctx.getProviderFor).toHaveBeenCalledWith("qq"); + }); + + it("does nothing when the feature is disabled", async () => { + const db = createDatabase(":memory:"); + db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" }); + const ctx = makeCtx(false, db); + await restoreQueueFromSnapshot.call(ctx); + expect(ctx.queue.list()).toEqual([]); + expect(ctx.resolveAndPlay).not.toHaveBeenCalled(); + }); + + it("a cancelled snapshot timer does not wipe persisted state (disconnect race)", () => { + vi.useFakeTimers(); + try { + const db = createDatabase(":memory:"); + db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" }); + const ctx = makeCtx(true, db); + ctx.queue.add(song119("a")); + ctx.queue.play(); + // Debounced snapshot scheduled, then a disconnect clears the queue and + // cancels the pending timer — the persisted row must survive for restore. + scheduleQueueSnapshot.call(ctx); + ctx.queue.clear(); + if (ctx.snapshotTimer) clearTimeout(ctx.snapshotTimer); + vi.advanceTimersByTime(3000); + expect(db.getQueueState("bot1")).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 29cfdf9..3efdd34 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -15,7 +15,7 @@ import { import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import { splitTextIntoChunks } from "./text-chunk.js"; import type { Logger } from "../logger.js"; -import type { BotDatabase, ProfileConfig } from "../data/database.js"; +import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js"; import { isProviderEnabled, defaultPlatform, @@ -175,6 +175,8 @@ export class BotInstance extends EventEmitter { /** Per-bot Jellyfin playback-report session (start / ~10s progress / stop). * null when the wired provider has no reporting capability. */ private jellyfinReporter: JellyfinPlaybackReporter | null = null; + /** Debounce handle for the live-queue snapshot writer (Feature 2, #119). */ + private snapshotTimer: ReturnType | null = null; constructor(options: BotInstanceOptions) { super(); @@ -268,6 +270,11 @@ export class BotInstance extends EventEmitter { this.setupPlayerEvents(); this.setupTsEvents(); + + // Feature 2 (#119): persist a debounced snapshot of the live queue whenever + // it changes, so it can be restored + resumed after a restart. Inert unless + // config.savedQueuesEnabled is on (checked inside the scheduler). + this.on("stateChange", () => this.scheduleQueueSnapshot()); } private setupPlayerEvents(): void { @@ -351,6 +358,15 @@ export class BotInstance extends EventEmitter { // this.connected was never flipped to true. Previously this handler // short-circuited on !this.connected, leaving player stuck as "playing". this.connected = false; + // Cancel any pending live-queue snapshot BEFORE clearing the queue: a + // debounced snapshot firing after clear() would persist an empty queue + // (clearQueueState), wiping the state we want to restore on reconnect — + // and since a manual stop→start reuses the same botId, that would clobber + // the new instance's restored row (#119). + if (this.snapshotTimer) { + clearTimeout(this.snapshotTimer); + this.snapshotTimer = null; + } this.spotifyController.stop(); this.currentSourceIsSpotify = false; this.player.stop(); @@ -434,10 +450,22 @@ export class BotInstance extends EventEmitter { this.connected = true; this.profileManager.onConnect(); this.emit("connected"); + // Feature 2 (#119): restore + resume the live queue persisted before the + // last shutdown. Best-effort and gated on savedQueuesEnabled; runs after + // the bot is fully connected so resolveAndPlay can actually push audio. + void this.restoreQueueFromSnapshot(); } disconnect(): void { this._cancelIdleTimer(); + // Cancel any pending live-queue snapshot before clearing so it can't fire + // afterwards and persist an empty queue over the state we keep for restore + // (#119). The disconnected handler cancels too, but do it here as well for + // the path where tsClient.disconnect() doesn't re-emit "disconnected". + if (this.snapshotTimer) { + clearTimeout(this.snapshotTimer); + this.snapshotTimer = null; + } this.spotifyController.stop(); this.currentSourceIsSpotify = false; this.player.stop(); @@ -720,6 +748,12 @@ export class BotInstance extends EventEmitter { return this.cmdMove(cmd); case "follow": return this.cmdFollow(msg); + case "save": + return this.cmdSaveQueue(cmd); + case "load": + return this.cmdLoadQueue(cmd); + case "queues": + return this.cmdListQueues(); case "help": return this.cmdHelp(); default: @@ -1013,24 +1047,93 @@ export class BotInstance extends EventEmitter { const { song, error } = await this.resolvePlayQuery(cmd); if (error) return error; const song0 = song!; + const ok = await this.playSingleSong(song0, requesterName); + if (!ok) return `Cannot play: ${song0.name}`; + return `Now playing: ${song0.name} - ${song0.artist}`; + } + + /** + * Play a single resolved song immediately, honoring config.playKeepsQueue: + * - false (default): clear the queue and play only this song — today's + * behavior. The prior track is stopped and released local uploads swept. + * - true (and the queue is non-empty): insert the song after the current + * track and jump to it (reusing addNext + playAt — no new queue logic), so + * the rest of the queue survives and continues after it. FM auto-refill is + * stopped (manual takeover), but existing queued songs are preserved. + * + * Shared by chat !play and the web /play-song route so the toggle decision + * lives in exactly one place (#119). Returns true if a track started playing. + */ + async playSingleSong(song: QueuedSong, requesterName?: string): Promise { + const s = this.withRequester(song, requesterName); + if (this.config.playKeepsQueue && !this.queue.isEmpty()) { + const insertedAt = + this.queue.getCurrentIndex() < 0 + ? this.queue.size() + : this.queue.getCurrentIndex() + 1; + this.player.stop(); + this.disableFmMode(); + this.queue.addNext(s); + this.queue.playAt(insertedAt); + this.player.resetFailures(); + // No sweep here: the queue is kept, so no local uploads were released. + return this.resolveAndPlay(this.queue.current()!); + } + // Legacy replace behavior (default). const previous = this.queue.current(); - if (previous && !this.isSameSong(previous, song0)) { + if (previous && !this.isSameSong(previous, s)) { this.player.stop(); } this.queue.clear(); this.disableFmMode(); - this.queue.add(this.withRequester(song0, requesterName)); + this.queue.add(s); this.queue.play(); // Reset failure counter on user-initiated play this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); // Sweep AFTER the new song is queued+resolved: the replaced songs are no - // longer referenced (and get deleted), but song0 — if it is the same local - // upload that was already playing — stays referenced and is preserved. + // longer referenced (and get deleted), but the song — if it is the same + // local upload that was already playing — stays referenced and is preserved. this.sweepLocalAudio("replaced"); - if (!ok) return `Cannot play: ${song0.name}`; - return `Now playing: ${song0.name} - ${song0.artist}`; + return ok; + } + + /** + * Load a saved song list into this bot's queue (#119). `replace` clears + + * plays from the first track (exits FM, like a fresh collection load); + * `append` adds to the end and only starts playing if the bot was idle + * (never interrupts a playing track). Loaded songs are re-tagged with the + * loader's name so play-history attribution stays correct. + */ + 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.player.stop(); + this.queue.clear(); + this.disableFmMode(); + for (const s of tagged) this.queue.add(s); + this.sweepLocalAudio("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() > startAt) { + this.queue.playAt(startAt); + this.player.resetFailures(); + await this.resolveAndPlay(this.queue.current()!); + } + } + this.emit("stateChange"); } private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise { @@ -1484,6 +1587,122 @@ export class BotInstance extends EventEmitter { return "Following you to your channel"; } + // ─── Saved queues (chat side, #119) ────────────────────────────────────── + // TeamSpeak users have no WebUI account, so chat save/load always uses the + // reserved SHARED_QUEUE_OWNER bucket. All three commands are inert (reply + // "此功能未启用") unless the admin enabled savedQueuesEnabled. + + 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 meta = this.database + .listSavedQueues(SHARED_QUEUE_OWNER, false) + .find((q) => q.name === name); + const full = meta ? this.database.getSavedQueue(meta.id) : null; + 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"); + } + + // ─── Live-queue persistence (Feature 2, #119) ──────────────────────────── + + /** Synchronous snapshot writer. Persists the live queue (or clears the row + * when empty). Best-effort — a DB failure logs and never interrupts play. */ + 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"); + } + } + + /** Debounce the snapshot writer (~1s) off the stateChange firehose. */ + private scheduleQueueSnapshot(): void { + if (!this.config.savedQueuesEnabled) return; + if (this.snapshotTimer) clearTimeout(this.snapshotTimer); + this.snapshotTimer = setTimeout(() => this.persistQueueSnapshot(), 1000); + // Don't keep the event loop alive just for a pending snapshot. + this.snapshotTimer.unref?.(); + } + + /** Restore + resume the live queue after (re)connect. Best-effort: resumes + * the current track from its START (URLs are re-resolved; no persisted + * elapsed). Spotify resume depends on the sidecar being available. */ + private async restoreQueueFromSnapshot(): Promise { + if (!this.config.savedQueuesEnabled) return; + let st; + try { + st = this.database.getQueueState(this.id); + } catch (err) { + this.logger.warn({ err }, "queue snapshot restore failed to read state"); + return; + } + 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.getProviderFor(st.fmPlatform as Platform); + } + const current = this.queue.current(); + if (current) { + this.player.resetFailures(); + await this.resolveAndPlay(current); + } + this.logger.info( + { count: st.songs.length, index: st.currentIndex }, + "Restored live queue from snapshot", + ); + } + private cmdHelp(): string { const p = this.config.commandPrefix; const def = defaultPlatform(this.config); @@ -1513,6 +1732,13 @@ export class BotInstance extends EventEmitter { `${p}album — Load album`, `${p}fm — Personal FM (default source: ${def}; source flags work too)`, `${p}artist — Play songs by artist (loop)`, + ...(this.config.savedQueuesEnabled + ? [ + `${p}save <名称> — Save current queue`, + `${p}load [-a] <名称> — Load a saved queue (-a appends)`, + `${p}queues — List saved queues`, + ] + : []), `${p}vote — Vote to skip`, `${p}lyrics — Show lyrics`, `${p}now — Current song info`, diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 08d263d..119aca6 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -603,4 +603,28 @@ describe("loadConfig error handling", () => { expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content); } }); + + it("defaults savedQueuesEnabled and playKeepsQueue to false", () => { + const c = getDefaultConfig(); + expect(c.savedQueuesEnabled).toBe(false); + expect(c.playKeepsQueue).toBe(false); + }); + + it("coerces non-boolean savedQueues/playKeepsQueue values to false on load", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ savedQueuesEnabled: "yes", playKeepsQueue: 1 })); + const c = loadConfig(path); + expect(c.savedQueuesEnabled).toBe(false); + expect(c.playKeepsQueue).toBe(false); + }); + + it("preserves savedQueues/playKeepsQueue true when explicitly enabled", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ savedQueuesEnabled: true, playKeepsQueue: true })); + const c = loadConfig(path); + expect(c.savedQueuesEnabled).toBe(true); + expect(c.playKeepsQueue).toBe(true); + }); }); diff --git a/src/data/config.ts b/src/data/config.ts index 6dc5cc2..baf671c 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -116,6 +116,19 @@ export interface BotConfig { idleTimeoutMinutes: number; /** Enable uploading and playback of server-stored local audio files. */ localAudioEnabled: boolean; + /** + * Enable named save/load of queues (chat + web) AND auto-restore of the live + * queue across a restart. Admin-controlled; default false so nothing is + * persisted/restored until an operator opts in. + */ + savedQueuesEnabled: boolean; + /** + * When true, a single-song immediate !play (chat) / play-song (web) inserts + * after the current track and jumps to it instead of clearing the queue, so + * the rest of the queue survives and continues afterwards. Default false + * keeps today's clear-and-play behavior. + */ + playKeepsQueue: boolean; // Public base URL used when generating share links (e.g. the bot专属链接). // Leave empty to use the browser's current origin. Example: // "https://music.example.com" or "http://1.2.3.4:3000" @@ -165,6 +178,8 @@ export function getDefaultConfig(): BotConfig { autoPauseOnEmpty: false, idleTimeoutMinutes: 0, localAudioEnabled: true, + savedQueuesEnabled: false, + playKeepsQueue: false, publicUrl: "", trustProxy: false, guestMode: { @@ -362,6 +377,12 @@ export function loadConfig(path: string): BotConfig { ) : defaults.enabledProviders; + // Strict-coerce the two feature flags exactly like spotify.enabled so a + // hand-edited / legacy / corrupt config.json can never silently enable + // them (`"yes"`, `1`, `null` → false; only a literal `true` enables). + const savedQueuesEnabled = partial.savedQueuesEnabled === true; + const playKeepsQueue = partial.playKeepsQueue === true; + // defaultPlatform → an explicit operator default (issue #126). Keep it only // when it names a KNOWN gateable provider that is ALSO currently enabled; // anything else (unknown value, disabled source, wrong type, missing) becomes @@ -397,6 +418,8 @@ export function loadConfig(path: string): BotConfig { jellyfin, audioQuality, enabledProviders, + savedQueuesEnabled, + playKeepsQueue, defaultPlatform: defaultPlatformPref, }; } diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 8352215..b03ff1e 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js"; +import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js"; import { createUserStore, GUEST_USER_ID } from "./users.js"; describe("database", () => { @@ -239,6 +239,86 @@ describe("database", () => { botDb.setCustomAvatarPath("bot-1", null); expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); }); + + const sq = (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", () => { + botDb.saveQueue("u1", "night", [sq("a"), sq("b")]); + const again = botDb.saveQueue("u1", "night", [sq("c")]); // overwrite + expect(again.songCount).toBe(1); + expect(botDb.listSavedQueues("u1", false)).toHaveLength(1); + const full = botDb.getSavedQueue(again.id)!; + expect(full.songs.map((s) => s.id)).toEqual(["c"]); + }); + + it("strips url before persisting", () => { + const saved = botDb.saveQueue("u1", "x", [ + { ...sq("a"), url: "http://example.com/a.mp3" } as never, + ]); + const full = botDb.getSavedQueue(saved.id)!; + expect((full.songs[0] as { url?: string }).url).toBeUndefined(); + }); + + it("lists own + shared when includeShared, own-only otherwise", () => { + botDb.saveQueue("u1", "mine", [sq("a")]); + botDb.saveQueue(SHARED_QUEUE_OWNER, "party", [sq("b")]); + expect(botDb.listSavedQueues("u1", false).map((q) => q.name)).toEqual(["mine"]); + expect( + botDb.listSavedQueues("u1", true).map((q) => q.name).sort(), + ).toEqual(["mine", "party"]); + }); + + it("caps songs at 1000 and queues at 50", () => { + expect(() => + botDb.saveQueue("u1", "big", Array.from({ length: 1001 }, (_, i) => sq("s" + i))), + ).toThrow(/1000/); + for (let i = 0; i < 50; i++) botDb.saveQueue("u1", "q" + i, [sq("a")]); + expect(() => botDb.saveQueue("u1", "q50", [sq("a")])).toThrow(/50/); + // Overwriting an existing name is always allowed despite the cap. + expect(() => botDb.saveQueue("u1", "q0", [sq("z")])).not.toThrow(); + }); + + it("deletes and degrades a corrupt blob to empty", () => { + const q = botDb.saveQueue("u1", "x", [sq("a")]); + botDb.db.prepare("UPDATE saved_queues SET songs='not json' WHERE id=?").run(q.id); + expect(botDb.getSavedQueue(q.id)!.songs).toEqual([]); + expect(botDb.deleteSavedQueue(q.id)).toBe(true); + expect(botDb.getSavedQueue(q.id)).toBeNull(); + expect(botDb.deleteSavedQueue(q.id)).toBe(false); // already gone + }); + }); + + describe("queue_state", () => { + it("upserts, reads back, and clears per bot", () => { + botDb.saveQueueState({ botId: "b1", songs: [sq("a")], currentIndex: 0, mode: "loop", isFmMode: true, fmPlatform: "netease" }); + botDb.saveQueueState({ botId: "b1", songs: [sq("a"), sq("b")], currentIndex: 1, mode: "seq", isFmMode: false, fmPlatform: "" }); + const st = botDb.getQueueState("b1")!; + expect(st.songs.map((s) => s.id)).toEqual(["a", "b"]); + expect(st.currentIndex).toBe(1); + expect(st.mode).toBe("seq"); + expect(st.isFmMode).toBe(false); + botDb.clearQueueState("b1"); + expect(botDb.getQueueState("b1")).toBeNull(); + }); + + it("round-trips FM flags and degrades a corrupt blob", () => { + botDb.saveQueueState({ botId: "b2", songs: [sq("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" }); + const st = botDb.getQueueState("b2")!; + expect(st.isFmMode).toBe(true); + expect(st.fmPlatform).toBe("qq"); + botDb.db.prepare("UPDATE queue_state SET songs='{' WHERE botId=?").run("b2"); + expect(botDb.getQueueState("b2")!.songs).toEqual([]); + }); + }); }); describe("guest principal migration", () => { diff --git a/src/data/database.ts b/src/data/database.ts index 2bb5a0c..595913f 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -1,6 +1,46 @@ import Database from "better-sqlite3"; import { CAPABILITIES, BOTS_ALL } from "./permissions.js"; import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js"; +import type { QueuedSong } from "../audio/queue.js"; + +/** + * Reserved owner id for chat-saved / opt-in-shared queues. A `__`-bracketed + * literal can never collide with a real WebUI user id (UUIDs), so it cleanly + * partitions "shared" saved queues from per-user private ones (issue #119). + */ +export const SHARED_QUEUE_OWNER = "__shared__"; +/** Cap per owner (private user OR the shared bucket). */ +export const MAX_SAVED_QUEUES = 50; +/** Cap per saved queue / persisted live-queue snapshot. */ +export const MAX_QUEUE_SONGS = 1000; + +/** A stored song is a QueuedSong minus the lazily-resolved `url`. */ +export type StoredSong = Omit; + +/** Saved-queue row without the (potentially large) songs blob — for list views. */ +export interface SavedQueueMeta { + id: number; + ownerId: string; + name: string; + songCount: number; + createdAt: string; + updatedAt: string; +} + +/** Full saved queue, including its songs. */ +export interface SavedQueue extends SavedQueueMeta { + songs: StoredSong[]; +} + +/** One-row-per-bot persisted live-queue state (Feature 2, auto-restore). */ +export interface QueueStateRow { + botId: string; + songs: StoredSong[]; + currentIndex: number; + mode: string; + isFmMode: boolean; + fmPlatform: string; +} export interface PlayHistoryEntry { botId: string; @@ -104,6 +144,15 @@ export interface BotDatabase { removeFavorite(userId: string, playlistId: string, platform: string): boolean; getFavorites(userId: string): FavoritePlaylist[]; isFavorited(userId: string, playlistId: string, platform: string): boolean; + // Saved queues (Feature 1) — upsert by (ownerId, name), capped. + saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; + listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; + getSavedQueue(id: number): SavedQueue | null; + deleteSavedQueue(id: number): boolean; + // Live-queue persistence (Feature 2) — one row per bot. + saveQueueState(state: QueueStateRow): void; + getQueueState(botId: string): QueueStateRow | null; + clearQueueState(botId: string): void; close(): void; } @@ -257,6 +306,28 @@ function initTables(db: Database.Database): void { FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId); + + 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); + + 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')) + ); `); } @@ -382,6 +453,67 @@ export function createDatabase(dbPath: string): BotDatabase { SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ? `); + // A corrupt/hand-edited songs blob must never throw into a route or the + // restore path — degrade to an empty list instead. + const parseSongs = (raw: string): StoredSong[] => { + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? (v as StoredSong[]) : []; + } catch { + return []; + } + }; + const rowToSavedMeta = (r: { + id: number; ownerId: string; name: string; songCount: number; createdAt: string; updatedAt: string; + }): SavedQueueMeta => ({ + id: r.id, + ownerId: r.ownerId, + name: r.name, + songCount: r.songCount, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }); + + const upsertSavedQueue = 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') + `); + const selectSavedQueueByOwnerName = db.prepare( + "SELECT * FROM saved_queues WHERE ownerId = ? AND name = ?", + ); + const selectSavedQueueIdByOwnerName = db.prepare( + "SELECT id FROM saved_queues WHERE ownerId = ? AND name = ?", + ); + const countSavedQueues = db.prepare( + "SELECT COUNT(*) AS c FROM saved_queues WHERE ownerId = ?", + ); + const listSavedQueuesOwn = db.prepare( + "SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? ORDER BY updatedAt DESC", + ); + const listSavedQueuesShared = db.prepare( + "SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? OR ownerId = ? ORDER BY updatedAt DESC", + ); + const selectSavedQueueById = db.prepare("SELECT * FROM saved_queues WHERE id = ?"); + const deleteSavedQueueById = db.prepare("DELETE FROM saved_queues WHERE id = ?"); + + const upsertQueueState = 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') + `); + const selectQueueState = db.prepare("SELECT * FROM queue_state WHERE botId = ?"); + const deleteQueueState = db.prepare("DELETE FROM queue_state WHERE botId = ?"); + return { db, @@ -502,6 +634,85 @@ export function createDatabase(dbPath: string): BotDatabase { return row !== undefined; }, + saveQueue(ownerId, name, songs) { + if (songs.length > MAX_QUEUE_SONGS) { + throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`); + } + // Strip any lazily-resolved url before persisting. + const stripped: StoredSong[] = songs.map((s) => { + const { url: _url, ...rest } = s as QueuedSong; + return rest; + }); + // Enforce the per-owner cap only for a NEW name (an overwrite of an + // existing saved queue must always be allowed). + const existing = selectSavedQueueIdByOwnerName.get(ownerId, name) as + | { id: number } + | undefined; + if (!existing) { + const { c } = countSavedQueues.get(ownerId) as { c: number }; + if (c >= MAX_SAVED_QUEUES) { + throw new Error(`保存失败:已保存队列数量超过上限 ${MAX_SAVED_QUEUES}`); + } + } + upsertSavedQueue.run({ + ownerId, + name, + songs: JSON.stringify(stripped), + songCount: stripped.length, + }); + const row = selectSavedQueueByOwnerName.get(ownerId, name) as SavedQueueMeta; + return { ...rowToSavedMeta(row), songs: stripped }; + }, + + listSavedQueues(ownerId, includeShared) { + const rows = includeShared + ? (listSavedQueuesShared.all(ownerId, SHARED_QUEUE_OWNER) as SavedQueueMeta[]) + : (listSavedQueuesOwn.all(ownerId) as SavedQueueMeta[]); + return rows.map(rowToSavedMeta); + }, + + getSavedQueue(id) { + const row = selectSavedQueueById.get(id) as + | (SavedQueueMeta & { songs: string }) + | undefined; + if (!row) return null; + return { ...rowToSavedMeta(row), songs: parseSongs(row.songs) }; + }, + + deleteSavedQueue(id) { + return deleteSavedQueueById.run(id).changes > 0; + }, + + saveQueueState(state) { + upsertQueueState.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 = selectQueueState.get(botId) as + | { botId: string; songs: string; currentIndex: number; mode: string; isFmMode: number; fmPlatform: string } + | undefined; + 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) { + deleteQueueState.run(botId); + }, + close() { db.close(); }, diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index 346fede..bac40fe 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -325,6 +325,38 @@ describe("bot router /settings", () => { expect(res.status).toBe(200); expect(config.spotify).toEqual(before); }); + + it("GET /settings echoes savedQueuesEnabled + playKeepsQueue (default false)", async () => { + const res = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(res.status).toBe(200); + expect(res.body.savedQueuesEnabled).toBe(false); + expect(res.body.playKeepsQueue).toBe(false); + }); + + it("POST /settings persists savedQueuesEnabled and playKeepsQueue", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ savedQueuesEnabled: true, playKeepsQueue: true }); + expect(res.status).toBe(200); + expect(res.body.savedQueuesEnabled).toBe(true); + expect(res.body.playKeepsQueue).toBe(true); + expect(config.savedQueuesEnabled).toBe(true); + expect(config.playKeepsQueue).toBe(true); + + const get = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(get.body.savedQueuesEnabled).toBe(true); + expect(get.body.playKeepsQueue).toBe(true); + }); + + it("POST /settings ignores non-boolean savedQueuesEnabled without 400", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ savedQueuesEnabled: "nope" }); + expect(res.status).toBe(200); + expect(config.savedQueuesEnabled).toBe(false); // unchanged from default + }); }); // Whole-branch I2: saving a Client ID in Settings must re-configure the single diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index d75bd6a..4653b4c 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -72,6 +72,8 @@ export function createBotRouter( idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, localAudioEnabled: config.localAudioEnabled, + savedQueuesEnabled: config.savedQueuesEnabled, + playKeepsQueue: config.playKeepsQueue, adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, spotify: maskedSpotify(), @@ -99,6 +101,16 @@ export function createBotRouter( if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled; + // Saved-queues + play-keeps-queue toggles (default off). Both read live from + // config by BotInstance / the saved-queues router, so no per-bot push needed; + // only a literal boolean mutates the stored value (junk is ignored). + if (typeof req.body.savedQueuesEnabled === "boolean") { + config.savedQueuesEnabled = req.body.savedQueuesEnabled; + } + if (typeof req.body.playKeepsQueue === "boolean") { + config.playKeepsQueue = req.body.playKeepsQueue; + } + const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object"; if (hasGuestMode) { const gm = config.guestMode; @@ -238,6 +250,8 @@ export function createBotRouter( idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, localAudioEnabled: config.localAudioEnabled, + savedQueuesEnabled: config.savedQueuesEnabled, + playKeepsQueue: config.playKeepsQueue, adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, spotify: maskedSpotify(), diff --git a/src/web/api/player.ts b/src/web/api/player.ts index ac6c5b0..8cdcac7 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -461,7 +461,11 @@ export function createPlayerRouter( } }); - // Play a single song by ID — resolves URL on demand + // Play a single song by ID — resolves URL on demand. Funnels through + // bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs + // insert-and-jump, keeping the queue) lives in one place shared with chat + // !play. Serialized via runExclusive like /play-now-song so concurrent + // requests can't interleave the queue mutation + playback (#119). router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => { try { const bot = (req as any).bot; @@ -474,23 +478,16 @@ export function createPlayerRouter( rejectDisabledLocalAudio(res); return; } - const queue = bot.getQueueManager(); - bot.getPlayer().stop(); - queue.clear(); - queue.add({ ...song, requestedBy: requesterName(req) }); - queue.play(); - - bot.getPlayer().resetFailures(); - const ok = await bot.resolveAndPlay(queue.current()!); - // Sweep AFTER the new song is queued+resolved, so replaying a local song - // that was still in the queue doesn't delete the file we're about to play. - bot.cleanupQueuedLocalSongs?.("queue_replaced"); - if (!ok) { - res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); - return; - } - - res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); + // 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. + 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); } catch (err) { res.status(500).json({ error: (err as Error).message }); } diff --git a/src/web/api/saved-queues.test.ts b/src/web/api/saved-queues.test.ts new file mode 100644 index 0000000..4c8f1bf --- /dev/null +++ b/src/web/api/saved-queues.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase } from "../../data/database.js"; +import type { BotManager } from "../../bot/manager.js"; +import { createSavedQueuesRouter } from "./saved-queues.js"; + +const song = (id: string) => ({ + id, + name: id, + artist: "", + album: "", + platform: "netease" as const, + coverUrl: "", + duration: 1, +}); + +function mount(enabled: boolean, opts: { queue?: unknown[] } = {}) { + const db = createDatabase(":memory:"); + const loads: Array<{ songs: unknown[]; mode: string; by?: string }> = []; + const bot = { + getQueueManager: () => ({ list: () => opts.queue ?? [song("a"), song("b")] }), + loadSavedQueue: async (songs: unknown[], mode: string, by?: string) => { + loads.push({ songs, mode, by }); + }, + }; + const botManager = { getBot: (_id: string) => bot } as unknown as BotManager; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { user: unknown }).user = { id: "u1", username: "alice", role: "member" }; + next(); + }); + app.use( + "/api/saved-queues", + createSavedQueuesRouter(db, botManager, () => enabled, pino({ level: "silent" })), + ); + return { app, db, loads }; +} + +describe("saved-queues router", () => { + it("403s every route when the feature is 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); + expect((await request(app).post("/api/saved-queues/1/load").send({ botId: "b" })).status).toBe(403); + expect((await request(app).delete("/api/saved-queues/1")).status).toBe(403); + }); + + it("saves the current queue (private) and lists it back", async () => { + const { app } = mount(true); + const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "night" }); + expect(save.status).toBe(200); + expect(save.body.queue.name).toBe("night"); + expect(save.body.queue.songCount).toBe(2); + expect(save.body.queue.ownerId).toBe("u1"); + + const list = await request(app).get("/api/saved-queues"); + expect(list.status).toBe(200); + expect(list.body.queues.map((q: { name: string }) => q.name)).toContain("night"); + }); + + it("saves to the shared bucket when shared:true", async () => { + const { app, db } = mount(true); + const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "party", shared: true }); + expect(save.status).toBe(200); + expect(save.body.queue.ownerId).toBe(SHARED_QUEUE_OWNER); + expect(db.listSavedQueues(SHARED_QUEUE_OWNER, false).map((q) => q.name)).toEqual(["party"]); + }); + + it("rejects saving an empty queue", async () => { + const { app } = mount(true, { queue: [] }); + const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "empty" }); + expect(save.status).toBe(400); + }); + + it("requires botId and name", async () => { + const { app } = mount(true); + expect((await request(app).post("/api/saved-queues").send({ name: "x" })).status).toBe(400); + expect((await request(app).post("/api/saved-queues").send({ botId: "b" })).status).toBe(400); + }); + + it("loads a shared queue (replace by default) into the bot", async () => { + const { app, db, loads } = mount(true); + const saved = db.saveQueue(SHARED_QUEUE_OWNER, "party", [song("a"), song("b")]); + const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b" }); + expect(load.status).toBe(200); + expect(load.body).toMatchObject({ ok: true, loaded: 2, mode: "replace" }); + expect(loads).toHaveLength(1); + expect(loads[0].mode).toBe("replace"); + expect(loads[0].by).toBe("alice"); + }); + + it("loads in append mode when requested", async () => { + const { app, db, loads } = mount(true); + const saved = db.saveQueue("u1", "mine", [song("a")]); + const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b", mode: "append" }); + expect(load.status).toBe(200); + expect(loads[0].mode).toBe("append"); + }); + + it("404s loading another user's private queue (no existence leak)", async () => { + const { app, db } = mount(true); + db.saveQueue("someoneElse", "private", [song("z")]); + 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); + }); + + it("deletes an own queue but 404s another user's private one", async () => { + const { app, db } = mount(true); + const mine = db.saveQueue("u1", "mine", [song("a")]); + const theirs = db.saveQueue("someoneElse", "private", [song("z")]); + expect((await request(app).delete(`/api/saved-queues/${theirs.id}`)).status).toBe(404); + expect((await request(app).delete(`/api/saved-queues/${mine.id}`)).status).toBe(200); + expect(db.getSavedQueue(mine.id)).toBeNull(); + }); +}); diff --git a/src/web/api/saved-queues.ts b/src/web/api/saved-queues.ts new file mode 100644 index 0000000..4749b54 --- /dev/null +++ b/src/web/api/saved-queues.ts @@ -0,0 +1,122 @@ +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"; + +/** + * The /api/saved-queues router (Feature 1, #119). Named save/load of queues, + * per-user with a reserved shared bucket. Every route is inert (403) unless + * savedQueuesEnabled is on, so the feature is fully gated behind the admin flag. + * + * Ownership model: + * - WebUI save with `shared:true` → SHARED_QUEUE_OWNER; otherwise the caller's + * own user id (private to them). + * - list returns the caller's own queues + shared ones. + * - load/delete are allowed only for the caller's own queues or shared ones; + * another user's private queue 404s (no existence leak, matching favorites). + */ +export function createSavedQueuesRouter( + database: BotDatabase, + botManager: BotManager, + isEnabled: () => boolean, + logger: Logger, +): Router { + const router = Router(); + + // Feature gate — inert (403) when savedQueuesEnabled is false. + router.use((_req, res, next) => { + if (!isEnabled()) { + res.status(403).json({ error: "此功能未启用" }); + return; + } + next(); + }); + + // GET / — the caller's own + shared saved queues (meta only, no songs blob). + router.get("/", (req, res) => { + const userId = req.user!.id; + res.json({ queues: database.listSavedQueues(userId, true) }); + }); + + // POST / — snapshot a bot's CURRENT queue and upsert it. + // body: { botId, name, shared? } + router.post("/", (req, res) => { + const userId = req.user!.id; + const { botId, name, shared } = req.body ?? {}; + if (typeof name !== "string" || !name.trim() || typeof botId !== "string" || !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); + logger.info({ userId, ownerId, name: saved.name, count: saved.songCount }, "saved queue upserted"); + res.json({ + queue: { + id: saved.id, + ownerId: saved.ownerId, + name: saved.name, + songCount: saved.songCount, + }, + }); + } catch (err) { + res.status(400).json({ error: (err as Error).message }); + } + }); + + // POST /:id/load — load a saved queue into a bot. body: { botId, mode } + router.post("/:id/load", async (req, res) => { + const userId = req.user!.id; + const username = req.user!.username; + const id = parseInt(req.params.id, 10); + const { botId, mode } = req.body ?? {}; + if (Number.isNaN(id) || typeof botId !== "string" || !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 — delete a saved queue (own or shared only). + router.delete("/:id", (req, res) => { + const userId = req.user!.id; + const id = parseInt(req.params.id, 10); + if (Number.isNaN(id)) { + res.status(400).json({ error: "invalid id" }); + return; + } + 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; +} diff --git a/src/web/server.ts b/src/web/server.ts index 2fbc548..a94a4b5 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; +import { createSavedQueuesRouter } from "./api/saved-queues.js"; import { createSpotifyRouter } from "./api/spotify.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { SpotifyProvider } from "../music/spotify/provider.js"; @@ -202,6 +203,18 @@ export function createWebServer(options: WebServerOptions): WebServer { ); } app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); + // Saved queues (Feature 1, #119). Members + admins only (requireNotGuest); + // the router itself 403s every route unless savedQueuesEnabled is on. + app.use( + "/api/saved-queues", + requireNotGuest, + createSavedQueuesRouter( + options.database, + options.botManager, + () => options.config.savedQueuesEnabled, + logger, + ), + ); // admin-only routes app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); diff --git a/web/src/App.vue b/web/src/App.vue index ea8c0e9..17cced8 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -166,6 +166,9 @@ onMounted(async () => { // Search or Playlist render hearts correctly without first visiting Home. // (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.) playerStore.fetchFavorites(); + // Non-critical: reads savedQueuesEnabled so the nav entry can show/hide. + // Guests get a 403 (swallowed) → the entry stays hidden for them. + if (!session.isGuest.value) playerStore.fetchBotSettings(); syncTimer = setInterval(() => playerStore.syncElapsed(), 3000); mobileRaf = requestAnimationFrame(updateMobileProgress); // Reconcile the dedicated-link scope only after the bot list is known: the diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 67bbc85..28f9b19 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -7,6 +7,12 @@ 搜索 音乐库 播放历史 + 已存队列