diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 137c907..2474ce2 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -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); + }); +}); + diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 58e74ca..4fc0638 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -968,6 +968,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 +1142,12 @@ export class BotInstance extends EventEmitter { return { error: `No recent search. Use ${p}search 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 +1164,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 { diff --git a/src/music/bilibili.test.ts b/src/music/bilibili.test.ts index da3c4da..cb43cc5 100644 --- a/src/music/bilibili.test.ts +++ b/src/music/bilibili.test.ts @@ -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 + }); +}); diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index cde63c2..fa0e080 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -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 { + 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 { + 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 { - const cached = this.cidCache.get(bvid); + private async getCid(bvid: string, page = 1): Promise { + 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 { - 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, diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index 42f10ef..b066b8b 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -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); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 3c5865e..f54876a 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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. diff --git a/web/src/App.vue b/web/src/App.vue index 51ae196..55e5675 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -7,6 +7,7 @@ +
@@ -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(); diff --git a/web/src/components/BilibiliPartModal.vue b/web/src/components/BilibiliPartModal.vue new file mode 100644 index 0000000..10aca6b --- /dev/null +++ b/web/src/components/BilibiliPartModal.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index dfc6ac4..696033a 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -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 { + 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 }); },