fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长

在网页端播放多P视频时弹出界面选择需要播放的P数,ts里!play播放则只播放第一P
This commit is contained in:
xxmod committed 2026-09-22 17:10:54 +08:00
1 parent 2ea02f54d9
commit 3c0e8df763
9 files changed
+795 -16

No files matched your search

+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,