mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-05 22:42:49 +08:00
fix: serialize artist playback and keep incomplete QQ catalogs retryable
This commit is contained in:
1 parent
f8b03acca3
commit
cf83916f39
4 files changed
+367
-107
No files matched your search
+166
-3
@@ -273,6 +273,168 @@ describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => {
|
||||
return { mid, title, singer: [{ name: "Adele" }], album: { mid: "al1", name: "Album" }, interval: 200 };
|
||||
}
|
||||
|
||||
it("does not cache a hot-only catalogue when the singer lookup for the album scan fails", async () => {
|
||||
let singerCalls = 0;
|
||||
mockGet.mockImplementation(async (url: string, cfg: any) => {
|
||||
if (url === "/getAlbumInfo") {
|
||||
return { data: { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
|
||||
}
|
||||
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
|
||||
const data = JSON.parse(cfg.params.data);
|
||||
if (data.req_0) {
|
||||
if (++singerCalls === 2) throw new Error("temporary singer lookup failure");
|
||||
return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("hot", "Hot")] } } } };
|
||||
}
|
||||
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
|
||||
return { data: { req_album: { data: { body: { album: { list } } } } } };
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot"]);
|
||||
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 0, req_album: { code: 2000 } },
|
||||
{ code: 500, req_album: { data: { body: { album: { list: [] } } } } },
|
||||
{ req_album: { data: { body: {} } } },
|
||||
{ req_album: { data: { body: { album: { list: {} } } } } },
|
||||
])("does not cache logical or malformed album-search failure %#", async (failedResponse) => {
|
||||
let failed = true;
|
||||
mockGet.mockImplementation(async (url: string, cfg: any) => {
|
||||
if (url === "/getAlbumInfo") return { data: { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
|
||||
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
|
||||
const data = JSON.parse(cfg.params.data);
|
||||
if (data.req_0) return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("hot", "Hot")] } } } };
|
||||
if (failed) return { data: failedResponse };
|
||||
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
|
||||
return { data: { code: 0, req_album: { code: 0, data: { body: { album: { list } } } } } };
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
const degraded = await provider.getArtistAllSongs("m1");
|
||||
expect(degraded.songs.map((s) => s.id)).toEqual(["hot"]);
|
||||
failed = false;
|
||||
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
|
||||
});
|
||||
|
||||
it("includes more than 50 short albums when the catalogue is below the 500-song ceiling", async () => {
|
||||
mockCatalogue({
|
||||
hot: [songRaw("hot", "Hot")],
|
||||
albumSearch: (page) => Array.from({ length: page === 1 ? 50 : page === 2 ? 10 : 0 }, (_, i) => ({ albumMID: `al${(page - 1) * 50 + i}`, singerMID: "m1" })),
|
||||
albumSongs: Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`al${i}`, [songRaw(`s${i}`, `${i}`)]])),
|
||||
});
|
||||
const result = await new QQMusicProvider("http://x").getArtistAllSongs("m1", 0, 100);
|
||||
expect(result.songs).toHaveLength(61);
|
||||
expect(result.total).toBe(61);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ response: { code: 2000, data: { list: [] } } },
|
||||
{ response: { data: {} } },
|
||||
{ response: { data: { list: [{}] } } },
|
||||
{ response: { data: { list: [{ mid: "" }] } } },
|
||||
{ response: { data: { list: [{ mid: " " }] } } },
|
||||
{ response: { data: { list: [{ mid: {} }] } } },
|
||||
])("does not cache a catalogue after a logical or malformed album-song failure %#", async (failedResponse) => {
|
||||
let failed = true;
|
||||
mockGet.mockImplementation(async (url: string, cfg: any) => {
|
||||
if (url === "/getAlbumInfo") return { data: failed ? failedResponse : { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
|
||||
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
|
||||
const data = JSON.parse(cfg.params.data);
|
||||
if (data.req_0) return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("hot", "Hot")] } } } };
|
||||
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
|
||||
return { data: { req_album: { data: { body: { album: { list } } } } } };
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot"]);
|
||||
failed = false;
|
||||
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
|
||||
});
|
||||
|
||||
it("does not cache a catalogue whose hot-song rows contain no song identifier", async () => {
|
||||
let failed = true;
|
||||
mockGet.mockImplementation(async (url: string, cfg: any) => {
|
||||
if (url === "/getAlbumInfo") return { data: { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
|
||||
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
|
||||
const data = JSON.parse(cfg.params.data);
|
||||
if (data.req_0) return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: failed ? [{}] : [songRaw("hot", "Hot")] } } } };
|
||||
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
|
||||
return { data: { req_album: { data: { body: { album: { list } } } } } };
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
await provider.getArtistAllSongs("m1");
|
||||
failed = false;
|
||||
const recovered = await provider.getArtistAllSongs("m1");
|
||||
expect(recovered.songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
|
||||
expect(recovered.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds a large catalogue at 500 unique songs while reporting remaining tracks", async () => {
|
||||
mockCatalogue({
|
||||
hot: [songRaw("hot", "Hot")],
|
||||
albumSearch: () => [{ albumMID: "al1", singerMID: "m1" }],
|
||||
albumSongs: { al1: Array.from({ length: 600 }, (_, i) => songRaw(`s${i}`, `${i}`)) },
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
const last = await provider.getArtistAllSongs("m1", 400, 100);
|
||||
expect(last.songs).toHaveLength(100);
|
||||
expect(last.hasMore).toBe(true);
|
||||
expect(last.total).toBeGreaterThan(500);
|
||||
expect((await provider.getArtistAllSongs("m1", 500, 100)).songs).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a complete catalogue of exactly 500 songs without an extra page", async () => {
|
||||
mockCatalogue({
|
||||
hot: [songRaw("hot", "Hot")],
|
||||
albumSearch: () => [{ albumMID: "al1", singerMID: "m1" }],
|
||||
albumSongs: { al1: Array.from({ length: 499 }, (_, i) => songRaw(`s${i}`, `${i}`)) },
|
||||
});
|
||||
const last = await new QQMusicProvider("http://x").getArtistAllSongs("m1", 400, 100);
|
||||
expect(last.total).toBe(500);
|
||||
expect(last.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a page without matching singers as the end of the search", async () => {
|
||||
mockCatalogue({
|
||||
hot: [songRaw("hot", "Hot")],
|
||||
albumSearch: (page) => page === 1
|
||||
? Array.from({ length: 50 }, (_, i) => ({ albumMID: `other${i}`, singerMID: "other" }))
|
||||
: page === 2 ? [{ albumMID: "al1", singerMID: "m1" }] : [],
|
||||
albumSongs: { al1: [songRaw("album-track", "Album track")] },
|
||||
});
|
||||
expect((await new QQMusicProvider("http://x").getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
|
||||
});
|
||||
|
||||
it("leaves repeated search pages incomplete and retryable", async () => {
|
||||
mockCatalogue({
|
||||
hot: [songRaw("hot", "Hot")],
|
||||
albumSearch: () => Array.from({ length: 50 }, (_, i) => ({ albumMID: `al${i}`, singerMID: "m1" })),
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
expect((await provider.getArtistAllSongs("m1")).hasMore).toBe(true);
|
||||
mockCatalogue({ hot: [songRaw("hot", "Hot")], albumSearch: () => [] });
|
||||
const recovered = await provider.getArtistAllSongs("m1");
|
||||
expect(recovered.total).toBe(1);
|
||||
expect(recovered.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds endless search pages of empty albums and leaves the partial result uncached", async () => {
|
||||
let searchCalls = 0;
|
||||
mockCatalogue({
|
||||
hot: [songRaw("hot", "Hot")],
|
||||
albumSearch: (page) => {
|
||||
if (++searchCalls > 110) throw new Error("unbounded upstream scan");
|
||||
return Array.from({ length: 50 }, (_, i) => ({ albumMID: `al${page}-${i}`, singerMID: "m1" }));
|
||||
},
|
||||
});
|
||||
const provider = new QQMusicProvider("http://x");
|
||||
const partial = await provider.getArtistAllSongs("m1");
|
||||
expect(searchCalls).toBeLessThanOrEqual(100);
|
||||
expect(partial.hasMore).toBe(true);
|
||||
mockCatalogue({ hot: [songRaw("hot", "Hot")], albumSearch: () => [] });
|
||||
expect((await provider.getArtistAllSongs("m1")).hasMore).toBe(false);
|
||||
});
|
||||
|
||||
/** singer detail (top 50) + album search pages + per-album song lists. */
|
||||
function mockCatalogue(opts: {
|
||||
hot?: any[];
|
||||
@@ -335,12 +497,12 @@ describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => {
|
||||
// The unrelated album (singerMID m9) is never fetched.
|
||||
const albumCalls = mockGet.mock.calls.filter((c: any[]) => c[0] === "/getAlbumInfo");
|
||||
expect(albumCalls.map((c: any[]) => c[1].params.albummid).sort()).toEqual(["al1", "al2"]);
|
||||
// Page 2 of the album search returns the same list → nothing new → stop.
|
||||
// A short page exhausts the search without another upstream request.
|
||||
const searchPages = mockGet.mock.calls
|
||||
.filter((c: any[]) => c[0] === "/cgi-bin/musicu.fcg")
|
||||
.map((c: any[]) => JSON.parse(c[1].params.data).req_album?.param?.page_num)
|
||||
.filter(Boolean);
|
||||
expect(searchPages).toEqual([1, 2]);
|
||||
expect(searchPages).toEqual([1]);
|
||||
});
|
||||
|
||||
it("slices pages with offset/limit and reports hasMore", async () => {
|
||||
@@ -387,7 +549,8 @@ describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => {
|
||||
const page = await p.getArtistAllSongs("m1", 0, 50);
|
||||
|
||||
expect(page.songs.map((s) => s.id)).toEqual(["s1"]);
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.total).toBe(250);
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("retries a failed album search once before giving up", async () => {
|
||||
|
||||
+106
-72
@@ -59,11 +59,28 @@ function isArtistAlbum(a: any, artistId: string): boolean {
|
||||
* album, so the merged list is memoised per singer for a while. */
|
||||
const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
const ARTIST_CATALOG_MAX_ENTRIES = 20;
|
||||
/** Album search pages to walk (50 albums each) and the hard cap on albums we
|
||||
* are willing to fetch songs for. */
|
||||
const ARTIST_ALBUM_MAX_PAGES = 3;
|
||||
const ARTIST_ALBUM_MAX = 50;
|
||||
/** Bound pathological search responses even when every album is empty or all
|
||||
* tracks are duplicates. Hitting this guard is an incomplete, uncached scan. */
|
||||
const ARTIST_ALBUM_MAX_PAGES = 100;
|
||||
const ARTIST_ALBUM_CONCURRENCY = 5;
|
||||
const ARTIST_CATALOG_MAX_SONGS = 500;
|
||||
|
||||
interface ArtistCatalog {
|
||||
songs: Song[];
|
||||
total: number;
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
/** A malformed row must not disappear in the mapper and make an incomplete
|
||||
* artist catalogue look like a successful, cacheable empty album. */
|
||||
function isQqSongRow(raw: unknown): boolean {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||
const song = raw as Record<string, unknown>;
|
||||
const id = song.mid ?? song.songmid ?? song.songMID ?? song.id ?? song.songid ?? song.songId;
|
||||
return typeof id === "string"
|
||||
? id.trim().length > 0
|
||||
: typeof id === "number" && Number.isSafeInteger(id) && id > 0;
|
||||
}
|
||||
|
||||
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
@@ -530,7 +547,16 @@ export class QQMusicProvider implements MusicProvider {
|
||||
const res = await this.api.get("/getAlbumInfo", {
|
||||
params: { albummid: albumId, ...this.cookieParams },
|
||||
});
|
||||
return mapQqSongs(res.data?.response?.data?.list ?? []);
|
||||
const response = res.data?.response;
|
||||
const list = response?.data?.list;
|
||||
if (
|
||||
(res.data?.code != null && Number(res.data.code) !== 0) ||
|
||||
(response?.code != null && Number(response.code) !== 0) ||
|
||||
!Array.isArray(list) || !list.every(isQqSongRow)
|
||||
) {
|
||||
throw new Error("QQ album-song lookup failed");
|
||||
}
|
||||
return mapQqSongs(list);
|
||||
}
|
||||
|
||||
/** music.web_singer_info_svr / get_singer_detail_info — singer info plus up
|
||||
@@ -553,7 +579,16 @@ export class QQMusicProvider implements MusicProvider {
|
||||
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||
params: { format: "json", data: reqData },
|
||||
});
|
||||
return res.data?.req_0?.data ?? null;
|
||||
const response = res.data?.req_0;
|
||||
const data = response?.data;
|
||||
if (
|
||||
(res.data?.code != null && Number(res.data.code) !== 0) ||
|
||||
(response?.code != null && Number(response.code) !== 0) ||
|
||||
typeof data?.singer_info?.name !== "string" ||
|
||||
!data.singer_info.name ||
|
||||
!Array.isArray(data.songlist) || !data.songlist.every(isQqSongRow)
|
||||
) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -591,94 +626,86 @@ export class QQMusicProvider implements MusicProvider {
|
||||
*/
|
||||
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
|
||||
const catalogue = await this.buildArtistCatalog(artistId);
|
||||
const safeOffset = Math.max(0, Math.trunc(offset) || 0);
|
||||
const safeLimit = Math.max(1, Math.min(Math.trunc(limit) || 50, 100));
|
||||
const songs = catalogue.slice(safeOffset, safeOffset + safeLimit);
|
||||
const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(Math.trunc(limit) || 50, 100)) : 50;
|
||||
const songs = catalogue.songs.slice(safeOffset, safeOffset + safeLimit);
|
||||
return {
|
||||
songs,
|
||||
total: catalogue.length,
|
||||
hasMore: safeOffset + songs.length < catalogue.length,
|
||||
total: catalogue.total,
|
||||
hasMore: safeOffset + songs.length < catalogue.total || catalogue.incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
/** Memoised full catalogues, keyed by singer MID (see ARTIST_CATALOG_TTL_MS). */
|
||||
private artistCatalog = new Map<string, { at: number; songs: Song[] }>();
|
||||
private artistCatalog = new Map<string, { at: number; catalogue: ArtistCatalog }>();
|
||||
|
||||
private async buildArtistCatalog(artistId: string): Promise<Song[]> {
|
||||
private async buildArtistCatalog(artistId: string): Promise<ArtistCatalog> {
|
||||
const cached = this.artistCatalog.get(artistId);
|
||||
if (cached && Date.now() - cached.at < ARTIST_CATALOG_TTL_MS) return cached.songs;
|
||||
if (cached && Date.now() - cached.at < ARTIST_CATALOG_TTL_MS) return cached.catalogue;
|
||||
|
||||
const merged: Song[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (song: Song) => {
|
||||
if (!song.id || seen.has(song.id)) return;
|
||||
seen.add(song.id);
|
||||
merged.push(song);
|
||||
if (merged.length < ARTIST_CATALOG_MAX_SONGS) merged.push(song);
|
||||
};
|
||||
|
||||
for (const song of await this.getArtistSongs(artistId, 50)) push(song);
|
||||
|
||||
// null = the album search could not be enumerated (transient upstream
|
||||
// failure). The hot list is still returned, but such a degraded catalogue
|
||||
// must NOT be cached: a 10-minute cache would silently turn "play the whole
|
||||
// singer" into "play the hot 50".
|
||||
const albumIds = await this.fetchArtistAlbumIds(artistId);
|
||||
let albumFetchFailed = false;
|
||||
for (let i = 0; albumIds && i < albumIds.length; i += ARTIST_ALBUM_CONCURRENCY) {
|
||||
const batch = albumIds.slice(i, i + ARTIST_ALBUM_CONCURRENCY);
|
||||
const lists = await Promise.all(
|
||||
batch.map((mid) =>
|
||||
this.getAlbumSongs(mid).catch(() => {
|
||||
albumFetchFailed = true;
|
||||
return [] as Song[];
|
||||
})
|
||||
)
|
||||
);
|
||||
for (const list of lists) {
|
||||
for (const song of list) push(song);
|
||||
const hot = await this.fetchSingerDetail(artistId, 50);
|
||||
for (const song of mapQqSongs(hot?.songlist)) push(song);
|
||||
const detail = await this.fetchSingerDetail(artistId, 1);
|
||||
const name = detail?.singer_info?.name;
|
||||
let failed = !hot || !detail;
|
||||
let complete = false;
|
||||
const albumIds = new Set<string>();
|
||||
const searchAlbumIds = new Set<string>();
|
||||
if (name) {
|
||||
for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES && merged.length < ARTIST_CATALOG_MAX_SONGS; page++) {
|
||||
const list = (await this.searchArtistAlbums(name, page, 50)) ?? (await this.searchArtistAlbums(name, page, 50));
|
||||
if (list === null) { failed = true; break; }
|
||||
if (list.length === 0) { complete = true; break; }
|
||||
const batchIds: string[] = [];
|
||||
let freshSearchEntries = 0;
|
||||
for (const entry of list) {
|
||||
const mid = String(entry?.albumMID ?? entry?.album_mid ?? "");
|
||||
if (!mid) { failed = true; continue; }
|
||||
if (!searchAlbumIds.has(mid)) { searchAlbumIds.add(mid); freshSearchEntries++; }
|
||||
if (isArtistAlbum(entry, artistId) && !albumIds.has(mid)) {
|
||||
albumIds.add(mid);
|
||||
batchIds.push(mid);
|
||||
}
|
||||
}
|
||||
// Repeated pages cannot prove exhaustion, but must not loop forever.
|
||||
if (freshSearchEntries === 0) { failed = true; break; }
|
||||
let fetchedAlbums = 0;
|
||||
for (let i = 0; i < batchIds.length && merged.length < ARTIST_CATALOG_MAX_SONGS; i += ARTIST_ALBUM_CONCURRENCY) {
|
||||
const batch = batchIds.slice(i, i + ARTIST_ALBUM_CONCURRENCY);
|
||||
const lists = await Promise.all(batch.map((mid) =>
|
||||
this.getAlbumSongs(mid).catch(() => { failed = true; return [] as Song[]; })
|
||||
));
|
||||
fetchedAlbums += batch.length;
|
||||
for (const songs of lists) for (const song of songs) push(song);
|
||||
}
|
||||
if (list.length < 50 && fetchedAlbums === batchIds.length && seen.size <= ARTIST_CATALOG_MAX_SONGS) { complete = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (albumIds !== null && !albumFetchFailed) {
|
||||
const incomplete = failed || !complete;
|
||||
const reported = Math.max(0, ...[hot?.total_song, detail?.total_song].map((n) => Number.isFinite(Number(n)) ? Math.trunc(Number(n)) : 0));
|
||||
const catalogue: ArtistCatalog = {
|
||||
songs: merged,
|
||||
total: incomplete ? Math.max(seen.size, reported, merged.length === ARTIST_CATALOG_MAX_SONGS ? ARTIST_CATALOG_MAX_SONGS + 1 : 0) : merged.length,
|
||||
incomplete,
|
||||
};
|
||||
// Cache complete catalogues and intentional 500-song truncation only.
|
||||
// Failure, repeated pages and an exhausted scan budget must remain retryable.
|
||||
if (!failed && (complete || merged.length === ARTIST_CATALOG_MAX_SONGS)) {
|
||||
if (this.artistCatalog.size >= ARTIST_CATALOG_MAX_ENTRIES) {
|
||||
const oldest = this.artistCatalog.keys().next().value;
|
||||
if (oldest !== undefined) this.artistCatalog.delete(oldest);
|
||||
}
|
||||
this.artistCatalog.set(artistId, { at: Date.now(), songs: merged });
|
||||
this.artistCatalog.set(artistId, { at: Date.now(), catalogue });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Every album MID the singer owns, via paged album search (see getArtistAlbums).
|
||||
* null = the search failed, so the album list is unknown (not empty). */
|
||||
private async fetchArtistAlbumIds(artistId: string): Promise<string[] | null> {
|
||||
const detail = await this.fetchSingerDetail(artistId, 1);
|
||||
const name = detail?.singer_info?.name;
|
||||
if (!name) return [];
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES; page++) {
|
||||
// One immediate retry absorbs a transient upstream hiccup, which would
|
||||
// otherwise look exactly like "this singer has no albums".
|
||||
const list =
|
||||
(await this.searchArtistAlbums(name, page, 50)) ?? (await this.searchArtistAlbums(name, page, 50));
|
||||
if (list === null) return null;
|
||||
if (list.length === 0) break;
|
||||
let added = 0;
|
||||
for (const entry of list) {
|
||||
if (!isArtistAlbum(entry, artistId)) continue;
|
||||
const mid = String(entry?.albumMID ?? entry?.album_mid ?? "");
|
||||
if (mid && !seen.has(mid)) {
|
||||
seen.add(mid);
|
||||
ids.push(mid);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (ids.length >= ARTIST_ALBUM_MAX) break;
|
||||
// A page that adds nothing means the search has drifted off this singer.
|
||||
if (added === 0) break;
|
||||
}
|
||||
return ids.slice(0, ARTIST_ALBUM_MAX);
|
||||
return catalogue;
|
||||
}
|
||||
|
||||
/** search_type=2 album search for a singer name — raw entries, null on failure. */
|
||||
@@ -703,7 +730,14 @@ export class QQMusicProvider implements MusicProvider {
|
||||
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||
params: { format: "json", data: reqData },
|
||||
});
|
||||
return res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||
const response = res.data?.req_album;
|
||||
const list = response?.data?.body?.album?.list;
|
||||
if (
|
||||
(res.data?.code != null && Number(res.data.code) !== 0) ||
|
||||
(response?.code != null && Number(response.code) !== 0) ||
|
||||
!Array.isArray(list)
|
||||
) return null;
|
||||
return list;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectArtistSongs } from "./player.js";
|
||||
import type { ArtistSongPage, Song } from "../../music/provider.js";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { createPlayerRouter } from "./player.js";
|
||||
import { BotInstance } from "../../bot/instance.js";
|
||||
import { PlayQueue } from "../../audio/queue.js";
|
||||
|
||||
function song(id: string): Song {
|
||||
return {
|
||||
@@ -48,6 +53,11 @@ describe("collectArtistSongs (play-artist all:true)", () => {
|
||||
expect(fetchPage).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("enforces the song cap even when an upstream page exceeds the requested limit", async () => {
|
||||
const fetchPage = vi.fn(async () => page(Array.from({ length: 600 }, (_, i) => String(i)), 600, false));
|
||||
expect(await collectArtistSongs(fetchPage, "a")).toHaveLength(500);
|
||||
});
|
||||
|
||||
it("stops on an empty page even when hasMore claims otherwise", async () => {
|
||||
const fetchPage = vi.fn(async () => page([], 9, true));
|
||||
|
||||
@@ -64,3 +74,56 @@ describe("collectArtistSongs (play-artist all:true)", () => {
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play-artist playback serialization", () => {
|
||||
it("keeps the queue and audible song consistent when single-song playback overlaps artist playback", async () => {
|
||||
const queue = new PlayQueue();
|
||||
let audible: string | null = null;
|
||||
let releaseArtist!: () => void;
|
||||
let notifyArtistStarted!: () => void;
|
||||
let notifySingleArrived!: () => void;
|
||||
const artistStarted = new Promise<void>((resolve) => { notifyArtistStarted = resolve; });
|
||||
const artistHold = new Promise<void>((resolve) => { releaseArtist = resolve; });
|
||||
const singleArrived = new Promise<void>((resolve) => { notifySingleArrived = resolve; });
|
||||
const bot: any = {
|
||||
playGate: Promise.resolve(),
|
||||
getProviderFor: () => ({ platform: "netease", getArtistSongs: async () => [song("A")], getArtistAllSongs: async () => page(["A"], 1, false) }),
|
||||
getPlayer: () => ({ stop: () => { audible = null; }, resetFailures: () => {} }),
|
||||
getQueueManager: () => queue,
|
||||
resolveAndPlay: async (track: Song) => {
|
||||
notifyArtistStarted();
|
||||
await artistHold;
|
||||
audible = track.id;
|
||||
return true;
|
||||
},
|
||||
playSingleSong: async (track: Song) => {
|
||||
queue.clear();
|
||||
queue.add(track);
|
||||
queue.play();
|
||||
audible = track.id;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
bot.runExclusive = (fn: () => Promise<unknown>) => BotInstance.prototype.runExclusive.call(bot, fn);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).user = { role: "admin" }; next(); });
|
||||
app.use("/api/player/b/play-song", (_req, _res, next) => { notifySingleArrived(); next(); });
|
||||
app.use("/api/player", createPlayerRouter({ getBot: () => bot } as any, { error: vi.fn() } as any));
|
||||
const artistRequest = request(app).post("/api/player/b/play-artist").send({ artistId: "artist", platform: "netease" }).then((res) => res);
|
||||
await artistStarted;
|
||||
const singleRequest = request(app).post("/api/player/b/play-song").send({ song: song("B") }).then((res) => res);
|
||||
// Let the overlapping HTTP request enter the real route while A's URL is pending.
|
||||
await singleArrived;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const whileArtistPending = queue.current()?.id;
|
||||
releaseArtist();
|
||||
const [artistResponse, singleResponse] = await Promise.all([artistRequest, singleRequest]);
|
||||
expect(artistResponse.status).toBe(200);
|
||||
expect(singleResponse.status).toBe(200);
|
||||
expect(whileArtistPending).toBe("A");
|
||||
expect(queue.current()?.id).toBe("B");
|
||||
expect(audible).toBe("B");
|
||||
});
|
||||
});
|
||||
+32
-32
@@ -26,6 +26,7 @@ export async function collectArtistSongs(
|
||||
if (!seen.has(song.id)) {
|
||||
seen.add(song.id);
|
||||
songs.push(song);
|
||||
if (songs.length === MAX_ARTIST_QUEUE) return songs;
|
||||
}
|
||||
}
|
||||
if (!page.hasMore || page.songs.length === 0) break;
|
||||
@@ -524,10 +525,6 @@ export function createPlayerRouter(
|
||||
// safety cap); otherwise the hot songs are the best it can offer.
|
||||
const fetchPage = provider.getArtistAllSongs?.bind(provider);
|
||||
|
||||
// Stop current playback
|
||||
bot.getPlayer().stop();
|
||||
bot.getPlayer().resetFailures();
|
||||
|
||||
const songs = fetchPage
|
||||
? await collectArtistSongs(fetchPage, artistId)
|
||||
: await provider.getArtistSongs(artistId, 50);
|
||||
@@ -552,37 +549,40 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = bot.getQueueManager();
|
||||
queue.clear();
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
// Catalogue and copyright lookups leave current playback running. Only
|
||||
// the queue replacement and playback itself occupy the shared play gate.
|
||||
const body = await bot.runExclusive(async () => {
|
||||
bot.getPlayer().stop();
|
||||
bot.getPlayer().resetFailures();
|
||||
const queue = bot.getQueueManager();
|
||||
queue.clear();
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
|
||||
const mode = queue.getMode();
|
||||
let first;
|
||||
if (mode === "random" || mode === "rloop") {
|
||||
const idx = Math.floor(Math.random() * queue.size());
|
||||
first = queue.playAt(idx);
|
||||
} else {
|
||||
first = queue.play();
|
||||
}
|
||||
const mode = queue.getMode();
|
||||
let first;
|
||||
if (mode === "random" || mode === "rloop") {
|
||||
const idx = Math.floor(Math.random() * queue.size());
|
||||
first = queue.playAt(idx);
|
||||
} else {
|
||||
first = queue.play();
|
||||
}
|
||||
|
||||
let started = first ? await bot.resolveAndPlay(first) : false;
|
||||
if (first && !started) {
|
||||
started = await bot.playNext(20);
|
||||
}
|
||||
let started = first ? await bot.resolveAndPlay(first) : false;
|
||||
if (first && !started) started = await bot.playNext(20);
|
||||
|
||||
const playing = queue.current();
|
||||
const loadedMsg = queueable.length < totalCount
|
||||
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||
: `已加载 ${queueable.length} 首`;
|
||||
if (started && playing) {
|
||||
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
|
||||
} else {
|
||||
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
|
||||
}
|
||||
const playing = queue.current();
|
||||
const loadedMsg = queueable.length < totalCount
|
||||
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||
: `已加载 ${queueable.length} 首`;
|
||||
return started && playing
|
||||
? { ok: true, message: `${loadedMsg},正在播放:${playing.name}` }
|
||||
: { ok: false, message: `${loadedMsg},但无法开始播放。` };
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "play-artist failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
|
||||
Reference in new issue
Block a user