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>
12 KiB
Save/Load Playlists + Queue Persistence — Design
Issue: #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:
- On restart — the process stops, the in-memory queue is gone.
- On "直接播放" —
!play <song>(and the WebUI "play now" path) callqueue.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:
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);
ownerIdis either a real user id or a reserved constantSHARED_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
!savealways writesownerId = SHARED_QUEUE_OWNER(TeamSpeak users have no WebUI account). - WebUI list shows your own + shared (labeled). Chat
!queues/!loadsee shared only.
- WebUI save has a "共享 (shared)" checkbox. Off →
- Overwrite:
UNIQUE(ownerId, name)→ save is an upsert (same owner+name replacessongs,songCount,updatedAt). - Caps (reject with a clear message): ≤ 50 saved queues per owner; ≤ 1000 songs per saved queue.
DB methods (added to BotDatabase)
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 thesongsblob (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 ascmdPlaylist). 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
requestedByof the loader (WebUI username /游客/ chatinvokerName) 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
favoritespattern.
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):
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(): QueueSnapshotandPlayQueue.restore(snapshot).snapshotcapturessongs(minus url),currentIndex,mode.restorerebuildssongs,currentIndex,mode, and resets the derivedplayedIndices/history/forwardStackto a clean, consistent state for the restored index.
BotInstancewrites the snapshot debounced (~1 s) onstateChange(queue mutations, track changes, and mode changes already emitstateChange). FM mode + fm platform captured alongside.- When the queue becomes empty (
clear()with nothing re-added), the row is cleared viaclearQueueState.
Restore (read path — "resume and play")
When a bot reaches connected/ready (the same lifecycle point autoStart uses):
- If
savedQueuesEnabledand aqueue_staterow exists →PlayQueue.restore(...), restore FM mode/provider. - 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/!fmstill 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 →
!playstill 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):
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
savedQueuesEnabledis 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
songsJSON 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
resolveAndPlayskip behavior).
Testing (TDD)
- DB:
saveQueueupsert + caps;listSavedQueuesown vs shared;getSavedQueue/deleteSavedQueue; ownership;queue_stateupsert/get/clear; JSON round-trip + corrupt-blob degradation. - PlayQueue:
snapshot/restoreround-trip (songs, index, mode; derived state consistent). - BotInstance:
!save/!load/!load -a/!queues; feature-disabled replies; snapshot-on-stateChange (debounced); resume-on-ready;playKeepsQueueinsert-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):
- Config + gates —
savedQueuesEnabled,playKeepsQueue, sanitization, Settings UI. - Feature 3 —
playKeepsQueuesingle-play behavior (small, self-contained). - Feature 1 — named save/load (DB → API → chat → WebUI page).
- Feature 2 — live-queue snapshot + resume-on-restart.
- Docs — README (commands, toggles, caveats).