mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Design doc for issue #119: named per-user/shared save/load (chat + web), auto-restore-and-resume of the live queue across restart (both behind an admin-controlled savedQueuesEnabled flag, default off), and an independent playKeepsQueue toggle so single-song !play inserts-and-plays instead of clearing the queue. All toggles default off — no behavior change until opted in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
12 KiB
Markdown
228 lines
12 KiB
Markdown
# 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).
|