├── .node-version ├── docs ├── api │ ├── other.md │ └── base.md ├── features │ ├── ffmpeg.md │ └── virtual-record.md ├── public │ ├── logo.png │ ├── webui.png │ └── preview.png ├── package.json ├── faq.md ├── api-examples.md ├── markdown-examples.md ├── index.md └── guide │ └── introduction.md ├── packages ├── http │ ├── src │ │ ├── types │ │ │ ├── index.d.ts │ │ │ ├── video.ts │ │ │ └── webhook.d.ts │ │ ├── middleware │ │ │ ├── error.ts │ │ │ ├── validator.ts │ │ │ └── multer.ts │ │ ├── routes │ │ │ ├── llm.ts │ │ │ ├── assets.ts │ │ │ ├── danma.ts │ │ │ └── user.ts │ │ └── services │ │ │ └── fileCache.ts │ ├── test-globals.js │ ├── vitest.config.ts │ ├── tsconfig.vitest.json │ ├── tsconfig.json │ └── package.json ├── BilibiliRecorder │ ├── .gitignore │ ├── vitest.config.ts │ ├── tsconfig.vitest.json │ ├── src │ │ ├── test.ts │ │ └── blive-message-listener │ │ │ └── index.js │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── HuYaRecorder │ ├── .gitignore │ ├── src │ │ ├── requester.ts │ │ ├── types.ts │ │ └── test.ts │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── DouYinDanma │ ├── .gitignore │ ├── CHANGELOG.md │ ├── src │ │ ├── api.ts │ │ └── utils.ts │ ├── tsconfig.json │ ├── package.json │ ├── types │ │ └── index.d.ts │ └── README.md ├── DouYinRecorder │ ├── .gitignore │ ├── src │ │ ├── test.ts │ │ └── types.ts │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── shared │ ├── src │ │ ├── llm │ │ │ ├── index.ts │ │ │ └── ollama.ts │ │ ├── presets │ │ │ ├── index.ts │ │ │ └── videoPreset.ts │ │ ├── utils │ │ │ ├── combineURLs.ts │ │ │ ├── crypto.ts │ │ │ ├── fonts.ts │ │ │ ├── log.ts │ │ │ └── speedCalculator.ts │ │ ├── sync │ │ │ └── index.ts │ │ ├── task │ │ │ ├── core │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── AbstractTask.ts │ │ │ └── audiowaveform.ts │ │ ├── db │ │ │ ├── service │ │ │ │ ├── index.ts │ │ │ │ ├── virtualRecordService.ts │ │ │ │ ├── videoSubDataService.ts │ │ │ │ ├── statisticsService.ts │ │ │ │ ├── streamerService.ts │ │ │ │ ├── uploadPartService.ts │ │ │ │ ├── videoSubService.ts │ │ │ │ └── recordHistoryService.ts │ │ │ ├── model │ │ │ │ ├── virtualRecord.ts │ │ │ │ ├── streamer.ts │ │ │ │ ├── statistics.ts │ │ │ │ └── videoSubData.ts │ │ │ └── index.ts │ │ └── video │ │ │ └── kuaishou.ts │ ├── test-globals.js │ ├── vitest.config.ts │ ├── tsconfig.vitest.json │ ├── tsconfig.json │ ├── test │ │ ├── sync │ │ │ ├── baiduPCS.test.ts │ │ │ └── aliyunpan.test.ts │ │ └── danmu │ │ │ └── index.test.ts │ └── package.json ├── app │ ├── build │ │ ├── icon.ico │ │ ├── icon.png │ │ ├── icon.icns │ │ ├── icons │ │ │ ├── 16x16.png │ │ │ ├── 24x24.png │ │ │ ├── 32x32.png │ │ │ ├── 48x48.png │ │ │ ├── 64x64.png │ │ │ ├── icon.icns │ │ │ ├── icon.ico │ │ │ ├── 128x128.png │ │ │ ├── 256x256.png │ │ │ ├── 512x512.png │ │ │ └── 1024x1024.png │ │ └── entitlements.mac.plist │ ├── resources │ │ └── icon.png │ ├── src │ │ ├── main │ │ │ ├── utils │ │ │ │ ├── log.ts │ │ │ │ └── index.ts │ │ │ ├── handlers.ts │ │ │ ├── common.ts │ │ │ └── cookie.ts │ │ ├── renderer │ │ │ ├── src │ │ │ │ ├── assets │ │ │ │ │ ├── images │ │ │ │ │ │ ├── moehime.jpg │ │ │ │ │ │ └── gift-postion.png │ │ │ │ │ └── css │ │ │ │ │ │ └── styles.less │ │ │ │ ├── apis │ │ │ │ │ ├── presets │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── danmu.ts │ │ │ │ │ │ ├── video.ts │ │ │ │ │ │ └── ffmpeg.ts │ │ │ │ │ ├── llm.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── danma.ts │ │ │ │ │ ├── video.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ ├── sync.ts │ │ │ │ │ ├── request.ts │ │ │ │ │ └── recordHistory.ts │ │ │ │ ├── pages │ │ │ │ │ ├── Tools │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ └── pages │ │ │ │ │ │ │ └── VideoCut │ │ │ │ │ │ │ └── composables │ │ │ │ │ │ │ └── useVideoPlayer.ts │ │ │ │ │ ├── setting │ │ │ │ │ │ ├── CutSetting.vue │ │ │ │ │ │ ├── VideoSetting.vue │ │ │ │ │ │ ├── TaskSetting.vue │ │ │ │ │ │ └── OtherSetting.vue │ │ │ │ │ └── Main │ │ │ │ │ │ └── logSvg.vue │ │ │ │ ├── utils │ │ │ │ │ ├── eventBus.ts │ │ │ │ │ └── fileSystem.ts │ │ │ │ ├── env.d.ts │ │ │ │ ├── types │ │ │ │ │ └── index.d.ts │ │ │ │ ├── enums │ │ │ │ │ └── index.ts │ │ │ │ ├── main.ts │ │ │ │ ├── components │ │ │ │ │ ├── Tip.vue │ │ │ │ │ ├── showDirectoryDialog.ts │ │ │ │ │ ├── showInput.ts │ │ │ │ │ ├── EditableText.vue │ │ │ │ │ └── ButtonGroup.vue │ │ │ │ ├── hooks │ │ │ │ │ ├── drive.ts │ │ │ │ │ ├── danmuPreset.ts │ │ │ │ │ └── useNotice.ts │ │ │ │ └── App.vue │ │ │ └── index.html │ │ └── types │ │ │ ├── index.d.ts │ │ │ └── preload.d.ts │ ├── dev-app-update.yml │ ├── tsconfig.json │ ├── tsconfig.web.json │ ├── tsconfig.node.json │ ├── .eslintrc.cjs │ ├── electron.vite.config.ts │ ├── electron-builder-no-ffmpeg.yml │ └── electron-builder.yml ├── liveManager │ ├── vitest.config.ts │ ├── tsconfig.vitest.json │ ├── tsconfig.json │ ├── package.json │ ├── CHANGELOG.md │ ├── src │ │ ├── api.ts │ │ └── common.ts │ └── test │ │ └── record_extra_data_controller.test.ts ├── DouYuRecorder │ ├── src │ │ ├── requester.ts │ │ ├── test.ts │ │ └── dy_client │ │ │ └── stt.ts │ ├── tsconfig.json │ ├── package.json │ └── CHANGELOG.md ├── huya-danmu │ ├── CHANGELOG.md │ ├── utils.js │ ├── test.js │ ├── package.json │ └── index.d.ts ├── CLI │ ├── tsconfig.json │ ├── scripts │ │ ├── removeDev.js │ │ └── copy_module.js │ ├── rollup.config.js │ ├── README.md │ └── package.json └── types │ ├── src │ ├── task.ts │ └── preset.ts │ ├── tsconfig.json │ └── package.json ├── vitest.workspace.js ├── .eslintignore ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── .dockerignore ├── .prettierrc.yaml ├── .prettierignore ├── docker ├── Caddyfile ├── fullstack-Caddyfile ├── config.json ├── start.sh ├── docker-compose.yml └── docker-compose-fullstack.yml ├── .editorconfig ├── .gitignore ├── .npmrc ├── pnpm-workspace.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── 2-feature.yml │ └── 1-bug.yml └── workflows │ ├── npm.yml │ └── docs.yml ├── patches └── trash.patch ├── .eslintrc.cjs ├── TODO.md ├── scripts ├── github-ci-pnpm-update.js └── github-ci-better-sqlite3.js └── package.json /.node-version: -------------------------------------------------------------------------------- 1 | v24.10.0 -------------------------------------------------------------------------------- /docs/api/other.md: -------------------------------------------------------------------------------- 1 | **自己抓去吧** 2 | -------------------------------------------------------------------------------- /docs/features/ffmpeg.md: -------------------------------------------------------------------------------- 1 | 待施工 2 | -------------------------------------------------------------------------------- /packages/http/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/features/virtual-record.md: -------------------------------------------------------------------------------- 1 | 待施工 2 | -------------------------------------------------------------------------------- /vitest.workspace.js: -------------------------------------------------------------------------------- 1 | export default ["packages/*"]; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ -------------------------------------------------------------------------------- /packages/HuYaRecorder/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ -------------------------------------------------------------------------------- /packages/DouYinDanma/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | .history -------------------------------------------------------------------------------- /packages/DouYinRecorder/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | .history -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile 4 | .dockerignore 5 | .git -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: false 2 | semi: true 3 | printWidth: 100 4 | endOfLine: auto 5 | -------------------------------------------------------------------------------- /packages/shared/src/llm/index.ts: -------------------------------------------------------------------------------- 1 | import ollama from "./ollama.js"; 2 | 3 | export { ollama }; 4 | -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/webui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/docs/public/webui.png -------------------------------------------------------------------------------- /docs/public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/docs/public/preview.png -------------------------------------------------------------------------------- /packages/app/build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icon.ico -------------------------------------------------------------------------------- /packages/app/build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icon.png -------------------------------------------------------------------------------- /packages/app/build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icon.icns -------------------------------------------------------------------------------- /packages/app/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/resources/icon.png -------------------------------------------------------------------------------- /packages/app/build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/16x16.png -------------------------------------------------------------------------------- /packages/app/build/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/24x24.png -------------------------------------------------------------------------------- /packages/app/build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/32x32.png -------------------------------------------------------------------------------- /packages/app/build/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/48x48.png -------------------------------------------------------------------------------- /packages/app/build/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/64x64.png -------------------------------------------------------------------------------- /packages/app/build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/icon.icns -------------------------------------------------------------------------------- /packages/app/build/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/icon.ico -------------------------------------------------------------------------------- /packages/app/src/main/utils/log.ts: -------------------------------------------------------------------------------- 1 | import logger from "@biliLive-tools/shared/utils/log.js"; 2 | 3 | export default logger; 4 | -------------------------------------------------------------------------------- /packages/app/build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/128x128.png -------------------------------------------------------------------------------- /packages/app/build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/256x256.png -------------------------------------------------------------------------------- /packages/app/build/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/512x512.png -------------------------------------------------------------------------------- /packages/app/build/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/build/icons/1024x1024.png -------------------------------------------------------------------------------- /packages/http/test-globals.js: -------------------------------------------------------------------------------- 1 | // src/test-globals.ts 2 | export const setup = () => { 3 | process.env.TZ = "Asia/Shanghai"; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/shared/test-globals.js: -------------------------------------------------------------------------------- 1 | // src/test-globals.ts 2 | export const setup = () => { 3 | process.env.TZ = "Asia/Shanghai"; 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | components.d.ts 8 | auto-imports.d.ts -------------------------------------------------------------------------------- /packages/app/dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: generic 2 | url: https://example.com/auto-updates 3 | updaterCacheDirName: biliLive-tools-updater 4 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /packages/shared/src/presets/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ffmpegPreset.js"; 2 | export * from "./videoPreset.js"; 3 | export * from "./danmuPreset.js"; 4 | -------------------------------------------------------------------------------- /docker/Caddyfile: -------------------------------------------------------------------------------- 1 | :3000 { 2 | root * /app/public 3 | file_server 4 | encode gzip 5 | 6 | # 处理前端路由 7 | try_files {path} /index.html 8 | } -------------------------------------------------------------------------------- /packages/app/src/renderer/src/assets/images/moehime.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/src/renderer/src/assets/images/moehime.jpg -------------------------------------------------------------------------------- /packages/app/src/renderer/src/assets/images/gift-postion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renmu123/biliLive-tools/HEAD/packages/app/src/renderer/src/assets/images/gift-postion.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/presets/index.ts: -------------------------------------------------------------------------------- 1 | import danmuPresetApi from "./danmu"; 2 | import ffmpegPresetApi from "./ffmpeg"; 3 | import videoPresetApi from "./video"; 4 | 5 | export { danmuPresetApi, ffmpegPresetApi, videoPresetApi }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | *.log* 5 | bin 6 | cookies.json 7 | tsconfig.tsbuildinfo 8 | lib 9 | 10 | *.db 11 | *.db-wal 12 | *.db-shm 13 | packages/CLI/config.json 14 | 15 | docs/.vitepress/dist 16 | docs/.vitepress/cache 17 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/Tools/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/liveManager/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | typecheck: { 6 | tsconfig: "tsconfig.vitest.json", 7 | }, 8 | globals: true, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | typecheck: { 6 | tsconfig: "tsconfig.vitest.json", 7 | }, 8 | globals: true, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "docs:dev": "vitepress dev --port 7170", 4 | "docs:build": "vitepress build", 5 | "docs:preview": "vitepress preview" 6 | }, 7 | "devDependencies": { 8 | "vitepress": "2.0.0-alpha.12" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/llm.ts: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | 3 | export const getModelList = async (baseUrl: string): Promise => { 4 | return request.get(`/llm/ollama/modelList`, { 5 | params: { 6 | baseUrl, 7 | }, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/shared/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | typecheck: { 6 | tsconfig: "tsconfig.vitest.json", 7 | }, 8 | globalSetup: "./test-globals.js", 9 | globals: true, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ 2 | node_sqlite3_binary_host_mirror=https://npmmirror.com/mirrors 3 | node-linker="hoisted" 4 | FFMPEG_BINARIES_URL=https://github.com/renmu123/ffmpeg-static/releases/download 5 | FFPROBE_BINARIES_URL=https://github.com/renmu123/ffmpeg-static/releases/download -------------------------------------------------------------------------------- /packages/DouYuRecorder/src/requester.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const requester = axios.create({ 4 | timeout: 10e3, 5 | // axios 会自动读取环境变量中的 http_proxy 和 https_proxy 并应用, 6 | // 但会导致请求报错 "Client network socket disconnected before secure TLS connection was established"。 7 | proxy: false, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/HuYaRecorder/src/requester.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const requester = axios.create({ 4 | timeout: 10e3, 5 | // axios 会自动读取环境变量中的 http_proxy 和 https_proxy 并应用, 6 | // 但会导致请求报错 "Client network socket disconnected before secure TLS connection was established"。 7 | proxy: false, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/app/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { biliApi } from "../main/bili"; 2 | 3 | import type { OpenDialogOptions as ElectronOpenDialogOptions } from "electron"; 4 | 5 | export type BiliApi = typeof biliApi; 6 | 7 | export interface OpenDialogOptions extends ElectronOpenDialogOptions { 8 | multi?: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/utils/eventBus.ts: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | 3 | type Events = { 4 | "open-setting-dialog": { 5 | tab?: "webhook"; 6 | extra?: { 7 | // webhook的房间号 8 | roomId?: string; 9 | }; 10 | }; 11 | }; 12 | 13 | const eventBus = mitt(); 14 | 15 | export default eventBus; 16 | -------------------------------------------------------------------------------- /packages/huya-danmu/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.1.4 2 | 3 | 初始化支持其他参数,来跳过默认的初始化查询 4 | 5 | ``` 6 | { 7 | roomid: string; 8 | uid?: number; 9 | subChannelId?: number; 10 | channelId?: number; 11 | } 12 | ``` 13 | 14 | # 0.1.3 15 | 16 | - 优化重试函数 17 | 18 | # 0.1.2 19 | 20 | - 为初始化请求添加重试 21 | 22 | # 0.1.1 23 | 24 | - 增加错误连接重试,默认为10次 25 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /packages/huya-danmu/utils.js: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | export const md5 = (str) => { 4 | return crypto.createHash("md5").update(str).digest("hex"); 5 | }; 6 | 7 | export function intToHexColor(int) { 8 | // 将整数转换为十六进制字符串,并确保其长度为 6 位 9 | const hex = int.toString(16).padStart(6, "0"); 10 | return `#${hex}`; 11 | } 12 | -------------------------------------------------------------------------------- /packages/http/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | typecheck: { 6 | tsconfig: "tsconfig.vitest.json", 7 | }, 8 | globalSetup: "./test-globals.js", 9 | coverage: { 10 | provider: "istanbul", // or 'v8' 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/DouYinDanma/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.1 2 | 3 | - 增加 `reconnectInterval` 重连等待时间,默认10秒 4 | 5 | # 0.2.1 6 | 7 | - `GiftMessage` 增加 `repeatEnd` 返回参数 8 | 9 | # 0.2.0 10 | 11 | - 增加 `timeoutInterval` 重试参数,默认100秒,在没有数据返回但ws未被关闭时超时后重新连接 12 | - 心跳包默认为10秒 13 | 14 | # 0.1.2 15 | 16 | - `chat` 增加 `eventTime` 返回参数 17 | 18 | # 0.1.1 19 | 20 | - 默认重试修改为10 21 | -------------------------------------------------------------------------------- /docker/fullstack-Caddyfile: -------------------------------------------------------------------------------- 1 | :3000 { 2 | # API 请求代理到后端 3 | handle_path /api* { 4 | reverse_proxy localhost:18010 5 | } 6 | 7 | # 静态文件服务 8 | handle { 9 | root * /app/public 10 | file_server 11 | encode gzip 12 | 13 | # 处理前端路由 14 | try_files {path} /index.html 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/app/src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | biliLive-tools 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/CLI/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "ESNext", 7 | "esModuleInterop": true, 8 | "declaration": false, 9 | "resolveJsonModule": true, 10 | "module": "ES2022", 11 | "moduleResolution": "bundler", 12 | }, 13 | "include": ["src/**/*.ts"] 14 | } -------------------------------------------------------------------------------- /packages/http/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "es2023", 7 | "module": "Node16", 8 | "moduleResolution": "Bundler", 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "declaration": false, 12 | }, 13 | "include": ["test/**/*.ts", "src/**/*.ts"], 14 | } -------------------------------------------------------------------------------- /packages/shared/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "es2023", 7 | "module": "Node16", 8 | "moduleResolution": "Bundler", 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "declaration": false, 12 | }, 13 | "include": ["test/**/*.ts", "src/**/*.ts"], 14 | } -------------------------------------------------------------------------------- /packages/liveManager/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "es2023", 7 | "module": "Node16", 8 | "moduleResolution": "Bundler", 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "declaration": false, 12 | }, 13 | "include": ["test/**/*.ts", "src/**/*.ts"], 14 | } -------------------------------------------------------------------------------- /packages/BilibiliRecorder/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "es2023", 7 | "module": "Node16", 8 | "moduleResolution": "Bundler", 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "declaration": false, 12 | }, 13 | "include": ["test/**/*.ts", "src/**/*.ts"], 14 | } -------------------------------------------------------------------------------- /packages/app/src/types/preload.d.ts: -------------------------------------------------------------------------------- 1 | import path from "path-unified"; 2 | 3 | import { ElectronAPI } from "@electron-toolkit/preload"; 4 | import { api } from "../preload/index"; 5 | 6 | declare global { 7 | interface Window { 8 | electron: ElectronAPI; 9 | api: typeof api; 10 | path: typeof path; 11 | isWeb: boolean; 12 | isFullstack: boolean; 13 | __APP_VERSION__: string; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docker/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 18010, 3 | "host": "0.0.0.0", 4 | "configFolder": "/app/data", 5 | "ffmpegPath": "/app/bin/ffmpeg", 6 | "ffprobePath": "/app/bin/ffprobe", 7 | "mesioPath": "/app/bin/mesio", 8 | "bililiveRecorderPath": "/app/bin/BililiveRecorder.Cli", 9 | "audiowaveformPath": "audiowaveform", 10 | "danmakuFactoryPath": "/app/bin/DanmakuFactory", 11 | "logPath": "/app/data/main.log" 12 | } 13 | -------------------------------------------------------------------------------- /packages/types/src/task.ts: -------------------------------------------------------------------------------- 1 | export type Status = "pending" | "running" | "paused" | "completed" | "error" | "canceled"; 2 | 3 | export interface DanmaOptions { 4 | // 1:保存到原始文件夹,2:保存到特定文件夹 5 | saveRadio: 1 | 2; 6 | savePath: string; 7 | // 完成后移除源文件 8 | removeOrigin?: boolean; 9 | // 复制源文件到临时文件夹 10 | copyInput?: boolean; 11 | // 生成到临时文件夹 12 | temp?: boolean; 13 | // 覆盖已存在的文件 14 | override?: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | # all packages in direct subdirs of packages/ 3 | - "packages/*" 4 | # all packages in subdirs of components/ 5 | - "components/**" 6 | # exclude packages that are inside test directories 7 | - "!**/test/**" 8 | 9 | # 定义目录和依赖版本号 10 | catalog: 11 | lodash-es: ^4.17.21 12 | axios: ^1.7.8 13 | better-sqlite3: 12.4.6 14 | font-ls: 0.6.2 15 | ntsuspend: ^1.0.2 16 | mitt: ^3.0.1 17 | -------------------------------------------------------------------------------- /packages/HuYaRecorder/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface StreamResult { 2 | flv: SourceProfile[]; 3 | hls: SourceProfile[]; 4 | } 5 | 6 | export interface StreamProfile { 7 | desc: string; 8 | bitRate: number; 9 | } 10 | 11 | export interface SourceProfile { 12 | name: string; 13 | url: string; 14 | streamName: string; 15 | presenterUid: number; 16 | subChannelId: number; 17 | channelId: number; 18 | suffix: string; 19 | } 20 | -------------------------------------------------------------------------------- /packages/http/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "module": "NodeNext", 7 | "target": "ESNext", 8 | "moduleResolution": "NodeNext", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "strictNullChecks": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["node_modules"] 16 | } -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "es2023", 7 | "module": "Node16", 8 | "moduleResolution": "Node16", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "strictNullChecks": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["node_modules"] 16 | } -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "composite": true, 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "target": "es2023", 7 | "module": "Node16", 8 | "moduleResolution": "Node16", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "strictNullChecks": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["node_modules"] 16 | } -------------------------------------------------------------------------------- /packages/http/src/middleware/error.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from "koa"; 2 | import log from "@biliLive-tools/shared/utils/log.js"; 3 | 4 | const errorMiddleware = async (ctx: Context, next: Next) => { 5 | try { 6 | await next(); 7 | } catch (error: any) { 8 | ctx.status = 500; 9 | ctx.body = error.message; 10 | ctx.app.emit("error", error, ctx); 11 | log.error(error); 12 | } 13 | }; 14 | export default errorMiddleware; 15 | -------------------------------------------------------------------------------- /packages/shared/src/utils/combineURLs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 将 Base URL 和 Relative URL 合并成一个新的 URL 3 | * 4 | * @param baseURL 基础 URL(例如:https://www.bilibili.com/) 5 | * @param relativeURL 相对 URL(例如:/video/av1) 6 | * @returns 合并后的 URL(例如:https://www.bilibili.com/video/av1) 7 | */ 8 | export const combineURLs = (baseURL: string, relativeURL: string): string => { 9 | return relativeURL 10 | ? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '') 11 | : baseURL; 12 | }; -------------------------------------------------------------------------------- /packages/DouYinDanma/src/api.ts: -------------------------------------------------------------------------------- 1 | export const getCookie = async () => { 2 | const res = await fetch("https://live.douyin.com/", { 3 | method: "GET", 4 | }); 5 | 6 | if (!res.headers.get("set-cookie")) { 7 | throw new Error("No cookie in response"); 8 | } 9 | 10 | const cookies = (res.headers.get("set-cookie") ?? "") 11 | .split(", ") 12 | .map((cookie) => { 13 | return cookie.split(";")[0]; 14 | }) 15 | .join("; "); 16 | 17 | return cookies; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/app/build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/HuYaRecorder/src/test.ts: -------------------------------------------------------------------------------- 1 | // execute in shell `ts-node src/test.ts` to run test 2 | // TODO: add to scripts 3 | import { createRecorderManager } from "@bililive-tools/manager"; 4 | import { provider } from "./index.js"; 5 | 6 | const manager = createRecorderManager({ providers: [provider] }); 7 | manager.addRecorder({ 8 | providerId: provider.id, 9 | channelId: "660000", 10 | quality: "low", 11 | streamPriorities: [], 12 | sourcePriorities: [], 13 | }); 14 | manager.startCheckLoop(); 15 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/src/test.ts: -------------------------------------------------------------------------------- 1 | // execute in shell `ts-node src/test.ts` to run test 2 | // TODO: add to scripts 3 | import { createRecorderManager } from "@bililive-tools/manager"; 4 | import { provider } from "./index.js"; 5 | 6 | const manager = createRecorderManager({ providers: [provider] }); 7 | manager.addRecorder({ 8 | providerId: provider.id, 9 | channelId: "7734200", 10 | quality: "low", 11 | streamPriorities: [], 12 | sourcePriorities: [], 13 | }); 14 | manager.startCheckLoop(); 15 | -------------------------------------------------------------------------------- /packages/DouYuRecorder/src/test.ts: -------------------------------------------------------------------------------- 1 | // execute in shell `ts-node src/test.ts` to run test 2 | // TODO: add to scripts 3 | import { createRecorderManager } from "@bililive-tools/manager"; 4 | import { provider } from "./index.js"; 5 | 6 | const manager = createRecorderManager({ providers: [provider] }); 7 | manager.addRecorder({ 8 | providerId: provider.id, 9 | channelId: "74751", 10 | quality: "highest", 11 | streamPriorities: [], 12 | sourcePriorities: [], 13 | }); 14 | manager.startCheckLoop(); 15 | -------------------------------------------------------------------------------- /packages/DouYinRecorder/src/test.ts: -------------------------------------------------------------------------------- 1 | // execute in shell `ts-node src/test.ts` to run test 2 | // TODO: add to scripts 3 | import { createRecorderManager } from "@bililive-tools/manager"; 4 | import { provider } from "./index.js"; 5 | 6 | const manager = createRecorderManager({ providers: [provider] }); 7 | manager.addRecorder({ 8 | providerId: provider.id, 9 | channelId: "317590520822", 10 | quality: "medium", 11 | streamPriorities: [], 12 | sourcePriorities: [], 13 | }); 14 | manager.startCheckLoop(); 15 | -------------------------------------------------------------------------------- /packages/app/src/main/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { IpcMainInvokeEvent, WebContents } from "electron"; 2 | 3 | export const notify = ( 4 | sender: WebContents, 5 | data: { 6 | type: "info" | "success" | "warning" | "error"; 7 | content: string; 8 | }, 9 | ) => { 10 | sender.send("notify", data); 11 | }; 12 | 13 | export const invokeWrap = any>(fn: T) => { 14 | return (_event: IpcMainInvokeEvent, ...args: Parameters): ReturnType => { 15 | return fn(...args); 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 启动后端服务 4 | echo "Starting backend service..." 5 | cd /app/backend 6 | node index.cjs server --config config.json & 7 | 8 | BACKEND_PID=$! 9 | 10 | # 等待后端启动 11 | echo "Waiting for backend to start..." 12 | sleep 3 13 | 14 | # 检查后端是否成功启动 15 | if ! kill -0 $BACKEND_PID 2>/dev/null; then 16 | echo "Backend failed to start" 17 | exit 1 18 | fi 19 | 20 | echo "Backend started successfully (PID: $BACKEND_PID)" 21 | 22 | # 启动 Caddy 23 | echo "Starting Caddy server..." 24 | caddy run --config /etc/caddy/Caddyfile 25 | -------------------------------------------------------------------------------- /packages/shared/src/sync/index.ts: -------------------------------------------------------------------------------- 1 | import { BaiduPCS } from "./baiduPCS.js"; 2 | import { AliyunPan } from "./aliyunpan.js"; 3 | import { Alist } from "./alist.js"; 4 | import { LocalCopy } from "./localCopy.js"; 5 | import { Pan123 } from "./pan123.js"; 6 | 7 | export { BaiduPCS } from "./baiduPCS.js"; 8 | export { AliyunPan } from "./aliyunpan.js"; 9 | export { Alist } from "./alist.js"; 10 | export { LocalCopy } from "./localCopy.js"; 11 | export { Pan123 } from "./pan123.js"; 12 | 13 | export type SyncClient = BaiduPCS | AliyunPan | Alist | LocalCopy | Pan123; 14 | -------------------------------------------------------------------------------- /packages/CLI/scripts/removeDev.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | 7 | function main() { 8 | // 移除package.json中的devDependencies 9 | const packageJsonPath = path.resolve(__dirname, "../package.json"); 10 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); 11 | delete packageJson.devDependencies; 12 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); 13 | } 14 | 15 | main(); 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature.yml: -------------------------------------------------------------------------------- 1 | name: ✨ 功能请求 2 | description: 提出一个新功能请求 3 | body: 4 | - type: checkboxes 5 | id: initial-checklist 6 | attributes: 7 | label: 我已尝试以下操作 8 | options: 9 | - label: 尝试使用 [最新版本](https://github.com/renmu123/biliLive-tools/releases) 10 | required: true 11 | - label: 查看是否有人已经提出过类似的功能请求 12 | required: true 13 | - type: textarea 14 | id: feature-description 15 | attributes: 16 | label: 描述 17 | description: 清晰简洁地描述您希望添加或改进的内容。如果需要,您可以附加屏幕截图或截屏视频。 18 | validations: 19 | required: true 20 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Status } from "@biliLive-tools/types"; 2 | import { TaskType } from "@biliLive-tools/shared/enum.js"; 3 | 4 | export interface Task { 5 | pid?: string; 6 | taskId: string; 7 | name: string; 8 | status: Status; 9 | type: TaskType; 10 | output?: any; 11 | progress: number; 12 | action: ("pause" | "kill" | "interrupt" | "restart")[]; 13 | startTime?: number; 14 | endTime?: number; 15 | custsomProgressMsg?: string; 16 | error?: string; 17 | children?: Task[]; 18 | duration: number; 19 | extra?: Record; 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/renderer/src/env.d.ts", 5 | "src/renderer/*.d.ts", 6 | "src/renderer/src/**/*", 7 | "src/renderer/src/**/*.vue", 8 | "src/types/*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "module": "ES2022", 13 | "moduleResolution": "bundler", 14 | "baseUrl": ".", 15 | "paths": { 16 | "@renderer/*": [ 17 | "src/renderer/src/*" 18 | ], 19 | "@types/*": [ 20 | "src/types/*" 21 | ], 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/shared/src/llm/ollama.ts: -------------------------------------------------------------------------------- 1 | // import { appConfig } from "../index.js"; 2 | 3 | import { Ollama } from "ollama"; 4 | 5 | function getModelList(host: string) { 6 | const ollama = new Ollama({ host }); 7 | return ollama.list(); 8 | } 9 | 10 | function chat(params: { model: string; messages: any[]; options: any }) { 11 | const host = "http://localhost:1300"; 12 | const ollama = new Ollama({ host: host }); 13 | return ollama.chat({ 14 | model: params.model, 15 | messages: params.messages, 16 | options: params.options, 17 | }); 18 | } 19 | 20 | export default { 21 | getModelList, 22 | chat, 23 | }; 24 | -------------------------------------------------------------------------------- /patches/trash.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/chunked-exec.js b/lib/chunked-exec.js 2 | index 9004c2fbfc36e965661a92483c46167e1db5606b..74e28fda835e4bc7d3ec04b682fe5747d9f2e2c2 100644 3 | --- a/lib/chunked-exec.js 4 | +++ b/lib/chunked-exec.js 5 | @@ -8,6 +8,6 @@ const pExecFile = promisify(execFile); 6 | export default async function chunkedExec(binary, paths, maxPaths) { 7 | for (const chunk of chunkify(paths, maxPaths)) { 8 | // eslint-disable-next-line no-await-in-loop 9 | - await pExecFile(fileURLToPath(binary), chunk); 10 | + await pExecFile(fileURLToPath(binary).replace("app.asar", "app.asar.unpacked"), chunk); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/shared/src/task/core/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Task Core Module 3 | * 提供任务系统的核心抽象类、任务队列和辅助函数 4 | */ 5 | 6 | // 导出类型定义 7 | export type { TaskEvents } from "./types.js"; 8 | 9 | // 导出抽象基类 10 | export { AbstractTask } from "./AbstractTask.js"; 11 | 12 | // 导出任务队列 13 | export { TaskQueue } from "./TaskQueue.js"; 14 | 15 | // 导出任务队列实例和辅助函数 16 | export { 17 | taskQueue, 18 | sendTaskNotify, 19 | handlePauseTask, 20 | handleResumeTask, 21 | handleKillTask, 22 | hanldeInterruptTask, 23 | handleListTask, 24 | handleQueryTask, 25 | handleStartTask, 26 | handleRemoveTask, 27 | handleRestartTask, 28 | } from "./taskHelpers.js"; 29 | -------------------------------------------------------------------------------- /packages/app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["electron.vite.config.*", "src/main/**/*", "src/core/**/*","src/utils/**/*", "src/preload/*","src/types/*", "test/index.test.ts"], 4 | "compilerOptions": { 5 | "esModuleInterop": true, 6 | "resolveJsonModule": true, 7 | "module": "ES2022", 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "noEmit": true, 11 | "types": ["electron-vite/node"], 12 | "baseUrl": ".", 13 | "paths": { 14 | "@types/*": [ 15 | "src/types/*" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/index.ts: -------------------------------------------------------------------------------- 1 | import StatisticsService from "./statisticsService.js"; 2 | import RecordHistoryService from "./recordHistoryService.js"; 3 | import VirtualRecordService from "./virtualRecordService.js"; 4 | import VideoSubDataService from "./videoSubDataService.js"; 5 | import StreamerService from "./streamerService.js"; 6 | import VideoSubService from "./videoSubService.js"; 7 | import UploadPartService from "./uploadPartService.js"; 8 | 9 | export { 10 | StatisticsService, 11 | RecordHistoryService, 12 | VirtualRecordService, 13 | VideoSubDataService, 14 | StreamerService, 15 | VideoSubService, 16 | UploadPartService, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/virtualRecordService.ts: -------------------------------------------------------------------------------- 1 | import type VirtualRecordModel from "../model/virtualRecord.js"; 2 | 3 | export default class VirtualRecordService { 4 | private virtualRecordModel: VirtualRecordModel; 5 | 6 | constructor({ virtualRecordModel }: { virtualRecordModel: VirtualRecordModel }) { 7 | this.virtualRecordModel = virtualRecordModel; 8 | } 9 | 10 | add({ path }: { path: string }) { 11 | return this.virtualRecordModel.add({ path }); 12 | } 13 | 14 | list() { 15 | return this.virtualRecordModel.list({}); 16 | } 17 | 18 | delete(id: number) { 19 | return this.virtualRecordModel.deleteById(id); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # UI镜像 3 | webui: 4 | image: renmu1234/bililive-tools-frontend 5 | ports: 6 | # 前者按需改动 7 | - "3000:3000" 8 | # 接口镜像 9 | api: 10 | image: renmu1234/bililive-tools-backend 11 | ports: 12 | - "18010:18010" 13 | volumes: 14 | # 映射的配置目录,用于持久化配置文件 15 | - ./data:/app/data 16 | # 存储文件的默认目录 17 | - ./video:/app/video 18 | # 字体目录 19 | - ./fonts:/usr/local/share/fonts 20 | environment: 21 | # 登录密钥 22 | - BILILIVE_TOOLS_PASSKEY=your_passkey 23 | # 账户加密密钥 24 | - BILILIVE_TOOLS_BILIKEY=your_bilikey 25 | # 中国时区 26 | - TZ=Asia/Shanghai 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "typescript.tsdk": "node_modules\\typescript\\lib", 12 | // allow autocomplete for ArkType expressions like "string | num" 13 | "editor.quickSuggestions": { 14 | "strings": "on" 15 | }, 16 | // prioritize ArkType's "type" for autoimports 17 | "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"], 18 | "github-actions.workflows.pinned.workflows": [] 19 | } 20 | -------------------------------------------------------------------------------- /packages/liveManager/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "strict": true, 7 | "sourceMap": false, 8 | "strictNullChecks": true, 9 | "strictPropertyInitialization": false, 10 | "moduleResolution": "Node16", 11 | "module": "Node16", 12 | "esModuleInterop": true, 13 | "removeComments": false, 14 | "noUnusedParameters": false, 15 | "noUnusedLocals": false, 16 | "noImplicitThis": false, 17 | "noImplicitAny": false, 18 | "noImplicitReturns": false, 19 | "declaration": true, 20 | "skipLibCheck": true, 21 | }, 22 | "include": ["src/**/*.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:vue/vue3-recommended", 8 | "@electron-toolkit", 9 | "@electron-toolkit/eslint-config-ts/eslint-recommended", 10 | "@vue/eslint-config-typescript/recommended", 11 | "@vue/eslint-config-prettier", 12 | ], 13 | rules: { 14 | "vue/require-default-prop": "off", 15 | "vue/multi-word-component-names": "off", 16 | "@typescript-eslint/no-non-null-assertion": "off", 17 | "@typescript-eslint/ban-ts-comment": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/index.ts: -------------------------------------------------------------------------------- 1 | import userApi from "./user"; 2 | export * from "./presets"; 3 | import recoderApi from "./recoder"; 4 | import configApi from "./config"; 5 | import taskApi from "./task"; 6 | import commonApi from "./common"; 7 | import biliApi from "./bili"; 8 | import api from "./request"; 9 | import videoApi from "./video"; 10 | import recordHistoryApi from "./recordHistory"; 11 | import danmaApi from "./danma"; 12 | 13 | import syncApi from "./sync"; 14 | 15 | export { 16 | userApi, 17 | recoderApi, 18 | configApi, 19 | taskApi, 20 | commonApi, 21 | biliApi, 22 | api, 23 | videoApi, 24 | syncApi, 25 | recordHistoryApi, 26 | danmaApi, 27 | }; 28 | -------------------------------------------------------------------------------- /packages/shared/src/task/core/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 任务事件类型定义 3 | */ 4 | export interface TaskEvents { 5 | "task-start": ({ taskId }: { taskId: string }) => void; 6 | "task-end": ({ taskId }: { taskId: string }) => void; 7 | "task-error": ({ taskId, error }: { taskId: string; error: string }) => void; 8 | "task-progress": ({ taskId }: { taskId: string }) => void; 9 | "task-pause": ({ taskId }: { taskId: string }) => void; 10 | "task-resume": ({ taskId }: { taskId: string }) => void; 11 | "task-cancel": ({ taskId }: { taskId: string; autoStart: boolean }) => void; 12 | "task-removed-queue": ({ taskId }: { taskId: string }) => void; 13 | [key: string]: (...args: any[]) => void; 14 | } 15 | -------------------------------------------------------------------------------- /packages/app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:vue/vue3-recommended", 8 | "@electron-toolkit", 9 | "@electron-toolkit/eslint-config-ts/eslint-recommended", 10 | "@vue/eslint-config-typescript/recommended", 11 | "@vue/eslint-config-prettier", 12 | ], 13 | rules: { 14 | "vue/require-default-prop": "off", 15 | "vue/multi-word-component-names": "off", 16 | "@typescript-eslint/no-non-null-assertion": "off", 17 | "@typescript-eslint/ban-ts-comment": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/HuYaRecorder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "Node16", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | // "strict": true, 8 | "sourceMap": false, 9 | // "strictNullChecks": true, 10 | // "strictPropertyInitialization": false, 11 | "moduleResolution": "Node16", 12 | "esModuleInterop": true, 13 | // "removeComments": false, 14 | // "noUnusedParameters": false, 15 | // "noUnusedLocals": false, 16 | // "noImplicitThis": false, 17 | // "noImplicitAny": true, 18 | // "noImplicitReturns": false, 19 | "skipLibCheck": true, 20 | "declaration": true 21 | }, 22 | "include": ["src/**/*.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/DouYuRecorder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "Node16", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | // "strict": true, 8 | "sourceMap": false, 9 | // "strictNullChecks": true, 10 | // "strictPropertyInitialization": false, 11 | "moduleResolution": "Node16", 12 | "esModuleInterop": true, 13 | // "removeComments": false, 14 | // "noUnusedParameters": false, 15 | // "noUnusedLocals": false, 16 | // "noImplicitThis": false, 17 | // "noImplicitAny": true, 18 | // "noImplicitReturns": false, 19 | "skipLibCheck": true, 20 | "declaration": true 21 | }, 22 | "include": ["src/**/*.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/CLI/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import json from "@rollup/plugin-json"; 5 | 6 | export default [ 7 | { 8 | external: ["ntsuspend", "@napi-rs/canvas", "font-ls", "better-sqlite3"], 9 | input: "src/index.ts", 10 | output: [ 11 | { 12 | dir: "lib", 13 | format: "cjs", 14 | entryFileNames: "[name].cjs", 15 | chunkFileNames: "[name]-[hash].cjs", 16 | }, 17 | ], 18 | // inlineDynamicImports: true, 19 | plugins: [typescript(), nodeResolve({ browser: false }), commonjs(), json()], 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /docker/docker-compose-fullstack.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # 全栈镜像 - 包含前端和后端 3 | fullstack: 4 | image: renmu1234/bililive-tools-fullstack 5 | # 如果需要本地构建,取消下面的注释 6 | # build: 7 | # context: .. 8 | # dockerfile: docker/fullstack-dockerfile 9 | ports: 10 | # 只需要暴露一个端口 11 | - "3000:3000" 12 | volumes: 13 | # 映射的配置目录,用于持久化配置文件 14 | - ./data:/app/data 15 | # 存储文件的默认目录 16 | - ./video:/app/video 17 | # 字体目录 18 | - ./fonts:/usr/share/fonts 19 | environment: 20 | # 登录密钥 21 | - BILILIVE_TOOLS_PASSKEY=your_passkey 22 | # 账户加密密钥 23 | - BILILIVE_TOOLS_BILIKEY=your_bilikey 24 | # 中国时区 25 | - TZ=Asia/Shanghai 26 | restart: unless-stopped 27 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/danma.ts: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | 3 | export const mergeXml = async ( 4 | inputFiles: { videoPath: string; danmakuPath: string }[], 5 | options: { 6 | output?: string; 7 | saveOriginPath: boolean; 8 | saveMeta?: boolean; 9 | }, 10 | ) => { 11 | const res = await request.post("/danma/mergeXml", { 12 | inputFiles, 13 | options, 14 | }); 15 | return res.data; 16 | }; 17 | 18 | export const parseForArtPlayer = async (filepath: string) => { 19 | const res = await request.post("/danma/parseForArtPlayer", { 20 | filepath, 21 | }); 22 | return res.data; 23 | }; 24 | 25 | const danma = { 26 | mergeXml, 27 | parseForArtPlayer, 28 | }; 29 | 30 | export default danma; 31 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/videoSubDataService.ts: -------------------------------------------------------------------------------- 1 | import type VideoSubDataModel from "../model/videoSubData.js"; 2 | import type { BaseVideoSubData } from "../model/videoSubData.js"; 3 | 4 | export default class VideoSubDataService { 5 | private videoSubDataModel: VideoSubDataModel; 6 | 7 | constructor({ videoSubDataModel }: { videoSubDataModel: VideoSubDataModel }) { 8 | this.videoSubDataModel = videoSubDataModel; 9 | } 10 | 11 | add(options: BaseVideoSubData) { 12 | return this.videoSubDataModel.add(options); 13 | } 14 | 15 | list(options: { platform: "douyu" | "huya"; subId: string }) { 16 | return this.videoSubDataModel.list(options); 17 | } 18 | 19 | delete(id: number) { 20 | return this.videoSubDataModel.deleteById(id); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/huya-danmu/test.js: -------------------------------------------------------------------------------- 1 | import huya_danmu from "./index.js"; 2 | 3 | const roomid = "910323"; 4 | const client = new huya_danmu(roomid); 5 | 6 | client.on("connect", () => { 7 | console.log(`已连接huya ${roomid}房间弹幕~`); 8 | }); 9 | 10 | client.on("message", (msg) => { 11 | switch (msg.type) { 12 | case "chat": 13 | console.log(`[${msg.from.name}]:${msg.content}`); 14 | break; 15 | case "gift": 16 | console.log(`[${msg.from.name}]->赠送${msg.count}个${msg.name}`); 17 | break; 18 | case "online": 19 | console.log(`[当前人气]:${msg.count}`); 20 | break; 21 | } 22 | }); 23 | 24 | client.on("error", (e) => { 25 | console.log(e); 26 | }); 27 | 28 | client.on("close", () => { 29 | console.log("close"); 30 | }); 31 | 32 | client.start(); 33 | -------------------------------------------------------------------------------- /packages/DouYinRecorder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "Node16", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | // "strict": true, 8 | "sourceMap": false, 9 | // "strictNullChecks": true, 10 | // "strictPropertyInitialization": false, 11 | "moduleResolution": "Node16", 12 | "esModuleInterop": true, 13 | // "removeComments": false, 14 | // "noUnusedParameters": false, 15 | // "noUnusedLocals": false, 16 | // "noImplicitThis": false, 17 | // "noImplicitAny": true, 18 | // "noImplicitReturns": false, 19 | "declaration": true, 20 | "skipLibCheck": true, 21 | "allowJs": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.js"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/huya-danmu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huya-danma-listener", 3 | "version": "0.1.4", 4 | "description": "huya danmu module", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "type": "module", 8 | "scripts": { 9 | "test": "" 10 | }, 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "keywords": [ 15 | "huya", 16 | "danmu" 17 | ], 18 | "author": "BacooTang", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/renmu123/biliLive-tools/tree/master/packages/huya-danmu" 22 | }, 23 | "dependencies": { 24 | "socks-proxy-agent": "^3.0.1", 25 | "to-arraybuffer": "^1.0.1", 26 | "ws": "^4.1.0" 27 | }, 28 | "homepage": "https://github.com/renmu123/biliLive-tools/tree/master/packages/huya-danmu" 29 | } 30 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "Node16", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | // "strict": true, 8 | "sourceMap": false, 9 | // "strictNullChecks": true, 10 | // "strictPropertyInitialization": false, 11 | "moduleResolution": "Node16", 12 | "esModuleInterop": true, 13 | // "removeComments": false, 14 | // "noUnusedParameters": false, 15 | // "noUnusedLocals": false, 16 | // "noImplicitThis": false, 17 | // "noImplicitAny": true, 18 | // "noImplicitReturns": false, 19 | "declaration": true, 20 | "skipLibCheck": true, 21 | "allowJs": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.js"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/DouYinDanma/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "Node16", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | // "strict": true, 8 | "sourceMap": false, 9 | // "strictNullChecks": true, 10 | // "strictPropertyInitialization": false, 11 | "moduleResolution": "Node16", 12 | "esModuleInterop": true, 13 | // "removeComments": false, 14 | // "noUnusedParameters": false, 15 | // "noUnusedLocals": false, 16 | // "noImplicitThis": false, 17 | // "noImplicitAny": true, 18 | // "noImplicitReturns": false, 19 | "declaration": false, 20 | "skipLibCheck": true, 21 | "allowJs": true 22 | }, 23 | "include": ["src/**/*.ts", "types/**/*.d.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@biliLive-tools/types", 3 | "version": "3.5.1", 4 | "type": "module", 5 | "description": "", 6 | "main": "./lib/index.js", 7 | "author": "renmu123", 8 | "license": "GPL-3.0", 9 | "homepage": "https://github.com/renmu123/biliLive-tools", 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "development": "./src/index.ts", 14 | "default": "./lib/index.js" 15 | }, 16 | "./*.js": { 17 | "types": "./src/*.ts", 18 | "development": "./src/*.ts", 19 | "default": "./lib/*.js" 20 | } 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "dev": "tsc -w", 25 | "start:dev": "tsx src/index.ts", 26 | "typecheck": "tsc --noEmit -p tsconfig.json --composite false", 27 | "test": "vitest run" 28 | }, 29 | "keywords": [] 30 | } 31 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export const uploadTitleTemplate = [ 2 | { 3 | value: "{{title}}", 4 | label: "直播标题", 5 | }, 6 | { 7 | value: "{{user}}", 8 | label: "主播名", 9 | }, 10 | { 11 | value: "{{roomId}}", 12 | label: "房间号", 13 | }, 14 | { 15 | value: "{{filename}}", 16 | label: "视频文件名", 17 | }, 18 | { 19 | value: "{{now}}", 20 | label: "视频录制时间(示例:2024.01.24)", 21 | }, 22 | { 23 | value: "{{yyyy}}", 24 | label: "年", 25 | }, 26 | { 27 | value: "{{MM}}", 28 | label: "月(补零)", 29 | }, 30 | { 31 | value: "{{dd}}", 32 | label: "日(补零)", 33 | }, 34 | { 35 | value: "{{HH}}", 36 | label: "时(补零)", 37 | }, 38 | { 39 | value: "{{mm}}", 40 | label: "分(补零)", 41 | }, 42 | { 43 | value: "{{ss}}", 44 | label: "秒(补零)", 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./routers"; 4 | import { createPinia } from "pinia"; 5 | import { init as axiosInit } from "./apis/request"; 6 | // @ts-ignore 7 | import path from "path-unified"; 8 | import "@imengyu/vue3-context-menu/lib/vue3-context-menu.css"; 9 | import ContextMenu from "@imengyu/vue3-context-menu"; 10 | 11 | const isWeb = !window.api; 12 | window.isWeb = isWeb; 13 | if (isWeb) { 14 | // @ts-ignore 15 | window.path = path; 16 | } 17 | window.isFullstack = import.meta.env.VITE_FULLSTACK === "true"; 18 | 19 | const init = async () => { 20 | await axiosInit(); 21 | const pinia = createPinia(); 22 | const app = createApp(App); 23 | // app.provide("app", app); 24 | app.use(router).use(ContextMenu).use(pinia).mount("#app"); 25 | }; 26 | 27 | init(); 28 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/statisticsService.ts: -------------------------------------------------------------------------------- 1 | import type StatisticsModel from "../model/statistics.js"; 2 | 3 | type Key = "start_time" | "pan123_token" | "bili_last_upload_time"; 4 | 5 | export default class StatisticsService { 6 | private statisticsModel: StatisticsModel; 7 | constructor({ statisticsModel }: { statisticsModel: StatisticsModel }) { 8 | this.statisticsModel = statisticsModel; 9 | } 10 | query(key: Key) { 11 | return this.statisticsModel.findOne({ where: { stat_key: key } }); 12 | } 13 | addOrUpdate(options: { where: { stat_key: Key }; create: { stat_key: Key; value: string } }) { 14 | const { where, create } = options; 15 | const data = this.query(where.stat_key); 16 | if (data) { 17 | this.statisticsModel.update(create); 18 | } else { 19 | this.statisticsModel.add(create); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/http/src/middleware/validator.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeAny, z } from "zod"; 2 | 3 | const validator = (schema: ZodTypeAny, source: "body" | "params" | "query") => { 4 | return async (ctx, next) => { 5 | try { 6 | await schema.parseAsync(ctx.request[source]); 7 | next(); 8 | } catch (err) { 9 | if (err instanceof z.ZodError) { 10 | ctx.status = 400; 11 | ctx.body = { 12 | message: `Invalid ${source} schema`, 13 | errors: err.errors, 14 | }; 15 | } else { 16 | next(err); 17 | } 18 | } 19 | }; 20 | }; 21 | 22 | const body = (schema: ZodTypeAny) => validator(schema, "body"); 23 | const params = (schema: ZodTypeAny) => validator(schema, "params"); 24 | const query = (schema: ZodTypeAny) => validator(schema, "query"); 25 | 26 | export { body, params, query }; 27 | export default validator; 28 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | 1. 为弹幕转换的部分配置增加可视化展示 2 | 2. 弹幕转换文件添加时过滤掉emoji 3 | 3. 任务队列支持查看文件的相关参数 4 | 4. 视频合并支持重新压制 5 | 5. 数据dashboard 6 | 1. 主播管理作为主页面,其次是直播场次,还有总共弹幕数量,其他统计数据 7 | 2. 点击主播,可以查看录制历史管理,以及更加详细的弹幕数据,可以点击历史查看弹幕,也可以直接查看。 8 | 3. 弹幕分析,弹幕总数,流水,词频,互动人数,互动最多,高能时刻 9 | 4. webhook记录查看 10 | 6. webhook支持webhook处理 11 | 7. 支持批量功能增加文件名冲突检测 12 | 8. 切片:切片油猴脚本数据结构的额外支持 13 | 9. 不同类型通知支持设置不同通知 14 | 10. 重构上传UI,上传失败时支持用之前的进行重试,上传任务支持修改参数 15 | 11. desc支持模板 16 | 12. 切片支持快速发布,字幕功能 17 | 13. 上传延迟删除 18 | 14. 稿件以及分p状态码查询 19 | 15. 斗鱼、虎牙下载支持失败持久化 20 | 16. 录播姬webhook支持将文件和弹幕纳入管理,需要新增一个选项 21 | 17. 录制:批量操作直播 22 | 18. 录制:删除录制记录时支持删除文件 23 | 19. 录制:全局禁止监听 24 | 20. 录制:webhook的全局配置,注意向后兼容 25 | 21. 任务队列:大量数据的性能优化 26 | 22. webhook flv自动修复 27 | 23. 录制:增加一个组的概念 28 | 24. 切片:增加单个导出、视频上传 29 | 25. 弹幕支持自定义输出参数,传入log以及视频的宽高 30 | 31 | 切片更多功能支持: 32 | 33 | - 项目优先 34 | - 波形图更多UI支持,设置UI修改 35 | - 支持更多样式布局,如隐藏设置栏目,可在设置中唤起 36 | - 一种新的弹幕布局 37 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/setting/CutSetting.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | 26 | 31 | -------------------------------------------------------------------------------- /packages/shared/src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | const ALGORITHM = "aes-256-cbc"; 4 | 5 | // 加密数据 6 | export const encrypt = (data: string, password: string): string => { 7 | const key = crypto.scryptSync(password, "salt", 32); 8 | const iv = Buffer.alloc(16, 0); 9 | const cipher = crypto.createCipheriv(ALGORITHM, key, iv); 10 | let encrypted = cipher.update(data, "utf8", "base64"); 11 | encrypted += cipher.final("base64"); 12 | return encrypted; 13 | }; 14 | 15 | // 解密数据 16 | export const decrypt = (encryptedData: string, password: string): string => { 17 | const key = crypto.scryptSync(password, "salt", 32); 18 | const iv = Buffer.alloc(16, 0); 19 | const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); 20 | let decrypted = decipher.update(encryptedData, "base64", "utf8"); 21 | decrypted += decipher.final("utf8"); 22 | return decrypted; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/http/src/routes/llm.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | import { ollama } from "@biliLive-tools/shared/llm/index.js"; 3 | import { addTranslateTask } from "@biliLive-tools/shared/task/llm.js"; 4 | 5 | const router = new Router({ 6 | prefix: "/llm", 7 | }); 8 | 9 | router.get("/ollama/modelList", async (ctx) => { 10 | const data = ctx.request.query as { 11 | baseUrl: string; 12 | }; 13 | const models = (await ollama.getModelList(data.baseUrl)).models.map((item) => item.name); 14 | ctx.body = models; 15 | }); 16 | 17 | router.post("/translate", async (ctx) => { 18 | const data = ctx.request.body as { 19 | input: string; 20 | output: string; 21 | }; 22 | console.log(data); 23 | const content = await addTranslateTask(data.input, data.output); 24 | // const models = (await ollama.getModelList(data.baseUrl)).models.map((item) => item.name); 25 | ctx.body = content; 26 | }); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /packages/shared/src/utils/fonts.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import font from "font-ls"; 3 | 4 | /** 5 | * 判断字符串是否全部为 ASCII 字符 6 | * @param str 要检查的字符串 7 | * @returns 如果字符串全部为 ASCII 字符,则返回 true;否则返回 false 8 | */ 9 | function isAscii(str: string): boolean { 10 | // eslint-disable-next-line no-control-regex 11 | return /^[\x00-\x7F]*$/.test(str); 12 | } 13 | 14 | export async function getFontsList(): Promise< 15 | { 16 | postscriptName: string; 17 | fullName: string; 18 | }[] 19 | > { 20 | // @ts-ignore 21 | const fonts = await font.getAvailableFonts(); 22 | // @ts-ignore 23 | return fonts.map((font) => { 24 | if (isAscii(font.postscriptName)) { 25 | return { 26 | postscriptName: font.postscriptName, 27 | fullName: font.localizedName, 28 | }; 29 | } else { 30 | return { 31 | postscriptName: font.enName, 32 | fullName: font.localizedName, 33 | }; 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /packages/shared/src/task/audiowaveform.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import logger from "../utils/log.js"; 3 | 4 | import { getBinPath } from "./video.js"; 5 | 6 | export function generateAudioWaveform( 7 | audioFilePath: string, 8 | outputJsonPath: string, 9 | ): Promise { 10 | const { audiowaveformPath } = getBinPath(); 11 | return new Promise((resolve, reject) => { 12 | const args = ["-i", audioFilePath, "-o", outputJsonPath]; 13 | const process = spawn(audiowaveformPath, args); 14 | logger.info(`audiowaveform process started: ${audiowaveformPath} ${args.join(" ")}`); 15 | process.on("error", (err) => { 16 | logger.error("audiowaveform process error:", err); 17 | reject(err); 18 | }); 19 | process.on("close", (code) => { 20 | if (code === 0) { 21 | resolve(); 22 | } else { 23 | reject(new Error(`Process exited with code ${code}`)); 24 | } 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/streamerService.ts: -------------------------------------------------------------------------------- 1 | import type StreamerModel from "../model/streamer.js"; 2 | import type { BaseStreamer, Streamer } from "../model/streamer.js"; 3 | 4 | export default class StreamerService { 5 | private streamerModel: StreamerModel; 6 | 7 | constructor({ streamerModel }: { streamerModel: StreamerModel }) { 8 | this.streamerModel = streamerModel; 9 | } 10 | 11 | add(options: BaseStreamer) { 12 | return this.streamerModel.add(options); 13 | } 14 | 15 | addMany(list: BaseStreamer[]) { 16 | return this.streamerModel.addMany(list); 17 | } 18 | 19 | list(options: Partial = {}): Streamer[] { 20 | return this.streamerModel.list(options); 21 | } 22 | 23 | query(options: Partial) { 24 | return this.streamerModel.query(options); 25 | } 26 | 27 | upsert(options: { where: Partial; create: BaseStreamer }) { 28 | return this.streamerModel.upsert(options); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/app/src/main/handlers.ts: -------------------------------------------------------------------------------- 1 | import { appConfig } from "@biliLive-tools/shared"; 2 | import { mergeAssMp4 } from "@biliLive-tools/shared/task/video.js"; 3 | 4 | import type { AppConfig } from "@biliLive-tools/types"; 5 | import type { IpcMainInvokeEvent } from "electron"; 6 | 7 | export const configHandlers = { 8 | "config:set": (_event: IpcMainInvokeEvent, key: any, value: any) => { 9 | appConfig.set(key, value); 10 | }, 11 | "config:get": (_event: IpcMainInvokeEvent, key: any) => { 12 | return appConfig.get(key); 13 | }, 14 | "config:getAll": () => { 15 | return appConfig.getAll(); 16 | }, 17 | "config:save": (_event: IpcMainInvokeEvent, newConfig: AppConfig) => { 18 | appConfig.setAll(newConfig); 19 | }, 20 | }; 21 | 22 | export const ffmpegHandlers = { 23 | mergeAssMp4: async (_event: IpcMainInvokeEvent, ...args: Parameters) => { 24 | const task = await mergeAssMp4(...args); 25 | return { 26 | taskId: task.taskId, 27 | }; 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # 常见问题 (FAQ) 2 | 3 | ## 如何最小化启动软件 4 | 5 | 在软件的启动参数中添加 `--hidden` 参数 6 | 7 | ## 更改部分配置不生效? 8 | 9 | - 绝大部分配置修改后**立即生效** 10 | - 某部分配置只对**当场直播生效** 11 | - 小部分配置**重启后生效** 12 | 13 | ## 如何查看原始 FFmpeg 命令输出? 14 | 15 | 压制完成后,在任务列表中查看日志。 16 | 17 | ## 如何评估压制速度? 18 | 19 | 进入队列页面,查看任务最后一栏的速率。可以根据速率调整压制参数。 20 | 21 | ## 压制预设如何设置? 22 | 23 | 1. 根据硬件选择对应的编码器 24 | 2. 推荐使用 CRF 或 CQ 等质量模式的默认参数 25 | 3. 压制后查看视频大小、时间、画质 26 | 4. 调整参数以满足需求 27 | 28 | 没有最好的参数,只有最合适的参数。不同视频、不同场景可能需要不同参数。 29 | 30 | 进阶请自行搜索 FFmpeg 相关教程。 31 | 32 | ## NVENC 或其他硬件转码无法正常使用? 33 | 34 | 1. 更新显卡驱动到最新版本 35 | 2. 如果仍然无法使用,尝试手动更换 FFmpeg 可执行文件为 6.0 版本 36 | 37 | ::: warning 注意 38 | 自定义 FFmpeg 后部分功能可能会无法使用。 39 | ::: 40 | 41 | ## 分辨率变化后不会切割? 42 | 43 | 设计如此。如果播放器不支持多分辨率: 44 | 45 | - 使用 VLC 播放器 46 | - 或尝试使用 `mesio` 录制器 47 | 48 | ## 任务与队列 49 | 50 | ## 最大任务数如何理解? 51 | 52 | 1. 手动暂停的任务不会被自动启动 53 | 2. 高能进度条任务会自动进行(速度很快) 54 | 55 | ## 如何查看任务日志? 56 | 57 | 在队列页面点击任务,可以查看详细日志和 FFmpeg 输出。 58 | 59 | ## 软件会收集我的数据吗? 60 | 61 | **不会**。本软件不存在任何数据追踪和上报。 62 | 63 | 所有数据都存储在本地,不会发送到任何服务器。 64 | -------------------------------------------------------------------------------- /packages/huya-danmu/index.d.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "node:events"; 2 | 3 | export default class HuYaDanMuListener extends EventEmitter { 4 | constructor( 5 | roomId: 6 | | string 7 | | { 8 | roomid: string; 9 | uid?: number; 10 | subChannelId?: number; 11 | channelId?: number; 12 | }, 13 | ); 14 | 15 | set_proxy(proxy: unknown): void; 16 | 17 | start(): Promise; 18 | 19 | stop(): void; 20 | } 21 | 22 | export type HuYaMessage = HuYaMessage$Chat | HuYaMessage$Gift; 23 | 24 | export interface HuYaMessage$Common { 25 | type: string; 26 | time: number; 27 | from: { name: string; rid: string }; 28 | id: string; 29 | } 30 | 31 | export interface HuYaMessage$Chat extends HuYaMessage$Common { 32 | type: "chat"; 33 | content: string; 34 | color: string; 35 | } 36 | 37 | export interface HuYaMessage$Gift extends HuYaMessage$Common { 38 | type: "gift"; 39 | name: string; 40 | count: number; 41 | price: number; 42 | earn: number; 43 | } 44 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/uploadPartService.ts: -------------------------------------------------------------------------------- 1 | import type UploadPartModel from "../model/uploadPart.js"; 2 | import type { BaseUploadPart, UploadPart } from "../model/uploadPart.js"; 3 | 4 | export default class UploadPartService { 5 | private uploadPartModel: UploadPartModel; 6 | 7 | constructor({ uploadPartModel }: { uploadPartModel: UploadPartModel }) { 8 | this.uploadPartModel = uploadPartModel; 9 | } 10 | 11 | add(options: BaseUploadPart) { 12 | return this.uploadPartModel.add(options); 13 | } 14 | 15 | addOrUpdate(options: Omit) { 16 | return this.uploadPartModel.addOrUpdate(options); 17 | } 18 | 19 | findValidPartByHash(file_hash: string, file_size: number): UploadPart | null { 20 | return this.uploadPartModel.findValidPartByHash(file_hash, file_size); 21 | } 22 | 23 | removeExpired() { 24 | return this.uploadPartModel.removeExpired(); 25 | } 26 | 27 | removeByCids(cids: number[]) { 28 | return this.uploadPartModel.removeByCids(cids); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/http/src/services/fileCache.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from "@biliLive-tools/shared/utils/index.js"; 2 | 3 | export function createFileCache() { 4 | // 视频ID管理,用于临时访问授权 5 | const videoPathMap = new Map(); 6 | 7 | // 定期清理过期的视频ID 8 | setInterval( 9 | () => { 10 | const now = Date.now(); 11 | for (const [id, info] of videoPathMap.entries()) { 12 | if (info.expireAt < now) { 13 | videoPathMap.delete(id); 14 | } 15 | } 16 | }, 17 | 60 * 60 * 1000, 18 | ); // 每小时清理一次 19 | 20 | return { 21 | get: (id: string) => { 22 | const info = videoPathMap.get(id); 23 | return info; 24 | }, 25 | set: (id: string, fileInfo: { path: string; expireAt: number }) => { 26 | videoPathMap.set(id, fileInfo); 27 | }, 28 | setFile: (filePath: string) => { 29 | const id = uuid(); 30 | videoPathMap.set(id, { path: filePath, expireAt: Date.now() + 24 * 60 * 60 * 1000 }); 31 | return id; 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/DouYinRecorder/src/types.ts: -------------------------------------------------------------------------------- 1 | export type APIType = "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random"; 2 | export type RealAPIType = Exclude; 3 | 4 | export interface APIEndpoint { 5 | name: APIType; 6 | priority: number; // 优先级,数字越小优先级越高 7 | weight: number; // 权重,用于负载均衡 8 | } 9 | 10 | export interface APIEndpointStatus { 11 | endpoint: APIEndpoint; 12 | failureCount: number; 13 | lastFailureTime: number; 14 | isBlocked: boolean; 15 | nextRetryTime: number; 16 | } 17 | 18 | export interface LoadBalancerConfig { 19 | maxFailures: number; // 最大失败次数,超过后进入禁用状态 20 | blockDuration: number; // 禁用时长(毫秒) 21 | retryMultiplier: number; // 重试倍增系数 22 | healthCheckInterval: number; // 健康检查间隔(毫秒) 23 | } 24 | 25 | export interface RoomInfo { 26 | living: boolean; 27 | nickname: string; 28 | sec_uid: string; 29 | avatar: string; 30 | api: RealAPIType; 31 | room: { 32 | title: string; 33 | cover: string; 34 | id_str: string; 35 | stream_url: any | null; 36 | } | null; 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/components/Tip.vue: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/api-examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Runtime API Examples 6 | 7 | This page demonstrates usage of some of the runtime APIs provided by VitePress. 8 | 9 | The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files: 10 | 11 | ```md 12 | 17 | 18 | ## Results 19 | 20 | ### Theme Data 21 |
{{ theme }}
22 | 23 | ### Page Data 24 |
{{ page }}
25 | 26 | ### Page Frontmatter 27 |
{{ frontmatter }}
28 | ``` 29 | 30 | 35 | 36 | ## Results 37 | 38 | ### Theme Data 39 |
{{ theme }}
40 | 41 | ### Page Data 42 |
{{ page }}
43 | 44 | ### Page Frontmatter 45 |
{{ frontmatter }}
46 | 47 | ## More 48 | 49 | Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata). 50 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/presets/danmu.ts: -------------------------------------------------------------------------------- 1 | import request from "../request"; 2 | import type { DanmuPreset } from "@biliLive-tools/types"; 3 | 4 | const list = async (): Promise => { 5 | const res = await request.get(`/preset/danmu`); 6 | return res.data; 7 | }; 8 | 9 | const get = async (id: string): Promise => { 10 | const res = await request.get(`/preset/danmu/${id}`); 11 | return res.data; 12 | }; 13 | 14 | const add = async (preset: DanmuPreset) => { 15 | return request.post(`/preset/danmu`, preset); 16 | }; 17 | 18 | const remove = async (id: string) => { 19 | return request.delete(`/preset/danmu/${id}`); 20 | }; 21 | 22 | const update = async (id: string, preset: DanmuPreset) => { 23 | return request.put(`/preset/danmu/${id}`, preset); 24 | }; 25 | 26 | const save = async (preset: DanmuPreset) => { 27 | if (preset.id) { 28 | return update(preset.id, preset); 29 | } else { 30 | return add(preset); 31 | } 32 | }; 33 | 34 | const danmuPreset = { 35 | list, 36 | get, 37 | add, 38 | remove, 39 | update, 40 | save, 41 | }; 42 | 43 | export default danmuPreset; 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug 报告 2 | description: 创建一个报告来帮助我们改进 3 | body: 4 | - type: checkboxes 5 | id: initial-checklist 6 | attributes: 7 | label: 我已尝试以下操作 8 | options: 9 | - label: 尝试使用 [最新版本](https://github.com/renmu123/biliLive-tools/releases) 10 | required: true 11 | - label: 已阅读文档,并查询过 issue 12 | required: true 13 | - type: textarea 14 | id: steps-to-reproduce 15 | attributes: 16 | label: 复现步骤 17 | description: 您如何复现这个问题?请逐步描述您从启动到问题发生时所做的步骤。如果需要,您可以附上截图或屏幕录像。 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: expected-behavior 22 | attributes: 23 | label: 预期行为 24 | description: 应该发生什么? 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: actual-behavior 29 | attributes: 30 | label: 实际行为 31 | description: 实际发生了什么? 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: share-log 36 | attributes: 37 | label: 分享log 38 | description: 将”设置-基本-log等级“调整至debug,贴上相关log 39 | validations: 40 | required: false 41 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/presets/video.ts: -------------------------------------------------------------------------------- 1 | import request from "../request"; 2 | import type { BiliupPreset } from "@biliLive-tools/types"; 3 | 4 | const list = async (): Promise => { 5 | const res = await request.get(`/preset/video`); 6 | return res.data; 7 | }; 8 | 9 | const get = async (id: string): Promise => { 10 | const res = await request.get(`/preset/video/${id}`); 11 | return res.data; 12 | }; 13 | 14 | const add = async (preset: BiliupPreset) => { 15 | return request.post(`/preset/video`, preset); 16 | }; 17 | 18 | const remove = async (id: string) => { 19 | return request.delete(`/preset/video/${id}`); 20 | }; 21 | 22 | const update = async (id: string, preset: BiliupPreset) => { 23 | return request.put(`/preset/video/${id}`, preset); 24 | }; 25 | 26 | const save = async (preset: BiliupPreset) => { 27 | if (preset.id) { 28 | return update(preset.id, preset); 29 | } else { 30 | return add(preset); 31 | } 32 | }; 33 | 34 | const videoPreset = { 35 | list, 36 | get, 37 | add, 38 | remove, 39 | update, 40 | save, 41 | }; 42 | 43 | export default videoPreset; 44 | -------------------------------------------------------------------------------- /packages/app/src/main/common.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import child_process from "node:child_process"; 3 | import { app, shell } from "electron"; 4 | 5 | import type { IpcMainInvokeEvent } from "electron"; 6 | 7 | export const commonHandlers = { 8 | "common:execFile": async (_event: IpcMainInvokeEvent, file: string, args: string[]) => { 9 | return new Promise((resolve, reject) => { 10 | child_process.execFile(file, args, (error, stdout) => { 11 | if (error) { 12 | reject(error); 13 | } else { 14 | resolve(stdout); 15 | } 16 | }); 17 | }); 18 | }, 19 | getVersion: () => { 20 | return app.getVersion(); 21 | }, 22 | openPath: (_event: IpcMainInvokeEvent, path: string) => { 23 | shell.openPath(path); 24 | }, 25 | openExternal: (_event: IpcMainInvokeEvent, url: string) => { 26 | shell.openExternal(url); 27 | }, 28 | "common:showItemInFolder": async (_event: IpcMainInvokeEvent, path: string) => { 29 | shell.showItemInFolder(path); 30 | }, 31 | exits: (_event: IpcMainInvokeEvent, path: string) => { 32 | return fs.pathExists(path); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/Main/logSvg.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | 46 | 49 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish-npm: 11 | runs-on: ubuntu-22.04 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | with: 20 | run_install: false 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version-file: ".node-version" 26 | cache: "pnpm" 27 | 28 | - name: Configure npm authentication 29 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 30 | 31 | - name: Install dependencies 32 | run: pnpm install 33 | 34 | - name: Build package 35 | run: pnpm run build:base 36 | 37 | - name: Publish to npm 38 | shell: bash 39 | env: 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | run: pnpm publish --filter=huya-danma-listener --filter douyin-danma-listener --filter bililive-cli --filter @bililive-tools/* --access public --no-git-checks 42 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/video.ts: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | 3 | import type { VideoAPI } from "@biliLive-tools/http/types/video.js"; 4 | 5 | /** 订阅相关 */ 6 | const subParse = async (url: string) => { 7 | const res = await request.post(`/video/sub/parse`, { url }); 8 | return res.data; 9 | }; 10 | 11 | const addSub = async (data: VideoAPI["SubAdd"]["Args"]) => { 12 | const res = await request.post(`/video/sub/add`, data); 13 | return res.data; 14 | }; 15 | 16 | const removeSub = async (id: number) => { 17 | const res = await request.post(`/video/sub/remove`, { id }); 18 | return res.data; 19 | }; 20 | 21 | const updateSub = async (data: VideoAPI["SubUpdate"]["Args"]) => { 22 | const res = await request.post(`/video/sub/update`, data); 23 | return res.data; 24 | }; 25 | 26 | const listSub = async () => { 27 | const res = await request.get(`/video/sub/list`); 28 | return res.data; 29 | }; 30 | 31 | const checkSub = async (id: number) => { 32 | const res = await request.post(`/video/sub/check`, { id }); 33 | return res.data; 34 | }; 35 | 36 | export default { 37 | subParse, 38 | addSub, 39 | removeSub, 40 | updateSub, 41 | listSub, 42 | checkSub, 43 | }; 44 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/src/blive-message-listener/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | listenAll 3 | } from "./chunk-5NTNTWA4.js"; 4 | 5 | // src/index.ts 6 | import { KeepLiveTCP } from "tiny-bilibili-ws"; 7 | 8 | // src/types/const.ts 9 | var GuardLevel = /* @__PURE__ */ ((GuardLevel2) => { 10 | GuardLevel2[GuardLevel2["None"] = 0] = "None"; 11 | GuardLevel2[GuardLevel2["Zongdu"] = 1] = "Zongdu"; 12 | GuardLevel2[GuardLevel2["Tidu"] = 2] = "Tidu"; 13 | GuardLevel2[GuardLevel2["Jianzhang"] = 3] = "Jianzhang"; 14 | return GuardLevel2; 15 | })(GuardLevel || {}); 16 | 17 | // src/index.ts 18 | var startListen = (roomId, handler, options) => { 19 | const live = new KeepLiveTCP(roomId, options?.ws); 20 | listenAll(live, roomId, handler); 21 | const listenerInstance = { 22 | live, 23 | roomId: live.roomId, 24 | online: live.online, 25 | closed: live.closed, 26 | close: () => live.close(), 27 | getAttention: () => live.getOnline(), 28 | getOnline: () => live.getOnline(), 29 | reconnect: () => live.reconnect(), 30 | heartbeat: () => live.heartbeat(), 31 | send: (op, data) => live.send(op, data) 32 | }; 33 | return listenerInstance; 34 | }; 35 | export { 36 | GuardLevel, 37 | startListen 38 | }; 39 | -------------------------------------------------------------------------------- /packages/DouYinDanma/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "douyin-danma-listener", 3 | "version": "0.3.1", 4 | "description": "douyin danma listener", 5 | "main": "./lib/index.js", 6 | "types": "./types/index.d.ts", 7 | "type": "module", 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "exports": { 12 | ".": { 13 | "types": "./src/index.ts", 14 | "development": "./src/index.ts", 15 | "default": "./lib/index.js" 16 | } 17 | }, 18 | "scripts": { 19 | "build": "tsc", 20 | "watch": "tsc -w", 21 | "gen:proto": "pbjs -t static-module -w es6 -o src/proto.js src/dy.proto" 22 | }, 23 | "files": [ 24 | "lib", 25 | "types" 26 | ], 27 | "keywords": [ 28 | "douyin", 29 | "recorder", 30 | "bililive-tools", 31 | "直播", 32 | "danma", 33 | "弹幕", 34 | "抖音" 35 | ], 36 | "repository": "https://github.com/renmu123/biliLive-tools/tree/master/packages/DouYinDanma", 37 | "author": "renmu123", 38 | "license": "GPLV3", 39 | "dependencies": { 40 | "protobufjs": "^7.4.0", 41 | "tiny-typed-emitter": "^2.1.0", 42 | "ws": "^8.18.0" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "*", 46 | "protobufjs-cli": "^1.1.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/components/showDirectoryDialog.ts: -------------------------------------------------------------------------------- 1 | import { App, createApp } from "vue"; 2 | import FileBrowserDialog from "./FileBrowserDialog.vue"; 3 | 4 | export default async function showDirectoryDialog( 5 | options: { 6 | type?: "file" | "directory" | "save"; 7 | multi?: boolean; 8 | exts?: string[]; 9 | extension?: string; 10 | defaultPath?: string; 11 | } = {}, 12 | ): Promise { 13 | return new Promise((resolve) => { 14 | const mountNode = document.createElement("div"); 15 | let dialogApp: App | undefined = createApp(FileBrowserDialog, { 16 | visible: true, 17 | ...options, 18 | close: () => { 19 | if (dialogApp) { 20 | dialogApp.unmount(); 21 | document.body.removeChild(mountNode); 22 | dialogApp = undefined; 23 | resolve(undefined); 24 | } 25 | }, 26 | confirm: (path: string[]) => { 27 | resolve(path); 28 | dialogApp?.unmount(); 29 | document.body.removeChild(mountNode); 30 | dialogApp = undefined; 31 | }, 32 | }); 33 | document.body.appendChild(mountNode); 34 | dialogApp.mount(mountNode); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /packages/HuYaRecorder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bililive-tools/huya-recorder", 3 | "version": "1.11.0", 4 | "description": "bililive-tools huya recorder implemention", 5 | "main": "./lib/index.js", 6 | "type": "module", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "development": "./src/index.ts", 14 | "default": "./lib/index.js" 15 | }, 16 | "./*.js": { 17 | "types": "./src/*.ts", 18 | "development": "./src/*.ts", 19 | "default": "./lib/*.js" 20 | } 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "watch": "tsc -w" 25 | }, 26 | "files": [ 27 | "lib" 28 | ], 29 | "keywords": [ 30 | "huya", 31 | "recorder", 32 | "bililive-tools", 33 | "直播", 34 | "录制", 35 | "虎牙" 36 | ], 37 | "repository": "https://github.com/renmu123/biliLive-tools/tree/master/packages/HuYaRecorder", 38 | "author": "renmu123", 39 | "license": "LGPL", 40 | "dependencies": { 41 | "@bililive-tools/manager": "workspace:^", 42 | "@tars/stream": "^2.0.3", 43 | "axios": "catalog:", 44 | "huya-danma-listener": "workspace:*", 45 | "lodash-es": "catalog:", 46 | "mitt": "catalog:" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/src/main/cookie.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, session } from "electron"; 2 | 3 | import type { IpcMainInvokeEvent } from "electron"; 4 | 5 | export const cookieHandlers = { 6 | "cookie:baidu": async (_event: IpcMainInvokeEvent) => { 7 | return new Promise((resolve, reject) => { 8 | const win = new BrowserWindow({ 9 | width: 1200, 10 | height: 800, 11 | resizable: true, 12 | webPreferences: { 13 | webSecurity: false, 14 | }, 15 | }); 16 | win.loadURL("https://pan.baidu.com/disk/main", { 17 | userAgent: 18 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188", 19 | }); 20 | 21 | win.on("closed", async () => { 22 | const cookies = await session.defaultSession.cookies.get({ domain: "pan.baidu.com" }); 23 | if (cookies.length) { 24 | resolve( 25 | cookies 26 | .map((cookie) => { 27 | return `${cookie.name}=${cookie.value}`; 28 | }) 29 | .join("; "), 30 | ); 31 | } else { 32 | reject(new Error("cookie not found")); 33 | } 34 | }); 35 | }); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bililive-tools/bilibili-recorder", 3 | "version": "1.11.0", 4 | "description": "bililive-tools bilibili recorder implemention", 5 | "main": "./lib/index.js", 6 | "type": "module", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "development": "./src/index.ts", 14 | "default": "./lib/index.js" 15 | }, 16 | "./*.js": { 17 | "types": "./src/*.ts", 18 | "development": "./src/*.ts", 19 | "default": "./lib/*.js" 20 | } 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "watch": "tsc -w" 25 | }, 26 | "files": [ 27 | "lib" 28 | ], 29 | "keywords": [ 30 | "bilibili", 31 | "recorder", 32 | "bililive-tools", 33 | "直播", 34 | "录制", 35 | "哔哩哔哩" 36 | ], 37 | "repository": "https://github.com/renmu123/biliLive-tools/tree/master/packages/BilibiliRecorder", 38 | "author": "renmu123", 39 | "license": "LGPL", 40 | "dependencies": { 41 | "@bililive-tools/manager": "workspace:^", 42 | "blive-message-listener": "^0.5.2", 43 | "mitt": "catalog:", 44 | "tiny-bilibili-ws": "^1.0.2", 45 | "lodash-es": "catalog:", 46 | "axios": "catalog:" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/components/showInput.ts: -------------------------------------------------------------------------------- 1 | import { App, createApp } from "vue"; 2 | import InputDialog from "./InputDialog.vue"; 3 | 4 | export default async function showInput( 5 | options: { 6 | title?: string; 7 | placeholder?: string; 8 | defaultValue?: string; 9 | type?: "text" | "password" | "textarea"; 10 | maxlength?: number; 11 | showCount?: boolean; 12 | rows?: number; 13 | required?: boolean; 14 | errorMessage?: string; 15 | } = {}, 16 | ): Promise { 17 | return new Promise((resolve) => { 18 | const mountNode = document.createElement("div"); 19 | let dialogApp: App | undefined = createApp(InputDialog, { 20 | visible: true, 21 | ...options, 22 | close: () => { 23 | resolve(undefined); 24 | if (dialogApp) { 25 | dialogApp.unmount(); 26 | document.body.removeChild(mountNode); 27 | dialogApp = undefined; 28 | } 29 | }, 30 | confirm: (value: string) => { 31 | resolve(value); 32 | dialogApp?.unmount(); 33 | document.body.removeChild(mountNode); 34 | dialogApp = undefined; 35 | }, 36 | }); 37 | document.body.appendChild(mountNode); 38 | dialogApp.mount(mountNode); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /packages/liveManager/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bililive-tools/manager", 3 | "version": "1.11.0", 4 | "description": "Batch scheduling recorders", 5 | "main": "./lib/index.js", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "types": "./src/index.ts", 10 | "development": "./src/index.ts", 11 | "default": "./lib/index.js" 12 | }, 13 | "./*.js": { 14 | "types": "./src/*.ts", 15 | "development": "./src/*.ts", 16 | "default": "./lib/*.js" 17 | } 18 | }, 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "scripts": { 23 | "build": "pnpm run test && tsc", 24 | "watch": "tsc -w", 25 | "test": "vitest run" 26 | }, 27 | "files": [ 28 | "lib" 29 | ], 30 | "keywords": [ 31 | "bililive-tools", 32 | "manager", 33 | "scheduling", 34 | "recorders" 35 | ], 36 | "repository": "https://github.com/renmu123/biliLive-tools", 37 | "author": "renmu123", 38 | "license": "LGPL", 39 | "dependencies": { 40 | "@renmu/fluent-ffmpeg": "2.3.3", 41 | "fast-xml-parser": "^4.5.0", 42 | "filenamify": "^7.0.0", 43 | "mitt": "catalog:", 44 | "string-argv": "^0.3.2", 45 | "lodash-es": "catalog:", 46 | "axios": "catalog:", 47 | "fs-extra": "^11.2.0", 48 | "ejs": "^3.1.10" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/Tools/pages/VideoCut/composables/useVideoPlayer.ts: -------------------------------------------------------------------------------- 1 | import { ref, type Ref } from "vue"; 2 | import { commonApi } from "@renderer/apis"; 3 | import type Artplayer from "artplayer"; 4 | 5 | export function useVideoPlayer(isWeb: Ref) { 6 | const videoInstance = ref(null); 7 | const videoRef = ref(null); 8 | 9 | /** 10 | * 加载视频文件 11 | * @param path 视频文件路径 12 | */ 13 | const loadVideo = async (path: string) => { 14 | if (isWeb.value) { 15 | const { videoId, type } = await commonApi.applyVideoId(path); 16 | const videoUrl = await commonApi.getVideo(videoId); 17 | await videoRef.value?.switchUrl(videoUrl, type as any); 18 | return videoUrl; 19 | } else { 20 | await videoRef.value?.switchUrl(path, path.endsWith(".flv") ? "flv" : ""); 21 | return path; 22 | } 23 | }; 24 | 25 | /** 26 | * 视频状态切换 27 | */ 28 | const togglePlay = () => { 29 | if (!videoInstance.value?.url) return; 30 | videoInstance.value.toggle(); 31 | }; 32 | 33 | /** 34 | * 视频播放器就绪回调 35 | */ 36 | const handleVideoReady = (instance: Artplayer) => { 37 | videoInstance.value = instance; 38 | }; 39 | 40 | return { 41 | videoInstance, 42 | videoRef, 43 | loadVideo, 44 | togglePlay, 45 | handleVideoReady, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /packages/DouYinDanma/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { TypedEmitter } from "tiny-typed-emitter"; 2 | import type { 3 | ChatMessage, 4 | MemberMessage, 5 | LikeMessage, 6 | SocialMessage, 7 | GiftMessage, 8 | RoomUserSeqMessage, 9 | RoomStatsMessage, 10 | RoomRankMessage, 11 | Message, 12 | } from "../types/types.js"; 13 | import WebSocket from "ws"; 14 | 15 | interface Events { 16 | open: () => void; 17 | close: () => void; 18 | reconnect: (count: number) => void; 19 | heartbeat: () => void; 20 | error: (error: Error) => void; 21 | chat: (message: ChatMessage) => void; 22 | member: (message: MemberMessage) => void; 23 | like: (message: LikeMessage) => void; 24 | social: (message: SocialMessage) => void; 25 | gift: (message: GiftMessage) => void; 26 | roomUserSeq: (message: RoomUserSeqMessage) => void; 27 | roomStats: (message: RoomStatsMessage) => void; 28 | roomRank: (message: RoomRankMessage) => void; 29 | message: (message: Message) => void; 30 | } 31 | declare class DouYinDanmaClient extends TypedEmitter { 32 | private ws: WebSocket; 33 | constructor( 34 | roomId: string, 35 | options?: { 36 | autoStart?: boolean; 37 | autoReconnect?: number; 38 | heartbeatInterval?: number; 39 | cookie?: string; 40 | }, 41 | ); 42 | connect(): Promise; 43 | } 44 | export default DouYinDanmaClient; 45 | -------------------------------------------------------------------------------- /docs/api/base.md: -------------------------------------------------------------------------------- 1 | # 基础 API 2 | 3 | biliLive-tools 的基础接口文档,使用前必看。 4 | 5 | ::: tip 6 | 如果是 `fullstack` 镜像中的api地址将会被代理到 `/api` 路径 7 | ::: 8 | 9 | ## 接口授权 10 | 11 | 绝大多数 API 接口除设计面向外部的都需要进行身份验证。 12 | 13 | ### 授权方式 14 | 15 | 在请求头中添加 `Authorization` 字段: 16 | 17 | ``` 18 | Authorization: your_passkey_here 19 | ``` 20 | 21 | ### 获取 PassKey 22 | 23 | - **客户端模式**: 在设置中可以配置自定义 PassKey 24 | - **命令行模式**: 通过环境变量 `BILILIVE_TOOLS_PASSKEY` 设置 25 | 26 | ### 调用示例 27 | 28 | ```javascript 29 | // 使用 fetch 30 | const response = await fetch("http://localhost:18010/common/version", { 31 | headers: { 32 | Authorization: "your_passkey_here", 33 | }, 34 | }); 35 | const data = await response.json(); 36 | 37 | // 使用 axios 38 | import axios from "axios"; 39 | 40 | const client = axios.create({ 41 | baseURL: "http://localhost:18010", 42 | headers: { 43 | Authorization: "your_passkey_here", 44 | }, 45 | }); 46 | 47 | const { data } = await client.get("/common/version"); 48 | ``` 49 | 50 | **Python:** 51 | 52 | ```python 53 | import requests 54 | 55 | headers = { 56 | 'Authorization': 'your_passkey_here' 57 | } 58 | 59 | response = requests.get('http://localhost:18010/common/version', headers=headers) 60 | data = response.json() 61 | ``` 62 | 63 | **cURL:** 64 | 65 | ```bash 66 | curl -H "Authorization: your_passkey_here" http://localhost:18010/common/version 67 | 68 | ``` 69 | -------------------------------------------------------------------------------- /packages/DouYinDanma/src/utils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import { gunzip } from "node:zlib"; 5 | 6 | import { get_sign } from "./webmssdk.js"; 7 | 8 | export function loadWebmssdk(jsFile: string): string { 9 | const dirPath = path.dirname(__filename); 10 | const jsPath = path.join(dirPath, jsFile); 11 | return fs.readFileSync(jsPath, "utf-8"); 12 | } 13 | 14 | export function getUserUniqueId(): string { 15 | return ( 16 | Math.floor(Math.random() * (7999999999999999999 - 7300000000000000000)) + 7300000000000000000 17 | ).toString(); 18 | } 19 | 20 | export function getXMsStub(params: Record): string { 21 | const sigParams = Object.entries(params) 22 | .map(([k, v]) => `${k}=${v}`) 23 | .join(","); 24 | return crypto.createHash("md5").update(sigParams).digest("hex"); 25 | } 26 | 27 | export function getSignature(xMsStub: string): string { 28 | try { 29 | return get_sign(xMsStub); 30 | } catch { 31 | return "00000000"; 32 | } 33 | return "00000000"; 34 | } 35 | 36 | export function decompressGzip(buffer: Buffer) { 37 | return new Promise((resolve, reject) => { 38 | gunzip(buffer, (err, result) => { 39 | if (err) { 40 | reject(err); 41 | } else { 42 | resolve(result); 43 | } 44 | }); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /packages/shared/src/presets/videoPreset.ts: -------------------------------------------------------------------------------- 1 | import CommonPreset from "./preset.js"; 2 | 3 | import type { BiliupConfig, BiliupPreset } from "@biliLive-tools/types"; 4 | import type { GlobalConfig } from "@biliLive-tools/types"; 5 | 6 | export const DEFAULT_BILIUP_CONFIG: BiliupConfig = { 7 | title: "", 8 | desc: "", 9 | dolby: 0, 10 | hires: 0, 11 | copyright: 1, 12 | tag: ["biliLive-tools"], // tag应该为""以,分割的字符串 13 | tid: 138, 14 | human_type2: undefined, 15 | source: "", 16 | dynamic: "", 17 | cover: "", 18 | noReprint: 0, 19 | openElec: 0, 20 | closeDanmu: 0, 21 | closeReply: 0, 22 | selectiionReply: 0, 23 | recreate: -1, 24 | no_disturbance: 0, 25 | autoComment: false, 26 | commentTop: false, 27 | comment: "", 28 | topic_name: null, 29 | is_only_self: 0, 30 | }; 31 | 32 | export class VideoPreset extends CommonPreset { 33 | constructor({ globalConfig }: { globalConfig: Pick }) { 34 | super(globalConfig.videoPresetPath, DEFAULT_BILIUP_CONFIG); 35 | } 36 | init(filePath: string) { 37 | super.init(filePath); 38 | } 39 | async get(id: string) { 40 | return super.get(id); 41 | } 42 | async list() { 43 | return super.list(); 44 | } 45 | async save(preset: BiliupPreset) { 46 | return super.save(preset); 47 | } 48 | async delete(id: string) { 49 | return super.delete(id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/DouYinDanma/README.md: -------------------------------------------------------------------------------- 1 | # 简介 2 | 3 | 抖音弹幕录制 4 | 5 | # 安装 6 | 7 | node>=18 8 | 9 | ```sh 10 | npm install douyin-danma-listener 11 | ``` 12 | 13 | # 使用 14 | 15 | ```javascript 16 | import DouYinDanmaClient from "douyin-danma-listener"; 17 | 18 | // roomId并非是你看到的房间号,你可以在 https://live.douyin.com/webcast/room/web/enter/ 中找到id_str参数 19 | const client = new DouYinDanmaClient("id_str"); 20 | client.on("chat", (message) => { 21 | console.log("收到弹幕:", message); 22 | }); 23 | client.connect(); 24 | ``` 25 | 26 | ## 参数 27 | 28 | 配置项如下: 29 | 30 | - `autoStart` (boolean): 是否自动开始连接,默认为 `false` 31 | - `autoReconnect` (number): 自动重连次数,默认为 `10` 32 | - `heartbeatInterval` (number): 心跳包发送间隔,单位为毫秒,默认为 `10000` 33 | - `cookie` (string): 可选的 Cookie 字符串,某些直播间可能需要? 34 | - `timeoutInterval` (number): 没有数据返回但`ws`未被主动关闭时超时后重新连接,单位为秒,默认`100` 35 | - `reconnectInterval`: 重连等待时间 36 | 37 | ## 事件 38 | 39 | 只支持了部分事件的解析 40 | 41 | - `open`: 连接成功时触发 42 | - `close`: 连接关闭时触发 43 | - `reconnect`: 重连时触发,参数为重连次数 44 | - `heartbeat`: 心跳包发送时触发 45 | - `error`: 发生错误时触发,参数为错误对象 46 | - `chat`: 收到弹幕消息时触发,参数为弹幕消息对象 47 | - `member`: 用户进入房间时触发,参数为用户信息对象 48 | - `like`: 收到点赞消息时触发,参数为点赞消息对象 49 | - `social`: 收到社交消息时触发,参数为社交消息对象 50 | - `gift`: 收到礼物消息时触发,参数为礼物消息对象 51 | - `roomUserSeq`: 收到房间用户序列消息时触发,参数为房间用户序列消息对象 52 | - `roomStats`: 收到房间统计消息时触发,参数为房间统计消息对象 53 | - `roomRank`: 收到房间排名消息时触发,参数为房间排名消息对象 54 | - `message`: 收到任意消息时触发,参数为消息对象 55 | 56 | # 协议 57 | 58 | GPLV3 59 | -------------------------------------------------------------------------------- /packages/DouYinRecorder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bililive-tools/douyin-recorder", 3 | "version": "1.11.1", 4 | "description": "@bililive-tools douyin recorder implemention", 5 | "main": "./lib/index.js", 6 | "type": "module", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "development": "./src/index.ts", 14 | "default": "./lib/index.js" 15 | }, 16 | "./*.js": { 17 | "types": "./src/*.ts", 18 | "development": "./src/*.ts", 19 | "default": "./lib/*.js" 20 | } 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "watch": "tsc -w", 25 | "gen:proto": "pbjs -t static-module -w es6 -o src/danma/proto.js src/danma/dy.proto" 26 | }, 27 | "files": [ 28 | "lib" 29 | ], 30 | "keywords": [ 31 | "douyin", 32 | "recorder", 33 | "bililive-tools", 34 | "直播", 35 | "录制", 36 | "抖音" 37 | ], 38 | "repository": "https://github.com/renmu123/biliLive-tools/tree/master/packages/DouYinRecorder", 39 | "author": "WhiteMind", 40 | "license": "LGPL", 41 | "dependencies": { 42 | "@bililive-tools/manager": "workspace:^", 43 | "axios": "catalog:", 44 | "douyin-danma-listener": "workspace:*", 45 | "lodash-es": "catalog:", 46 | "mitt": "catalog:", 47 | "sm-crypto": "^0.3.13" 48 | }, 49 | "devDependencies": { 50 | "@types/node": "*" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/http/src/routes/assets.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import fs from "fs-extra"; 3 | import Router from "@koa/router"; 4 | import { config, fileCache } from "../index.js"; 5 | 6 | const router = new Router({ 7 | prefix: "/assets", 8 | }); 9 | 10 | router.get("/cover/:filename", async (ctx) => { 11 | const { filename } = ctx.params; 12 | const coverPath = path.join(config.userDataPath, "cover", filename); 13 | 14 | if (await fs.pathExists(coverPath)) { 15 | ctx.type = path.extname(coverPath); 16 | ctx.body = fs.createReadStream(coverPath); 17 | } else { 18 | ctx.status = 404; 19 | ctx.body = "Cover not found"; 20 | } 21 | }); 22 | 23 | router.get("/download/:id", async (ctx) => { 24 | const { id } = ctx.params; 25 | 26 | const file = fileCache.get(id); 27 | if (!file) { 28 | ctx.status = 404; 29 | ctx.body = { message: "文件不存在" }; 30 | return; 31 | } 32 | if (!(await fs.pathExists(file.path))) { 33 | ctx.status = 404; 34 | ctx.body = { message: "文件不存在" }; 35 | return; 36 | } 37 | 38 | const stat = await fs.stat(file.path); 39 | ctx.set("Content-Length", stat.size.toString()); 40 | ctx.set("Content-Type", "application/octet-stream"); 41 | ctx.set( 42 | "Content-Disposition", 43 | `attachment; filename=${encodeURIComponent(path.basename(file.path))}`, 44 | ); 45 | ctx.body = fs.createReadStream(file.path); 46 | }); 47 | 48 | export default router; 49 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/videoSubService.ts: -------------------------------------------------------------------------------- 1 | import type VideoSubModel from "../model/videoSub.js"; 2 | import type { VideoSub, VideoSubItem, AddVideoSub, UpdateVideoSub } from "../model/videoSub.js"; 3 | 4 | export default class VideoSubService { 5 | private videoSubModel: VideoSubModel; 6 | 7 | constructor({ videoSubModel }: { videoSubModel: VideoSubModel }) { 8 | this.videoSubModel = videoSubModel; 9 | } 10 | 11 | add(data: AddVideoSub) { 12 | return this.videoSubModel.add(data); 13 | } 14 | 15 | list(options: Partial = {}): VideoSubItem[] { 16 | return this.videoSubModel.list(options).map((item) => { 17 | return { 18 | ...item, 19 | enable: !!item.enable, 20 | options: JSON.parse(item.options), 21 | }; 22 | }); 23 | } 24 | 25 | query(options: Partial): VideoSubItem | null { 26 | const item = this.videoSubModel.query(options); 27 | if (!item) return null; 28 | return { 29 | ...item, 30 | enable: !!item.enable, 31 | options: JSON.parse(item.options), 32 | }; 33 | } 34 | 35 | update(data: UpdateVideoSub) { 36 | return this.videoSubModel.updateVideoSub(data); 37 | } 38 | 39 | updateLastRunTime(data: { id: number; lastRunTime: number }) { 40 | return this.videoSubModel.updateLastRunTime(data); 41 | } 42 | 43 | delete(id: number) { 44 | return this.videoSubModel.deleteById(id); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/shared/src/db/model/virtualRecord.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import BaseModel from "./baseModel.js"; 3 | 4 | import type { Database } from "better-sqlite3"; 5 | 6 | const BaseVirtualRecord = z.object({ 7 | path: z.string(), 8 | }); 9 | 10 | const VirtualRecord = BaseVirtualRecord.extend({ 11 | id: z.number(), 12 | created_at: z.number(), 13 | }); 14 | 15 | export type BaseVirtualRecord = z.infer; 16 | export type VirtualRecord = z.infer; 17 | 18 | export default class VirtualRecordModel extends BaseModel { 19 | constructor({ db }: { db: Database }) { 20 | super(db, "virtual_record"); 21 | this.createTable(); 22 | } 23 | 24 | async createTable() { 25 | const createTableSQL = ` 26 | CREATE TABLE IF NOT EXISTS ${this.tableName} ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键 28 | path TEXT NOT NULL, -- 文件路径 29 | created_at INTEGER DEFAULT (strftime('%s', 'now')) -- 创建时间,时间戳,自动生成 30 | ) STRICT; 31 | `; 32 | return super.createTable(createTableSQL); 33 | } 34 | 35 | add(options: BaseVirtualRecord) { 36 | const data = BaseVirtualRecord.parse(options); 37 | return this.insert(data); 38 | } 39 | 40 | deleteById(id: number) { 41 | const sql = `DELETE FROM ${this.tableName} WHERE id = ?`; 42 | return this.db.prepare(sql).run(id); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/markdown-examples.md: -------------------------------------------------------------------------------- 1 | # Markdown Extension Examples 2 | 3 | This page demonstrates some of the built-in markdown extensions provided by VitePress. 4 | 5 | ## Syntax Highlighting 6 | 7 | VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: 8 | 9 | **Input** 10 | 11 | ````md 12 | ```js{4} 13 | export default { 14 | data () { 15 | return { 16 | msg: 'Highlighted!' 17 | } 18 | } 19 | } 20 | ``` 21 | ```` 22 | 23 | **Output** 24 | 25 | ```js{4} 26 | export default { 27 | data () { 28 | return { 29 | msg: 'Highlighted!' 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | ## Custom Containers 36 | 37 | **Input** 38 | 39 | ```md 40 | ::: info 41 | This is an info box. 42 | ::: 43 | 44 | ::: tip 45 | This is a tip. 46 | ::: 47 | 48 | ::: warning 49 | This is a warning. 50 | ::: 51 | 52 | ::: danger 53 | This is a dangerous warning. 54 | ::: 55 | 56 | ::: details 57 | This is a details block. 58 | ::: 59 | ``` 60 | 61 | **Output** 62 | 63 | ::: info 64 | This is an info box. 65 | ::: 66 | 67 | ::: tip 68 | This is a tip. 69 | ::: 70 | 71 | ::: warning 72 | This is a warning. 73 | ::: 74 | 75 | ::: danger 76 | This is a dangerous warning. 77 | ::: 78 | 79 | ::: details 80 | This is a details block. 81 | ::: 82 | 83 | ## More 84 | 85 | Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown). 86 | -------------------------------------------------------------------------------- /packages/http/src/types/video.ts: -------------------------------------------------------------------------------- 1 | import videoSub from "@biliLive-tools/shared/video/videoSub.js"; 2 | 3 | type Platform = "douyu" | "bilibili" | "huya" | "bilibiliLive" | "kuaishou"; 4 | 5 | export type VideoAPI = { 6 | parseVideo: { 7 | Args: { url: string }; 8 | Resp: { 9 | platform: Platform; 10 | videoId: string; 11 | title: string; 12 | resolutions: { label: string; value: string }[]; 13 | parts: { name: string; partId: string; isEditing: boolean; extra?: Record }[]; 14 | extra?: Record; 15 | }; 16 | }; 17 | downloadVideo: { 18 | Args: { 19 | id: string; 20 | platform: Platform; 21 | savePath: string; 22 | filename: string; 23 | resolution?: string; 24 | extra?: Record; 25 | danmu: "none" | "xml"; 26 | override: boolean; 27 | onlyAudio?: boolean; 28 | onlyDanmu?: boolean; 29 | }; 30 | }; 31 | SubList: { 32 | Args: {}; 33 | Resp: ReturnType<(typeof videoSub)["list"]>; 34 | }; 35 | SubAdd: { 36 | Args: Parameters<(typeof videoSub)["add"]>[0]; 37 | Resp: number; 38 | }; 39 | SubRemove: { 40 | Args: { id: number }; 41 | Resp: number; 42 | }; 43 | SubUpdate: { 44 | Args: Parameters<(typeof videoSub)["update"]>[0]; 45 | Resp: number; 46 | }; 47 | SubParse: { 48 | Args: { url: string }; 49 | Resp: Parameters<(typeof videoSub)["add"]>[0]; 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/DouYuRecorder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bililive-tools/douyu-recorder", 3 | "version": "1.11.0", 4 | "description": "bililive-tools douyu recorder implemention", 5 | "main": "./lib/index.js", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "types": "./src/index.ts", 10 | "development": "./src/index.ts", 11 | "default": "./lib/index.js" 12 | }, 13 | "./*.js": { 14 | "types": "./src/*.ts", 15 | "development": "./src/*.ts", 16 | "default": "./lib/*.js" 17 | } 18 | }, 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "watch": "tsc -w" 25 | }, 26 | "files": [ 27 | "lib" 28 | ], 29 | "keywords": [ 30 | "douyu", 31 | "recorder", 32 | "bililive-tools", 33 | "直播", 34 | "录制", 35 | "斗鱼" 36 | ], 37 | "repository": "https://github.com/renmu123/biliLive-tools/tree/master/packages/DouYuRecorder", 38 | "author": "renmu123", 39 | "license": "LGPL", 40 | "dependencies": { 41 | "@bililive-tools/manager": "workspace:^", 42 | "mitt": "catalog:", 43 | "query-string": "^9.1.1", 44 | "safe-eval": "^0.4.1", 45 | "ws": "^8.18.0", 46 | "lodash-es": "catalog:", 47 | "axios": "catalog:", 48 | "douyu-api": "^0.2.0" 49 | }, 50 | "devDependencies": { 51 | "@types/ws": "^8.5.13" 52 | }, 53 | "optionalDependencies": { 54 | "bufferutil": "^4.0.8", 55 | "utf-8-validate": "^6.0.5" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/DouYuRecorder/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.11.0 2 | 3 | - 录播姬引擎支持切割 4 | 5 | # 1.10.0 6 | 7 | - 重构:录制器相关的参数修改为 `Downloader` 8 | - 修复 `qualityRetry` 修改后不会生效的bug 9 | - 修复:录播姬引擎分段时间不支持浮点数 10 | - segment 参数如果以"B","KB","MB","GB"结尾,会使用文件大小分段 11 | 12 | # 1.9.0 13 | 14 | - `recordHandle` 新增参数 `recorderType` 15 | - 修复某些情况下服务端时间戳不存在时使用客户端时间 16 | - 录制:优化ffmpeg默认参数,fmp4使用m4s后缀 [#224](https://github.com/renmu123/biliLive-tools/pull/224) 17 | 18 | # 1.8.0 19 | 20 | - 触发标题黑名单设定额外状态 21 | - 新增`debugLevel`参数,支持`none`、`basic`、`verbose` 22 | - 尽可能避免scdn 23 | - 录播姬引擎支持 24 | 25 | # 1.7.1 26 | 27 | - 优化斗鱼链接解析 28 | - 修复检查错误状态不会被重置的bug 29 | 30 | # 1.7.0 31 | 32 | - 增加检查错误状态值 33 | 34 | # 1.6.0 35 | 36 | - 支持 `recorderType` 参数用于配置底层录制器,支持`auto | ffmpeg | mesio` 37 | - 修复cdn错误显示 38 | - 增加更多礼物 39 | - 修复 `videoFormat=auto` 时某些情况下格式的判断 40 | 41 | # 1.5.1 42 | 43 | - `resolveChannelInfoFromURL` 新增返回参数:`avatar` 44 | - 优化直播间解析 45 | 46 | # 1.5.0 47 | 48 | - 支持 `useServerTimestamp` 控制弹幕是否使用服务端时间戳 49 | - 荧光棒价格置为0 50 | 51 | # 1.4.0 52 | 53 | - 支持 `onlyAudio` 来只录制音频 54 | 55 | # 1.3.0 56 | 57 | 1. 分P时获取更加准确的标题以及封面信息 58 | 2. 弹幕时间使用服务端时间 59 | 60 | # 1.2.0 61 | 62 | - 支持 `source` 参数,用于指定 cdn 63 | - 支持 `videoFormat`参数: "auto", "ts", "mkv" 64 | - 弹幕默认重连次数修改为10 65 | 66 | # 1.1.0 67 | 68 | 1. 重用ffmpeg录制器 69 | 2. 礼物弹幕支持开通钻粉和续费钻粉 70 | 3. 斗鱼录制支持标题关键词来不进行录制 [#53](https://github.com/renmu123/biliLive-tools/pull/53) 71 | 4. `qualityRetry` 支持 -1 参数 72 | 73 | # 1.0.3 74 | 75 | 1. 更新依赖 76 | 77 | # 1.0.1 78 | 79 | ## Bug fix 80 | 81 | 1. 修复缺少依赖的bug 82 | -------------------------------------------------------------------------------- /packages/DouYuRecorder/src/dy_client/stt.ts: -------------------------------------------------------------------------------- 1 | export namespace STT { 2 | export function escape(v: string): string { 3 | return v.toString().replace(/@/g, "@A").replace(/\//g, "@S"); 4 | } 5 | 6 | export function unescape(v: string): string { 7 | return v.toString().replace(/@S/g, "/").replace(/@A/g, "@"); 8 | } 9 | 10 | export function serialize(obj: unknown): string { 11 | if (obj == null) throw new Error("Cant serialize null value"); 12 | 13 | if (Array.isArray(obj)) { 14 | return obj.map((v) => STT.serialize(v)).join(""); 15 | } 16 | 17 | if (typeof obj === "object") { 18 | return Object.entries(obj) 19 | .map(([k, v]) => `${k}@=${STT.serialize(v)}`) 20 | .join(""); 21 | } 22 | 23 | return STT.escape(obj.toString()) + "/"; 24 | } 25 | 26 | export function deserialize(raw: string): unknown { 27 | if (raw.includes("//")) { 28 | return raw 29 | .split("//") 30 | .filter((e) => e !== "") 31 | .map((item) => STT.deserialize(item)); 32 | } 33 | 34 | if (raw.includes("@=")) { 35 | return raw 36 | .split("/") 37 | .filter((part) => part !== "") 38 | .reduce( 39 | (obj, part) => { 40 | const [key, val] = part.split("@="); 41 | obj[key] = val ? STT.deserialize(val) : ""; 42 | return obj; 43 | }, 44 | {} as Record, 45 | ); 46 | } 47 | 48 | return STT.unescape(raw); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/BilibiliRecorder/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.11.0 2 | 3 | - 录播姬引擎切割支持 4 | 5 | # 1.10.0 6 | 7 | - 支持 `15000` 画质 8 | - 修复 `qualityRetry` 修改后不会生效的bug 9 | - 修复:录播姬引擎分段时间不支持浮点数 10 | - segment 参数如果以"B","KB","MB","GB"结尾,会使用文件大小分段 11 | 12 | # 1.9.0 13 | 14 | - `recordHandle` 新增参数 `recorderType` 15 | - 优化ffmpeg默认参数,fmp4使用m4s后缀 [#224](https://github.com/renmu123/biliLive-tools/pull/224) 16 | - 支持 `25000` 原画真彩画质 17 | 18 | # 1.8.0 19 | 20 | - 新增`debugLevel`参数,支持`none`、`basic`、`verbose` 21 | - 触发标题黑名单设定额外状态 22 | - 录播姬引擎支持 23 | 24 | # 1.7.1 25 | 26 | - 修复检查错误状态不会被重置的bug 27 | 28 | # 1.7.0 29 | 30 | - 增加检查错误状态值 31 | 32 | # 1.6.0 33 | 34 | - 支持 `recorderType` 参数用于配置底层录制器,支持`auto | ffmpeg | mesio` 35 | - 修复 `videoFormat=auto` 时某些情况下格式的判断 36 | - 修复上船的礼物价格错误 37 | 38 | # 1.5.1 39 | 40 | - `resolveChannelInfoFromURL` 新增返回参数:`avatar` 41 | - 弹幕录制切换回原项目`blive-message-listener` 42 | 43 | # 1.5.0 44 | 45 | - 修复部分服务端礼物弹幕时间戳错误的bug 46 | - 支持 `useServerTimestamp` 控制弹幕是否使用服务端时间戳 47 | 48 | # 1.4.1 49 | 50 | - 修复无法获取弹幕的bug 51 | 52 | # 1.4.0 53 | 54 | - 支持 `onlyAudio` 参数来只录制音频 55 | 56 | # 1.3.0 57 | 58 | - 支持标题关键词来不进行录制 59 | - 修改默认画质匹配逻辑以处理hevc真原画 60 | - 分P时获取更加准确的标题以及封面信息 61 | - 优化弹幕时间使用服务端时间 62 | 63 | # 1.2.0 64 | 65 | 1. 支持 `videoFormat`参数: "auto", "ts", "mkv" 66 | 2. 弹幕默认重试次数修改为10 67 | 68 | # 1.1.0 69 | 70 | ## 优化 71 | 72 | 1. 优化 http_hls 流优化非法流的判定 73 | 2. ts流比fmp4优先级更高 74 | 3. 弹幕服务器增加重试 75 | 4. `qualityRetry` 支持 -1 参数 76 | 77 | ## Bug fix 78 | 79 | 1. 修复hls代理未生效 80 | 81 | # 1.0.1 82 | 83 | ## Bug fix 84 | 85 | 1. 修复缺少依赖的bug 86 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/presets/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import request from "../request"; 2 | import type { FfmpegPreset, FfmpegOptions } from "@biliLive-tools/types"; 3 | 4 | const list = async (): Promise => { 5 | const res = await request.get(`/preset/ffmpeg`); 6 | return res.data; 7 | }; 8 | 9 | const options = async (): Promise< 10 | { 11 | value: string; 12 | label: string; 13 | children: { 14 | value: string; 15 | label: string; 16 | config: FfmpegOptions; 17 | }[]; 18 | }[] 19 | > => { 20 | const res = await request.get(`/preset/ffmpeg/options`); 21 | return res.data; 22 | }; 23 | 24 | const get = async (id: string): Promise => { 25 | const res = await request.get(`/preset/ffmpeg/${id}`); 26 | return res.data; 27 | }; 28 | 29 | const add = async (preset: FfmpegPreset) => { 30 | return request.post(`/preset/ffmpeg`, preset); 31 | }; 32 | 33 | const remove = async (id: string) => { 34 | return request.delete(`/preset/ffmpeg/${id}`); 35 | }; 36 | 37 | const update = async (id: string, preset: FfmpegPreset) => { 38 | return request.put(`/preset/ffmpeg/${id}`, preset); 39 | }; 40 | 41 | const save = async (preset: FfmpegPreset) => { 42 | if (preset.id) { 43 | return update(preset.id, preset); 44 | } else { 45 | return add(preset); 46 | } 47 | }; 48 | 49 | const ffmpegPreset = { 50 | list, 51 | get, 52 | add, 53 | remove, 54 | update, 55 | save, 56 | options, 57 | }; 58 | 59 | export default ffmpegPreset; 60 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs Build Verification 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | 14 | steps: 15 | - name: Check out Git repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: ".node-version" 22 | cache: "npm" 23 | cache-dependency-path: "docs/package-lock.json" 24 | 25 | - name: Install dependencies 26 | working-directory: ./docs 27 | run: npm ci 28 | 29 | - name: Build docs 30 | working-directory: ./docs 31 | run: npm run docs:build 32 | 33 | # - name: Upload build artifacts 34 | # if: success() 35 | # uses: actions/upload-artifact@v4 36 | # with: 37 | # name: docs-build 38 | # path: docs/.vitepress/dist 39 | # retention-days: 7 40 | 41 | # - name: Build summary 42 | # if: success() 43 | # run: | 44 | # echo "### ✅ Docs build successful" >> $GITHUB_STEP_SUMMARY 45 | # echo "" >> $GITHUB_STEP_SUMMARY 46 | # echo "**Build info:**" >> $GITHUB_STEP_SUMMARY 47 | # echo "- Commit: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY 48 | # echo "- Branch/Tag: ${{ github.ref }}" >> $GITHUB_STEP_SUMMARY 49 | # echo "- Triggered by: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY 50 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/setting/VideoSetting.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 42 | 43 | 48 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/components/EditableText.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 45 | 46 | 64 | -------------------------------------------------------------------------------- /packages/http/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@biliLive-tools/http", 3 | "version": "3.5.1", 4 | "type": "module", 5 | "description": "", 6 | "main": "./lib/index.js", 7 | "author": "renmu123", 8 | "license": "GPL-3.0", 9 | "homepage": "https://github.com/renmu123/biliLive-tools", 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "development": "./src/index.ts", 14 | "default": "./lib/index.js" 15 | }, 16 | "./*.js": { 17 | "types": "./src/*.ts", 18 | "development": "./src/*.ts", 19 | "default": "./lib/*.js" 20 | } 21 | }, 22 | "scripts": { 23 | "build": "pnpm run test && pnpm run typecheck && tsc", 24 | "dev": "tsc -w", 25 | "start": "node ./lib/index.js", 26 | "start:dev": "tsx watch src/index.ts", 27 | "typecheck": "tsc --noEmit -p tsconfig.json --composite false", 28 | "test": "vitest run" 29 | }, 30 | "keywords": [], 31 | "dependencies": { 32 | "@bililive-tools/manager": "workspace:*", 33 | "@biliLive-tools/shared": "workspace:*", 34 | "@biliLive-tools/types": "workspace:*", 35 | "@koa/bodyparser": "^5.1.1", 36 | "@koa/cors": "^5.0.0", 37 | "chokidar": "^3.6.0", 38 | "cli-progress": "^3.12.0", 39 | "koa": "^2.15.3", 40 | "@koa/router": "^14.0.0", 41 | "koa-sse-stream": "^0.2.0", 42 | "multer": "1.4.5-lts.1", 43 | "zod": "^3.23.8" 44 | }, 45 | "devDependencies": { 46 | "@types/cli-progress": "^3.11.6", 47 | "@types/koa": "^2.15.0", 48 | "@types/koa__router": "^12.0.5", 49 | "tsx": "^4.19.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/app/electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { defineConfig, externalizeDepsPlugin } from "electron-vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | import AutoImport from "unplugin-auto-import/vite"; 5 | import Components from "unplugin-vue-components/vite"; 6 | import { NaiveUiResolver } from "unplugin-vue-components/resolvers"; 7 | 8 | import { version } from "./package.json"; 9 | 10 | process.env.VITE_VERSION = version; 11 | 12 | export default defineConfig({ 13 | main: { 14 | plugins: [ 15 | externalizeDepsPlugin({ 16 | exclude: ["@biliLive-tools/shared", "@biliLive-tools/http", "@biliLive-tools/types"], 17 | }), 18 | ], 19 | }, 20 | preload: { 21 | plugins: [externalizeDepsPlugin()], 22 | }, 23 | renderer: { 24 | server: { 25 | port: 28080, 26 | }, 27 | resolve: { 28 | alias: { 29 | "@renderer": resolve("src/renderer/src"), 30 | "@types": resolve("src/types"), 31 | }, 32 | }, 33 | plugins: [ 34 | vue({ 35 | script: { 36 | defineModel: true, 37 | }, 38 | }), 39 | AutoImport({ 40 | imports: [ 41 | "vue", 42 | { 43 | "naive-ui": ["useDialog", "useMessage", "useNotification", "useLoadingBar"], 44 | }, 45 | "pinia", 46 | { 47 | "@renderer/hooks/useNotice": ["useNotice"], 48 | }, 49 | ], 50 | }), 51 | Components({ 52 | resolvers: [NaiveUiResolver()], 53 | }), 54 | ], 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /packages/CLI/README.md: -------------------------------------------------------------------------------- 1 | # 介绍 2 | 3 | [biliLive-tools](https://github.com/renmu123/biliLive-tools)的命令行版本 4 | 5 | # 安装 6 | 7 | `npm i bililive-cli -g` 8 | 9 | 也有可能需要使用 10 | 11 | `npm i bililive-cli -g --force` 12 | 13 | 14 | 15 | # 使用 16 | 17 | ## 配置 18 | 19 | 使用前通过 `biliLive config gen` 生成默认配置文件,如果你已经安装客户端,相关配置会被自动设置(仅限win) 20 | 如果你仅使用上传功能,那么无需配置二进制文件,二进制文件可在[这里](https://github.com/renmu123/biliLive-tools/releases/tag/0.2.1)找到,以`platform--arch-version`命名,如果没有当前版本,以最近版本为准,配置为绝对路径以及分配执行权限 21 | 22 | ```js 23 | { 24 | port: 18010, // 启动端口,如果不希望与客户端的冲突,请修改为其他端口号 25 | host: "127.0.0.1", // host,如果需要暴露出去,可以修改为0.0.0.0 26 | configFolder: "config", // 配置文件夹 27 | ffmpegPath: "ffmpeg.exe", // ffmpeg二进制路径 28 | ffprobePath: "ffprobe.exe", // ffprobe二进制路径 29 | danmakuFactoryPath: "DanmakuFactory.exe", // DanmakuFactory二进制路径 30 | logPath: "main.log", // log文件路径,绝对路径 31 | mesioPath: "mesio.exe", 32 | bililiveRecorderPath: "BililiveRecorder.Cli.exe", 33 | audiowaveformPath: "audiowaveform.exe" 34 | } 35 | ``` 36 | 37 | ## 运行 38 | 39 | ```bash 40 | Usage: biliLive server 41 | 42 | 启动web服务器 43 | 44 | Options: 45 | -c, --config 配置文件 46 | -h, --host host,覆盖配置文件的参数,可选 47 | -p, --port port,覆盖配置文件的参数,可选 48 | ``` 49 | 50 | ## webui 51 | 52 | 鉴权密钥为 `config/appConfig.json` 的 `passKey` 参数,或者设置 `BILILIVE_TOOLS_PASSKEY` 环境变量 53 | 54 | 你可以选择线上页面:https://bililive.irenmu.com/ 55 | 56 | 或者也可以自行[部署](https://github.com/renmu123/biliLive-webui) 57 | -------------------------------------------------------------------------------- /packages/liveManager/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.11.0 2 | 3 | - 录播姬引擎支持切割 4 | - 录播姬引擎不再默认显示日志文件 5 | - mesio 支持 0.3.5 版本的智能停止 6 | - 修复某些情况下弹幕文件命名错误的情况 7 | 8 | # 1.10.0 9 | 10 | - 重构:录制器相关的参数修改为 `Downloader` 11 | - 修复:录播姬引擎分段时间不支持浮点数 12 | - segment 参数如果以"B","KB","MB","GB"结尾,会使用文件大小分段 13 | 14 | # 1.9.0 15 | 16 | - `recordHandle` 新增参数 `recorderType` 17 | - mesio 引擎默认禁用任何代理 18 | - 优化弹幕内存占用 19 | - 录制:优化ffmpeg默认参数,fmp4使用m4s后缀 [#224](https://github.com/renmu123/biliLive-tools/pull/224) 20 | - savePathRule 参数支持 `startTime` `recordStartTime` `liveStartTime` 参数 21 | 22 | # 1.8.0 23 | 24 | - 标题黑名单增加额外状态 25 | - 新增`debugLevel`参数,支持`none`、`basic`、`verbose` 26 | - 增加`maxThreadCount`参数任务并发数 27 | - 增加`waitTime`支持任务结束后的等待时间 28 | - 录播姬引擎支持 29 | 30 | # 1.7.0 31 | 32 | - 修复 mesio 某些情况下录制结束重命名错误的bug 33 | - 弹幕使用xml流式写入 34 | 35 | # 1.6.0 36 | 37 | - 支持 `recorderType` 参数用于配置底层录制器,支持`auto | ffmpeg | mesio` 38 | - 修复未设置分段时录制音频时不触发文件创建和结束时间的bug 39 | - 部分事件的参数修改为序列化参数 40 | - 新增`recordRetryImmediately`即“录制错误立即重试”选项,用于在触发"invalid stream"后自动重试,一场直播最多触发五次,不对虎牙生效 41 | 42 | # 1.4.1 43 | 44 | - `resolveChannelInfoFromURL` 新增返回参数:`avatar` 45 | 46 | # 1.4.0 47 | 48 | - `savePathRule` 支持 [ejs](https://ejs.co/) 模板引擎 49 | - 修复 `.mp4` 及 `.mkv` 录制出错时数据不完整的bug 50 | - 支持 `useServerTimestamp` 控制弹幕是否使用服务端时间戳 51 | 52 | # 1.3.0 53 | 54 | 1. 分P时获取更加准确的标题以及封面信息 55 | 56 | # 1.2.1 57 | 58 | - 修复 `videoFormat=auto` 时开启分段结束后的重命名错误 59 | 60 | # 1.2.0 61 | 62 | 1. 支持 `videoFormat`参数: "auto", "ts", "mkv" 63 | 64 | # 1.1.0 65 | 66 | ## 功能 67 | 68 | 1. 增加虎牙 `quality` 类型、`api`,`formatName` 参数支持 69 | 2. 封面保存现在在这个包中实现了 70 | 71 | # 1.0.1 72 | 73 | ## Bug fix 74 | 75 | 1. 修复缺少依赖的bug 76 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/user.ts: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | import { generateHMACSHA256 } from "../utils"; 3 | 4 | /** 5 | * @description Get user list 6 | */ 7 | export const getUserList = async (): Promise< 8 | { 9 | uid: number; 10 | name: string; 11 | face?: string; 12 | expires: number; 13 | }[] 14 | > => { 15 | const res = await request.get(`/user/list`); 16 | return res.data; 17 | }; 18 | 19 | /** 20 | * @description Refresh user info 21 | */ 22 | const refresh = async (uid: number) => { 23 | const res = await request.post(`/user/update`, { 24 | uid, 25 | }); 26 | return res.data; 27 | }; 28 | 29 | /** 30 | * @description Delete user 31 | */ 32 | const deleteUser = async (uid: number) => { 33 | const res = await request.post(`/user/delete`, { 34 | uid, 35 | }); 36 | return res.data; 37 | }; 38 | 39 | const updateAuth = async (uid: number) => { 40 | const res = await request.post(`/user/update_auth`, { 41 | uid, 42 | }); 43 | return res.data; 44 | }; 45 | 46 | const getCookie = async (uid: number) => { 47 | const timestamp = Math.floor(Date.now() / 1000); 48 | const secret = "r96gkr8ahc34fsrewr34"; 49 | const signature = await generateHMACSHA256(`${uid}${timestamp}`, secret); 50 | 51 | const res = await request.post(`/user/get_cookie`, { 52 | uid, 53 | timestamp, 54 | signature, 55 | }); 56 | const data = res.data; 57 | 58 | return data; 59 | }; 60 | 61 | const userApi = { 62 | getList: getUserList, 63 | refresh, 64 | delete: deleteUser, 65 | updateAuth, 66 | getCookie, 67 | }; 68 | 69 | export default userApi; 70 | -------------------------------------------------------------------------------- /scripts/github-ci-pnpm-update.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { exec } from "child_process"; 3 | 4 | async function getPnpmVersion() { 5 | return new Promise((resolve, reject) => { 6 | exec("pnpm --version", (error, stdout, stderr) => { 7 | if (error) { 8 | console.error(`执行错误: ${error}`); 9 | reject(error); 10 | return; 11 | } 12 | if (stderr) { 13 | console.error(`标准错误: ${stderr}`); 14 | reject(stderr); 15 | return; 16 | } 17 | const pnpmVersion = stdout.trim(); 18 | console.log(`当前 pnpm 版本: ${pnpmVersion}`); 19 | resolve(pnpmVersion); 20 | }); 21 | }); 22 | } 23 | 24 | // https://github.com/pnpm/pnpm/issues/5638 25 | async function updatePnpm() { 26 | if (process.platform !== "win32") return; 27 | 28 | const version = await getPnpmVersion(); 29 | // C:\Users\runneradmin\setup-pnpm\node_modules\.pnpm\pnpm@9.6.0\node_modules\pnpm\bin\pnpm.cjs 30 | // github的runner环境中,pnpm安装在C:\Users\runneradmin\setup-pnpm\node_modules\.pnpm 31 | const filePath = `C:\\Users\\runneradmin\\setup-pnpm\\node_modules\\.pnpm\\pnpm@${version}\\node_modules\\pnpm\\bin\\pnpm.cjs`; 32 | // 修改第一行为 #!node 33 | fs.readFile(filePath, "utf8", (err, data) => { 34 | if (err) { 35 | console.error(err); 36 | throw err; 37 | } 38 | const result = data.replace(/^#!.*\n/, "#!node\n"); 39 | fs.writeFile(filePath, result, "utf8", (err) => { 40 | if (err) { 41 | console.error(err); 42 | return; 43 | } 44 | console.log("修改 pnpm.cjs 成功", result); 45 | }); 46 | }); 47 | } 48 | 49 | updatePnpm(); 50 | -------------------------------------------------------------------------------- /packages/types/src/preset.ts: -------------------------------------------------------------------------------- 1 | import { type } from "arktype"; 2 | 3 | const fontSizeResponsiveParam = type(["number", "number"]); 4 | 5 | export const danmuConfig = type({ 6 | resolution: ["number > 0", "number > 0"], 7 | scrolltime: "number", 8 | fixtime: "number", 9 | density: "number", 10 | customDensity: "number", 11 | fontname: "string", 12 | fontsize: "number > 0", 13 | /** 百分制下的透明度 */ 14 | opacity100: "number > 0", 15 | outline: "number", 16 | "outline-blur": "number >= 0", 17 | "outline-opacity-percentage": "0 <= number <= 100", 18 | shadow: "number", 19 | displayarea: "number", 20 | scrollarea: "number", 21 | bold: "boolean", 22 | showusernames: "boolean", 23 | saveblocked: "boolean", 24 | showmsgbox: "boolean", 25 | msgboxsize: ["number", "number"], 26 | msgboxpos: ["number", "number"], 27 | msgboxfontsize: "number", 28 | msgboxduration: "number", 29 | giftminprice: "number", 30 | blockmode: '("R2L" | "L2R" | "TOP" | "BOTTOM" | "SPECIAL" | "COLOR" | "REPEAT")[]', 31 | statmode: "string[]", 32 | /** 分辨率自适应 */ 33 | resolutionResponsive: "boolean", 34 | /** 字体大小自适应 */ 35 | fontSizeResponsive: "boolean", 36 | /** 字体大小自适应参数, */ 37 | fontSizeResponsiveParams: fontSizeResponsiveParam.array(), 38 | blacklist: "string", 39 | filterFunction: "string", 40 | "blacklist-regex": "boolean", 41 | "line-spacing": "number", 42 | timeshift: "number", 43 | }); 44 | export type DanmuConfig = typeof danmuConfig.infer; 45 | 46 | // 弹幕预设配置 47 | export const danmuPresetSchema = type({ 48 | id: "string", 49 | name: "string", 50 | config: danmuConfig, 51 | }); 52 | 53 | export type DanmuPreset = typeof danmuPresetSchema.infer; 54 | -------------------------------------------------------------------------------- /packages/CLI/scripts/copy_module.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | // TODO:显然其他类型打包还有问题 7 | const cli_node_modules = path.resolve(__dirname, "../lib/node_modules"); 8 | const pnpm_node_modules = path.resolve(__dirname, "../../../node_modules"); 9 | 10 | // console.log("__dirname", __dirname, pnpm_node_modules); 11 | 12 | function main() { 13 | // 找到@napi-rs相关包,复制到cli_node_modules,这个路径可能不存在,不存在则创建 14 | if (!fs.existsSync(cli_node_modules)) { 15 | fs.mkdirSync(cli_node_modules); 16 | } 17 | // 复制canvas相关文件 18 | fs.cpSync(path.join(pnpm_node_modules, "@napi-rs"), path.join(cli_node_modules, "@napi-rs"), { 19 | recursive: true, 20 | }); 21 | // 复制ntsuspend相关文件, 22 | fs.cpSync(path.join(pnpm_node_modules, "ntsuspend"), path.join(cli_node_modules, "ntsuspend"), { 23 | recursive: true, 24 | }); 25 | // 复制font-list相关文件, 26 | fs.cpSync(path.join(pnpm_node_modules, "font-ls"), path.join(cli_node_modules, "font-ls"), { 27 | recursive: true, 28 | }); 29 | // 复制better-sqlite3相关文件, 30 | fs.cpSync( 31 | path.join(pnpm_node_modules, "better-sqlite3"), 32 | path.join(cli_node_modules, "better-sqlite3"), 33 | { 34 | recursive: true, 35 | }, 36 | ); 37 | fs.cpSync( 38 | path.join(pnpm_node_modules, "file-uri-to-path"), 39 | path.join(cli_node_modules, "file-uri-to-path"), 40 | { 41 | recursive: true, 42 | }, 43 | ); 44 | fs.cpSync(path.join(pnpm_node_modules, "bindings"), path.join(cli_node_modules, "bindings"), { 45 | recursive: true, 46 | }); 47 | } 48 | 49 | main(); 50 | -------------------------------------------------------------------------------- /packages/liveManager/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { assert } from "./utils.js"; 3 | 4 | const requester = axios.create({ 5 | timeout: 10e3, 6 | // axios 会自动读取环境变量中的 http_proxy 和 https_proxy 并应用, 7 | // 但会导致请求报错 "Client network socket disconnected before secure TLS connection was established"。 8 | proxy: false, 9 | }); 10 | 11 | interface BilibiliResp { 12 | code: number; 13 | message: string; 14 | msg?: string; 15 | data: T; 16 | } 17 | 18 | type LiveStatus = 19 | // 未开播 20 | | 0 21 | // 直播中 22 | | 1 23 | // 轮播中 24 | | 2; 25 | 26 | export async function getBiliStatusInfoByRoomIds(RoomIds: RoomId[]) { 27 | const roomParams = `${RoomIds.map((id) => `room_ids=${id}`).join("&")}`; 28 | const res = await requester.get< 29 | BilibiliResp<{ 30 | by_uids: {}; 31 | by_room_ids: Record< 32 | RoomId, 33 | { 34 | title: string; 35 | uname: string; 36 | live_time: string; 37 | live_status: LiveStatus; 38 | cover: string; 39 | is_encrypted: boolean; 40 | } 41 | >; 42 | }> 43 | >( 44 | `https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo?${roomParams}&req_biz=web_room_componet`, 45 | ); 46 | 47 | assert(res.data.code === 0, `Unexpected resp, code ${res.data.code}, msg ${res.data.message}`); 48 | 49 | const obj: Record = {}; 50 | for (const roomId of RoomIds) { 51 | try { 52 | const data = res.data.data.by_room_ids[roomId]; 53 | obj[roomId] = data?.live_status === 1; 54 | } catch (e) { 55 | continue; 56 | } 57 | } 58 | return obj; 59 | } 60 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/hooks/drive.ts: -------------------------------------------------------------------------------- 1 | import { driver } from "driver.js"; 2 | import { useStorage } from "@vueuse/core"; 3 | import "driver.js/dist/driver.css"; 4 | 5 | export const useDrive = () => { 6 | const state = useStorage("drive-store", { videoCut: false }, localStorage, { 7 | mergeDefaults: true, 8 | }); 9 | 10 | const videoCutDrive = () => { 11 | if (state.value.videoCut) return; 12 | const driverObj = driver({ 13 | showProgress: true, 14 | allowClose: false, 15 | onNextClick: (element: any) => { 16 | console.log("onNextClick", element); 17 | driverObj.moveNext(); 18 | }, 19 | steps: [ 20 | { 21 | element: ".cut-file-area", 22 | popover: { title: "导入视频", description: "你可以点击添加视频文件" }, 23 | }, 24 | { 25 | element: ".cut-add-segment", 26 | popover: { title: "添加片段", description: "在当前时间添加一个片段" }, 27 | }, 28 | { 29 | element: ".cut-video", 30 | popover: { title: "预览视频", description: "前进后退视频,在需要的地方切下" }, 31 | }, 32 | { 33 | element: ".cut-set-end", 34 | popover: { title: "设置结束时间", description: "设置片段的结束时间" }, 35 | }, 36 | { 37 | element: ".cut-search-danmu", 38 | popover: { title: "弹幕搜索", description: "点击后查询弹幕,快速添加片段" }, 39 | }, 40 | { 41 | element: ".cut-export", 42 | popover: { title: "导出", description: "所有片段处理完毕后,点击导出" }, 43 | }, 44 | ], 45 | onDestroyed: () => { 46 | state.value.videoCut = true; 47 | }, 48 | }); 49 | 50 | driverObj.drive(); 51 | }; 52 | return { videoCutDrive }; 53 | }; 54 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "biliLive-tools" 7 | text: "直播一站式处理工具" 8 | tagline: 支持弹幕转换、视频压制、自动上传B站、多平台直播录制 9 | image: 10 | src: /preview.png 11 | alt: biliLive-tools 12 | actions: 13 | - theme: brand 14 | text: 介绍 15 | link: /guide/introduction 16 | - theme: alt 17 | text: 详细功能 18 | link: /features/live-record 19 | - theme: alt 20 | text: GitHub 21 | link: https://github.com/renmu123/biliLive-tools 22 | 23 | features: 24 | - icon: 📹 25 | title: 多平台录制 26 | details: 支持B站、斗鱼、虎牙、抖音平台直播录制,支持弹幕,ffmpeg、mesio、录播姬多种引擎 27 | - icon: 🔄 28 | title: Webhook支持 29 | details: 支持录播姬、blrec、DDTV、oneliverec等录播工具的webhook集成,支持自动化上传到B站 30 | - icon: 📼 31 | title: 视频处理 32 | details: 支持各种ffmepg的操作、压制、转码、合并、FLV修复 33 | - icon: 🚀 34 | title: 虚拟录制 35 | details: 支持全平台录播平台的自动化操作 36 | - icon: 🎬 37 | title: 弹幕处理 38 | details: 支持 XML 弹幕转换与视频压制,完美兼容 DanmakuFactory 39 | - icon: ✂️ 40 | title: 视频切片 41 | details: 根据弹幕和高能进度条快速定位精彩片段,支持批量导出 42 | - icon: ☁️ 43 | title: 文件同步 44 | details: 支持百度网盘、阿里云盘、Alist等多种网盘进行录播自动同步 45 | - icon: ⚙️ 46 | title: 高度可定制 47 | details: 丰富的配置选项,支持自定义 FFmpeg 参数、压制预设等 48 | - icon: 🔔 49 | title: 多种通知 50 | details: 支持邮箱、Server酱、Telegram Bot、ntfy等多种通知方式 51 | --- 52 | 53 | ## 社区 54 | 55 | - QQ 群:872011161 56 | - [B站系列视频教程](https://www.bilibili.com/video/BV1Hs421M755/) 57 | - [GitHub Issues](https://github.com/renmu123/biliLive-tools/issues) 58 | 59 | ## 赞赏支持 60 | 61 | 如果本项目对你有帮助,请我喝瓶快乐水吧 🥤 62 | 63 | - [爱发电](https://afdian.com/a/renmu123) 64 | - [B站充电](https://space.bilibili.com/10995238) 65 | -------------------------------------------------------------------------------- /packages/shared/src/db/model/streamer.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import BaseModel from "./baseModel.js"; 3 | 4 | import type { Database } from "better-sqlite3"; 5 | 6 | const BaseStreamer = z.object({ 7 | name: z.string(), 8 | platform: z.string(), // "Bilibili" | "DouYu" | "HuYa" | "unknown" 9 | room_id: z.string(), 10 | }); 11 | 12 | const Streamer = BaseStreamer.extend({ 13 | id: z.number(), 14 | created_at: z.number().optional(), 15 | }); 16 | 17 | export type BaseStreamer = z.infer; 18 | export type Streamer = z.infer; 19 | 20 | export default class StreamerModel extends BaseModel { 21 | constructor({ db }: { db: Database }) { 22 | super(db, "streamer"); 23 | this.createTable(); 24 | } 25 | 26 | async createTable() { 27 | const createTableSQL = ` 28 | CREATE TABLE IF NOT EXISTS ${this.tableName} ( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键 30 | name TEXT NOT NULL, -- 主播名 31 | room_id TEXT NOT NULL, -- 房间id 32 | platform TEXT DEFAULT unknown, -- 平台,bilibili,douyu,unknown 33 | created_at INTEGER DEFAULT (strftime('%s', 'now')), -- 创建时间,时间戳,自动生成 34 | UNIQUE(platform, room_id) -- 唯一联合约束 35 | ) STRICT; 36 | `; 37 | return super.createTable(createTableSQL); 38 | } 39 | 40 | add(options: BaseStreamer) { 41 | const data = BaseStreamer.parse(options); 42 | return this.insert(data); 43 | } 44 | 45 | addMany(list: BaseStreamer[]) { 46 | const filterList = list.map((item) => BaseStreamer.parse(item)); 47 | return this.insertMany(filterList); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/guide/introduction.md: -------------------------------------------------------------------------------- 1 | # 介绍 2 | 3 | ## 什么是 biliLive-tools? 4 | 5 | biliLive-tools 是一个直播的一站式处理工具,支持弹幕转换与视频压制并上传至B站,支持多平台直播录制。 6 | 7 | ![Downloads](https://img.shields.io/github/downloads/renmu123/biliLive-tools/total) 8 | 9 | ## 主要特性 10 | 11 | ### 🎯 开箱即用 12 | 13 | 软件的目标是开箱即用,体验优先。默认配置下满足大部分人使用需求,同时支持个性化配置来增加可用性。 14 | 15 | ### 🔄 一站式处理 16 | 17 | 做这款工具的初衷是为了解决录播工具的碎片化。往往想完整处理一场带有弹幕的录播要使用多个软件的配合,一些工具只有CLI,加大了使用难度。 18 | 19 | ### 🚀 全自动化 20 | 21 | - 支持录播姬、blrec、DDTV、oneliverec的webhook自动化处理 22 | - 支持自动弹幕转换、视频压制 23 | - 支持自动上传至B站 24 | - 支持自动同步到云存储平台 25 | 26 | ### 📹 多平台录制 27 | 28 | 支持以下平台的直播录制: 29 | 30 | - **B站**:支持所有画质、多种流格式、Cookie登录 31 | - **斗鱼**:支持多画质、多线路 32 | - **虎牙**:支持多画质、多线路 33 | - **抖音**:支持多画质、Cookie登录 34 | 35 | 所有平台均支持弹幕录制,部分平台支持礼物、SC、舰长等信息。 36 | 37 | ### 🎬 专业的视频处理 38 | 39 | - **弹幕转换**:基于 DanmakuFactory,完美兼容 40 | - **弹幕压制**:支持将弹幕压制到视频中 41 | - **视频转码**:支持 FFmpeg 转码及转封装 42 | - **FLV修复**:支持非标准FLV文件的修复 43 | - **视频切片**:根据弹幕和高能进度条快速切片 44 | 45 | ### ☁️ 云端同步 46 | 47 | 支持将录制的视频自动同步到: 48 | 49 | - 百度网盘(BaiduPCS-Go) 50 | - 阿里云盘(aliyunpan) 51 | - Alist 52 | - 123网盘 53 | 54 | ## 适用场景 55 | 56 | ### 录播man 57 | 58 | 如果你正在寻找: 59 | 60 | - XML弹幕转换工具 61 | - 弹幕压制工具 62 | - Webhook自动上传工具 63 | - 录播视频下载工具 64 | 65 | 那么 biliLive-tools 非常适合你! 66 | 67 | ### 切片man 68 | 69 | 如果你需要: 70 | 71 | - 快速定位精彩片段 72 | - 批量处理视频切片 73 | - 下载录播视频并切片 74 | 75 | biliLive-tools 也能满足你的需求! 76 | 77 | ## 设计理念 78 | 79 | 1. **开箱即用**:默认配置满足大部分使用需求 80 | 2. **体验优先**:提供直观的图形界面,降低使用门槛 81 | 3. **高度可定制**:支持丰富的自定义选项 82 | 4. **全平台支持**:提供桌面程序、CLI、Docker、Web等多种使用方式 83 | 84 | ## 技术栈 85 | 86 | - **桌面程序**:Electron + Vue 3 + TypeScript 87 | - **CLI**:Node.js + TypeScript 88 | - **Docker**:Frontend + Backend 分离部署 89 | - **视频处理**:FFmpeg 90 | - **弹幕处理**:DanmakuFactory 91 | - **录制引擎**:FFmpeg、mesio、录播姬 92 | -------------------------------------------------------------------------------- /packages/http/src/types/webhook.d.ts: -------------------------------------------------------------------------------- 1 | export type Platform = string; 2 | export type PickPartial = Pick> & Partial>; 3 | export type UploadStatus = "pending" | "uploading" | "uploaded" | "error"; 4 | export type OpenEvent = "FileOpening"; 5 | export type CloseEvent = "FileClosed"; 6 | export type ErrorEvent = "FileError"; 7 | 8 | export interface Part { 9 | partId: string; 10 | title: string; 11 | startTime?: number; 12 | endTime?: number; 13 | // 录制状态, recording: 正在录制, recorded: 已录制, prehandled: 已处理完转码, handled: 已全部处理完成 14 | recordStatus: "recording" | "recorded" | "prehandled" | "handled"; 15 | // 处理后的文件路径,可能是弹幕版的 16 | filePath: string; 17 | // 处理后的文件路径上传状态 18 | uploadStatus: UploadStatus; 19 | cover?: string; // 封面 20 | // 原始文件路径 21 | rawFilePath: string; 22 | // 原始文件路径上传状态 23 | rawUploadStatus: UploadStatus; 24 | } 25 | 26 | export interface Options { 27 | event: OpenEvent | CloseEvent | ErrorEvent; 28 | filePath: string; 29 | roomId: string; 30 | time: string; 31 | username: string; 32 | title: string; 33 | coverPath?: string; 34 | danmuPath?: string; 35 | platform: Platform; 36 | } 37 | 38 | export interface CustomEvent { 39 | /** 如果你想使用断播续传功能,请在上一个`FileClosed`事件后在时间间隔内发送`FileOpening`事件 */ 40 | event: OpenEvent | CloseEvent; 41 | /** 视频文件的绝对路径 */ 42 | filePath: string; 43 | /** 房间号,用于断播续传需要 */ 44 | roomId: string; 45 | /** 用于标题格式化的时间,示例:"2021-05-14T17:52:54.946" */ 46 | time: string; 47 | /** 标题,用于格式化视频标题 */ 48 | title: string; 49 | /** 主播名称,用于格式化视频标题 */ 50 | username: string; 51 | /** 封面路径 */ 52 | coverPath?: string; 53 | /** 弹幕路径 */ 54 | danmuPath?: string; 55 | /** 平台名称,默认为 custom */ 56 | platform?: Platform; 57 | } 58 | -------------------------------------------------------------------------------- /packages/app/electron-builder-no-ffmpeg.yml: -------------------------------------------------------------------------------- 1 | appId: com.electron.biliLiveTools 2 | productName: biliLive-tools 3 | directories: 4 | buildResources: build 5 | files: 6 | - "!**/.vscode/*" 7 | - "!src/*" 8 | - "!electron.vite.config.{js,ts,mjs,cjs}" 9 | - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" 10 | - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" 11 | - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" 12 | - "!resources/bin/ffmpeg.exe" 13 | - "!resources/bin/ffprobe.exe" 14 | asarUnpack: 15 | - resources/** 16 | win: 17 | executableName: biliLive-tools 18 | nsis: 19 | artifactName: ${name}-${version}-no-ffmpeg-setup.${ext} 20 | shortcutName: ${productName} 21 | uninstallDisplayName: ${productName} 22 | createDesktopShortcut: always 23 | deleteAppDataOnUninstall: true 24 | mac: 25 | entitlementsInherit: build/entitlements.mac.plist 26 | extendInfo: 27 | - NSCameraUsageDescription: Application requests access to the device's camera. 28 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 29 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 30 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 31 | notarize: false 32 | dmg: 33 | artifactName: ${name}-${version}-no-ffmpeg.${ext} 34 | linux: 35 | target: 36 | - AppImage 37 | - snap 38 | - deb 39 | maintainer: electronjs.org 40 | category: Utility 41 | appImage: 42 | artifactName: ${name}-${version}-no-ffmpeg.${ext} 43 | npmRebuild: false 44 | publish: 45 | provider: generic 46 | url: https://example.com/auto-updates 47 | electronLanguages: 48 | - zh-CN 49 | - en-US 50 | -------------------------------------------------------------------------------- /packages/CLI/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bililive-cli", 3 | "version": "3.5.1", 4 | "type": "module", 5 | "description": "biliLive-tools的cli程序", 6 | "main": "./lib/index.js", 7 | "author": "renmu123", 8 | "license": "GPL-3.0", 9 | "homepage": "https://github.com/renmu123/biliLive-tools", 10 | "scripts": { 11 | "start": "tsx src/index.ts", 12 | "dev": "rollup --config rollup.config.js -w", 13 | "build:cli": "pnpm run build && pnpm run movePackage && pnpm run pkg", 14 | "build": "rimraf lib && rollup --config rollup.config.js", 15 | "movePackage": "node ./scripts/copy_module.js", 16 | "pkg": "pkg ./lib/index.cjs --output ./dist/biliLive", 17 | "zip:win": "cd dist && bestzip biliLive-cli-win-x64.zip biliLive.exe", 18 | "zip:linux": "cd dist && bestzip biliLive-cli-linux-x64.zip ./biliLive", 19 | "release": "pnpm run build && pnpm publish --access=public" 20 | }, 21 | "pkg": { 22 | "assets": "./lib/node_modules/**" 23 | }, 24 | "bin": { 25 | "biliLive": "./lib/index.cjs" 26 | }, 27 | "keywords": [ 28 | "biliLive-tools", 29 | "cli", 30 | "录播处理", 31 | "b站上传", 32 | "bilibili" 33 | ], 34 | "files": [ 35 | "lib" 36 | ], 37 | "dependencies": { 38 | "@napi-rs/canvas": "^0.1.60", 39 | "font-ls": "0.6.2", 40 | "ntsuspend": "^1.0.2", 41 | "better-sqlite3": "12.4.6" 42 | }, 43 | "devDependencies": { 44 | "@biliLive-tools/http": "workspace:*", 45 | "@biliLive-tools/shared": "workspace:*", 46 | "@biliLive-tools/types": "workspace:*", 47 | "@types/cli-progress": "^3.11.6", 48 | "@yao-pkg/pkg": "^6.1.1", 49 | "bestzip": "^2.2.1", 50 | "cli-progress": "^3.12.0", 51 | "commander": "^12.1.0", 52 | "rimraf": "^6.0.1", 53 | "tsx": "^4.19.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/shared/src/task/core/AbstractTask.ts: -------------------------------------------------------------------------------- 1 | import { TypedEmitter } from "tiny-typed-emitter"; 2 | import type { Status } from "@biliLive-tools/types"; 3 | 4 | import { uuid } from "../../utils/index.js"; 5 | import type { TaskEvents } from "./types.js"; 6 | 7 | /** 8 | * 任务抽象基类 9 | * 所有任务类型都应继承此类 10 | */ 11 | export abstract class AbstractTask { 12 | taskId: string; 13 | pid?: string; 14 | status: Status; 15 | name: string; 16 | relTaskId?: string; 17 | output?: string; 18 | progress: number; 19 | custsomProgressMsg: string; 20 | action: ("pause" | "kill" | "interrupt" | "restart")[]; 21 | startTime: number = 0; 22 | endTime?: number; 23 | error?: string; 24 | pauseStartTime: number | null = 0; 25 | totalPausedDuration: number = 0; 26 | emitter = new TypedEmitter(); 27 | limitTime?: [] | [string, string]; 28 | extra?: Record; 29 | on: TypedEmitter["on"]; 30 | emit: TypedEmitter["emit"]; 31 | 32 | abstract type: string; 33 | abstract exec(): void; 34 | abstract kill(): void; 35 | abstract pause(): void; 36 | abstract resume(): void; 37 | 38 | constructor() { 39 | this.taskId = uuid(); 40 | this.status = "pending"; 41 | this.name = this.taskId; 42 | this.progress = 0; 43 | this.action = ["pause", "kill"]; 44 | this.custsomProgressMsg = ""; 45 | this.on = this.emitter.on.bind(this.emitter); 46 | this.emit = this.emitter.emit.bind(this.emitter); 47 | } 48 | 49 | /** 50 | * 获取任务持续时间 51 | * @returns 持续时间(毫秒) 52 | */ 53 | getDuration(): number { 54 | if (this.status === "pending") return 0; 55 | const now = Date.now(); 56 | const currentTime = this.endTime || now; 57 | return Math.max(currentTime - this.startTime, 0); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/HuYaRecorder/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.11.0 2 | 3 | - 支持 `wup` 接口,`auto` 行为从星秀区使用`mp`改为使用`wup`接口 4 | - 录播姬引擎支持切割 5 | 6 | # 1.10.0 7 | 8 | - 重构:录制器相关的参数修改为 `Downloader` 9 | - 修复“画质匹配重试次数”不会被重置的bug 10 | - 修复 `qualityRetry` 修改后不会生效的bug 11 | - 修复:录播姬引擎分段时间不支持浮点数 12 | - segment 参数如果以"B","KB","MB","GB"结尾,会使用文件大小分段 13 | 14 | # 1.9.0 15 | 16 | - `recordHandle` 新增参数 `recorderType` 17 | - 录制:优化ffmpeg默认参数,fmp4使用m4s后缀 [#224](https://github.com/renmu123/biliLive-tools/pull/224) 18 | 19 | # 1.8.0 20 | 21 | - 新增`debugLevel`参数,支持`none`、`basic`、`verbose` 22 | - 支持标题黑名单 23 | - 触发标题黑名单设定额外状态 24 | - 录播姬引擎支持 25 | 26 | # 1.7.1 27 | 28 | - 修复检查错误状态不会被重置的bug 29 | 30 | # 1.7.0 31 | 32 | - 增加检查错误状态值 33 | 34 | # 1.6.0 35 | 36 | - let recorderType: "ffmpeg" | "mesio" = this.recorderType ?? "ffmpeg"; 37 | 38 | - 修复 `videoFormat=auto` 时某些情况下格式的判断 39 | - 支持真原画画质 40 | - 修复“画质匹配重试次数”不生效的bug 41 | 42 | # 1.3.2 43 | 44 | - 修复星秀区录制原画可能失败的bug 45 | - `resolveChannelInfoFromURL` 新增返回参数:`avatar` 46 | 47 | # 1.3.1 48 | 49 | - 修复获取未直播主播时的信息失败 50 | 51 | # 1.3.0 52 | 53 | 1. 分P时获取更加准确的标题以及封面信息 54 | 2. 废弃 `formatName` 参数,转为使用 `formatPriorities` 参数,默 55 | 3. 认为['flv','hls'] 56 | 57 | # 1.2.0 58 | 59 | 1. 支持 `videoFormat`参数: "auto", "ts", "mkv" 60 | 61 | # 1.1.1 62 | 63 | ## Bug修复 64 | 65 | 1. 修复虎牙星秀区无法录制的bug,感谢 https://github.com/ihmily/DouyinLiveRecorder/pull/993 66 | 67 | # 1.1.0 68 | 69 | ## 破坏性更改 70 | 71 | 1. `quality` 参数值修改,具体见文档 72 | 73 | ## 功能 74 | 75 | 1. 支持 `api` 参数,用于获取直播流时选择使用 `web` 还是 `mp` 接口,默认情况下星秀区使用mp,其他使用web接口 76 | 2. 支持 `sourcePriorities` 参数,按提供的源优先级去给CDN列表排序,并过滤掉不在优先级配置中的源,在未匹配到的情况下会优先使用TX的CDN,具体参数见 CDN 参数 77 | 3. 支持 `formatName` 参数,支持 flv,hls 参数,默认使用flv 78 | 4. 画质支持 蓝光20M,蓝光10M 79 | 80 | ## Bug修复 81 | 82 | 1. 修复画质无法生效的bug 83 | 2. 录制星秀专区不再破碎,默认使用 web 接口,如果检测到为星秀专区,使用 mp 接口 84 | 85 | # 1.0.1 86 | 87 | ## Bug fix 88 | 89 | 1. 修复缺少依赖的bug 90 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/assets/css/styles.less: -------------------------------------------------------------------------------- 1 | .center { 2 | text-align: center; 3 | } 4 | 5 | .flex { 6 | display: flex; 7 | } 8 | .inline-flex { 9 | display: inline-flex; 10 | } 11 | .flex.align-center, 12 | .inline-flex.align-center { 13 | align-items: center; 14 | } 15 | .flex.justify-center, 16 | .inline-flex.justify-center { 17 | justify-content: center; 18 | } 19 | .flex.column, 20 | .inline-flex.column { 21 | flex-direction: column; 22 | } 23 | 24 | .pointer { 25 | cursor: pointer; 26 | } 27 | 28 | /* 宽度和颜色可以根据需要进行调整 */ 29 | ::-webkit-scrollbar { 30 | width: 8px; /* 滚动条宽度 */ 31 | border-radius: 8px; 32 | } 33 | ::-webkit-scrollbar-track { 34 | background-color: none; /* 滚动条轨道背景颜色 */ 35 | } 36 | 37 | @media screen and (prefers-color-scheme: light) { 38 | ::-webkit-scrollbar-thumb { 39 | background-color: #e1e1e9; /* 滚动条滑块颜色 */ 40 | border-radius: 8px; 41 | } 42 | 43 | ::-webkit-scrollbar-thumb:hover { 44 | background-color: #6b6969; /* 鼠标悬停时滚动条滑块颜色 */ 45 | } 46 | } 47 | 48 | @media screen and (prefers-color-scheme: dark) { 49 | ::-webkit-scrollbar-thumb { 50 | background-color: #555; /* 滚动条滑块颜色 */ 51 | border-radius: 8px; 52 | } 53 | 54 | ::-webkit-scrollbar-thumb:hover { 55 | background-color: #383838; /* 鼠标悬停时滚动条滑块颜色 */ 56 | } 57 | } 58 | 59 | .n-modal-body-wrapper { 60 | max-height: 95%; 61 | margin: auto; 62 | } 63 | 64 | .n-modal-body-wrapper { 65 | .n-card__footer { 66 | position: sticky; 67 | bottom: 0; 68 | background: var(--n-color-modal); 69 | padding: 20px 40px; 70 | } 71 | } 72 | 73 | a[href^="http"] { 74 | color: skyblue; 75 | } 76 | 77 | .driver-popover-close-btn { 78 | display: flex !important; 79 | } 80 | 81 | .n-menu-item, 82 | .n-tabs-tab-wrapper { 83 | user-select: none; 84 | -webkit-user-drag: none; 85 | } 86 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/sync.ts: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | 3 | import type { SyncType } from "@biliLive-tools/types"; 4 | 5 | const syncTestUpload = async (data: { 6 | remoteFolder: string; 7 | type: SyncType; 8 | execPath?: string; 9 | apiUrl?: string; 10 | username?: string; 11 | password?: string; 12 | clientId?: string; 13 | clientSecret?: string; 14 | }) => { 15 | const res = await request.post(`/sync/uploadTest`, data); 16 | return res.data; 17 | }; 18 | 19 | const syncTestLogin = async (data: { 20 | execPath?: string; 21 | type: SyncType; 22 | apiUrl?: string; 23 | username?: string; 24 | password?: string; 25 | clientId?: string; 26 | clientSecret?: string; 27 | }) => { 28 | const res = await request.get(`/sync/isLogin`, { params: data }); 29 | return res.data; 30 | }; 31 | 32 | const baiduPCSLogin = async (data: { cookie: string; execPath: string }) => { 33 | const res = await request.post(`/sync/baiduPCSLogin`, data); 34 | return res.data; 35 | }; 36 | 37 | const aliyunpanLogin = async (data: { 38 | execPath: string; 39 | type: "getUrl" | "cancel" | "confirm"; 40 | }) => { 41 | const res = await request.get(`/sync/aliyunpanLogin`, { params: data }); 42 | return res.data; 43 | }; 44 | 45 | const pan123Login = async (data: { clientId: string; clientSecret: string }) => { 46 | const res = await request.post(`/sync/pan123Login`, data); 47 | return res.data; 48 | }; 49 | 50 | const sync = async (data: { 51 | file: string; 52 | type: SyncType; 53 | targetPath: string; 54 | options: { removeOrigin: boolean }; 55 | }) => { 56 | const res = await request.post(`/sync/sync`, data); 57 | return res.data; 58 | }; 59 | 60 | export default { 61 | syncTestUpload, 62 | syncTestLogin, 63 | baiduPCSLogin, 64 | aliyunpanLogin, 65 | pan123Login, 66 | sync, 67 | }; 68 | -------------------------------------------------------------------------------- /packages/shared/src/db/model/statistics.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import BaseModel from "./baseModel.js"; 3 | 4 | import type { Database } from "better-sqlite3"; 5 | 6 | const BaseStatistics = z.object({ 7 | stat_key: z.string(), 8 | value: z.string(), 9 | }); 10 | 11 | const Statistics = BaseStatistics.extend({ 12 | created_at: z.number(), 13 | }); 14 | 15 | export type BaseStatistics = z.infer; 16 | export type Statistics = z.infer; 17 | 18 | export default class StatisticsModel extends BaseModel { 19 | constructor({ db }: { db: Database }) { 20 | super(db, "statistics"); 21 | this.createTable(); 22 | } 23 | 24 | async createTable() { 25 | const createTableSQL = ` 26 | CREATE TABLE IF NOT EXISTS ${this.tableName} ( 27 | stat_key TEXT PRIMARY KEY, -- 键 28 | value TEXT NOT NULL, -- 值 29 | created_at INTEGER DEFAULT (strftime('%s', 'now')) -- 创建时间,时间戳,自动生成 30 | ) STRICT; 31 | `; 32 | return super.createTable(createTableSQL); 33 | } 34 | 35 | add(options: BaseStatistics) { 36 | const data = BaseStatistics.parse(options); 37 | return this.insert(data); 38 | } 39 | update(options: BaseStatistics) { 40 | const data = BaseStatistics.parse(options); 41 | const sql = `UPDATE ${this.tableName} SET value = ? WHERE stat_key = ?`; 42 | 43 | return this.db.prepare(sql).run(data.value, data.stat_key); 44 | } 45 | // upsert(options: { 46 | // where: { 47 | // stat_key: string; 48 | // }; 49 | // create: BaseStatistics; 50 | // }) { 51 | // return this.model.upsert(options); 52 | // } 53 | // query(stat_key: string): BaseStatistics | null { 54 | // return this.model.findOne({ where: { stat_key } }); 55 | // } 56 | } 57 | -------------------------------------------------------------------------------- /packages/DouYinRecorder/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.11.1 2 | 3 | - 修复某些情况下cookie不生效的bug 4 | - 优化弹幕重连逻辑 5 | 6 | # 1.11.0 7 | 8 | - 录播姬引擎支持切割 9 | 10 | # 1.10.0 11 | 12 | - 重构:录制器相关的参数修改为 `Downloader` 13 | - 修复某些接口时不会触发关键词检测的bug 14 | - 修复“画质匹配重试次数”不会被重置的bug 15 | - 修复 `qualityRetry` 修改后不会生效的bug 16 | - 修复:录播姬引擎分段时间不支持浮点数 17 | - segment 参数如果以"B","KB","MB","GB"结尾,会使用文件大小分段 18 | 19 | # 1.9.1 20 | 21 | - 修复 `userWeb` 接口在未直播时移除报错的bug 22 | 23 | # 1.9.0 24 | 25 | - `recordHandle` 新增参数 `recorderType` 26 | - 修复礼物弹幕缺失的情况 27 | - 修复两种接口的返回类型值错误 28 | - 修复使用 random 接口时,获取流可能失败的情况 29 | - 录制:优化ffmpeg默认参数,fmp4使用m4s后缀 [#224](https://github.com/renmu123/biliLive-tools/pull/224) 30 | 31 | # 1.8.0 32 | 33 | - 新增`debugLevel`参数,支持`none`、`basic`、`verbose` 34 | - 支持标题黑名单 35 | - 录播姬引擎支持 36 | - 触发标题黑名单设定额外状态 37 | 38 | # 1.7.1 39 | 40 | - 修复检查错误状态不会被重置的bug 41 | 42 | # 1.7.0 43 | 44 | - 支持 `mobile`、`userHTML`、`balance`、`random` 接口, 其中 `mobile`、`userHTML` 需要传递`uid`参数,内容为`sec_user_id` 45 | - 修复某些无法获取到直播间信息的查询 46 | - 增加检查错误状态值 47 | 48 | # 1.6.0 49 | 50 | - 支持 `recorderType` 参数用于配置底层录制器,支持`auto | ffmpeg | mesio` 51 | - 修复 `videoFormat=auto` 时某些情况下格式的判断 52 | - 支持用户主页解析 53 | - 支持客户端分享用户主页解析 54 | - 支持HTML解析接口 55 | - 修复“画质匹配重试次数”不生效的bug 56 | - 礼物支持真实价格 57 | 58 | # 1.5.3 59 | 60 | - 修复抖音调用接口错误 61 | 62 | # 1.5.2 63 | 64 | - `resolveChannelInfoFromURL` 新增返回参数:`avatar` 65 | - 增加更多链接的解析 66 | - 优化获取ttwid的策略 67 | 68 | # 1.5.1 69 | 70 | - 增加 `a_bogus` 修复抖音无法录制的bug 71 | 72 | # 1.5.0 73 | 74 | - 支持 `useServerTimestamp` 控制弹幕是否使用服务端时间戳 75 | 76 | # 1.4.0 77 | 78 | - 支持 `doubleScreen` 选项用来处理双屏录播流,开启后如果是双屏直播,那么就使用拼接的流 79 | 80 | # 1.3.0 81 | 82 | - 支持 `https://v.douyin.com/xxx/` 链接解析 83 | - 分P时获取更加准确的标题以及封面信息 84 | - 弹幕时间使用服务端时间 85 | - 支持 `auth` 参数,用于传递cookie 86 | - 支持 `formatPriorities` 用来控制 `flv`和`hls`流的优先级 87 | 88 | # 1.2.0 89 | 90 | 1. 支持 `videoFormat`参数: "auto", "ts", "mkv" 91 | 92 | # 1.0.1 93 | 94 | 1. 修复礼物弹幕时间戳错误 95 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/hooks/danmuPreset.ts: -------------------------------------------------------------------------------- 1 | import { danmuPresetApi } from "@renderer/apis"; 2 | import { useFileDialog } from "@vueuse/core"; 3 | import { uuid } from "@renderer/utils"; 4 | import { useDanmuPreset as useDanmuPresetStore } from "@renderer/stores"; 5 | 6 | import type { DanmuConfig } from "@biliLive-tools/types"; 7 | 8 | export const usePresetFile = () => { 9 | const notice = useNotice(); 10 | const { getDanmuPresets } = useDanmuPresetStore(); 11 | 12 | const exportPreset = async (config: DanmuConfig, name: string) => { 13 | const preset = config; 14 | const blob = new Blob([JSON.stringify(preset)], { type: "application/json" }); 15 | const url = URL.createObjectURL(blob); 16 | const a = document.createElement("a"); 17 | a.href = url; 18 | a.download = `${name}.json`; 19 | a.click(); 20 | URL.revokeObjectURL(url); 21 | }; 22 | 23 | const { open, onChange } = useFileDialog({ 24 | accept: ".json", // Set to accept only image files 25 | directory: false, // Select directories instead of files if set true 26 | multiple: false, 27 | }); 28 | 29 | onChange((files) => { 30 | if (!files) return; 31 | if (files.length === 0) return; 32 | importPreset(files[0]); 33 | }); 34 | 35 | const importPreset = async (file: File) => { 36 | const reader = new FileReader(); 37 | reader.onload = async (e) => { 38 | const config = JSON.parse(e.target?.result as string) as DanmuConfig; 39 | await danmuPresetApi.save({ 40 | id: uuid(), 41 | name: file.name.replace(".json", ""), 42 | config, 43 | }); 44 | notice.success({ 45 | title: "导入成功", 46 | duration: 1000, 47 | }); 48 | getDanmuPresets(); 49 | }; 50 | reader.readAsText(file); 51 | }; 52 | return { 53 | exportPreset, 54 | importPreset: open, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/request.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import router from "../routers/index"; 3 | 4 | const api = axios.create({ 5 | headers: { 6 | "Content-Type": "application/json", 7 | }, 8 | }); 9 | 10 | if (import.meta.hot) { 11 | window.isWeb = !window.api; 12 | init(); 13 | } 14 | 15 | export async function init() { 16 | if (window.isWeb) { 17 | const baseURL = window.localStorage.getItem("api"); 18 | if (baseURL) { 19 | api.defaults.baseURL = baseURL; 20 | } else { 21 | if (!import.meta.env.VITE_DEFAULT_SERVER) { 22 | api.defaults.baseURL = `http://127.0.0.1:18010`; 23 | } 24 | } 25 | } else { 26 | const appConfig = await window.api.config.getAll(); 27 | const protocol = appConfig.https ? "https" : "http"; 28 | api.defaults.baseURL = `${protocol}://127.0.0.1:${appConfig.port}`; 29 | api.defaults.headers.Authorization = appConfig.passKey; 30 | } 31 | } 32 | 33 | api.interceptors.request.use( 34 | (config) => { 35 | // header authorization 36 | const keyStorage = window.localStorage.getItem("key"); 37 | if (keyStorage) { 38 | config.headers.Authorization = keyStorage; 39 | } 40 | const baseURL = window.localStorage.getItem("api"); 41 | if (baseURL) { 42 | config.baseURL = baseURL; 43 | } 44 | return config; 45 | }, 46 | (error) => { 47 | return Promise.reject(error); 48 | }, 49 | ); 50 | 51 | api.interceptors.response.use( 52 | (response) => { 53 | return Promise.resolve(response); 54 | }, 55 | (error) => { 56 | const route = router.currentRoute.value; 57 | if (error?.response?.status === 401 && route.name !== "Login") { 58 | window.localStorage.removeItem("key"); 59 | router.push("/login"); 60 | } 61 | return Promise.reject(error?.response?.data ?? error?.response?.status); 62 | }, 63 | ); 64 | 65 | export default api; 66 | -------------------------------------------------------------------------------- /packages/shared/src/db/model/videoSubData.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import BaseModel from "./baseModel.js"; 3 | 4 | import type { Database } from "better-sqlite3"; 5 | 6 | const BaseVideoSubData = z.object({ 7 | subId: z.string(), 8 | platform: z.enum(["douyu", "huya"]), 9 | videoId: z.string(), 10 | // 是否完成,占位 11 | completed: z.union([z.literal(0), z.literal(1)]).default(1), 12 | // 重试次数,占位 13 | retry: z.number().default(0), 14 | }); 15 | 16 | const VideoSubData = BaseVideoSubData.extend({ 17 | id: z.number(), 18 | created_at: z.number(), 19 | }); 20 | 21 | export type BaseVideoSubData = z.infer; 22 | export type VideoSubData = z.infer; 23 | 24 | export default class VideoSubDataModel extends BaseModel { 25 | constructor({ db }: { db: Database }) { 26 | super(db, "video_sub_data"); 27 | this.createTable(); 28 | } 29 | 30 | async createTable() { 31 | const createTableSQL = ` 32 | CREATE TABLE IF NOT EXISTS ${this.tableName} ( 33 | id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键 34 | subId TEXT NOT NULL, -- 主要id,用于查询订阅 35 | platform TEXT NOT NULL, -- 平台,douyu,huya 36 | videoId TEXT NOT NULL, -- 视频id 37 | completed INTEGER DEFAULT 1, -- 是否完成 38 | retry INTEGER DEFAULT 0, -- 重试次数 39 | created_at INTEGER DEFAULT (strftime('%s', 'now')) -- 创建时间,时间戳,自动生成 40 | ) STRICT; 41 | `; 42 | return super.createTable(createTableSQL); 43 | } 44 | 45 | add(options: BaseVideoSubData) { 46 | const data = BaseVideoSubData.parse(options); 47 | return this.insert(data); 48 | } 49 | 50 | deleteById(id: number) { 51 | const sql = `DELETE FROM ${this.tableName} WHERE id = ?`; 52 | return this.db.prepare(sql).run(id); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/setting/TaskSetting.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /packages/liveManager/src/common.ts: -------------------------------------------------------------------------------- 1 | import { AnyObject, UnknownObject } from "./utils.js"; 2 | 3 | export type ChannelId = string; 4 | 5 | export const Qualities = ["lowest", "low", "medium", "high", "highest"] as const; 6 | export const DouyuQualities = [0, 2, 3, 4, 8] as const; 7 | // 14100: 2K HDR;14000:2K;4200:HDR(10M);0:原画;8000:蓝光8M;4000:蓝光4M;2000:超清;500:流畅 8 | export const HuYaQualities = [ 9 | 0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500, -1, 10 | ] as const; 11 | export const DouYinQualities = ["origin", "uhd", "hd", "sd", "ld", "ao", "real_origin"] as const; 12 | export type Quality = string | number; 13 | 14 | export interface MessageSender { 15 | uid?: string; 16 | name: string; 17 | avatar?: string; 18 | extra?: E; 19 | } 20 | 21 | export interface Comment { 22 | type: "comment"; 23 | timestamp: number; 24 | text: string; 25 | mode?: number; 26 | color?: string; 27 | sender?: MessageSender; 28 | extra?: E; 29 | } 30 | 31 | export interface GiveGift { 32 | type: "give_gift"; 33 | timestamp: number; 34 | name: string; 35 | count: number; 36 | price: number; 37 | text?: string; 38 | cost?: number; 39 | color?: string; 40 | sender?: MessageSender; 41 | extra?: E; 42 | } 43 | 44 | export interface Guard { 45 | type: "guard"; 46 | timestamp: number; 47 | name: string; 48 | count: number; 49 | price: number; 50 | level: number; 51 | text?: string; 52 | cost?: number; 53 | color?: string; 54 | sender?: MessageSender; 55 | extra?: E; 56 | } 57 | 58 | export interface SuperChat { 59 | type: "super_chat"; 60 | timestamp: number; 61 | text: string; 62 | price: number; 63 | sender?: MessageSender; 64 | extra?: E; 65 | } 66 | 67 | export type Message = Comment | GiveGift | SuperChat | Guard; 68 | -------------------------------------------------------------------------------- /packages/shared/src/db/service/recordHistoryService.ts: -------------------------------------------------------------------------------- 1 | import type RecordHistoryModel from "../model/recordHistory.js"; 2 | import type { BaseLiveHistory, LiveHistory } from "../model/recordHistory.js"; 3 | 4 | export default class RecordHistoryService { 5 | private recordHistoryModel: RecordHistoryModel; 6 | 7 | constructor({ recordHistoryModel }: { recordHistoryModel: RecordHistoryModel }) { 8 | this.recordHistoryModel = recordHistoryModel; 9 | } 10 | 11 | add(options: BaseLiveHistory) { 12 | return this.recordHistoryModel.add(options); 13 | } 14 | 15 | addMany(list: BaseLiveHistory[]) { 16 | return this.recordHistoryModel.addMany(list); 17 | } 18 | 19 | list(options: Partial): LiveHistory[] { 20 | return this.recordHistoryModel.list(options); 21 | } 22 | 23 | /** 24 | * 分页查询记录历史,支持时间范围过滤和排序 25 | * @param options 查询参数 26 | * @returns 分页结果 27 | */ 28 | paginate(options: { 29 | where: Partial; 30 | page?: number; 31 | pageSize?: number; 32 | startTime?: number; 33 | endTime?: number; 34 | orderBy?: string; 35 | orderDirection?: "ASC" | "DESC"; 36 | }): { data: LiveHistory[]; total: number } { 37 | return this.recordHistoryModel.paginateWithTimeRange(options); 38 | } 39 | 40 | query(options: Partial) { 41 | return this.recordHistoryModel.query(options); 42 | } 43 | 44 | update(options: Partial) { 45 | return this.recordHistoryModel.update(options); 46 | } 47 | 48 | /** 49 | * 删除单个录制历史记录 50 | * @param id 记录ID 51 | * @returns 删除的记录数量 52 | */ 53 | removeRecord(id: number): number { 54 | return this.recordHistoryModel.deleteBy("id", id); 55 | } 56 | 57 | /** 58 | * 批量删除录制历史记录 59 | * @param streamerId 主播ID 60 | * @returns 删除的记录数量 61 | */ 62 | removeRecordsByStreamerId(streamerId: number): number { 63 | return this.recordHistoryModel.deleteBy("streamer_id", streamerId); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/shared/src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import logger from "electron-log/node.js"; 4 | import type { LevelOption } from "electron-log"; 5 | 6 | logger.transports.file.maxSize = 1002430 * 5; // 5M 7 | logger.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}"; 8 | // logger.transports.file.resolvePathFn = () => path.join(app.getPath("logs"), `main.log`); 9 | 10 | export function initLogger(path: string, level: LevelOption) { 11 | logger.transports.file.resolvePathFn = () => path; 12 | setLogLevel(level); 13 | logger.transports.file.setAppName("biliLive-tools"); 14 | return logger; 15 | } 16 | 17 | export function setLogLevel(level: LevelOption) { 18 | logger.transports.file.level = level; 19 | } 20 | 21 | const clearAxiosLog = (args: any[]) => { 22 | return args.map((arg) => { 23 | try { 24 | if (axios.isAxiosError(arg)) { 25 | const axiosError = arg; 26 | if (axiosError?.config?.transformRequest) { 27 | axiosError.config.transformRequest = undefined; 28 | } 29 | if (axiosError?.config?.transformResponse) { 30 | axiosError.config.transformResponse = undefined; 31 | } 32 | if (axiosError?.config?.env) { 33 | axiosError.config.env = undefined; 34 | } 35 | if (axiosError?.config?.headers?.cookie) { 36 | delete axiosError.config.headers.cookie; 37 | } 38 | return axiosError; 39 | } 40 | } catch (e) { 41 | return arg; 42 | } 43 | return arg; 44 | }); 45 | }; 46 | 47 | const logObj = { 48 | info: (...args: any[]) => { 49 | logger.info(...clearAxiosLog(args)); 50 | }, 51 | error: (...args: any[]) => { 52 | logger.error(...clearAxiosLog(args)); 53 | }, 54 | warn: (...args: any[]) => { 55 | logger.warn(...clearAxiosLog(args)); 56 | }, 57 | debug: (...args: any[]) => { 58 | logger.debug(...clearAxiosLog(args)); 59 | }, 60 | }; 61 | 62 | export default logObj; 63 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/utils/fileSystem.ts: -------------------------------------------------------------------------------- 1 | import showDialog from "@renderer/components/showDirectoryDialog"; 2 | 3 | export const showSaveDialog = async (options: { 4 | defaultPath?: string; 5 | extension?: string; 6 | }): Promise => { 7 | if (window.isWeb) { 8 | const filePath = ( 9 | await showDialog({ 10 | type: "save", 11 | extension: options.extension ?? "mp4", 12 | defaultPath: options.defaultPath, 13 | }) 14 | )?.[0]; 15 | return filePath; 16 | } else { 17 | const outputPath = await window.api.showSaveDialog({ 18 | defaultPath: options.defaultPath, 19 | filters: [ 20 | { name: "文件", extensions: [options.extension ?? "mp4"] }, 21 | { name: "所有文件", extensions: ["*"] }, 22 | ], 23 | }); 24 | return outputPath; 25 | } 26 | }; 27 | 28 | export const showDirectoryDialog = async (options: { 29 | defaultPath?: string; 30 | }): Promise => { 31 | if (window.isWeb) { 32 | const filePath = ( 33 | await showDialog({ 34 | type: "directory", 35 | }) 36 | )?.[0]; 37 | return filePath; 38 | } else { 39 | const file = await window.api.openDirectory({ 40 | defaultPath: options.defaultPath, 41 | }); 42 | return file; 43 | } 44 | }; 45 | 46 | export const showFileDialog = async (options: { extensions: string[]; multi?: boolean }) => { 47 | let files: string[] | undefined = []; 48 | if (window.isWeb) { 49 | files = await showDialog({ 50 | type: "file", 51 | multi: options.multi, 52 | exts: options.extensions, 53 | }); 54 | } else { 55 | files = await window.api.openFile({ 56 | multi: options.multi, 57 | filters: [ 58 | { 59 | name: "file", 60 | extensions: options.extensions, 61 | }, 62 | { 63 | name: "所有文件", 64 | extensions: ["*"], 65 | }, 66 | ], 67 | }); 68 | } 69 | return files; 70 | }; 71 | -------------------------------------------------------------------------------- /packages/shared/test/sync/baiduPCS.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { BaiduPCS } from "../../src/sync/baiduPCS"; 3 | 4 | describe("BaiduPCS", () => { 5 | describe("parseProgress", () => { 6 | it("应该正确解析带索引的进度信息", () => { 7 | const baiduPCS = new BaiduPCS(); 8 | const mockEmit = vi.fn(); 9 | baiduPCS.emit = mockEmit; 10 | 11 | const progressOutput = "[1] ↑ 305.06MB/1.01GB 2.15MB/s in 33s"; 12 | baiduPCS["parseProgress"](progressOutput); 13 | 14 | expect(mockEmit).toHaveBeenCalledWith("progress", { 15 | index: 1, 16 | uploaded: "305.06MB", 17 | total: "1.01GB", 18 | speed: "2.15MB/s", 19 | elapsed: "33s", 20 | percentage: expect.any(Number), 21 | }); 22 | }); 23 | 24 | it("应该正确解析不带索引的进度信息", () => { 25 | const baiduPCS = new BaiduPCS(); 26 | const mockEmit = vi.fn(); 27 | baiduPCS.emit = mockEmit; 28 | 29 | const progressOutput = "↑ 500KB/1MB 100KB/s in 5s"; 30 | baiduPCS["parseProgress"](progressOutput); 31 | 32 | expect(mockEmit).not.toHaveBeenCalled(); 33 | }); 34 | 35 | it("应该忽略不匹配的进度信息", () => { 36 | const baiduPCS = new BaiduPCS(); 37 | const mockEmit = vi.fn(); 38 | baiduPCS.emit = mockEmit; 39 | 40 | const invalidOutput = "Some random text"; 41 | baiduPCS["parseProgress"](invalidOutput); 42 | 43 | expect(mockEmit).not.toHaveBeenCalled(); 44 | }); 45 | 46 | it("应该正确计算百分比", () => { 47 | const baiduPCS = new BaiduPCS(); 48 | const mockEmit = vi.fn(); 49 | baiduPCS.emit = mockEmit; 50 | 51 | const progressOutput = "[1] ↑ 512MB/1GB 2MB/s in 10s"; 52 | baiduPCS["parseProgress"](progressOutput); 53 | 54 | expect(mockEmit).toHaveBeenCalledWith("progress", { 55 | index: 1, 56 | uploaded: "512MB", 57 | total: "1GB", 58 | speed: "2MB/s", 59 | elapsed: "10s", 60 | percentage: 50, 61 | }); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /packages/app/electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.electron.biliLiveTools 2 | productName: biliLive-tools 3 | artifactName: ${productName}-${version}-${os}-${arch}.${ext} 4 | 5 | directories: 6 | buildResources: build 7 | files: 8 | - "!**/.vscode/*" 9 | - "!src/*" 10 | - "!electron.vite.config.{js,ts,mjs,cjs}" 11 | - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" 12 | - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" 13 | - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" 14 | asarUnpack: 15 | - resources/** 16 | win: 17 | executableName: biliLive-tools 18 | target: 19 | - target: nsis 20 | arch: x64 21 | - target: zip 22 | arch: x64 23 | 24 | nsis: 25 | shortcutName: ${productName} 26 | uninstallDisplayName: ${productName} 27 | createDesktopShortcut: always 28 | deleteAppDataOnUninstall: true 29 | oneClick: false 30 | allowToChangeInstallationDirectory: true 31 | portable: 32 | artifactName: ${productName}-${version}-${os}-${arch}-portable.${ext} 33 | 34 | mac: 35 | entitlementsInherit: build/entitlements.mac.plist 36 | extendInfo: 37 | - NSCameraUsageDescription: Application requests access to the device's camera. 38 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 39 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 40 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 41 | notarize: false 42 | dmg: 43 | # artifactName: ${name}-${version}.${ext} 44 | linux: 45 | target: 46 | # - AppImage 47 | # - snap 48 | # - target: snap 49 | # arch: x64 50 | - target: deb 51 | arch: x64 52 | - target: zip 53 | arch: x64 54 | maintainer: renmu123 55 | category: Utility 56 | appImage: 57 | # artifactName: ${name}-${version}.${ext} 58 | npmRebuild: false 59 | publish: 60 | provider: generic 61 | url: https://example.com/auto-updates 62 | electronLanguages: 63 | - zh-CN 64 | - en-US 65 | -------------------------------------------------------------------------------- /packages/shared/test/danmu/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from "vitest"; 2 | 3 | import { parseMetadata } from "../../src/danmu/index"; 4 | 5 | describe.concurrent("parseMetadata", () => { 6 | it("true", () => { 7 | expect(true).toBe(true); 8 | }); 9 | // it("should parse BililiveRecorderRecordInfo from XML object", () => { 10 | // const jObj = { 11 | // i: { 12 | // BililiveRecorderRecordInfo: { 13 | // "@_roomid": "27183290", 14 | // "@_shortid": "0", 15 | // "@_name": "雪糕cheese", 16 | // "@_title": "和塔宝妮妮一起玩恐怖游戏", 17 | // "@_areanameparent": "虚拟主播", 18 | // "@_areanamechild": "虚拟Gamer", 19 | // "@_start_time": "2024-07-31T19:02:41.6685322+08:00", 20 | // }, 21 | // }, 22 | // }; 23 | // const metadata = parseMetadata(jObj); 24 | // expect(metadata).toEqual({ 25 | // streamer: "雪糕cheese", 26 | // room_id: "27183290", 27 | // live_title: "和塔宝妮妮一起玩恐怖游戏", 28 | // live_start_time: 1722423761, 29 | // }); 30 | // }); 31 | // it("should parse metadata from XML object", async () => { 32 | // const jObj = { 33 | // i: { 34 | // metadata: { 35 | // user_name: "JohnDoe", 36 | // room_id: "123456", 37 | // room_title: "Test Room", 38 | // live_start_time: "2022-01-01T00:00:00Z", 39 | // }, 40 | // }, 41 | // }; 42 | // const metadata = await parseMetadata(jObj); 43 | // expect(metadata).toEqual({ 44 | // streamer: "JohnDoe", 45 | // room_id: "123456", 46 | // live_title: "Test Room", 47 | // live_start_time: 1640995200, 48 | // }); 49 | // }); 50 | // it("should handle missing metadata", () => { 51 | // const jObj = { 52 | // i: {}, 53 | // }; 54 | // const metadata = parseMetadata(jObj); 55 | // expect(metadata).toEqual({ 56 | // streamer: undefined, 57 | // room_id: undefined, 58 | // live_title: undefined, 59 | // live_start_time: undefined, 60 | // }); 61 | // }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/shared/test/sync/aliyunpan.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { AliyunPan } from "../../src/sync/aliyunpan"; 3 | 4 | describe("AliyunPan", () => { 5 | describe("parseProgress", () => { 6 | it("应该正确解析MB单位的进度信息", () => { 7 | const aliyunPan = new AliyunPan(); 8 | const mockEmit = vi.fn(); 9 | aliyunPan.emit = mockEmit; 10 | 11 | const progressOutput = "14.31MB/210.56MB(6.80%) 1.07MB/s"; 12 | aliyunPan["parseProgress"](progressOutput); 13 | 14 | expect(mockEmit).toHaveBeenCalledWith("progress", { 15 | uploaded: "14.31MB", 16 | total: "210.56MB", 17 | percentage: 6.8, 18 | speed: "1.07MB/s", 19 | }); 20 | }); 21 | 22 | it("应该正确解析GB单位的进度信息", () => { 23 | const aliyunPan = new AliyunPan(); 24 | const mockEmit = vi.fn(); 25 | aliyunPan.emit = mockEmit; 26 | 27 | const progressOutput = "1.5GB/2.0GB(75.00%) 50.0MB/s"; 28 | aliyunPan["parseProgress"](progressOutput); 29 | 30 | expect(mockEmit).toHaveBeenCalledWith("progress", { 31 | uploaded: "1.5GB", 32 | total: "2.0GB", 33 | percentage: 75.0, 34 | speed: "50.0MB/s", 35 | }); 36 | }); 37 | 38 | it("应该正确解析KB单位的进度信息", () => { 39 | const aliyunPan = new AliyunPan(); 40 | const mockEmit = vi.fn(); 41 | aliyunPan.emit = mockEmit; 42 | 43 | const progressOutput = "500KB/1000KB(50.00%) 100KB/s"; 44 | aliyunPan["parseProgress"](progressOutput); 45 | 46 | expect(mockEmit).toHaveBeenCalledWith("progress", { 47 | uploaded: "500KB", 48 | total: "1000KB", 49 | percentage: 50.0, 50 | speed: "100KB/s", 51 | }); 52 | }); 53 | 54 | it("应该忽略不匹配的进度信息", () => { 55 | const aliyunPan = new AliyunPan(); 56 | const mockEmit = vi.fn(); 57 | aliyunPan.emit = mockEmit; 58 | 59 | const invalidOutput = "Some random text"; 60 | aliyunPan["parseProgress"](invalidOutput); 61 | 62 | expect(mockEmit).not.toHaveBeenCalled(); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/hooks/useNotice.ts: -------------------------------------------------------------------------------- 1 | import { useNotification } from "naive-ui"; 2 | 3 | interface Options { 4 | title: string; 5 | content?: string; 6 | duration?: number; 7 | closable?: boolean; 8 | } 9 | 10 | export function useNotice() { 11 | const { info, success, warning, error } = useNotification(); 12 | return { 13 | info: (input: string | Options) => { 14 | const iOptions = { 15 | duration: 1500, 16 | keepAliveOnHover: true, 17 | }; 18 | if (typeof input === "object") { 19 | return info({ 20 | ...iOptions, 21 | ...input, 22 | }); 23 | } else { 24 | return info({ 25 | ...iOptions, 26 | title: input, 27 | }); 28 | } 29 | }, 30 | success: (input: string | Options) => { 31 | const iOptions = { 32 | duration: 1000, 33 | keepAliveOnHover: true, 34 | }; 35 | if (typeof input === "object") { 36 | return success({ 37 | ...iOptions, 38 | ...input, 39 | }); 40 | } else { 41 | return success({ 42 | ...iOptions, 43 | title: input, 44 | }); 45 | } 46 | }, 47 | warning: (input: string | Options) => { 48 | const iOptions = { 49 | duration: 1500, 50 | keepAliveOnHover: true, 51 | }; 52 | if (typeof input === "object") { 53 | return warning({ 54 | ...iOptions, 55 | ...input, 56 | }); 57 | } else { 58 | return warning({ 59 | ...iOptions, 60 | title: input, 61 | }); 62 | } 63 | }, 64 | error: (input: string | Options) => { 65 | const iOptions = { 66 | duration: 2000, 67 | keepAliveOnHover: true, 68 | }; 69 | if (typeof input === "object") { 70 | return error({ 71 | ...iOptions, 72 | ...input, 73 | }); 74 | } else { 75 | return error({ 76 | ...iOptions, 77 | title: input, 78 | }); 79 | } 80 | }, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /packages/http/src/routes/danma.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | 3 | import { mergeXml } from "@biliLive-tools/shared/task/danmu.js"; 4 | import { parseDanmu } from "@biliLive-tools/shared/danmu/index.js"; 5 | 6 | const router = new Router({ 7 | prefix: "/danma", 8 | }); 9 | 10 | router.post("/mergeXml", async (ctx) => { 11 | const { inputFiles, options } = ctx.request.body as { 12 | inputFiles: { videoPath: string; danmakuPath: string }[]; 13 | options: { 14 | output?: string; 15 | saveOriginPath: boolean; 16 | saveMeta?: boolean; 17 | }; 18 | }; 19 | await mergeXml(inputFiles, options); 20 | ctx.body = "OK"; 21 | }); 22 | 23 | function int2HexColor(color: number): string { 24 | let hex = color.toString(16); 25 | while (hex.length < 6) { 26 | hex = "0" + hex; 27 | } 28 | return `#${hex}`; 29 | } 30 | 31 | router.post("/parseForArtPlayer", async (ctx) => { 32 | const { filepath } = ctx.request.body as { 33 | filepath: string; 34 | }; 35 | const data = await parseDanmu(filepath); 36 | const danmuList: { 37 | text: string; // 弹幕文本 38 | time: number; // 弹幕时间, 默认为当前播放器时间 39 | mode: 0 | 1 | 2; // 弹幕模式: 0: 滚动(默认),1: 顶部,2: 底部 40 | color: string; // 弹幕颜色,默认为白色 41 | border: boolean; // 弹幕是否有描边, 默认为 false 42 | style: {}; // 弹幕自定义样式, 默认为空对象 43 | }[] = []; 44 | for (const item of data.danmu) { 45 | if (!item.text) continue; 46 | if (!item.p) continue; 47 | const pData = item.p.split(","); 48 | if (pData.length < 4) continue; 49 | 50 | let mode = 0; 51 | const rawMode = Number(pData[1]); 52 | if (rawMode < 4) { 53 | mode = 0; 54 | } else if (rawMode === 4) { 55 | mode = 2; 56 | } else if (rawMode === 5) { 57 | mode = 1; 58 | } else { 59 | continue; 60 | } 61 | 62 | danmuList.push({ 63 | text: item.text, 64 | time: Number(pData[0]), 65 | mode: mode as 0 | 1 | 2, 66 | color: int2HexColor(Number(pData[3]) || 16777215), 67 | border: false, 68 | style: {}, 69 | }); 70 | } 71 | ctx.body = danmuList; 72 | }); 73 | 74 | export default router; 75 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/apis/recordHistory.ts: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | 3 | /** 4 | * 查询直播记录列表 5 | * @param params 查询参数 6 | * @returns 查询结果 7 | */ 8 | export interface QueryRecordsParams { 9 | room_id: string; 10 | platform: string; 11 | page?: number; 12 | pageSize?: number; 13 | startTime?: number; 14 | endTime?: number; 15 | } 16 | 17 | export interface RecordHistoryItem { 18 | id: number; 19 | streamer_id: number; 20 | live_start_time: number; 21 | record_start_time: number; 22 | record_end_time?: number; 23 | title: string; 24 | video_file?: string; 25 | created_at: number; 26 | video_duration?: number; 27 | danma_num?: number; 28 | interact_num?: number; 29 | danma_density?: number | null; // 弹幕密度,弹幕数量/视频时长 30 | } 31 | 32 | export interface QueryRecordsResponse { 33 | code: number; 34 | data: RecordHistoryItem[]; 35 | pagination: { 36 | total: number; 37 | page: number; 38 | pageSize: number; 39 | }; 40 | } 41 | 42 | /** 43 | * 查询直播记录 44 | */ 45 | export async function queryRecords(params: QueryRecordsParams) { 46 | const res = await request.get("/record-history/list", { 47 | params, 48 | }); 49 | return res.data; 50 | } 51 | 52 | /** 53 | * 删除单个直播记录 54 | * @param id 记录ID 55 | * @returns 删除结果 56 | */ 57 | export async function removeRecord(id: number) { 58 | const res = await request.delete(`/record-history/${id}`); 59 | return res.data; 60 | } 61 | 62 | export async function getFileInfo(id: number): Promise<{ 63 | videoFileId: string; 64 | videoFileExt: string; 65 | videoFilePath: string; 66 | danmaFileId: string | null; 67 | danmaFileExt: string | null; 68 | danmaFilePath: string | null; 69 | }> { 70 | const res = await request.get(`/record-history/file/${id}`); 71 | return res.data; 72 | } 73 | 74 | /** 75 | * 下载视频文件 76 | */ 77 | export async function downloadFile(id: number): Promise { 78 | const { videoFileId } = await getFileInfo(id); 79 | const fileUrl = `${request.defaults.baseURL}/assets/download/${videoFileId}`; 80 | return fileUrl; 81 | } 82 | 83 | export default { 84 | queryRecords, 85 | removeRecord, 86 | downloadFile, 87 | getFileInfo, 88 | }; 89 | -------------------------------------------------------------------------------- /packages/liveManager/test/record_extra_data_controller.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { convert2Xml, RecordExtraData } from "../src/record_extra_data_controller.js"; 3 | 4 | describe("convert2Xml", () => { 5 | it("should convert record extra data to XML format", () => { 6 | const data: RecordExtraData = { 7 | meta: { 8 | recordStartTimestamp: 1633072800000, 9 | }, 10 | messages: [ 11 | { 12 | type: "comment", 13 | timestamp: 1633072801000, 14 | text: "Hello World", 15 | sender: { uid: "123", name: "user1" }, 16 | color: "#ffffff", 17 | mode: 1, 18 | }, 19 | { 20 | type: "give_gift", 21 | timestamp: 1633072802000, 22 | name: "Gift", 23 | count: 1, 24 | price: 100, 25 | sender: { uid: "124", name: "user2" }, 26 | }, 27 | { 28 | type: "super_chat", 29 | timestamp: 1633072803000, 30 | text: "Super Chat", 31 | price: 200, 32 | sender: { uid: "125", name: "user3" }, 33 | }, 34 | { 35 | type: "guard", 36 | timestamp: 1633072804000, 37 | name: "Guard", 38 | count: 1, 39 | price: 300, 40 | level: 1, 41 | sender: { uid: "126", name: "user4" }, 42 | }, 43 | ], 44 | }; 45 | 46 | const xml = convert2Xml(data); 47 | 48 | const expectedXml = ` 49 | 50 | 51 | 1633072800000 52 | 53 | Hello World 54 | 55 | Super Chat 56 | 57 | 58 | `; 59 | 60 | expect(xml).toBe(expectedXml); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/shared/src/video/kuaishou.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import M3U8Downloader from "@renmu/m3u8-downloader"; 4 | import axios from "axios"; 5 | 6 | import { taskQueue, KuaishouDownloadVideoTask } from "../task/task.js"; 7 | import { getBinPath } from "../task/video.js"; 8 | import { uuid } from "../utils/index.js"; 9 | import { getTempPath } from "../utils/index.js"; 10 | 11 | async function download( 12 | output: string, 13 | url: string, 14 | options: { 15 | override?: boolean; 16 | }, 17 | ) { 18 | if ((await fs.pathExists(output)) && !options.override) throw new Error(`${output}已存在`); 19 | 20 | const { ffmpegPath } = getBinPath(); 21 | const downloader = new M3U8Downloader(url, output, { 22 | convert2Mp4: true, 23 | ffmpegPath: ffmpegPath, 24 | segmentsDir: path.join(getTempPath(), uuid()), 25 | }); 26 | const task = new KuaishouDownloadVideoTask(downloader, { 27 | name: `下载任务:${path.parse(output).name}`, 28 | }); 29 | taskQueue.addTask(task, true); 30 | return task; 31 | } 32 | 33 | /** 34 | * 解析视频 35 | */ 36 | const parseVideo = async ( 37 | productId: string, 38 | ): Promise<{ 39 | currentWork: { 40 | author: { 41 | name: string; 42 | }; 43 | playUrlV2: { 44 | hevc: string; 45 | h264: string; 46 | }; 47 | playUrlV3: { 48 | h264: { 49 | adaptationSet: { 50 | representation: { 51 | url: string; 52 | qualityType: string; 53 | qualityLabel: string; 54 | }[]; 55 | }[]; 56 | }; 57 | }; 58 | createTime: number; 59 | }; 60 | }> => { 61 | const res = await axios.get(`https://live.kuaishou.com/live_api/playback/detail`, { 62 | headers: { 63 | "User-Agent": 64 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", 65 | host: "live.kuaishou.com", 66 | }, 67 | params: { 68 | productId, 69 | }, 70 | }); 71 | if (res.status !== 200) { 72 | throw new Error("请求错误"); 73 | } 74 | return res.data.data; 75 | }; 76 | 77 | export default { 78 | parseVideo, 79 | download, 80 | }; 81 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@biliLive-tools/shared", 3 | "version": "3.5.1", 4 | "type": "module", 5 | "description": "", 6 | "main": "./lib/index.js", 7 | "author": "renmu123", 8 | "license": "GPL-3.0", 9 | "homepage": "https://github.com/renmu123/biliLive-tools", 10 | "exports": { 11 | ".": { 12 | "types": "./src/index.ts", 13 | "development": "./src/index.ts", 14 | "default": "./lib/index.js" 15 | }, 16 | "./*.js": { 17 | "types": "./src/*.ts", 18 | "development": "./src/*.ts", 19 | "default": "./lib/*.js" 20 | } 21 | }, 22 | "scripts": { 23 | "build": "pnpm run test && pnpm run typecheck && tsc", 24 | "dev": "tsc -w", 25 | "start:dev": "tsx src/index.ts", 26 | "typecheck": "tsc --noEmit -p tsconfig.json --composite false", 27 | "test": "vitest run" 28 | }, 29 | "keywords": [], 30 | "dependencies": { 31 | "@biliLive-tools/types": "workspace:*", 32 | "@bililive-tools/bilibili-recorder": "workspace:*", 33 | "@bililive-tools/douyin-recorder": "workspace:*", 34 | "@bililive-tools/douyu-recorder": "workspace:*", 35 | "@bililive-tools/huya-recorder": "workspace:*", 36 | "@bililive-tools/manager": "workspace:*", 37 | "@napi-rs/canvas": "^0.1.60", 38 | "@renmu/bili-api": "2.11.1", 39 | "@renmu/fluent-ffmpeg": "2.3.3", 40 | "@renmu/m3u8-downloader": "^0.4.1", 41 | "@renmu/throttle": "^1.0.3", 42 | "arktype": "^2.1.2", 43 | "ass-compiler": "^0.1.14", 44 | "awilix": "^11.0.4", 45 | "better-sqlite3": "catalog:", 46 | "check-disk-space": "^3.4.0", 47 | "dayjs": "^1.11.18", 48 | "douyu-api": "^0.2.0", 49 | "ejs": "^3.1.10", 50 | "fast-xml-parser": "^4.5.0", 51 | "font-ls": "catalog:", 52 | "nodemailer": "^6.9.16", 53 | "ntsuspend": "catalog:", 54 | "ollama": "^0.5.9", 55 | "p-limit": "^6.1.0", 56 | "pan123-uploader": "0.3.0", 57 | "serverchan-sdk": "^1.0.6", 58 | "subtitle": "^4.2.1", 59 | "tiny-typed-emitter": "^2.1.0", 60 | "trash": "^9.0.0", 61 | "tree-kill": "^1.2.2", 62 | "zod": "^3.23.8" 63 | }, 64 | "devDependencies": { 65 | "@types/nodemailer": "^6.4.17" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/shared/src/utils/speedCalculator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 上传/下载速度计算器 3 | * 使用时间窗口平滑算法计算传输速度 4 | */ 5 | export class SpeedCalculator { 6 | private progressHistory: Array<{ loaded: number; timestamp: number }> = []; 7 | private readonly speedWindowMs: number; 8 | 9 | /** 10 | * @param speedWindowMs 时间窗口大小(毫秒),默认 3000ms 11 | */ 12 | constructor(speedWindowMs: number = 3000) { 13 | this.speedWindowMs = speedWindowMs; 14 | } 15 | 16 | /** 17 | * 重置速度计算器 18 | */ 19 | reset(): void { 20 | this.progressHistory = []; 21 | } 22 | 23 | /** 24 | * 初始化速度计算器 25 | * @param startTimestamp 开始时间戳 26 | */ 27 | init(startTimestamp: number): void { 28 | this.progressHistory = [{ loaded: 0, timestamp: startTimestamp }]; 29 | } 30 | 31 | /** 32 | * 清理超出时间窗口的历史记录 33 | * @param currentTime 当前时间戳 34 | */ 35 | private cleanupProgressHistory(currentTime: number): void { 36 | const windowStartTime = currentTime - this.speedWindowMs; 37 | this.progressHistory = this.progressHistory.filter( 38 | (progress) => progress.timestamp >= windowStartTime, 39 | ); 40 | } 41 | 42 | /** 43 | * 计算速度(使用时间窗口平滑) 44 | * @param currentLoaded 当前已传输字节数 45 | * @param currentTime 当前时间戳 46 | * @returns 格式化的速度字符串(MB/s) 47 | */ 48 | calculateSpeed(currentLoaded: number, currentTime: number): string { 49 | // 添加当前进度到历史记录 50 | this.progressHistory.push({ loaded: currentLoaded, timestamp: currentTime }); 51 | 52 | // 清理超出时间窗口的旧数据 53 | this.cleanupProgressHistory(currentTime); 54 | 55 | // 如果历史记录不足,返回默认值 56 | if (this.progressHistory.length < 2) { 57 | return "0.00 MB/s"; 58 | } 59 | 60 | // 使用时间窗口内的第一个和最后一个数据点计算平均速度 61 | const oldest = this.progressHistory[0]; 62 | const newest = this.progressHistory[this.progressHistory.length - 1]; 63 | 64 | const timeDiff = (newest.timestamp - oldest.timestamp) / 1000; // 转换为秒 65 | const dataDiff = newest.loaded - oldest.loaded; // 字节差 66 | 67 | if (timeDiff <= 0 || dataDiff <= 0) { 68 | return "0.00 MB/s"; 69 | } 70 | 71 | const speedMBps = dataDiff / (1024 * 1024) / timeDiff; // MB/s 72 | return `${speedMBps.toFixed(2)} MB/s`; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /scripts/github-ci-better-sqlite3.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import * as tar from "tar"; 4 | import download from "download"; 5 | import { SingleBar } from "cli-progress"; 6 | 7 | // 由于未知的原因,better-sqlite3的二进制包在github的release中下载的是错误的版本,所以需要手动下载 8 | 9 | async function downloadFile(url, desc) { 10 | const downloader = download(url, desc); 11 | const progressBar = new SingleBar({ 12 | format: "下载进度 |{bar}| {percentage}% | ETA: {eta}s", 13 | barCompleteChar: "\u2588", 14 | barIncompleteChar: "\u2591", 15 | hideCursor: true, 16 | }); 17 | progressBar.start(100, 0); 18 | 19 | downloader.on("downloadProgress", (progress) => { 20 | progressBar.update(progress.percent * 100); 21 | }); 22 | downloader.on("error", (err) => { 23 | console.error(err); 24 | }); 25 | downloader.on("end", () => { 26 | console.log("\n下载成功"); 27 | }); 28 | await downloader; 29 | progressBar.stop(); 30 | } 31 | 32 | async function downloadBin() { 33 | const betterSqlie3Version = "v11.5.0"; 34 | // better-sqlite3-v11.1.2-electron-v125-win32-x64.tar.gz 35 | const filename = `better-sqlite3-${betterSqlie3Version}-electron-v130-${process.platform}-${process.arch}.tar.gz`; 36 | const downloadUrl = `https://github.com/WiseLibs/better-sqlite3/releases/download/${betterSqlie3Version}/${filename}`; 37 | console.log("下载地址:", downloadUrl); 38 | 39 | await downloadFile(downloadUrl, "."); 40 | 41 | const extractToPath = path.resolve("./node_modules/better-sqlite3"); 42 | 43 | tar 44 | .x({ 45 | file: filename, 46 | C: extractToPath, 47 | }) 48 | .then(() => { 49 | console.log("解压完成"); 50 | fs.unlink(filename, (err) => { 51 | if (err) { 52 | console.error("删除压缩包失败:", err); 53 | } else { 54 | console.log("删除压缩包成功"); 55 | } 56 | }); 57 | }) 58 | .catch((err) => { 59 | console.error("解压失败:", err); 60 | fs.unlink(filename, (err) => { 61 | if (err) { 62 | console.error("删除压缩包失败:", err); 63 | } else { 64 | console.log("删除压缩包成功"); 65 | } 66 | }); 67 | }); 68 | // 解压.tar.gz到 node_modules/better-sqlite3 69 | } 70 | 71 | downloadBin(); 72 | -------------------------------------------------------------------------------- /packages/http/src/middleware/multer.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /*! 4 | * multer 5 | * Copyright(c) 2014 Hage Yaapa 6 | * Copyright(c) 2015 Fangdun Cai 7 | * MIT Licensed 8 | */ 9 | 10 | /** 11 | * Module dependencies. 12 | */ 13 | 14 | import originalMulter from "multer"; 15 | 16 | declare module "koa" { 17 | interface Request { 18 | body?: any; 19 | rawBody: string; 20 | file?: { 21 | fieldname: string; 22 | originalname: string; 23 | encoding: string; 24 | mimetype: string; 25 | destination: string; 26 | filename: string; 27 | path: string; 28 | size: number; 29 | }; 30 | } 31 | } 32 | 33 | function multer(options) { 34 | const m = originalMulter(options); 35 | 36 | makePromise(m, "any"); 37 | makePromise(m, "array"); 38 | makePromise(m, "fields"); 39 | makePromise(m, "none"); 40 | makePromise(m, "single"); 41 | 42 | return m; 43 | } 44 | 45 | function makePromise(multer, name) { 46 | if (!multer[name]) return; 47 | 48 | const fn = multer[name]; 49 | 50 | multer[name] = function () { 51 | // eslint-disable-next-line prefer-rest-params 52 | const middleware = Reflect.apply(fn, this, arguments); 53 | 54 | return async (ctx, next) => { 55 | await new Promise((resolve, reject) => { 56 | middleware(ctx.req, ctx.res, (err) => { 57 | if (err) return reject(err); 58 | if ("request" in ctx) { 59 | if (ctx.req.body) { 60 | ctx.request.body = ctx.req.body; 61 | delete ctx.req.body; 62 | } 63 | 64 | if (ctx.req.file) { 65 | ctx.request.file = ctx.req.file; 66 | ctx.file = ctx.req.file; 67 | delete ctx.req.file; 68 | } 69 | 70 | if (ctx.req.files) { 71 | ctx.request.files = ctx.req.files; 72 | ctx.files = ctx.req.files; 73 | delete ctx.req.files; 74 | } 75 | } 76 | 77 | resolve(ctx); 78 | }); 79 | }); 80 | 81 | return next(); 82 | }; 83 | }; 84 | } 85 | 86 | multer.diskStorage = originalMulter.diskStorage; 87 | multer.memoryStorage = originalMulter.memoryStorage; 88 | 89 | export default multer; 90 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/pages/setting/OtherSetting.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 61 | 62 | 67 | -------------------------------------------------------------------------------- /packages/http/src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | import biliService from "@biliLive-tools/shared/task/bili.js"; 3 | import crypto from "crypto"; 4 | 5 | const router = new Router({ 6 | prefix: "/user", 7 | }); 8 | 9 | router.get("/list", async (ctx) => { 10 | const list = biliService.readUserList(); 11 | 12 | ctx.body = list.map((item) => { 13 | return { 14 | uid: item.mid, 15 | name: item.name, 16 | face: item.avatar, 17 | expires: item.expires, 18 | }; 19 | }); 20 | }); 21 | 22 | router.post("/delete", async (ctx) => { 23 | const { uid } = ctx.request.body as { uid: number }; 24 | await biliService.deleteUser(uid); 25 | ctx.status = 200; 26 | }); 27 | 28 | router.post("/update", async (ctx) => { 29 | const { uid } = ctx.request.body as { uid: number }; 30 | await biliService.updateUserInfo(uid); 31 | ctx.status = 200; 32 | }); 33 | 34 | router.post("/update_auth", async (ctx) => { 35 | const { uid } = ctx.request.body as { uid: number }; 36 | await biliService.updateAuth(uid); 37 | ctx.status = 200; 38 | }); 39 | 40 | router.post("/get_cookie", async (ctx) => { 41 | const { uid, timestamp, signature } = ctx.request.body as { 42 | uid: number; 43 | timestamp: number; 44 | signature: string; 45 | }; 46 | const currentTimestamp = Math.floor(Date.now() / 1000); 47 | 48 | if (Math.abs(currentTimestamp - timestamp) > 10) { 49 | ctx.status = 400; 50 | ctx.body = "请求超时"; 51 | return; 52 | } 53 | 54 | const secret = "r96gkr8ahc34fsrewr34"; 55 | const hash = crypto.createHmac("sha256", secret).update(`${uid}${timestamp}`).digest("hex"); 56 | 57 | if (hash !== signature) { 58 | ctx.status = 400; 59 | ctx.body = "签名无效"; 60 | return; 61 | } 62 | 63 | try { 64 | const data = await biliService.getBuvidConf(); 65 | const buvid = data.data.b_3; 66 | 67 | const obj = biliService.getCookie(uid); 68 | const cookie = Object.entries(obj) 69 | .map(([key, value]) => { 70 | return `${key}=${value}`; 71 | }) 72 | .join("; "); 73 | ctx.body = `${cookie}; buvid3=${buvid}`; 74 | } catch (error) { 75 | ctx.status = 500; 76 | ctx.body = "获取失败,请重试"; 77 | } 78 | }); 79 | 80 | export default router; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@biliLive-tools/root", 3 | "version": "3.5.1", 4 | "description": "B站直播录制处理工具", 5 | "author": "renmu123", 6 | "license": "GPL-3.0-only", 7 | "homepage": "https://github.com/renmu123/biliLive-tools", 8 | "type": "module", 9 | "scripts": { 10 | "dev": "chcp 65001 && pnpm run --filter biliLive-tools dev", 11 | "devMain": "chcp 65001 && pnpm run --filter biliLive-tools devMain", 12 | "build:app": "pnpm run build:base && pnpm run --filter biliLive-tools build:app", 13 | "build:webui": "pnpm run build:base && pnpm run --filter biliLive-tools build:webui", 14 | "build:app:no-ffmpeg": "pnpm run build:base && pnpm run --filter biliLive-tools build:app:no-ffmpeg", 15 | "build:cli": "pnpm run build:base && pnpm run --filter bililive-cli build:cli", 16 | "build:base": "pnpm run --filter @biliLive-tools/types --filter douyin-danma-listener --filter @biliLive-tools/shared build && pnpm --filter @biliLive-tools/http --filter @bililive-tools/* --parallel run build", 17 | "preinstall": "npx only-allow pnpm", 18 | "install:bin": "node ./scripts/install-bin.js", 19 | "github-ci-pnpm-update": "node ./scripts/github-ci-pnpm-update.js", 20 | "test": "vitest run" 21 | }, 22 | "dependencies": { 23 | "axios": "^1.7.8", 24 | "fs-extra": "^11.2.0", 25 | "jszip": "^3.10.1", 26 | "lodash-es": "^4.17.21", 27 | "uuid": "^10.0.0" 28 | }, 29 | "devDependencies": { 30 | "@rollup/plugin-commonjs": "^26.0.3", 31 | "@rollup/plugin-json": "^6.1.0", 32 | "@rollup/plugin-node-resolve": "^15.3.0", 33 | "@rollup/plugin-typescript": "^11.1.6", 34 | "@types/node": "24.7.2", 35 | "@types/uuid": "^10.0.0", 36 | "@vitest/coverage-istanbul": "^3.2.4", 37 | "cli-progress": "^3.12.0", 38 | "download": "^8.0.0", 39 | "prettier": "^3.6.2", 40 | "rollup": "^4.24.4", 41 | "tar": "^7.4.3", 42 | "typescript": "5.9.3", 43 | "vitest": "^3.2.4" 44 | }, 45 | "packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321", 46 | "pnpm": { 47 | "patchedDependencies": { 48 | "trash": "patches/trash.patch" 49 | }, 50 | "overrides": { 51 | "node-abi": "3.78.0" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/shared/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { setupContainer } from "./container.js"; 2 | 3 | import type { Database as DatabaseType } from "better-sqlite3"; 4 | import type { Container } from "./container.js"; 5 | 6 | export let dbContainer: ReturnType; 7 | export let statisticsService: Container["statisticsService"]; 8 | export let virtualRecordService: Container["virtualRecordService"]; 9 | export let videoSubDataService: Container["videoSubDataService"]; 10 | export let streamerService: Container["streamerService"]; 11 | export let videoSubService: Container["videoSubService"]; 12 | export let recordHistoryService: Container["recordHistoryService"]; 13 | export let uploadPartService: Container["uploadPartService"]; 14 | export let danmuService: Container["danmuService"]; 15 | 16 | export const initDB = (dbRootPath: string): void => { 17 | // 依赖注入容器 18 | dbContainer = setupContainer(dbRootPath); 19 | setExportServices(dbContainer); 20 | }; 21 | 22 | export const closeDB = (): void => { 23 | const mainDb = dbContainer.resolve("db"); 24 | const danmuDb = dbContainer.resolve("danmuDb"); 25 | if (mainDb) mainDb.close(); 26 | if (danmuDb) danmuDb.close(); 27 | }; 28 | 29 | export const backupDB = (filename: string) => { 30 | const mainDb = dbContainer.resolve("db"); 31 | if (mainDb) return mainDb.backup(filename); 32 | return; 33 | }; 34 | 35 | export const reconnectDB = (): void => { 36 | const dbRootPath = dbContainer.resolve("dbRootPath"); 37 | initDB(dbRootPath); 38 | }; 39 | 40 | const setExportServices = (dbContainer: ReturnType) => { 41 | statisticsService = dbContainer.resolve("statisticsService"); 42 | virtualRecordService = dbContainer.resolve("virtualRecordService"); 43 | videoSubDataService = dbContainer.resolve("videoSubDataService"); 44 | streamerService = dbContainer.resolve("streamerService"); 45 | videoSubService = dbContainer.resolve("videoSubService"); 46 | recordHistoryService = dbContainer.resolve("recordHistoryService"); 47 | uploadPartService = dbContainer.resolve("uploadPartService"); 48 | danmuService = dbContainer.resolve("danmuService"); 49 | }; 50 | 51 | export const getMainDb = (): DatabaseType => dbContainer.resolve("db"); 52 | export const getDanmuDb = (): DatabaseType => dbContainer.resolve("danmuDb"); 53 | -------------------------------------------------------------------------------- /packages/app/src/renderer/src/components/ButtonGroup.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 42 | 43 | 91 | --------------------------------------------------------------------------------