# arclive **Repository Path**: sidney_admin/arclive ## Basic Information - **Project Name**: arclive - **Description**: 国内直播平台的自动录播工具 - **Primary Language**: Unknown - **License**: AGPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-07 - **Last Updated**: 2026-10-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Arclive 国内直播平台的**自动录播工具**。关注一个直播间,开播自动录、断流自动重连、 按平台惯例自动分段。录完之后自己去「录像库」或任务详情里勾选要拼的片段 合并成一个文件 —— **可以跨场次选**(同一次直播因为掉线、手动停止或跨零点 被拆成好几场时,把每场里想要的那几段拼到一起),拼哪些由你决定,工具不猜。 记录列表也能手动清理。删除是**彻底的**:数据库记录和磁盘上的录像文件一起删掉, 没有回收站,所以确认框会先把这件事说清楚。仍被别的记录引用的文件会保留下来 (跨场次合并出来的产物属于参与的每一场,删除其中一场不该把另一场也毁掉)。 内核是 **TypeScript 全栈、零运行时依赖**(只用 Node 内置的 `node:http` / `node:sqlite` / 内置 `fetch`),靠 Node 22.6+ 的**类型擦除**直接跑 `.ts`, 没有构建步骤。 前端是 Vite + React 19 + Tailwind v4 + **Ant Design 6**。控件(按钮、卡片、 弹窗、下拉、复选、开关、空状态、骨架屏、图标)一律用 antd 官方组件, `web/src/ui.tsx` 只留一层适配:对外保留本项目原有的调用形态,内部转述 antd。 这样页面上的 289 个调用点不受换库影响,出问题只可能出在适配层那一处。 **字号与布局跟着视口走**。字号不再写死,按窗口宽度分四档(1200 以下 13px、 1200–1599 是 14px、1600–2199 是 15px、2200 以上 16px),侧栏宽度、内容留白、 表格行高走同一档。桌面宽屏上不会出现「屏幕越大字越小」,超宽屏也不会字放大了 但容器没放宽。档位表在 `web/src/theme.tsx` 的 `SCALE_STEPS`,它是唯一的真相来源: 同时喂给 antd 令牌和 `--fs-*` CSS 变量(页面文字通过 Tailwind 语义类读后者), 所以 antd 组件与手写文字一定同步缩放。 > **依赖边界**:antd 只装在前端。**内核依旧零运行时依赖**,这条没有变。 > 代价是前端产物从 112 KB 涨到 321 KB(gzip 后)—— antd 一套同等功能的 > 组件组本身就要 132 KB。这笔账记在更新日志的 0.3.0 里。 > **为什么外壳底色用内联样式写**:antd 6 的样式是运行时由 cssinjs 注入 > `` 的,选择器带哈希类、特异性比静态样式表高,且改主题后热更新会留下 > 旧的注入块 —— 「白侧栏 + 深藏青顶栏」那条断裂横线就是这么来的。 > 所以 `Layout.tsx` 里顶栏/侧栏的底色同时由令牌和内联的 `var(--s-1)` 给出, > 两条路互为兜底。对应断言在 `core/scripts/ui-smoke.ts` 的「外壳」一节。 --- ## 目录 - [界面](#界面) · [支持的平台](#支持的平台) · [环境要求](#环境要求) · [快速开始](#快速开始) - [目录结构](#目录结构) · [测试](#测试) · [数据放在哪](#数据放在哪) · [目录约定](#目录约定) - 想直接看界面改了些什么:控制台里的 **更新日志** 页(内容随内核发布、 存在数据库里,前端只通过 `GET /api/changelog` 取回来渲染) --- ## 界面 控制台一共 12 个页面,每个页面一个源文件。「代码 → 界面」的对应关系就是 下面这条列表,图放在 `docs/screenshots/`。 > **这些图不是手工拼的示意图**,而是 `npm run ui`(`core/scripts/ui-smoke.ts`) > 在真实 Chrome 里逐个打开页面、断言完之后自己截下来的那一版界面。 > 也就是说:**图里是什么,跑起来就是什么** —— 界面改坏了截图会跟着变, > 不存在「文档里的图还是上一版设计」这种情况。 > > 原始 PNG 在 `core/.ui-smoke/shots/`(不提交);提交的是转过的 WebP > (宽 1600、质量 88),26 张合计约 1.4MB。转换用 `npm run shots` > (需要本机有带 Pillow 的 Python,缺了会跳过而不是报错)。 ### 登录 **登录 / 首次设置** `/` —— `web/src/auth.tsx` 启用访问控制之后,未登录时整棵树都不挂载 —— 所以这是全站**唯一**有 `h1` 的页面。 宽屏左右分栏:左栏讲「这是什么产品、它凭什么可信」(数据不出机器 / 单用户 / 会话存 HttpOnly Cookie), 右栏是表单。错误**内联在表单里**而不是弹 toast(toast 会消失,而"密码错了"这句话得等用户改完再消失); 连续失败触发的 429 会把冷却秒数变成倒计时并把按钮锁住,而不是让人反复点。 ![登录页](docs/screenshots/login.webp) ### 运行 **总览** `/` —— `web/src/pages/Overview.tsx` 在录的看画面,有问题的看状态,正常未开播的不占空间。 ![总览](docs/screenshots/overview.webp) **关注任务** `/targets` —— `web/src/pages/Targets.tsx` 卡片是「状态徽标 + 标题 + 指标行 + 底部按钮」四段,按钮钉在卡片底部, 所以同一行的卡片按钮是对齐的。 ![关注任务](docs/screenshots/targets.webp) **任务详情** `/targets/:id` —— `web/src/pages/TargetDetail.tsx` 一个目标的全部场次与分段,也是勾选要合并的片段的地方。 ![任务详情](docs/screenshots/target-detail.webp) **录制预览** `/preview` —— `web/src/pages/Preview.tsx` 照直播列表的做法排:左边封面/实时帧,右边标题与状态,指标在下面一行。 ![录制预览](docs/screenshots/preview.webp) ### 内容 **录像库** `/library` —— `web/src/pages/Library.tsx` 按天分组的场次列表。删除是彻底的(记录 + 磁盘文件),确认框会先把这件事写清楚。 ![录像库](docs/screenshots/library.webp) **剪辑台** `/workbench` —— `web/src/pages/Workbench.tsx` 左栏场次(新的在上),右栏时间轴。**可以跨场次勾选** —— 同一次直播被拆成几场时, 把每场里想要的那几段拼到一起。 ![剪辑台](docs/screenshots/workbench.webp) **分段合并面板** —— 同上,`Workbench.tsx` 全选/单选与选中计数联动,跨场次选中时会提示「这些片段不在同一场」。 ![合并面板](docs/screenshots/workbench-merge-panel.webp) **跨场次勾选 + 本场时间轴** —— `Workbench.tsx` 同一次直播被掉线/跨零点拆成几场之后,把每场里想要的那几段拼到一起。 ![跨场次勾选](docs/screenshots/workbench-cross-session.webp) **媒体库** `/media` —— `web/src/pages/Media.tsx` 直接在磁盘上浏览录制目录。这里删的是文件,**不碰数据库记录** —— 所以确认框会额外说明「会留下多少条指向空文件的记录」。 ![媒体库](docs/screenshots/media.webp) **彻底删除之后的空态** —— `Library.tsx` 记录与磁盘文件一起删干净之后,列表换成空状态,而不是留一片空白。 ![删除后的空态](docs/screenshots/library-after-purge.webp) **统计** `/stats` —— `web/src/pages/Stats.tsx` 总时长 / 总体积 / 成功率 / 每日趋势 / 平台分布。 ![统计](docs/screenshots/stats.webp) ### 运维与系统 **通知** `/notifications` —— `web/src/pages/Notifications.tsx` 按天分组、按级别配色。与「事件流」的分工:事件流是全部流水(会被清理), 这一页是值得回头看的那几条(不清理,带已读态)。 ![通知](docs/screenshots/notifications.webp) **问题** `/issues` —— `web/src/pages/Issues.tsx` 需要你决策的事集中在这里,每条附一个可执行的处置动作。 ![问题](docs/screenshots/issues.webp) **设置** `/settings` —— `web/src/pages/Settings.tsx` 完全由内核的 `SETTING_SPECS` 驱动渲染 —— 加一个设置项只要在内核里加一行, 界面自动出现,两端不会对「有哪些设置项」产生分歧。 左导航按「录制 / 采集 / 外观与通知 / 数据」四段分组,每项挂**三个独立状态位**: 红竖条是「这一项填错了」、数字胶囊是「这一项改了还没保存」、⚠ 是「改完要重启内核」—— 三者可能同时成立,共用一个位置必然丢掉一个,所以各占一处。 顶部有一条「已配置 N / M 组」的进度,改完想反悔时用「还原」下拉按分组回退。 ![设置](docs/screenshots/settings.webp) **改了什么** —— 设置页顶栏的「改了什么 (N)」按钮, 列出本次相较上次保存的每一处改动(`旧值 → 新值`),按分组聚合,需重启的单独标出来。 和「保存」时的重启确认框共用同一个 `ChangeList` 组件 —— 同一件事只有一套措辞。 ![改了什么](docs/screenshots/settings-change-list.webp) **从浏览器导入 Cookie** —— 「设置 → 平台凭据」里的弹窗, 内核侧在 `core/src/credential-import.ts` 读的是浏览器的**副本**,绝不写回;取值不出后端,界面上一个字符都不会出现。 ![从浏览器导入 Cookie](docs/screenshots/settings-credential-import.webp) **侧栏与外壳** —— `web/src/Layout.tsx` 侧栏可以手动收起(宽屏也支持,不止是窄屏自动折),状态记在 localStorage; 导航按「运行 / 内容 / 运维 / 系统」四组,**组标题可点、可折叠**, 收起时组内"有事"的项会把提示冒到组标题上,不会被藏起来。 底栏是一行式内核状态条(在录几路 / 空闲 + 版本号), 点开是内核详情浮层:已运行时长、数据目录、输出目录,两个目录都能一键复制 ——录播工具最常问的「文件到底写哪去了」在这里就有答案。 ![内核详情浮层](docs/screenshots/sider-kernel-detail.webp) **更新日志** `/changelog` —— `web/src/pages/Changelog.tsx` 内容随内核发布、存在数据库里(来源是 `core/src/store/seed/changelog.ts`), 这一页通过 `GET /api/changelog` 取回来。默认折叠,要读细节再单项展开。 ![更新日志](docs/screenshots/changelog.webp) ### 主题与自适应 **配色面板** —— `web/src/PalettePicker.tsx` + `web/src/palettes.ts` 4 套预设(默认科技蓝 / 警察 / 赛博 / 哔哩)外加一个自己取色的。 主色换一套会同时改掉所有用到品牌色的地方。 ![配色面板](docs/screenshots/palette-picker.webp) 暗色不是把亮色反相:底色改成中性石墨,层次靠**对比**拉开, 而不是靠饱和度 —— 原先偏重的蓝底配上蓝主色会整屏浑成一片。 | 暗色 · 默认 | 暗色 · 赛博 | | --- | --- | | ![暗色默认](docs/screenshots/theme-dark-default.webp) | ![暗色赛博](docs/screenshots/theme-dark-cyber.webp) | | 暗色 · 哔哩 | 暗色 · 警察 | | ![暗色哔哩](docs/screenshots/theme-dark-bilibili.webp) | ![暗色警察](docs/screenshots/theme-dark-police.webp) | 字号与布局跟着视口走(`web/src/theme.tsx` 的 `SCALE_STEPS` 是唯一的真相来源): | 1920 宽 | 2560 宽 | | --- | --- | | ![1920](docs/screenshots/settings-wide-1920.webp) | ![2560](docs/screenshots/settings-ultrawide-2560.webp) | --- ## 支持的平台 | 平台 | 状态 | 说明 | |---|---|---| | 哔哩哔哩 | 可用 | 取流能力正常,但 B 站已收紧匿名拉流:**建议配 Cookie(SESSDATA)**,否则 CDN 可能直接 403(2026-10 实测) | | 斗鱼 | 可用 | 已适配新版签名协议(服务端下发密钥 + 迭代摘要) | | 虎牙 | 可用 | 单连接持续拉流,一场录成一个大文件(不再每 2–3 秒重连碎成小段) | | 抖音 | 可用 | 无需登录 / Cookie;流地址藏在首屏 `__pace_f` 推送里。注意:房间页可能对**出口 IP** 触发人机验证(返回 HTTP 200 的「验证码中间页」而非错误码),此时用浏览器打开一次直播间过验证即可,配 Cookie 可缓解 | | 快手 | 可用 | 未登录也能取流(`__INITIAL_STATE__` 回退路径带出 playUrls) | | 小红书 | 测试中 | **需要登录 Cookie**(`web_session`)。直播接口要求登录态,页面本身只有站点配置 | | 微博 | 可用 | 免登录可读房间信息;开播判据用「流地址非空」而不是状态码(状态码含义无官方文档),在播 / 下播两端均已实测 | ### 平台凭据 斗鱼 / 虎牙 / 快手 / 微博**裸房间号即可取流,无需登录、无需 Cookie**。 **哔哩哔哩**与**抖音**同样支持裸房间号取流,但两家都在收紧匿名访问(2026-10 实测): B 站匿名请求 CDN 可能直接 403,抖音房间页可能对出口 IP 触发人机验证 —— 配一份 Cookie 可显著提高稳定性。 **小红书例外** —— 它的直播接口要求登录态,必须先填 Cookie 才能取流。 仅在个别平台想拿更高码率线路等特殊情况时才需要凭据 —— 这时靠「设置 → 平台凭据」填 Cookie。 不用手动去开发者工具里复制:点 **从浏览器导入** 会列出本机所有浏览器的配置, 告诉你每个平台能读到多少条、缺哪个登录态 Cookie,确认之后才写。 - **Firefox** 的 Cookie 库(`cookies.sqlite`)不加密,直接读,这条路径不需要任何外部程序。 - **Chrome / Edge / Brave** 需要走一次 DPAPI 解密,内核会派生一次 PowerShell 调 `ProtectedData.Unprotect`(仅 Windows,且只在当前用户态下)。 - **Chrome / Edge 127 之后新写的 Cookie 读不出来**(`v20` App-Bound 加密)。 那批密钥绑在浏览器进程上,设计上就无法离线解开。它们会被单独计数并说明, 而不是假装成功 —— 详见 `src/credential-import.ts` 的注释。 - 浏览器里的 Cookie 库**只会被复制一份来读**,原库一个字节都不改; 取值全程留在内核进程里,HTTP 响应里只有「几条、叫什么名字」。 ## 环境要求 - **Node.js >= 22.6**(依赖类型擦除跑 `.ts`;开发在 22.22.2 上验证,22.12 也验过) - 22.6–22.12 需要 `--experimental-strip-types` 与 `--experimental-sqlite`, 之后的版本默认开启。**这一点由脚本自己探测**([core/scripts/node-ts.mjs](core/scripts/node-ts.mjs) 与 [core/packaging/launcher.cjs](core/packaging/launcher.cjs)), 所以同一份 `npm start` 在新旧 Node 上都不用改配置。 - **ffmpeg**(5.x 验证过)。程序按「显式指定 → 环境变量 → PATH → 常见安装目录 → 盘符根下的 `ffmpeg*/bin`」顺序自动查找 ## 快速开始 ### 只想用(推荐) ```bash npm run exe # 打包出 dist/arclive/(Windows: Arclive.exe / macOS·Linux: arclive) ``` 运行那个可执行文件:内核起来后自动打开浏览器,前端产物由内核自己伺服, **一个进程就是全部**。不装 Node 也能跑。 ### 开发(前后台一起) ```bash npm install --prefix core && npm install --prefix web # 各自的开发依赖 npm run dev # = 双击根目录的 dev.cmd ``` 它会同时起**后台内核**(8787)与**前台 Vite**(5173,热更新;端口被占时自动换一个), 两路日志都带 `[后台]/[前台]` 前缀,等内核真的就绪之后才打开浏览器。 按 Ctrl+C 一起停掉(Windows 上连子孙进程一起清,不会留下占着端口的孤儿)。 ```bash npm run dev -- --no-open # 不起浏览器 npm run dev -- --backend-only # 只起内核 npm run dev -- --frontend-only # 只起前端 ``` 如果 8787 上**已经有一个内核在跑**(比如你之前开着控制台),脚本会**复用它而不是再起一个** —— 两个内核连同一个数据库去录同一批目标,会把同一段时间写两份,而界面看起来完全正常。 ### 只用内核(不启前端) ```bash cd core && npm start # http://127.0.0.1:8787 ``` 命令行同样可用(`npm run cli` 会自动补上当前 Node 需要的实验性标志; 直接 `node src/main.ts` 在 22.12 及更早的版本上会报 `Unknown file extension ".ts"`): ```bash npm run cli -- add bilibili 12345 # 添加关注 npm run cli -- list # 看关注列表 npm run cli -- status # 看运行状态 npm run cli -- check # 环境自检 npm run cli -- help # 全部命令 ``` ### 打包成独立可执行文件(Windows / macOS / Linux) ```bash cd core npm run exe # 产出 dist/arclive/:可执行文件 + app/ 目录 ``` 同一份脚本打三个平台,产物名与说明文件都跟着平台走: | 平台 | 产物 | 说明 | | --- | --- | --- | | Windows | `Arclive.exe` | PE,双击即用 | | macOS | `arclive` | Mach-O,注入后会自动 ad-hoc 重签 | | Linux | `arclive` | ELF,自动带执行位 | 产物是「**小启动器 + 隔壁 `app/` 目录**」,不是真正的单文件。 原因是内核要读磁盘上的 `migrations/*.sql` 与 `web/dist/*`, 全塞进单个文件就得改写静态资源与迁移的加载方式 —— 而那正是路径穿越防护、Range/206 这些最该保持安静的代码。 好处是应用代码一行不用改,将来只替换 `app/web/dist` 即可,不必重打 80MB。 macOS 上有两处不写就会「打包成功但跑不起来」:postject 必须显式指定 Mach-O 段名 `NODE_SEA`(它的默认段名是 `__POSTJECT`,SEA 不认), 注入后必须 ad-hoc 重签(签名损坏的二进制会被内核直接杀掉, 用户只看到「双击没反应」)。脚本里都已经处理好,并会打印出来。 打包脚本最后会用产物**真跑一次自检**,所以「打出来了」与「能跑」是同一句话。 ### 跨平台验证 `.github/workflows/ci.yml` 分两个矩阵: - **校验**:Linux / macOS / Windows 各跑一遍 typecheck、单测、 HTTP 冒烟与真实浏览器冒烟(十一个页面),失败时连截图一起上传。 - **打包**:Windows x64 / macOS arm64 / Linux x64 / Linux arm64 各打一份, 并让产物自己跑一遍 `check`,然后把整个 `dist/arclive/` 作为 artifact 上传。 > Intel macOS 不在打包矩阵里:GitHub 的 `macos-13`(最后一个 Intel 镜像) > 已退役,而 x64 与 arm64 走的是完全同一条代码路径(同一个 `platform=darwin`、 > 同一套 Mach-O 处理),只是宿主 Node 的架构不同。 > 在 Intel 机器上直接跑 `npm run exe` 即可。 ## 目录结构 ``` core/ 录制内核(可独立运行,无前端也能用) src/adapters/ 平台适配器:parse / resolve / probe / 取流地址 src/engine/ ffmpeg 流引擎:分段、断流重连、退避策略 src/orchestrator/ 编排:探测循环 → 会话 → 引擎 → 落库;分片合并 src/store/ SQLite 持久化(含迁移);seed/ 是随版本发布的内置数据 (更新日志),开库时幂等同步进库,页面再从库里读 src/purge.ts 唯一的删文件入口:三道闸门都在这里,也都能被单独断言 src/credential-import.ts 浏览器 Cookie 的定位、解密与平台归属 src/api/ REST + SSE(node:http,无框架) src/main.ts CLI 入口(serve / add / list / status / …) test/ 离线单测 scripts/ 冒烟与自检脚本 web/ 控制台前端(Vite + React + TypeScript + Tailwind + Ant Design 6) src/theme.tsx 主题层:亮/暗状态喂两处(ConfigProvider + .dark 类), 外加视口自适应尺度表 SCALE_STEPS(字号/侧栏/留白四档) src/ui.tsx antd 适配层,外加 antd 没有的几件产品语义件 (Copyable / StatCard / KeyValue / Sparkline) ``` ## 测试 ```bash cd core npm run typecheck # 类型检查(core) npm test # 离线单测,不联网、不需要 ffmpeg npm run smoke # 条件表达式、进度解析等纯逻辑 npm run web # 前端静态产物的接缝(Range / 缓存头 / 404 回落) npm run ui # 真实浏览器跑六个页面(需要本机有 Chrome) ``` ### 需要「正在直播的房间」的冒烟 下面几个脚本会**真的拉流录制**,所以房间号必须由调用方给 —— 源码里刻意不留默认值:写死一个真实房间号,既等于把「在用谁的房间」 跟着代码发布出去,也会因为那个房间下线而让失败信息变得极难理解。 ```bash # 方式一:命令行 node scripts/api-smoke.ts bilibili <房间号> 8 # 方式二:环境变量 ARCLIVE_TEST_ROOM=<房间号> npm run api ``` | 脚本 | 作用 | 房间号来源 | |---|---|---| | `api-smoke.ts` | REST + SSE 全链路,含真实录制与合并 | `ARCLIVE_TEST_ROOM` | | `orchestrator-smoke.ts` | 探测 → 开会话 → 录像 → 落库 | `ARCLIVE_TEST_ROOM` | | `record-smoke.ts` | 单房间实录取样 | `ARCLIVE_TEST_ROOM` | | `platform-check.ts` | 七个平台的连通性自检 | `ARCLIVE_CHECK_<平台>` | ## 数据放在哪 默认都在 `~/.arclive/` 下:`arclive.db`(数据库)与 `media/`(录像)。 两个位置都能改: - 启动参数 / 环境变量:`ARCLIVE_HOME`、`ARCLIVE_DB`、`ARCLIVE_MEDIA`、`ARCLIVE_PORT` - 运行期设置:控制台「设置」页(存在数据库里,改完立即生效) 录像命名模板默认 `{platformName}/{anchor}/{date}/{time}_{title}`。 > **注意**:如果把录制目录设成项目目录(`ARCLIVE_MEDIA` 或设置页里改), > 一场直播就会往仓库里写进几百 MB 的 flv。`.gitignore` 已按平台名做了兜底, > 但更稳妥的做法是把录制目录放在项目之外。 ## 目录约定 - 平台适配器只负责「怎么问平台要地址」,不碰 ffmpeg - 重连策略是一段**纯函数**(`src/engine/reconnect.ts`),不碰进程和时钟, 所以「网络抖动」那条路径也能被断言钉住 - 分段合并只做 `-c copy`,不重编码:几 GB 的录像几秒钟拼完,画质一比特不损 - 合并前会去掉「接缝处被平台重复收录的那一小段」(见 `src/orchestrator/overlap.ts`) - ffmpeg 的分段台账(`.csv`,精确段时长的唯一来源)写入系统临时目录、录制收尾即删, 不会出现在 `media/` 里,所以录制目录始终干净、不会跟着 git 一起被跟踪 - **删文件集中在 `src/purge.ts`**,不散在路由里。它是全项目唯一会不可逆弄丢 用户数据的地方,所以三道闸门(只能在录制/数据目录之内、只能删普通文件、 仍被别的记录引用就不删)都写在这里、也都能被单独断言 - `src/api/routes/sessions.ts` 的 `DELETE` 默认**不删文件**,要彻底删得显式带 `?files=1`:一个手滑的 `curl` 不该把几 GB 的录像带走 - 浏览器 Cookie 的定位与解密集中在 `src/credential-import.ts`。里面有三条不能松的: 复制副本再读(绝不写回浏览器)、取值不出后端、解不开就如实报告。 解密的正确性由 `test/credential-import.ts` 用**真加密的样本**验, 它不依赖机器上装没装浏览器 - 更新日志是**有类型的数据**(`core/src/store/seed/changelog.ts`),但它**存在数据库里**: 开库时由 `seedBuiltinData` 幂等同步进 `changelog_release` / `_item` / `_detail` 三张表, 页面与 `ui-smoke` 各自从库里(前者经 `GET /api/changelog`)读同一份内容。 同步是「内容不一致就整版重写」,所以修了日志里的错别字也能同步到已经装过的库; 反过来说,手工改库里的日志文本会在下次启动时被改回去 —— 想改就改种子文件。 `runtime.ts` 在**模块加载时**就会核对「最新一版 == 内核 VERSION」,对不上直接抛错