fix: serialize artist playback and keep incomplete QQ catalogs retryable

This commit is contained in:
TIANYAO ZHANG committed 2026-10-03 17:17:35 +08:00
1 parent f8b03acca3
commit cf83916f39
4 files changed
+367 -107

No files matched your search

+166 -3
View File
@@ -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
View File
@@ -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;
} }
+63
View File
@@ -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
View File
@@ -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 });