Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 07ad861ecf fix(bilibili): keep long videos playing when the CDN drops the stream (#161)
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) <noreply@anthropic.com>
2026-09-27 21:52:47 +08:00
9 changed files with 249 additions and 226 deletions

No files matched your search

-1
View File
@@ -364,7 +364,6 @@ sudo systemctl start tsmusicbot
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 | | `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) | | `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认网易云,自动续播) | | `!fm` | 私人 FM(默认网易云,自动续播) |
+8
View File
@@ -64,6 +64,14 @@ describe("buildFfmpegArgs", () => {
expect(ssIdx).toBeGreaterThan(iIdx); 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", () => { it("does not insert -ss when seekSeconds is 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0); const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).not.toContain("-ss"); expect(args).not.toContain("-ss");
+9 -2
View File
@@ -76,8 +76,9 @@ export function cleanupTempDir(dir: string): void {
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = []; const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url); 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( args.push(
"-headers", "-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`, `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", "-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); args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net). // 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", "-"); args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args; return args;
+78 -51
View File
@@ -1591,69 +1591,96 @@ describe("BotInstance Bilibili multi-P resolution", () => {
}); });
describe("cmdPlaylist with a playlist link (#160)", () => { describe("resumeInterruptedStream — long B站 streams dying mid-play (#161)", () => {
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as ( const resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream as (
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> }, this: unknown,
) => Promise<string>; ) => Promise<boolean>;
function makeCtx() { function makeCtx(opts: { platform?: string; elapsed?: number; duration?: number; url?: string | null } = {}) {
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" }; const song: any = {
const makeProvider = (platform: string) => ({ id: "BV1abc", name: "Long", artist: "A", album: "", coverUrl: "",
platform, platform: opts.platform ?? "bilibili", duration: opts.duration ?? 10_000, url: "old",
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
});
const providers: Record<string, any> = {
netease: makeProvider("netease"),
qq: makeProvider("qq"),
youtube: makeProvider("youtube"),
}; };
const queued: any[] = []; let elapsed = opts.elapsed ?? 1000;
return { let state: "idle" | "playing" = "idle";
providers, const provider = {
queued, getSongUrl: vi.fn(async () => (opts.url === null ? null : { url: opts.url ?? "https://fresh.test/a.m4s" })),
getProvider: vi.fn(() => providers.netease), };
getProviderFor: vi.fn((p: string) => providers[p]), const ctx: any = {
assertProviderEnabled: vi.fn(), song,
extractId: (BotInstance.prototype as any).extractId, provider,
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId, connected: true,
player: { stop: vi.fn() }, effectiveDuration: song.duration,
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] }, streamRecovery: null,
disableFmMode: vi.fn(), queue: { current: vi.fn(() => song) },
withRequester: (s: any) => s, player: {
resolveAndPlay: vi.fn(async () => true), getElapsed: vi.fn(() => elapsed),
sweepLocalAudio: vi.fn(), getState: vi.fn(() => state),
play: vi.fn(() => { state = "playing"; }),
},
getProviderFor: vi.fn(() => provider),
logger: { warn: vi.fn(), info: vi.fn() },
emit: vi.fn(), emit: vi.fn(),
setElapsed: (v: number) => { elapsed = v; state = "idle"; },
}; };
return ctx;
} }
const cmd = (args: string, flags: string[] = []) =>
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => { it("re-resolves the URL and resumes at the current position when a B站 stream ends early", async () => {
const ctx = makeCtx(); const ctx = makeCtx({ elapsed: 1000, duration: 10_000 });
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")); expect(await resumeInterruptedStream.call(ctx)).toBe(true);
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267"); expect(ctx.provider.getSongUrl).toHaveBeenCalledWith("BV1abc");
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled(); expect(ctx.player.play).toHaveBeenCalledWith("https://fresh.test/a.m4s", 1000, 10_000);
expect(ctx.queued[0].platform).toBe("qq"); expect(ctx.song.url).toBe("https://fresh.test/a.m4s");
expect(reply).toMatch(/^Loaded 1 songs/);
}); });
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => { it("does nothing near the real end of the track (normal EOF)", async () => {
const ctx = makeCtx(); const ctx = makeCtx({ elapsed: 9_990, duration: 10_000 });
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123")); expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123"); expect(ctx.provider.getSongUrl).not.toHaveBeenCalled();
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
}); });
it("checks the link's platform is enabled", async () => { it("does nothing for other platforms or an unknown duration", async () => {
const ctx = makeCtx(); expect(await resumeInterruptedStream.call(makeCtx({ platform: "netease" }))).toBe(false);
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); }); const unknown = makeCtx({ duration: 0 });
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用"); unknown.effectiveDuration = 0;
expect(await resumeInterruptedStream.call(unknown)).toBe(false);
}); });
it("keeps the old behavior for a bare id", async () => { 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(); const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("2829883282")); ctx.provider.getSongUrl.mockImplementation(async () => {
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282"); 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();
}); });
}); });
+79 -26
View File
@@ -13,13 +13,7 @@ import {
canRunCommand, canRunCommand,
type ParsedCommand, type ParsedCommand,
} from "./commands.js"; } from "./commands.js";
import { import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
parseSongRef,
parseSelectionIndex,
parsePlaylistRef,
findShareShortLink,
resolveShareLink,
} from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js"; import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js"; import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
@@ -199,6 +193,8 @@ export class BotInstance extends EventEmitter {
private lastSearchResults: Song[] = []; private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */ /** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
private effectiveDuration: number | undefined; 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<unknown> = Promise.resolve(); private playGate: Promise<unknown> = Promise.resolve();
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop). /** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
* null when the wired provider has no reporting capability. */ * null when the wired provider has no reporting capability. */
@@ -328,8 +324,17 @@ export class BotInstance extends EventEmitter {
}); });
this.player.on("trackEnd", () => { this.player.on("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"); this.logger.debug("Track ended, advancing queue");
this.playNext().catch((err) => { return this.playNext();
})
.catch((err) => {
this.logger.error({ err }, "playNext failed after trackEnd"); this.logger.error({ err }, "playNext failed after trackEnd");
}); });
}); });
@@ -1122,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<Platform> = 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<boolean> {
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<void> { private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try { try {
await this.profileManager.onSongChange(song); await this.profileManager.onSongChange(song);
@@ -1519,21 +1587,8 @@ export class BotInstance extends EventEmitter {
} }
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> { private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>"; if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
// A playlist link (#160) names its own platform, so it wins over flags.
// App share short links are followed one hop to the real URL first.
let ref = parsePlaylistRef(cmd.args);
if (!ref) {
const shortLink = findShareShortLink(cmd.args);
if (shortLink) {
const target = await resolveShareLink(shortLink);
ref = target ? parsePlaylistRef(target) : null;
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
}
}
if (ref) this.assertProviderEnabled(ref.platform);
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search // Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args); const id = this.extractId(cmd.args);
@@ -1541,9 +1596,7 @@ export class BotInstance extends EventEmitter {
let playlistId: string; let playlistId: string;
if (ref) { if (isDirectId || id !== cmd.args) {
playlistId = ref.id;
} else if (isDirectId || id !== cmd.args) {
// Input is a direct ID or URL containing an ID — use existing logic // Input is a direct ID or URL containing an ID — use existing logic
playlistId = id; playlistId = id;
} else { } else {
+1 -63
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => { describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => { it("returns null for a plain search term", () => {
@@ -120,65 +120,3 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
expect(parseSelectionIndex("")).toBeNull(); expect(parseSelectionIndex("")).toBeNull();
}); });
}); });
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
expect(parsePlaylistRef("华语经典")).toBeNull();
expect(parsePlaylistRef("2829883282")).toBeNull();
expect(parsePlaylistRef("")).toBeNull();
});
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
});
it("does not mistake a NetEase userid= for the playlist id", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
});
it("parses QQ Music playlist URLs", () => {
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
});
it("parses YouTube playlist URLs by their list= id", () => {
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
});
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
});
it("finds the link inside an app's share text", () => {
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
});
});
describe("findShareShortLink (#160)", () => {
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
});
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
expect(findShareShortLink("华语经典")).toBeNull();
});
});
describe("resolveShareLink (#160)", () => {
it("returns the redirect target", async () => {
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
});
it("returns null when there is no redirect or the request fails", async () => {
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
});
});
-81
View File
@@ -1,5 +1,3 @@
import axios from "axios";
/** /**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query, * Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single * so same-name songs can be disambiguated instead of always getting the single
@@ -100,82 +98,3 @@ export function parseSelectionIndex(raw: string): number | null {
const n = parseInt(m[1], 10); const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null; return Number.isFinite(n) && n > 0 ? n : null;
} }
export interface PlaylistRef {
id: string;
platform: "netease" | "qq" | "youtube";
}
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
function stripUrlBBCode(text: string): string {
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
}
/**
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
* share text. The platform comes from the URL, so a QQ link works without
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
* the caller handles as before.
*/
export function parsePlaylistRef(raw: string): PlaylistRef | null {
const q = stripUrlBBCode(raw ?? "").trim();
if (!q) return null;
if (/music\.163\.com/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
if (/y\.qq\.com/i.test(q)) {
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
if (/youtube\.com|youtu\.be/i.test(q)) {
const m = /[?&]list=([\w-]+)/.exec(q);
if (m) return { id: m[1], platform: "youtube" };
}
return null;
}
/**
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
* short link — what the phone apps copy. Only these hosts are recognized so
* the bot never fetches an arbitrary user-supplied URL.
*/
export function findShareShortLink(raw: string): string | null {
const q = stripUrlBBCode(raw ?? "");
const m =
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
return m ? m[0] : null;
}
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
const redirectGet: RedirectGet = async (url) => {
const res = await axios.get(url, {
maxRedirects: 0,
timeout: 5000,
validateStatus: () => true,
responseType: "stream",
});
res.data?.destroy?.();
const location = res.headers.location;
return { status: res.status, location: typeof location === "string" ? location : undefined };
};
/** Follow a share short link one hop. Returns the target URL, or null. */
export async function resolveShareLink(
url: string,
get: RedirectGet = redirectGet,
): Promise<string | null> {
try {
const { status, location } = await get(url);
if (status < 300 || status >= 400 || !location) return null;
return new URL(location, url).toString();
} catch {
return null;
}
}
+42 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js"; import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => { describe("BiliBiliProvider.search pagination", () => {
function mockProvider() { function mockProvider() {
@@ -162,3 +162,44 @@ describe("BiliBiliProvider multi-P support", () => {
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid 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);
});
});
+32 -1
View File
@@ -42,6 +42,37 @@ export interface BiliVideoPartsResult {
parts: BiliVideoPart[]; parts: BiliVideoPart[];
} }
/**
* PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:<port>, *.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。 * 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。 * 支持形如 "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 (b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
); );
const biliUrl = best.baseUrl ?? best.base_url; const biliUrl = pickStableAudioUrl(best);
return biliUrl ? { url: biliUrl } : null; return biliUrl ? { url: biliUrl } : null;
} catch { } catch {
return null; return null;