feat: add BiliBili audio source integration

Implement BiliBiliProvider for video-as-audio playback using direct
BiliBili API calls (search, video info, DASH audio URL extraction).
Add QR code login support and cookie persistence. Update FFmpeg to
send Referer header for BiliBili CDN URLs. Extend platform union type
to "netease" | "qq" | "bilibili" across all interfaces. Add -b flag
for chat commands and B站 badge in web UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 15:14:27 +08:00
1 parent 7d329a0db1
commit a6881403ef
15 files changed
+425 -36

No files matched your search

+12 -2
View File
@@ -58,11 +58,21 @@ export class AudioPlayer extends EventEmitter {
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback"); this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
const args = [ const args: string[] = [];
// BiliBili CDN requires Referer header for audio playback
if (url.includes("bilivideo") || url.includes("bilibili")) {
args.push(
"-headers",
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
);
}
args.push(
"-reconnect", "1", "-reconnect", "1",
"-reconnect_streamed", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5", "-reconnect_delay_max", "5",
]; );
if (seekSeconds > 0) { if (seekSeconds > 0) {
args.push("-ss", String(seekSeconds)); args.push("-ss", String(seekSeconds));
} }
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string; name: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq"; platform: "netease" | "qq" | "bilibili";
url?: string; // resolved lazily at play time url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
+15 -8
View File
@@ -22,6 +22,7 @@ export interface BotInstanceOptions {
tsOptions: TS3ClientOptions; tsOptions: TS3ClientOptions;
neteaseProvider: MusicProvider; neteaseProvider: MusicProvider;
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -49,6 +50,7 @@ export class BotInstance extends EventEmitter {
private queue: PlayQueue; private queue: PlayQueue;
private neteaseProvider: MusicProvider; private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -62,6 +64,7 @@ export class BotInstance extends EventEmitter {
this.name = options.name; this.name = options.name;
this.neteaseProvider = options.neteaseProvider; this.neteaseProvider = options.neteaseProvider;
this.qqProvider = options.qqProvider; this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider;
this.database = options.database; this.database = options.database;
this.config = options.config; this.config = options.config;
this.logger = options.logger.child({ botId: this.id }); this.logger = options.logger.child({ botId: this.id });
@@ -207,12 +210,15 @@ export class BotInstance extends EventEmitter {
} }
} }
getProviderFor(platform: "netease" | "qq"): MusicProvider { getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
private getProvider(useQQ: boolean): MusicProvider { private getProvider(flags: Set<string>): MusicProvider {
return useQQ ? this.qqProvider : this.neteaseProvider; if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
return this.neteaseProvider;
} }
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */ /** Resolve URL for a song and start playing it. Skips to next if URL fails. */
@@ -245,7 +251,7 @@ export class BotInstance extends EventEmitter {
private async cmdPlay(cmd: ParsedCommand): Promise<string> { private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !play <song name or URL>"; if (!cmd.args) return "Usage: !play <song name or URL>";
const provider = this.getProvider(cmd.flags.has("q")); const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1); const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0) if (result.songs.length === 0)
return `No results found for: ${cmd.args}`; return `No results found for: ${cmd.args}`;
@@ -262,7 +268,7 @@ export class BotInstance extends EventEmitter {
private async cmdAdd(cmd: ParsedCommand): Promise<string> { private async cmdAdd(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !add <song name>"; if (!cmd.args) return "Usage: !add <song name>";
const provider = this.getProvider(cmd.flags.has("q")); const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1); const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0) if (result.songs.length === 0)
return `No results found for: ${cmd.args}`; return `No results found for: ${cmd.args}`;
@@ -367,7 +373,7 @@ export class BotInstance extends EventEmitter {
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> { private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist ID or URL>"; if (!cmd.args) return "Usage: !playlist <playlist ID or URL>";
const provider = this.getProvider(cmd.flags.has("q")); const provider = this.getProvider(cmd.flags);
const id = this.extractId(cmd.args); const id = this.extractId(cmd.args);
const songs = await provider.getPlaylistSongs(id); const songs = await provider.getPlaylistSongs(id);
if (songs.length === 0) return "Playlist is empty or not found"; if (songs.length === 0) return "Playlist is empty or not found";
@@ -384,7 +390,7 @@ export class BotInstance extends EventEmitter {
private async cmdAlbum(cmd: ParsedCommand): Promise<string> { private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !album <album ID>"; if (!cmd.args) return "Usage: !album <album ID>";
const provider = this.getProvider(cmd.flags.has("q")); const provider = this.getProvider(cmd.flags);
const songs = await provider.getAlbumSongs(cmd.args); const songs = await provider.getAlbumSongs(cmd.args);
if (songs.length === 0) return "Album is empty or not found"; if (songs.length === 0) return "Album is empty or not found";
@@ -437,7 +443,7 @@ export class BotInstance extends EventEmitter {
private async cmdLyrics(): Promise<string> { private async cmdLyrics(): Promise<string> {
const song = this.queue.current(); const song = this.queue.current();
if (!song) return "Nothing is playing"; if (!song) return "Nothing is playing";
const provider = this.getProvider(song.platform === "qq"); const provider = this.getProviderFor(song.platform);
const lyrics = await provider.getLyrics(song.id); const lyrics = await provider.getLyrics(song.id);
if (lyrics.length === 0) return "No lyrics available"; if (lyrics.length === 0) return "No lyrics available";
const lines = lyrics.slice(0, 10).map((l) => l.text); const lines = lyrics.slice(0, 10).map((l) => l.text);
@@ -461,6 +467,7 @@ export class BotInstance extends EventEmitter {
"TSMusicBot Commands:", "TSMusicBot Commands:",
`${p}play <song> — Search and play`, `${p}play <song> — Search and play`,
`${p}play -q <song> — Search from QQ Music`, `${p}play -q <song> — Search from QQ Music`,
`${p}play -b <song> — Search from BiliBili`,
`${p}add <song> — Add to queue`, `${p}add <song> — Add to queue`,
`${p}pause/resume — Pause/resume`, `${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`, `${p}next/prev — Next/previous`,
+5
View File
@@ -23,6 +23,7 @@ export class BotManager {
private bots = new Map<string, BotInstance>(); private bots = new Map<string, BotInstance>();
private neteaseProvider: MusicProvider; private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -30,12 +31,14 @@ export class BotManager {
constructor( constructor(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
qqProvider: MusicProvider, qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
database: BotDatabase, database: BotDatabase,
config: BotConfig, config: BotConfig,
logger: Logger logger: Logger
) { ) {
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider; this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.database = database; this.database = database;
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
@@ -57,6 +60,7 @@ export class BotManager {
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -125,6 +129,7 @@ export class BotManager {
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+1 -1
View File
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
songName: string; songName: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq"; platform: "netease" | "qq" | "bilibili";
coverUrl: string; coverUrl: string;
} }
+6
View File
@@ -6,6 +6,7 @@ import { createLogger } from "./logger.js";
import { createApiServerManager } from "./music/api-server.js"; import { createApiServerManager } from "./music/api-server.js";
import { NeteaseProvider } from "./music/netease.js"; import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js"; import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { createCookieStore } from "./music/auth.js"; import { createCookieStore } from "./music/auth.js";
import { BotManager } from "./bot/manager.js"; import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js"; import { createWebServer } from "./web/server.js";
@@ -34,16 +35,20 @@ async function main() {
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl()); const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl()); const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider();
const cookieStore = createCookieStore(COOKIE_DIR); const cookieStore = createCookieStore(COOKIE_DIR);
const neteaseCookie = cookieStore.load("netease"); const neteaseCookie = cookieStore.load("netease");
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie); if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
const qqCookie = cookieStore.load("qq"); const qqCookie = cookieStore.load("qq");
if (qqCookie) qqProvider.setCookie(qqCookie); if (qqCookie) qqProvider.setCookie(qqCookie);
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const botManager = new BotManager( const botManager = new BotManager(
neteaseProvider, neteaseProvider,
qqProvider, qqProvider,
bilibiliProvider,
db, db,
config, config,
logger logger
@@ -55,6 +60,7 @@ async function main() {
botManager, botManager,
neteaseProvider, neteaseProvider,
qqProvider, qqProvider,
bilibiliProvider,
database: db, database: db,
config, config,
logger, logger,
+4 -4
View File
@@ -2,8 +2,8 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
export interface CookieStore { export interface CookieStore {
save(platform: "netease" | "qq", cookie: string): void; save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
load(platform: "netease" | "qq"): string; load(platform: "netease" | "qq" | "bilibili"): string;
} }
export function createCookieStore(cookieDir: string): CookieStore { export function createCookieStore(cookieDir: string): CookieStore {
@@ -12,7 +12,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
} }
return { return {
save(platform: "netease" | "qq", cookie: string): void { save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
const filePath = path.join(cookieDir, `${platform}.json`); const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync( fs.writeFileSync(
filePath, filePath,
@@ -21,7 +21,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
); );
}, },
load(platform: "netease" | "qq"): string { load(platform: "netease" | "qq" | "bilibili"): string {
const filePath = path.join(cookieDir, `${platform}.json`); const filePath = path.join(cookieDir, `${platform}.json`);
if (!fs.existsSync(filePath)) return ""; if (!fs.existsSync(filePath)) return "";
try { try {
+258
View File
@@ -0,0 +1,258 @@
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
Playlist,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
} from "./provider.js";
const BILIBILI_HEADERS = {
Referer: "https://www.bilibili.com",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
private passportApi: AxiosInstance;
private cookie = "";
private quality = "high";
private cidCache = new Map<string, number>();
constructor() {
this.api = axios.create({
baseURL: "https://api.bilibili.com",
timeout: 15000,
headers: BILIBILI_HEADERS,
});
this.passportApi = axios.create({
baseURL: "https://passport.bilibili.com",
timeout: 15000,
headers: BILIBILI_HEADERS,
});
}
private get cookieHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
/** Strip HTML tags from BiliBili search results */
private stripHtml(str: string): string {
return str.replace(/<[^>]+>/g, "");
}
async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get("/x/web-interface/search/type", {
params: {
search_type: "video",
keyword: query,
page_size: limit,
},
headers: this.cookieHeaders,
});
const results = res.data?.data?.result ?? [];
const songs: Song[] = results.map((v: any) => ({
id: String(v.bvid),
name: this.stripHtml(v.title ?? ""),
artist: v.author ?? "",
album: "",
duration: v.duration
? typeof v.duration === "string"
? this.parseDurationString(v.duration)
: v.duration
: 0,
coverUrl: v.pic
? v.pic.startsWith("//")
? `https:${v.pic}`
: v.pic
: "",
platform: "bilibili" as const,
}));
return { songs, playlists: [], albums: [] };
}
/** Parse "MM:SS" duration string to seconds */
private parseDurationString(dur: string): number {
const parts = dur.split(":");
if (parts.length === 2) {
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
}
return parseInt(dur, 10) || 0;
}
async getSongDetail(songId: string): Promise<Song | null> {
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: songId },
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);
}
return {
id: String(data.bvid),
name: data.title ?? "",
artist: data.owner?.name ?? "",
album: "",
duration: data.duration ?? 0,
coverUrl: data.pic ?? "",
platform: "bilibili" as const,
};
} 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);
if (cached) return cached;
const detail = await this.getSongDetail(bvid);
if (!detail) return null;
return this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
const cid = await this.getCid(songId);
if (!cid) return null;
try {
const res = await this.api.get("/x/player/playurl", {
params: {
cid,
bvid: songId,
fnval: 16, // DASH format
},
headers: this.cookieHeaders,
});
const audioStreams = res.data?.data?.dash?.audio;
if (!audioStreams || audioStreams.length === 0) return null;
// Pick highest bandwidth audio stream
const best = audioStreams.reduce((a: any, b: any) =>
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
);
return best.baseUrl ?? best.base_url ?? null;
} catch {
return null;
}
}
// --- QR Code Login ---
async getQrCode(): Promise<QrCodeResult> {
const res = await this.passportApi.get(
"/x/passport-login/web/qrcode/generate"
);
const data = res.data?.data ?? {};
return {
qrUrl: data.url ?? "",
key: data.qrcode_key ?? "",
};
}
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const res = await this.passportApi.get(
"/x/passport-login/web/qrcode/poll",
{ params: { qrcode_key: key }, headers: this.cookieHeaders }
);
const code = res.data?.data?.code;
switch (code) {
case 0: {
// Login success — extract cookie from response headers
const setCookieHeaders = res.headers["set-cookie"];
if (setCookieHeaders) {
this.cookie = setCookieHeaders
.map((c: string) => c.split(";")[0])
.join("; ");
}
// Also check if cookie is returned in response data
if (res.data?.data?.url) {
// BiliBili returns refresh info in the URL, cookie comes from set-cookie headers
}
return "confirmed";
}
case 86038:
return "expired";
case 86090:
return "scanned";
case 86101:
default:
return "waiting";
}
}
// --- Auth Status ---
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
try {
const res = await this.api.get("/x/web-interface/nav", {
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (data && data.isLogin) {
return {
loggedIn: true,
nickname: data.uname,
avatarUrl: data.face,
};
}
} catch {
// ignore
}
return { loggedIn: false };
}
setCookie(cookie: string): void {
this.cookie = cookie;
}
getCookie(): string {
return this.cookie;
}
// --- Not applicable for BiliBili (video platform) ---
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
return [];
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
}
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string; album: string;
duration: number; // seconds duration: number; // seconds
coverUrl: string; coverUrl: string;
platform: "netease" | "qq"; platform: "netease" | "qq" | "bilibili";
} }
export interface SongWithUrl extends Song { export interface SongWithUrl extends Song {
@@ -17,7 +17,7 @@ export interface Playlist {
name: string; name: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq"; platform: "netease" | "qq" | "bilibili";
} }
export interface Album { export interface Album {
@@ -26,7 +26,7 @@ export interface Album {
artist: string; artist: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq"; platform: "netease" | "qq" | "bilibili";
} }
export interface LyricLine { export interface LyricLine {
@@ -54,7 +54,7 @@ export interface AuthStatus {
} }
export interface MusicProvider { export interface MusicProvider {
readonly platform: "netease" | "qq"; readonly platform: "netease" | "qq" | "bilibili";
search(query: string, limit?: number): Promise<SearchResult>; search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<string | null>; getSongUrl(songId: string, quality?: string): Promise<string | null>;
+6 -2
View File
@@ -6,12 +6,14 @@ import type { Logger } from "../../logger.js";
export function createAuthRouter( export function createAuthRouter(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
qqProvider: MusicProvider, qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger, logger: Logger,
cookieStore?: CookieStore cookieStore?: CookieStore
): Router { ): Router {
const router = Router(); const router = Router();
function getProvider(platform?: string): MusicProvider { function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -55,7 +57,8 @@ export function createAuthRouter(
// When confirmed, persist cookie // When confirmed, persist cookie
if (status === "confirmed") { if (status === "confirmed") {
const cookie = provider.getCookie(); const cookie = provider.getCookie();
const plat = (platform as string) === "qq" ? "qq" as const : "netease" as const; const plat = (platform as string) === "bilibili" ? "bilibili" as const
: (platform as string) === "qq" ? "qq" as const : "netease" as const;
if (cookie && cookieStore) { if (cookie && cookieStore) {
cookieStore.save(plat, cookie); cookieStore.save(plat, cookie);
logger.info({ platform: plat }, "Cookie persisted to disk"); logger.info({ platform: plat }, "Cookie persisted to disk");
@@ -118,7 +121,8 @@ export function createAuthRouter(
} }
const provider = getProvider(platform); const provider = getProvider(platform);
provider.setCookie(cookie); provider.setCookie(cookie);
const plat = platform === "qq" ? "qq" as const : "netease" as const; const plat = platform === "bilibili" ? "bilibili" as const
: platform === "qq" ? "qq" as const : "netease" as const;
if (cookieStore) { if (cookieStore) {
cookieStore.save(plat, cookie); cookieStore.save(plat, cookie);
} }
+9 -1
View File
@@ -5,11 +5,13 @@ import type { Logger } from "../../logger.js";
export function createMusicRouter( export function createMusicRouter(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
qqProvider: MusicProvider, qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger logger: Logger
): Router { ): Router {
const router = Router(); const router = Router();
function getProvider(platform?: string): MusicProvider { function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -40,9 +42,10 @@ export function createMusicRouter(
return; return;
} }
const parsedLimit = parseInt(limit as string) || 20; const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult] = await Promise.allSettled([ const [neteaseResult, qqResult, bilibiliResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit), neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit), qqProvider.search(q as string, parsedLimit),
bilibiliProvider.search(q as string, parsedLimit),
]); ]);
const songs = [ const songs = [
@@ -50,6 +53,7 @@ export function createMusicRouter(
? neteaseResult.value.songs ? neteaseResult.value.songs
: []), : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []), ...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
]; ];
res.json({ songs }); res.json({ songs });
@@ -198,6 +202,7 @@ export function createMusicRouter(
res.json({ res.json({
netease: neteaseProvider.getQuality(), netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(), qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
}); });
}); });
@@ -214,6 +219,9 @@ export function createMusicRouter(
if (!platform || platform === "qq") { if (!platform || platform === "qq") {
qqProvider.setQuality(quality); qqProvider.setQuality(quality);
} }
if (!platform || platform === "bilibili") {
bilibiliProvider.setQuality(quality);
}
logger.info({ quality, platform }, "Audio quality changed"); logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality }); res.json({ success: true, quality });
}); });
+7 -6
View File
@@ -11,6 +11,7 @@ export function createPlayerRouter(
database?: BotDatabase, database?: BotDatabase,
neteaseProvider?: MusicProvider, neteaseProvider?: MusicProvider,
qqProvider?: MusicProvider, qqProvider?: MusicProvider,
bilibiliProvider?: MusicProvider,
): Router { ): Router {
const router = Router(); const router = Router();
@@ -32,7 +33,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "query is required" }); res.status(400).json({ error: "query is required" });
return; return;
} }
const flags = platform === "qq" ? "-q" : ""; const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!"); const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!");
if (!cmd) { if (!cmd) {
res.status(400).json({ error: "Invalid command" }); res.status(400).json({ error: "Invalid command" });
@@ -49,7 +50,7 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { query, platform } = req.body; const { query, platform } = req.body;
const flags = platform === "qq" ? "-q" : ""; const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!"); const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!");
if (!cmd) { if (!cmd) {
res.status(400).json({ error: "Invalid command" }); res.status(400).json({ error: "Invalid command" });
@@ -173,7 +174,7 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; const { playlistId, platform } = req.body;
const flags = platform === "qq" ? "-q" : ""; const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand( const cmd = parseCommand(
`!playlist ${flags} ${playlistId}`.trim(), `!playlist ${flags} ${playlistId}`.trim(),
"!" "!"
@@ -191,7 +192,7 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; const { playlistId, platform } = req.body;
const provider = platform === "qq" ? qqProvider : neteaseProvider; const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
if (!provider) { if (!provider) {
res.status(500).json({ error: "Provider not available" }); res.status(500).json({ error: "Provider not available" });
return; return;
@@ -238,7 +239,7 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { songId, platform } = req.body; const { songId, platform } = req.body;
const provider = platform === "qq" ? qqProvider : neteaseProvider; const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
if (!provider) { if (!provider) {
res.status(500).json({ error: "Provider not available" }); res.status(500).json({ error: "Provider not available" });
return; return;
@@ -272,7 +273,7 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { songId, platform } = req.body; const { songId, platform } = req.body;
const provider = platform === "qq" ? qqProvider : neteaseProvider; const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
if (!provider) { if (!provider) {
res.status(500).json({ error: "Provider not available" }); res.status(500).json({ error: "Provider not available" });
return; return;
+4 -3
View File
@@ -19,6 +19,7 @@ export interface WebServerOptions {
botManager: BotManager; botManager: BotManager;
neteaseProvider: MusicProvider; neteaseProvider: MusicProvider;
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -44,15 +45,15 @@ export function createWebServer(options: WebServerOptions): WebServer {
); );
app.use( app.use(
"/api/music", "/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, logger) createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger)
); );
app.use("/api/player", createPlayerRouter( app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database, options.botManager, logger, options.database,
options.neteaseProvider, options.qqProvider, options.neteaseProvider, options.qqProvider, options.bilibiliProvider,
)); ));
app.use( app.use(
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, logger, options.cookieStore) createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
); );
app.get("/api/health", (_req, res) => { app.get("/api/health", (_req, res) => {
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span> <span class="song-name">{{ song.name }}</span>
<span <span
class="platform-badge" class="platform-badge"
:class="song.platform === 'qq' ? 'badge-qq' : 'badge-netease'" :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
>{{ song.platform === 'qq' ? 'QQ' : '网易云' }}</span> >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -113,6 +113,11 @@ function formatDuration(seconds: number): string {
color: #12b76a; color: #12b76a;
} }
.badge-bilibili {
background: rgba(0, 161, 214, 0.15);
color: #00a1d6;
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+86 -2
View File
@@ -193,6 +193,75 @@
<button class="btn-primary btn-save" @click="saveCookie('qq')">保存Cookie</button> <button class="btn-primary btn-save" @click="saveCookie('qq')">保存Cookie</button>
</div> </div>
</div> </div>
<!-- BiliBili -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:video-outline" class="account-icon bilibili-icon" />
<div class="account-info">
<div class="account-name">哔哩哔哩</div>
<div class="account-status" :class="{ logged: bilibiliAuth.loggedIn }">
{{ bilibiliAuth.loggedIn ? `已登录: ${bilibiliAuth.nickname}` : '未登录' }}
</div>
</div>
</div>
<div class="login-methods">
<button
class="login-btn"
:class="{ active: bilibiliLoginMode === 'qr' }"
@click="startQrLogin('bilibili')"
:disabled="bilibiliQr.loading"
>
<Icon icon="mdi:qrcode" />
扫码登录
</button>
<button
class="login-btn"
:class="{ active: bilibiliLoginMode === 'cookie' }"
@click="bilibiliLoginMode = 'cookie'"
>
<Icon icon="mdi:cookie" />
Cookie登录
</button>
</div>
<!-- QR Code -->
<div v-if="bilibiliLoginMode === 'qr'" class="qr-section">
<div v-if="bilibiliQr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="bilibiliQr.dataUrl" class="qr-wrap">
<img :src="bilibiliQr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="bilibiliQr.status">
<template v-if="bilibiliQr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用哔哩哔哩APP扫码
</template>
<template v-else-if="bilibiliQr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="bilibiliQr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 登录成功!
</template>
<template v-else-if="bilibiliQr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin('bilibili')">重新生成</button>
</template>
</div>
</div>
</div>
<!-- Cookie -->
<div v-if="bilibiliLoginMode === 'cookie'" class="cookie-section">
<textarea
v-model="bilibiliCookie"
class="textarea"
placeholder="粘贴哔哩哔哩Cookie..."
rows="3"
/>
<button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button>
</div>
</div>
</section> </section>
<!-- Audio Quality --> <!-- Audio Quality -->
@@ -248,6 +317,7 @@ const newBotName = ref('');
const newBotServer = ref(''); const newBotServer = ref('');
const neteaseCookie = ref(''); const neteaseCookie = ref('');
const qqCookie = ref(''); const qqCookie = ref('');
const bilibiliCookie = ref('');
const commandPrefix = ref('!'); const commandPrefix = ref('!');
// Audio quality // Audio quality
@@ -278,10 +348,12 @@ async function setQuality(q: string) {
// Login mode: 'qr' | 'cookie' | null // Login mode: 'qr' | 'cookie' | null
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null); const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
const qqLoginMode = ref<'qr' | 'cookie' | null>(null); const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null);
// Auth status // Auth status
const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
// QR state // QR state
interface QrState { interface QrState {
@@ -298,19 +370,25 @@ const neteaseQr = reactive<QrState>({
const qqQr = reactive<QrState>({ const qqQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null, loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
}); });
const bilibiliQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
});
function getQrState(platform: string): QrState { function getQrState(platform: string): QrState {
if (platform === 'bilibili') return bilibiliQr;
return platform === 'netease' ? neteaseQr : qqQr; return platform === 'netease' ? neteaseQr : qqQr;
} }
async function checkAuthStatus() { async function checkAuthStatus() {
try { try {
const [nRes, qRes] = await Promise.all([ const [nRes, qRes, bRes] = await Promise.all([
axios.get('/api/auth/status', { params: { platform: 'netease' } }), axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
]); ]);
Object.assign(neteaseAuth, nRes.data); Object.assign(neteaseAuth, nRes.data);
Object.assign(qqAuth, qRes.data); Object.assign(qqAuth, qRes.data);
Object.assign(bilibiliAuth, bRes.data);
} catch { } catch {
// API not ready // API not ready
} }
@@ -319,6 +397,7 @@ async function checkAuthStatus() {
async function startQrLogin(platform: string) { async function startQrLogin(platform: string) {
const qr = getQrState(platform); const qr = getQrState(platform);
if (platform === 'netease') neteaseLoginMode.value = 'qr'; if (platform === 'netease') neteaseLoginMode.value = 'qr';
else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr';
else qqLoginMode.value = 'qr'; else qqLoginMode.value = 'qr';
// Stop existing poll // Stop existing poll
@@ -412,7 +491,7 @@ async function toggleBot(botId: string, connected: boolean) {
} }
async function saveCookie(platform: string) { async function saveCookie(platform: string) {
const cookie = platform === 'netease' ? neteaseCookie.value : qqCookie.value; const cookie = platform === 'bilibili' ? bilibiliCookie.value : platform === 'netease' ? neteaseCookie.value : qqCookie.value;
if (!cookie) return; if (!cookie) return;
try { try {
await axios.post('/api/auth/cookie', { platform, cookie }); await axios.post('/api/auth/cookie', { platform, cookie });
@@ -434,6 +513,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer); if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer);
if (qqQr.pollTimer) clearInterval(qqQr.pollTimer); if (qqQr.pollTimer) clearInterval(qqQr.pollTimer);
if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer);
}); });
</script> </script>
@@ -563,6 +643,10 @@ onUnmounted(() => {
.account-icon { .account-icon {
font-size: 28px; font-size: 28px;
color: var(--color-primary); color: var(--color-primary);
&.bilibili-icon {
color: #00a1d6;
}
} }
.account-name { .account-name {