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:
saopig1andClaude Opus 4.6 committed 2026-03-30 03:05:40 +08:00
1 parent 2e4d6d8d40
commit 080c2836c9
3 files changed
+90 -106

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()})` });