mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
refactor: lazy URL resolution — store only metadata, fetch URL at play time
Follows TS3AudioBot-NetEaseCloudmusic-plugin pattern: - QueuedSong.url is now optional (metadata-only queue entries) - resolveAndPlay() fetches URL on-demand right before playback - Playlist/album/FM load instantly (only metadata stored) - playNext() resolves URL lazily, skips up to 3 songs on failure - Avoids expired URLs for songs deep in the queue Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2e4d6d8d40
commit
080c2836c9
3 files changed
+90
-106
No files matched your search
+1
-1
@@ -11,7 +11,7 @@ export interface QueuedSong {
|
|||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
platform: "netease" | "qq";
|
platform: "netease" | "qq";
|
||||||
url: string;
|
url?: string; // resolved lazily at play time
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-58
@@ -197,10 +197,42 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProviderFor(platform: "netease" | "qq"): MusicProvider {
|
||||||
|
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||||
|
}
|
||||||
|
|
||||||
private getProvider(useQQ: boolean): MusicProvider {
|
private getProvider(useQQ: boolean): MusicProvider {
|
||||||
return useQQ ? this.qqProvider : this.neteaseProvider;
|
return useQQ ? this.qqProvider : this.neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
|
||||||
|
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
|
||||||
|
const provider = this.getProviderFor(song.platform);
|
||||||
|
try {
|
||||||
|
const url = await provider.getSongUrl(song.id);
|
||||||
|
if (!url) {
|
||||||
|
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
song.url = url;
|
||||||
|
this.player.play(url);
|
||||||
|
this.database.addPlayHistory({
|
||||||
|
botId: this.id,
|
||||||
|
songId: song.id,
|
||||||
|
songName: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
platform: song.platform,
|
||||||
|
coverUrl: song.coverUrl,
|
||||||
|
});
|
||||||
|
this.emit("stateChange");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err, songId: song.id }, "Failed to resolve URL");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
if (!cmd.args) return "Usage: !play <song name or URL>";
|
||||||
const provider = this.getProvider(cmd.flags.has("q"));
|
const provider = this.getProvider(cmd.flags.has("q"));
|
||||||
@@ -209,30 +241,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `No results found for: ${cmd.args}`;
|
return `No results found for: ${cmd.args}`;
|
||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
const url = await provider.getSongUrl(song.id);
|
|
||||||
if (!url) return `Cannot get play URL for: ${song.name}`;
|
|
||||||
|
|
||||||
const queuedSong: QueuedSong = {
|
|
||||||
...song,
|
|
||||||
url,
|
|
||||||
platform: provider.platform,
|
|
||||||
};
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.queue.add(queuedSong);
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
this.player.play(url);
|
|
||||||
|
|
||||||
this.database.addPlayHistory({
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
botId: this.id,
|
if (!ok) return `Cannot play: ${song.name}`;
|
||||||
songId: song.id,
|
|
||||||
songName: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
platform: provider.platform,
|
|
||||||
coverUrl: song.coverUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit("stateChange");
|
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${song.name} - ${song.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,10 +258,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `No results found for: ${cmd.args}`;
|
return `No results found for: ${cmd.args}`;
|
||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
const url = await provider.getSongUrl(song.id);
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
if (!url) return `Cannot get play URL for: ${song.name}`;
|
|
||||||
|
|
||||||
this.queue.add({ ...song, url, platform: provider.platform });
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
|
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
|
||||||
}
|
}
|
||||||
@@ -271,19 +282,19 @@ export class BotInstance extends EventEmitter {
|
|||||||
return "Stopped and queue cleared";
|
return "Stopped and queue cleared";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdNext(): string {
|
private async cmdNext(): Promise<string> {
|
||||||
this.playNext();
|
await this.playNext();
|
||||||
const current = this.queue.current();
|
const current = this.queue.current();
|
||||||
if (current)
|
if (current)
|
||||||
return `Now playing: ${current.name} - ${current.artist}`;
|
return `Now playing: ${current.name} - ${current.artist}`;
|
||||||
return "Queue is empty";
|
return "Queue is empty";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdPrev(): string {
|
private async cmdPrev(): Promise<string> {
|
||||||
const prev = this.queue.prev();
|
const prev = this.queue.prev();
|
||||||
if (prev) {
|
if (prev) {
|
||||||
this.player.play(prev.url);
|
const ok = await this.resolveAndPlay(prev);
|
||||||
this.emit("stateChange");
|
if (!ok) return "Cannot play previous song";
|
||||||
return `Now playing: ${prev.name} - ${prev.artist}`;
|
return `Now playing: ${prev.name} - ${prev.artist}`;
|
||||||
}
|
}
|
||||||
return "No previous song";
|
return "No previous song";
|
||||||
@@ -353,15 +364,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
const url = await provider.getSongUrl(song.id);
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
if (url) {
|
|
||||||
this.queue.add({ ...song, url, platform: provider.platform });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) this.player.play(first.url);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Loaded playlist: ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
||||||
@@ -372,15 +380,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
const url = await provider.getSongUrl(song.id);
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
if (url) {
|
|
||||||
this.queue.add({ ...song, url, platform: provider.platform });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) this.player.play(first.url);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Loaded album: ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdFm(): Promise<string> {
|
private async cmdFm(): Promise<string> {
|
||||||
@@ -393,13 +398,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
const url = await this.neteaseProvider.getSongUrl(song.id);
|
this.queue.add({ ...song, platform: "netease" });
|
||||||
if (url) {
|
|
||||||
this.queue.add({ ...song, url, platform: "netease" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) this.player.play(first.url);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||||
}
|
}
|
||||||
@@ -464,20 +466,20 @@ export class BotInstance extends EventEmitter {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private playNext(): void {
|
private async playNext(): Promise<void> {
|
||||||
this.voteSkipUsers.clear();
|
this.voteSkipUsers.clear();
|
||||||
const next = this.queue.next();
|
const next = this.queue.next();
|
||||||
if (next) {
|
if (next) {
|
||||||
this.player.play(next.url);
|
const ok = await this.resolveAndPlay(next);
|
||||||
this.database.addPlayHistory({
|
if (!ok) {
|
||||||
botId: this.id,
|
// Skip to next if URL resolve fails (up to 3 retries)
|
||||||
songId: next.id,
|
for (let i = 0; i < 3; i++) {
|
||||||
songName: next.name,
|
const retry = this.queue.next();
|
||||||
artist: next.artist,
|
if (!retry) break;
|
||||||
album: next.album,
|
if (await this.resolveAndPlay(retry)) return;
|
||||||
platform: next.platform,
|
}
|
||||||
coverUrl: next.coverUrl,
|
this.player.stop();
|
||||||
});
|
}
|
||||||
} else {
|
} else {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-47
@@ -136,7 +136,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play a playlist by ID — fetches all songs, gets URLs, loads into queue
|
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
||||||
router.post("/:botId/play-playlist", async (req, res) => {
|
router.post("/:botId/play-playlist", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
@@ -153,59 +153,49 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get URL for first song and play it
|
// Add all songs as metadata only (no URL fetching!)
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
const player = bot.getPlayer();
|
|
||||||
queue.clear();
|
queue.clear();
|
||||||
|
|
||||||
let firstUrl: string | null = null;
|
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
const url = await provider.getSongUrl(song.id);
|
queue.add({ ...song, platform: provider.platform });
|
||||||
if (url) {
|
|
||||||
queue.add({ ...song, url, platform: provider.platform });
|
|
||||||
if (!firstUrl) firstUrl = url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstUrl) {
|
// Only resolve URL for the first song
|
||||||
queue.play();
|
const first = queue.play();
|
||||||
player.play(firstUrl);
|
if (first) {
|
||||||
|
await bot.resolveAndPlay(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: `Loaded ${queue.size()} songs. Now playing: ${queue.current()?.name ?? "unknown"}` });
|
res.json({ message: `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Play playlist failed");
|
logger.error({ err }, "Play playlist failed");
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play a single song by ID — no search needed
|
// Play a single song by ID — resolves URL on demand
|
||||||
router.post("/:botId/play-by-id", async (req, res) => {
|
router.post("/:botId/play-by-id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { songId, platform } = req.body;
|
||||||
const provider = platform === "qq" ? qqProvider : neteaseProvider;
|
const provider = (platform === "qq" ? qqProvider : neteaseProvider)!;
|
||||||
if (!provider) {
|
|
||||||
res.status(500).json({ error: "Provider not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [song, url] = await Promise.all([
|
const song = await provider.getSongDetail(songId);
|
||||||
provider.getSongDetail(songId),
|
if (!song) {
|
||||||
provider.getSongUrl(songId),
|
res.json({ message: "Song not found" });
|
||||||
]);
|
|
||||||
|
|
||||||
if (!song || !url) {
|
|
||||||
res.json({ message: "Cannot get song URL" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
const player = bot.getPlayer();
|
|
||||||
queue.clear();
|
queue.clear();
|
||||||
queue.add({ ...song, url, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform });
|
||||||
queue.play();
|
queue.play();
|
||||||
player.play(url);
|
|
||||||
|
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}` });
|
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -213,34 +203,26 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a song to queue by ID
|
// Add a song to queue by ID — metadata only
|
||||||
router.post("/:botId/add-by-id", async (req, res) => {
|
router.post("/:botId/add-by-id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { songId, platform } = req.body;
|
||||||
const provider = platform === "qq" ? qqProvider : neteaseProvider;
|
const provider = (platform === "qq" ? qqProvider : neteaseProvider)!;
|
||||||
if (!provider) {
|
|
||||||
res.status(500).json({ error: "Provider not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [song, url] = await Promise.all([
|
const song = await provider.getSongDetail(songId);
|
||||||
provider.getSongDetail(songId),
|
if (!song) {
|
||||||
provider.getSongUrl(songId),
|
res.json({ message: "Song not found" });
|
||||||
]);
|
|
||||||
|
|
||||||
if (!song || !url) {
|
|
||||||
res.json({ message: "Cannot get song URL" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
queue.add({ ...song, url, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform });
|
||||||
|
|
||||||
// If nothing is playing, start playing
|
// If nothing is playing, start the first song
|
||||||
if (bot.getPlayer().getState() === "idle") {
|
if (bot.getPlayer().getState() === "idle") {
|
||||||
queue.play();
|
const first = queue.play();
|
||||||
bot.getPlayer().play(url);
|
if (first) await bot.resolveAndPlay(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: `Added: ${song.name} - ${song.artist} (position ${queue.size()})` });
|
res.json({ message: `Added: ${song.name} - ${song.artist} (position ${queue.size()})` });
|
||||||
|
|||||||
Reference in new issue
Block a user