From 080c2836c9700933ac06e40987c5eb0acea4269c Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 03:05:40 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20lazy=20URL=20resolution=20=E2=80=94?= =?UTF-8?q?=20store=20only=20metadata,=20fetch=20URL=20at=20play=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/audio/queue.ts | 2 +- src/bot/instance.ts | 118 +++++++++++++++++++++--------------------- src/web/api/player.ts | 76 +++++++++++---------------- 3 files changed, 90 insertions(+), 106 deletions(-) diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 39ba699..dbe1ba9 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -11,7 +11,7 @@ export interface QueuedSong { artist: string; album: string; platform: "netease" | "qq"; - url: string; + url?: string; // resolved lazily at play time coverUrl: string; duration: number; // seconds } diff --git a/src/bot/instance.ts b/src/bot/instance.ts index ccc5c5a..ca5d760 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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 { 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 { + 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 { if (!cmd.args) return "Usage: !play "; const provider = this.getProvider(cmd.flags.has("q")); @@ -209,30 +241,12 @@ export class BotInstance extends EventEmitter { return `No results found for: ${cmd.args}`; 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.add(queuedSong); + this.queue.add({ ...song, platform: provider.platform }); this.queue.play(); - this.player.play(url); - this.database.addPlayHistory({ - botId: this.id, - songId: song.id, - songName: song.name, - artist: song.artist, - album: song.album, - platform: provider.platform, - coverUrl: song.coverUrl, - }); - - this.emit("stateChange"); + const ok = await this.resolveAndPlay(this.queue.current()!); + if (!ok) return `Cannot play: ${song.name}`; return `Now playing: ${song.name} - ${song.artist}`; } @@ -244,10 +258,7 @@ export class BotInstance extends EventEmitter { return `No results found for: ${cmd.args}`; const song = result.songs[0]; - const url = await provider.getSongUrl(song.id); - if (!url) return `Cannot get play URL for: ${song.name}`; - - this.queue.add({ ...song, url, platform: provider.platform }); + this.queue.add({ ...song, platform: provider.platform }); this.emit("stateChange"); 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"; } - private cmdNext(): string { - this.playNext(); + private async cmdNext(): Promise { + await this.playNext(); const current = this.queue.current(); if (current) return `Now playing: ${current.name} - ${current.artist}`; return "Queue is empty"; } - private cmdPrev(): string { + private async cmdPrev(): Promise { const prev = this.queue.prev(); if (prev) { - this.player.play(prev.url); - this.emit("stateChange"); + const ok = await this.resolveAndPlay(prev); + if (!ok) return "Cannot play previous song"; return `Now playing: ${prev.name} - ${prev.artist}`; } return "No previous song"; @@ -353,15 +364,12 @@ export class BotInstance extends EventEmitter { this.queue.clear(); for (const song of songs) { - const url = await provider.getSongUrl(song.id); - if (url) { - this.queue.add({ ...song, url, platform: provider.platform }); - } + this.queue.add({ ...song, platform: provider.platform }); } const first = this.queue.play(); - if (first) this.player.play(first.url); + if (first) await this.resolveAndPlay(first); 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 { @@ -372,15 +380,12 @@ export class BotInstance extends EventEmitter { this.queue.clear(); for (const song of songs) { - const url = await provider.getSongUrl(song.id); - if (url) { - this.queue.add({ ...song, url, platform: provider.platform }); - } + this.queue.add({ ...song, platform: provider.platform }); } const first = this.queue.play(); - if (first) this.player.play(first.url); + if (first) await this.resolveAndPlay(first); 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 { @@ -393,13 +398,10 @@ export class BotInstance extends EventEmitter { this.queue.clear(); for (const song of songs) { - const url = await this.neteaseProvider.getSongUrl(song.id); - if (url) { - this.queue.add({ ...song, url, platform: "netease" }); - } + this.queue.add({ ...song, platform: "netease" }); } const first = this.queue.play(); - if (first) this.player.play(first.url); + if (first) await this.resolveAndPlay(first); this.emit("stateChange"); return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; } @@ -464,20 +466,20 @@ export class BotInstance extends EventEmitter { ].join("\n"); } - private playNext(): void { + private async playNext(): Promise { this.voteSkipUsers.clear(); const next = this.queue.next(); if (next) { - this.player.play(next.url); - this.database.addPlayHistory({ - botId: this.id, - songId: next.id, - songName: next.name, - artist: next.artist, - album: next.album, - platform: next.platform, - coverUrl: next.coverUrl, - }); + const ok = await this.resolveAndPlay(next); + if (!ok) { + // Skip to next if URL resolve fails (up to 3 retries) + for (let i = 0; i < 3; i++) { + const retry = this.queue.next(); + if (!retry) break; + if (await this.resolveAndPlay(retry)) return; + } + this.player.stop(); + } } else { this.player.stop(); } diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 1257bd9..388d100 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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) => { try { const bot = (req as any).bot; @@ -153,59 +153,49 @@ export function createPlayerRouter( return; } - // Get URL for first song and play it + // Add all songs as metadata only (no URL fetching!) 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; - } + queue.add({ ...song, platform: provider.platform }); } - if (firstUrl) { - queue.play(); - player.play(firstUrl); + // Only resolve URL for the first song + const first = queue.play(); + 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) { logger.error({ err }, "Play playlist failed"); 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) => { 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 provider = (platform === "qq" ? qqProvider : neteaseProvider)!; - const [song, url] = await Promise.all([ - provider.getSongDetail(songId), - provider.getSongUrl(songId), - ]); - - if (!song || !url) { - res.json({ message: "Cannot get song URL" }); + const song = await provider.getSongDetail(songId); + if (!song) { + res.json({ message: "Song not found" }); return; } const queue = bot.getQueueManager(); - const player = bot.getPlayer(); queue.clear(); - queue.add({ ...song, url, platform: provider.platform }); + queue.add({ ...song, platform: provider.platform }); 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}` }); } 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) => { 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 provider = (platform === "qq" ? qqProvider : neteaseProvider)!; - const [song, url] = await Promise.all([ - provider.getSongDetail(songId), - provider.getSongUrl(songId), - ]); - - if (!song || !url) { - res.json({ message: "Cannot get song URL" }); + const song = await provider.getSongDetail(songId); + if (!song) { + res.json({ message: "Song not found" }); return; } 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") { - queue.play(); - bot.getPlayer().play(url); + const first = queue.play(); + if (first) await bot.resolveAndPlay(first); } res.json({ message: `Added: ${song.name} - ${song.artist} (position ${queue.size()})` });