mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
b8ec50c7f7
commit
69a2e8c264
14 files changed
+1250
-26
No files matched your search
@@ -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
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user