feat(#119): save/load queues + live-queue persistence + playKeepsQueue (backend)

Add three default-off capabilities that stop the play queue from being lost,
all gated behind admin/independent toggles so existing behavior is unchanged
until an operator opts in:

- Named save/load of queues (Feature 1): new saved_queues table (per-user +
  reserved __shared__ owner, capped at 50 queues / 1000 songs, JSON song blob
  that degrades to empty on corruption); /api/saved-queues router (list/save/
  load/delete with ownership 404s, inert 403 when disabled); chat commands
  !save / !load [-a] / !queues; BotInstance.loadSavedQueue (replace/append).
- Auto-restore live queue across restart (Feature 2): PlayQueue.snapshot/restore,
  queue_state table (one row per bot), a debounced snapshot writer driven off
  stateChange, and restore+resume on connect. Cancels the pending snapshot on
  disconnect so a stale write can't wipe the row a restart must restore.
- playKeepsQueue (Feature 3): BotInstance.playSingleSong funnels chat !play and
  the web /play-song route through one place; when enabled a single-song play
  inserts-after-current and jumps instead of clearing the queue.

Config gains savedQueuesEnabled + playKeepsQueue (both default false, strict-
coerced on load like spotify.enabled); the settings API round-trips them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Fable 5 committed 2026-07-17 01:28:48 +08:00
1 parent b8ec50c7f7
commit 69a2e8c264
14 files changed
+1250 -26

No files matched your search

+122
View File
@@ -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;
}