mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+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) => {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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 { 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));
|
||||
|
||||
Reference in new issue
Block a user