mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
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>
123 lines
4.2 KiB
TypeScript
123 lines
4.2 KiB
TypeScript
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;
|
|
}
|