mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Implement BiliBiliProvider for video-as-audio playback using direct BiliBili API calls (search, video info, DASH audio URL extraction). Add QR code login support and cookie persistence. Update FFmpeg to send Referer header for BiliBili CDN URLs. Extend platform union type to "netease" | "qq" | "bilibili" across all interfaces. Add -b flag for chat commands and B站 badge in web UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
325 lines
10 KiB
TypeScript
325 lines
10 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();
|
|
});
|
|
|
|
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 flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
|
const cmd = parseCommand(`!play ${flags} ${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 flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
|
const cmd = parseCommand(`!add ${flags} ${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;
|
|
const cmd = parseCommand(`!vol ${volume}`, "!")!;
|
|
const response = await bot.executeCommand(cmd);
|
|
res.json({ message: response });
|
|
} catch (err) {
|
|
res.status(500).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
router.post("/:botId/mode", async (req, res) => {
|
|
try {
|
|
const bot = (req as any).bot;
|
|
const { mode } = req.body;
|
|
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
|
|
if (typeof position !== "number" || position < 0) {
|
|
res.status(400).json({ error: "position (seconds) is required" });
|
|
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();
|
|
bot.getPlayer().stop(); // Stop current playback first
|
|
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 flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
|
const cmd = parseCommand(
|
|
`!playlist ${flags} ${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;
|
|
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
|
if (!provider) {
|
|
res.status(500).json({ error: "Provider not available" });
|
|
return;
|
|
}
|
|
|
|
// Stop current playback
|
|
bot.getPlayer().stop();
|
|
|
|
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 = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
|
if (!provider) {
|
|
res.status(500).json({ error: "Provider not available" });
|
|
return;
|
|
}
|
|
|
|
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();
|
|
|
|
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 = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
|
if (!provider) {
|
|
res.status(500).json({ error: "Provider not available" });
|
|
return;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|