From f74b55ddbc26b2251b002efa5f83f1bb97660369 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Mon, 6 Jul 2026 15:53:08 +0800 Subject: [PATCH] docs(spec): design for save/load playlists + queue persistence (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-07-06-save-load-playlist-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-save-load-playlist-design.md diff --git a/docs/superpowers/specs/2026-07-06-save-load-playlist-design.md b/docs/superpowers/specs/2026-07-06-save-load-playlist-design.md new file mode 100644 index 0000000..79052c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-save-load-playlist-design.md @@ -0,0 +1,227 @@ +# Save/Load Playlists + Queue Persistence — Design + +**Issue:** [#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119) — 加入保存和加载播放清单功能 +**Date:** 2026-07-06 +**Status:** Approved (design), pending implementation plan + +## Problem + +The play queue lives only in memory (`PlayQueue` inside each `BotInstance`). It is lost in two situations the user calls out: + +1. **On restart** — the process stops, the in-memory queue is gone. +2. **On "直接播放"** — `!play ` (and the WebUI "play now" path) call `queue.clear()`, wiping the queue to play one song. + +The user wants to stop losing the queue. Two related-but-separate capabilities were agreed: + +- **Named save/load** of queues (manual), plus **auto-restore** of the live queue across restarts. +- An **independent** option to make single-song immediate-play *not* clear the queue. + +Everything ships **behind admin/independent toggles that default OFF**, so existing behavior is unchanged until an operator opts in. + +## Scope & agreed decisions + +| Decision | Choice | +| --- | --- | +| Core behavior | **Both** — named save/load **and** auto-restore live queue across restart | +| Saved-queue ownership | **Per-user** (like `favorite_playlists`), plus a reserved `__shared__` owner for chat + opt-in sharing | +| Trigger surface | **Web + chat** commands | +| Auto-restore on restart | **Restore and resume playing** (gated by `savedQueuesEnabled`) | +| Load semantics | **Replace (default) + append option** (`-a` flag / WebUI Append button) | +| Feature gate | `savedQueuesEnabled` — **default false, admin-controlled** | +| Single-play clear | Independent `playKeepsQueue` toggle — **default false** | + +### Out of scope (YAGNI) + +- Renaming a saved queue (delete + re-save instead). +- Mid-track resume on restart (resume from the current track's **start**; URLs are re-resolved). +- Normalized per-song storage (songs stored as a JSON blob). +- Sharing granularity beyond "private to me" vs "shared" (one boolean). + +## Storage approach + +A saved queue is an ordered list of songs that is only ever saved and loaded **whole** — never queried song-by-song. So songs are stored as a **JSON `TEXT` blob**, not a normalized child table. Each stored song is a `QueuedSong` **without `url`** (URLs are resolved lazily at play time, exactly as today). This mirrors how `QueuedSong` already flows and keeps the schema to a single row per saved queue. + +--- + +## Feature 1 — Named save/load (`savedQueuesEnabled`) + +### Data model + +New table: + +```sql +CREATE TABLE IF NOT EXISTS saved_queues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ownerId TEXT NOT NULL, -- WebUI user id, or the reserved SHARED owner + name TEXT NOT NULL, + songs TEXT NOT NULL, -- JSON array of stored songs (QueuedSong minus url) + songCount INTEGER NOT NULL DEFAULT 0, + createdAt TEXT NOT NULL DEFAULT (datetime('now')), + updatedAt TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(ownerId, name) +); +CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId); +``` + +- **`ownerId`** is either a real user id **or** a reserved constant `SHARED_QUEUE_OWNER = "__shared__"` (a value that can never collide with a real user id). +- **Ownership rule (reconciles per-user with chat):** + - **WebUI save** has a **"共享 (shared)" checkbox.** Off → `ownerId = req.user.id` (private to you). On → `ownerId = SHARED_QUEUE_OWNER`. + - **Chat `!save`** always writes `ownerId = SHARED_QUEUE_OWNER` (TeamSpeak users have no WebUI account). + - **WebUI list** shows **your own + shared** (labeled). **Chat `!queues`/`!load`** see **shared only**. +- **Overwrite:** `UNIQUE(ownerId, name)` → save is an **upsert** (same owner+name replaces `songs`, `songCount`, `updatedAt`). +- **Caps** (reject with a clear message): ≤ **50** saved queues per owner; ≤ **1000** songs per saved queue. + +### DB methods (added to `BotDatabase`) + +```ts +saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; // upsert +listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; // meta only (no songs blob) +getSavedQueue(id: number): SavedQueue | null; // full, with songs +deleteSavedQueue(id: number): boolean; +``` + +- `StoredSong` = `Omit`. +- `SavedQueueMeta` = row without the `songs` blob (id, ownerId, name, songCount, timestamps) — keeps list responses light. +- JSON (de)serialize at the DB boundary; parse failures on a corrupt blob degrade to an empty song list (never throw into a route). + +### Web API — `src/web/api/saved-queues.ts` + +All routes require auth + `player.queue` capability, and **404/403 when `savedQueuesEnabled` is false** (feature inert). + +| Method / path | Behavior | +| --- | --- | +| `GET /api/saved-queues` | list current user's own + shared (meta only) | +| `POST /api/saved-queues` | body `{ botId, name, shared? }` → snapshot that bot's **current queue** songs, upsert | +| `POST /api/saved-queues/:id/load` | body `{ botId, mode: "replace"\|"append" }` → load into that bot | +| `DELETE /api/saved-queues/:id` | delete (only own or shared; not another user's private) | + +Ownership check on load/delete: allow if `ownerId === req.user.id` or `ownerId === SHARED_QUEUE_OWNER`; else 404 (no existence leak, matching the favorites pattern). + +### Chat commands (new, in `BotInstance.executeCommand`) + +- `!save <名称>` — save current queue → shared bucket. +- `!load <名称>` — replace queue with a saved (shared) queue and play. +- `!load -a <名称>` — append a saved (shared) queue to the end. +- `!queues` — list shared saved queues (names + counts). + +All four reply **"此功能未启用"** when `savedQueuesEnabled` is false. Added to help text and the command table. + +### Load semantics (shared by web + chat) + +- **replace** — `queue.clear()`, add all stored songs, `queue.play()` + `resolveAndPlay(first)` (same shape as `cmdPlaylist`). Exits FM mode. +- **append** — add all stored songs to the end; if idle, start the first newly-added one; never interrupts a playing track. +- Loaded songs are re-tagged with a `requestedBy` of the loader (WebUI username / `游客` / chat `invokerName`) so play-history attribution stays correct (integrates with #121). + +### WebUI + +A new **"已保存队列 / Saved Queues"** page (nav entry visible only when `savedQueuesEnabled`): + +- **Save current queue**: name input + **共享** checkbox → `POST`. +- Per entry (reusing `SongCard`/list styles): **Load** (replace), **Append**, **Delete**, showing name / song count / shared badge / owner. +- Store/composable follows the existing `favorites` pattern. + +--- + +## Feature 2 — Auto-restore live queue across restart (`savedQueuesEnabled`) + +Gated by the **same** `savedQueuesEnabled` flag (part of the "saved queues" feature). + +### Data model + +One row per bot (the live snapshot, continuously overwritten): + +```sql +CREATE TABLE IF NOT EXISTS queue_state ( + botId TEXT PRIMARY KEY, + songs TEXT NOT NULL, -- JSON array of StoredSong + currentIndex INTEGER NOT NULL, + mode TEXT NOT NULL, -- PlayMode value + isFmMode INTEGER NOT NULL DEFAULT 0, + fmPlatform TEXT NOT NULL DEFAULT '', + updatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +DB methods: `saveQueueState(state)` (upsert), `getQueueState(botId)`, `clearQueueState(botId)`. + +### Snapshot (write path) + +- Add `PlayQueue.snapshot(): QueueSnapshot` and `PlayQueue.restore(snapshot)`. + - `snapshot` captures `songs` (minus url), `currentIndex`, `mode`. + - `restore` rebuilds `songs`, `currentIndex`, `mode`, and resets the derived `playedIndices`/`history`/`forwardStack` to a clean, consistent state for the restored index. +- `BotInstance` writes the snapshot **debounced (~1 s)** on `stateChange` (queue mutations, track changes, and mode changes already emit `stateChange`). FM mode + fm platform captured alongside. +- When the queue becomes empty (`clear()` with nothing re-added), the row is cleared via `clearQueueState`. + +### Restore (read path — "resume and play") + +When a bot reaches **connected/ready** (the same lifecycle point `autoStart` uses): + +1. If `savedQueuesEnabled` and a `queue_state` row exists → `PlayQueue.restore(...)`, restore FM mode/provider. +2. If there was a current track → `resolveAndPlay(current)` (re-resolves URL, plays **from the track's start**). + +**Honest caveats (documented in README + spec):** + +- Resumes the **current track from its start**, not the exact millisecond (URLs are re-resolved; no persisted elapsed/seek). +- **Spotify** auto-resume is **best-effort** — it depends on the sidecar/controller being back up; non-Spotify sources are reliable. +- Resume only happens for bots that reach the connected state (auto-started, or on next manual start). + +--- + +## Feature 3 — `playKeepsQueue` (independent single-play toggle) + +**Independent** config flag, **not** gated by `savedQueuesEnabled`. Default false → today's behavior. + +Affects **single-song immediate play only**: chat `!play | URL>` and the WebUI play-now / play-by-id path. + +| `playKeepsQueue` | `!play ` behavior | +| --- | --- | +| `false` (default) | `queue.clear()` → play only that song (today) | +| `true` | `addNext(song)` (insert after current) → `playAt(insertedAt)` (jump & play now) → `resolveAndPlay`; queue **kept**; on track-end, `next()` continues the queue | + +- Reuses existing `PlayQueue.addNext` + `playAt` — **no new queue logic.** +- **Not** applied to collection loads — `!playlist` / `!album` / `!artist` / `!fm` still replace the queue (loading a collection is meant to replace; the request is about 单曲/single songs). +- **Empty queue** → equivalent to a normal play (nothing to preserve). +- **FM mode** → `!play` still exits FM (manual takeover), but existing queued songs are preserved and continue after the single song (auto-refill stops because FM is off). Documented. + +--- + +## Config + +Add to `BotConfig` (in `src/data/config.ts`), both **default false**, both sanitized on load exactly like `localAudioEnabled` / `autoPauseOnEmpty` (so a hand-edited / legacy / corrupt `config.json` can never silently enable them): + +```ts +savedQueuesEnabled: boolean; // default false — gates Features 1 & 2 (admin-controlled) +playKeepsQueue: boolean; // default false — independent (Feature 3) +``` + +- Set via **Settings → 行为设置** (the existing admin behavior-settings surface, written through `POST /api/bot/settings`). +- When `savedQueuesEnabled` is false: chat save/load/queues reply "此功能未启用"; `/api/saved-queues/*` return 403/404; the WebUI page/nav is hidden; no snapshotting; no auto-restore. + +## Error handling & edge cases + +- Corrupt `songs` JSON blob → treated as empty list; never throws into a route or the restore path. +- Save with a duplicate name → upsert (overwrite), not an error. +- Load/delete of a non-owned private queue → 404. +- Caps exceeded → 4xx with a clear message (web) / friendly reply (chat). +- Snapshot writes are best-effort and debounced; a DB write failure logs and never interrupts playback. +- Restore of a Spotify-containing queue → best-effort per source; failures skip to next (existing `resolveAndPlay` skip behavior). + +## Testing (TDD) + +- **DB:** `saveQueue` upsert + caps; `listSavedQueues` own vs shared; `getSavedQueue`/`deleteSavedQueue`; ownership; `queue_state` upsert/get/clear; JSON round-trip + corrupt-blob degradation. +- **PlayQueue:** `snapshot`/`restore` round-trip (songs, index, mode; derived state consistent). +- **BotInstance:** `!save`/`!load`/`!load -a`/`!queues`; feature-disabled replies; snapshot-on-stateChange (debounced); resume-on-ready; `playKeepsQueue` insert-and-jump vs clear; collections still replace; FM interaction. +- **Web API:** auth + capability + feature-gate (403/404); save (own/shared); load replace/append; delete ownership; caps. +- **Config:** defaults false; load sanitization (legacy/corrupt/non-boolean → false). +- **Frontend:** Saved Queues page (save w/ shared toggle, load, append, delete, hidden when disabled); store/composable. +- Then: full suite (`npx vitest run --no-file-parallelism`) + `npx tsc --noEmit` + `cd web && npm run build`. + +## Rollout / staging + +Implement in stages (each independently valuable, all default-off): + +1. **Config + gates** — `savedQueuesEnabled`, `playKeepsQueue`, sanitization, Settings UI. +2. **Feature 3** — `playKeepsQueue` single-play behavior (small, self-contained). +3. **Feature 1** — named save/load (DB → API → chat → WebUI page). +4. **Feature 2** — live-queue snapshot + resume-on-restart. +5. **Docs** — README (commands, toggles, caveats).