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

+282
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } 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 { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
@@ -972,3 +975,282 @@ describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
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 { splitTextIntoChunks } from "./text-chunk.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 {
isProviderEnabled,
defaultPlatform,
@@ -166,6 +166,8 @@ export class BotInstance extends EventEmitter {
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
* null when the wired provider has no reporting capability. */
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) {
super();
@@ -246,6 +248,11 @@ export class BotInstance extends EventEmitter {
this.setupPlayerEvents();
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 {
@@ -329,6 +336,15 @@ export class BotInstance extends EventEmitter {
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
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.currentSourceIsSpotify = false;
this.player.stop();
@@ -412,10 +428,22 @@ export class BotInstance extends EventEmitter {
this.connected = true;
this.profileManager.onConnect();
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 {
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.currentSourceIsSpotify = false;
this.player.stop();
@@ -698,6 +726,12 @@ export class BotInstance extends EventEmitter {
return this.cmdMove(cmd);
case "follow":
return this.cmdFollow(msg);
case "save":
return this.cmdSaveQueue(cmd);
case "load":
return this.cmdLoadQueue(cmd);
case "queues":
return this.cmdListQueues();
case "help":
return this.cmdHelp();
default:
@@ -991,24 +1025,93 @@ export class BotInstance extends EventEmitter {
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
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();
if (previous && !this.isSameSong(previous, song0)) {
if (previous && !this.isSameSong(previous, s)) {
this.player.stop();
}
this.queue.clear();
this.disableFmMode();
this.queue.add(this.withRequester(song0, requesterName));
this.queue.add(s);
this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
// 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
// upload that was already playing — stays referenced and is preserved.
// longer referenced (and get deleted), but the song — if it is the same
// local upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced");
if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`;
return ok;
}
/**
* 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> {
@@ -1439,6 +1542,122 @@ export class BotInstance extends EventEmitter {
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 {
const p = this.config.commandPrefix;
const def = defaultPlatform(this.config);
@@ -1468,6 +1687,13 @@ export class BotInstance extends EventEmitter {
`${p}album <name or id> — Load album`,
`${p}fm — Personal FM (default source: ${def}; source flags work too)`,
`${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}lyrics — Show lyrics`,
`${p}now — Current song info`,