diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index be5f3fb..b6bd9f0 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -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"]); + }); + }); }); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 7afec2c..6274a05 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -17,6 +17,18 @@ export interface QueuedSong { 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[]; + currentIndex: number; + mode: PlayMode; +} + export class PlayQueue { private songs: QueuedSong[] = []; private currentIndex = -1; @@ -273,4 +285,32 @@ export class PlayQueue { unplayedCount(): number { 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 = []; + } } diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index bb5aa77..7f2feba 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -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; +const loadSavedQueue = BotInstance.prototype.loadSavedQueue as ( + this: unknown, + songs: unknown[], + mode: "replace" | "append", + requesterName?: string, +) => Promise; +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; +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; + +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(); + } + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index baaf8f1..ebd8e41 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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 | 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 { + 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 { + 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 { @@ -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 { + 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 { + 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 — Load album`, `${p}fm — Personal FM (default source: ${def}; source flags work too)`, `${p}artist — 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`, diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 7fbce19..deb0d1c 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -494,4 +494,28 @@ describe("loadConfig error handling", () => { 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); + }); }); diff --git a/src/data/config.ts b/src/data/config.ts index b5b515f..4b6aadf 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -91,6 +91,19 @@ export interface BotConfig { idleTimeoutMinutes: number; /** Enable uploading and playback of server-stored local audio files. */ 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专属链接). // Leave empty to use the browser's current origin. Example: // "https://music.example.com" or "http://1.2.3.4:3000" @@ -130,6 +143,8 @@ export function getDefaultConfig(): BotConfig { autoPauseOnEmpty: false, idleTimeoutMinutes: 0, localAudioEnabled: true, + savedQueuesEnabled: false, + playKeepsQueue: false, publicUrl: "", trustProxy: false, guestMode: { @@ -317,6 +332,12 @@ export function loadConfig(path: string): BotConfig { ) : 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 { ...defaults, ...partial, @@ -325,6 +346,8 @@ export function loadConfig(path: string): BotConfig { spotify, jellyfin, enabledProviders, + savedQueuesEnabled, + playKeepsQueue, }; } } diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 7520a4f..381b75c 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; 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"; describe("database", () => { @@ -153,6 +153,86 @@ describe("database", () => { botDb.setCustomAvatarPath("bot-1", null); 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", () => { diff --git a/src/data/database.ts b/src/data/database.ts index 992fc94..51cc805 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -1,6 +1,46 @@ import Database from "better-sqlite3"; import { CAPABILITIES, BOTS_ALL } from "./permissions.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; + +/** 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 { botId: string; @@ -81,6 +121,15 @@ export interface BotDatabase { removeFavorite(userId: string, playlistId: string, platform: string): boolean; getFavorites(userId: string): FavoritePlaylist[]; 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; } @@ -223,6 +272,28 @@ function initTables(db: Database.Database): void { 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 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 = ? `); + // 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 { db, @@ -432,6 +564,85 @@ export function createDatabase(dbPath: string): BotDatabase { 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() { db.close(); }, diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index eb174a6..3f06367 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -325,6 +325,38 @@ describe("bot router /settings", () => { expect(res.status).toBe(200); 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 diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index fd22b8d..f055e8e 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -72,6 +72,8 @@ export function createBotRouter( idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, localAudioEnabled: config.localAudioEnabled, + savedQueuesEnabled: config.savedQueuesEnabled, + playKeepsQueue: config.playKeepsQueue, adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, spotify: maskedSpotify(), @@ -98,6 +100,16 @@ export function createBotRouter( if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; 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"; if (hasGuestMode) { const gm = config.guestMode; @@ -214,6 +226,8 @@ export function createBotRouter( idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, localAudioEnabled: config.localAudioEnabled, + savedQueuesEnabled: config.savedQueuesEnabled, + playKeepsQueue: config.playKeepsQueue, adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, spotify: maskedSpotify(), diff --git a/src/web/api/player.ts b/src/web/api/player.ts index ac6c5b0..8cdcac7 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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) => { try { const bot = (req as any).bot; @@ -474,23 +478,16 @@ export function createPlayerRouter( rejectDisabledLocalAudio(res); return; } - const queue = bot.getQueueManager(); - bot.getPlayer().stop(); - queue.clear(); - queue.add({ ...song, requestedBy: requesterName(req) }); - queue.play(); - - bot.getPlayer().resetFailures(); - const ok = await bot.resolveAndPlay(queue.current()!); - // 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. - 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'}` }); + // The cleanupQueuedLocalSongs sweep now lives inside playSingleSong's + // clear branch — do NOT also call it here, or it would delete retained + // local uploads in keep-queue mode. + const body = await bot.runExclusive(async () => { + const ok = await bot.playSingleSong({ ...song }, requesterName(req)); + return ok + ? { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` } + : { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }; + }); + res.json(body); } catch (err) { res.status(500).json({ error: (err as Error).message }); } diff --git a/src/web/api/saved-queues.test.ts b/src/web/api/saved-queues.test.ts new file mode 100644 index 0000000..4c8f1bf --- /dev/null +++ b/src/web/api/saved-queues.test.ts @@ -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(); + }); +}); diff --git a/src/web/api/saved-queues.ts b/src/web/api/saved-queues.ts new file mode 100644 index 0000000..4749b54 --- /dev/null +++ b/src/web/api/saved-queues.ts @@ -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; +} diff --git a/src/web/server.ts b/src/web/server.ts index 35d99ff..212d0e9 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; +import { createSavedQueuesRouter } from "./api/saved-queues.js"; import { createSpotifyRouter } from "./api/spotify.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.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)); + // 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 app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));