Files
teamspeak-music-bot/src/web/api/player.ts
T
saopig1andClaude Opus 4.6 da5b34f5f4 feat(profile): auto-update bot avatar, nickname, and away status based on playing song
Add BotProfileManager that updates the bot's TeamSpeak presence when
songs change: album cover as avatar, song info in nickname, away status
toggled on stop/play. Each feature is independently configurable via
REST API and persisted to the database. Permission-safe — features that
fail due to insufficient server permissions are silently disabled until
reconnect. Description falls back to nickname display on TS3 (only
supported via TS6 HTTP Query).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:14:29 +08:00

389 lines
12 KiB
TypeScript

import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js";
export function createPlayerRouter(
botManager: BotManager,
logger: Logger,
database?: BotDatabase,
neteaseProvider?: MusicProvider,
qqProvider?: MusicProvider,
bilibiliProvider?: MusicProvider,
): Router {
const router = Router();
router.use("/:botId", (req, res, next) => {
const bot = botManager.getBot(req.params.botId);
if (!bot) {
res.status(404).json({ error: "Bot not found" });
return;
}
(req as any).bot = bot;
next();
});
/** Map API platform string to the corresponding command flag. */
const platformFlag = (platform: unknown): string => {
if (platform === "bilibili") return "-b";
if (platform === "qq") return "-q";
if (platform === "youtube") return "-y";
return "";
};
router.post("/:botId/play", async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
if (!query) {
res.status(400).json({ error: "query is required" });
return;
}
const cmd = parseCommand(`!play ${platformFlag(platform)} ${query}`.trim(), "!");
if (!cmd) {
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/add", async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
const cmd = parseCommand(`!add ${platformFlag(platform)} ${query}`.trim(), "!");
if (!cmd) {
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
const simpleCommand = (cmdStr: string) => async (req: any, res: any) => {
try {
const bot = req.bot;
const cmd = parseCommand(cmdStr, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
};
router.post("/:botId/pause", simpleCommand("!pause"));
router.post("/:botId/resume", simpleCommand("!resume"));
router.post("/:botId/next", simpleCommand("!next"));
router.post("/:botId/prev", simpleCommand("!prev"));
router.post("/:botId/stop", simpleCommand("!stop"));
router.post("/:botId/clear", simpleCommand("!clear"));
router.post("/:botId/volume", async (req, res) => {
try {
const bot = (req as any).bot;
const { volume } = req.body;
// Reject bad input with a proper 4xx instead of letting cmdVol
// return a "Usage:" string inside a 200 body — API clients can't
// detect that failure mode, and the UI would silently swallow it.
if (
typeof volume !== "number" ||
!Number.isFinite(volume) ||
volume < 0 ||
volume > 100
) {
res
.status(400)
.json({ error: "volume must be a number between 0 and 100" });
return;
}
const cmd = parseCommand(`!vol ${Math.round(volume)}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
router.post("/:botId/mode", async (req, res) => {
try {
const bot = (req as any).bot;
const { mode } = req.body;
if (typeof mode !== "string" || !VALID_MODES.has(mode)) {
res
.status(400)
.json({ error: "mode must be one of: seq, loop, random, rloop" });
return;
}
const cmd = parseCommand(`!mode ${mode}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Get current elapsed time (ground truth from server)
router.get("/:botId/elapsed", (req, res) => {
const bot = (req as any).bot;
res.json({ elapsed: bot.getPlayer().getElapsed() });
});
// Seek to position
router.post("/:botId/seek", async (req, res) => {
try {
const bot = (req as any).bot;
const { position } = req.body; // seconds
// typeof NaN === "number" and NaN < 0 is false, so a plain range
// check lets NaN/Infinity through and later corrupts seekOffset.
if (typeof position !== "number" || !Number.isFinite(position) || position < 0) {
res
.status(400)
.json({ error: "position must be a finite non-negative number" });
return;
}
bot.getPlayer().seek(position);
res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/queue", (req, res) => {
const bot = (req as any).bot;
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
});
router.delete("/:botId/queue/:index", async (req, res) => {
try {
const bot = (req as any).bot;
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Jump to a specific index in the queue (without clearing it)
router.post("/:botId/play-at", async (req, res) => {
try {
const bot = (req as any).bot;
const { index } = req.body;
if (typeof index !== "number" || index < 0) {
res.status(400).json({ error: "index is required" });
return;
}
const queue = bot.getQueueManager();
// Validate the index BEFORE stopping current playback — otherwise an
// invalid index silently kills the user's current song and leaves the
// queue idle.
if (index >= queue.size()) {
res.status(400).json({ error: "Invalid queue index" });
return;
}
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
res.status(400).json({ error: "Invalid queue index" });
return;
}
const ok = await bot.resolveAndPlay(song);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
return;
}
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/playlist", async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
const cmd = parseCommand(
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!"
)!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Play a playlist by ID — stores metadata only, resolves URL for first song
// Respects current play mode (random = pick random first song)
router.post("/:botId/play-playlist", async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) {
res.json({ message: "Playlist is empty" });
return;
}
const queue = bot.getQueueManager();
queue.clear();
for (const song of songs) {
queue.add({ ...song, platform: provider.platform });
}
// Use queue.play() for sequential, or pick random index for random modes
const mode = queue.getMode();
let first;
if (mode === "random" || mode === "rloop") {
const idx = Math.floor(Math.random() * queue.size());
first = queue.playAt(idx);
} else {
first = queue.play();
}
if (first) {
await bot.resolveAndPlay(first);
}
res.json({ message: `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}` });
} catch (err) {
logger.error({ err }, "Play playlist failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-by-id", async (req, res) => {
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
res.json({ message: "Song not found" });
return;
}
const queue = bot.getQueueManager();
queue.clear();
queue.add({ ...song, platform: provider.platform });
queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
return;
}
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Add a song to queue by ID — metadata only
router.post("/:botId/add-by-id", async (req, res) => {
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
res.json({ message: "Song not found" });
return;
}
const queue = bot.getQueueManager();
queue.add({ ...song, platform: provider.platform });
// If nothing is playing, start the first song
if (bot.getPlayer().getState() === "idle") {
const first = queue.play();
if (first) await bot.resolveAndPlay(first);
}
res.json({ message: `Added: ${song.name} - ${song.artist} (position ${queue.size()})` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// --- Profile config endpoints ---
router.get("/:botId/profile", (req, res) => {
const bot = (req as any).bot;
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
pm.updateConfig(req.body);
if (database) {
database.saveProfileConfig(bot.id, pm.getConfig());
}
res.json(pm.getConfig());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/history", (req, res) => {
if (!database) {
res.json({ history: [] });
return;
}
const limit = parseInt(req.query.limit as string) || 50;
const records = database.getPlayHistory(req.params.botId, limit);
const history = records.map((r) => ({
id: r.songId,
name: r.songName,
artist: r.artist,
album: r.album,
duration: 0,
coverUrl: r.coverUrl,
platform: r.platform,
playedAt: r.playedAt,
}));
res.json({ history });
});
return router;
}