@bililive-tools/bilibili-recorder
Version:
bililive-tools bilibili recorder implemention
352 lines (351 loc) • 12.9 kB
JavaScript
import path from "node:path";
import mitt from "mitt";
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createDownloader, } from "@bililive-tools/manager";
import { getInfo, getStream, getLiveStatus, getStrictStream } from "./stream.js";
import DanmaClient from "./danma.js";
function createRecorder(opts) {
// 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
// 此标志来操作这个对象的地方,不然会跳过 proxy 的拦截。
const recorder = {
id: opts.id ?? genRecorderUUID(),
extra: opts.extra ?? {},
// @ts-ignore
...mitt(),
...opts,
cache: null,
availableStreams: [],
availableSources: [],
state: "idle",
qualityRetry: opts.qualityRetry ?? 0,
useM3U8Proxy: opts.useM3U8Proxy ?? false,
customHost: opts.customHost,
useServerTimestamp: opts.useServerTimestamp ?? true,
m3u8ProxyUrl: opts.m3u8ProxyUrl,
formatName: opts.formatName ?? "auto",
codecName: opts.codecName ?? "auto",
recorderType: opts.recorderType ?? "ffmpeg",
getChannelURL() {
return `https://live.bilibili.com/${this.channelId}`;
},
checkLiveStatusAndRecord: utils.singleton(checkLiveStatusAndRecord),
toJSON() {
return defaultToJSON(provider, this);
},
async getLiveInfo() {
const channelId = this.channelId;
const info = await getInfo(channelId);
return {
channelId,
...info,
};
},
async getStream() {
const res = await getStream({
channelId: this.channelId,
quality: this.quality,
formatName: this.formatName,
codecName: this.codecName,
});
return res.currentStream;
},
// batchLiveStatusCheck: async function (channels: string[]) {
// const data = await getStatusInfoByUIDs([roomInit.uid]);
// },
};
const recorderWithSupportUpdatedEvent = new Proxy(recorder, {
set(obj, prop, value) {
Reflect.set(obj, prop, value);
if (typeof prop === "string") {
obj.emit("Updated", [prop]);
}
return true;
},
});
return recorderWithSupportUpdatedEvent;
}
const ffmpegOutputOptions = [];
const ffmpegInputOptions = [
"-rw_timeout",
"10000000",
"-timeout",
"10000000",
"-headers",
"Referer:https://live.bilibili.com/",
];
const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, banLiveId, }) {
if (this.recordHandle != null)
return this.recordHandle;
try {
const { living, liveId, owner: _owner, title: _title } = await getLiveStatus(this.channelId);
this.liveInfo = {
living,
owner: _owner,
title: _title,
avatar: "",
cover: "",
liveId: liveId,
liveStartTime: new Date(),
recordStartTime: new Date(),
};
this.state = "idle";
}
catch (error) {
this.state = "check-error";
throw error;
}
if (this.liveInfo.liveId === banLiveId) {
this.tempStopIntervalCheck = true;
}
else {
this.tempStopIntervalCheck = false;
}
if (this.tempStopIntervalCheck)
return null;
if (!this.liveInfo.living)
return null;
// 检查标题是否包含关键词,如果包含则不自动录制
if (utils.checkTitleKeywordsBeforeRecord(this.liveInfo.title, this, isManualStart))
return null;
const liveInfo = await getInfo(this.channelId);
const { owner, title, roomId, liveStartTime, recordStartTime } = liveInfo;
this.liveInfo = liveInfo;
const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
const strictQuality = utils.shouldUseStrictQuality(qualityRetryLeft, this.qualityRetry, isManualStart);
let res;
try {
res = await getStream({
channelId: this.channelId,
quality: this.quality,
cookie: this.auth,
strictQuality: strictQuality,
formatName: this.formatName,
codecName: this.codecName,
onlyAudio: this.onlyAudio,
customHost: this.customHost,
});
}
catch (err) {
if (qualityRetryLeft > 0)
await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
this.state = "check-error";
throw err;
}
this.state = "recording";
const { streamOptions, currentStream: stream, sources: availableSources, streams: availableStreams, } = res;
this.availableStreams = availableStreams.map((s) => s.desc);
this.availableSources = availableSources.map((s) => s.name);
this.usedStream = stream.name;
this.usedSource = stream.source;
let url = stream.url;
let intervalId = null;
if (this.useM3U8Proxy && streamOptions.format_name === "ts") {
url = `${this.m3u8ProxyUrl}?id=${this.id}&format=hls`;
this.emit("DebugLog", {
type: "common",
text: `is hls stream, use proxy: ${url}`,
});
intervalId = setInterval(async () => {
const url = await getStrictStream(Number(this.channelId), {
qn: streamOptions.qn,
cookie: this.auth,
protocol_name: streamOptions.protocol_name,
format_name: streamOptions.format_name,
codec_name: streamOptions.codec_name,
});
if (this.recordHandle) {
this.recordHandle.url = url;
}
this.emit("DebugLog", {
type: "common",
text: `update stream: ${url}`,
});
}, 50 * 60 * 1000);
}
let isEnded = false;
const onEnd = (...args) => {
if (isEnded)
return;
isEnded = true;
this.emit("DebugLog", {
type: "common",
text: `record end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
});
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
this.recordHandle?.stop(reason);
};
const downloader = createDownloader(this.recorderType, {
url: url,
outputOptions: ffmpegOutputOptions,
inputOptions: ffmpegInputOptions,
segment: this.segment ?? 0,
getSavePath: (opts) => getSavePath({
owner,
title: opts.title ?? title,
startTime: opts.startTime,
liveStartTime: liveStartTime,
recordStartTime,
extraMs: opts.extraMs,
}),
formatName: streamOptions.format_name,
disableDanma: this.disableProvideCommentsWhenRecording,
videoFormat: this.videoFormat,
debugLevel: this.debugLevel ?? "none",
headers: {
Referer: "https://live.bilibili.com/",
},
}, onEnd, async () => {
const info = await getInfo(this.channelId);
return info;
});
const handleVideoCreated = async ({ filename, title, cover, rawFilename }) => {
this.emit("videoFileCreated", { filename, cover, rawFilename });
if (title && this?.liveInfo) {
this.liveInfo.title = title;
}
if (cover && this?.liveInfo) {
this.liveInfo.cover = cover;
}
const extraDataController = downloader.getExtraDataController();
extraDataController?.setMeta({
room_id: String(roomId),
platform: provider?.id,
liveStartTimestamp: liveInfo.liveStartTime?.getTime(),
// recordStopTimestamp: Date.now(),
title: title,
user_name: owner,
});
};
downloader.on("videoFileCreated", handleVideoCreated);
downloader.on("videoFileCompleted", (data) => {
this.emit("videoFileCompleted", data);
});
downloader.on("DebugLog", (data) => {
this.emit("DebugLog", data);
});
downloader.on("progress", (progress) => {
if (this.recordHandle) {
this.recordHandle.progress = progress;
}
this.emit("progress", progress);
});
let danmaClient = new DanmaClient(roomId, {
auth: this.auth,
uid: Number(this.uid),
useServerTimestamp: this.useServerTimestamp,
});
// 开启了禁止提供弹幕功能,并且也没有设置标题关键词,才完全禁止连接弹幕服务器,否则都连接弹幕服务器,前者不处理弹幕消息,后者根据标题关键词来判断是否停止录制
const enableDanmaListen = !this.disableProvideCommentsWhenRecording ||
utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords);
danmaClient.on("Message", (msg) => {
if (this.disableProvideCommentsWhenRecording)
return;
const extraDataController = downloader.getExtraDataController();
if (!extraDataController)
return;
if (msg.type === "super_chat" && this.saveSCDanma === false)
return;
if ((msg.type === "give_gift" || msg.type === "guard") && this.saveGiftDanma === false)
return;
this.emit("Message", msg);
extraDataController.addMessage(msg);
});
danmaClient.on("onRoomInfoChange", (msg) => {
if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
const title = msg?.body?.title ?? "";
const hasTitleKeyword = utils.hasBlockedTitleKeywords(title, this.titleKeywords);
if (hasTitleKeyword) {
this.state = "title-blocked";
this.emit("DebugLog", {
type: "common",
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
});
// 停止录制
this.recordHandle && this.recordHandle.stop("直播间标题包含关键词");
}
}
});
if (enableDanmaListen) {
try {
danmaClient.start();
}
catch (err) {
this.emit("DebugLog", {
type: "error",
text: `弹幕连接失败,错误信息: ${String(err)}`,
});
}
}
const downloaderArgs = downloader.getArguments();
downloader.run();
const cut = utils.singleton(async () => {
if (!this.recordHandle)
return;
downloader.cut();
});
const stop = utils.singleton(async (reason) => {
if (!this.recordHandle)
return;
this.state = "stopping-record";
intervalId && clearInterval(intervalId);
try {
danmaClient.stop();
await downloader.stop();
}
catch (err) {
this.emit("DebugLog", {
type: "common",
text: `stop record error: ${String(err)}`,
});
}
this.usedStream = undefined;
this.usedSource = undefined;
this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
this.recordHandle = undefined;
this.liveInfo = undefined;
this.state = "idle";
this.cache.set("qualityRetryLeft", this.qualityRetry);
});
this.recordHandle = {
id: genRecordUUID(),
stream: stream.name,
source: stream.source,
recorderType: downloader.type,
url: stream.url,
downloaderArgs,
savePath: downloader.videoFilePath,
stop,
cut,
};
this.emit("RecordStart", this.recordHandle);
return this.recordHandle;
};
export const provider = {
id: "Bilibili",
name: "Bilibili",
siteURL: "https://live.bilibili.com/",
matchURL(channelURL) {
return /https?:\/\/(?:.*?\.)?bilibili.com\//.test(channelURL);
},
async resolveChannelInfoFromURL(channelURL) {
if (!this.matchURL(channelURL))
return null;
const id = path.basename(new URL(channelURL).pathname);
const info = await getInfo(id);
return {
id: info.roomId.toString(),
title: info.title,
owner: info.owner,
uid: info.uid,
avatar: info.avatar,
};
},
createRecorder(opts) {
return createRecorder({ providerId: provider.id, ...opts });
},
fromJSON(recorder) {
return defaultFromJSON(this, recorder);
},
setFFMPEGOutputArgs(args) {
ffmpegOutputOptions.splice(0, ffmpegOutputOptions.length, ...args);
},
};