Merge pull request #133 from ZHANGTIANYAO1/feat/issue-119-saved-playlists

feat: save/load playlists, restart queue persistence, and playKeepsQueue (#119)
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-07-17 11:09:23 +08:00
commit 0ad00cd7f7
26 files changed
+3426 -29

No files matched your search

+18 -2
View File
@@ -31,6 +31,7 @@
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
@@ -329,7 +330,8 @@ sudo systemctl start tsmusicbot
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 | | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 | | **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) | | **已存队列** | 保存当前队列为清单、加载(替换)/ 追加 / 删除已保存清单(仅在管理员开启「保存/加载播放清单」后出现) |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停、保存/加载播放清单、单曲直接播放不清空队列) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令 ### TeamSpeak 文字命令
@@ -367,9 +369,14 @@ sudo systemctl start tsmusicbot
| `!now` | 当前播放信息 | | `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 | | `!vote` | 投票跳过当前歌曲 |
| `!move <频道名>` | 移动到指定频道 | | `!move <频道名>` | 移动到指定频道 |
| `!save <名称>` | 保存当前队列为一份已保存清单(需启用「保存/加载播放清单」,聊天保存进入共享清单) |
| `!load [-a] <名称>` | 加载已保存清单(默认替换当前队列并播放;加 `-a` 追加到队列末尾) |
| `!queues` | 列出已保存(共享)清单 |
| `!help` | 显示帮助信息 | | `!help` | 显示帮助信息 |
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next` > 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
>
> `!save` / `!load` / `!queues` 仅在管理员开启「保存/加载播放清单」后可用(默认关闭),未启用时回复「此功能未启用」。
### TeamSpeak 命令权限(管理类命令限制) ### 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)。这里只列出重要变更和面向用户的破坏性改动。 > 完整历史请查看 [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) 贡献;随后调整为可选音源)** **Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)**
File diff suppressed because it is too large. Load diff
@@ -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 <song>` (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<QueuedSong, "url">`.
- `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 <song | #N | id:<id> | URL>` and the WebUI play-now / play-by-id path.
| `playKeepsQueue` | `!play <song>` 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).
+41
View File
@@ -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"]);
});
});
}); });
+40
View File
@@ -17,6 +17,18 @@ export interface QueuedSong {
requestedBy?: string; 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<QueuedSong, "url">[];
currentIndex: number;
mode: PlayMode;
}
export class PlayQueue { export class PlayQueue {
private songs: QueuedSong[] = []; private songs: QueuedSong[] = [];
private currentIndex = -1; private currentIndex = -1;
@@ -273,4 +285,32 @@ export class PlayQueue {
unplayedCount(): number { unplayedCount(): number {
return this.songs.length - this.playedIndices.size; 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 = [];
}
} }
+282 -1
View File
@@ -1,12 +1,14 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
import type { BotInstanceOptions } 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 { TS3TextMessage } from "../ts-protocol/client.js";
import type { SpotifyController } from "../music/spotify/controller.js"; import type { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { MusicProvider } from "../music/provider.js"; import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js"; import type { BotDatabase } from "../data/database.js";
import { createDatabase } from "../data/database.js";
import type { AvatarStore } from "../data/avatars.js"; import type { AvatarStore } from "../data/avatars.js";
import type { BotConfig } from "../data/config.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"); 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<boolean>;
const loadSavedQueue = BotInstance.prototype.loadSavedQueue as (
this: unknown,
songs: unknown[],
mode: "replace" | "append",
requesterName?: string,
) => Promise<void>;
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<string>;
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<void>;
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();
}
});
});
+233 -7
View File
@@ -15,7 +15,7 @@ import {
import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js"; import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js"; import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
import { import {
isProviderEnabled, isProviderEnabled,
defaultPlatform, defaultPlatform,
@@ -175,6 +175,8 @@ export class BotInstance extends EventEmitter {
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop). /** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
* null when the wired provider has no reporting capability. */ * null when the wired provider has no reporting capability. */
private jellyfinReporter: JellyfinPlaybackReporter | null = null; private jellyfinReporter: JellyfinPlaybackReporter | null = null;
/** Debounce handle for the live-queue snapshot writer (Feature 2, #119). */
private snapshotTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: BotInstanceOptions) { constructor(options: BotInstanceOptions) {
super(); super();
@@ -268,6 +270,11 @@ export class BotInstance extends EventEmitter {
this.setupPlayerEvents(); this.setupPlayerEvents();
this.setupTsEvents(); 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 { private setupPlayerEvents(): void {
@@ -351,6 +358,15 @@ export class BotInstance extends EventEmitter {
// this.connected was never flipped to true. Previously this handler // this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing". // short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false; 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.spotifyController.stop();
this.currentSourceIsSpotify = false; this.currentSourceIsSpotify = false;
this.player.stop(); this.player.stop();
@@ -434,10 +450,22 @@ export class BotInstance extends EventEmitter {
this.connected = true; this.connected = true;
this.profileManager.onConnect(); this.profileManager.onConnect();
this.emit("connected"); 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 { disconnect(): void {
this._cancelIdleTimer(); 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.spotifyController.stop();
this.currentSourceIsSpotify = false; this.currentSourceIsSpotify = false;
this.player.stop(); this.player.stop();
@@ -720,6 +748,12 @@ export class BotInstance extends EventEmitter {
return this.cmdMove(cmd); return this.cmdMove(cmd);
case "follow": case "follow":
return this.cmdFollow(msg); return this.cmdFollow(msg);
case "save":
return this.cmdSaveQueue(cmd);
case "load":
return this.cmdLoadQueue(cmd);
case "queues":
return this.cmdListQueues();
case "help": case "help":
return this.cmdHelp(); return this.cmdHelp();
default: default:
@@ -1013,24 +1047,93 @@ export class BotInstance extends EventEmitter {
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
const song0 = song!; 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<boolean> {
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(); const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) { if (previous && !this.isSameSong(previous, s)) {
this.player.stop(); this.player.stop();
} }
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
this.queue.add(this.withRequester(song0, requesterName)); this.queue.add(s);
this.queue.play(); this.queue.play();
// Reset failure counter on user-initiated play // Reset failure counter on user-initiated play
this.player.resetFailures(); this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!); const ok = await this.resolveAndPlay(this.queue.current()!);
// Sweep AFTER the new song is queued+resolved: the replaced songs are no // 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 // longer referenced (and get deleted), but the song — if it is the same
// upload that was already playing — stays referenced and is preserved. // local upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced"); this.sweepLocalAudio("replaced");
if (!ok) return `Cannot play: ${song0.name}`; return ok;
return `Now playing: ${song0.name} - ${song0.artist}`; }
/**
* 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<void> {
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<string> { private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
@@ -1484,6 +1587,122 @@ export class BotInstance extends EventEmitter {
return "Following you to your channel"; 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<string> {
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<void> {
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 { private cmdHelp(): string {
const p = this.config.commandPrefix; const p = this.config.commandPrefix;
const def = defaultPlatform(this.config); const def = defaultPlatform(this.config);
@@ -1513,6 +1732,13 @@ export class BotInstance extends EventEmitter {
`${p}album <name or id> — Load album`, `${p}album <name or id> — Load album`,
`${p}fm — Personal FM (default source: ${def}; source flags work too)`, `${p}fm — Personal FM (default source: ${def}; source flags work too)`,
`${p}artist <name> — Play songs by artist (loop)`, `${p}artist <name> — 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}vote — Vote to skip`,
`${p}lyrics — Show lyrics`, `${p}lyrics — Show lyrics`,
`${p}now — Current song info`, `${p}now — Current song info`,
+24
View File
@@ -603,4 +603,28 @@ describe("loadConfig error handling", () => {
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content); 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);
});
}); });
+23
View File
@@ -116,6 +116,19 @@ export interface BotConfig {
idleTimeoutMinutes: number; idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */ /** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean; 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专属链接). // Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example: // Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000" // "https://music.example.com" or "http://1.2.3.4:3000"
@@ -165,6 +178,8 @@ export function getDefaultConfig(): BotConfig {
autoPauseOnEmpty: false, autoPauseOnEmpty: false,
idleTimeoutMinutes: 0, idleTimeoutMinutes: 0,
localAudioEnabled: true, localAudioEnabled: true,
savedQueuesEnabled: false,
playKeepsQueue: false,
publicUrl: "", publicUrl: "",
trustProxy: false, trustProxy: false,
guestMode: { guestMode: {
@@ -362,6 +377,12 @@ export function loadConfig(path: string): BotConfig {
) )
: defaults.enabledProviders; : 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 // defaultPlatform → an explicit operator default (issue #126). Keep it only
// when it names a KNOWN gateable provider that is ALSO currently enabled; // when it names a KNOWN gateable provider that is ALSO currently enabled;
// anything else (unknown value, disabled source, wrong type, missing) becomes // anything else (unknown value, disabled source, wrong type, missing) becomes
@@ -397,6 +418,8 @@ export function loadConfig(path: string): BotConfig {
jellyfin, jellyfin,
audioQuality, audioQuality,
enabledProviders, enabledProviders,
savedQueuesEnabled,
playKeepsQueue,
defaultPlatform: defaultPlatformPref, defaultPlatform: defaultPlatformPref,
}; };
} }
+81 -1
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest"; 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"; import { createUserStore, GUEST_USER_ID } from "./users.js";
describe("database", () => { describe("database", () => {
@@ -239,6 +239,86 @@ describe("database", () => {
botDb.setCustomAvatarPath("bot-1", null); botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); 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", () => { describe("guest principal migration", () => {
+211
View File
@@ -1,6 +1,46 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js"; import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.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<QueuedSong, "url">;
/** 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 { export interface PlayHistoryEntry {
botId: string; botId: string;
@@ -104,6 +144,15 @@ export interface BotDatabase {
removeFavorite(userId: string, playlistId: string, platform: string): boolean; removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[]; getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean; 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; close(): void;
} }
@@ -257,6 +306,28 @@ function initTables(db: Database.Database): void {
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE 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 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 = ? 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 { return {
db, db,
@@ -502,6 +634,85 @@ export function createDatabase(dbPath: string): BotDatabase {
return row !== undefined; 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() { close() {
db.close(); db.close();
}, },
+32
View File
@@ -325,6 +325,38 @@ describe("bot router /settings", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(config.spotify).toEqual(before); 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 // Whole-branch I2: saving a Client ID in Settings must re-configure the single
+14
View File
@@ -72,6 +72,8 @@ export function createBotRouter(
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled, localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
adminGroups: config.adminGroups ?? [], adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode, guestMode: config.guestMode,
spotify: maskedSpotify(), spotify: maskedSpotify(),
@@ -99,6 +101,16 @@ export function createBotRouter(
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled; 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"; const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) { if (hasGuestMode) {
const gm = config.guestMode; const gm = config.guestMode;
@@ -238,6 +250,8 @@ export function createBotRouter(
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled, localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
adminGroups: config.adminGroups ?? [], adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode, guestMode: config.guestMode,
spotify: maskedSpotify(), spotify: maskedSpotify(),
+15 -18
View File
@@ -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) => { router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
@@ -474,23 +478,16 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res); rejectDisabledLocalAudio(res);
return; return;
} }
const queue = bot.getQueueManager(); // The cleanupQueuedLocalSongs sweep now lives inside playSingleSong's
bot.getPlayer().stop(); // clear branch — do NOT also call it here, or it would delete retained
queue.clear(); // local uploads in keep-queue mode.
queue.add({ ...song, requestedBy: requesterName(req) }); const body = await bot.runExclusive(async () => {
queue.play(); const ok = await bot.playSingleSong({ ...song }, requesterName(req));
return ok
bot.getPlayer().resetFailures(); ? { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }
const ok = await bot.resolveAndPlay(queue.current()!); : { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
// 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. res.json(body);
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'}` });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
} }
+119
View File
@@ -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();
});
});
+122
View File
@@ -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;
}
+13
View File
@@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js"; import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js"; import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js"; import { createFavoritesRouter } from "./api/favorites.js";
import { createSavedQueuesRouter } from "./api/saved-queues.js";
import { createSpotifyRouter } from "./api/spotify.js"; import { createSpotifyRouter } from "./api/spotify.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { SpotifyProvider } from "../music/spotify/provider.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)); 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 // admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
+3
View File
@@ -166,6 +166,9 @@ onMounted(async () => {
// Search or Playlist render hearts correctly without first visiting Home. // Search or Playlist render hearts correctly without first visiting Home.
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.) // (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
playerStore.fetchFavorites(); 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); syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress); mobileRaf = requestAnimationFrame(updateMobileProgress);
// Reconcile the dedicated-link scope only after the bot list is known: the // Reconcile the dedicated-link scope only after the bot list is known: the
+6
View File
@@ -7,6 +7,12 @@
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink> <RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
<RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink> <RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink>
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink> <RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
<RouterLink
v-if="store.savedQueuesEnabled && !session.isGuest.value"
to="/saved-queues"
class="nav-link"
active-class="active"
>已存队列</RouterLink>
</div> </div>
<div class="nav-right"> <div class="nav-right">
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { isShared, sortQueues, SHARED_OWNER, type SavedQueueMeta } from './savedQueues';
const meta = (over: Partial<SavedQueueMeta>): SavedQueueMeta => ({
id: 1,
ownerId: 'u1',
name: 'q',
songCount: 0,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
...over,
});
describe('savedQueues helper', () => {
it('flags the shared owner', () => {
expect(isShared({ ownerId: SHARED_OWNER })).toBe(true);
expect(isShared({ ownerId: 'u1' })).toBe(false);
});
it('sorts by updatedAt descending (newest first)', () => {
const out = sortQueues([
meta({ id: 1, updatedAt: '2026-01-01' }),
meta({ id: 2, updatedAt: '2026-02-01' }),
meta({ id: 3, updatedAt: '2026-01-15' }),
]);
expect(out.map((q) => q.id)).toEqual([2, 3, 1]);
});
it('does not mutate the input array', () => {
const input = [meta({ id: 1, updatedAt: '2026-01-01' }), meta({ id: 2, updatedAt: '2026-02-01' })];
sortQueues(input);
expect(input.map((q) => q.id)).toEqual([1, 2]);
});
});
+27
View File
@@ -0,0 +1,27 @@
// Pure, framework-free helpers for the Saved Queues feature (#119). Kept
// separate from the API composable so the list/ownership logic is unit-testable
// without a DOM or axios.
export interface SavedQueueMeta {
id: number;
ownerId: string;
name: string;
songCount: number;
createdAt: string;
updatedAt: string;
}
/** Must match SHARED_QUEUE_OWNER in src/data/database.ts. */
export const SHARED_OWNER = '__shared__';
/** Whether a saved queue lives in the shared bucket (vs. private to a user). */
export function isShared(q: Pick<SavedQueueMeta, 'ownerId'>): boolean {
return q.ownerId === SHARED_OWNER;
}
/** Newest-updated first; stable for equal timestamps. */
export function sortQueues<T extends { updatedAt: string }>(qs: T[]): T[] {
return [...qs].sort((a, b) =>
a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0,
);
}
+52
View File
@@ -0,0 +1,52 @@
import { ref } from 'vue';
import axios from 'axios';
import { sortQueues, type SavedQueueMeta } from './savedQueues.js';
/**
* API composable for the Saved Queues feature (#119). Wraps /api/saved-queues.
* `disabled` becomes true when the server returns 403 (savedQueuesEnabled off),
* so the page can render a "feature disabled" state instead of an error.
*/
export function useSavedQueues() {
const queues = ref<SavedQueueMeta[]>([]);
const loading = ref(false);
const disabled = ref(false);
const error = ref<string | null>(null);
async function list(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await axios.get('/api/saved-queues');
queues.value = sortQueues((res.data?.queues ?? []) as SavedQueueMeta[]);
disabled.value = false;
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
if (status === 403) {
disabled.value = true;
queues.value = [];
} else {
error.value = '加载已保存队列失败';
}
} finally {
loading.value = false;
}
}
async function save(botId: string, name: string, shared: boolean): Promise<void> {
await axios.post('/api/saved-queues', { botId, name, shared });
await list();
}
async function load(id: number, botId: string, mode: 'replace' | 'append'): Promise<{ loaded: number; mode: string }> {
const res = await axios.post(`/api/saved-queues/${id}/load`, { botId, mode });
return res.data;
}
async function remove(id: number): Promise<void> {
await axios.delete(`/api/saved-queues/${id}`);
await list();
}
return { queues, loading, disabled, error, list, save, load, remove };
}
+1
View File
@@ -22,6 +22,7 @@ const router = createRouter({
}, },
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') }, { path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
{ path: '/history', name: 'history', component: () => import('../views/History.vue') }, { path: '/history', name: 'history', component: () => import('../views/History.vue') },
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') }, { path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') }, { path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') }, { path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
+16
View File
@@ -115,6 +115,10 @@ export const usePlayerStore = defineStore('player', {
enabledProviders: [] as string[], enabledProviders: [] as string[],
defaultSource: 'netease' as string, defaultSource: 'netease' as string,
// Whether the admin enabled save/load queues (#119). Gates the nav entry
// and the /saved-queues page. Fetched from /api/bot/settings (non-guest).
savedQueuesEnabled: false,
// Home page cache, split by source // Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] }, dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
@@ -595,6 +599,18 @@ export const usePlayerStore = defineStore('player', {
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform); return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
}, },
/** Load global bot settings we care about client-side (currently just the
* saved-queues gate). Guests get a 403 here, which we swallow so the nav
* entry simply stays hidden for them. */
async fetchBotSettings() {
try {
const res = await axios.get('/api/bot/settings');
this.savedQueuesEnabled = res.data?.savedQueuesEnabled === true;
} catch {
// not critical (guests 403; keep previous value otherwise)
}
},
/** Load the server-side source gate (enabledProviders + default). */ /** Load the server-side source gate (enabledProviders + default). */
async fetchProviders() { async fetchProviders() {
try { try {
+301
View File
@@ -0,0 +1,301 @@
<template>
<div class="saved-queues-page">
<button class="back-btn" @click="$router.back()">
<Icon icon="mdi:arrow-left" />
返回
</button>
<h1 class="page-title">已保存队列</h1>
<div v-if="disabled" class="empty">
此功能未启用。请在「设置 → 行为设置」中开启「保存/加载播放清单」。
</div>
<template v-else>
<!-- Save current queue -->
<section class="save-card">
<div class="save-title">保存当前队列</div>
<div class="save-hint">
将机器人 <strong>{{ activeBotName }}</strong> 当前的播放队列保存为一份清单,稍后可加载或追加。
</div>
<div class="save-row">
<input
v-model="newName"
class="input"
type="text"
placeholder="清单名称"
maxlength="80"
@keyup.enter="onSave"
/>
<label class="shared-check">
<input v-model="newShared" type="checkbox" />
共享
</label>
<button class="btn-primary" :disabled="!canSave || saving" @click="onSave">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</section>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="queues.length === 0" class="empty">还没有已保存的队列</div>
<div v-else class="queue-list">
<div v-for="q in queues" :key="q.id" class="queue-row">
<div class="queue-info">
<span class="queue-name">{{ q.name }}</span>
<span v-if="isShared(q)" class="badge shared">共享</span>
<span v-else class="badge private">私有</span>
<span class="queue-count">{{ q.songCount }} 首</span>
</div>
<div class="queue-actions">
<button class="btn-sm primary" :disabled="!activeBotId || busyId === q.id" @click="onLoad(q, 'replace')">
加载
</button>
<button class="btn-sm" :disabled="!activeBotId || busyId === q.id" @click="onLoad(q, 'append')">
追加
</button>
<button class="btn-sm danger" :disabled="busyId === q.id" @click="onDelete(q)">
删除
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
import { useSavedQueues } from '../composables/useSavedQueues.js';
import { isShared, type SavedQueueMeta } from '../composables/savedQueues.js';
const store = usePlayerStore();
const { queues, loading, disabled, list, save, load, remove } = useSavedQueues();
const newName = ref('');
const newShared = ref(false);
const saving = ref(false);
const busyId = ref<number | null>(null);
const activeBotId = computed(() => store.activeBotId);
const activeBotName = computed(() => store.activeBot?.name ?? '(未选择)');
const canSave = computed(() => !!activeBotId.value && newName.value.trim().length > 0);
async function onSave() {
if (!canSave.value || saving.value) return;
saving.value = true;
try {
await save(activeBotId.value!, newName.value.trim(), newShared.value);
store.notify(`已保存「${newName.value.trim()}」`, 'info');
newName.value = '';
newShared.value = false;
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
store.notify(msg || '保存失败', 'error');
} finally {
saving.value = false;
}
}
async function onLoad(q: SavedQueueMeta, mode: 'replace' | 'append') {
if (!activeBotId.value) return;
busyId.value = q.id;
try {
const res = await load(q.id, activeBotId.value, mode);
store.notify(
mode === 'append' ? `已追加「${q.name}」(${res.loaded} 首)` : `已加载「${q.name}」(${res.loaded} 首)`,
'info',
);
store.fetchQueue();
} catch {
store.notify('加载失败', 'error');
} finally {
busyId.value = null;
}
}
async function onDelete(q: SavedQueueMeta) {
busyId.value = q.id;
try {
await remove(q.id);
store.notify(`已删除「${q.name}」`, 'info');
} catch {
store.notify('删除失败', 'error');
} finally {
busyId.value = null;
}
}
onMounted(async () => {
if (!store.activeBotId) {
await store.fetchBots();
}
await list();
});
</script>
<style lang="scss" scoped>
.back-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
opacity: 0.7;
margin-bottom: 16px;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
.page-title {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
.save-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 18px;
margin-bottom: 24px;
}
.save-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.save-hint {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 14px;
}
.save-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 180px;
padding: 10px 12px;
font-size: 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: inherit;
&:focus { outline: none; border-color: var(--color-primary); }
}
.shared-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
cursor: pointer;
}
.btn-primary {
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
cursor: pointer;
transition: filter var(--transition-fast);
&:hover:not(:disabled) { filter: brightness(1.08); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.queue-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.queue-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
flex-wrap: wrap;
}
.queue-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.queue-name {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-count {
font-size: 12px;
color: var(--text-tertiary);
}
.badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
&.shared { background: var(--color-primary-15); color: var(--color-primary); }
&.private { background: var(--hover-bg); color: var(--text-secondary); }
}
.queue-actions {
display: flex;
gap: 6px;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-sm);
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover:not(:disabled) { background: var(--bg-secondary); border-color: var(--color-primary); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
&.primary { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
&.danger { color: #ef4444; }
}
.loading {
text-align: center;
padding: 60px;
color: var(--text-secondary);
}
.empty {
text-align: center;
padding: 60px 20px;
color: var(--text-tertiary);
font-size: 14px;
}
</style>
+48
View File
@@ -790,6 +790,32 @@
@change="saveLocalAudioEnabled" @change="saveLocalAudioEnabled"
/> />
</label> </label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">保存/加载播放清单(含重启后自动恢复队列)</div>
<div class="profile-toggle-hint">开启后可在网页与聊天命令(!save / !load / !queues)保存和加载播放清单,并在机器人重启后自动恢复并继续播放上次的队列。重启只能从当前曲目的开头恢复(不记忆进度);Spotify 恢复为尽力而为。默认关闭。</div>
</div>
<input
v-model="savedQueuesEnabled"
type="checkbox"
class="profile-toggle-switch"
@change="saveSavedQueuesEnabled"
/>
</label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">直接播放单曲时不清空队列</div>
<div class="profile-toggle-hint">开启后,直接播放单曲会插入到当前歌曲之后并立即播放,播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单/专辑/电台仍会替换队列。默认关闭。</div>
</div>
<input
v-model="playKeepsQueue"
type="checkbox"
class="profile-toggle-switch"
@change="savePlayKeepsQueue"
/>
</label>
</section> </section>
<!-- Guest Mode (admin only) --> <!-- Guest Mode (admin only) -->
@@ -1570,6 +1596,9 @@ const idleTimeout = ref(0);
// Defaults OFF to match the backend default (config.ts getDefaultConfig). // Defaults OFF to match the backend default (config.ts getDefaultConfig).
const autoPauseOnEmpty = ref(false); const autoPauseOnEmpty = ref(false);
const localAudioEnabled = ref(true); const localAudioEnabled = ref(true);
// Saved-queues + play-keeps-queue toggles (#119), both default OFF.
const savedQueuesEnabled = ref(false);
const playKeepsQueue = ref(false);
async function loadIdleTimeout() { async function loadIdleTimeout() {
try { try {
@@ -1577,6 +1606,9 @@ async function loadIdleTimeout() {
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
localAudioEnabled.value = res.data.localAudioEnabled ?? true; localAudioEnabled.value = res.data.localAudioEnabled ?? true;
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
playKeepsQueue.value = res.data.playKeepsQueue ?? false;
store.savedQueuesEnabled = savedQueuesEnabled.value;
applyGuestModeFromServer(res.data.guestMode); applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups); applyAdminGroupsFromServer(res.data.adminGroups);
applySpotifyConfig(res.data.spotify); applySpotifyConfig(res.data.spotify);
@@ -1610,6 +1642,22 @@ async function saveLocalAudioEnabled() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
async function saveSavedQueuesEnabled() {
try {
const res = await axios.post('/api/bot/settings', { savedQueuesEnabled: savedQueuesEnabled.value });
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? savedQueuesEnabled.value;
// Keep the shared store flag (nav gate) in sync without a reload.
store.savedQueuesEnabled = savedQueuesEnabled.value;
} catch { /* ignore */ }
}
async function savePlayKeepsQueue() {
try {
const res = await axios.post('/api/bot/settings', { playKeepsQueue: playKeepsQueue.value });
playKeepsQueue.value = res.data.playKeepsQueue ?? playKeepsQueue.value;
} catch { /* ignore */ }
}
// --- Spotify (Connect) settings (§8) --- // --- Spotify (Connect) settings (§8) ---
// NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth // NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth
// `authorized`). It is deliberately NOT wired to the player store's // `authorized`). It is deliberately NOT wired to the player store's