feat(#119): save/load queues + live-queue persistence + playKeepsQueue (backend)

Add three default-off capabilities that stop the play queue from being lost,
all gated behind admin/independent toggles so existing behavior is unchanged
until an operator opts in:

- Named save/load of queues (Feature 1): new saved_queues table (per-user +
  reserved __shared__ owner, capped at 50 queues / 1000 songs, JSON song blob
  that degrades to empty on corruption); /api/saved-queues router (list/save/
  load/delete with ownership 404s, inert 403 when disabled); chat commands
  !save / !load [-a] / !queues; BotInstance.loadSavedQueue (replace/append).
- Auto-restore live queue across restart (Feature 2): PlayQueue.snapshot/restore,
  queue_state table (one row per bot), a debounced snapshot writer driven off
  stateChange, and restore+resume on connect. Cancels the pending snapshot on
  disconnect so a stale write can't wipe the row a restart must restore.
- playKeepsQueue (Feature 3): BotInstance.playSingleSong funnels chat !play and
  the web /play-song route through one place; when enabled a single-song play
  inserts-after-current and jumps instead of clearing the queue.

Config gains savedQueuesEnabled + playKeepsQueue (both default false, strict-
coerced on load like spotify.enabled); the settings API round-trips them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Fable 5 committed 2026-07-17 01:28:48 +08:00
1 parent b8ec50c7f7
commit 69a2e8c264
14 files changed
+1250 -26

No files matched your search

+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"]);
});
});
});