mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 14:02: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 };
|
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. */
|
/** singer detail (top 50) + album search pages + per-album song lists. */
|
||||||
function mockCatalogue(opts: {
|
function mockCatalogue(opts: {
|
||||||
hot?: any[];
|
hot?: any[];
|
||||||
@@ -335,12 +497,12 @@ describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => {
|
|||||||
// The unrelated album (singerMID m9) is never fetched.
|
// The unrelated album (singerMID m9) is never fetched.
|
||||||
const albumCalls = mockGet.mock.calls.filter((c: any[]) => c[0] === "/getAlbumInfo");
|
const albumCalls = mockGet.mock.calls.filter((c: any[]) => c[0] === "/getAlbumInfo");
|
||||||
expect(albumCalls.map((c: any[]) => c[1].params.albummid).sort()).toEqual(["al1", "al2"]);
|
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
|
const searchPages = mockGet.mock.calls
|
||||||
.filter((c: any[]) => c[0] === "/cgi-bin/musicu.fcg")
|
.filter((c: any[]) => c[0] === "/cgi-bin/musicu.fcg")
|
||||||
.map((c: any[]) => JSON.parse(c[1].params.data).req_album?.param?.page_num)
|
.map((c: any[]) => JSON.parse(c[1].params.data).req_album?.param?.page_num)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
expect(searchPages).toEqual([1, 2]);
|
expect(searchPages).toEqual([1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("slices pages with offset/limit and reports hasMore", async () => {
|
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);
|
const page = await p.getArtistAllSongs("m1", 0, 50);
|
||||||
|
|
||||||
expect(page.songs.map((s) => s.id)).toEqual(["s1"]);
|
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 () => {
|
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. */
|
* album, so the merged list is memoised per singer for a while. */
|
||||||
const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000;
|
const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||||
const ARTIST_CATALOG_MAX_ENTRIES = 20;
|
const ARTIST_CATALOG_MAX_ENTRIES = 20;
|
||||||
/** Album search pages to walk (50 albums each) and the hard cap on albums we
|
/** Bound pathological search responses even when every album is empty or all
|
||||||
* are willing to fetch songs for. */
|
* tracks are duplicates. Hitting this guard is an incomplete, uncached scan. */
|
||||||
const ARTIST_ALBUM_MAX_PAGES = 3;
|
const ARTIST_ALBUM_MAX_PAGES = 100;
|
||||||
const ARTIST_ALBUM_MAX = 50;
|
|
||||||
const ARTIST_ALBUM_CONCURRENCY = 5;
|
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[] {
|
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
@@ -530,7 +547,16 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const res = await this.api.get("/getAlbumInfo", {
|
const res = await this.api.get("/getAlbumInfo", {
|
||||||
params: { albummid: albumId, ...this.cookieParams },
|
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
|
/** 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", {
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
params: { format: "json", data: reqData },
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -591,94 +626,86 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
*/
|
*/
|
||||||
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
|
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
|
||||||
const catalogue = await this.buildArtistCatalog(artistId);
|
const catalogue = await this.buildArtistCatalog(artistId);
|
||||||
const safeOffset = Math.max(0, Math.trunc(offset) || 0);
|
const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
|
||||||
const safeLimit = Math.max(1, Math.min(Math.trunc(limit) || 50, 100));
|
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(Math.trunc(limit) || 50, 100)) : 50;
|
||||||
const songs = catalogue.slice(safeOffset, safeOffset + safeLimit);
|
const songs = catalogue.songs.slice(safeOffset, safeOffset + safeLimit);
|
||||||
return {
|
return {
|
||||||
songs,
|
songs,
|
||||||
total: catalogue.length,
|
total: catalogue.total,
|
||||||
hasMore: safeOffset + songs.length < catalogue.length,
|
hasMore: safeOffset + songs.length < catalogue.total || catalogue.incomplete,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Memoised full catalogues, keyed by singer MID (see ARTIST_CATALOG_TTL_MS). */
|
/** 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);
|
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 merged: Song[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const push = (song: Song) => {
|
const push = (song: Song) => {
|
||||||
if (!song.id || seen.has(song.id)) return;
|
if (!song.id || seen.has(song.id)) return;
|
||||||
seen.add(song.id);
|
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);
|
const hot = await this.fetchSingerDetail(artistId, 50);
|
||||||
|
for (const song of mapQqSongs(hot?.songlist)) push(song);
|
||||||
// null = the album search could not be enumerated (transient upstream
|
const detail = await this.fetchSingerDetail(artistId, 1);
|
||||||
// failure). The hot list is still returned, but such a degraded catalogue
|
const name = detail?.singer_info?.name;
|
||||||
// must NOT be cached: a 10-minute cache would silently turn "play the whole
|
let failed = !hot || !detail;
|
||||||
// singer" into "play the hot 50".
|
let complete = false;
|
||||||
const albumIds = await this.fetchArtistAlbumIds(artistId);
|
const albumIds = new Set<string>();
|
||||||
let albumFetchFailed = false;
|
const searchAlbumIds = new Set<string>();
|
||||||
for (let i = 0; albumIds && i < albumIds.length; i += ARTIST_ALBUM_CONCURRENCY) {
|
if (name) {
|
||||||
const batch = albumIds.slice(i, i + ARTIST_ALBUM_CONCURRENCY);
|
for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES && merged.length < ARTIST_CATALOG_MAX_SONGS; page++) {
|
||||||
const lists = await Promise.all(
|
const list = (await this.searchArtistAlbums(name, page, 50)) ?? (await this.searchArtistAlbums(name, page, 50));
|
||||||
batch.map((mid) =>
|
if (list === null) { failed = true; break; }
|
||||||
this.getAlbumSongs(mid).catch(() => {
|
if (list.length === 0) { complete = true; break; }
|
||||||
albumFetchFailed = true;
|
const batchIds: string[] = [];
|
||||||
return [] as Song[];
|
let freshSearchEntries = 0;
|
||||||
})
|
for (const entry of list) {
|
||||||
)
|
const mid = String(entry?.albumMID ?? entry?.album_mid ?? "");
|
||||||
);
|
if (!mid) { failed = true; continue; }
|
||||||
for (const list of lists) {
|
if (!searchAlbumIds.has(mid)) { searchAlbumIds.add(mid); freshSearchEntries++; }
|
||||||
for (const song of list) push(song);
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const incomplete = failed || !complete;
|
||||||
if (albumIds !== null && !albumFetchFailed) {
|
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) {
|
if (this.artistCatalog.size >= ARTIST_CATALOG_MAX_ENTRIES) {
|
||||||
const oldest = this.artistCatalog.keys().next().value;
|
const oldest = this.artistCatalog.keys().next().value;
|
||||||
if (oldest !== undefined) this.artistCatalog.delete(oldest);
|
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;
|
return catalogue;
|
||||||
}
|
|
||||||
|
|
||||||
/** 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** search_type=2 album search for a singer name — raw entries, null on failure. */
|
/** 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", {
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
params: { format: "json", data: reqData },
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { collectArtistSongs } from "./player.js";
|
import { collectArtistSongs } from "./player.js";
|
||||||
import type { ArtistSongPage, Song } from "../../music/provider.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 {
|
function song(id: string): Song {
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +53,11 @@ describe("collectArtistSongs (play-artist all:true)", () => {
|
|||||||
expect(fetchPage).toHaveBeenCalledTimes(5);
|
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 () => {
|
it("stops on an empty page even when hasMore claims otherwise", async () => {
|
||||||
const fetchPage = vi.fn(async () => page([], 9, true));
|
const fetchPage = vi.fn(async () => page([], 9, true));
|
||||||
|
|
||||||
@@ -64,3 +74,56 @@ describe("collectArtistSongs (play-artist all:true)", () => {
|
|||||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
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)) {
|
if (!seen.has(song.id)) {
|
||||||
seen.add(song.id);
|
seen.add(song.id);
|
||||||
songs.push(song);
|
songs.push(song);
|
||||||
|
if (songs.length === MAX_ARTIST_QUEUE) return songs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!page.hasMore || page.songs.length === 0) break;
|
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.
|
// safety cap); otherwise the hot songs are the best it can offer.
|
||||||
const fetchPage = provider.getArtistAllSongs?.bind(provider);
|
const fetchPage = provider.getArtistAllSongs?.bind(provider);
|
||||||
|
|
||||||
// Stop current playback
|
|
||||||
bot.getPlayer().stop();
|
|
||||||
bot.getPlayer().resetFailures();
|
|
||||||
|
|
||||||
const songs = fetchPage
|
const songs = fetchPage
|
||||||
? await collectArtistSongs(fetchPage, artistId)
|
? await collectArtistSongs(fetchPage, artistId)
|
||||||
: await provider.getArtistSongs(artistId, 50);
|
: await provider.getArtistSongs(artistId, 50);
|
||||||
@@ -552,37 +549,40 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = bot.getQueueManager();
|
// Catalogue and copyright lookups leave current playback running. Only
|
||||||
queue.clear();
|
// the queue replacement and playback itself occupy the shared play gate.
|
||||||
for (const song of queueable) {
|
const body = await bot.runExclusive(async () => {
|
||||||
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
bot.getPlayer().stop();
|
||||||
}
|
bot.getPlayer().resetFailures();
|
||||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
const queue = bot.getQueueManager();
|
||||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
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();
|
const mode = queue.getMode();
|
||||||
let first;
|
let first;
|
||||||
if (mode === "random" || mode === "rloop") {
|
if (mode === "random" || mode === "rloop") {
|
||||||
const idx = Math.floor(Math.random() * queue.size());
|
const idx = Math.floor(Math.random() * queue.size());
|
||||||
first = queue.playAt(idx);
|
first = queue.playAt(idx);
|
||||||
} else {
|
} else {
|
||||||
first = queue.play();
|
first = queue.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
let started = first ? await bot.resolveAndPlay(first) : false;
|
let started = first ? await bot.resolveAndPlay(first) : false;
|
||||||
if (first && !started) {
|
if (first && !started) started = await bot.playNext(20);
|
||||||
started = await bot.playNext(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
const playing = queue.current();
|
const playing = queue.current();
|
||||||
const loadedMsg = queueable.length < totalCount
|
const loadedMsg = queueable.length < totalCount
|
||||||
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||||
: `已加载 ${queueable.length} 首`;
|
: `已加载 ${queueable.length} 首`;
|
||||||
if (started && playing) {
|
return started && playing
|
||||||
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
|
? { ok: true, message: `${loadedMsg},正在播放:${playing.name}` }
|
||||||
} else {
|
: { ok: false, message: `${loadedMsg},但无法开始播放。` };
|
||||||
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
|
});
|
||||||
}
|
res.json(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "play-artist failed");
|
logger.error({ err }, "play-artist failed");
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
|||||||
Reference in new issue
Block a user