UNPKG

@bililive-tools/douyin-recorder

Version:
297 lines (296 loc) 9.67 kB
import path from "node:path"; import mitt from "mitt"; import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, FFMPEGRecorder, } from "@bililive-tools/manager"; import { getInfo, getStream } from "./stream.js"; import { ensureFolderExist, singleton } from "./utils.js"; import DouYinDanmaClient from "douyin-danma-listener"; function createRecorder(opts) { // 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过 // 此标志来操作这个对象的地方,不然会跳过 proxy 的拦截。 const recorder = { id: opts.id ?? genRecorderUUID(), extra: opts.extra ?? {}, // @ts-ignore ...mitt(), ...opts, availableStreams: [], availableSources: [], qualityMaxRetry: opts.qualityRetry ?? 0, qualityRetry: opts.qualityRetry ?? 0, state: "idle", getChannelURL() { return `https://live.douyin.com/${this.channelId}`; }, checkLiveStatusAndRecord: 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, streamPriorities: this.streamPriorities, sourcePriorities: this.sourcePriorities, }); return res.currentStream; }, }; 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 = [ "-c", "copy", "-movflags", "faststart+frag_keyframe+empty_moov", "-min_frag_duration", "60000000", ]; const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) { if (this.recordHandle != null) return this.recordHandle; const liveInfo = await getInfo(this.channelId); const { living, owner, title, liveId } = liveInfo; this.liveInfo = liveInfo; if (liveInfo.liveId === banLiveId) { this.tempStopIntervalCheck = true; } else { this.tempStopIntervalCheck = false; } if (this.tempStopIntervalCheck) return null; if (!living) return null; this.emit("LiveStart", { liveId }); let res; try { let strictQuality = false; if (this.qualityRetry > 0) { strictQuality = true; } if (this.qualityMaxRetry < 0) { strictQuality = true; } if (isManualStart) { strictQuality = false; } res = await getStream({ channelId: this.channelId, quality: this.quality, streamPriorities: this.streamPriorities, sourcePriorities: this.sourcePriorities, strictQuality: strictQuality, }); } catch (err) { this.state = "idle"; throw err; } this.state = "recording"; const { 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; // TODO: emit update event let isEnded = false; const onEnd = (...args) => { if (isEnded) return; isEnded = true; this.emit("DebugLog", { type: "common", text: `ffmpeg 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 recorder = new FFMPEGRecorder({ url: stream.url, outputOptions: ffmpegOutputOptions, segment: this.segment ?? 0, getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }), disableDanma: this.disableProvideCommentsWhenRecording, videoFormat: this.videoFormat ?? "auto", }, onEnd); const savePath = getSavePath({ owner, title, }); try { ensureFolderExist(savePath); } catch (err) { this.state = "idle"; throw err; } const handleVideoCreated = async ({ filename }) => { this.emit("videoFileCreated", { filename }); const extraDataController = recorder.getExtraDataController(); extraDataController?.setMeta({ room_id: this.channelId, platform: provider?.id, // liveStartTimestamp: liveInfo.startTime?.getTime(), recordStopTimestamp: Date.now(), title: title, user_name: owner, }); }; recorder.on("videoFileCreated", handleVideoCreated); recorder.on("videoFileCompleted", ({ filename }) => { this.emit("videoFileCompleted", { filename }); }); recorder.on("DebugLog", (data) => { this.emit("DebugLog", data); }); recorder.on("progress", (progress) => { if (this.recordHandle) { this.recordHandle.progress = progress; } this.emit("progress", progress); }); const client = new DouYinDanmaClient(liveInfo.liveId); client.on("chat", (msg) => { const extraDataController = recorder.getExtraDataController(); if (!extraDataController) return; const comment = { type: "comment", timestamp: Date.now(), text: msg.content, color: "#ffffff", sender: { uid: msg.user.id, name: msg.user.nickName, // avatar: msg.user.AvatarThumb.urlListList[0], // extra: { // level: msg.level, // }, }, }; // console.log("comment", comment); this.emit("Message", comment); extraDataController.addMessage(comment); }); client.on("gift", (msg) => { const extraDataController = recorder.getExtraDataController(); if (!extraDataController) return; if (this.saveGiftDanma === false) return; const gift = { type: "give_gift", timestamp: Number(msg.sendTime), name: msg.gift.name, price: 1, count: Number(msg.totalCount), color: "#ffffff", sender: { uid: msg.user.id, name: msg.user.nickName, // avatar: msg.ic, // extra: { // level: msg.level, // }, }, }; // console.log("gift", gift); this.emit("Message", gift); extraDataController.addMessage(gift); }); // client.on("open", () => { // console.log("open"); // }); // client.on("close", () => { // console.log("close"); // }); // client.on("error", (err) => { // console.log("error", err); // }); // client.on("heartbeat", () => { // // console.log("heartbeat"); // }); if (!this.disableProvideCommentsWhenRecording) { client.connect(); } const ffmpegArgs = recorder.getArguments(); recorder.run(); const stop = singleton(async (reason) => { if (!this.recordHandle) return; this.state = "stopping-record"; client.close(); try { await recorder.stop(); } catch (err) { this.emit("DebugLog", { type: "common", text: `stop ffmpeg 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.recordHandle = { id: genRecordUUID(), stream: stream.name, source: stream.source, url: stream.url, ffmpegArgs, savePath: savePath, stop, }; this.emit("RecordStart", this.recordHandle); return this.recordHandle; }; export const provider = { id: "DouYin", name: "抖音", siteURL: "https://live.douyin.com/", matchURL(channelURL) { // TODO: 暂时不支持 v.douyin.com return /https?:\/\/live\.douyin\.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, title: info.title, owner: info.owner, }; }, createRecorder(opts) { return createRecorder({ providerId: provider.id, ...opts }); }, fromJSON(recorder) { return defaultFromJSON(this, recorder); }, setFFMPEGOutputArgs(args) { ffmpegOutputOptions.splice(0, ffmpegOutputOptions.length, ...args); }, };