fix(qq): batch-precheck playable URLs before queueing playlist

Many users' QQ playlists (especially collected/subscribed ones) contain
a large fraction of songs that return result=104003 (region/copyright
restricted) for the current account. Sequential retry-skip wasted time
guessing — for the user's ACG古风 collected playlist, only 3 of 115
songs are actually streamable, so a 20-retry budget had < 50% chance
of finding a hit before giving up.

Add a getPlayableSongIds(ids) method to the QQ provider that calls the
upstream /getMusicPlay with a comma-separated batch of mids — the
wrapper resolves all of them in a single upstream call (~2-3s for 100+
songs). /play-playlist now duck-types this method and, when present,
filters the playlist down to playable songs before queueing.

Response message reports "Loaded N of M songs (rest are copyright/
region restricted)" so the user sees exactly what's happening instead
of guessing why the queue is short or playback didn't start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-06 15:34:16 +08:00
1 parent 4301da2e37
commit 3d0aca52d0
2 files changed
+57 -3

No files matched your search

+30
View File
@@ -115,6 +115,36 @@ export class QQMusicProvider implements MusicProvider {
return null; return null;
} }
/**
* Batch-check which song mids are actually streamable. QQ playlists
* (especially collected ones) frequently contain a majority of songs
* that return result=104003 ("no copyright/region restricted") for the
* current user — a sequential retry loop wastes time guessing.
*
* The wrapper's /getMusicPlay accepts a comma-separated songmid list
* and resolves all of them in a single upstream call (~2-3s for 100+
* songs), so this is much cheaper than per-song probing.
*/
async getPlayableSongIds(songIds: string[]): Promise<Set<string>> {
if (songIds.length === 0) return new Set();
try {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songIds.join(","), quality: this.quality, ...this.cookieParams },
});
const playUrlMap: Record<string, { url?: string }> =
res.data?.data?.playUrl ?? {};
const playable = new Set<string>();
for (const [mid, info] of Object.entries(playUrlMap)) {
if (info?.url) playable.add(mid);
}
return playable;
} catch {
// On any error, fall through to per-song retry path — return empty
// and let the caller try songs sequentially via getSongUrl.
return new Set();
}
}
async getSongDetail(songId: string): Promise<Song | null> { async getSongDetail(songId: string): Promise<Song | null> {
// Try /getSongInfo for full metadata, but fall through to a minimal // Try /getSongInfo for full metadata, but fall through to a minimal
// stub if the library endpoint fails (current @sansenjian/qq-music-api // stub if the library endpoint fails (current @sansenjian/qq-music-api
+27 -3
View File
@@ -249,9 +249,30 @@ export function createPlayerRouter(
return; return;
} }
// QQ-specific optimization: many users' QQ playlists contain a
// large fraction of songs that return result=104003 (region/copyright
// restricted). Batch-resolve URLs once and only queue the playable
// ones, otherwise the playback retry loop wastes time guessing.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string>> };
if (typeof qqLike.getPlayableSongIds === "function") {
const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
if (playable.size > 0) {
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
// If batch returned nothing, leave queueable as-is and let the
// sequential retry path try anyway (handles the case where the
// batch endpoint failed entirely).
}
if (queueable.length === 0) {
res.json({ message: `Loaded ${totalCount} songs but none were playable (likely copyright/region restrictions).` });
return;
}
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
for (const song of songs) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
} }
@@ -276,10 +297,13 @@ export function createPlayerRouter(
} }
const playing = queue.current(); const playing = queue.current();
const loadedMsg = queueable.length < totalCount
? `Loaded ${queueable.length} of ${totalCount} songs (rest are copyright/region restricted)`
: `Loaded ${queueable.length} songs`;
if (started && playing) { if (started && playing) {
res.json({ message: `Loaded ${songs.length} songs. Now playing: ${playing.name}` }); res.json({ message: `${loadedMsg}. Now playing: ${playing.name}` });
} else { } else {
res.json({ message: `Loaded ${songs.length} songs but none were playable (likely copyright/region restrictions).` }); res.json({ message: `${loadedMsg}, but couldn't start playback.` });
} }
} catch (err) { } catch (err) {
logger.error({ err }, "Play playlist failed"); logger.error({ err }, "Play playlist failed");