mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: play-by-id and play-playlist endpoints, fix lyrics sync timing
- New endpoints: play-by-id, add-by-id, play-playlist (load all songs at once) - Playlist 'Play All' now uses server-side bulk load (no N sequential searches) - Search/Home/Playlist views use playById instead of search-by-name - Fixed updateBotStatus timing: capture prev state before mutation - Early return on song change prevents stale pause/resume logic Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2c87f1cff9
commit
2e4d6d8d40
6 files changed
+163
-20
No files matched your search
+117
-1
@@ -1,13 +1,16 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
import type { BotDatabase } from "../../data/database.js";
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import { parseCommand } from "../../bot/commands.js";
|
import { parseCommand } from "../../bot/commands.js";
|
||||||
|
|
||||||
export function createPlayerRouter(
|
export function createPlayerRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
database?: BotDatabase
|
database?: BotDatabase,
|
||||||
|
neteaseProvider?: MusicProvider,
|
||||||
|
qqProvider?: MusicProvider,
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -133,6 +136,119 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Play a playlist by ID — fetches all songs, gets URLs, loads into queue
|
||||||
|
router.post("/:botId/play-playlist", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { playlistId, platform } = req.body;
|
||||||
|
const provider = platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
|
if (!provider) {
|
||||||
|
res.status(500).json({ error: "Provider not available" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await provider.getPlaylistSongs(playlistId);
|
||||||
|
if (songs.length === 0) {
|
||||||
|
res.json({ message: "Playlist is empty" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get URL for first song and play it
|
||||||
|
const queue = bot.getQueueManager();
|
||||||
|
const player = bot.getPlayer();
|
||||||
|
queue.clear();
|
||||||
|
|
||||||
|
let firstUrl: string | null = null;
|
||||||
|
for (const song of songs) {
|
||||||
|
const url = await provider.getSongUrl(song.id);
|
||||||
|
if (url) {
|
||||||
|
queue.add({ ...song, url, platform: provider.platform });
|
||||||
|
if (!firstUrl) firstUrl = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstUrl) {
|
||||||
|
queue.play();
|
||||||
|
player.play(firstUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: `Loaded ${queue.size()} songs. Now playing: ${queue.current()?.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 — no search needed
|
||||||
|
router.post("/:botId/play-by-id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { songId, platform } = req.body;
|
||||||
|
const provider = platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
|
if (!provider) {
|
||||||
|
res.status(500).json({ error: "Provider not available" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [song, url] = await Promise.all([
|
||||||
|
provider.getSongDetail(songId),
|
||||||
|
provider.getSongUrl(songId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!song || !url) {
|
||||||
|
res.json({ message: "Cannot get song URL" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = bot.getQueueManager();
|
||||||
|
const player = bot.getPlayer();
|
||||||
|
queue.clear();
|
||||||
|
queue.add({ ...song, url, platform: provider.platform });
|
||||||
|
queue.play();
|
||||||
|
player.play(url);
|
||||||
|
|
||||||
|
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
|
||||||
|
router.post("/:botId/add-by-id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { songId, platform } = req.body;
|
||||||
|
const provider = platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
|
if (!provider) {
|
||||||
|
res.status(500).json({ error: "Provider not available" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [song, url] = await Promise.all([
|
||||||
|
provider.getSongDetail(songId),
|
||||||
|
provider.getSongUrl(songId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!song || !url) {
|
||||||
|
res.json({ message: "Cannot get song URL" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = bot.getQueueManager();
|
||||||
|
queue.add({ ...song, url, platform: provider.platform });
|
||||||
|
|
||||||
|
// If nothing is playing, start playing
|
||||||
|
if (bot.getPlayer().getState() === "idle") {
|
||||||
|
queue.play();
|
||||||
|
bot.getPlayer().play(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
router.get("/:botId/history", (req, res) => {
|
||||||
if (!database) {
|
if (!database) {
|
||||||
res.json({ history: [] });
|
res.json({ history: [] });
|
||||||
|
|||||||
+4
-1
@@ -46,7 +46,10 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
"/api/music",
|
"/api/music",
|
||||||
createMusicRouter(options.neteaseProvider, options.qqProvider, logger)
|
createMusicRouter(options.neteaseProvider, options.qqProvider, logger)
|
||||||
);
|
);
|
||||||
app.use("/api/player", createPlayerRouter(options.botManager, logger, options.database));
|
app.use("/api/player", createPlayerRouter(
|
||||||
|
options.botManager, logger, options.database,
|
||||||
|
options.neteaseProvider, options.qqProvider,
|
||||||
|
));
|
||||||
app.use(
|
app.use(
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
createAuthRouter(options.neteaseProvider, options.qqProvider, logger, options.cookieStore)
|
createAuthRouter(options.neteaseProvider, options.qqProvider, logger, options.cookieStore)
|
||||||
|
|||||||
@@ -76,10 +76,14 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateBotStatus(botId: string, status: BotStatus) {
|
updateBotStatus(botId: string, status: BotStatus) {
|
||||||
|
// Capture previous state BEFORE updating
|
||||||
const prev = this.bots.find((b) => b.id === botId);
|
const prev = this.bots.find((b) => b.id === botId);
|
||||||
const prevSongId = prev?.currentSong?.id;
|
const prevSongId = prev?.currentSong?.id;
|
||||||
|
const prevPaused = prev?.paused ?? false;
|
||||||
|
const prevPlaying = prev?.playing ?? false;
|
||||||
const newSongId = status.currentSong?.id;
|
const newSongId = status.currentSong?.id;
|
||||||
|
|
||||||
|
// Update the bot status
|
||||||
const index = this.bots.findIndex((b) => b.id === botId);
|
const index = this.bots.findIndex((b) => b.id === botId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
this.bots[index] = status;
|
this.bots[index] = status;
|
||||||
@@ -87,19 +91,24 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.bots.push(status);
|
this.bots.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset play timer when song changes
|
// Only adjust timing for the active bot
|
||||||
|
if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return;
|
||||||
|
|
||||||
|
// Song changed — reset timer
|
||||||
if (newSongId && newSongId !== prevSongId) {
|
if (newSongId && newSongId !== prevSongId) {
|
||||||
this.playStartedAt = Date.now();
|
this.playStartedAt = Date.now();
|
||||||
this.pausedElapsed = 0;
|
this.pausedElapsed = 0;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track pause/resume
|
// Resumed from pause
|
||||||
if (status.playing && !status.paused && prev?.paused) {
|
if (status.playing && !status.paused && prevPaused) {
|
||||||
// Resumed — adjust start time
|
|
||||||
this.playStartedAt = Date.now() - this.pausedElapsed * 1000;
|
this.playStartedAt = Date.now() - this.pausedElapsed * 1000;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (status.paused && !prev?.paused) {
|
|
||||||
// Paused — save elapsed
|
// Paused
|
||||||
|
if (status.paused && prevPlaying && !prevPaused) {
|
||||||
this.pausedElapsed = this.playStartedAt > 0
|
this.pausedElapsed = this.playStartedAt > 0
|
||||||
? (Date.now() - this.playStartedAt) / 1000
|
? (Date.now() - this.playStartedAt) / 1000
|
||||||
: 0;
|
: 0;
|
||||||
@@ -154,11 +163,30 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.pausedElapsed = 0;
|
this.pausedElapsed = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async playById(songId: string, platform = 'netease') {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform });
|
||||||
|
this.playStartedAt = Date.now();
|
||||||
|
this.pausedElapsed = 0;
|
||||||
|
},
|
||||||
|
|
||||||
async addToQueue(query: string, platform = 'netease') {
|
async addToQueue(query: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
|
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addToQueueById(songId: string, platform = 'netease') {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
||||||
|
},
|
||||||
|
|
||||||
|
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||||
|
this.playStartedAt = Date.now();
|
||||||
|
this.pausedElapsed = 0;
|
||||||
|
},
|
||||||
|
|
||||||
async pause() {
|
async pause() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
this.pausedElapsed = this.playStartedAt > 0
|
this.pausedElapsed = this.playStartedAt > 0
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
v-for="song in store.dailySongs.slice(0, 12)"
|
v-for="song in store.dailySongs.slice(0, 12)"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
class="daily-card hover-scale"
|
class="daily-card hover-scale"
|
||||||
@click="store.play(song.name, song.platform)"
|
@click="store.playById(song.id, song.platform)"
|
||||||
>
|
>
|
||||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||||
<div class="daily-name">{{ song.name }}</div>
|
<div class="daily-name">{{ song.name }}</div>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
:active="store.currentSong?.id === song.id"
|
:active="store.currentSong?.id === song.id"
|
||||||
@play="store.play(song.name, song.platform)"
|
@play="store.playById(song.id, song.platform)"
|
||||||
@add="store.addToQueue(song.name, song.platform)"
|
@add="store.addToQueueById(song.id, song.platform)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -76,13 +76,9 @@ const songs = ref<SongItem[]>([]);
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
async function playAll() {
|
async function playAll() {
|
||||||
if (songs.value.length > 0) {
|
const id = route.params.id as string;
|
||||||
const first = songs.value[0];
|
const platform = (route.query.platform as string) || 'netease';
|
||||||
await store.play(first.name, first.platform);
|
await store.playPlaylist(id, platform);
|
||||||
for (let i = 1; i < songs.value.length; i++) {
|
|
||||||
await store.addToQueue(songs.value[i].name, songs.value[i].platform);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
:active="store.currentSong?.id === song.id"
|
:active="store.currentSong?.id === song.id"
|
||||||
@play="store.play(song.name, song.platform)"
|
@play="store.playById(song.id, song.platform)"
|
||||||
@add="store.addToQueue(song.name, song.platform)"
|
@add="store.addToQueueById(song.id, song.platform)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user