From 07ad861ecfc701b9fb061a2af650d348096bea8a Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:52:47 +0800 Subject: [PATCH] fix(bilibili): keep long videos playing when the CDN drops the stream (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long B站 videos (2-3 h) still stopped ~15-20 min in, the same symptom as #89. Reconnecting to the same URL is not enough once the CDN session is gone, so: - Prefer an upos/cos mirror from baseUrl + backupUrl over PCDN hosts (*.mcdn.bilivideo.cn, *.szbdyd.com), which are the ones that cut off. - When a B站 track ends more than 30 s before its known duration, fetch a fresh URL and resume at the current position instead of advancing. Up to 3 attempts without real progress, then advance as before; a track the user started meanwhile is never clobbered. - Seek B站 URLs input-side (-ss before -i). Their CDN serves Range, so a resume jumps to the byte offset instead of re-downloading everything before it (measured locally: 0.2 s vs a full-file download at 1.5 h into a 2 h fMP4), which would otherwise trip the 60 s stall watchdog. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/audio/player.test.ts | 8 ++++ src/audio/player.ts | 11 ++++- src/bot/instance.test.ts | 94 ++++++++++++++++++++++++++++++++++++++ src/bot/instance.ts | 82 +++++++++++++++++++++++++++++++-- src/music/bilibili.test.ts | 43 ++++++++++++++++- src/music/bilibili.ts | 33 ++++++++++++- 6 files changed, 263 insertions(+), 8 deletions(-) diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index e3aca27..d19b36f 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -64,6 +64,14 @@ describe("buildFfmpegArgs", () => { expect(ssIdx).toBeGreaterThan(iIdx); }); + it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => { + const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600); + const ssIdx = args.indexOf("-ss"); + expect(args[ssIdx + 1]).toBe("3600"); + expect(ssIdx).toBeLessThan(args.indexOf("-i")); + expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss + }); + it("does not insert -ss when seekSeconds is 0", () => { const args = buildFfmpegArgs("https://example.com/song.mp3", 0); expect(args).not.toContain("-ss"); diff --git a/src/audio/player.ts b/src/audio/player.ts index a47c722..18cc644 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -76,8 +76,9 @@ export function cleanupTempDir(dir: string): void { export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { const args: string[] = []; const isHttp = /^https?:\/\//i.test(url); + const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili")); - if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) { + if (isBilibili) { args.push( "-headers", `Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`, @@ -103,9 +104,15 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { "-reconnect_on_http_error", "4xx,5xx", ); } + // B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight + // to the byte offset. Output-side seek would download and decode everything + // before the target first — minutes for a resume deep into a 3-hour video + // (#161), long enough to trip the stall watchdog. + const inputSideSeek = isBilibili; + if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds)); 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)); + if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds)); args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"); return args; diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 2474ce2..14800af 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1590,3 +1590,97 @@ describe("BotInstance Bilibili multi-P resolution", () => { }); }); + +describe("resumeInterruptedStream — long B站 streams dying mid-play (#161)", () => { + const resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream as ( + this: unknown, + ) => Promise; + + function makeCtx(opts: { platform?: string; elapsed?: number; duration?: number; url?: string | null } = {}) { + const song: any = { + id: "BV1abc", name: "Long", artist: "A", album: "", coverUrl: "", + platform: opts.platform ?? "bilibili", duration: opts.duration ?? 10_000, url: "old", + }; + let elapsed = opts.elapsed ?? 1000; + let state: "idle" | "playing" = "idle"; + const provider = { + getSongUrl: vi.fn(async () => (opts.url === null ? null : { url: opts.url ?? "https://fresh.test/a.m4s" })), + }; + const ctx: any = { + song, + provider, + connected: true, + effectiveDuration: song.duration, + streamRecovery: null, + queue: { current: vi.fn(() => song) }, + player: { + getElapsed: vi.fn(() => elapsed), + getState: vi.fn(() => state), + play: vi.fn(() => { state = "playing"; }), + }, + getProviderFor: vi.fn(() => provider), + logger: { warn: vi.fn(), info: vi.fn() }, + emit: vi.fn(), + setElapsed: (v: number) => { elapsed = v; state = "idle"; }, + }; + return ctx; + } + + it("re-resolves the URL and resumes at the current position when a B站 stream ends early", async () => { + const ctx = makeCtx({ elapsed: 1000, duration: 10_000 }); + expect(await resumeInterruptedStream.call(ctx)).toBe(true); + expect(ctx.provider.getSongUrl).toHaveBeenCalledWith("BV1abc"); + expect(ctx.player.play).toHaveBeenCalledWith("https://fresh.test/a.m4s", 1000, 10_000); + expect(ctx.song.url).toBe("https://fresh.test/a.m4s"); + }); + + it("does nothing near the real end of the track (normal EOF)", async () => { + const ctx = makeCtx({ elapsed: 9_990, duration: 10_000 }); + expect(await resumeInterruptedStream.call(ctx)).toBe(false); + expect(ctx.provider.getSongUrl).not.toHaveBeenCalled(); + }); + + it("does nothing for other platforms or an unknown duration", async () => { + expect(await resumeInterruptedStream.call(makeCtx({ platform: "netease" }))).toBe(false); + const unknown = makeCtx({ duration: 0 }); + unknown.effectiveDuration = 0; + expect(await resumeInterruptedStream.call(unknown)).toBe(false); + }); + + it("gives up after 3 attempts that make no progress, then lets the queue advance", async () => { + const ctx = makeCtx({ elapsed: 1000 }); + for (let i = 0; i < 3; i++) { + ctx.setElapsed(1000); + expect(await resumeInterruptedStream.call(ctx)).toBe(true); + } + ctx.setElapsed(1000); + expect(await resumeInterruptedStream.call(ctx)).toBe(false); + expect(ctx.player.play).toHaveBeenCalledTimes(3); + }); + + it("resets the attempt budget once a resume actually plays on for a while", async () => { + const ctx = makeCtx({ elapsed: 1000 }); + for (let i = 0; i < 3; i++) { + ctx.setElapsed(1000); + await resumeInterruptedStream.call(ctx); + } + ctx.setElapsed(2000); // the last resume played ~16 more minutes + expect(await resumeInterruptedStream.call(ctx)).toBe(true); + }); + + it("falls through to advancing when no fresh URL can be fetched", async () => { + const ctx = makeCtx({ url: null }); + expect(await resumeInterruptedStream.call(ctx)).toBe(false); + expect(ctx.player.play).not.toHaveBeenCalled(); + }); + + it("does not clobber a different track the user started while the URL was resolving", async () => { + const ctx = makeCtx(); + ctx.provider.getSongUrl.mockImplementation(async () => { + ctx.queue.current.mockReturnValue({ id: "other" }); // user ran !next meanwhile + return { url: "https://fresh.test/a.m4s" }; + }); + expect(await resumeInterruptedStream.call(ctx)).toBe(true); // handled: don't advance again + expect(ctx.player.play).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 4fc0638..3f47dd9 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -193,6 +193,8 @@ export class BotInstance extends EventEmitter { private lastSearchResults: Song[] = []; /** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */ private effectiveDuration: number | undefined; + /** Resume attempts for the current song's stream (#161); see resumeInterruptedStream. */ + private streamRecovery: { song: QueuedSong; attempts: number; position: number } | null = null; private playGate: Promise = Promise.resolve(); /** Per-bot Jellyfin playback-report session (start / ~10s progress / stop). * null when the wired provider has no reporting capability. */ @@ -322,10 +324,19 @@ export class BotInstance extends EventEmitter { }); this.player.on("trackEnd", () => { - this.logger.debug("Track ended, advancing queue"); - this.playNext().catch((err) => { - this.logger.error({ err }, "playNext failed after trackEnd"); - }); + this.resumeInterruptedStream() + .catch((err) => { + this.logger.warn({ err }, "Stream resume failed"); + return false; + }) + .then((resumed) => { + if (resumed) return; + this.logger.debug("Track ended, advancing queue"); + return this.playNext(); + }) + .catch((err) => { + this.logger.error({ err }, "playNext failed after trackEnd"); + }); }); this.player.on("error", (err: Error) => { @@ -1116,6 +1127,69 @@ export class BotInstance extends EventEmitter { } } + /** Platforms whose CDN stream can die mid-file on long content (#89, #161). */ + private static readonly RESUMABLE_PLATFORMS: ReadonlySet = new Set(["bilibili"]); + /** A track that ends within this many seconds of its duration ended normally. */ + private static readonly STREAM_END_TOLERANCE_S = 30; + private static readonly MAX_STREAM_RESUMES = 3; + + /** + * Called when the player reports a track end. If a B站 stream ended long + * before its known duration, the CDN dropped it (#161): fetch a fresh URL + * and continue from where it stopped instead of skipping the rest of a + * 2-3 hour video. Gives up after MAX_STREAM_RESUMES attempts that make no + * real progress, so a truly broken stream still advances the queue. + * + * Returns true when it handled the end (resumed, or a newer track has + * already taken over), false when the caller should advance the queue. + */ + private async resumeInterruptedStream(): Promise { + const song = this.queue.current(); + if (!song || !this.connected || !BotInstance.RESUMABLE_PLATFORMS.has(song.platform)) { + return false; + } + const duration = this.effectiveDuration ?? song.duration; + const position = Math.floor(this.player.getElapsed()); + if (!(duration > 0) || duration - position <= BotInstance.STREAM_END_TOLERANCE_S) { + return false; + } + + const recovery = this.streamRecovery; + if ( + !recovery || + recovery.song !== song || + position - recovery.position > BotInstance.STREAM_END_TOLERANCE_S + ) { + this.streamRecovery = { song, attempts: 0, position }; + } + const state = this.streamRecovery!; + if (state.attempts >= BotInstance.MAX_STREAM_RESUMES) { + this.logger.warn( + { songId: song.id, position, duration, attempts: state.attempts }, + "Stream keeps ending early — giving up and advancing", + ); + this.streamRecovery = null; + return false; + } + state.attempts++; + state.position = position; + + this.logger.warn( + { songId: song.id, position, duration, attempt: state.attempts }, + "Stream ended before the track did — resuming with a fresh URL", + ); + const result = await this.getProviderFor(song.platform).getSongUrl(song.id); + // The user may have skipped/stopped while we were resolving; never + // clobber whatever is playing now. + if (this.queue.current() !== song || this.player.getState() !== "idle") return true; + if (!result?.url || !this.connected) return false; + + song.url = result.url; + this.player.play(result.url, position, duration); + this.emit("stateChange"); + return true; + } + private async syncProfileToSong(song: QueuedSong | null): Promise { try { await this.profileManager.onSongChange(song); diff --git a/src/music/bilibili.test.ts b/src/music/bilibili.test.ts index cb43cc5..89b7be6 100644 --- a/src/music/bilibili.test.ts +++ b/src/music/bilibili.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { BiliBiliProvider } from "./bilibili.js"; +import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js"; describe("BiliBiliProvider.search pagination", () => { function mockProvider() { @@ -162,3 +162,44 @@ describe("BiliBiliProvider multi-P support", () => { expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid }); }); + +describe("pickStableAudioUrl (#161 long streams dying mid-play)", () => { + const pcdn = "https://xy1x2x3x4xy.mcdn.bilivideo.cn:4483/upgcxcode/1/2/3/3-1-30280.m4s?e=x&deadline=1"; + const szbdyd = "https://cn-hk-eq-01-01.szbdyd.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1"; + const upos = "https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1"; + const upos2 = "https://upos-sz-mirror08c.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1"; + + it("prefers an upos/cos mirror over a PCDN baseUrl", () => { + expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd, upos] })).toBe(upos); + }); + + it("keeps the baseUrl when it is already a stable host", () => { + expect(pickStableAudioUrl({ baseUrl: upos, backupUrl: [upos2] })).toBe(upos); + }); + + it("accepts the snake_case field names", () => { + expect(pickStableAudioUrl({ base_url: pcdn, backup_url: [upos2] })).toBe(upos2); + }); + + it("falls back to the baseUrl when every candidate is PCDN", () => { + expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd] })).toBe(pcdn); + }); + + it("returns undefined when there is no url at all", () => { + expect(pickStableAudioUrl({})).toBeUndefined(); + }); + + it("getSongUrl returns the stable mirror of the best stream", async () => { + const p = new BiliBiliProvider(); + (p as any).cidCache.set("BV1abc", 42); + (p as any).api = { + get: vi.fn().mockResolvedValue({ + data: { data: { dash: { audio: [ + { bandwidth: 64000, baseUrl: "https://upos-sz-mirrorcos.bilivideo.com/low.m4s" }, + { bandwidth: 320000, baseUrl: pcdn, backupUrl: [upos] }, + ] } } }, + }), + }; + expect((await p.getSongUrl("BV1abc"))?.url).toBe(upos); + }); +}); diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index fa0e080..0b2fed3 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -42,6 +42,37 @@ export interface BiliVideoPartsResult { parts: BiliVideoPart[]; } +/** + * PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:, *.szbdyd.com). Their + * sessions get cut mid-file, which kills long streams partway (#89, #161), + * and a reconnect to the same host rarely recovers. + */ +const BILI_PCDN_HOST = /\.mcdn\.bilivideo\.cn$|\.szbdyd\.com$/i; + +/** + * Pick the audio URL least likely to die mid-stream: the first upos/cos + * mirror among baseUrl + backupUrl, else the baseUrl as before. + */ +export function pickStableAudioUrl(stream: { + baseUrl?: string; + base_url?: string; + backupUrl?: string[]; + backup_url?: string[]; +}): string | undefined { + const primary = stream.baseUrl ?? stream.base_url; + const candidates = [primary, ...(stream.backupUrl ?? stream.backup_url ?? [])].filter( + (u): u is string => typeof u === "string" && u.length > 0, + ); + const stable = candidates.find((u) => { + try { + return !BILI_PCDN_HOST.test(new URL(u).hostname); + } catch { + return false; + } + }); + return stable ?? primary; +} + /** * 解析带有分P信息的 B站 ID 或 URL。 * 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。 @@ -353,7 +384,7 @@ export class BiliBiliProvider implements MusicProvider { (b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a ); - const biliUrl = best.baseUrl ?? best.base_url; + const biliUrl = pickStableAudioUrl(best); return biliUrl ? { url: biliUrl } : null; } catch { return null;