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
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,6 +17,18 @@ export interface QueuedSong {
|
|||||||
requestedBy?: string;
|
requestedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persistable view of a queue: its songs (minus the lazily-resolved `url`),
|
||||||
|
* the current index, and the play mode. Used to snapshot/restore the live queue
|
||||||
|
* across restarts (issue #119). Derived state (playedIndices/history/forward
|
||||||
|
* stack) is intentionally NOT captured — restore() rebuilds it consistently.
|
||||||
|
*/
|
||||||
|
export interface QueueSnapshot {
|
||||||
|
songs: Omit<QueuedSong, "url">[];
|
||||||
|
currentIndex: number;
|
||||||
|
mode: PlayMode;
|
||||||
|
}
|
||||||
|
|
||||||
export class PlayQueue {
|
export class PlayQueue {
|
||||||
private songs: QueuedSong[] = [];
|
private songs: QueuedSong[] = [];
|
||||||
private currentIndex = -1;
|
private currentIndex = -1;
|
||||||
@@ -273,4 +285,32 @@ export class PlayQueue {
|
|||||||
unplayedCount(): number {
|
unplayedCount(): number {
|
||||||
return this.songs.length - this.playedIndices.size;
|
return this.songs.length - this.playedIndices.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the queue as a persistable snapshot (songs minus `url`, current
|
||||||
|
* index, mode). Songs keep their `requestedBy` so restored play-history
|
||||||
|
* attribution stays correct. See restore().
|
||||||
|
*/
|
||||||
|
snapshot(): QueueSnapshot {
|
||||||
|
return {
|
||||||
|
songs: this.songs.map(({ url: _url, ...s }) => s),
|
||||||
|
currentIndex: this.currentIndex,
|
||||||
|
mode: this.mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the queue contents from a snapshot. Rebuilds the derived
|
||||||
|
* playedIndices/history/forwardStack to a clean, consistent state for the
|
||||||
|
* restored index (an out-of-range index degrades to -1 = "nothing current").
|
||||||
|
*/
|
||||||
|
restore(s: QueueSnapshot): void {
|
||||||
|
this.songs = s.songs.map((song) => ({ ...song }));
|
||||||
|
this.mode = s.mode;
|
||||||
|
this.currentIndex =
|
||||||
|
s.currentIndex >= 0 && s.currentIndex < this.songs.length ? s.currentIndex : -1;
|
||||||
|
this.playedIndices = new Set(this.currentIndex >= 0 ? [this.currentIndex] : []);
|
||||||
|
this.history = [];
|
||||||
|
this.forwardStack = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
|
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
|
||||||
import type { BotInstanceOptions } 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 { TS3TextMessage } from "../ts-protocol/client.js";
|
||||||
import type { SpotifyController } from "../music/spotify/controller.js";
|
import type { SpotifyController } from "../music/spotify/controller.js";
|
||||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.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");
|
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 { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
import type { Logger } from "../logger.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 {
|
import {
|
||||||
isProviderEnabled,
|
isProviderEnabled,
|
||||||
defaultPlatform,
|
defaultPlatform,
|
||||||
@@ -166,6 +166,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
||||||
* null when the wired provider has no reporting capability. */
|
* null when the wired provider has no reporting capability. */
|
||||||
private jellyfinReporter: JellyfinPlaybackReporter | null = null;
|
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) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -246,6 +248,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
this.setupPlayerEvents();
|
this.setupPlayerEvents();
|
||||||
this.setupTsEvents();
|
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 {
|
private setupPlayerEvents(): void {
|
||||||
@@ -329,6 +336,15 @@ export class BotInstance extends EventEmitter {
|
|||||||
// this.connected was never flipped to true. Previously this handler
|
// this.connected was never flipped to true. Previously this handler
|
||||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||||
this.connected = false;
|
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.spotifyController.stop();
|
||||||
this.currentSourceIsSpotify = false;
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
@@ -412,10 +428,22 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.profileManager.onConnect();
|
this.profileManager.onConnect();
|
||||||
this.emit("connected");
|
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 {
|
disconnect(): void {
|
||||||
this._cancelIdleTimer();
|
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.spotifyController.stop();
|
||||||
this.currentSourceIsSpotify = false;
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
@@ -698,6 +726,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
return this.cmdMove(cmd);
|
return this.cmdMove(cmd);
|
||||||
case "follow":
|
case "follow":
|
||||||
return this.cmdFollow(msg);
|
return this.cmdFollow(msg);
|
||||||
|
case "save":
|
||||||
|
return this.cmdSaveQueue(cmd);
|
||||||
|
case "load":
|
||||||
|
return this.cmdLoadQueue(cmd);
|
||||||
|
case "queues":
|
||||||
|
return this.cmdListQueues();
|
||||||
case "help":
|
case "help":
|
||||||
return this.cmdHelp();
|
return this.cmdHelp();
|
||||||
default:
|
default:
|
||||||
@@ -991,24 +1025,93 @@ export class BotInstance extends EventEmitter {
|
|||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
const song0 = song!;
|
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();
|
const previous = this.queue.current();
|
||||||
if (previous && !this.isSameSong(previous, song0)) {
|
if (previous && !this.isSameSong(previous, s)) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
}
|
}
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
this.queue.add(this.withRequester(song0, requesterName));
|
this.queue.add(s);
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
// Reset failure counter on user-initiated play
|
// Reset failure counter on user-initiated play
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
|
// 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
|
// longer referenced (and get deleted), but the song — if it is the same
|
||||||
// upload that was already playing — stays referenced and is preserved.
|
// local upload that was already playing — stays referenced and is preserved.
|
||||||
this.sweepLocalAudio("replaced");
|
this.sweepLocalAudio("replaced");
|
||||||
if (!ok) return `Cannot play: ${song0.name}`;
|
return ok;
|
||||||
return `Now playing: ${song0.name} - ${song0.artist}`;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
@@ -1439,6 +1542,122 @@ export class BotInstance extends EventEmitter {
|
|||||||
return "Following you to your channel";
|
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 {
|
private cmdHelp(): string {
|
||||||
const p = this.config.commandPrefix;
|
const p = this.config.commandPrefix;
|
||||||
const def = defaultPlatform(this.config);
|
const def = defaultPlatform(this.config);
|
||||||
@@ -1468,6 +1687,13 @@ export class BotInstance extends EventEmitter {
|
|||||||
`${p}album <name or id> — Load album`,
|
`${p}album <name or id> — Load album`,
|
||||||
`${p}fm — Personal FM (default source: ${def}; source flags work too)`,
|
`${p}fm — Personal FM (default source: ${def}; source flags work too)`,
|
||||||
`${p}artist <name> — Play songs by artist (loop)`,
|
`${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}vote — Vote to skip`,
|
||||||
`${p}lyrics — Show lyrics`,
|
`${p}lyrics — Show lyrics`,
|
||||||
`${p}now — Current song info`,
|
`${p}now — Current song info`,
|
||||||
|
|||||||
@@ -494,4 +494,28 @@ describe("loadConfig error handling", () => {
|
|||||||
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content);
|
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults savedQueuesEnabled and playKeepsQueue to false", () => {
|
||||||
|
const c = getDefaultConfig();
|
||||||
|
expect(c.savedQueuesEnabled).toBe(false);
|
||||||
|
expect(c.playKeepsQueue).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces non-boolean savedQueues/playKeepsQueue values to false on load", () => {
|
||||||
|
const dir = makeTmpDir();
|
||||||
|
const path = join(dir, "config.json");
|
||||||
|
writeFileSync(path, JSON.stringify({ savedQueuesEnabled: "yes", playKeepsQueue: 1 }));
|
||||||
|
const c = loadConfig(path);
|
||||||
|
expect(c.savedQueuesEnabled).toBe(false);
|
||||||
|
expect(c.playKeepsQueue).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves savedQueues/playKeepsQueue true when explicitly enabled", () => {
|
||||||
|
const dir = makeTmpDir();
|
||||||
|
const path = join(dir, "config.json");
|
||||||
|
writeFileSync(path, JSON.stringify({ savedQueuesEnabled: true, playKeepsQueue: true }));
|
||||||
|
const c = loadConfig(path);
|
||||||
|
expect(c.savedQueuesEnabled).toBe(true);
|
||||||
|
expect(c.playKeepsQueue).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -91,6 +91,19 @@ export interface BotConfig {
|
|||||||
idleTimeoutMinutes: number;
|
idleTimeoutMinutes: number;
|
||||||
/** Enable uploading and playback of server-stored local audio files. */
|
/** Enable uploading and playback of server-stored local audio files. */
|
||||||
localAudioEnabled: boolean;
|
localAudioEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Enable named save/load of queues (chat + web) AND auto-restore of the live
|
||||||
|
* queue across a restart. Admin-controlled; default false so nothing is
|
||||||
|
* persisted/restored until an operator opts in.
|
||||||
|
*/
|
||||||
|
savedQueuesEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* When true, a single-song immediate !play (chat) / play-song (web) inserts
|
||||||
|
* after the current track and jumps to it instead of clearing the queue, so
|
||||||
|
* the rest of the queue survives and continues afterwards. Default false
|
||||||
|
* keeps today's clear-and-play behavior.
|
||||||
|
*/
|
||||||
|
playKeepsQueue: boolean;
|
||||||
// Public base URL used when generating share links (e.g. the bot专属链接).
|
// Public base URL used when generating share links (e.g. the bot专属链接).
|
||||||
// Leave empty to use the browser's current origin. Example:
|
// Leave empty to use the browser's current origin. Example:
|
||||||
// "https://music.example.com" or "http://1.2.3.4:3000"
|
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||||
@@ -130,6 +143,8 @@ export function getDefaultConfig(): BotConfig {
|
|||||||
autoPauseOnEmpty: false,
|
autoPauseOnEmpty: false,
|
||||||
idleTimeoutMinutes: 0,
|
idleTimeoutMinutes: 0,
|
||||||
localAudioEnabled: true,
|
localAudioEnabled: true,
|
||||||
|
savedQueuesEnabled: false,
|
||||||
|
playKeepsQueue: false,
|
||||||
publicUrl: "",
|
publicUrl: "",
|
||||||
trustProxy: false,
|
trustProxy: false,
|
||||||
guestMode: {
|
guestMode: {
|
||||||
@@ -317,6 +332,12 @@ export function loadConfig(path: string): BotConfig {
|
|||||||
)
|
)
|
||||||
: defaults.enabledProviders;
|
: defaults.enabledProviders;
|
||||||
|
|
||||||
|
// Strict-coerce the two feature flags exactly like spotify.enabled so a
|
||||||
|
// hand-edited / legacy / corrupt config.json can never silently enable
|
||||||
|
// them (`"yes"`, `1`, `null` → false; only a literal `true` enables).
|
||||||
|
const savedQueuesEnabled = partial.savedQueuesEnabled === true;
|
||||||
|
const playKeepsQueue = partial.playKeepsQueue === true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...partial,
|
...partial,
|
||||||
@@ -325,6 +346,8 @@ export function loadConfig(path: string): BotConfig {
|
|||||||
spotify,
|
spotify,
|
||||||
jellyfin,
|
jellyfin,
|
||||||
enabledProviders,
|
enabledProviders,
|
||||||
|
savedQueuesEnabled,
|
||||||
|
playKeepsQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
|
import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
|
||||||
import { createUserStore, GUEST_USER_ID } from "./users.js";
|
import { createUserStore, GUEST_USER_ID } from "./users.js";
|
||||||
|
|
||||||
describe("database", () => {
|
describe("database", () => {
|
||||||
@@ -153,6 +153,86 @@ describe("database", () => {
|
|||||||
botDb.setCustomAvatarPath("bot-1", null);
|
botDb.setCustomAvatarPath("bot-1", null);
|
||||||
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sq = (id: string) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
artist: "",
|
||||||
|
album: "",
|
||||||
|
platform: "netease" as const,
|
||||||
|
coverUrl: "",
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saved_queues", () => {
|
||||||
|
it("upserts by (ownerId, name) and returns songs", () => {
|
||||||
|
botDb.saveQueue("u1", "night", [sq("a"), sq("b")]);
|
||||||
|
const again = botDb.saveQueue("u1", "night", [sq("c")]); // overwrite
|
||||||
|
expect(again.songCount).toBe(1);
|
||||||
|
expect(botDb.listSavedQueues("u1", false)).toHaveLength(1);
|
||||||
|
const full = botDb.getSavedQueue(again.id)!;
|
||||||
|
expect(full.songs.map((s) => s.id)).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips url before persisting", () => {
|
||||||
|
const saved = botDb.saveQueue("u1", "x", [
|
||||||
|
{ ...sq("a"), url: "http://example.com/a.mp3" } as never,
|
||||||
|
]);
|
||||||
|
const full = botDb.getSavedQueue(saved.id)!;
|
||||||
|
expect((full.songs[0] as { url?: string }).url).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists own + shared when includeShared, own-only otherwise", () => {
|
||||||
|
botDb.saveQueue("u1", "mine", [sq("a")]);
|
||||||
|
botDb.saveQueue(SHARED_QUEUE_OWNER, "party", [sq("b")]);
|
||||||
|
expect(botDb.listSavedQueues("u1", false).map((q) => q.name)).toEqual(["mine"]);
|
||||||
|
expect(
|
||||||
|
botDb.listSavedQueues("u1", true).map((q) => q.name).sort(),
|
||||||
|
).toEqual(["mine", "party"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps songs at 1000 and queues at 50", () => {
|
||||||
|
expect(() =>
|
||||||
|
botDb.saveQueue("u1", "big", Array.from({ length: 1001 }, (_, i) => sq("s" + i))),
|
||||||
|
).toThrow(/1000/);
|
||||||
|
for (let i = 0; i < 50; i++) botDb.saveQueue("u1", "q" + i, [sq("a")]);
|
||||||
|
expect(() => botDb.saveQueue("u1", "q50", [sq("a")])).toThrow(/50/);
|
||||||
|
// Overwriting an existing name is always allowed despite the cap.
|
||||||
|
expect(() => botDb.saveQueue("u1", "q0", [sq("z")])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes and degrades a corrupt blob to empty", () => {
|
||||||
|
const q = botDb.saveQueue("u1", "x", [sq("a")]);
|
||||||
|
botDb.db.prepare("UPDATE saved_queues SET songs='not json' WHERE id=?").run(q.id);
|
||||||
|
expect(botDb.getSavedQueue(q.id)!.songs).toEqual([]);
|
||||||
|
expect(botDb.deleteSavedQueue(q.id)).toBe(true);
|
||||||
|
expect(botDb.getSavedQueue(q.id)).toBeNull();
|
||||||
|
expect(botDb.deleteSavedQueue(q.id)).toBe(false); // already gone
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queue_state", () => {
|
||||||
|
it("upserts, reads back, and clears per bot", () => {
|
||||||
|
botDb.saveQueueState({ botId: "b1", songs: [sq("a")], currentIndex: 0, mode: "loop", isFmMode: true, fmPlatform: "netease" });
|
||||||
|
botDb.saveQueueState({ botId: "b1", songs: [sq("a"), sq("b")], currentIndex: 1, mode: "seq", isFmMode: false, fmPlatform: "" });
|
||||||
|
const st = botDb.getQueueState("b1")!;
|
||||||
|
expect(st.songs.map((s) => s.id)).toEqual(["a", "b"]);
|
||||||
|
expect(st.currentIndex).toBe(1);
|
||||||
|
expect(st.mode).toBe("seq");
|
||||||
|
expect(st.isFmMode).toBe(false);
|
||||||
|
botDb.clearQueueState("b1");
|
||||||
|
expect(botDb.getQueueState("b1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips FM flags and degrades a corrupt blob", () => {
|
||||||
|
botDb.saveQueueState({ botId: "b2", songs: [sq("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" });
|
||||||
|
const st = botDb.getQueueState("b2")!;
|
||||||
|
expect(st.isFmMode).toBe(true);
|
||||||
|
expect(st.fmPlatform).toBe("qq");
|
||||||
|
botDb.db.prepare("UPDATE queue_state SET songs='{' WHERE botId=?").run("b2");
|
||||||
|
expect(botDb.getQueueState("b2")!.songs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("guest principal migration", () => {
|
describe("guest principal migration", () => {
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
|
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
|
||||||
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
|
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
|
||||||
|
import type { QueuedSong } from "../audio/queue.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved owner id for chat-saved / opt-in-shared queues. A `__`-bracketed
|
||||||
|
* literal can never collide with a real WebUI user id (UUIDs), so it cleanly
|
||||||
|
* partitions "shared" saved queues from per-user private ones (issue #119).
|
||||||
|
*/
|
||||||
|
export const SHARED_QUEUE_OWNER = "__shared__";
|
||||||
|
/** Cap per owner (private user OR the shared bucket). */
|
||||||
|
export const MAX_SAVED_QUEUES = 50;
|
||||||
|
/** Cap per saved queue / persisted live-queue snapshot. */
|
||||||
|
export const MAX_QUEUE_SONGS = 1000;
|
||||||
|
|
||||||
|
/** A stored song is a QueuedSong minus the lazily-resolved `url`. */
|
||||||
|
export type StoredSong = Omit<QueuedSong, "url">;
|
||||||
|
|
||||||
|
/** Saved-queue row without the (potentially large) songs blob — for list views. */
|
||||||
|
export interface SavedQueueMeta {
|
||||||
|
id: number;
|
||||||
|
ownerId: string;
|
||||||
|
name: string;
|
||||||
|
songCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full saved queue, including its songs. */
|
||||||
|
export interface SavedQueue extends SavedQueueMeta {
|
||||||
|
songs: StoredSong[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-row-per-bot persisted live-queue state (Feature 2, auto-restore). */
|
||||||
|
export interface QueueStateRow {
|
||||||
|
botId: string;
|
||||||
|
songs: StoredSong[];
|
||||||
|
currentIndex: number;
|
||||||
|
mode: string;
|
||||||
|
isFmMode: boolean;
|
||||||
|
fmPlatform: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayHistoryEntry {
|
export interface PlayHistoryEntry {
|
||||||
botId: string;
|
botId: string;
|
||||||
@@ -81,6 +121,15 @@ export interface BotDatabase {
|
|||||||
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
||||||
getFavorites(userId: string): FavoritePlaylist[];
|
getFavorites(userId: string): FavoritePlaylist[];
|
||||||
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
||||||
|
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
|
||||||
|
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
|
||||||
|
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
||||||
|
getSavedQueue(id: number): SavedQueue | null;
|
||||||
|
deleteSavedQueue(id: number): boolean;
|
||||||
|
// Live-queue persistence (Feature 2) — one row per bot.
|
||||||
|
saveQueueState(state: QueueStateRow): void;
|
||||||
|
getQueueState(botId: string): QueueStateRow | null;
|
||||||
|
clearQueueState(botId: string): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +272,28 @@ function initTables(db: Database.Database): void {
|
|||||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_queues (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ownerId TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
songs TEXT NOT NULL,
|
||||||
|
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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS queue_state (
|
||||||
|
botId TEXT PRIMARY KEY,
|
||||||
|
songs TEXT NOT NULL,
|
||||||
|
currentIndex INTEGER NOT NULL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
isFmMode INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fmPlatform TEXT NOT NULL DEFAULT '',
|
||||||
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +413,67 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// A corrupt/hand-edited songs blob must never throw into a route or the
|
||||||
|
// restore path — degrade to an empty list instead.
|
||||||
|
const parseSongs = (raw: string): StoredSong[] => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
return Array.isArray(v) ? (v as StoredSong[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const rowToSavedMeta = (r: {
|
||||||
|
id: number; ownerId: string; name: string; songCount: number; createdAt: string; updatedAt: string;
|
||||||
|
}): SavedQueueMeta => ({
|
||||||
|
id: r.id,
|
||||||
|
ownerId: r.ownerId,
|
||||||
|
name: r.name,
|
||||||
|
songCount: r.songCount,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const upsertSavedQueue = db.prepare(`
|
||||||
|
INSERT INTO saved_queues (ownerId, name, songs, songCount)
|
||||||
|
VALUES (@ownerId, @name, @songs, @songCount)
|
||||||
|
ON CONFLICT(ownerId, name) DO UPDATE SET
|
||||||
|
songs = excluded.songs,
|
||||||
|
songCount = excluded.songCount,
|
||||||
|
updatedAt = datetime('now')
|
||||||
|
`);
|
||||||
|
const selectSavedQueueByOwnerName = db.prepare(
|
||||||
|
"SELECT * FROM saved_queues WHERE ownerId = ? AND name = ?",
|
||||||
|
);
|
||||||
|
const selectSavedQueueIdByOwnerName = db.prepare(
|
||||||
|
"SELECT id FROM saved_queues WHERE ownerId = ? AND name = ?",
|
||||||
|
);
|
||||||
|
const countSavedQueues = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS c FROM saved_queues WHERE ownerId = ?",
|
||||||
|
);
|
||||||
|
const listSavedQueuesOwn = db.prepare(
|
||||||
|
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? ORDER BY updatedAt DESC",
|
||||||
|
);
|
||||||
|
const listSavedQueuesShared = db.prepare(
|
||||||
|
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? OR ownerId = ? ORDER BY updatedAt DESC",
|
||||||
|
);
|
||||||
|
const selectSavedQueueById = db.prepare("SELECT * FROM saved_queues WHERE id = ?");
|
||||||
|
const deleteSavedQueueById = db.prepare("DELETE FROM saved_queues WHERE id = ?");
|
||||||
|
|
||||||
|
const upsertQueueState = db.prepare(`
|
||||||
|
INSERT INTO queue_state (botId, songs, currentIndex, mode, isFmMode, fmPlatform, updatedAt)
|
||||||
|
VALUES (@botId, @songs, @currentIndex, @mode, @isFmMode, @fmPlatform, datetime('now'))
|
||||||
|
ON CONFLICT(botId) DO UPDATE SET
|
||||||
|
songs = excluded.songs,
|
||||||
|
currentIndex = excluded.currentIndex,
|
||||||
|
mode = excluded.mode,
|
||||||
|
isFmMode = excluded.isFmMode,
|
||||||
|
fmPlatform = excluded.fmPlatform,
|
||||||
|
updatedAt = datetime('now')
|
||||||
|
`);
|
||||||
|
const selectQueueState = db.prepare("SELECT * FROM queue_state WHERE botId = ?");
|
||||||
|
const deleteQueueState = db.prepare("DELETE FROM queue_state WHERE botId = ?");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|
||||||
@@ -432,6 +564,85 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
return row !== undefined;
|
return row !== undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
saveQueue(ownerId, name, songs) {
|
||||||
|
if (songs.length > MAX_QUEUE_SONGS) {
|
||||||
|
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
|
||||||
|
}
|
||||||
|
// Strip any lazily-resolved url before persisting.
|
||||||
|
const stripped: StoredSong[] = songs.map((s) => {
|
||||||
|
const { url: _url, ...rest } = s as QueuedSong;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
// Enforce the per-owner cap only for a NEW name (an overwrite of an
|
||||||
|
// existing saved queue must always be allowed).
|
||||||
|
const existing = selectSavedQueueIdByOwnerName.get(ownerId, name) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!existing) {
|
||||||
|
const { c } = countSavedQueues.get(ownerId) as { c: number };
|
||||||
|
if (c >= MAX_SAVED_QUEUES) {
|
||||||
|
throw new Error(`保存失败:已保存队列数量超过上限 ${MAX_SAVED_QUEUES}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upsertSavedQueue.run({
|
||||||
|
ownerId,
|
||||||
|
name,
|
||||||
|
songs: JSON.stringify(stripped),
|
||||||
|
songCount: stripped.length,
|
||||||
|
});
|
||||||
|
const row = selectSavedQueueByOwnerName.get(ownerId, name) as SavedQueueMeta;
|
||||||
|
return { ...rowToSavedMeta(row), songs: stripped };
|
||||||
|
},
|
||||||
|
|
||||||
|
listSavedQueues(ownerId, includeShared) {
|
||||||
|
const rows = includeShared
|
||||||
|
? (listSavedQueuesShared.all(ownerId, SHARED_QUEUE_OWNER) as SavedQueueMeta[])
|
||||||
|
: (listSavedQueuesOwn.all(ownerId) as SavedQueueMeta[]);
|
||||||
|
return rows.map(rowToSavedMeta);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSavedQueue(id) {
|
||||||
|
const row = selectSavedQueueById.get(id) as
|
||||||
|
| (SavedQueueMeta & { songs: string })
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...rowToSavedMeta(row), songs: parseSongs(row.songs) };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSavedQueue(id) {
|
||||||
|
return deleteSavedQueueById.run(id).changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveQueueState(state) {
|
||||||
|
upsertQueueState.run({
|
||||||
|
botId: state.botId,
|
||||||
|
songs: JSON.stringify(state.songs),
|
||||||
|
currentIndex: state.currentIndex,
|
||||||
|
mode: state.mode,
|
||||||
|
isFmMode: state.isFmMode ? 1 : 0,
|
||||||
|
fmPlatform: state.fmPlatform,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getQueueState(botId) {
|
||||||
|
const r = selectQueueState.get(botId) as
|
||||||
|
| { botId: string; songs: string; currentIndex: number; mode: string; isFmMode: number; fmPlatform: string }
|
||||||
|
| undefined;
|
||||||
|
if (!r) return null;
|
||||||
|
return {
|
||||||
|
botId: r.botId,
|
||||||
|
songs: parseSongs(r.songs),
|
||||||
|
currentIndex: r.currentIndex,
|
||||||
|
mode: r.mode,
|
||||||
|
isFmMode: r.isFmMode === 1,
|
||||||
|
fmPlatform: r.fmPlatform,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQueueState(botId) {
|
||||||
|
deleteQueueState.run(botId);
|
||||||
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -325,6 +325,38 @@ describe("bot router /settings", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(config.spotify).toEqual(before);
|
expect(config.spotify).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /settings echoes savedQueuesEnabled + playKeepsQueue (default false)", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.savedQueuesEnabled).toBe(false);
|
||||||
|
expect(res.body.playKeepsQueue).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings persists savedQueuesEnabled and playKeepsQueue", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ savedQueuesEnabled: true, playKeepsQueue: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.savedQueuesEnabled).toBe(true);
|
||||||
|
expect(res.body.playKeepsQueue).toBe(true);
|
||||||
|
expect(config.savedQueuesEnabled).toBe(true);
|
||||||
|
expect(config.playKeepsQueue).toBe(true);
|
||||||
|
|
||||||
|
const get = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(get.body.savedQueuesEnabled).toBe(true);
|
||||||
|
expect(get.body.playKeepsQueue).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings ignores non-boolean savedQueuesEnabled without 400", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ savedQueuesEnabled: "nope" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.savedQueuesEnabled).toBe(false); // unchanged from default
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Whole-branch I2: saving a Client ID in Settings must re-configure the single
|
// Whole-branch I2: saving a Client ID in Settings must re-configure the single
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export function createBotRouter(
|
|||||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
localAudioEnabled: config.localAudioEnabled,
|
localAudioEnabled: config.localAudioEnabled,
|
||||||
|
savedQueuesEnabled: config.savedQueuesEnabled,
|
||||||
|
playKeepsQueue: config.playKeepsQueue,
|
||||||
adminGroups: config.adminGroups ?? [],
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
spotify: maskedSpotify(),
|
spotify: maskedSpotify(),
|
||||||
@@ -98,6 +100,16 @@ export function createBotRouter(
|
|||||||
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||||
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
|
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
|
||||||
|
|
||||||
|
// Saved-queues + play-keeps-queue toggles (default off). Both read live from
|
||||||
|
// config by BotInstance / the saved-queues router, so no per-bot push needed;
|
||||||
|
// only a literal boolean mutates the stored value (junk is ignored).
|
||||||
|
if (typeof req.body.savedQueuesEnabled === "boolean") {
|
||||||
|
config.savedQueuesEnabled = req.body.savedQueuesEnabled;
|
||||||
|
}
|
||||||
|
if (typeof req.body.playKeepsQueue === "boolean") {
|
||||||
|
config.playKeepsQueue = req.body.playKeepsQueue;
|
||||||
|
}
|
||||||
|
|
||||||
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
|
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
|
||||||
if (hasGuestMode) {
|
if (hasGuestMode) {
|
||||||
const gm = config.guestMode;
|
const gm = config.guestMode;
|
||||||
@@ -214,6 +226,8 @@ export function createBotRouter(
|
|||||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
localAudioEnabled: config.localAudioEnabled,
|
localAudioEnabled: config.localAudioEnabled,
|
||||||
|
savedQueuesEnabled: config.savedQueuesEnabled,
|
||||||
|
playKeepsQueue: config.playKeepsQueue,
|
||||||
adminGroups: config.adminGroups ?? [],
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
spotify: maskedSpotify(),
|
spotify: maskedSpotify(),
|
||||||
|
|||||||
+15
-18
@@ -461,7 +461,11 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play a single song by ID — resolves URL on demand
|
// Play a single song by ID — resolves URL on demand. Funnels through
|
||||||
|
// bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs
|
||||||
|
// insert-and-jump, keeping the queue) lives in one place shared with chat
|
||||||
|
// !play. Serialized via runExclusive like /play-now-song so concurrent
|
||||||
|
// requests can't interleave the queue mutation + playback (#119).
|
||||||
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
|
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
@@ -474,23 +478,16 @@ export function createPlayerRouter(
|
|||||||
rejectDisabledLocalAudio(res);
|
rejectDisabledLocalAudio(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const queue = bot.getQueueManager();
|
// The cleanupQueuedLocalSongs sweep now lives inside playSingleSong's
|
||||||
bot.getPlayer().stop();
|
// clear branch — do NOT also call it here, or it would delete retained
|
||||||
queue.clear();
|
// local uploads in keep-queue mode.
|
||||||
queue.add({ ...song, requestedBy: requesterName(req) });
|
const body = await bot.runExclusive(async () => {
|
||||||
queue.play();
|
const ok = await bot.playSingleSong({ ...song }, requesterName(req));
|
||||||
|
return ok
|
||||||
bot.getPlayer().resetFailures();
|
? { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }
|
||||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
: { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
|
||||||
// Sweep AFTER the new song is queued+resolved, so replaying a local song
|
});
|
||||||
// that was still in the queue doesn't delete the file we're about to play.
|
res.json(body);
|
||||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
|
||||||
if (!ok) {
|
|
||||||
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase } from "../../data/database.js";
|
||||||
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
|
import { createSavedQueuesRouter } from "./saved-queues.js";
|
||||||
|
|
||||||
|
const song = (id: string) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
artist: "",
|
||||||
|
album: "",
|
||||||
|
platform: "netease" as const,
|
||||||
|
coverUrl: "",
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount(enabled: boolean, opts: { queue?: unknown[] } = {}) {
|
||||||
|
const db = createDatabase(":memory:");
|
||||||
|
const loads: Array<{ songs: unknown[]; mode: string; by?: string }> = [];
|
||||||
|
const bot = {
|
||||||
|
getQueueManager: () => ({ list: () => opts.queue ?? [song("a"), song("b")] }),
|
||||||
|
loadSavedQueue: async (songs: unknown[], mode: string, by?: string) => {
|
||||||
|
loads.push({ songs, mode, by });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const botManager = { getBot: (_id: string) => bot } as unknown as BotManager;
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as unknown as { user: unknown }).user = { id: "u1", username: "alice", role: "member" };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
"/api/saved-queues",
|
||||||
|
createSavedQueuesRouter(db, botManager, () => enabled, pino({ level: "silent" })),
|
||||||
|
);
|
||||||
|
return { app, db, loads };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("saved-queues router", () => {
|
||||||
|
it("403s every route when the feature is disabled", async () => {
|
||||||
|
const { app } = mount(false);
|
||||||
|
expect((await request(app).get("/api/saved-queues")).status).toBe(403);
|
||||||
|
expect((await request(app).post("/api/saved-queues").send({ botId: "b", name: "x" })).status).toBe(403);
|
||||||
|
expect((await request(app).post("/api/saved-queues/1/load").send({ botId: "b" })).status).toBe(403);
|
||||||
|
expect((await request(app).delete("/api/saved-queues/1")).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the current queue (private) and lists it back", async () => {
|
||||||
|
const { app } = mount(true);
|
||||||
|
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "night" });
|
||||||
|
expect(save.status).toBe(200);
|
||||||
|
expect(save.body.queue.name).toBe("night");
|
||||||
|
expect(save.body.queue.songCount).toBe(2);
|
||||||
|
expect(save.body.queue.ownerId).toBe("u1");
|
||||||
|
|
||||||
|
const list = await request(app).get("/api/saved-queues");
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
expect(list.body.queues.map((q: { name: string }) => q.name)).toContain("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves to the shared bucket when shared:true", async () => {
|
||||||
|
const { app, db } = mount(true);
|
||||||
|
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "party", shared: true });
|
||||||
|
expect(save.status).toBe(200);
|
||||||
|
expect(save.body.queue.ownerId).toBe(SHARED_QUEUE_OWNER);
|
||||||
|
expect(db.listSavedQueues(SHARED_QUEUE_OWNER, false).map((q) => q.name)).toEqual(["party"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects saving an empty queue", async () => {
|
||||||
|
const { app } = mount(true, { queue: [] });
|
||||||
|
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "empty" });
|
||||||
|
expect(save.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires botId and name", async () => {
|
||||||
|
const { app } = mount(true);
|
||||||
|
expect((await request(app).post("/api/saved-queues").send({ name: "x" })).status).toBe(400);
|
||||||
|
expect((await request(app).post("/api/saved-queues").send({ botId: "b" })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a shared queue (replace by default) into the bot", async () => {
|
||||||
|
const { app, db, loads } = mount(true);
|
||||||
|
const saved = db.saveQueue(SHARED_QUEUE_OWNER, "party", [song("a"), song("b")]);
|
||||||
|
const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b" });
|
||||||
|
expect(load.status).toBe(200);
|
||||||
|
expect(load.body).toMatchObject({ ok: true, loaded: 2, mode: "replace" });
|
||||||
|
expect(loads).toHaveLength(1);
|
||||||
|
expect(loads[0].mode).toBe("replace");
|
||||||
|
expect(loads[0].by).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads in append mode when requested", async () => {
|
||||||
|
const { app, db, loads } = mount(true);
|
||||||
|
const saved = db.saveQueue("u1", "mine", [song("a")]);
|
||||||
|
const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b", mode: "append" });
|
||||||
|
expect(load.status).toBe(200);
|
||||||
|
expect(loads[0].mode).toBe("append");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s loading another user's private queue (no existence leak)", async () => {
|
||||||
|
const { app, db } = mount(true);
|
||||||
|
db.saveQueue("someoneElse", "private", [song("z")]);
|
||||||
|
const other = db.listSavedQueues("someoneElse", false)[0];
|
||||||
|
const load = await request(app).post(`/api/saved-queues/${other.id}/load`).send({ botId: "b", mode: "replace" });
|
||||||
|
expect(load.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an own queue but 404s another user's private one", async () => {
|
||||||
|
const { app, db } = mount(true);
|
||||||
|
const mine = db.saveQueue("u1", "mine", [song("a")]);
|
||||||
|
const theirs = db.saveQueue("someoneElse", "private", [song("z")]);
|
||||||
|
expect((await request(app).delete(`/api/saved-queues/${theirs.id}`)).status).toBe(404);
|
||||||
|
expect((await request(app).delete(`/api/saved-queues/${mine.id}`)).status).toBe(200);
|
||||||
|
expect(db.getSavedQueue(mine.id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import { SHARED_QUEUE_OWNER } from "../../data/database.js";
|
||||||
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The /api/saved-queues router (Feature 1, #119). Named save/load of queues,
|
||||||
|
* per-user with a reserved shared bucket. Every route is inert (403) unless
|
||||||
|
* savedQueuesEnabled is on, so the feature is fully gated behind the admin flag.
|
||||||
|
*
|
||||||
|
* Ownership model:
|
||||||
|
* - WebUI save with `shared:true` → SHARED_QUEUE_OWNER; otherwise the caller's
|
||||||
|
* own user id (private to them).
|
||||||
|
* - list returns the caller's own queues + shared ones.
|
||||||
|
* - load/delete are allowed only for the caller's own queues or shared ones;
|
||||||
|
* another user's private queue 404s (no existence leak, matching favorites).
|
||||||
|
*/
|
||||||
|
export function createSavedQueuesRouter(
|
||||||
|
database: BotDatabase,
|
||||||
|
botManager: BotManager,
|
||||||
|
isEnabled: () => boolean,
|
||||||
|
logger: Logger,
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Feature gate — inert (403) when savedQueuesEnabled is false.
|
||||||
|
router.use((_req, res, next) => {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
res.status(403).json({ error: "此功能未启用" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET / — the caller's own + shared saved queues (meta only, no songs blob).
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
res.json({ queues: database.listSavedQueues(userId, true) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST / — snapshot a bot's CURRENT queue and upsert it.
|
||||||
|
// body: { botId, name, shared? }
|
||||||
|
router.post("/", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const { botId, name, shared } = req.body ?? {};
|
||||||
|
if (typeof name !== "string" || !name.trim() || typeof botId !== "string" || !botId) {
|
||||||
|
res.status(400).json({ error: "botId and name are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bot = botManager.getBot(botId);
|
||||||
|
if (!bot) {
|
||||||
|
res.status(404).json({ error: "bot not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const songs = bot.getQueueManager().list();
|
||||||
|
if (songs.length === 0) {
|
||||||
|
res.status(400).json({ error: "队列为空,无法保存" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ownerId = shared === true ? SHARED_QUEUE_OWNER : userId;
|
||||||
|
try {
|
||||||
|
const saved = database.saveQueue(ownerId, name.trim(), songs);
|
||||||
|
logger.info({ userId, ownerId, name: saved.name, count: saved.songCount }, "saved queue upserted");
|
||||||
|
res.json({
|
||||||
|
queue: {
|
||||||
|
id: saved.id,
|
||||||
|
ownerId: saved.ownerId,
|
||||||
|
name: saved.name,
|
||||||
|
songCount: saved.songCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/load — load a saved queue into a bot. body: { botId, mode }
|
||||||
|
router.post("/:id/load", async (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const username = req.user!.username;
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const { botId, mode } = req.body ?? {};
|
||||||
|
if (Number.isNaN(id) || typeof botId !== "string" || !botId) {
|
||||||
|
res.status(400).json({ error: "invalid id/botId" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sq = database.getSavedQueue(id);
|
||||||
|
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bot = botManager.getBot(botId);
|
||||||
|
if (!bot) {
|
||||||
|
res.status(404).json({ error: "bot not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadMode = mode === "append" ? "append" : "replace";
|
||||||
|
await bot.loadSavedQueue(sq.songs, loadMode, username || "游客");
|
||||||
|
res.json({ ok: true, loaded: sq.songs.length, mode: loadMode });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /:id — delete a saved queue (own or shared only).
|
||||||
|
router.delete("/:id", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "invalid id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sq = database.getSavedQueue(id);
|
||||||
|
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
database.deleteSavedQueue(id);
|
||||||
|
logger.info({ userId, id }, "saved queue deleted");
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js";
|
|||||||
import { createAuditStore } from "../data/audit.js";
|
import { createAuditStore } from "../data/audit.js";
|
||||||
import { createAuditRouter } from "./api/audit.js";
|
import { createAuditRouter } from "./api/audit.js";
|
||||||
import { createFavoritesRouter } from "./api/favorites.js";
|
import { createFavoritesRouter } from "./api/favorites.js";
|
||||||
|
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
||||||
import { createSpotifyRouter } from "./api/spotify.js";
|
import { createSpotifyRouter } from "./api/spotify.js";
|
||||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||||
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
||||||
@@ -188,6 +189,18 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||||
|
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
|
||||||
|
// the router itself 403s every route unless savedQueuesEnabled is on.
|
||||||
|
app.use(
|
||||||
|
"/api/saved-queues",
|
||||||
|
requireNotGuest,
|
||||||
|
createSavedQueuesRouter(
|
||||||
|
options.database,
|
||||||
|
options.botManager,
|
||||||
|
() => options.config.savedQueuesEnabled,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// admin-only routes
|
// admin-only routes
|
||||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
||||||
|
|||||||
Reference in new issue
Block a user