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

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