Compare commits

...
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 faf6ac09ec fix(profile): move the now-playing channel description with the bot (#159)
When the bot was moved to another channel, the channel it left kept the
now-playing description forever: updateChannelDescription always targeted
getChannelId(), which by then already reported the new channel.

Remember which channel we last wrote to. On a self clientMoved event,
clear that channel and, if a song is playing, write the description to
the new one. Stop now clears the channel we actually wrote to, so a
missed move event can't leave a stale description behind either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:36:57 +08:00
TIANYAO ZHANG 8ff51ea6e0 Merge pull request #166 from xxmod/main
fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长
2026-09-27 21:12:25 +08:00
xxmod 3c0e8df763 fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长
在网页端播放多P视频时弹出界面选择需要播放的P数,ts里!play播放则只播放第一P
2026-09-22 17:10:54 +08:00
TIANYAO ZHANG 2ea02f54d9 Merge pull request #155 from ZHANGTIANYAO1/fix/152-setup-console-eio
fix(setup): stop a failed console write from aborting setup, require Node 22+ (#152)
2026-08-31 16:22:36 +08:00
TIANYAO ZHANG 1407cadf7b Merge pull request #154 from XuVIIJay/fix/output-side-seek
fix(audio): 网易云拖动进度条播放中断,改用输出侧 seek 兼容不支持 Range 的 CDN
2026-08-24 11:19:37 +08:00
XuVIIJayandClaude Opus 4.7 9fd1b39092 fix(audio): seek after -i (output-side) so drag-seek works on non-seekable HTTP CDNs
Input-side fast seek (-ss before -i) requires the HTTP server to support
Range/keyframe seeking. NetEase's CDN (music.126.net signed streams) doesn't,
so dragging the progress bar hung FFmpeg and the player force-killed it
(SIGKILL) with no audio. Moving -ss after -i decodes from the start and
discards to the target, which works on any HTTP stream; FFmpeg still fast-seeks
when the CDN supports it, so QQ keeps its instant resume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-20 21:07:21 +08:00
13 changed files with 915 additions and 25 deletions

No files matched your search

+2 -2
View File
@@ -55,13 +55,13 @@ describe("buildFfmpegArgs", () => {
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
it("inserts -ss before -i when seekSeconds > 0", () => {
it("inserts -ss after -i when seekSeconds > 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
const ssIdx = args.indexOf("-ss");
const iIdx = args.indexOf("-i");
expect(ssIdx).toBeGreaterThan(-1);
expect(args[ssIdx + 1]).toBe("42");
expect(ssIdx).toBeLessThan(iIdx);
expect(ssIdx).toBeGreaterThan(iIdx);
});
it("does not insert -ss when seekSeconds is 0", () => {
+3 -1
View File
@@ -103,8 +103,10 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx",
);
}
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
+52
View File
@@ -1538,3 +1538,55 @@ describe("BotInstance live-queue persistence (#119)", () => {
}
});
});
describe("BotInstance Bilibili multi-P resolution", () => {
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
const multiPSongDetail = {
id: "BV1multiP?p=1",
name: "测试视频 - P1 分P1",
artist: "UP主",
album: "",
duration: 100, // P1 duration
coverUrl: "",
platform: "bilibili" as const,
};
const mockBili = {
platform: "bilibili" as const,
search: vi.fn().mockResolvedValue({
songs: [{
id: "BV1multiP",
name: "测试视频",
artist: "UP主",
album: "",
duration: 300, // total duration in search
coverUrl: "",
platform: "bilibili",
}],
albums: [],
playlists: [],
}),
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
};
const ctx = {
config: { commandPrefix: "!" },
lastSearchResults: [] as any[],
getProvider: () => mockBili,
getProviderFor: () => mockBili,
};
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
name: "play",
args: "测试视频",
rawArgs: ["测试视频"],
flags: new Set(),
});
expect(res.song).toBeDefined();
expect(res.song.id).toBe("BV1multiP?p=1");
expect(res.song.name).toBe("测试视频 - P1 分P1");
expect(res.song.duration).toBe(100);
});
});
+26 -3
View File
@@ -456,11 +456,16 @@ export class BotInstance extends EventEmitter {
this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy();
});
this.tsClient.on("clientMoved", (event: { id: number }) => {
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
if (event.id === this.tsClient.getClientId()) {
// Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive.
this.voiceDucking.reset(false);
// Carry the now-playing channel description over to the new
// channel instead of leaving it stale in the old one (#159).
this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => {
this.logger.warn({ err }, "Channel description move update failed");
});
} else {
this.voiceDucking.removeSpeaker(event.id);
}
@@ -968,6 +973,14 @@ export class BotInstance extends EventEmitter {
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
if (song.platform === "bilibili" && (!song.id.includes("?p=") || song.duration === 0)) {
const detail = await provider.getSongDetail(song.id);
if (detail) {
song.duration = detail.duration;
song.name = detail.name;
song.id = detail.id;
}
}
const result = await provider.getSongUrl(song.id);
if (!result?.url) {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
@@ -1134,7 +1147,12 @@ export class BotInstance extends EventEmitter {
return { error: `No recent search. Use ${p}search <name> first.` };
if (sel > this.lastSearchResults.length)
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
return { song: this.lastSearchResults[sel - 1] };
let song = this.lastSearchResults[sel - 1];
if (song.platform === "bilibili") {
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
if (detail) song = { ...detail, platform: "bilibili" };
}
return { song };
}
// 2) id/URL — fetch that exact song.
@@ -1151,7 +1169,12 @@ export class BotInstance extends EventEmitter {
const provider = this.getProvider(cmd.flags);
const result = await provider.search(args, 1);
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
return { song: { ...result.songs[0], platform: provider.platform } };
let song = result.songs[0];
if (provider.platform === "bilibili") {
const detail = await provider.getSongDetail(song.id);
if (detail) song = detail;
}
return { song: { ...song, platform: provider.platform } };
}
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
+62
View File
@@ -202,3 +202,65 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
});
});
describe("BotProfileManager channel description follows the bot (#159)", () => {
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
let channelEdits: () => string[];
beforeEach(() => {
ts = makeMockTs() as any;
ts.cid = 5n;
(ts as any).getChannelId = () => ts.cid;
channelEdits = () =>
(ts.sendCommandNoWait as any).mock.calls
.map((c: any[]) => c[0] as string)
.filter((cmd: string) => cmd.startsWith("channeledit"));
});
it("clears the old channel and fills the new one when moved while playing", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
expect(channelEdits()).toEqual([
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
]);
ts.cid = 9n;
await pm.onChannelMoved(9n);
const edits = channelEdits();
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
});
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
await pm.onSongChange(null);
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
});
it("a move while idle touches no channel description", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("a move is ignored when the channel description feature is off", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("an event for the channel the description is already in is a no-op", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
await pm.onChannelMoved(5n);
expect(channelEdits()).toHaveLength(1);
});
});
+47 -5
View File
@@ -32,6 +32,13 @@ export class BotProfileManager {
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/**
* Channel whose description currently holds our now-playing text, or null
* if we have not written one. Remembered so that when the bot is moved we
* can still clean up the channel it was taken out of (#159) — by then
* getChannelId() already reports the new channel.
*/
private channelDescCid: bigint | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
@@ -134,6 +141,8 @@ export class BotProfileManager {
onConnect(): void {
this.generation++;
this.currentSong = null;
// Channel ids are per-server; never carry one across a (re)connect.
this.channelDescCid = null;
this.permDenied = {
avatar: false,
description: false,
@@ -150,6 +159,31 @@ export class BotProfileManager {
}
}
/**
* Called when the bot itself has been moved to another channel (#159).
* Clears the now-playing text from the channel it left and, if a song is
* playing, writes it to the channel it is in now.
*/
async onChannelMoved(newChannelId: bigint): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
const oldChannelId = this.channelDescCid;
if (oldChannelId === newChannelId) return;
try {
if (oldChannelId !== null) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${oldChannelId} channel_description=`,
);
this.channelDescCid = null;
}
} catch (err) {
this.handleFeatureError("channelDesc", err);
return;
}
if (this.currentSong) {
await this.updateChannelDescription(this.currentSong, newChannelId);
}
}
getConfig(): ProfileConfig {
return { ...this.config };
}
@@ -400,18 +434,25 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
private async updateChannelDescription(
song: QueuedSong | null,
targetChannelId?: bigint,
): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
const channelId = targetChannelId ?? this.tsClient.getChannelId();
if (!song) {
// Prefer the channel we actually wrote to, in case a move event
// was missed and the bot is somewhere else now.
const target = this.channelDescCid ?? channelId;
if (target === 0n) return; // unknown channel
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
`channeledit cid=${target} channel_description=`,
);
this.channelDescCid = null;
return;
}
if (channelId === 0n) return; // unknown channel
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
@@ -422,6 +463,7 @@ export class BotProfileManager {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
this.channelDescCid = channelId;
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
+125
View File
@@ -37,3 +37,128 @@ describe("BiliBiliProvider.search pagination", () => {
expect(searchParams(get).page).toBe("1");
});
});
describe("BiliBiliProvider multi-P support", () => {
it("parseBilibiliId extracts bvid and page correctly", async () => {
const { parseBilibiliId } = await import("./bilibili.js");
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
bvid: "BV1yxHQeYEuE",
page: 5,
});
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
});
function mockViewProvider(viewData: any, playUrlData?: any) {
const p = new BiliBiliProvider();
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
if (url === "/x/web-interface/view") {
return Promise.resolve({ data: { data: viewData } });
}
if (url === "/x/player/playurl") {
return Promise.resolve({ data: { data: playUrlData ?? {} } });
}
return Promise.resolve({ data: {} });
});
(p as any).buvidInitialized = true;
(p as any).api = { get };
return { p, get };
}
const multiPViewData = {
bvid: "BV1multiP",
title: "测试多P教程",
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
duration: 300, // 总时长 300 秒 (120 + 180)
owner: { name: "UP主测试" },
pages: [
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
],
};
const singlePViewData = {
bvid: "BV1singleP",
title: "测试单P视频",
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
duration: 200,
owner: { name: "UP主测试" },
pages: [
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
],
};
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
const { p } = mockViewProvider(singlePViewData);
const song = await p.getSongDetail("BV1singleP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1singleP");
expect(song!.name).toBe("测试单P视频");
expect(song!.duration).toBe(200);
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=1");
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP?p=2");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=2");
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
expect(song!.duration).toBe(180); // P2 独立时长
expect(song!.platform).toBe("bilibili");
});
it("getVideoParts returns all parts with duration and cid", async () => {
const { p } = mockViewProvider(multiPViewData);
const partsResult = await p.getVideoParts("BV1multiP");
expect(partsResult).not.toBeNull();
expect(partsResult!.bvid).toBe("BV1multiP");
expect(partsResult!.title).toBe("测试多P教程");
expect(partsResult!.parts).toHaveLength(2);
expect(partsResult!.parts[0]).toEqual({
part: 1,
cid: 10001,
title: "第一讲 入门",
duration: 120,
});
expect(partsResult!.parts[1]).toEqual({
part: 2,
cid: 10002,
title: "第二讲 进阶",
duration: 180,
});
});
it("getSongUrl requests playurl with correct cid for specific part", async () => {
const playUrlResponse = {
dash: {
audio: [
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
],
},
};
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
const result = await p.getSongUrl("BV1multiP?p=2");
expect(result).not.toBeNull();
expect(result!.url).toBe("http://audio.320k.test");
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
expect(playurlCall).toBeTruthy();
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
});
});
+106 -11
View File
@@ -27,6 +27,38 @@ const WBI_MIXIN_KEY_ENC_TAB = [
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export interface BiliVideoPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliVideoPartsResult {
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliVideoPart[];
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
*/
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
const str = (songId ?? "").trim();
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
if (!bvMatch) {
return { bvid: str, page: 1 };
}
const bvid = bvMatch[0];
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
const page = pageStr ? parseInt(pageStr, 10) : 1;
return { bvid, page: Math.max(1, page) };
}
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -192,18 +224,41 @@ export class BiliBiliProvider implements MusicProvider {
}
async getSongDetail(songId: string): Promise<Song | null> {
const { bvid, page } = parseBilibiliId(songId);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: songId },
params: { bvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
// Cache cid for later audio URL fetching
if (data.pages?.[0]?.cid) {
this.cidCache.set(songId, data.pages[0].cid);
const pages = data.pages ?? [];
// 缓存所有分P的 cid 映射
for (const p of pages) {
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(bvid, pages[0].cid);
}
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
// 若为多P视频,返回对应分P的名称与独立时长
if (pages.length > 1 && targetPage) {
const partTitle = targetPage.part && targetPage.part !== data.title
? `${data.title} - P${targetPage.page} ${targetPage.part}`
: `${data.title} (P${targetPage.page})`;
return {
id: `${bvid}?p=${targetPage.page}`,
name: partTitle,
artist: data.owner?.name ?? "",
album: "",
duration: targetPage.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
}
return {
@@ -211,7 +266,7 @@ export class BiliBiliProvider implements MusicProvider {
name: data.title ?? "",
artist: data.owner?.name ?? "",
album: "",
duration: data.duration ?? 0,
duration: targetPage?.duration ?? data.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
@@ -220,9 +275,47 @@ export class BiliBiliProvider implements MusicProvider {
}
}
/** 获取视频所有分P列表 */
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
const { bvid: cleanBvid } = parseBilibiliId(bvid);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: cleanBvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
const pages = data.pages ?? [];
for (const p of pages) {
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(cleanBvid, pages[0].cid);
}
return {
bvid: cleanBvid,
title: data.title ?? "",
coverUrl: this.normalizeCover(data.pic ?? ""),
artist: data.owner?.name ?? "",
parts: pages.map((p: any) => ({
part: p.page,
cid: p.cid,
title: p.part ?? `P${p.page}`,
duration: p.duration ?? 0,
})),
};
} catch {
return null;
}
}
/** Get CID for a bvid, using cache when available */
private async getCid(bvid: string): Promise<number | null> {
const cached = this.cidCache.get(bvid);
private async getCid(bvid: string, page = 1): Promise<number | null> {
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
if (cached) return cached;
// Limit cache size to prevent unbounded growth
@@ -231,20 +324,22 @@ export class BiliBiliProvider implements MusicProvider {
if (firstKey) this.cidCache.delete(firstKey);
}
const detail = await this.getSongDetail(bvid);
const songId = page > 1 ? `${bvid}?p=${page}` : bvid;
const detail = await this.getSongDetail(songId);
if (!detail) return null;
return this.cidCache.get(bvid) ?? null;
return this.cidCache.get(key) ?? this.cidCache.get(`${bvid}?p=${page}`) ?? this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
const cid = await this.getCid(songId);
const { bvid, page } = parseBilibiliId(songId);
const cid = await this.getCid(bvid, page);
if (!cid) return null;
try {
const res = await this.api.get("/x/player/playurl", {
params: {
cid,
bvid: songId,
bvid,
fnval: 16, // DASH format
},
headers: this.cookieHeaders,
+65
View File
@@ -438,3 +438,68 @@ describe("music router POST /local/upload — content types and size cap (#149)"
expect(uploadAudio).not.toHaveBeenCalled();
});
});
describe("music router GET /bilibili/parts", () => {
it("returns 400 when bvid is missing", async () => {
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts");
expect(res.status).toBe(400);
expect(res.body.error).toBe("bvid is required");
});
it("returns parts from bilibili provider when available", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue({
bvid: "BV1test",
title: "多P视频测试",
parts: [
{ part: 1, cid: 101, title: "P1", duration: 100 },
{ part: 2, cid: 102, title: "P2", duration: 200 },
],
}),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
expect(res.status).toBe(200);
expect(res.body.bvid).toBe("BV1test");
expect(res.body.parts).toHaveLength(2);
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
});
it("returns 404 when getVideoParts returns null", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue(null),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
expect(res.status).toBe(404);
});
});
+25
View File
@@ -382,6 +382,31 @@ export function createMusicRouter(
}
});
// B站分P列表查询
router.get("/bilibili/parts", async (req, res) => {
try {
const bvid = (req.query.bvid as string)?.trim();
if (!bvid) {
res.status(400).json({ error: "bvid is required" });
return;
}
const provider = bilibiliProvider as any;
if (typeof provider.getVideoParts === "function") {
const result = await provider.getVideoParts(bvid);
if (!result) {
res.status(404).json({ error: "Video not found" });
return;
}
res.json(result);
} else {
res.status(501).json({ error: "Not supported" });
}
} catch (err) {
logger.error({ err }, "Get bilibili parts failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Enabled sources + default platform, for the web UI (source tabs, default
// search/playback source). Without a config (unit-test routers) everything
// reports enabled with the legacy netease default.
+2
View File
@@ -7,6 +7,7 @@
<Player />
<Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<BilibiliPartModal />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
@@ -102,6 +103,7 @@ import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue';
import BilibiliPartModal from './components/BilibiliPartModal.vue';
const playerStore = usePlayerStore();
const session = useSession();
+293
View File
@@ -0,0 +1,293 @@
<template>
<div v-if="modal.open" class="edit-modal-overlay" @click.self="store.closeBilibiliPartModal">
<div class="edit-modal">
<h3 class="modal-title">选择分P</h3>
<div class="form-group">
<label>视频名称</label>
<div class="video-info-box">
<CoverArt :url="modal.coverUrl" :size="44" :radius="8" />
<div class="video-meta">
<div class="video-title" :title="modal.title">{{ modal.title }}</div>
<div class="video-hint">共 {{ modal.parts.length }} 个分P · {{ actionHint }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>分P列表</label>
<div class="parts-list">
<div
v-for="part in modal.parts"
:key="part.part"
class="part-item"
:class="{ active: selectedPart?.part === part.part }"
@click="selectedPart = part"
@dblclick="confirmSelect(part)"
>
<span class="part-badge">P{{ part.part }}</span>
<span class="part-name" :title="part.title">{{ part.title }}</span>
<span class="part-duration">{{ formatDuration(part.duration) }}</span>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="store.closeBilibiliPartModal">取消</button>
<button
class="btn-primary"
:disabled="!selectedPart"
@click="selectedPart && confirmSelect(selectedPart)"
>
{{ confirmBtnText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { usePlayerStore, type BiliPart } from '../stores/player.js';
import CoverArt from './CoverArt.vue';
const store = usePlayerStore();
const modal = computed(() => store.biliPartModal);
const selectedPart = ref<BiliPart | null>(null);
// 弹窗打开时默认选中第 1 P
watch(
() => modal.value.open,
(open) => {
if (open && modal.value.parts.length > 0) {
selectedPart.value = modal.value.parts[0];
} else {
selectedPart.value = null;
}
},
{ immediate: true },
);
const actionHint = computed(() => {
if (modal.value.action === 'playNext') return '添加到下一首播放';
if (modal.value.action === 'add') return '添加到播放队列';
return '立即播放';
});
const confirmBtnText = computed(() => {
if (modal.value.action === 'playNext') return '下一首播放';
if (modal.value.action === 'add') return '添加到队列';
return '播放';
});
function confirmSelect(part: BiliPart) {
store.selectBilibiliPart(part);
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '--:--';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<style lang="scss" scoped>
.edit-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.edit-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 28px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
min-height: 0;
label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
opacity: 0.7;
color: var(--text-primary);
}
}
.video-info-box {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
.video-meta {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.video-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.parts-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-sm);
}
}
.part-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
background: var(--color-primary-10, rgba(0, 161, 214, 0.08));
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary-15, rgba(0, 161, 214, 0.15));
.part-badge {
background: var(--color-primary);
color: white;
}
.part-name {
color: var(--color-primary);
font-weight: 600;
}
}
.part-badge {
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: var(--radius-xs);
background: var(--color-primary-10, rgba(0, 161, 214, 0.1));
color: var(--color-primary);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.part-name {
flex: 1;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.part-duration {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 10px;
}
.btn-primary {
padding: 10px 20px;
background: var(--color-primary);
color: white;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
border: none;
cursor: pointer;
transition: transform var(--transition-fast);
&:hover:not(:disabled) {
transform: scale(1.02);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-secondary {
padding: 10px 20px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
border: none;
cursor: pointer;
transition: opacity var(--transition-fast);
&:hover {
opacity: 0.8;
}
}
</style>
+107 -3
View File
@@ -17,6 +17,24 @@ export interface Song {
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
export interface BiliPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliPartModalState {
open: boolean;
song: Song | null;
action: 'play' | 'playNext' | 'add';
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliPart[];
}
export interface AlbumItem {
id: string;
name: string;
@@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', {
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
// Bilibili 多P分P选择弹窗状态
biliPartModal: {
open: false,
song: null,
action: 'play',
bvid: '',
title: '',
coverUrl: '',
artist: '',
parts: [] as BiliPart[],
} as BiliPartModalState,
}),
getters: {
@@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', {
this.notification = { id: Date.now(), message, type };
},
async playSong(song: Song) {
/**
* 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续
*/
async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise<boolean> {
try {
const cleanBvid = song.id.split('?')[0].split(':')[0];
const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } });
const parts: BiliPart[] = res.data?.parts ?? [];
if (parts.length > 1) {
this.biliPartModal = {
open: true,
song,
action,
bvid: cleanBvid,
title: res.data.title || song.name,
coverUrl: res.data.coverUrl || song.coverUrl,
artist: res.data.artist || song.artist,
parts,
};
return true; // 弹窗接管
}
if (parts.length === 1) {
song.duration = parts[0].duration;
}
} catch {
// 网络请求异常则降级为正常播放
}
return false;
},
selectBilibiliPart(part: BiliPart) {
if (!this.biliPartModal.open || !this.biliPartModal.song) return;
const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal;
this.biliPartModal.open = false;
const partTitle = part.title && part.title !== title
? `${title} - P${part.part} ${part.title}`
: `${title} (P${part.part})`;
const partSong: Song = {
...song,
id: `${bvid}?p=${part.part}`,
name: partTitle,
artist: artist || song.artist,
coverUrl: coverUrl || song.coverUrl,
duration: part.duration,
};
if (action === 'play') {
this.playSong(partSong, true);
} else if (action === 'playNext') {
this.playNextSong(partSong, true);
} else if (action === 'add') {
this.addSong(partSong, true);
}
},
closeBilibiliPartModal() {
this.biliPartModal.open = false;
this.biliPartModal.song = null;
},
async playSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'play');
if (handled) return;
}
// Guests use the non-destructive "play now" (insert-next + skip) so they
// can't wipe everyone else's queue; members/admins keep the normal behavior.
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
@@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
async playNextSong(song: Song) {
async playNextSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'playNext');
if (handled) return;
}
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
@@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', {
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
},
async addSong(song: Song) {
async addSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'add');
if (handled) return;
}
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
},