From cf83916f39edea217a0d7ac1e275db3ea0ccc34b Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:17:35 +0800 Subject: [PATCH] fix: serialize artist playback and keep incomplete QQ catalogs retryable --- src/music/qq.test.ts | 169 +++++++++++++++++++++++++++++- src/music/qq.ts | 178 +++++++++++++++++++------------- src/web/api/play-artist.test.ts | 63 +++++++++++ src/web/api/player.ts | 64 ++++++------ 4 files changed, 367 insertions(+), 107 deletions(-) diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts index 53f5d03..ceee44f 100644 --- a/src/music/qq.test.ts +++ b/src/music/qq.test.ts @@ -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 () => { diff --git a/src/music/qq.ts b/src/music/qq.ts index 674fbc3..261f05c 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -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; + 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 { 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(); + private artistCatalog = new Map(); - private async buildArtistCatalog(artistId: string): Promise { + private async buildArtistCatalog(artistId: string): Promise { 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(); 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(); + const searchAlbumIds = new Set(); + 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 { - const detail = await this.fetchSingerDetail(artistId, 1); - const name = detail?.singer_info?.name; - if (!name) return []; - const ids: string[] = []; - const seen = new Set(); - 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; } diff --git a/src/web/api/play-artist.test.ts b/src/web/api/play-artist.test.ts index e46bc62..55c651b 100644 --- a/src/web/api/play-artist.test.ts +++ b/src/web/api/play-artist.test.ts @@ -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((resolve) => { notifyArtistStarted = resolve; }); + const artistHold = new Promise((resolve) => { releaseArtist = resolve; }); + const singleArrived = new Promise((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) => 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"); + }); +}); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 0e2fdaa..c8e9c1c 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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 });