diff --git a/.gitignore b/.gitignore index ea07064..a0f1aad 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,9 @@ electron/dist/ electron/out/ electron/.tmp-prepare-runtime/ +# 运行时数据(会话库等) +runtime/ + # 嵌入式 Python / FFmpeg(由 electron/scripts/prepare-runtime.mjs 生成,勿提交大文件) /python/ /tools/ffmpeg/bin/ diff --git a/README.md b/README.md index 1f4ebe0..1a6181b 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,49 @@ -![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light) +# 无人直播助手 -## 💡简介 -[![Python Version](https://img.shields.io/badge/python-3.11.6-blue.svg)](https://www.python.org/downloads/release/python-3116/) -[![Supported Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Linux-blue.svg)](https://github.com/ihmily/DouyinLiveRecorder) -[![Docker Pulls](https://img.shields.io/docker/pulls/ihmily/douyin-live-recorder?label=Docker%20Pulls&color=blue&logo=docker)](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags) -![GitHub issues](https://img.shields.io/github/issues/ihmily/DouyinLiveRecorder.svg) -[![Latest Release](https://img.shields.io/github/v/release/ihmily/DouyinLiveRecorder)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) -[![Downloads](https://img.shields.io/github/downloads/ihmily/DouyinLiveRecorder/total)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) +Windows 桌面端 **抖音 → YouTube 无人转播** 控制台:内置 Electron 客户端、FastAPI 控制面(`web2`)、FFmpeg 推流 worker、多频道 Pro、推流诊断。 + +[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Electron](https://img.shields.io/badge/electron-33-blue.svg)](https://www.electronjs.org/) +[![Platform](https://img.shields.io/badge/platform-Windows-blue.svg)]() + +> 录制核心基于 [DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder) 生态;Linux 全功能服务端可参考同级项目 **sh2**(`d2ypp2`)。本仓库侧重 **Windows 懒人包 + Electron 控制台**。 --- -## 本分支扩展(Electron 控制台 · 抖音 → YouTube) +## 目录 -在上游 **DouyinLiveRecorder** 能力之上,本仓库增加了 **Windows Electron 客户端**、**web2 FastAPI 控制台**、**EasyTier 远程访问**、**本地 SQLite 工作台**、**微信 Chatbot 桥接** 等。 +- [快速开始](#快速开始) +- [产品定位](#产品定位) +- [系统架构](#系统架构) +- [仓库结构](#仓库结构) +- [界面与功能](#界面与功能) +- [web2 API](#web2-api) +- [配置与数据路径](#配置与数据路径) +- [单频道与多频道 Pro](#单频道与多频道-pro) +- [推流诊断 / 质量守卫 / 转播历史](#推流诊断--质量守卫--转播历史) +- [youLIVE 目标端监控](#youlive-目标端监控) +- [摄像头直推](#摄像头直推) +- [会员登录门禁(可选)](#会员登录门禁可选) +- [开发与构建](#开发与构建) +- [打包发布](#打包发布) +- [环境变量](#环境变量) +- [故障排查](#故障排查) +- [相关文档](#相关文档) +- [许可证](#许可证) -### 架构概览 +--- -| 层级 | 说明 | -|------|------| -| Python / FFmpeg | 多平台录制主逻辑;`douyin_youtube_ffplay.py` 将抖音流推至 YouTube(RTMPS);`web2.py` 提供进程管理、配置读写、`/client/overview` 工作台数据、网络/代理/系统指标、YouTube OAuth 与微信代理 API。 | -| Electron 主进程 | 启动 uvicorn;内置 **EasyTier**(`easytier-core` + `easytier-cli`);**sql.js** 持久化 `client-data.sqlite`(直播事件、用户操作、运行日志、快照);**YouTube Studio 侧窗** 注入与油猴脚本 [YouTube Studio Auto Dismiss](https://greasyfork.org/en/scripts/557378-youtube-studio-auto-dismiss) 等价的自动 Dismiss 逻辑。 | -| 渲染进程 | Verge 风格 UI:录制、网络(远程绑定 + 测速)、代理、系统、微信、**工作台**(频道 / EasyTier / 微信 / 快照 / 事件表)。 | +## 快速开始 -### 配置与数据路径 +### 最终用户(便携包) -| 路径 | 用途 | -|------|------| -| `config/youtube.ini` | 单路串流密钥等(与上游一致)。 | -| `config/youtube_channels.json` | **多路一对一**:每条为 `douyinUrl` + `youtubeKey`(不同抖音 ↔ 不同 YouTube),见 example;**禁止**同一抖音绑多个 YouTube、**禁止**多路共用一个密钥。未使用该文件时退化为单路 `youtube.ini` + `URL_config.ini`。 | -| `config/easytier_network.json` | 组网名、密钥、peer 引导节点。 | -| `config/wechat_chatbot.json` | 微信代理 API 配置(与 openclaw-wechat 兼容)。 | -| `%AppData%`/应用用户数据目录下的 `client-data.sqlite` | Electron 工作台 SQLite。 | +1. 运行发布包中的 **无人直播助手** 可执行文件。 +2. 打开 **直播** 页,在界面中填写: + - YouTube **串流密钥**(Studio → 直播 → 串流设置); + - **源站地址**(抖音直播间 URL,每行一个)。 +3. 点击 **开始直播**;在 **当前直播日志** 中查看业务状态与 FFmpeg 输出。 -### 功能摘要 - -- **工作台**:展示 **YouTube 频道(名称 : 密钥预览)**、**EasyTier `easytier-cli peer list` 解析的节点数(含手机/RN 等)**、**微信是否已配置 / 昵称与 wcId / 代理在线状态**;直播事件表含 **relay_intent**(抖音房间 → YouTube key 后缀)、**relay_session**(转播时长秒数)等。 -- **多路 Pro**:多组并行「抖音直播间 → YouTube 串流」,每组**唯一配对**(禁止一抖音多推、禁止多抖音同密钥)。录制页开关仅存偏好;**多进程推流引擎仍在开发**。 -- **Tampermonkey**:默认用 Electron 内建注入实现 Studio 自动 Dismiss,无需强制安装油猴扩展;若需完整 Tampermonkey,可自行使用 `session.loadExtension` 加载扩展目录。 -- **SRS**:[SRS](https://github.com/ossrs/srs) 计划在 `third_party/srs` 集成,具体能力待定(占位说明见该目录 `README.txt`)。 - -### Electron 开发 +### 开发者 ```bash cd electron @@ -47,674 +51,354 @@ pnpm install pnpm dev ``` +首次开发若缺少离线 Python/FFmpeg,可在 `electron/` 下执行 `pnpm prepare:runtime`。 + +仅调试后端 API(仓库根目录): + +```bash +pip install -r requirements.txt +uvicorn web2:app --host 127.0.0.1 --port 8001 --reload +``` + --- -一款**简易**的可循环值守的直播录制工具,基于FFmpeg实现多平台直播源录制,支持自定义配置录制以及直播状态推送。 +## 产品定位 - +| 场景 | 说明 | +|------|------| +| **无人转播** | 从抖音等源站拉流,经本机 FFmpeg 推送到 YouTube RTMPS。 | +| **值守运行** | 通过内置 `BuiltinSupervisor`(或可选 PM2)管理 `youtube2.py`、`douyin_youtube_ffplay.py` 等 worker。 | +| **桌面控制台** | Electron 提供直播、网络、系统等页面。 | +| **多路 Pro** | 多组「一个抖音直播间 ↔ 一个 YouTube 串流密钥」并行,进程名 `youtube2__{channelId}`。 | -## 😺已支持平台 +**配对原则(强制)** -- [x] 抖音 -- [x] TikTok -- [x] 快手 -- [x] 虎牙 -- [x] 斗鱼 -- [x] YY -- [x] B站 -- [x] 小红书 -- [x] bigo -- [x] blued -- [x] SOOP(原AfreecaTV) -- [x] 网易cc -- [x] 千度热播 -- [x] PandaTV -- [x] 猫耳FM -- [x] Look直播 -- [x] WinkTV -- [x] TTingLive(原Flextv) -- [x] PopkonTV -- [x] TwitCasting -- [x] 百度直播 -- [x] 微博直播 -- [x] 酷狗直播 -- [x] TwitchTV -- [x] LiveMe -- [x] 花椒直播 -- [x] 流星直播 -- [x] ShowRoom -- [x] Acfun -- [x] 映客直播 -- [x] 音播直播 -- [x] 知乎直播 -- [x] CHZZK -- [x] 嗨秀直播 -- [x] vv星球直播 -- [x] 17Live -- [x] 浪Live -- [x] 畅聊直播 -- [x] 飘飘直播 -- [x] 六间房直播 -- [x] 乐嗨直播 -- [x] 花猫直播 -- [x] Shopee -- [x] Youtube -- [x] 淘宝 -- [x] 京东 -- [x] Faceit -- [x] 咪咕 -- [x] 连接直播 -- [x] 来秀直播 -- [x] Picarto -- [ ] 更多平台正在更新中 +- 一个抖音直播间地址只对应 **一个** YouTube 串流密钥; +- **禁止** 同一抖音向多个 YouTube 推流; +- **禁止** 多个抖音共用同一密钥。 - +--- -## 🎈项目结构 +## 系统架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Electron 渲染进程 (React + Vite) │ +│ 直播 / 摄像头 / 网络 / 系统 │ +└───────────────────────────┬─────────────────────────────────────┘ + │ preload (contextBridge) +┌───────────────────────────▼─────────────────────────────────────┐ +│ Electron 主进程 │ +│ · 启动 uvicorn (web2:app) │ +│ · sql.js → client-data.sqlite │ +│ · 托盘、深链 --nav= │ +│ · YouTube Studio 系统浏览器侧车 │ +│ · youLIVE 服务(监听 / 弹幕 / 桌面通知 / 本地 SQLite) │ +└───────────────────────────┬─────────────────────────────────────┘ + │ http://127.0.0.1:8001(可 0.0.0.0) +┌───────────────────────────▼─────────────────────────────────────┐ +│ web2.py (FastAPI) │ +│ 进程启停 / 状态诊断 / 配置读写 / Pro / 质量守卫 / 会话历史 │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + youtube2.py douyin_youtube_ffplay.py tiktok*.py + │ │ + └──────── FFmpeg + Supervisor / PM2 ────────────────┘ +``` + +| 模块 | 文件 | 职责 | +|------|------|------| +| Worker | `youtube2.py`, `douyin_youtube_ffplay.py`, `main.py` | 拉流、转码、推流 | +| 控制面 | `web2.py` | HTTP API | +| 运行时诊断 | `web2_process_runtime.py` | `business_status`、FFmpeg 速度/帧数 | +| 质量守卫 | `web2_quality_guard.py` | 推流质量评分 | +| 转播历史 | `web2_relay_log.py` | 会话 SQLite、日志导入 | +| 配置治理 | `web2_config_governance.py` | 保存前备份、恢复 | +| 多频道 | `web2_relay_pro.py` | `youtube_channels.json`、`config/relay_pro/` | +| 摄像头 | `web2_camera.py`, `camera_youtube.py` | DirectShow 采集、NVENC 推流 | +| youLIVE | `electron/src/youlive/` | YouTube 监听、弹幕、账号、桌面通知 | +| 主进程 | `electron/src/main/` | 运行时打包、IPC、本地 DB | +| 界面 | `electron/src/renderer/` | React SPA | + +--- + +## 仓库结构 ``` . -└── DouyinLiveRecorder/ - ├── /config -> (config record) - ├── /logs -> (save runing log file) - ├── /backup_config -> (backup file) - ├── /douyinliverecorder -> (package) - ├── initializer.py-> (check and install nodejs) - ├── spider.py-> (get live data) - ├── stream.py-> (get live stream address) - ├── utils.py -> (contains utility functions) - ├── logger.py -> (logger handdle) - ├── room.py -> (get room info) - ├── ab_sign.py-> (generate dy token) - ├── /javascript -> (some decrypt code) - ├── main.py -> (main file) - ├── ffmpeg_install.py -> (ffmpeg install script) - ├── demo.py -> (call package test demo) - ├── msg_push.py -> (send live status update message) - ├── ffmpeg.exe -> (record video) - ├── index.html -> (play m3u8 and flv video) - ├── requirements.txt -> (library dependencies) - ├── docker-compose.yaml -> (Container Orchestration File) - ├── Dockerfile -> (Application Build Recipe) - ├── StopRecording.vbs -> (stop recording script on Windows) - ... +├── electron/ # 无人直播助手(Electron + React) +│ ├── src/main/ # 主进程 +│ ├── src/preload/ # contextBridge +│ ├── src/renderer/ # UI(直播 / 摄像头 / 网络 / 系统) +│ ├── src/youlive/ # 内嵌 youLIVE(主进程服务 + 共享类型) +│ └── scripts/ # 离线运行时、图标 +├── web2.py # FastAPI 入口 +├── web2_*.py # 后端子模块 +├── config/ # 运行配置 +├── backup_config/__web__/ # 配置自动备份 +├── runtime/youtube_relay/ # 转播会话 sessions.sqlite +├── src/ # 录制核心(spider、stream、room…) +├── camera_youtube.py # 摄像头→YouTube worker +├── douyin_youtube_ffplay.py # 抖音→YouTube 转播 +├── youtube2.py # YouTube worker +├── docs/ # 补充文档 +└── requirements.txt ``` - +--- -## 🌱使用说明 +## 界面与功能 -- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可,里面有打包好的录制软件。(有些电脑可能会报毒,直接忽略即可,如果下载时被浏览器屏蔽,请更换浏览器下载) +| 页面 | 说明 | +|------|------| +| **直播** | 单频道 / Pro:抖音源站转播、密钥、监听、启停、链路诊断、转播历史 | +| **摄像头** | 物理摄像头 + 麦克风 → FFmpeg → YouTube(`camera_youtube` 进程) | +| **网络** | 测速、代理 | +| **系统** | 负载、开机启动、路径、会员登录限制开关 | -- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`。 -- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。 +直播页能力摘要: -- 另外,如果需要录制TikTok、AfreecaTV等海外平台,请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。 +- **自适应轮询**:推流中 ~0.8s,空闲 ~4s,后台标签 ~30s +- **链路诊断**:源站 / 推流 / 目标三层检查(`GET /youtube/diagnostics`) +- **YouTube 目标端监控**:监听地址写入 `youtube_channels.json`,与转播线路绑定 +- **桌面通知**:推流未到达 YouTube、目标端下播、弹幕关键词(需 youLIVE 桌面通知开启) +- **转播历史**:PM2 状态自动记会话;支持从 PM2 日志导入 +- **配置历史备份**:保存前自动备份,界面可恢复 +- **YouTube Studio**:系统浏览器打开,便于核对密钥 +- **源站预览**:解析抖音 URL 并尝试显示主播昵称 +- **配对校验**:Pro 多线路保存时检测抖音地址 / YouTube 密钥跨频道重复,冲突时展示横幅并阻止启动 -- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。 +--- -- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清,https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。 +## web2 API -- 如果要长时间挂着软件循环监测直播,最好循环时间设置长一点(咱也不差没录制到的那几分钟),避免因请求频繁导致被官方封禁IP 。 +默认:`http://127.0.0.1:8001`(`WEB2_PORT` 可改)。 -- 要停止直播录制,Windows平台可执行StopRecording.vbs脚本文件,或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。 -- 最后,欢迎右上角给本项目一个star,同时也非常乐意大家提交pr。 +| 分类 | 路径 | 说明 | +|------|------|------| +| 健康 | `GET /health` | 存活(无需鉴权) | +| 进程 | `GET /process_monitor` | 任务列表(含 Pro 频道) | +| 进程 | `GET /start` `/stop` `/restart` | 参数 `process=` | +| 状态 | `GET /status?process=` | 进程状态 + 业务诊断 + FFmpeg 指标 | +| 配置 | `GET/POST /youtube/ini_model` | 结构化 `youtube.ini` | +| 配置 | `GET/POST /get_url_config` `save_url_config` | `URL_config.ini` | +| 配置 | `GET /config/backups?file=` | 历史备份列表 | +| 配置 | `POST /config/restore` | 恢复(`file`, `backupId`) | +| Pro | `/relay_pro/*` | 多频道 CRUD、密钥、URL、YouTube 监听地址、live_status | +| 诊断 | `GET /youtube/diagnostics` | 源站 / FFmpeg / 目标三层诊断 | +| 诊断 | `GET /youtube/quality_guard` | 质量评分(诊断内部复用) | +| 摄像头 | `GET /camera/devices` | FFmpeg 枚举音视频采集设备 | +| 摄像头 | `GET/POST /camera/config` | 读写 `camera_live.ini` | +| 历史 | `GET /youtube/relay/sessions` | 转播会话 | +| 历史 | `POST /youtube/relay/sessions/import` | 从 PM2 日志导入 | +| 网络 | `GET /network_status` `GET/POST /proxy_config` | 测速与代理 | +| 系统 | `GET /system_metrics` `GET /host/gpu_status` | 主机与 NVENC | +| OAuth | `GET /youtube/oauth/*` | Google 拉取串流密钥(UI 可关) | -  +**可选鉴权**:`config/web2_api_token.txt` 或 `WEB2_API_TOKEN` 存在时,除 `/health` 外需: -直播间链接示例: - -``` -抖音: -https://live.douyin.com/745964462470 -https://v.douyin.com/iQFeBnt/ -https://live.douyin.com/yall1102 (链接+抖音号) -https://v.douyin.com/CeiU5cbX (主播主页地址) - -TikTok: -https://www.tiktok.com/@pearlgaga88/live - -快手: -https://live.kuaishou.com/u/yall1102 - -虎牙: -https://www.huya.com/52333 - -斗鱼: -https://www.douyu.com/3637778?dyshid= -https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid= - -YY: -https://www.yy.com/22490906/22490906 - -B站: -https://live.bilibili.com/320 - -小红书(直播间分享地址): -http://xhslink.com/xpJpfM - -bigo直播: -https://www.bigo.tv/cn/716418802 - -buled直播: -https://app.blued.cn/live?id=Mp6G2R - -SOOP: -https://play.sooplive.co.kr/sw7love - -网易cc: -https://cc.163.com/583946984 - -千度热播: -https://qiandurebo.com/web/video.php?roomnumber=33333 - -PandaTV: -https://www.pandalive.co.kr/live/play/bara0109 - -猫耳FM: -https://fm.missevan.com/live/868895007 - -Look直播: -https://look.163.com/live?id=65108820&position=3 - -WinkTV: -https://www.winktv.co.kr/live/play/anjer1004 - -FlexTV(TTinglive):: -https://www.flextv.co.kr/channels/593127/live - -PopkonTV: -https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117 -https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117 - -TwitCasting: -https://twitcasting.tv/c:uonq - -百度直播: -https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category - -微博直播: -https://weibo.com/l/wblive/p/show/1022:2321325026370190442592 - -酷狗直播: -https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom= - -TwitchTV: -https://www.twitch.tv/gamerbee - -LiveMe: -https://www.liveme.com/zh/v/17141543493018047815/index.html - -花椒直播: -https://www.huajiao.com/l/345096174 - -流星直播: -https://www.7u66.com/100960 - -ShowRoom: -https://www.showroom-live.com/room/profile?room_id=480206 (主播主页地址) - -Acfun: -https://live.acfun.cn/live/179922 - -映客直播: -https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904 - -音播直播: -https://live.ybw1666.com/800002949 - -知乎直播: -https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址) - -CHZZK: -https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 - -嗨秀直播: -https://www.haixiutv.com/6095106 - -VV星球直播: -https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar - -17Live: -https://17.live/en/live/6302408 - -浪Live: -https://www.lang.live/en-US/room/3349463 - -畅聊直播: -https://live.tlclw.com/106188 - -飘飘直播: -https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl - -六间房直播: -https://v.6.cn/634435 - -乐嗨直播: -https://www.lehaitv.com/8059096 - -花猫直播: -https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331 - -Shopee: -https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458 - -Youtube: -https://www.youtube.com/watch?v=cS6zS5hi1w0 - -淘宝(需cookie): -https://tbzb.taobao.com/live?liveId=532359023188 -https://m.tb.cn/h.TWp0HTd - -京东: -https://3.cn/28MLBy-E - -Faceit: -https://www.faceit.com/zh/players/Compl1/stream - -连接直播: -https://show.lailianjie.com/10000258 - -咪咕直播: -https://www.miguvideo.com/p/live/120000541321 - -来秀直播: -https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 - -Picarto: -https://www.picarto.tv/cuteavalanche +```http +Authorization: Bearer <密钥> ``` -  +Electron 主进程自动注入 API 鉴权头。 -## 🎃源码运行 -使用源码运行,可参考下面的步骤。 +--- -1.首先拉取或手动下载本仓库项目代码 +## 配置与数据路径 + +| 路径 | 用途 | +|------|------| +| `config/youtube.ini` | 单路密钥与 `[youtube]` 选项(GPU、分辨率、RTMPS) | +| `config/URL_config.ini` | 单路源站 URL 列表 | +| `config/youtube_channels.json` | Pro 频道元数据(见 `youtube_channels.example.json`) | +| `config/relay_pro/url_config.ch_*.ini` | 每频道源站地址 | +| `config/relay_pro/youtube.ch_*.ini` | 每频道密钥(后端物化) | +| `config/web2_api_token.txt` | API 鉴权密钥 | +| `config/web2_bind_host.txt` | 监听地址(懒人包默认 `127.0.0.1`;局域网访问可设 `0.0.0.0` 并配置 token) | +| `config/google_oauth.example.json` | YouTube OAuth 模板 | +| `config/ffmpeg_proxy.ini` | FFmpeg 代理 | +| `config/camera_live.ini` | 摄像头采集参数(见 `camera_live.example.ini`) | +| `backup_config/__web__/` | 配置版本备份 | +| `runtime/youtube_relay/sessions.sqlite` | 转播会话库 | +| 用户数据目录 `client-data.sqlite` | Electron 本地事件与快照 | + +示例 `config/youtube_channels.json`: + +```json +{ + "version": 1, + "channels": [ + { + "id": "ch_a", + "name": "路线A", + "douyinUrl": "https://live.douyin.com/123456789", + "youtubeKey": "xxxx-xxxx-xxxx-xxxx" + } + ] +} +``` + +--- + +## 单频道与多频道 Pro + +### 单频道 + +1. 填写 `config/youtube.ini` 中的串流密钥。 +2. 填写 `config/URL_config.ini` 中的抖音直播间地址。 +3. **直播** 页选择 `youtube2` → **开始直播**。 + +### 多频道 Pro + +1. 直播页打开 **Pro** 开关。 +2. 界面新增频道,或编辑 `config/youtube_channels.json`。 +3. 每频道进程:`youtube2__{channelId}`,独立密钥与 URL 文件。 +4. 网格内按频道启停;预检要求密钥与源站均已配置。 + +--- + +## 推流诊断 / 质量守卫 / 转播历史 + +`GET /status` 的 `business_status` 示例: + +| 状态 | 含义 | +|------|------| +| `streaming` | 正在推流 | +| `relay_slow` | 推流速度低于实时 | +| `source_stalled` | FFmpeg 进度停滞 | +| `youtube_key_rejected` | YouTube 拒绝 RTMPS | +| `waiting_source` | 等待源站开播 | +| `misconfigured` | 配置不完整 | +| `monitoring` | 在线监测中 | + +**链路诊断**:`GET /youtube/diagnostics` → 源站 / FFmpeg / 目标三层检查与综合评分。 + +**质量守卫**:`GET /youtube/quality_guard` → FFmpeg 指标与告警(诊断层内部复用)。 + +**转播历史**:`runtime/youtube_relay/sessions.sqlite` + +- PM2 `online` / 停止时自动开闭会话; +- `/status` 轮询时从日志同步主播与时长; +- 界面 **从日志导入** 可解析历史 PM2 输出。 + +--- + +## youLIVE 目标端监控 + +内嵌于 Electron 主进程的 **youLIVE** 模块(`electron/src/youlive/`),通过 preload 暴露 `window.youlive` API,为直播页提供 YouTube **目标端**能力,与 `web2` 控制的 **源站推流** 互补。 + +| 能力 | 说明 | +|------|------| +| **监听列表** | 按 YouTube 频道 / 直播页 URL 轮询开播状态、观众数 | +| **转播绑定** | 将监听地址写入 `youtube_channels.json` 的 `youtubeWatchUrl`,与 Pro 线路一一对应 | +| **弹幕抓取** | 直播进行中拉取聊天消息,支持关键词告警 | +| **桌面通知** | 推流未到达 YouTube、目标端下播、弹幕关键词命中时通知(需在 youLIVE 设置中开启) | +| **本地存储** | `youlive` SQLite 保存监听项、会话、告警历史 | + +直播页 **YouTube 目标端监控** 面板在 Pro / 单路模式下自动同步监听地址;摄像头页亦可绑定 `youtube_watch_url` 做目标端校验。 + +--- + +## 摄像头直推 + +**摄像头** 页将本机物理摄像头 + 麦克风经 FFmpeg 直推 YouTube RTMPS,进程名 `camera_youtube`(与抖音转播 `youtube2` 独立)。 + +1. 在界面选择 **视频设备** / **音频设备**(后端 `GET /camera/devices` 枚举 DirectShow 设备)。 +2. 设置分辨率、帧率、码率;可选 NVENC 硬件编码。 +3. 填写 YouTube **串流密钥**(复用 `config/youtube.ini`)。 +4. 可选填写 **YouTube 直播页地址** 供目标端监听。 +5. 点击 **开始直播**。 + +配置持久化于 `config/camera_live.ini`(模板见 `config/camera_live.example.ini`)。 + +--- + +## 会员登录门禁(可选) + +默认 **关闭**(无登录 UI,启动直播无账号要求)。 + +开启:**系统** → **客户端与系统集成** → **会员登录限制** + +- 侧栏显示登录/注册; +- 开始/重启前需 PocketBase 会员(`X-PB-Token`); +- 状态键:`localStorage` → `pb_auth_gate_enabled`。 + +--- + +## 开发与构建 + +| 组件 | 版本 | +|------|------| +| Node.js | 18+ | +| pnpm | 10+ | +| Python | 3.10+(开发机;发布包自带运行时) | + +| 命令(在 `electron/`) | 说明 | +|------------------------|------| +| `pnpm dev` | 开发模式 | +| `pnpm build` | 编译到 `out/` | +| `pnpm prepare:runtime` | 准备离线 Python/FFmpeg | +| `pnpm verify:runtime` | 校验运行时 | +| `pnpm dist` | Windows 便携包 | + +--- + +## 打包发布 ```bash -git clone https://github.com/ihmily/DouyinLiveRecorder.git +cd electron +pnpm install +pnpm dist ``` -2.进入项目文件夹,安装依赖 +产物:`electron/dist/` 便携版。仓库根(除 `electron/`)打入 `resources/backend/`,用户机无需单独安装 Python。 -```bash -cd DouyinLiveRecorder -``` +--- -> [!TIP] -> - 不论你是否已安装 **Python>=3.10** 环境, 都推荐使用 [**uv**](https://github.com/astral-sh/uv) 运行, 因为它可以自动管理虚拟环境和方便地管理 **Python** 版本, **不过这完全是可选的**
-> 使用以下命令安装 -> ```bash -> # 在 macOS 和 Linux 上安装 uv -> curl -LsSf https://astral.sh/uv/install.sh | sh -> ``` -> ```powershell -> # 在 Windows 上安装 uv -> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" -> ``` -> - 如果安装依赖速度太慢, 你可以考虑使用国内 pip 镜像源:
-> 在 `pip` 命令使用 `-i` 参数指定, 如 `pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
-> 或者在 `uv` 命令 `--index` 选项指定, 如 `uv sync --index https://pypi.tuna.tsinghua.edu.cn/simple` +## 环境变量 -
+| 变量 | 说明 | +|------|------| +| `WEB2_PORT` | API 端口,默认 `8001` | +| `WEB2_HOST` | 监听地址(优先于 `web2_bind_host.txt`) | +| `WEB2_API_TOKEN` | API 密钥 | +| `WEB2_SUPERVISOR` | 进程后端;Pro 多频道勿强制 PM2 | +| `VITE_API_BASE` | 渲染进程 API 根(非 Electron 调试) | +| `VITE_WEB2_API_TOKEN` | 开发用 Bearer | - 如果已安装 Python>=3.10 环境 +--- - - :white_check_mark: 在虚拟环境中安装 (推荐) - - 1. 创建虚拟环境 +## 故障排查 - - 使用系统已安装的 Python, 不使用 uv - - ```bash - python -m venv .venv - ``` +| 现象 | 处理 | +|------|------| +| 无法启动 / 服务未就绪 | 查 uvicorn 日志;确认 8001 未被占用;`pnpm verify:runtime` | +| YouTube 无画面 | 看 `youtube_key_rejected`;核对 Studio 密钥;检查 RTMPS 与代理 | +| `relay_slow` / 掉帧 | 查 NVENC;降分辨率;关占用 CPU 的程序 | +| Pro 无法启动 | 勿 `WEB2_SUPERVISOR=pm2`;每频道密钥与 URL 已保存;检查配对冲突横幅 | +| 摄像头无画面 | 确认 DirectShow 设备名与 `camera_live.ini` 一致;尝试关闭 GPU 编码 | +| 目标端监听无数据 | 确认 `youtubeWatchUrl` / `youtube_watch_url` 已填写;youLIVE 桌面通知已开启 | +| 配置误改 | 直播页 **历史备份** 或 `POST /config/restore` | - - 使用 uv, 默认使用系统 Python, 你可以添加 `--python` 选项指定 Python 版本而不使用系统 Python [uv官方文档](https://docs.astral.sh/uv/concepts/python-versions/) - - ```bash - uv venv - ``` - - 2. 在终端激活虚拟环境 (在未安装 uv 或你想要手动激活虚拟环境时执行, 若已安装 uv, 可以跳过这一步, uv 会自动激活并使用虚拟环境) - - **Bash** 中 - ```bash - source .venv/Scripts/activate - ``` +日志: - **Powershell** 中 - ```powershell - .venv\Scripts\activate.ps1 - ``` - - **Windows CMD** 中 - ```bat - .venv\Scripts\activate.bat - ``` +- Worker:`.pm2/logs/` 或界面「当前直播日志」 +- 客户端:系统页打开「客户端日志」目录 - 3. 安装依赖 - - ```bash - # 使用 pip (若安装太慢或失败, 可使用 `-i` 指定镜像源) - pip3 install -U pip && pip3 install -r requirements.txt - # 或者使用 uv (可使用 `--index` 指定镜像源) - uv sync - # 或者 - uv pip sync requirements.txt - ``` +--- - - :x: 在系统 Python 环境中安装 (不推荐) - - ```bash - pip3 install -U pip && pip3 install -r requirements.txt - ``` +## 相关文档 -
+| 文档 | 说明 | +|------|------| +| [docs/YOUTUBE_LIVE_SEO.md](docs/YOUTUBE_LIVE_SEO.md) | YouTube 直播 SEO | +| [docs/REALTIME_AUDIO_DEMUCS.md](docs/REALTIME_AUDIO_DEMUCS.md) | 实时音频 Demucs | -
+--- - 如果未安装 Python>=3.10 环境 +## 许可证 - 你可以使用 [**uv**](https://github.com/astral-sh/uv) 安装依赖 - - ```bash - # uv 将使用 3.10 及以上的最新 python 发行版自动创建并使用虚拟环境, 可使用 --python 选项指定 python 版本, 参见 https://docs.astral.sh/uv/reference/cli/#uv-sync--python 和 https://docs.astral.sh/uv/reference/cli/#uv-pip-sync--python - uv sync - # 或 - uv pip sync requirements.txt - ``` - -
- -3.安装[FFmpeg](https://ffmpeg.org/download.html#build-linux),如果是Windows系统,这一步可跳过。对于Linux系统,执行以下命令安装 - -CentOS执行 - -```bash -yum install epel-release -yum install ffmpeg -``` - -Ubuntu则执行 - -```bash -apt update -apt install ffmpeg -``` - -macOS 执行 - -**如果已经安装 Homebrew 请跳过这一步** - -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` - -```bash -brew install ffmpeg -``` - -4.运行程序 - -```python -python main.py - -``` -或 - -```bash -uv run main.py -``` - -其中Linux系统请使用`python3 main.py` 运行。 - -  -## 🐋容器运行 - -在运行命令之前,请确保您的机器上安装了 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/) - -1.快速启动 - -最简单方法是运行项目中的 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,只需简单执行以下命令: - -```bash -docker-compose up -``` - -可选 `-d` 在后台运行。 - - - -2.构建镜像(可选) - -如果你只想简单的运行程序,则不需要做这一步。Docker镜像仓库中代码版本可能不是最新的,如果要运行本仓库主分支最新代码,可以本地自定义构建,通过修改 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,如将镜像名修改为 `douyin-live-recorder:latest`,并取消 `# build: .` 注释,然后再执行 - -```bash -docker build -t douyin-live-recorder:latest . -docker-compose up -``` - -或者直接使用下面命令进行构建并启动 - -```bash -docker-compose -f docker-compose.yaml up -``` - - - -3.停止容器实例 - -```bash -docker-compose stop -``` - - - -4.注意事项 - -①在docker容器内运行本程序之前,请先在配置文件中添加要录制的直播间地址。 - -②在容器内时,如果手动中断容器运行停止录制,会导致正在录制的视频文件损坏! - -**无论哪种运行方式,为避免手动中断或者异常中断导致录制的视频文件损坏的情况,推荐使用 `ts` 格式保存**。 - -  - -## 🤖相关项目 - -- StreamCap: https://github.com/ihmily/StreamCap -- streamget: https://github.com/ihmily/streamget - -  - -## ❤️贡献者 - -   [![Hmily](https://github.com/ihmily.png?size=50)](https://github.com/ihmily) -[![iridescentGray](https://github.com/iridescentGray.png?size=50)](https://github.com/iridescentGray) -[![annidy](https://github.com/annidy.png?size=50)](https://github.com/annidy) -[![wwkk2580](https://github.com/wwkk2580.png?size=50)](https://github.com/wwkk2580) -[![missuo](https://github.com/missuo.png?size=50)](https://github.com/missuo) -xueli12 -kaine1973 -yinruiqing -Max-Tortoise -[![justdoiting](https://github.com/justdoiting.png?size=50)](https://github.com/justdoiting) -[![dhbxs](https://github.com/dhbxs.png?size=50)](https://github.com/dhbxs) -[![wujiyu115](https://github.com/wujiyu115.png?size=50)](https://github.com/wujiyu115) -[![zhanghao333](https://github.com/zhanghao333.png?size=50)](https://github.com/zhanghao333) -gyc0123 - -   [![HoratioShaw](https://github.com/HoratioShaw.png?size=50)](https://github.com/HoratioShaw) -[![nov30th](https://github.com/nov30th.png?size=50)](https://github.com/nov30th) -[![727155455](https://github.com/727155455.png?size=50)](https://github.com/727155455) -[![nixingshiguang](https://github.com/nixingshiguang.png?size=50)](https://github.com/nixingshiguang) -[![1411430556](https://github.com/1411430556.png?size=50)](https://github.com/1411430556) -[![Ovear](https://github.com/Ovear.png?size=50)](https://github.com/Ovear) -  - -## ⏳提交日志 - -- 20251024 - - 修复抖音风控无法获取数据问题 - - - 新增soop.com录制支持 - - - 修复bigo录制 - -- 20250127 - - 新增淘宝、京东、faceit直播录制 - - 修复小红书直播流录制以及转码问题 - - 修复畅聊、VV星球、flexTV直播录制 - - 修复批量微信直播推送 - - 新增email发送ssl和port配置 - - 新增强制转h264配置 - - 更新ffmpeg版本 - - 重构包为异步函数! - -- 20241130 - - 新增shopee、youtube直播录制 - - 新增支持自定义m3u8、flv地址录制 - - 新增自定义执行脚本,支持python、bat、bash等 - - 修复YY直播、花椒直播和小红书直播录制 - - 修复b站标题获取错误 - - 修复log日志错误 -- 20241030 - - 新增嗨秀直播、vv星球直播、17Live、浪Live、SOOP、畅聊直播(原时光直播)、飘飘直播、六间房直播、乐嗨直播、花猫直播等10个平台直播录制 - - 修复小红书直播录制,支持小红书作者主页地址录制直播 - - 新增支持ntfy消息推送,以及新增支持批量推送多个地址(逗号分隔多个推送地址) - - 修复Liveme直播录制、twitch直播录制 - - 新增Windows平台一键停止录制VB脚本程序 -- 20241005 - - 新增邮箱和Bark推送 - - 新增直播注释停止录制 - - 优化分段录制 - - 重构部分代码 -- 20240928 - - 新增知乎直播、CHZZK直播录制 - - 修复音播直播录制 -- 20240903 - - 新增抖音双屏录制、音播直播录制 - - 修复PandaTV、bigo直播录制 -- 20240713 - - 新增映客直播录制 -- 20240705 - - 新增时光直播录制 -- 20240701 - - 修复虎牙直播录制2分钟断流问题 - - 新增自定义直播推送内容 -- 20240621 - - 新增Acfun、ShowRoom直播录制 - - 修复微博录制、新增直播源线路 - - 修复斗鱼直播60帧录制 - - 修复酷狗直播录制 - - 修复TikTok部分无法解析直播源 - - 修复抖音无法录制连麦直播 -- 20240510 - - 修复部分虎牙直播间录制错误 -- 20240508 - - 修复花椒直播录制 - - 更改文件路径解析方式 [@kaine1973](https://github.com/kaine1973) -- 20240506 - - 修复抖音录制画质解析bug - - 修复虎牙录制 60帧最高画质问题 - - 新增流星直播录制 -- 20240427 - - 新增LiveMe、花椒直播录制 -- 20240425 - - 新增TwitchTV直播录制 -- 20240424 - - 新增酷狗直播录制、优化PopkonTV直播录制 -- 20240423 - - 新增百度直播录制、微博直播录制 - - 修复斗鱼录制直播回放的问题 - - 新增直播源地址显示以及输出到日志文件设置 -- 20240311 - - 修复海外平台录制bug,增加画质选择,增强录制稳定性 - - 修复虎牙录制bug (虎牙`一起看`频道 有特殊限制,有时无法录制) -- 20240309 - - 修复虎牙直播、小红书直播和B站直播录制 - - 新增5个直播平台录制,包括winktv、flextv、look、popkontv、twitcasting - - 新增部分海外平台账号密码配置,实现自动登录并更新配置文件中的cookie - - 新增自定义配置需要使用代理录制的平台 - - 新增只推送开播消息不进行录制设置 - - 修复了一些bug -- 20240209 - - 优化AfreecaTV录制,新增账号密码登录获取cookie以及持久保存 - - 修复了小红书直播因官方更新直播域名,导致无法录制直播的问题 - - 修复了更新URL配置文件的bug - - 最后,祝大家新年快乐! - -
点击展开更多提交日志 - -- 20240129 - - 新增猫耳FM直播录制 -- 20240127 - - 新增千度热播直播录制、新增pandaTV(韩国)直播录制 - - 新增telegram直播状态消息推送,修复了某些bug - - 新增自定义设置不同直播间的录制画质(即每个直播间录制画质可不同) - - 修改录制视频保存路径为 `downloads` 文件夹,并且分平台进行保存。 -- 20240114 - - 新增网易cc直播录制,优化ffmpeg参数,修改AfreecaTV输入直播地址格式 - - 修改日志记录器 @[iridescentGray](https://github.com/iridescentGray) -- 20240102 - - 修复Linux上运行,新增docker配置文件 -- 20231210 - - 修复录制分段bug,修复bigo录制检测bug - - 新增自定义修改录制主播名 - - 新增AfreecaTV直播录制,修复某些可能会发生的bug -- 20231207 - - 新增blued直播录制,修复YY直播录制,新增直播结束消息推送 -- 20231206 - - 新增bigo直播录制 -- 20231203 - - 新增小红书直播录制(全网首发),目前小红书官方没有切换清晰度功能,因此直播录制也只有默认画质 - - 小红书录制暂时无法循环监测,每次主播开启直播,都要重新获取一次链接 - - 获取链接的方式为 将直播间转发到微信,在微信中打开后,复制页面的链接。 -- 20231030 - - 本次更新只是进行修复,没时间新增功能。 - - 欢迎各位大佬提pr 帮忙更新维护 -- 20230930 - - 新增抖音从接口获取直播流,增强稳定性 - - 修改快手获取直播流的方式,改用从官方接口获取 - - 祝大家中秋节快乐! -- 20230919 - - 修复了快手版本更新后录制出错的问题,增加了其自动获取cookie(~~稳定性未知~~) - - 修复了TikTok显示正在直播但不进行录制的问题 -- 20230907 - - 修复了因抖音官方更新了版本导致的录制出错以及短链接转换出错 - - 修复B站无法录制原画视频的bug - - 修改了配置文件字段,新增各平台自定义设置Cookie -- 20230903 - - 修复了TikTok录制时报644无法录制的问题 - - 新增直播状态推送到钉钉和微信的功能,如有需要请看 [设置推送教程](https://d04vqdiqwr3.feishu.cn/docx/XFPwdDDvfobbzlxhmMYcvouynDh?from=from_copylink) - - 最近比较忙,其他问题有时间再更新 -- 20230816 - - 修复斗鱼直播(官方更新了字段)和快手直播录制出错的问题 -- 20230814 - - 新增B站直播录制 - - 写了一个在线播放M3U8和FLV视频的网页源码,打开即可食用 -- 20230812 - - 新增YY直播录制 -- 20230808 - - 修复主播重新开播无法再次录制的问题 -- 20230807 - - 新增了斗鱼直播录制 - - 修复显示录制完成之后会重新开始录制的问题 -- 20230805 - - 新增了虎牙直播录制,其暂时只能用flv视频流进行录制 - - Web API 新增了快手和虎牙这两个平台的直播流解析(TikTok要代理) -- 20230804 - - 新增了快手直播录制,优化了部分代码 - - 上传了一个自动化获取抖音直播间页面Cookie的代码,可以用于录制 -- 20230803 - - 通宵更新 - - 新增了国际版抖音TikTok的直播录制,去除冗余 简化了部分代码 -- 20230724 - - 新增了一个通过抖音直播间地址获取直播视频流链接的API接口,上传即可用 -
-   - -## 有问题可以提issue, 我会在这里持续添加更多直播平台的录制 欢迎Star -#### +本项目继承上游 DouyinLiveRecorder 及相关依赖的许可证条款。使用第三方平台直播服务请遵守各平台服务条款。 diff --git a/camera_youtube.py b/camera_youtube.py new file mode 100644 index 0000000..864c098 --- /dev/null +++ b/camera_youtube.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""Physical camera / microphone → YouTube RTMPS live relay (Windows DirectShow first).""" + +from __future__ import annotations + +import configparser +import os +import platform +import signal +import subprocess +import sys +import time +from pathlib import Path + +from web2_proxy_config import merge_child_env + +BASE_DIR = Path(__file__).resolve().parent +CONFIG_DIR = BASE_DIR / "config" +CAMERA_INI = CONFIG_DIR / "camera_live.ini" +YOUTUBE_INI = CONFIG_DIR / "youtube.ini" + +RUNNING = True + + +def _on_signal(*_args): + global RUNNING + RUNNING = False + + +signal.signal(signal.SIGTERM, _on_signal) +signal.signal(signal.SIGINT, _on_signal) + + +def _load_camera() -> dict[str, str]: + defaults = { + "video_device": "", + "audio_device": "", + "width": "1280", + "height": "720", + "fps": "30", + "bitrate": "2500", + "use_gpu": "是", + } + if not CAMERA_INI.exists(): + return defaults + cp = configparser.ConfigParser() + cp.read(CAMERA_INI, encoding="utf-8") + if not cp.has_section("camera"): + return defaults + out = dict(defaults) + for k in out: + if cp.has_option("camera", k): + out[k] = (cp.get("camera", k) or "").strip() + return out + + +def _load_youtube_key() -> tuple[str, str]: + rtmps = "rtmps://a.rtmp.youtube.com/live2/" + if not YOUTUBE_INI.exists(): + return "", rtmps + cp = configparser.ConfigParser() + cp.read(YOUTUBE_INI, encoding="utf-8") + if not cp.has_section("youtube"): + return "", rtmps + key = "" + for name, val in cp.items("youtube"): + v = (val or "").strip() + if name in ("key", "stream_key") and v and not v.startswith("#"): + key = v + break + if key.startswith("rtmp://") or key.startswith("rtmps://"): + return key, key + return key, rtmps + key if key else "" + + +def _pick_nvenc(force_sw: bool) -> tuple[str, list[str]]: + if force_sw: + return "libx264", ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high"] + try: + if platform.system().lower() not in ("windows", "linux"): + raise RuntimeError("skip") + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5) + if check.returncode != 0: + raise RuntimeError("no nvidia") + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5) + if "h264_nvenc" in (enc.stdout or ""): + print("[INFO] 使用 NVENC 硬件编码") + return "h264_nvenc", ["-profile:v", "high", "-tune", "ll", "-rc", "cbr"] + except Exception: + pass + return "libx264", ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high"] + + +def _build_cmd(cfg: dict[str, str], youtube_url: str) -> list[str]: + w = int(cfg.get("width") or "1280") + h = int(cfg.get("height") or "720") + fps = int(float(cfg.get("fps") or "30")) + br = int(cfg.get("bitrate") or "2500") + b_v = f"{br}k" + maxrate = f"{min(int(br * 1.2), 8000)}k" + bufsize = f"{br * 2}k" + video_dev = cfg.get("video_device") or "" + audio_dev = cfg.get("audio_device") or "" + force_sw = (cfg.get("use_gpu") or "").lower() in ("否", "0", "false", "无", "关", "off") + codec, vopts = _pick_nvenc(force_sw) + + sys_name = platform.system().lower() + if sys_name == "windows": + inp = f"video={video_dev}" + if audio_dev: + inp += f":audio={audio_dev}" + input_args = [ + "-f", + "dshow", + "-video_size", + f"{w}x{h}", + "-framerate", + str(fps), + "-i", + inp, + ] + elif sys_name == "darwin": + dev = video_dev or "0" + input_args = ["-f", "avfoundation", "-framerate", str(fps), "-video_size", f"{w}x{h}", "-i", f"{dev}:0"] + else: + dev = video_dev or "/dev/video0" + input_args = ["-f", "v4l2", "-framerate", str(fps), "-video_size", f"{w}x{h}", "-i", dev] + if audio_dev: + input_args = ["-f", "v4l2", "-i", dev, "-f", "alsa", "-i", audio_dev] + + return [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "info", + *input_args, + "-c:v", + codec, + *vopts, + "-pix_fmt", + "yuv420p", + "-b:v", + b_v, + "-maxrate", + maxrate, + "-bufsize", + bufsize, + "-g", + str(fps * 2), + "-c:a", + "aac", + "-b:a", + "128k", + "-ar", + "44100", + "-f", + "flv", + youtube_url, + ] + + +def main() -> None: + print("[INFO] 摄像头 → YouTube 转播进程已启动") + proc: subprocess.Popen | None = None + while RUNNING: + cfg = _load_camera() + key, youtube_url = _load_youtube_key() + if not key: + print("[WARN] 请在 config/youtube.ini 配置 YouTube 串流密钥 (key=…)") + time.sleep(5) + continue + if not (cfg.get("video_device") or "").strip(): + print("[WARN] 请在摄像头配置中选择 video_device") + time.sleep(5) + continue + cmd = _build_cmd(cfg, youtube_url) + print("[INFO] 启动 FFmpeg 摄像头推流…") + print("[INFO] ffmpeg " + " ".join(cmd[1:])) + env = merge_child_env(os.environ.copy()) + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=str(BASE_DIR), + ) + except Exception as e: + print(f"[ERROR] 无法启动 FFmpeg: {e}") + time.sleep(5) + continue + + assert proc.stdout is not None + while RUNNING: + line = proc.stdout.readline() + if not line: + break + print(line.rstrip(), flush=True) + code = proc.wait() + proc = None + if not RUNNING: + break + print(f"[WARN] FFmpeg 退出 code={code},3 秒后重试…") + time.sleep(3) + + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=5) + except Exception: + try: + proc.kill() + except Exception: + pass + print("[INFO] 摄像头转播进程已退出") + + +if __name__ == "__main__": + main() diff --git a/config/camera_live.example.ini b/config/camera_live.example.ini new file mode 100644 index 0000000..95ce564 --- /dev/null +++ b/config/camera_live.example.ini @@ -0,0 +1,11 @@ +[camera] +# FFmpeg 枚举的设备名(Windows DirectShow 为引号内完整名称) +video_device = +audio_device = +width = 1280 +height = 720 +fps = 30 +bitrate = 2500 +use_gpu = 是 +# YouTube 频道/直播页地址,供目标端监听 +youtube_watch_url = diff --git a/config/wechat_chatbot.example.json b/config/wechat_chatbot.example.json deleted file mode 100644 index 120fb25..0000000 --- a/config/wechat_chatbot.example.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "apiKey": "wc_live_xxxxxxxx", - "proxyUrl": "http://your-proxy-server:3000", - "deviceType": "ipad", - "proxy": "10", - "accountId": "default", - "webhookHost": "", - "webhookPort": 18790, - "webhookPath": "/webhook/wechat" -} diff --git a/docs/p2p-remote.md b/docs/p2p-remote.md deleted file mode 100644 index e91e064..0000000 --- a/docs/p2p-remote.md +++ /dev/null @@ -1,72 +0,0 @@ -# 远程控制与 P2P 虚拟网(EasyTier) - -## 方案对比(简要) - -| 方案 | 特点 | -|------|------| -| [EasyTier](https://github.com/EasyTier/EasyTier) | 去中心化 mesh、NAT 穿透、Win/Android 有现成客户端;与「本机 HTTP API」组合简单。 | -| Tailscale / ZeroTier | 需协调服务或自建 Moon;上手快,依赖厂商或自建协调器。 | -| 纯 WebRTC | 真 P2P,但需信令(常要服务器)或复杂打洞流程,不适合直接替代「虚拟网 + REST」。 | - -对本项目(`web2` FastAPI + Electron + 未来 React Native),**EasyTier + 固定虚拟 IP 访问 `http://:8001`** 是务实组合。 - -## Windows 桌面端:内置 EasyTier - -- 构建/开发前会执行 `pnpm run prepare-easytier`(或 `electron/scripts/download-easytier.mjs`),从 [EasyTier 官方 Release](https://github.com/EasyTier/EasyTier/releases) 下载 Windows x64 包并解压 `easytier-core.exe` 到 `electron/resources/easytier/`(LGPL-3.0,见同目录 `NOTICE-EasyTier.txt`)。 -- 应用启动后自动拉起该进程,并在 `config/easytier_network.json` **首次自动生成**组网名与密钥(`peerUrls` 可留空或按需添加社区共享节点)。 -- 若需跳过下载(如无网络 CI),可设置环境变量 `SKIP_EASYTIER=1`。 - -## 本项目已做的改造 - -1. **监听地址**:Electron 启动的 uvicorn 默认仍为 `127.0.0.1`。若存在 `config/web2_bind_host.txt`(单行,如 `0.0.0.0`)或环境变量 `WEB2_HOST`,则按该地址绑定。 -2. **API 鉴权(可选)**:若存在 `config/web2_api_token.txt`(单行密钥)或环境变量 `WEB2_API_TOKEN`,则除 `/health`、OpenAPI 文档路径外,请求需带: - - `Authorization: Bearer <密钥>`,或 - - `X-API-Token: <密钥>` -3. **Electron 前端**:自动从主进程读取 token 文件并注入请求头;浏览器 / React Native 需自行配置同一密钥。 -4. **「网络」页**:提供「允许远程」开关(写入 `web2_bind_host.txt` 并重启后端)、本机候选虚拟网 IP 列表、**生成 API 密钥**,以及 **二维码**(JSON 文本,供 RN 扫码解析)。 - -### 扫码绑定 JSON 格式(`v` 为版本号) - -```json -{ - "v": 1, - "baseUrl": "http://10.126.126.1:8001", - "port": 8001, - "token": "与 web2_api_token.txt 一致", - "easytier": { - "networkName": "与 PC 上 config/easytier_network.json 一致", - "networkSecret": "同上", - "peerUrls": [] - } -} -``` - -React Native:解析 JSON 后,**App 内**需使用与 PC 同版本的 EasyTier 组件并启动同一 `networkName` / `networkSecret`(可选 `peerUrls`),再请求 `baseUrl` 并带 `Authorization: Bearer ${token}`。 - -### 移动端 App 内置 EasyTier - -- Android 可参考 [EasyTier 官方 APK](https://github.com/EasyTier/EasyTier/releases) 或同仓库原生库,将组网逻辑与扫码得到的 `easytier` 字段对齐;**同一套 JSON 即可实现 PC↔手机同网,无需用户再单独安装 EasyTier 客户端**(由你们 App 内置分发)。 - -## EasyTier 侧(用户操作) - -1. PC 与手机分别安装 EasyTier,使用**相同** `--network-name` 与 `--network-secret`。 -2. 按 [EasyTier 文档](https://github.com/EasyTier/EasyTier) 连接共享节点或自建入口,确认两端能 `ping` 通虚拟 IP。 -3. 在 PC 上把 `config/web2_bind_host.txt` 设为 `0.0.0.0`,并创建 `config/web2_api_token.txt`(随机长串)。 -4. 重启本应用;在手机浏览器或 RN 中请求:`http://:8001/process_monitor` 等,并带上 `Authorization` 头。 - -## React Native 示例 - -```ts -const BASE = 'http://10.126.126.1:8001' // 替换为 PC 在 EasyTier 中的 IP -const TOKEN = '你的 web2_api_token.txt 内容' - -fetch(`${BASE}/health`).then((r) => r.json()) -fetch(`${BASE}/process_monitor`, { - headers: { Authorization: `Bearer ${TOKEN}` }, -}) -``` - -## 安全说明 - -- `0.0.0.0` 且无 token 时,控制台会警告;**不要**将无鉴权 API 暴露到公网。 -- 防火墙仅向 EasyTier 虚拟网段放行 `8001`(若需)。 diff --git a/electron/package.json b/electron/package.json index 66f9115..37e5103 100644 --- a/electron/package.json +++ b/electron/package.json @@ -17,20 +17,23 @@ }, "dependencies": { "d3": "^7.9.0", + "drizzle-orm": "^0.44.2", "echarts": "^6.0.0", - "qrcode": "^1.5.4", + "framer-motion": "^12.23.12", + "lucide-react": "^0.525.0", "react": "^18.3.1", "react-dom": "^18.3.1", "sql.js": "^1.14.1", - "three": "^0.183.2" + "three": "^0.183.2", + "uuid": "^11.1.0", + "youtubei.js": "^17.2.0" }, "devDependencies": { "@types/d3": "^7.4.3", "@types/node": "^22.10.0", - "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@types/three": "^0.183.1", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", "@resvg/resvg-js": "^2.6.2", "electron": "^33.2.0", diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml index 85c7dd5..bde21fe 100644 --- a/electron/pnpm-lock.yaml +++ b/electron/pnpm-lock.yaml @@ -11,9 +11,18 @@ importers: d3: specifier: ^7.9.0 version: 7.9.0 + drizzle-orm: + specifier: ^0.44.2 + version: 0.44.7(sql.js@1.14.1) echarts: specifier: ^6.0.0 version: 6.0.0 + framer-motion: + specifier: ^12.23.12 + version: 12.42.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lucide-react: + specifier: ^0.525.0 + version: 0.525.0(react@18.3.1) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -29,6 +38,12 @@ importers: three: specifier: ^0.183.2 version: 0.183.2 + uuid: + specifier: ^11.1.0 + version: 11.1.1 + youtubei.js: + specifier: ^17.2.0 + version: 17.2.0 devDependencies: '@resvg/resvg-js': specifier: ^2.6.2 @@ -48,9 +63,9 @@ importers: '@types/react-dom': specifier: ^18.3.1 version: 18.3.7(@types/react@18.3.28) - '@types/three': - specifier: ^0.183.1 - version: 0.183.1 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) @@ -167,13 +182,13 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} - '@dimforge/rapier3d-compat@0.12.0': - resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} - '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -599,9 +614,6 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} - '@tweenjs/tween.js@23.1.3': - resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -760,18 +772,12 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/stats.js@0.17.4': - resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} - - '@types/three@0.183.1': - resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/webxr@0.5.24': - resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} - '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -781,12 +787,10 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@webgpu/types@0.1.69': - resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} - '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -1284,6 +1288,98 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + drizzle-orm@0.44.7: + resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1419,6 +1515,20 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1732,6 +1842,11 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-react@0.525.0: + resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1747,8 +1862,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - meshoptimizer@1.0.1: - resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} @@ -1834,6 +1950,12 @@ packages: engines: {node: '>=10'} hasBin: true + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2234,6 +2356,9 @@ packages: tslib@2.3.0: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -2277,6 +2402,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -2381,6 +2510,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + youtubei.js@17.2.0: + resolution: {integrity: sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -2509,13 +2641,13 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bufbuild/protobuf@2.12.1': {} + '@develar/schema-utils@2.6.5': dependencies: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) - '@dimforge/rapier3d-compat@0.12.0': {} - '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -2848,8 +2980,6 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@tweenjs/tween.js@23.1.3': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -3050,23 +3180,11 @@ snapshots: dependencies: '@types/node': 22.19.15 - '@types/stats.js@0.17.4': {} - - '@types/three@0.183.1': - dependencies: - '@dimforge/rapier3d-compat': 0.12.0 - '@tweenjs/tween.js': 23.1.3 - '@types/stats.js': 0.17.4 - '@types/webxr': 0.5.24 - '@webgpu/types': 0.1.69 - fflate: 0.8.2 - meshoptimizer: 1.0.1 + '@types/uuid@10.0.0': {} '@types/verror@1.10.11': optional: true - '@types/webxr@0.5.24': {} - '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.15 @@ -3084,8 +3202,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@webgpu/types@0.1.69': {} - '@xmldom/xmldom@0.8.11': {} abbrev@1.1.1: {} @@ -3695,6 +3811,10 @@ snapshots: dotenv@16.6.1: {} + drizzle-orm@0.44.7(sql.js@1.14.1): + optionalDependencies: + sql.js: 1.14.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3887,6 +4007,15 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + framer-motion@12.42.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -4222,6 +4351,10 @@ snapshots: lru-cache@7.18.3: {} + lucide-react@0.525.0(react@18.3.1): + dependencies: + react: 18.3.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4255,7 +4388,7 @@ snapshots: math-intrinsics@1.1.0: {} - meshoptimizer@1.0.1: {} + meriyah@6.1.4: {} mime-db@1.52.0: {} @@ -4328,6 +4461,12 @@ snapshots: mkdirp@1.0.4: {} + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -4757,6 +4896,8 @@ snapshots: tslib@2.3.0: {} + tslib@2.8.1: {} + type-fest@0.13.1: optional: true @@ -4790,6 +4931,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -4888,6 +5031,12 @@ snapshots: yocto-queue@0.1.0: {} + youtubei.js@17.2.0: + dependencies: + '@bufbuild/protobuf': 2.12.1 + fflate: 0.8.2 + meriyah: 6.1.4 + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/electron/resources/easytier/NOTICE-EasyTier.txt b/electron/resources/easytier/NOTICE-EasyTier.txt deleted file mode 100644 index 4e87710..0000000 --- a/electron/resources/easytier/NOTICE-EasyTier.txt +++ /dev/null @@ -1,3 +0,0 @@ -EasyTier 以 LGPL-3.0 授权发布。 -源码: https://github.com/EasyTier/EasyTier -本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。 diff --git a/electron/src/main/appHost.ts b/electron/src/main/appHost.ts index 820e92d..b178e8c 100644 --- a/electron/src/main/appHost.ts +++ b/electron/src/main/appHost.ts @@ -6,7 +6,7 @@ import { app, BrowserWindow, ipcMain, shell } from 'electron' import fs from 'fs' import path from 'path' -const VALID_NAV = new Set(['desk', 'record', 'network', 'remote', 'system', 'wechat']) +const VALID_NAV = new Set(['record', 'camera', 'network', 'system']) export function parseNavFromArgv(argv: string[]): string | null { for (const a of argv) { diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index bf97d24..1b8a1e1 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -1,11 +1,8 @@ import { app, BrowserWindow, Menu, Tray, dialog, ipcMain, nativeImage, shell } from 'electron' -import { execFile, spawn, ChildProcessWithoutNullStreams } from 'child_process' -import { promisify } from 'util' +import { spawn, ChildProcessWithoutNullStreams } from 'child_process' import http from 'http' import fs from 'fs' import path from 'path' -import crypto from 'crypto' -import { networkInterfaces } from 'os' import { fileURLToPath } from 'url' import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar' @@ -31,12 +28,15 @@ import { } from './appHost' import { setMainBrowserWindow } from './mainWindowRef' import { ensureUserAgreementAccepted } from './userAgreementGate' +import { + attachYouLiveWindow, + initYouLiveServices, + shutdownYouLiveServices, +} from './youliveBootstrap' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const execFileAsync = promisify(execFile) - const PORT = Number(process.env.WEB2_PORT || 8001) /** uvicorn 监听地址;0.0.0.0 时建议配置 config/web2_api_token.txt 供远程访问 */ let web2Host = '127.0.0.1' @@ -131,91 +131,6 @@ function ensureTray(): void { ) } -/** - * 供扫码绑定 baseUrl:优先 Wi‑Fi/以太网等局域网 IPv4,排除常见虚拟网卡名。 - */ -function collectLanCandidateIPs(): { address: string; name: string }[] { - const nets = networkInterfaces() - const seen = new Set() - const preferred: { address: string; name: string }[] = [] - const other: { address: string; name: string }[] = [] - - const preferredRe = /(wlan|wi-?fi|wifi|wireless)/i - const excludedRe = /(easytier|wintun|tap|tun|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx|docker|vmware|virtualbox|vbox|venet|virtual ethernet|\bve\b)/i - - for (const [name, addrs] of Object.entries(nets)) { - if (!addrs) continue - if (excludedRe.test(name)) continue - for (const a of addrs) { - if (a.family !== 'IPv4' || a.internal) continue - if (a.address === '127.0.0.1') continue - if (seen.has(a.address)) continue - seen.add(a.address) - const item = { address: a.address, name: name || 'network' } - if (preferredRe.test(name)) preferred.push(item) - else other.push(item) - } - } - if (preferred.length) return preferred - return other -} - -async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> { - const br = _backendRoot ?? getBackendRoot() - await ensureOfflineRuntimeAssetsAsync(br) - const py = _pythonExe ?? resolvePythonExe(br) - web2Host = resolveWeb2Host() - await killPythonProcessForRestart() - startPythonBackend(br, py) - try { - await waitForHealth(90000) - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } - } - return { ok: true } -} - -/** - * 切换监听地址 / 轮换 API 密钥时必须等旧 uvicorn 完全退出并释放端口。 - * Windows 上若仅 spawn taskkill 不等待,旧进程仍占用 8001,新进程启动失败,但 /health 仍由旧进程响应, - * waitForHealth 误判成功,随后旧进程被杀死 → 前端 ERR_CONNECTION_REFUSED。 - */ -async function killPythonProcessForRestart(): Promise { - if (!pythonChild || pythonChild.killed) { - pythonChild = null - return - } - const child = pythonChild - const pid = child.pid - pythonChild = null - if (process.platform === 'win32') { - try { - await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { - windowsHide: true, - timeout: 25000, - maxBuffer: 1024 * 1024, - }) - } catch { - /* 进程已退出等 */ - } - await new Promise((r) => setTimeout(r, 500)) - } else { - try { - child.kill('SIGTERM') - } catch { - /* ignore */ - } - await new Promise((resolve) => { - const t = setTimeout(() => resolve(), 10000) - child.once('close', () => { - clearTimeout(t) - resolve() - }) - }) - await new Promise((r) => setTimeout(r, 200)) - } -} - function getBackendRoot(): string { if (app.isPackaged) { return path.join(process.resourcesPath, 'backend') @@ -237,7 +152,7 @@ function resolveWeb2Host(): string { } catch { /* ignore */ } - return '0.0.0.0' + return '127.0.0.1' } function resolveWeb2ApiTokenForChild(): string | undefined { @@ -473,9 +388,9 @@ async function createWindow(): Promise { mainWindow.once('ready-to-show', () => mainWindow?.show()) mainWindow.webContents.once('did-finish-load', () => { appendClientLog('info', 'main', '渲染界面已加载') - if (appUiReadyFlag) { - mainWindow?.webContents.send('app:ready') - } + attachYouLiveWindow(mainWindow) + appUiReadyFlag = true + mainWindow?.webContents.send('app:ready') if (pendingNav && mainWindow && !mainWindow.isDestroyed()) { sendNavToWindow(mainWindow, pendingNav) pendingNav = null @@ -688,80 +603,12 @@ if (!gotLock) { ipcMain.removeHandler('web2:get-api-base') ipcMain.handle('web2:get-api-base', () => `http://${healthCheckHost()}:${PORT}`) - ipcMain.removeHandler('remote:get-status') - ipcMain.handle('remote:get-status', () => { - const bindHost = resolveWeb2Host() - return { - bindHost, - remoteEnabled: bindHost === '0.0.0.0', - port: PORT, - hasToken: !!resolveWeb2ApiTokenForChild(), - candidates: collectLanCandidateIPs(), - envHostOverride: !!process.env.WEB2_HOST?.trim(), - } - }) - - ipcMain.removeHandler('remote:get-bind-payload') - ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => { - if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null - const token = resolveWeb2ApiTokenForChild() ?? '' - const ip = selectedIp.trim() - const candidates = collectLanCandidateIPs() - if (!candidates.some((c) => c.address === ip)) return null - return { - v: 1 as const, - baseUrl: `http://${ip}:${PORT}`, - port: PORT, - token, - } - }) - - ipcMain.removeHandler('remote:set-enabled') - ipcMain.handle('remote:set-enabled', async (_evt, enabled: unknown) => { - if (process.env.WEB2_HOST?.trim()) { - return { - ok: false, - error: '已设置环境变量 WEB2_HOST,请改用环境变量或删除后再用界面开关。', - } - } - if (typeof enabled !== 'boolean') { - return { ok: false, error: '参数无效' } - } - if (enabled === false) { - return { ok: false, error: '本应用固定开启局域网 / 虚拟网访问,无法关闭。' } - } - const br = _backendRoot ?? getBackendRoot() - const cfgDir = path.join(br, 'config') - fs.mkdirSync(cfgDir, { recursive: true }) - fs.writeFileSync(path.join(cfgDir, 'web2_bind_host.txt'), enabled ? '0.0.0.0' : '127.0.0.1', 'utf-8') - const r = await restartWeb2Backend() - return r - }) - - ipcMain.removeHandler('remote:generate-token') - ipcMain.handle('remote:generate-token', async () => { - const br = _backendRoot ?? getBackendRoot() - const cfgDir = path.join(br, 'config') - fs.mkdirSync(cfgDir, { recursive: true }) - const tok = crypto.randomBytes(24).toString('hex') - fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8') - const r = await restartWeb2Backend() - return r - }) - ipcMain.removeHandler('app:is-ready') ipcMain.handle('app:is-ready', () => appUiReadyFlag) web2Host = resolveWeb2Host() const backendRoot = getBackendRoot() - - /** 与 createWindow 并行:解压运行时与加载界面同时进行,缩短到「可交互」的总耗时 */ - const runtimePrep = ensureOfflineRuntimeAssetsAsync(backendRoot) - await Promise.all([createWindow(), runtimePrep]) - - appendClientLog('info', 'main', '主窗口与离线运行时已就绪,正在启动本地服务') - const pythonExe = resolvePythonExe(backendRoot) _backendRoot = backendRoot _pythonExe = pythonExe @@ -774,18 +621,26 @@ if (!gotLock) { app.quit() return } - startPythonBackend(backendRoot, pythonExe) - appendClientLog('info', 'main', '后台并行启动本地服务与用户协议') + /** 与 createWindow 并行:解压运行时、拉起本地服务与加载界面同时进行 */ + const runtimePrep = ensureOfflineRuntimeAssetsAsync(backendRoot) + void runtimePrep.then(() => { + startPythonBackend(backendRoot, pythonExe) + appendClientLog('info', 'main', '本地服务已启动') + }) + await Promise.all([createWindow(), runtimePrep]) + + void initYouLiveServices(mainWindow).catch((e) => { + console.error('[youlive]', e) + appendClientLog('warn', 'main', `youLIVE 模块启动失败: ${e instanceof Error ? e.message : String(e)}`) + }) + + appendClientLog('info', 'main', '主窗口已显示,后台服务并行就绪中') void (async () => { try { await Promise.all([waitForHealth(), ensureUserAgreementAccepted(resolvePreload)]) - appUiReadyFlag = true - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('app:ready') - } - appendClientLog('info', 'main', '本地服务已就绪,界面可交互') + appendClientLog('info', 'main', '本地服务与健康检查已通过') } catch (e) { const msg = e instanceof Error ? e.message : String(e) if (msg.includes('user refused') || msg.includes('agreement window closed')) { @@ -809,6 +664,7 @@ if (!gotLock) { app.on('before-quit', () => { isQuitting = true appendClientLog('info', 'main', '应用退出') + shutdownYouLiveServices() flushClientDb() killPythonTree() }) diff --git a/electron/src/main/youliveBootstrap.ts b/electron/src/main/youliveBootstrap.ts new file mode 100644 index 0000000..28f10b3 --- /dev/null +++ b/electron/src/main/youliveBootstrap.ts @@ -0,0 +1,64 @@ +import type { BrowserWindow } from 'electron' + +import { initDatabase, closeDatabase } from '../youlive/main/db' +import { MonitorManager } from '../youlive/main/services/monitor-manager' +import { RecordManager } from '../youlive/main/services/record-manager' +import { RelayManager } from '../youlive/main/services/relay-manager' +import { AccountManager } from '../youlive/main/services/account-manager' +import { registerIpcHandlers } from '../youlive/main/ipc/handlers' +import { getSettings } from '../youlive/main/services/store' +import { applyLaunchAtLogin } from '../youlive/main/services/stats-service' +import { disposePageScraper } from '../youlive/main/services/youtube/page-scraper' +import { partnerService } from '../youlive/main/services/partner-service' +import { preloadDeviceOverview } from '../youlive/main/services/device-detector/cache' +import type { MonitorEvent } from '../youlive/shared/types' + +let booted = false +const recorder = new RecordManager() +const relay = new RelayManager() +const monitor = new MonitorManager((_event: MonitorEvent) => {}) +const accounts = new AccountManager() + +monitor.setRecorder(recorder) +monitor.setRelay(relay) + +export async function initYouLiveServices(mainWindow: BrowserWindow | null): Promise { + if (booted) { + if (mainWindow && !mainWindow.isDestroyed()) { + monitor.setMainWindow(mainWindow) + accounts.setMainWindow(mainWindow) + } + return + } + + await initDatabase() + partnerService.init() + applyLaunchAtLogin(getSettings().launchAtLogin) + registerIpcHandlers(monitor, recorder, relay, accounts) + preloadDeviceOverview() + + if (mainWindow && !mainWindow.isDestroyed()) { + monitor.setMainWindow(mainWindow) + accounts.setMainWindow(mainWindow) + } + + if (getSettings().autoStartMonitor) { + await monitor.start() + } + + booted = true +} + +export function attachYouLiveWindow(win: BrowserWindow | null): void { + if (!booted || !win || win.isDestroyed()) return + monitor.setMainWindow(win) + accounts.setMainWindow(win) +} + +export function shutdownYouLiveServices(): void { + if (!booted) return + monitor.stop() + disposePageScraper() + closeDatabase() + booted = false +} diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index 86191d7..6ac09e9 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -1,5 +1,9 @@ import { contextBridge, ipcRenderer } from 'electron' +import { youliveApi } from './youliveApi' + +contextBridge.exposeInMainWorld('youlive', youliveApi) + contextBridge.exposeInMainWorld('recorderConsole', { platform: process.platform, /** 沙箱下 preload 无可靠 shell,经主进程打开系统浏览器 */ @@ -11,13 +15,6 @@ contextBridge.exposeInMainWorld('web2Remote', { getApiToken: (): Promise => ipcRenderer.invoke('web2:get-api-token'), }) -contextBridge.exposeInMainWorld('remoteBinding', { - getStatus: () => ipcRenderer.invoke('remote:get-status'), - setRemoteEnabled: (enabled: boolean) => ipcRenderer.invoke('remote:set-enabled', enabled), - generateToken: () => ipcRenderer.invoke('remote:generate-token'), - getBindPayload: (selectedIp: string) => ipcRenderer.invoke('remote:get-bind-payload', selectedIp), -}) - contextBridge.exposeInMainWorld('workspace', { logLiveEvent: (payload: unknown) => ipcRenderer.invoke('workspace:log-live', payload), logUserAction: (payload: unknown) => ipcRenderer.invoke('workspace:log-user', payload), diff --git a/electron/src/preload/youliveApi.ts b/electron/src/preload/youliveApi.ts new file mode 100644 index 0000000..2336224 --- /dev/null +++ b/electron/src/preload/youliveApi.ts @@ -0,0 +1,260 @@ +import { ipcRenderer } from 'electron' +import { IPC } from '../youlive/shared/types' +import type { + AppSettings, + AppStats, + ExportFormat, + LiveSession, + MonitorEvent, + MonitorLog, + RecordStatus, + RelayStatus, + StreamInfo, + WatchEntry, + YoutubeIdentifier, + ChatMessage, + ChatAlertRecord, + LiveScreenshot +} from '../youlive/shared/types' +import type { LiveEnrichment } from '../youlive/shared/enrichment-types' +import type { + PartnerCatalog, + PartnerOffer, + OfferTrigger, + VpsDeployScript +} from '../youlive/shared/partner-types' +import type { GoogleAccount, ManagedChannel, AccountSyncResult, GoogleAccountStatus } from '../youlive/shared/account-types' +import type { DeviceOverview } from '../youlive/shared/device-types' + +export interface YouliveApi { + watch: { + list: () => Promise + parse: (input: string) => Promise + add: (input: string, label?: string) => Promise + remove: (watchId: string) => Promise + update: ( + watchId: string, + patch: Partial< + Pick + > + ) => Promise + check: (watchId: string) => Promise + } + monitor: { + start: () => Promise<{ running: boolean }> + stop: () => Promise<{ running: boolean }> + status: () => Promise<{ running: boolean }> + onEvent: (cb: (event: MonitorEvent) => void) => () => void + } + sessions: { + get: () => Promise + history: (limit?: number) => Promise + } + chat: { + history: (videoId: string) => Promise + subscribe: (videoId: string) => Promise + unsubscribe: (videoId: string) => Promise + alerts: { + get: (videoId?: string, limit?: number) => Promise + clear: (videoId?: string) => Promise + } + } + record: { + start: (videoId: string) => Promise + stop: (videoId: string) => Promise + status: (videoId?: string) => Promise + streamlinkAvailable: () => Promise + } + relay: { + start: (videoId: string, rtmpUrl?: string) => Promise + stop: (videoId: string) => Promise + status: (videoId?: string) => Promise + ffmpegAvailable: () => Promise + } + stats: { + get: () => Promise + } + stream: { + info: (videoId: string, accountId?: string) => Promise + refresh: (videoId: string, accountId?: string) => Promise + } + enrich: { + get: (videoId: string, accountId?: string) => Promise + refresh: (videoId: string, accountId?: string) => Promise + } + webhook: { + test: () => Promise<{ ok: boolean; message: string }> + } + export: { + chat: (videoId: string, format: ExportFormat) => Promise<{ success: boolean; count: number }> + logs: (format: ExportFormat) => Promise<{ success: boolean; count: number }> + } + settings: { + get: () => Promise + set: (patch: Partial) => Promise + } + logs: { + get: () => Promise + } + dialog: { + selectDir: () => Promise + } + openExternal: (url: string) => void + partner: { + getCatalog: () => Promise + getOffers: (triggers: OfferTrigger[], limit?: number) => Promise + getToolbox: () => Promise + dismiss: (offerId: string) => Promise + trackClick: (offerId: string) => Promise + getVpsScript: () => Promise + refreshCatalog: () => Promise + openTutorial: (tutorialId: string) => Promise<{ ok: boolean; url: string }> + getVersion: () => Promise + } + account: { + list: () => Promise + active: () => Promise + setActive: (accountId: string | null) => Promise + login: (label?: string) => Promise + remove: (accountId: string) => Promise + setDefault: (accountId: string) => Promise + sync: (accountId: string) => Promise + channels: (accountId: string) => Promise + watchAll: (accountId: string) => Promise<{ added: number; skipped: number }> + watchChannel: (accountId: string, channelId: string) => Promise + relogin: (accountId: string) => Promise + check: (accountId: string) => Promise + } + device: { + get: () => Promise + onUpdated: (cb: (overview: DeviceOverview) => void) => () => void + } + screenshot: { + get: (videoId: string) => Promise + capture: (videoId: string, accountId?: string) => Promise + } + syncRelayWatches: ( + items: Array<{ relayId: string; label: string; input: string }>, + ) => Promise<{ synced: number; skipped: number }> + notifyDesktop: (title: string, body: string) => Promise +} + +const api: YouliveApi = { + watch: { + list: () => ipcRenderer.invoke(IPC.WATCH_LIST), + parse: (input) => ipcRenderer.invoke(IPC.WATCH_PARSE, input), + add: (input, label) => ipcRenderer.invoke(IPC.WATCH_ADD, input, label), + remove: (watchId) => ipcRenderer.invoke(IPC.WATCH_REMOVE, watchId), + update: (watchId, patch) => ipcRenderer.invoke(IPC.WATCH_UPDATE, watchId, patch), + check: (watchId) => ipcRenderer.invoke(IPC.WATCH_CHECK, watchId) + }, + monitor: { + start: () => ipcRenderer.invoke(IPC.MONITOR_START), + stop: () => ipcRenderer.invoke(IPC.MONITOR_STOP), + status: () => ipcRenderer.invoke(IPC.MONITOR_STATUS), + onEvent: (cb) => { + const handler = (_: Electron.IpcRendererEvent, event: MonitorEvent) => cb(event) + ipcRenderer.on(IPC.EVENT, handler) + return () => ipcRenderer.removeListener(IPC.EVENT, handler) + } + }, + sessions: { + get: () => ipcRenderer.invoke(IPC.SESSIONS_GET), + history: (limit) => ipcRenderer.invoke(IPC.SESSION_HISTORY, limit) + }, + chat: { + history: (videoId) => ipcRenderer.invoke(IPC.CHAT_HISTORY, videoId), + subscribe: (videoId) => ipcRenderer.invoke(IPC.CHAT_SUBSCRIBE, videoId), + unsubscribe: (videoId) => ipcRenderer.invoke(IPC.CHAT_UNSUBSCRIBE, videoId), + alerts: { + get: (videoId, limit) => ipcRenderer.invoke(IPC.CHAT_ALERTS_GET, videoId, limit), + clear: (videoId) => ipcRenderer.invoke(IPC.CHAT_ALERTS_CLEAR, videoId) + } + }, + record: { + start: (videoId) => ipcRenderer.invoke(IPC.RECORD_START, videoId), + stop: (videoId) => ipcRenderer.invoke(IPC.RECORD_STOP, videoId), + status: (videoId) => ipcRenderer.invoke(IPC.RECORD_STATUS, videoId), + streamlinkAvailable: () => ipcRenderer.invoke('record:streamlink-available') + }, + relay: { + start: (videoId, rtmpUrl) => ipcRenderer.invoke(IPC.RELAY_START, videoId, rtmpUrl), + stop: (videoId) => ipcRenderer.invoke(IPC.RELAY_STOP, videoId), + status: (videoId) => ipcRenderer.invoke(IPC.RELAY_STATUS, videoId), + ffmpegAvailable: () => ipcRenderer.invoke('relay:ffmpeg-available') + }, + stats: { + get: () => ipcRenderer.invoke(IPC.STATS_GET) + }, + stream: { + info: (videoId, accountId) => ipcRenderer.invoke(IPC.STREAM_INFO, videoId, accountId), + refresh: (videoId, accountId) => ipcRenderer.invoke('stream:refresh', videoId, accountId) + }, + enrich: { + get: (videoId, accountId) => ipcRenderer.invoke(IPC.ENRICH_GET, videoId, accountId), + refresh: (videoId, accountId) => + ipcRenderer.invoke(IPC.ENRICH_REFRESH, videoId, accountId) + }, + webhook: { + test: () => ipcRenderer.invoke(IPC.WEBHOOK_TEST) + }, + export: { + chat: (videoId, format) => ipcRenderer.invoke(IPC.EXPORT_CHAT, videoId, format), + logs: (format) => ipcRenderer.invoke(IPC.EXPORT_LOGS, format) + }, + settings: { + get: () => ipcRenderer.invoke(IPC.SETTINGS_GET), + set: (patch) => ipcRenderer.invoke(IPC.SETTINGS_SET, patch) + }, + logs: { + get: () => ipcRenderer.invoke(IPC.LOGS_GET) + }, + dialog: { + selectDir: () => ipcRenderer.invoke(IPC.DIALOG_SELECT_DIR) + }, + openExternal: (url) => { + void ipcRenderer.invoke('shell:open', url) + }, + partner: { + getCatalog: () => ipcRenderer.invoke(IPC.PARTNER_CATALOG), + getOffers: (triggers, limit) => ipcRenderer.invoke(IPC.PARTNER_OFFERS, triggers, limit), + getToolbox: () => ipcRenderer.invoke('partner:toolbox'), + dismiss: (offerId) => ipcRenderer.invoke(IPC.PARTNER_DISMISS, offerId), + trackClick: (offerId) => ipcRenderer.invoke(IPC.PARTNER_TRACK, offerId), + getVpsScript: () => ipcRenderer.invoke(IPC.PARTNER_VPS_SCRIPT), + refreshCatalog: () => ipcRenderer.invoke(IPC.PARTNER_REFRESH), + openTutorial: (tutorialId) => ipcRenderer.invoke(IPC.PARTNER_OPEN_TUTORIAL, tutorialId), + getVersion: () => ipcRenderer.invoke(IPC.PARTNER_VERSION) + }, + account: { + list: () => ipcRenderer.invoke(IPC.ACCOUNT_LIST), + active: () => ipcRenderer.invoke(IPC.ACCOUNT_ACTIVE), + setActive: (accountId) => ipcRenderer.invoke('account:set-active', accountId), + login: (label) => ipcRenderer.invoke(IPC.ACCOUNT_LOGIN, label), + remove: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_REMOVE, accountId), + setDefault: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_SET_DEFAULT, accountId), + sync: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_SYNC, accountId), + channels: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_CHANNELS, accountId), + watchAll: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_WATCH_ALL, accountId), + watchChannel: (accountId, channelId) => + ipcRenderer.invoke(IPC.ACCOUNT_WATCH_CHANNEL, accountId, channelId), + relogin: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_RELOGIN, accountId), + check: (accountId) => ipcRenderer.invoke(IPC.ACCOUNT_CHECK, accountId) + }, + device: { + get: () => ipcRenderer.invoke(IPC.DEVICE_GET), + onUpdated: (cb) => { + const handler = (_: Electron.IpcRendererEvent, overview: DeviceOverview) => cb(overview) + ipcRenderer.on(IPC.DEVICE_UPDATED, handler) + return () => ipcRenderer.removeListener(IPC.DEVICE_UPDATED, handler) + } + }, + screenshot: { + get: (videoId) => ipcRenderer.invoke(IPC.SCREENSHOT_GET, videoId), + capture: (videoId, accountId) => ipcRenderer.invoke(IPC.SCREENSHOT_CAPTURE, videoId, accountId) + }, + syncRelayWatches: (items) => ipcRenderer.invoke('youlive:sync-relay-watches', items), + notifyDesktop: (title, body) => ipcRenderer.invoke(IPC.NOTIFY_DESKTOP, title, body) +} + +export const youliveApi = api \ No newline at end of file diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 44b5f91..f9a4818 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1197,6 +1197,10 @@ body.ready { max-width: min(100%, 520px); } +.live-command__quick-actions .btn { + white-space: nowrap; +} + .live-command__stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -1274,6 +1278,106 @@ body.ready { background: linear-gradient(145deg, #f04438, #9f1f19); } +.live-pipeline__step--idle { + opacity: 0.45; + filter: grayscale(0.35); +} + +.live-pipeline__step--pending { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35), 0 0 18px rgba(255, 255, 255, 0.12); +} + +.live-pipeline__step--warn { + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.75); +} + +.live-pipeline__step--error { + box-shadow: 0 0 0 2px rgba(255, 82, 82, 0.85); + animation: live-pipeline-pulse 1.4s ease-in-out infinite; +} + +.live-pipeline__flow.is-active .live-pipeline__flow-line, +.live-pipeline__flow.is-active .live-pipeline__flow-head { + stroke: var(--success); + opacity: 1; +} + +@keyframes live-pipeline-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.72; + } +} + +.live-backend-pending { + margin: 0.65rem 0 0; + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.84rem; + color: var(--text-muted); + border: 1px dashed var(--border-subtle); + background: rgba(255, 255, 255, 0.03); +} + +.relay-validation-banner { + margin-bottom: 0.75rem; + padding: 0.65rem 0.85rem; + border-radius: 10px; + border: 1px solid rgba(245, 101, 101, 0.45); + background: rgba(245, 101, 101, 0.08); + font-size: 0.84rem; +} + +.relay-validation-banner__errors ul, +.relay-validation-banner__notes { + margin: 0.35rem 0 0; + padding-left: 1.1rem; +} + +.relay-validation-banner__errors p { + margin: 0.35rem 0 0; + color: var(--text-muted); +} + +.relay-validation-banner__notes { + color: var(--text-muted); + list-style: disc; +} + +.relay-log-card__filters { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.5rem 0 0.75rem; + align-items: center; +} + +.relay-log-card__search { + flex: 1 1 12rem; + min-width: 10rem; +} + +.relay-log-card__status { + min-width: 7rem; +} + +.relay-log-card__pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.65rem; +} + +.relay-log-card__pager-btns { + display: flex; + gap: 0.35rem; +} + .live-pipeline__arrow { color: var(--text-muted); font-size: 28px; @@ -2983,57 +3087,6 @@ button.net-badge { color: #b8c0cc; } -.card--remote { - border-color: rgba(100, 149, 237, 0.28); - background: linear-gradient(165deg, rgba(100, 149, 237, 0.07) 0%, var(--bg-card) 100%); -} - -.remote-link { - color: var(--accent, #6d9eff); - text-decoration: underline; - text-underline-offset: 2px; -} - -.remote-code { - font-family: ui-monospace, monospace; - font-size: 12px; - color: #b8c0cc; -} - -.remote-et-status { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 0.75rem; - margin-bottom: 0.75rem; -} - -.remote-et-pill { - font-size: 12px; - padding: 0.2rem 0.55rem; - border-radius: 999px; -} - -.remote-et-pill.on { - background: rgba(61, 214, 140, 0.15); - color: #9ee6c0; -} - -.remote-et-pill.off { - background: rgba(139, 146, 158, 0.15); - color: #8b929e; -} - -.remote-et-pill.warn { - background: rgba(245, 166, 35, 0.15); - color: #e8c07d; -} - -.remote-et-meta { - font-size: 12px; - color: var(--text-muted, #8b929e); -} - .remote-warn { margin: 0 0 0.75rem; padding: 0.6rem 0.75rem; @@ -3044,85 +3097,6 @@ button.net-badge { font-size: 13px; } -.remote-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem 1rem; - margin-bottom: 0.75rem; -} - -.wc-proxy-param { - width: 7rem; - max-width: 100%; -} - -.remote-toggle-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - user-select: none; -} - -.remote-toggle-input { - width: 1.1rem; - height: 1.1rem; - accent-color: var(--accent, #6d9eff); -} - -.remote-token-badge { - font-size: 13px; - padding: 0.2rem 0.6rem; - border-radius: 999px; -} - -.remote-token-badge.on { - background: rgba(61, 214, 140, 0.15); - color: #9ee6c0; -} - -.remote-token-badge.off { - background: rgba(245, 101, 101, 0.12); - color: #f0a0a0; -} - -.remote-qr-block { - margin-top: 0.5rem; - padding: 1rem; - border-radius: 10px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.06); - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; -} - -.remote-qr-hint { - margin: 0; - font-size: 13px; - color: var(--text-muted, #8b929e); -} - -.remote-qr-img { - border-radius: 8px; - background: #fff; - padding: 8px; -} - -.remote-qr-json { - margin: 0; - padding: 0.75rem; - font-size: 12px; - line-height: 1.45; - max-width: 100%; - overflow: auto; - border-radius: 8px; - background: rgba(0, 0, 0, 0.35); - color: #c8cdd5; -} - .remote-error { margin: 0.5rem 0 0; color: #f56565; @@ -3850,6 +3824,221 @@ button.net-badge { margin: 0 0 0.35rem; } +.quality-guard { + margin: 0.75rem 0 0; + padding: 0.65rem 0.85rem; + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: var(--surface-elevated, rgba(255, 255, 255, 0.03)); +} + +.quality-guard--ok { + border-color: rgba(72, 187, 120, 0.35); +} + +.quality-guard--warn { + border-color: rgba(237, 137, 54, 0.4); +} + +.quality-guard--bad { + border-color: rgba(245, 101, 101, 0.45); +} + +.quality-guard__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 0.75rem; +} + +.quality-guard__title { + font-size: 0.82rem; + color: var(--text-muted); +} + +.quality-guard__score { + font-size: 1.05rem; +} + +.quality-guard__meta { + font-size: 0.78rem; + color: var(--text-muted); +} + +.quality-guard__issues { + margin: 0.45rem 0 0; + padding-left: 1.1rem; + font-size: 0.82rem; +} + +.quality-guard__issue--critical { + color: #f56565; +} + +.quality-guard__issue--warning { + color: #ed8936; +} + +.quality-guard__ok, +.quality-guard__err { + margin: 0.35rem 0 0; + font-size: 0.82rem; +} + +.quality-guard__layers { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.5rem; +} + +.quality-guard__layer { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 4.5rem; + padding: 0.35rem 0.55rem; + border-radius: 8px; + border: 1px solid var(--border-subtle); + font-size: 0.78rem; +} + +.quality-guard__layer.is-ok { + border-color: rgba(72, 187, 120, 0.45); + background: rgba(72, 187, 120, 0.08); +} + +.quality-guard__layer.is-bad { + border-color: rgba(237, 137, 54, 0.45); + background: rgba(237, 137, 54, 0.08); +} + +.quality-guard__layer-name { + color: var(--text-muted); +} + +.quality-guard__layer-state { + font-weight: 600; +} + +.camera-device-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +@media (max-width: 720px) { + .camera-device-grid { + grid-template-columns: 1fr; + } +} + +.camera-device-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.84rem; +} + +.camera-device-field span { + color: var(--text-muted); +} + +.camera-device-field--full { + margin-top: 0.75rem; +} + +.app-body--camera .app-main { + max-width: 920px; +} + +.live-yt-monitor__warn { + margin: 0.5rem 0 0; + padding: 0.45rem 0.65rem; + border-radius: 8px; + font-size: 0.82rem; + color: #ed8936; + background: rgba(237, 137, 54, 0.1); + border: 1px solid rgba(237, 137, 54, 0.35); +} + +.relay-log-card__head { + display: flex; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.relay-log-card__actions { + display: flex; + gap: 0.4rem; +} + +.relay-log-list { + list-style: none; + margin: 0; + padding: 0; +} + +.relay-log-list__row { + display: flex; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0; + border-top: 1px solid var(--border-subtle); +} + +.relay-log-list__sub { + display: block; + font-size: 0.78rem; + color: var(--text-muted); +} + +.relay-log-list__meta { + text-align: right; + font-size: 0.82rem; + white-space: nowrap; +} + +.relay-log-list__status { + display: block; + font-size: 0.75rem; + color: var(--text-muted); +} + +.config-backups-inline { + margin-top: 0.45rem; +} + +.config-backups-inline__panel { + margin-top: 0.35rem; + padding: 0.45rem 0.55rem; + border: 1px dashed var(--border-subtle); + border-radius: 8px; +} + +.config-backups-inline__list { + list-style: none; + margin: 0; + padding: 0; + font-size: 0.82rem; +} + +.config-backups-inline__list li { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.65rem; + align-items: center; + padding: 0.25rem 0; +} + +.config-backups-inline__reason { + color: var(--text-muted); + font-size: 0.75rem; +} + .user-agreement-dialog__li { margin: 0 0 0.35rem; padding-left: 0.25rem; @@ -3858,3 +4047,127 @@ button.net-badge { .user-agreement-dialog__gap { height: 0.35rem; } + +/* YouTube 目标端监控(youLIVE 能力融入直播页) */ +.live-yt-monitor { + border-color: rgba(124, 92, 255, 0.22); + background: linear-gradient(165deg, rgba(124, 92, 255, 0.06) 0%, var(--bg-card) 100%); +} + +.live-yt-monitor__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.live-yt-monitor__pill { + padding: 6px 12px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 650; + border: 1px solid var(--border-card); + color: var(--text-muted); + background: var(--bg-card-muted); +} + +.live-yt-monitor__pill.is-on { + color: var(--success); + border-color: rgba(61, 214, 140, 0.35); + background: var(--success-soft); +} + +.live-yt-monitor__stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.live-yt-monitor__stat { + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-card-muted); +} + +.live-yt-monitor__stat span { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.live-yt-monitor__stat strong { + font-size: 16px; + font-weight: 750; +} + +.live-yt-monitor__session { + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.03); +} + +.live-yt-monitor__session-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.live-yt-monitor__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); +} + +.live-yt-monitor__dot--live { + background: var(--success); + box-shadow: 0 0 0 4px var(--success-soft); +} + +.live-yt-monitor__session-meta { + font-size: 12px; + color: var(--text-muted); +} + +.live-yt-monitor__chat { + list-style: none; + margin: 0; + padding: 0; + max-height: 160px; + overflow: auto; + font-size: 12px; +} + +.live-yt-monitor__chat li { + padding: 4px 0; + border-top: 1px dashed var(--border-subtle); +} + +.live-yt-monitor__chat-author { + font-weight: 650; + margin-right: 6px; + color: var(--accent-2); +} + +.live-yt-monitor__chat-empty { + color: var(--text-muted); + border: none; +} + +.live-yt-monitor__sync { + margin-bottom: 8px; +} + +@media (max-width: 720px) { + .live-yt-monitor__stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 71ccc5f..6596170 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { apiFetch, + apiFetchJson, apiUrl, + checkApiHealth, clearPbMemberSession, getPbAuthToken, getPbUserSnapshot, @@ -9,14 +11,21 @@ import { setPbUserSnapshot, type PbUserSnapshot, } from './api' -import PbAuthModal from './PbAuthModal' +import CameraLivePage from './CameraLivePage' +import SidebarBrand from './components/SidebarBrand' +import QualityGuardBanner from './components/QualityGuardBanner' +import RelayLogPanel from './components/RelayLogPanel' +import RelayValidationBanner from './components/RelayValidationBanner' +import ConfigBackupsInline from './components/ConfigBackupsInline' +import LivePipeline from './components/LivePipeline' +import { NavIcon } from './components/icons/NavIcons' +import LivePulse from './components/ui/LivePulse' +import { getPbAuthGateEnabled, PB_AUTH_GATE_EVENT } from './pbAuthGate' import { pbAuthRefresh, type PbUserRecord } from './pbAuthClient' import NetworkDashboard from './NetworkDashboard' -import RemoteBinding from './RemoteBinding' import ProxySettings from './ProxySettings' import SystemMetrics from './SystemMetrics' -import WeChatChatbot from './WeChatChatbot' -import WorkspaceDashboard from './WorkspaceDashboard' +import LiveYoutubeMonitor from './components/LiveYoutubeMonitor' import { logLiveEvent, logUserAction } from './workspace' import { STORAGE_MULTI_CHANNEL_PRO, @@ -29,76 +38,37 @@ import { parseYoutubeStreamKeySuffix, } from './youtubeConfigUtils' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' +import { classifyRowActivity, relayLiveStatusPollMs, statusPollMs } from './adaptivePoll' +import { useAdaptiveInterval, usePageVisible } from './hooks/useAdaptiveInterval' +import { + businessStatusLabel, + formatDurationCn, + formatFfmpegSpeed, + formatRuntimeSeconds, + statusPresentation, + type StatusVariant, +} from './liveStatusLabels' +import type { GpuCaps, LiveDetailState, ControlStatusPayload } from './types/live' +import { liveDetailFromStatus } from './types/live' +import { + countConfigUrlLines, + douyinUrlListDisplayFromServer, + formatControlOutput, + mergeYoutubeOptionsForUi, + proChannelPm2Process, + validateStreamKeysUnique, + youtubeIniActiveKey, + loadYoutubeWatchUrl, + saveYoutubeWatchUrl, + type ProChannelEditor, + type YoutubeIniModel, +} from './youtubeLiveUtils' +import { readInitialTheme, type AppTheme } from './components/SidebarBrand' const VIP_URL = 'https://vip.hackrobot.cn/' type Entry = { script: string; label: string; pm2: string } -type YoutubeIniModel = { - keys: string[] - activeIndex: number - options: Record - header: string -} - -function youtubeIniActiveKey(m: YoutubeIniModel): string { - if (!m.keys?.length) return '' - const i = Math.max(0, Math.min(m.activeIndex, m.keys.length - 1)) - return (m.keys[i] || '').trim() -} - -type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean } - -type ProChannelEditor = { keys: string[]; activeIndex: number; urlText: string } - -function proChannelPm2Process(channelId: string): string { - return `youtube2__${channelId}` -} - -/** 与 web2_relay_pro._norm_stream_key 一致,用于保存前去重校验 */ -function normStreamKeyForDedup(k: string): string { - let s = (k || '').trim() - if (!s) return '' - const low = s.toLowerCase() - if (low.startsWith('rtmp://') || low.startsWith('rtmps://')) { - const parts = s.replace(/\/+$/, '').split('/') - s = parts[parts.length - 1] || s - } - return s.trim().toLowerCase() -} - -function validateStreamKeysUnique(keys: string[]): string | null { - const seen = new Set() - for (const k of keys) { - const t = k.trim() - if (!t) continue - const n = normStreamKeyForDedup(t) - if (!n) continue - if (seen.has(n)) return '同一线路内不能重复同一串流密钥' - seen.add(n) - } - return null -} - -/** 仅含空行与 # 注释时视为空,避免默认模板占满输入框 */ -function douyinUrlListDisplayFromServer(content: string): string { - const lines = String(content ?? '').split(/\r?\n/) - const hasReal = lines.some((line) => { - const t = line.trim() - if (!t) return false - if (t.startsWith('#')) return false - return true - }) - return hasReal ? String(content) : '' -} - -function countConfigUrlLines(content: string): number { - return String(content ?? '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')).length -} - type DouyinPreviewRow = { ok?: boolean url?: string @@ -107,109 +77,16 @@ type DouyinPreviewRow = { error?: string } -function mergeYoutubeOptionsForUi(opts: Record, caps: GpuCaps | null): Record { - const o = { ...opts } - const nv = caps?.nvenc_available ?? false - const capsKnown = caps !== null - const res = (o.output_resolution || '').toLowerCase() - o.output_resolution = res.includes('1080') ? '1080p' : '720p' - - const ug = (o.use_gpu || '').trim() - if (!ug) { - o.use_gpu = capsKnown ? (nv ? '是' : '无') : '是' - } else if (['是', '否', '无'].includes(ug)) { - if (capsKnown && ug === '是' && !nv) o.use_gpu = '无' - } else { - o.use_gpu = capsKnown ? (nv ? '否' : '无') : '否' - } - - const ad = (o.audio_denoise || '').trim().toLowerCase() - if (!ad) o.audio_denoise = '否' - else if (['是', '开', '1', 'true', 'on', 'yes'].includes(ad)) o.audio_denoise = '是' - else o.audio_denoise = '否' - - return o +function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { room: string; href: string } { + const combined = `${urlIni}\n${douyinUrl ?? ''}` + const urls = extractDouyinLiveUrls(combined) + const hints = extractDouyinRoomHints(combined) + const room = hints[0] || '—' + const href = urls[0] || (hints[0] ? `https://live.douyin.com/${hints[0]}` : '') + return { room, href } } -function formatDurationCn(totalSec: number): string { - if (totalSec < 60) return `${totalSec} 秒` - const m = Math.floor(totalSec / 60) - const s = totalSec % 60 - if (m < 60) return `${m} 分 ${s} 秒` - const h = Math.floor(m / 60) - const mm = m % 60 - return `${h} 小时 ${mm} 分` -} - -function formatRuntimeSeconds(sec: number | null | undefined): string { - if (typeof sec !== 'number' || !Number.isFinite(sec)) return '—' - return formatDurationCn(Math.max(0, Math.floor(sec))) -} - -function businessStatusLabel(raw?: string, note?: string): string { - const s = (raw || '').trim().toLowerCase() - if (s === 'streaming') return '正在推流到 YouTube' - if (s === 'waiting_source') return '等待源站开播' - if (s === 'source_live') return '源站已开播,等待 FFmpeg 推流' - if (s === 'monitoring') return '进程在线,正在监测源站' - if (s === 'misconfigured') return '配置异常' - if (s === 'stale') return '日志心跳过期' - if (s === 'error') return '近期有异常' - if (s === 'stopped') return '未运行' - return note?.trim() || '暂无业务态' -} - -type NavId = 'desk' | 'record' | 'network' | 'remote' | 'system' | 'wechat' - -type AppTheme = 'light' | 'dark' - -function readInitialTheme(): AppTheme { - try { - const d = document.documentElement.getAttribute('data-theme') - if (d === 'light' || d === 'dark') return d - const x = localStorage.getItem('theme') - if (x === 'light' || x === 'dark') return x - } catch { - /* ignore */ - } - return 'dark' -} - -function ThemeIconSun() { - return ( - - ) -} - -function ThemeIconMoon() { - return ( - - ) -} - -/** 侧栏品牌区:直播摄像机 + 红点(LIVE 语义) */ -function SidebarBrandLogoIcon() { - return ( - - ) -} +type NavId = 'record' | 'camera' | 'network' | 'system' function IconForbidden({ className }: { className?: string }) { return ( @@ -242,168 +119,20 @@ function IconLiveIdle({ className }: { className?: string }) { ) } -function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { room: string; href: string } { - const combined = `${urlIni}\n${douyinUrl ?? ''}` - const urls = extractDouyinLiveUrls(combined) - const hints = extractDouyinRoomHints(combined) - const room = hints[0] || '—' - const href = urls[0] || (hints[0] ? `https://live.douyin.com/${hints[0]}` : '') - return { room, href } -} - -function pbUserInitials(u: PbUserSnapshot): string { - const n = (u.name || '').trim() - if (n.length >= 2) return n.slice(0, 2).toUpperCase() - if (n.length === 1) return n.toUpperCase() - const e = (u.email || '').trim() - if (e.length >= 2) return e.slice(0, 2).toUpperCase() - return '?' -} - -function SidebarBrand({ - tag, - theme, - applyTheme, - pbUser, - onLogout, - onLogin, - onRegister, -}: { - tag: string - theme: AppTheme - applyTheme: (t: AppTheme) => void - pbUser: PbUserSnapshot | null - onLogout: () => void - onLogin: () => void - onRegister: () => void -}) { - const showMember = !!(pbUser && (pbUser.email || pbUser.name)) - const displayName = (pbUser?.name || '').trim() || (pbUser?.email || '').split('@')[0] || '用户' - - return ( -
-
-
- -
-
- 无人直播助手 - -
-
- - {tag ?
{tag}
: null} - {showMember && pbUser ? ( -
-
-
- {pbUserInitials(pbUser)} -
-
- - {displayName} - -
- -
-
- ) : ( -
-
- - -
-
- )} -
- ) -} - const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ - { id: 'desk', label: '仪表盘', hint: '' }, - { id: 'record', label: '直播', hint: '推流与直播控制' }, + { id: 'record', label: '直播', hint: '抖音转播推流与直播控制' }, + { id: 'camera', label: '摄像头', hint: '物理摄像头推流到 YouTube' }, { id: 'network', label: '网络', hint: '连通与测速' }, - { id: 'remote', label: '远程控制', hint: '' }, { id: 'system', label: '系统', hint: '负载与网速' }, - { id: 'wechat', label: '微信', hint: 'Chatbot 与扫码' }, ] -/** 侧栏:直播下为仪表盘;不展示微信(深链仍可用) */ +/** 侧栏:直播 + 摄像头 + 网络 + 系统 */ const SIDEBAR_NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ NAV_ITEMS.find((n) => n.id === 'record')!, - NAV_ITEMS.find((n) => n.id === 'desk')!, - ...NAV_ITEMS.filter((n) => ['network', 'remote', 'system'].includes(n.id)), + NAV_ITEMS.find((n) => n.id === 'camera')!, + ...NAV_ITEMS.filter((n) => ['network', 'system'].includes(n.id)), ] -type StatusVariant = - | 'loading' - | 'online' - | 'stopped' - | 'error' - | 'warn' - | 'offline' - | 'unknown' - | 'empty' - -/** 状态文案:避免技术术语,用「正在直播 / 已停止」等说法 */ -function statusPresentation(processStatus: string): { line: string; variant: StatusVariant } { - if (processStatus === 'online') return { line: '正在直播', variant: 'online' } - if (processStatus === 'stopped') return { line: '已停止', variant: 'stopped' } - if (processStatus === 'errored') return { line: '出现异常', variant: 'error' } - if (processStatus === 'launching' || processStatus === 'starting') - return { line: '正在启动…', variant: 'warn' } - if (processStatus === 'waiting restart') return { line: '等待重启', variant: 'warn' } - if (processStatus === 'not_found') return { line: '尚未开始', variant: 'offline' } - if (processStatus === 'invalid') return { line: '无法识别', variant: 'error' } - return { line: '状态未知', variant: 'unknown' } -} - -async function readControlResponse(res: Response): Promise> { - let data: Record = {} - try { - const parsed = (await res.json()) as unknown - if (parsed && typeof parsed === 'object') data = parsed as Record - } catch { - data = {} - } - if (!res.ok) { - const msg = [data.output, data.error, data.message].find( - (x): x is string => typeof x === 'string' && x.trim().length > 0, - ) - throw new Error(msg || `HTTP ${res.status}`) - } - return data -} - export default function App() { const [theme, setTheme] = useState(readInitialTheme) @@ -414,8 +143,7 @@ export default function App() { variant: 'loading', }) const [bootError, setBootError] = useState(null) - /** Electron:主进程已显示窗口但本地服务/协议未完成时为 false,避免误请求 API */ - const [shellReady, setShellReady] = useState(() => typeof window === 'undefined' || !window.appBootstrap) + const [backendReady, setBackendReady] = useState(false) const [emptyHint, setEmptyHint] = useState(null) const [ytIniModel, setYtIniModel] = useState({ @@ -425,18 +153,14 @@ export default function App() { header: '', }) const [urlText, setUrlText] = useState('') + const [singleYoutubeWatchText, setSingleYoutubeWatchText] = useState('') const [youtubeStatus, setYoutubeStatus] = useState('就绪') const [urlStatus, setUrlStatus] = useState('就绪') - const [liveDetail, setLiveDetail] = useState<{ - processStatus: string - recentLog: string - recentError: string - businessStatus?: string - businessNote?: string - ffmpegPids?: number[] - logAgeSeconds?: number | null - isPushing?: boolean - }>({ processStatus: 'unknown', recentLog: '', recentError: '' }) + const [liveDetail, setLiveDetail] = useState({ + processStatus: 'unknown', + recentLog: '', + recentError: '', + }) const [liveStartedAtMs, setLiveStartedAtMs] = useState(null) const [controlNotice, setControlNotice] = useState('') const [liveTick, setLiveTick] = useState(0) @@ -453,11 +177,19 @@ export default function App() { const relayOnlineSinceRef = useRef(null) const [multiChannelPro, setMultiChannelPro] = useState(false) const [proChannelsList, setProChannelsList] = useState< - { id: string; name: string; keyPreview: string; douyinUrl?: string }[] + { + id: string + name: string + keyPreview: string + douyinUrl?: string + configured?: boolean + youtubeWatchUrl?: string + }[] >([]) const [proChannelsError, setProChannelsError] = useState(null) const [proChannelEditors, setProChannelEditors] = useState>({}) const [saveProKeyLoadingId, setSaveProKeyLoadingId] = useState(null) + const [saveProBundleLoadingId, setSaveProBundleLoadingId] = useState(null) const [saveUrlLoadingId, setSaveUrlLoadingId] = useState(null) const [highlightedChannelId, setHighlightedChannelId] = useState('') const highlightedChannelIdRef = useRef('') @@ -472,6 +204,7 @@ export default function App() { const [gpuCaps, setGpuCaps] = useState(null) const [douyinPreviews, setDouyinPreviews] = useState([]) const [pbAuthModalOpen, setPbAuthModalOpen] = useState(false) + const [pbAuthGateEnabled, setPbAuthGateEnabled] = useState(() => getPbAuthGateEnabled()) const [pbUser, setPbUser] = useState(null) const pendingPbGateRef = useRef<((ok: boolean) => void) | null>(null) @@ -480,25 +213,8 @@ export default function App() { channelStatuses?: { channelId: string; channelName: string; live: boolean }[] } const [proRelayStatus, setProRelayStatus] = useState(null) - - useEffect(() => { - const b = window.appBootstrap - if (!b) { - setShellReady(true) - return - } - let cancelled = false - void b.isReady().then((r) => { - if (!cancelled && r) setShellReady(true) - }) - const off = b.onReady(() => { - if (!cancelled) setShellReady(true) - }) - return () => { - cancelled = true - off() - } - }, []) + const [relayValidationErrors, setRelayValidationErrors] = useState([]) + const [relayValidationNotes, setRelayValidationNotes] = useState([]) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -510,6 +226,23 @@ export default function App() { }, [theme]) useEffect(() => { + const onGateChange = (e: Event) => { + setPbAuthGateEnabled((e as CustomEvent).detail) + } + window.addEventListener(PB_AUTH_GATE_EVENT, onGateChange) + return () => window.removeEventListener(PB_AUTH_GATE_EVENT, onGateChange) + }, []) + + useEffect(() => { + if (pbAuthGateEnabled) return + setPbAuthModalOpen(false) + const p = pendingPbGateRef.current + pendingPbGateRef.current = null + p?.(true) + }, [pbAuthGateEnabled]) + + useEffect(() => { + if (!pbAuthGateEnabled) return const snap = getPbUserSnapshot() if (snap?.email || snap?.name) setPbUser(snap) const tok = getPbAuthToken() @@ -528,7 +261,7 @@ export default function App() { clearPbMemberSession() setPbUser(null) }) - }, []) + }, [pbAuthGateEnabled]) useEffect(() => { const g = window.appTheme?.get @@ -554,16 +287,11 @@ export default function App() { if (!h?.onNavigate) return return h.onNavigate((raw) => { const nav = - raw === 'desk' || raw === 'wechat' - ? 'record' - : raw === 'proxy' - ? 'network' - : raw === 'record' || - raw === 'network' || - raw === 'remote' || - raw === 'system' - ? raw - : null + raw === 'proxy' + ? 'network' + : raw === 'record' || raw === 'camera' || raw === 'network' || raw === 'system' + ? raw + : null if (nav) setNav(nav) }) }, []) @@ -579,8 +307,34 @@ export default function App() { const isTiktok = ent ? isTiktokScript(script) : false const isObs = ent ? isObsScript(script) : false + const pageVisible = usePageVisible() + const statusActivity = useMemo( + () => + classifyRowActivity({ + process_status: liveDetail.processStatus, + business_status: liveDetail.businessStatus, + is_pushing: liveDetail.isPushing, + }), + [liveDetail], + ) + const statusPollInterval = useMemo( + () => statusPollMs({ activity: statusActivity, pageVisible }), + [statusActivity, pageVisible], + ) + const proPushingCount = (proRelayStatus?.channelStatuses ?? []).filter((s) => s.live).length + const relayPollInterval = useMemo( + () => relayLiveStatusPollMs({ pushingLanes: proPushingCount, pageVisible }), + [proPushingCount, pageVisible], + ) + + const monitorProcess = useMemo(() => { + if (isYoutube && multiChannelPro && highlightedChannelId) { + return proChannelPm2Process(highlightedChannelId) + } + return currentProcess + }, [isYoutube, multiChannelPro, highlightedChannelId, currentProcess]) + useEffect(() => { - if (!shellReady) return let cancelled = false void apiFetch(apiUrl('/host/gpu_status')) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) @@ -597,7 +351,7 @@ export default function App() { return () => { cancelled = true } - }, [shellReady]) + }, []) useEffect(() => { if (!isYoutube || multiChannelPro) return @@ -850,6 +604,61 @@ export default function App() { [proChannelEditors], ) + const saveProWatchFor = useCallback( + async (channelId: string, watchOverride?: string): Promise => { + const ed = proChannelEditors[channelId] + const watch = (watchOverride ?? ed?.youtubeWatchText ?? '').trim() + try { + const msg = await saveYoutubeWatchUrl(channelId, watch) + setYoutubeStatus(msg) + return true + } catch (err) { + setYoutubeStatus(`监听地址保存失败:${err instanceof Error ? err.message : String(err)}`) + return false + } + }, + [proChannelEditors], + ) + + const saveProChannelBundle = useCallback( + async (channelId: string, opts?: { urlOverride?: string }): Promise => { + if (!channelId) return false + setSaveProBundleLoadingId(channelId) + try { + const synced = await apiFetchJson<{ message?: string }>( + `${apiUrl('/relay_pro/sync_active')}?channel_id=${encodeURIComponent(channelId)}`, + { method: 'POST' }, + ) + if (synced.message) setYoutubeStatus(synced.message) + const savedKeys = await saveProChannelKeysFor(channelId) + if (!savedKeys) return false + const savedUrl = await saveProUrlFor(channelId, opts?.urlOverride) + if (!savedUrl) return false + const savedWatch = await saveProWatchFor(channelId) + if (!savedWatch) return false + setYoutubeStatus('线路配置已保存(密钥、源站地址、YouTube 监听地址)') + try { + const overview = await apiFetchJson<{ relayValidationErrors?: string[]; relayNotes?: string[] }>( + apiUrl('/client/overview'), + ) + setRelayValidationErrors( + Array.isArray(overview.relayValidationErrors) ? overview.relayValidationErrors.filter(Boolean) : [], + ) + setRelayValidationNotes(Array.isArray(overview.relayNotes) ? overview.relayNotes.filter(Boolean) : []) + } catch { + /* ignore */ + } + return true + } catch (err) { + setYoutubeStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) + return false + } finally { + setSaveProBundleLoadingId(null) + } + }, + [saveProChannelKeysFor, saveProUrlFor, saveProWatchFor], + ) + const saveConfig = useCallback( async ( endpoint: string, @@ -912,6 +721,7 @@ export default function App() { }, []) const ensureCanControlLive = useCallback(async (): Promise => { + if (!pbAuthGateEnabled) return true const tok = getPbAuthToken() if (tok) { try { @@ -937,7 +747,7 @@ export default function App() { pendingPbGateRef.current = resolve setPbAuthModalOpen(true) }) - }, []) + }, [pbAuthGateEnabled]) const runPm2Action = useCallback( async (action: string, processOverride?: string) => { @@ -951,11 +761,12 @@ export default function App() { setLoadingAction(action) } try { - const res = await apiFetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(proc)}`) - const data = await readControlResponse(res) + const data = await apiFetchJson( + `${apiUrl(`/${action}`)}?process=${encodeURIComponent(proc)}`, + ) if (action === 'status') { - const ps = data.process_status as string + const ps = String(data.process_status ?? '') const prev = lastLiveStatusRef.current const prevPs = prev?.process === proc ? prev.status : undefined if (prevPs !== 'online' && ps === 'online') { @@ -983,36 +794,11 @@ export default function App() { } const { line, variant } = statusPresentation(ps) setStatusMeta({ line, variant }) - - if (ps === 'not_found') { - setLiveDetail({ processStatus: ps, recentLog: '', recentError: '' }) - } else { - const rl = String(data.recent_log ?? '') - const re = String(data.recent_error ?? '') - const pids = Array.isArray(data.ffmpeg_pids) - ? data.ffmpeg_pids.map((x) => Number(x)).filter((x) => Number.isFinite(x)) - : [] - const ageRaw = data.log_age_seconds - setLiveDetail({ - processStatus: ps, - recentLog: rl, - recentError: re, - businessStatus: typeof data.business_status === 'string' ? data.business_status : '', - businessNote: typeof data.business_note === 'string' ? data.business_note : '', - ffmpegPids: pids, - logAgeSeconds: typeof ageRaw === 'number' && Number.isFinite(ageRaw) ? ageRaw : null, - isPushing: data.is_pushing === true, - }) - } + setLiveDetail(liveDetailFromStatus(data, ps)) } else { const e = getEntry(proc) const who = e ? e.label : '当前任务' - const out = - typeof data.output === 'string' - ? data.output - : [data.pm2_output, data.message, data.note] - .filter((x): x is string => typeof x === 'string') - .join('\n') || '已完成' + const out = formatControlOutput(data as Record) const actionLabel = action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action logLiveEvent({ @@ -1050,6 +836,46 @@ export default function App() { [currentProcess, getEntry, isYoutube, ytIniModel, urlText, multiChannelPro, ensureCanControlLive], ) + const pollCurrentStatus = useCallback(() => { + if (!entries.length || !currentProcess) return + if (isYoutube && multiChannelPro) { + if (!highlightedChannelId) return + void runPm2Action('status', proChannelPm2Process(highlightedChannelId)) + return + } + void runPm2Action('status') + }, [entries.length, currentProcess, runPm2Action, isYoutube, multiChannelPro, highlightedChannelId]) + + useEffect(() => { + pollCurrentStatus() + }, [pollCurrentStatus]) + + useAdaptiveInterval( + pollCurrentStatus, + statusPollInterval, + !!(entries.length && currentProcess && nav === 'record'), + ) + + const pollProRelayStatus = useCallback(async () => { + if (!isYoutube || !multiChannelPro) return + try { + const j = await apiFetchJson(apiUrl('/relay_pro/live_status')) + setProRelayStatus(j) + } catch { + /* ignore */ + } + }, [isYoutube, multiChannelPro]) + + useEffect(() => { + void pollProRelayStatus() + }, [pollProRelayStatus]) + + useAdaptiveInterval( + () => void pollProRelayStatus(), + relayPollInterval, + !!(isYoutube && multiChannelPro && nav === 'record'), + ) + useEffect(() => { document.body.classList.add('ready') }, []) @@ -1081,12 +907,20 @@ export default function App() { }, [nav]) useEffect(() => { - if (!shellReady) return let cancelled = false - ;(async () => { + let retryTimer: ReturnType | undefined + let attempts = 0 + const MAX_BOOT_RETRIES = 45 + + const boot = async () => { + attempts += 1 try { + const healthy = await checkApiHealth() + if (!healthy) throw new Error('health check failed') const filtered = await loadProcessMonitor() if (cancelled) return + setBootError(null) + setBackendReady(true) if (!filtered.length) { setEmptyHint( UI_DROPDOWN_YOUTUBE_ONLY @@ -1108,43 +942,28 @@ export default function App() { /* ignore */ } setCurrentProcess(pick) + setStatusMeta({ line: '就绪', variant: 'idle' }) } catch (e) { - setBootError( - `软件内部的直播服务没有启动成功。\n\n若您使用的是完整安装包,请尝试重启电脑后再打开;仍不行请联系技术支持。\n\n(技术信息:${e instanceof Error ? e.message : String(e)})`, - ) - setStatusMeta({ line: '未连接', variant: 'offline' }) + if (cancelled) return + if (attempts >= MAX_BOOT_RETRIES) { + setBootError( + '本地控制服务未能在限定时间内启动。\n\n请确认:\n1. 未被安全软件拦截\n2. 端口 8001 未被占用\n3. 安装目录完整(含 Python / web2)\n\n关闭后重新打开应用,或联系技术支持。', + ) + setBackendReady(false) + return + } + setBackendReady(false) + setStatusMeta({ line: `连接中…(${attempts}/${MAX_BOOT_RETRIES})`, variant: 'loading' }) + retryTimer = setTimeout(() => void boot(), 1200) } - })() + } + + void boot() return () => { cancelled = true + if (retryTimer) clearTimeout(retryTimer) } - }, [loadProcessMonitor, shellReady]) - - useEffect(() => { - if (!entries.length || !currentProcess) return - if (isYoutube && multiChannelPro) { - if (!highlightedChannelId) return - void runPm2Action('status', proChannelPm2Process(highlightedChannelId)) - return - } - void runPm2Action('status') - }, [entries.length, currentProcess, runPm2Action, isYoutube, multiChannelPro, highlightedChannelId]) - - useEffect(() => { - if (!entries.length || !currentProcess) return - if (isYoutube && multiChannelPro) { - if (!highlightedChannelId) return - const pm = proChannelPm2Process(highlightedChannelId) - const id = window.setInterval(() => { - void runPm2Action('status', pm) - }, 1000) - return () => clearInterval(id) - } - const id = window.setInterval(() => { - void runPm2Action('status') - }, 1000) - return () => clearInterval(id) - }, [entries.length, currentProcess, runPm2Action, isYoutube, multiChannelPro, highlightedChannelId]) + }, [loadProcessMonitor]) useEffect(() => { if (!ent || !currentProcess) return @@ -1157,7 +976,12 @@ export default function App() { if (!isYoutube || !multiChannelPro) return [] try { const r = await apiFetch(apiUrl('/relay_pro/channels')) - const d = (await r.json()) as { ok?: boolean; error?: string; channels?: typeof proChannelsList } + const d = (await r.json()) as { + ok?: boolean + error?: string + channels?: typeof proChannelsList + singleYoutubeWatchUrl?: string + } if (!d.ok) { setProChannelsError(d.error || '无法加载 Pro 频道') setProChannelsList([]) @@ -1166,6 +990,9 @@ export default function App() { setProChannelsError(null) const list = d.channels || [] setProChannelsList(list) + if (typeof d.singleYoutubeWatchUrl === 'string' && d.singleYoutubeWatchUrl.trim()) { + setSingleYoutubeWatchText(d.singleYoutubeWatchUrl.trim()) + } return list } catch (e) { setProChannelsError(e instanceof Error ? e.message : String(e)) @@ -1175,9 +1002,20 @@ export default function App() { }, [isYoutube, multiChannelPro]) useEffect(() => { - if (!shellReady || !isYoutube || !multiChannelPro) return + if (!isYoutube || multiChannelPro) return + let cancelled = false + void loadYoutubeWatchUrl('single').then((url) => { + if (!cancelled && url) setSingleYoutubeWatchText(url) + }) + return () => { + cancelled = true + } + }, [isYoutube, multiChannelPro]) + + useEffect(() => { + if (!isYoutube || !multiChannelPro) return void reloadProChannelsList() - }, [shellReady, isYoutube, multiChannelPro, reloadProChannelsList]) + }, [isYoutube, multiChannelPro, reloadProChannelsList]) const submitNewChannelDraft = useCallback(async () => { if (!newChannelDraft) return @@ -1263,16 +1101,14 @@ export default function App() { } }, [multiChannelPro]) - const runProChannelStart = useCallback( - async (channelId: string) => { + const runProChannelAction = useCallback( + async (channelId: string, action: 'start' | 'restart') => { if (!channelId) return const allowed = await ensureCanControlLive() if (!allowed) return const pm = proChannelPm2Process(channelId) - setLoadingAction('start') + setLoadingAction(action) try { - const saved = await saveProChannelKeysFor(channelId) - if (!saved) return const edBeforeUrlSave = proChannelEditors[channelId] let urlOverride: string | undefined if ( @@ -1283,42 +1119,41 @@ export default function App() { if (loadedUrl === null) return urlOverride = loadedUrl } - const savedUrl = await saveProUrlFor(channelId, urlOverride) - if (!savedUrl) return - const res = await apiFetch(`${apiUrl('/start')}?process=${encodeURIComponent(pm)}`) - const data = await readControlResponse(res) + const saved = await saveProChannelBundle(channelId, { urlOverride }) + if (!saved) return + const data = await apiFetchJson( + `${apiUrl(`/${action}`)}?process=${encodeURIComponent(pm)}`, + ) const e = getEntry(pm) const who = e ? e.label : '当前任务' - const out = - typeof data.output === 'string' - ? data.output - : [data.pm2_output, data.message, data.note] - .filter((x): x is string => typeof x === 'string') - .join('\n') || '已完成' + const out = formatControlOutput(data as Record) + const actionLabel = action === 'start' ? '开始直播' : '重新开始' logLiveEvent({ eventType: 'control', process: pm, - message: '开始直播 已执行', - detail: { action: 'start', channelId }, + message: `${actionLabel} 已执行`, + detail: { action, channelId }, }) - const ed = proChannelEditors[channelId] - const keySrc = (ed?.keys[0] ?? '').trim() - const suffix = parseYoutubeStreamKeySuffix(`key = ${keySrc}`) - const rooms = extractDouyinRoomHints(ed?.urlText ?? '') - logLiveEvent({ - eventType: 'relay_intent', - process: pm, - message: `推流意图(须一对一:不同源站直播间↔不同 YouTube key):源站 ${rooms.join('、') || '(未解析房间)'} → YouTube …${suffix || '?'}`, - detail: { - douyinRooms: rooms, - keySuffix: suffix, - multiChannelPro: true, - channelId, - pairingPolicy: 'one_douyin_one_youtube', - }, - }) - setControlNotice(`已对「${who}」执行:开始直播\n${out}`) + if (action === 'start') { + const ed = proChannelEditors[channelId] + const keySrc = (ed?.keys[0] ?? '').trim() + const suffix = parseYoutubeStreamKeySuffix(`key = ${keySrc}`) + const rooms = extractDouyinRoomHints(ed?.urlText ?? '') + logLiveEvent({ + eventType: 'relay_intent', + process: pm, + message: `推流意图(须一对一:不同源站直播间↔不同 YouTube key):源站 ${rooms.join('、') || '(未解析房间)'} → YouTube …${suffix || '?'}`, + detail: { + douyinRooms: rooms, + keySuffix: suffix, + multiChannelPro: true, + channelId, + pairingPolicy: 'one_douyin_one_youtube', + }, + }) + } + setControlNotice(`已对「${who}」执行:${actionLabel}\n${out}`) setTimeout(() => void runPm2Action('status', pm), 1500) } catch (err) { setControlNotice(`操作失败:${err instanceof Error ? err.message : String(err)}`) @@ -1326,87 +1161,52 @@ export default function App() { setLoadingAction(null) } }, - [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelKeysFor, saveProUrlFor, ensureCanControlLive], + [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelBundle, ensureCanControlLive], + ) + + const runProChannelStart = useCallback( + (channelId: string) => runProChannelAction(channelId, 'start'), + [runProChannelAction], ) const runProChannelRestart = useCallback( - async (channelId: string) => { - if (!channelId) return - const allowed = await ensureCanControlLive() - if (!allowed) return - const pm = proChannelPm2Process(channelId) - setLoadingAction('restart') - try { - const saved = await saveProChannelKeysFor(channelId) - if (!saved) return - const edBeforeUrlSave = proChannelEditors[channelId] - let urlOverride: string | undefined - if ( - !proUrlLoadedChannelIdsRef.current.has(channelId) && - countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0 - ) { - const loadedUrl = await loadProUrlTextFor(channelId, true) - if (loadedUrl === null) return - urlOverride = loadedUrl - } - const savedUrl = await saveProUrlFor(channelId, urlOverride) - if (!savedUrl) return - const res = await apiFetch(`${apiUrl('/restart')}?process=${encodeURIComponent(pm)}`) - const data = await readControlResponse(res) - - const e = getEntry(pm) - const who = e ? e.label : '当前任务' - const out = - typeof data.output === 'string' - ? data.output - : [data.pm2_output, data.message, data.note] - .filter((x): x is string => typeof x === 'string') - .join('\n') || '已完成' - logLiveEvent({ - eventType: 'control', - process: pm, - message: '重新开始 已执行', - detail: { action: 'restart', channelId }, - }) - setControlNotice(`已对「${who}」执行:重新开始\n${out}`) - setTimeout(() => void runPm2Action('status', pm), 1500) - } catch (err) { - setControlNotice(`操作失败:${err instanceof Error ? err.message : String(err)}`) - } finally { - setLoadingAction(null) - } - }, - [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelKeysFor, saveProUrlFor, ensureCanControlLive], + (channelId: string) => runProChannelAction(channelId, 'restart'), + [runProChannelAction], ) - useEffect(() => { - if (!shellReady || !isYoutube || !multiChannelPro) return - let cancelled = false - const poll = async () => { - try { - const r = await apiFetch(apiUrl('/relay_pro/live_status')) - if (!r.ok || cancelled) return - const j = (await r.json()) as ProRelayStatusPayload - if (!cancelled) setProRelayStatus(j) - } catch { - /* ignore */ - } - } - void poll() - const id = window.setInterval(poll, 2000) - return () => { - cancelled = true - clearInterval(id) - } - }, [shellReady, isYoutube, multiChannelPro]) - const channelIdsSig = useMemo( () => proChannelsList.map((c) => c.id).join('\0'), [proChannelsList], ) useEffect(() => { - if (!shellReady || !isYoutube || !multiChannelPro || !proChannelsList.length) { + if (!isYoutube || !multiChannelPro || !backendReady) return + let cancelled = false + void (async () => { + try { + const data = await apiFetchJson<{ + relayValidationErrors?: string[] + relayNotes?: string[] + }>(apiUrl('/client/overview')) + if (cancelled) return + setRelayValidationErrors( + Array.isArray(data.relayValidationErrors) ? data.relayValidationErrors.filter(Boolean) : [], + ) + setRelayValidationNotes(Array.isArray(data.relayNotes) ? data.relayNotes.filter(Boolean) : []) + } catch { + if (!cancelled) { + setRelayValidationErrors([]) + setRelayValidationNotes([]) + } + } + })() + return () => { + cancelled = true + } + }, [isYoutube, multiChannelPro, backendReady, channelIdsSig, proChannelsList.length]) + + useEffect(() => { + if (!isYoutube || !multiChannelPro || !proChannelsList.length) { if (!multiChannelPro) setProChannelEditors({}) return } @@ -1422,14 +1222,20 @@ export default function App() { youtubeKey?: string youtubeKeys?: string[] youtubeActiveIndex?: number + youtubeWatchUrl?: string } const ksRaw = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length ? cd.youtubeKeys.map(String) : [cd.youtubeKey || ''] const k0 = (ksRaw[0] ?? '').trim() - /* Electron:每频道仅一条密钥;服务端若曾有多条,只取第一条同步到 UI */ - /* 不在此拉 url_config:避免慢请求/竞态把 A 的地址写进 B。直播地址仅由「当前高亮」effect + 用户编辑维护 */ - next[c.id] = { keys: [k0 || ''], activeIndex: 0, urlText: '' } + const watchFromServer = (cd.youtubeWatchUrl ?? c.youtubeWatchUrl ?? '').trim() + const watchText = watchFromServer || (await loadYoutubeWatchUrl(c.id)) + next[c.id] = { + keys: [k0 || ''], + activeIndex: 0, + urlText: '', + youtubeWatchText: watchText, + } committed[c.id] = k0 ? [k0] : [] } catch { /* skip */ @@ -1439,7 +1245,11 @@ export default function App() { setProChannelEditors((prev) => { const merged: Record = {} for (const id of Object.keys(next)) { - merged[id] = { ...next[id], urlText: prev[id]?.urlText ?? '' } + merged[id] = { + ...next[id], + urlText: prev[id]?.urlText ?? '', + youtubeWatchText: prev[id]?.youtubeWatchText ?? next[id].youtubeWatchText, + } } return merged }) @@ -1449,11 +1259,11 @@ export default function App() { return () => { cancelled = true } - }, [shellReady, isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) + }, [isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) /** 唯一从服务端填充「直播地址列表」的路径(与全量密钥同步解耦) */ useEffect(() => { - if (!shellReady || !isYoutube || !multiChannelPro || !highlightedChannelId) return + if (!isYoutube || !multiChannelPro || !highlightedChannelId) return const cid = highlightedChannelId let cancelled = false void (async () => { @@ -1481,14 +1291,14 @@ export default function App() { return () => { cancelled = true } - }, [shellReady, highlightedChannelId, isYoutube, multiChannelPro]) + }, [highlightedChannelId, isYoutube, multiChannelPro]) useEffect(() => { - if (!shellReady || !UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return + if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return void loadYtOauthStatus() const id = window.setInterval(() => void loadYtOauthStatus(), 5000) return () => clearInterval(id) - }, [shellReady, isYoutube, loadYtOauthStatus]) + }, [isYoutube, loadYtOauthStatus]) const douyinHints = useMemo(() => { if (!isYoutube && !isTiktok) return [] @@ -1507,7 +1317,7 @@ export default function App() { }, [isYoutube, multiChannelPro, highlightedChannelId, proChannelEditors, urlText]) useEffect(() => { - if (!shellReady || (!isYoutube && !isTiktok)) return + if (!isYoutube && !isTiktok) return const urls = extractDouyinLiveUrls(previewUrlText) if (!urls.length) { setDouyinPreviews([]) @@ -1526,7 +1336,7 @@ export default function App() { .catch(() => setDouyinPreviews([])) }, 450) return () => clearTimeout(t) - }, [shellReady, previewUrlText, isYoutube, isTiktok]) + }, [previewUrlText, isYoutube, isTiktok]) const openExternalUrl = useCallback((href: string) => { const open = window.recorderConsole?.openExternal @@ -1553,8 +1363,9 @@ export default function App() { const relayPs = liveDetail.processStatus const isRelayProcessLive = relayPs === 'online' const pmBusy = loadingAction !== null - const startLiveDisabled = pmBusy || isRelayProcessLive - const stopLiveDisabled = pmBusy || !isRelayProcessLive + const startLiveDisabled = pmBusy || isRelayProcessLive || !backendReady + const stopLiveDisabled = pmBusy || !isRelayProcessLive || !backendReady + const hasRelayValidationBlock = relayValidationErrors.length > 0 const proLiveRows = useMemo(() => { const liveMap = new Map( @@ -1583,7 +1394,8 @@ export default function App() { const keyConfigured = !!keyText const live = liveMap.get(c.id) ?? false const isCurrentEditor = c.id === highlightedChannelId - if (!keyConfigured && sourceCount <= 0 && !live && !isCurrentEditor) continue + const serverConfigured = c.configured ?? (keyConfigured || sourceCount > 0) + if (!serverConfigured && !live && !isCurrentEditor) continue const readyReason = !keyConfigured ? '缺少 YouTube 串流密钥' : sourceCount <= 0 @@ -1605,6 +1417,18 @@ export default function App() { return rows }, [proChannelsList, proRelayStatus, committedChannelKeys, proChannelEditors, highlightedChannelId]) + const youtubeRelayWatchItems = useMemo(() => { + if (!isYoutube) return [] + if (multiChannelPro) { + return proChannelsList.map((c) => ({ + relayId: c.id, + label: c.name, + input: (proChannelEditors[c.id]?.youtubeWatchText ?? c.youtubeWatchUrl ?? '').trim(), + })) + } + return [{ relayId: 'single', label: '默认线路', input: singleYoutubeWatchText.trim() }] + }, [isYoutube, multiChannelPro, proChannelsList, proChannelEditors, singleYoutubeWatchText]) + /** 多频道下若尚未选中线路,默认高亮第一条频道,未配置频道也能直接进入编辑 */ useEffect(() => { if (!isYoutube || !multiChannelPro) return @@ -1674,6 +1498,13 @@ export default function App() { ok: (selectedProRow?.sourceCount ?? 0) > 0, detail: selectedProRow ? `${selectedProRow.sourceCount} 条地址` : '请先选择一条线路', }) + items.push({ + label: 'YouTube 监听', + ok: !!(proChannelEditors[highlightedChannelId]?.youtubeWatchText ?? '').trim(), + detail: (proChannelEditors[highlightedChannelId]?.youtubeWatchText ?? '').trim() + ? '已配置目标频道地址' + : '建议填写 YouTube 频道/直播页地址', + }) } else { items.push({ label: '串流密钥', @@ -1685,6 +1516,11 @@ export default function App() { ok: singleSourceCount > 0, detail: singleSourceCount > 0 ? `${singleSourceCount} 条地址` : '请填写至少一个源站直播间地址', }) + items.push({ + label: 'YouTube 监听', + ok: !!singleYoutubeWatchText.trim(), + detail: singleYoutubeWatchText.trim() ? '已配置目标频道地址' : '建议填写 YouTube 频道/直播页地址', + }) } items.push({ label: '编码能力', @@ -1700,17 +1536,20 @@ export default function App() { }, [ gpuCaps, gpuStatusText, + highlightedChannelId, isYoutube, keySuffixForUi, liveBusinessLabel, liveDetail.isPushing, liveDetail.processStatus, multiChannelPro, + proChannelEditors, proReadyCount, proVisibleLineCount, selectedProRow, singleKeyConfigured, singleSourceCount, + singleYoutubeWatchText, ]) if (bootError) { @@ -1721,6 +1560,7 @@ export default function App() { tag="" theme={theme} applyTheme={applyTheme} + showAuth={pbAuthGateEnabled} pbUser={pbUser} onLogout={handlePbLogout} onLogin={handlePbLogin} @@ -1753,6 +1593,7 @@ export default function App() { tag="" theme={theme} applyTheme={applyTheme} + showAuth={pbAuthGateEnabled} pbUser={pbUser} onLogout={handlePbLogout} onLogin={handlePbLogin} @@ -1767,6 +1608,9 @@ export default function App() { onClick={() => setNav(item.id)} title={item.hint} > + + + {item.label} {nav === item.id && } @@ -1778,7 +1622,12 @@ export default function App() {
-

{navMeta?.label ?? ''}

+

+ + + + {navMeta?.label ?? ''} +

{navMeta?.hint ?

{navMeta.hint}

: null}
@@ -1881,35 +1730,30 @@ export default function App() {
- {nav === 'desk' && ( -
- -
- )} {nav === 'network' && ( -
+
)} - {nav === 'remote' && ( -
- + {nav === 'camera' && ( +
+
)} {nav === 'system' && ( -
+
)} - {nav === 'wechat' && ( -
- -
- )} {nav === 'record' && ( -
+
{emptyHint && (
@@ -1924,7 +1768,7 @@ export default function App() {
- YouTube 无人直播 + 🎬 YouTube 无人直播

{multiChannelPro ? '多频道 Pro 控制台' : '单频道直播控制台'}

@@ -1940,6 +1784,7 @@ export default function App() { liveReady ? 'live-command__badge--ok' : 'live-command__badge--warn' }`} > + {livePreflightText} )}
@@ -1965,10 +1810,12 @@ export default function App() {
+ ⚙️ 工作模式 {multiChannelPro ? '多频道 Pro' : '单频道'}
+ 📡 运行状态 {multiChannelPro @@ -1977,6 +1824,7 @@ export default function App() {
+ 🔑 密钥 {multiChannelPro @@ -1985,37 +1833,44 @@ export default function App() {
+ 🔗 源站 {multiChannelPro ? `${proSourceCount} 条` : `${singleSourceCount} 条`}
+ 🖥️ 编码 {gpuStatusText}
{multiChannelPro && selectedProRow && (
+ 📺 当前频道 {selectedProRow.channelName}
)}
-
-
- 源站 - 直播间地址 -
-
-
- 本机采集 - FFmpeg / GPU -
-
-
- YouTube - RTMP 推流 -
-
+ {!backendReady ? ( +

+ 本地控制服务连接中…直播开关暂不可用,连接成功后自动恢复。 +

+ ) : null} + + + + setControlNotice(text)} + /> + +
{preflightItems.map((item) => ( @@ -2041,7 +1896,7 @@ export default function App() { {proLiveRows.map((row) => { const ed = proChannelEditors[row.channelId] const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') - const startDisabled = pmBusy || row.live || !row.ready + const startDisabled = pmBusy || row.live || !row.ready || !backendReady || hasRelayValidationBlock const stopDisabled = pmBusy || !row.live const pm = proChannelPm2Process(row.channelId) const rowSelected = row.channelId === highlightedChannelId @@ -2100,7 +1955,17 @@ export default function App() { type="button" className="pro-channel-live-btn pro-channel-live-btn--start" disabled={startDisabled} - title={row.live ? '该频道已在播' : row.ready ? '仅启动本频道的转播进程' : row.readyReason} + title={ + row.live + ? '该频道已在播' + : hasRelayValidationBlock + ? '存在转播配对冲突,请先修正配置' + : !backendReady + ? '控制服务连接中' + : row.ready + ? '仅启动本频道的转播进程' + : row.readyReason + } onClick={() => { if (startDisabled) return void runProChannelStart(row.channelId) @@ -2179,6 +2044,7 @@ export default function App() { {isYoutube && multiChannelPro && ( <> + {proChannelsError && (

{proChannelsError} @@ -2248,10 +2114,14 @@ export default function App() {