mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-09 08:22:50 +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
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectArtistSongs } from "./player.js";
|
||||
import type { ArtistSongPage, Song } from "../../music/provider.js";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { createPlayerRouter } from "./player.js";
|
||||
import { BotInstance } from "../../bot/instance.js";
|
||||
import { PlayQueue } from "../../audio/queue.js";
|
||||
|
||||
function song(id: string): Song {
|
||||
return {
|
||||
@@ -48,6 +53,11 @@ describe("collectArtistSongs (play-artist all:true)", () => {
|
||||
expect(fetchPage).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("enforces the song cap even when an upstream page exceeds the requested limit", async () => {
|
||||
const fetchPage = vi.fn(async () => page(Array.from({ length: 600 }, (_, i) => String(i)), 600, false));
|
||||
expect(await collectArtistSongs(fetchPage, "a")).toHaveLength(500);
|
||||
});
|
||||
|
||||
it("stops on an empty page even when hasMore claims otherwise", async () => {
|
||||
const fetchPage = vi.fn(async () => page([], 9, true));
|
||||
|
||||
@@ -64,3 +74,56 @@ describe("collectArtistSongs (play-artist all:true)", () => {
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play-artist playback serialization", () => {
|
||||
it("keeps the queue and audible song consistent when single-song playback overlaps artist playback", async () => {
|
||||
const queue = new PlayQueue();
|
||||
let audible: string | null = null;
|
||||
let releaseArtist!: () => void;
|
||||
let notifyArtistStarted!: () => void;
|
||||
let notifySingleArrived!: () => void;
|
||||
const artistStarted = new Promise<void>((resolve) => { notifyArtistStarted = resolve; });
|
||||
const artistHold = new Promise<void>((resolve) => { releaseArtist = resolve; });
|
||||
const singleArrived = new Promise<void>((resolve) => { notifySingleArrived = resolve; });
|
||||
const bot: any = {
|
||||
playGate: Promise.resolve(),
|
||||
getProviderFor: () => ({ platform: "netease", getArtistSongs: async () => [song("A")], getArtistAllSongs: async () => page(["A"], 1, false) }),
|
||||
getPlayer: () => ({ stop: () => { audible = null; }, resetFailures: () => {} }),
|
||||
getQueueManager: () => queue,
|
||||
resolveAndPlay: async (track: Song) => {
|
||||
notifyArtistStarted();
|
||||
await artistHold;
|
||||
audible = track.id;
|
||||
return true;
|
||||
},
|
||||
playSingleSong: async (track: Song) => {
|
||||
queue.clear();
|
||||
queue.add(track);
|
||||
queue.play();
|
||||
audible = track.id;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
bot.runExclusive = (fn: () => Promise<unknown>) => BotInstance.prototype.runExclusive.call(bot, fn);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).user = { role: "admin" }; next(); });
|
||||
app.use("/api/player/b/play-song", (_req, _res, next) => { notifySingleArrived(); next(); });
|
||||
app.use("/api/player", createPlayerRouter({ getBot: () => bot } as any, { error: vi.fn() } as any));
|
||||
const artistRequest = request(app).post("/api/player/b/play-artist").send({ artistId: "artist", platform: "netease" }).then((res) => res);
|
||||
await artistStarted;
|
||||
const singleRequest = request(app).post("/api/player/b/play-song").send({ song: song("B") }).then((res) => res);
|
||||
// Let the overlapping HTTP request enter the real route while A's URL is pending.
|
||||
await singleArrived;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const whileArtistPending = queue.current()?.id;
|
||||
releaseArtist();
|
||||
const [artistResponse, singleResponse] = await Promise.all([artistRequest, singleRequest]);
|
||||
expect(artistResponse.status).toBe(200);
|
||||
expect(singleResponse.status).toBe(200);
|
||||
expect(whileArtistPending).toBe("A");
|
||||
expect(queue.current()?.id).toBe("B");
|
||||
expect(audible).toBe("B");
|
||||
});
|
||||
});
|
||||
+32
-32
@@ -26,6 +26,7 @@ export async function collectArtistSongs(
|
||||
if (!seen.has(song.id)) {
|
||||
seen.add(song.id);
|
||||
songs.push(song);
|
||||
if (songs.length === MAX_ARTIST_QUEUE) return songs;
|
||||
}
|
||||
}
|
||||
if (!page.hasMore || page.songs.length === 0) break;
|
||||
@@ -524,10 +525,6 @@ export function createPlayerRouter(
|
||||
// safety cap); otherwise the hot songs are the best it can offer.
|
||||
const fetchPage = provider.getArtistAllSongs?.bind(provider);
|
||||
|
||||
// Stop current playback
|
||||
bot.getPlayer().stop();
|
||||
bot.getPlayer().resetFailures();
|
||||
|
||||
const songs = fetchPage
|
||||
? await collectArtistSongs(fetchPage, artistId)
|
||||
: await provider.getArtistSongs(artistId, 50);
|
||||
@@ -552,37 +549,40 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = bot.getQueueManager();
|
||||
queue.clear();
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
// Catalogue and copyright lookups leave current playback running. Only
|
||||
// the queue replacement and playback itself occupy the shared play gate.
|
||||
const body = await bot.runExclusive(async () => {
|
||||
bot.getPlayer().stop();
|
||||
bot.getPlayer().resetFailures();
|
||||
const queue = bot.getQueueManager();
|
||||
queue.clear();
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
|
||||
const mode = queue.getMode();
|
||||
let first;
|
||||
if (mode === "random" || mode === "rloop") {
|
||||
const idx = Math.floor(Math.random() * queue.size());
|
||||
first = queue.playAt(idx);
|
||||
} else {
|
||||
first = queue.play();
|
||||
}
|
||||
const mode = queue.getMode();
|
||||
let first;
|
||||
if (mode === "random" || mode === "rloop") {
|
||||
const idx = Math.floor(Math.random() * queue.size());
|
||||
first = queue.playAt(idx);
|
||||
} else {
|
||||
first = queue.play();
|
||||
}
|
||||
|
||||
let started = first ? await bot.resolveAndPlay(first) : false;
|
||||
if (first && !started) {
|
||||
started = await bot.playNext(20);
|
||||
}
|
||||
let started = first ? await bot.resolveAndPlay(first) : false;
|
||||
if (first && !started) started = await bot.playNext(20);
|
||||
|
||||
const playing = queue.current();
|
||||
const loadedMsg = queueable.length < totalCount
|
||||
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||
: `已加载 ${queueable.length} 首`;
|
||||
if (started && playing) {
|
||||
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
|
||||
} else {
|
||||
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
|
||||
}
|
||||
const playing = queue.current();
|
||||
const loadedMsg = queueable.length < totalCount
|
||||
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||
: `已加载 ${queueable.length} 首`;
|
||||
return started && playing
|
||||
? { ok: true, message: `${loadedMsg},正在播放:${playing.name}` }
|
||||
: { ok: false, message: `${loadedMsg},但无法开始播放。` };
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "play-artist failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
|
||||
Reference in new issue
Block a user