Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8705d4847 | ||
|
|
def681cbe3 | ||
|
|
7a03a670bc | ||
|
|
1a2b43fd11 | ||
|
|
136362a34a | ||
|
|
9ccafcc0d5 | ||
|
|
43a07f144f | ||
|
|
fccb46ae7b | ||
|
|
ea99d733dc | ||
|
|
8c14404831 | ||
|
|
032c75fb6f | ||
|
|
950cb0ae4b | ||
|
|
9700404418 | ||
|
|
1da3383735 | ||
|
|
b850d1a8bc | ||
|
|
b6c5185a59 | ||
|
|
4030a4dc28 | ||
|
|
00bcaee9fa | ||
|
|
54daccea6b | ||
|
|
4eac033e93 | ||
|
|
17900701f1 | ||
|
|
033b76bda5 | ||
|
|
bf6b827e1f | ||
|
|
b4183ed85b | ||
|
|
f0823c7593 | ||
|
|
326ab8aea2 | ||
|
|
3842833ad0 | ||
|
|
9e589e6263 | ||
|
|
7090f4481a | ||
|
|
eafcc2b7d0 | ||
|
|
e510e66c13 | ||
|
|
d86e8e339d | ||
|
|
ba4aaa1b65 | ||
|
|
475e15bc16 | ||
|
|
31e77d5203 | ||
|
|
6c383ee489 | ||
|
|
9d3ec4fa2c | ||
|
|
7402773a3e | ||
|
|
140790c4a4 | ||
|
|
1bdf41ac3f |
834
README.md
834
README.md
@@ -1,679 +1,259 @@
|
|||||||

|
# DouyinLiveRecorder 转播版
|
||||||
|
|
||||||
## 💡简介
|
[](https://www.python.org/downloads/)
|
||||||
[](https://www.python.org/downloads/release/python-3116/)
|
[](https://github.com/ihmily/DouyinLiveRecorder)
|
||||||
[](https://github.com/ihmily/DouyinLiveRecorder)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags)
|
|
||||||

|
|
||||||
[](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
|
|
||||||
[](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
|
|
||||||
|
|
||||||
一款**简易**的可循环值守的直播录制工具,基于FFmpeg实现多平台直播源录制,支持自定义配置录制以及直播状态推送。
|
基于 [DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder) 的**转播增强版**,将原本的录制下载功能扩展为**实时推流转播**,支持抖音/TikTok 等直播推送到 YouTube、HDMI 等目标。
|
||||||
|
|
||||||
</div>
|
## 与原版的主要区别
|
||||||
|
|
||||||
## 😺已支持平台
|
| 功能 | 原版 DouyinLiveRecorder | 本转播版 |
|
||||||
|
|------|-------------------------|----------|
|
||||||
|
| 核心用途 | 录制直播到本地文件 | 实时转播到 YouTube / HDMI |
|
||||||
|
| 抖音 → YouTube | 不支持 | ✅ 支持,含 AI 人声分离防版权 |
|
||||||
|
| 抖音 → HDMI/SRS | 不支持 | ✅ 支持(TikTok 模式) |
|
||||||
|
| 录制到本地 | ✅ 支持 | ✅ 保留支持 |
|
||||||
|
| 平台支持 | 40+ 平台 | 继承全部平台 |
|
||||||
|
|
||||||
- [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
|
|
||||||
- [ ] 更多平台正在更新中
|
|
||||||
|
|
||||||
</div>
|
## 功能特性
|
||||||
|
|
||||||
## 🎈项目结构
|
### 转播模式
|
||||||
|
|
||||||
|
- **YouTube 转播**:抖音直播 → YouTube Live,支持 AI 人声分离(Demucs)降低音乐版权识别
|
||||||
|
- **TikTok/HDMI 转播**:抖音直播 → SRS 推流 → HDMI 采集卡或 OBS
|
||||||
|
- **本地录制**:保留原版全部录制能力
|
||||||
|
|
||||||
|
### 防版权识别(YouTube 专用)
|
||||||
|
|
||||||
|
- **Demucs AI 人声分离**:只保留人声,去除背景音乐,从源头避免 Content ID 匹配
|
||||||
|
- **NVIDIA GPU 加速**:自动检测 CUDA,优先使用 GPU 加速 Demucs
|
||||||
|
- **无 GPU 回退**:无显卡时自动回退到 CPU,不报错
|
||||||
|
- **可配置开关**:可在 `config/youtube.ini` 中关闭 Demucs,使用原始音频
|
||||||
|
|
||||||
|
### 循环值守与多直播间
|
||||||
|
|
||||||
|
- 自动轮询直播间状态
|
||||||
|
- 主播下播后自动停止推流,并快速重检
|
||||||
|
- 支持多直播间 URL 配置,单路推流时自动切换
|
||||||
|
- 直播状态推送(钉钉、微信、Telegram 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
└── DouyinLiveRecorder/
|
├── main.py # 主入口(本地录制 + SRS 转播)
|
||||||
├── /config -> (config record)
|
├── youtube.py # YouTube 转播入口
|
||||||
├── /logs -> (save runing log file)
|
├── tiktok.py # TikTok/HDMI 转播入口
|
||||||
├── /backup_config -> (backup file)
|
├── douyin_youtube_ffplay.py # 抖音 → YouTube 推流(含 Demucs 管道)
|
||||||
├── /douyinliverecorder -> (package)
|
├── douyin_srs_ffplay.py # 抖音 → SRS/HDMI 推流
|
||||||
├── initializer.py-> (check and install nodejs)
|
├── web.py # Web 配置界面
|
||||||
├── spider.py-> (get live data)
|
├── config/
|
||||||
├── stream.py-> (get live stream address)
|
│ ├── config.ini # 通用配置
|
||||||
├── utils.py -> (contains utility functions)
|
│ ├── youtube.ini # YouTube 推流配置(stream key、Demucs 开关)
|
||||||
├── logger.py -> (logger handdle)
|
│ └── URL_config.ini # 直播间 URL 列表
|
||||||
├── room.py -> (get room info)
|
├── src/ # 爬虫、流解析等核心逻辑
|
||||||
├── ab_sign.py-> (generate dy token)
|
├── backup/ # 备份与辅助模块
|
||||||
├── /javascript -> (some decrypt code)
|
├── requirements.txt
|
||||||
├── main.py -> (main file)
|
├── pyproject.toml
|
||||||
├── ffmpeg_install.py -> (ffmpeg install script)
|
└── README.md
|
||||||
├── 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)
|
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</div>
|
---
|
||||||
|
|
||||||
## 🌱使用说明
|
## 快速开始
|
||||||
|
|
||||||
- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可,里面有打包好的录制软件。(有些电脑可能会报毒,直接忽略即可,如果下载时被浏览器屏蔽,请更换浏览器下载)
|
### 1. 环境要求
|
||||||
|
|
||||||
- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`。
|
- **Python** >= 3.10
|
||||||
- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。
|
- **FFmpeg**(需支持 libx264、h264_nvenc)
|
||||||
|
- **可选**:NVIDIA GPU + CUDA(用于 Demucs 加速)
|
||||||
|
|
||||||
- 另外,如果需要录制TikTok、AfreecaTV等海外平台,请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。
|
### 2. 安装依赖
|
||||||
|
|
||||||
- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。
|
```bash
|
||||||
|
# 克隆或下载项目
|
||||||
|
cd douyinyoutube
|
||||||
|
|
||||||
- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清,https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。
|
# 创建虚拟环境(推荐)
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows:
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# Ubuntu/Linux:
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
- 如果要长时间挂着软件循环监测直播,最好循环时间设置长一点(咱也不差没录制到的那几分钟),避免因请求频繁导致被官方封禁IP 。
|
# 安装基础依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
- 要停止直播录制,Windows平台可执行StopRecording.vbs脚本文件,或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。
|
|
||||||
- 最后,欢迎右上角给本项目一个star,同时也非常乐意大家提交pr。
|
|
||||||
|
|
||||||
 
|
|
||||||
|
|
||||||
直播间链接示例:
|
|
||||||
|
|
||||||
|
# 若使用 YouTube 转播 + Demucs 防版权,需额外安装:
|
||||||
|
pip install -r requirements-youtube.txt
|
||||||
|
# GPU 版本(NVIDIA)需先安装 CUDA 版 PyTorch:
|
||||||
|
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||||
```
|
```
|
||||||
抖音:
|
|
||||||
|
### 3. 配置
|
||||||
|
|
||||||
|
#### YouTube 转播
|
||||||
|
|
||||||
|
1. 编辑 `config/youtube.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[youtube]
|
||||||
|
# YouTube 直播 stream key(从 YouTube 直播控制台获取)
|
||||||
|
key = xxxx-xxxx-xxxx-xxxx
|
||||||
|
|
||||||
|
# 是否启用 Demucs 人声分离(防版权识别)
|
||||||
|
# 是 = 启用,否 = 使用原始音频
|
||||||
|
enable_demucs = 是
|
||||||
|
|
||||||
|
# Demucs 是否优先使用 GPU(有 NVIDIA 显卡时)
|
||||||
|
# 是 = 优先 GPU,否 = 强制 CPU
|
||||||
|
demucs_use_gpu = 是
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 编辑 `config/URL_config.ini`,添加抖音直播间地址:
|
||||||
|
|
||||||
|
```ini
|
||||||
https://live.douyin.com/745964462470
|
https://live.douyin.com/745964462470
|
||||||
https://v.douyin.com/iQFeBnt/
|
https://live.douyin.com/xxx,主播名
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
 
|
3. 在 `config/config.ini` 中将 `视频保存格式` 设为 `ts`(触发 YouTube 推流逻辑)
|
||||||
|
|
||||||
## 🎃源码运行
|
#### TikTok/HDMI 转播
|
||||||
使用源码运行,可参考下面的步骤。
|
|
||||||
|
|
||||||
1.首先拉取或手动下载本仓库项目代码
|
- 使用 `tiktok.py` 入口,配置 SRS 推流地址
|
||||||
|
- 详见 `tiktok/` 目录说明
|
||||||
|
|
||||||
|
### 4. 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ihmily/DouyinLiveRecorder.git
|
# YouTube 转播
|
||||||
|
python youtube.py
|
||||||
|
|
||||||
|
# 或使用 uv
|
||||||
|
uv run youtube.py
|
||||||
```
|
```
|
||||||
|
|
||||||
2.进入项目文件夹,安装依赖
|
---
|
||||||
|
|
||||||
|
## 详细配置说明
|
||||||
|
|
||||||
|
### config/youtube.ini
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| key | YouTube 直播 stream key | 必填 |
|
||||||
|
| enable_demucs | 是否启用 Demucs 人声分离 | 是 |
|
||||||
|
| demucs_use_gpu | 是否优先使用 NVIDIA GPU | 是 |
|
||||||
|
|
||||||
|
### config/config.ini
|
||||||
|
|
||||||
|
- **视频保存格式**:设为 `ts` 时,youtube.py 会走 YouTube 推流逻辑,而非本地录制
|
||||||
|
- **循环时间**:轮询间隔(秒)
|
||||||
|
- **代理**:海外平台需配置代理
|
||||||
|
|
||||||
|
### URL_config.ini 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
# 单行一个直播间
|
||||||
|
https://live.douyin.com/745964462470
|
||||||
|
|
||||||
|
# 可加画质和备注
|
||||||
|
超清,https://live.douyin.com/xxx
|
||||||
|
https://live.douyin.com/yyy,主播名
|
||||||
|
|
||||||
|
# 注释掉不监测
|
||||||
|
# https://live.douyin.com/zzz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demucs 防版权说明
|
||||||
|
|
||||||
|
### 原理
|
||||||
|
|
||||||
|
- 使用 [Demucs](https://github.com/facebookresearch/demucs) 对混音进行人声分离
|
||||||
|
- 只保留人声轨道推送到 YouTube,背景音乐被物理移除
|
||||||
|
- Content ID 无法匹配已移除的音乐
|
||||||
|
|
||||||
|
### 延迟与性能
|
||||||
|
|
||||||
|
- **延迟**:约 4–8 秒(与 chunk 大小相关)
|
||||||
|
- **GPU**:有 NVIDIA 显卡时自动使用 CUDA 加速
|
||||||
|
- **CPU**:无 GPU 时自动回退,不影响运行
|
||||||
|
|
||||||
|
### 回退机制
|
||||||
|
|
||||||
|
1. **Demucs 未安装**:自动使用原始 FFmpeg 推流
|
||||||
|
2. **GPU 不可用**:自动使用 CPU 运行 Demucs
|
||||||
|
3. **enable_demucs = 否**:直接使用原始音频
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 平台兼容性
|
||||||
|
|
||||||
|
| 平台 | 录制 | YouTube 转播 | Demucs |
|
||||||
|
|------|------|--------------|--------|
|
||||||
|
| Windows | ✅ | ✅ | ✅(CPU/GPU) |
|
||||||
|
| Ubuntu Server | ✅ | ✅ | ✅(优先) |
|
||||||
|
| 其他 Linux | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web 配置界面
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd DouyinLiveRecorder
|
python web.py
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
- 浏览器访问配置页面
|
||||||
> - 不论你是否已安装 **Python>=3.10** 环境, 都推荐使用 [**uv**](https://github.com/astral-sh/uv) 运行, 因为它可以自动管理虚拟环境和方便地管理 **Python** 版本, **不过这完全是可选的**<br />
|
- 支持编辑 `youtube.ini`、`URL_config.ini`
|
||||||
> 使用以下命令安装
|
- 选择 youtube / tiktok / obs 等模式
|
||||||
> ```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 镜像源:<br />
|
|
||||||
> 在 `pip` 命令使用 `-i` 参数指定, 如 `pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`<br />
|
|
||||||
> 或者在 `uv` 命令 `--index` 选项指定, 如 `uv sync --index https://pypi.tuna.tsinghua.edu.cn/simple`
|
|
||||||
|
|
||||||
<details>
|
---
|
||||||
|
|
||||||
<summary>如果已安装 <b>Python>=3.10</b> 环境</summary>
|
## 常见问题
|
||||||
|
|
||||||
- :white_check_mark: 在虚拟环境中安装 (推荐)
|
### 1. Demucs 安装失败
|
||||||
|
|
||||||
1. 创建虚拟环境
|
|
||||||
|
|
||||||
- 使用系统已安装的 Python, 不使用 uv
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用 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
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
|
|
||||||
<summary>如果未安装 <b>Python>=3.10</b> 环境</summary>
|
|
||||||
|
|
||||||
你可以使用 [**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
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
3.安装[FFmpeg](https://ffmpeg.org/download.html#build-linux),如果是Windows系统,这一步可跳过。对于Linux系统,执行以下命令安装
|
|
||||||
|
|
||||||
CentOS执行
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yum install epel-release
|
# 仅 CPU
|
||||||
yum install ffmpeg
|
pip install demucs torch torchaudio
|
||||||
|
|
||||||
|
# 使用国内镜像
|
||||||
|
pip install demucs torch torchaudio -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
```
|
```
|
||||||
|
|
||||||
Ubuntu则执行
|
### 2. 无 NVIDIA 显卡
|
||||||
|
|
||||||
```bash
|
- 程序会自动使用 CPU 运行 Demucs
|
||||||
apt update
|
- 可在 `youtube.ini` 中设置 `demucs_use_gpu = 否` 以跳过 GPU 检测
|
||||||
apt install ffmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
macOS 执行
|
### 3. 推流中断
|
||||||
|
|
||||||
**如果已经安装 Homebrew 请跳过这一步**
|
- 检查网络与代理
|
||||||
|
- 检查 YouTube stream key 是否有效
|
||||||
|
- 查看控制台日志定位错误
|
||||||
|
|
||||||
```bash
|
### 4. 抖音风控
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
- 适当增大循环时间
|
||||||
brew install ffmpeg
|
- 确保 Cookie 有效(录制抖音需配置)
|
||||||
```
|
|
||||||
|
|
||||||
4.运行程序
|
---
|
||||||
|
|
||||||
```python
|
## 致谢
|
||||||
python main.py
|
|
||||||
|
|
||||||
```
|
- 原项目:[ihmily/DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder)
|
||||||
或
|
- 人声分离:[facebookresearch/demucs](https://github.com/facebookresearch/demucs)
|
||||||
|
|
||||||
```bash
|
---
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
其中Linux系统请使用`python3 main.py` 运行。
|
## 许可证
|
||||||
|
|
||||||
 
|
MIT License
|
||||||
## 🐋容器运行
|
|
||||||
|
|
||||||
在运行命令之前,请确保您的机器上安装了 [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
|
|
||||||
|
|
||||||
 
|
|
||||||
|
|
||||||
## ❤️贡献者
|
|
||||||
|
|
||||||
   [](https://github.com/ihmily)
|
|
||||||
[](https://github.com/iridescentGray)
|
|
||||||
[](https://github.com/annidy)
|
|
||||||
[](https://github.com/wwkk2580)
|
|
||||||
[](https://github.com/missuo)
|
|
||||||
<a href="https://github.com/xueli12" target="_blank"><img src="https://github.com/xueli12.png?size=50" alt="xueli12" style="width:53px; height:51px;" /></a>
|
|
||||||
<a href="https://github.com/kaine1973" target="_blank"><img src="https://github.com/kaine1973.png?size=50" alt="kaine1973" style="width:53px; height:51px;" /></a>
|
|
||||||
<a href="https://github.com/yinruiqing" target="_blank"><img src="https://github.com/yinruiqing.png?size=50" alt="yinruiqing" style="width:53px; height:51px;" /></a>
|
|
||||||
<a href="https://github.com/Max-Tortoise" target="_blank"><img src="https://github.com/Max-Tortoise.png?size=50" alt="Max-Tortoise" style="width:53px; height:51px;" /></a>
|
|
||||||
[](https://github.com/justdoiting)
|
|
||||||
[](https://github.com/dhbxs)
|
|
||||||
[](https://github.com/wujiyu115)
|
|
||||||
[](https://github.com/zhanghao333)
|
|
||||||
<a href="https://github.com/gyc0123" target="_blank"><img src="https://github.com/gyc0123.png?size=50" alt="gyc0123" style="width:53px; height:51px;" /></a>
|
|
||||||
|
|
||||||
   [](https://github.com/HoratioShaw)
|
|
||||||
[](https://github.com/nov30th)
|
|
||||||
[](https://github.com/727155455)
|
|
||||||
[](https://github.com/nixingshiguang)
|
|
||||||
[](https://github.com/1411430556)
|
|
||||||
[](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
|
|
||||||
- 最后,祝大家新年快乐!
|
|
||||||
|
|
||||||
<details><summary>点击展开更多提交日志</summary>
|
|
||||||
|
|
||||||
- 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接口,上传即可用
|
|
||||||
</details>
|
|
||||||
 
|
|
||||||
|
|
||||||
## 有问题可以提issue, 我会在这里持续添加更多直播平台的录制 欢迎Star
|
|
||||||
####
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
https://live.douyin.com/372551154040,主播: 向嘉_手打柠檬茶
|
https://live.douyin.com/629587976874,主播: 伟利火烧
|
||||||
https://live.douyin.com/203476627795,主播: 蜜桃姐姐(手打柠檬茶)
|
https://live.douyin.com/606957569518,主播: 李泽宇(煎饼商)
|
||||||
https://live.douyin.com/174360482665,主播: 冰块_(体力的神是我大姐)
|
https://live.douyin.com/288000519466,主播: 五花肉肉
|
||||||
https://live.douyin.com/651493903077,主播: 茶聚手打柠檬茶(小王同学)
|
https://live.douyin.com/69064125466,主播: 张大丽丽的丽茶努力版
|
||||||
https://live.douyin.com/503988877962,主播: 涛涛茶档
|
https://live.douyin.com/838061972379,主播: 番茄妹妹
|
||||||
https://live.douyin.com/664793904394,主播: 摆摊打柠檬
|
https://live.douyin.com/562629369787,主播: 叹茶柠檬茶
|
||||||
https://live.douyin.com/162450932525,主播: 曾漂亮•手打柠檬茶
|
https://live.douyin.com/565208465584,主播: 财圆圆
|
||||||
https://live.douyin.com/182011399880,主播: 心怡(手打柠檬茶)
|
https://live.douyin.com/51142028798,主播: 小夏妹妹
|
||||||
https://live.douyin.com/473695498831,主播: 知心(手打柠檬茶)
|
https://live.douyin.com/974451428277,主播: 雅如柠檬茶
|
||||||
https://live.douyin.com/210443559964,主播: 小小诺
|
|
||||||
https://live.douyin.com/21146786242,主播: 胡辣汤早餐店
|
|
||||||
https://live.douyin.com/369626255042,主播: 六六的摆摊日记
|
|
||||||
https://live.douyin.com/41148117948,主播: 煎饼老杜
|
|
||||||
https://live.douyin.com/109578500864,主播: 三妹凉皮
|
|
||||||
https://live.douyin.com/35932729226,主播: 内向小学生
|
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
[youtube]
|
[youtube]
|
||||||
# 只写 stream key(推荐方式)
|
# 只写 stream key(推荐方式)
|
||||||
key = ue78-1c3e-mr9g-14mz-9r4z
|
key = x04z-564w-aks7-embw-30y4
|
||||||
|
|
||||||
|
# ===== 优先:推流画质 =====
|
||||||
|
# 视频码率 Mbps(YouTube 推荐 2.5,可设 4-4.5 追求更高画质)
|
||||||
|
video_bitrate_mbps = 2.5
|
||||||
|
|
||||||
|
# 推流状态日志间隔(秒),每 N 秒打印帧率/码率/网络等
|
||||||
|
stats_interval_sec = 10
|
||||||
|
|
||||||
|
# ===== 其次:音频版权处理 =====
|
||||||
|
# 是否启用 Demucs 人声分离(防 YouTube Content ID 版权识别)
|
||||||
|
# 若推流几十秒后断流(YouTube 显示 no data),可设为 否 使用原始音频
|
||||||
|
# 是 = 启用,只保留人声;否 = 使用原始音频
|
||||||
|
enable_demucs = 是
|
||||||
|
|
||||||
|
# Demucs 是否优先使用 NVIDIA GPU 加速
|
||||||
|
# 是 = 有显卡则用 GPU;否 = 强制 CPU;无显卡时自动回退到 CPU
|
||||||
|
demucs_use_gpu = 是
|
||||||
|
|
||||||
|
# 人声混合比例(0.5-1.0),改善自然度、减少尖锐
|
||||||
|
# 0.75 = 75%人声+25%混合(推荐);1.0 = 纯人声
|
||||||
|
demucs_vocal_mix = 0.75
|
||||||
|
|
||||||
|
# 混合来源:bass=低音(无混响无重复);other=其他(可能带混响导致重复感)
|
||||||
|
demucs_mix_source = bass
|
||||||
456
demucs_pipeline.py
Normal file
456
demucs_pipeline.py
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
# demucs_pipeline.py
|
||||||
|
# Demucs 人声分离管道:FFmpeg 拉流 → Demucs 分离人声 → FFmpeg 推流
|
||||||
|
# 兼容 Windows / Ubuntu,支持 NVIDIA GPU 加速与 CPU 回退
|
||||||
|
"""
|
||||||
|
Demucs 音频管道模块
|
||||||
|
用于将混音分离为人声,推送到 YouTube 时避免 Content ID 识别背景音乐。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
import platform
|
||||||
|
|
||||||
|
try:
|
||||||
|
from stream_stats import StreamStats, parse_and_maybe_print
|
||||||
|
_STREAM_STATS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_STREAM_STATS_AVAILABLE = False
|
||||||
|
|
||||||
|
# 可选依赖:Demucs 和 PyTorch
|
||||||
|
DEMUCS_AVAILABLE = False
|
||||||
|
DEMUCS_MODEL = None
|
||||||
|
TORCH_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
TORCH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from demucs.pretrained import get_model
|
||||||
|
from demucs.apply import apply_model
|
||||||
|
DEMUCS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _check_demucs_available():
|
||||||
|
"""检查 Demucs 是否可用"""
|
||||||
|
return DEMUCS_AVAILABLE and TORCH_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_demucs_model(use_gpu: bool):
|
||||||
|
"""加载 Demucs 模型,支持 GPU/CPU 回退"""
|
||||||
|
global DEMUCS_MODEL
|
||||||
|
if not _check_demucs_available():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# htdemucs 较轻量,适合实时;htdemucs_ft 质量更好但更慢
|
||||||
|
model_name = "htdemucs"
|
||||||
|
device = "cpu"
|
||||||
|
if use_gpu and TORCH_AVAILABLE and torch.cuda.is_available():
|
||||||
|
try:
|
||||||
|
device = "cuda"
|
||||||
|
except Exception:
|
||||||
|
device = "cpu"
|
||||||
|
model = get_model(model_name)
|
||||||
|
model.eval()
|
||||||
|
return model.to(device)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Demucs 模型加载失败,回退原始音频: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Demucs 模型采样率
|
||||||
|
DEMUCS_SAMPLE_RATE = 44100
|
||||||
|
DEMUCS_CHANNELS = 2
|
||||||
|
# 每次处理的音频长度(秒),1.0 更易实时,2.0 质量更好但 P40 可能跟不上
|
||||||
|
DEMUCS_CHUNK_SEC = 1.0
|
||||||
|
# 字节 per sample (s16le: 2 bytes)
|
||||||
|
BYTES_PER_SAMPLE = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _demucs_separate_vocals(model, audio_chunk, device, vocal_mix_ratio=1.0, mix_source="bass"):
|
||||||
|
"""
|
||||||
|
对音频块进行人声分离,返回人声轨道。
|
||||||
|
vocal_mix_ratio: 1.0=纯人声;0.75=75%人声+25%混合(更自然)
|
||||||
|
mix_source: "bass"=与低音混合(少混响无重复感);"other"=与其他混合(可能带混响)
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
# audio_chunk: numpy or tensor, shape (channels, samples)
|
||||||
|
if hasattr(audio_chunk, 'numpy'):
|
||||||
|
wav = audio_chunk
|
||||||
|
else:
|
||||||
|
import numpy as np
|
||||||
|
wav = torch.from_numpy(audio_chunk).float()
|
||||||
|
# Demucs 期望 (batch, channels, samples)
|
||||||
|
if wav.dim() == 2:
|
||||||
|
wav = wav.unsqueeze(0)
|
||||||
|
with torch.no_grad():
|
||||||
|
out = apply_model(model, wav, device=device, progress=False, overlap=0.1)
|
||||||
|
# out: (batch, sources, channels, samples)
|
||||||
|
# sources: [drums=0, bass=1, other=2, vocals=3]
|
||||||
|
vocals = out[0, 3]
|
||||||
|
if vocal_mix_ratio >= 1.0:
|
||||||
|
return vocals.cpu().numpy()
|
||||||
|
# 混合 bass 或 other:bass 无混响、无重复感;other 可能带混响导致"重复一遍"
|
||||||
|
blend = out[0, 1] if mix_source == "bass" else out[0, 2]
|
||||||
|
mixed = vocals * vocal_mix_ratio + blend * (1.0 - vocal_mix_ratio)
|
||||||
|
return mixed.cpu().numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_demucs_worker(audio_read_pipe, audio_write_socket, model, device, stop_event, vocal_mix_ratio=1.0, mix_source="bass"):
|
||||||
|
"""Demucs 工作线程:从 FFmpeg 读音频,分离人声,写入 socket"""
|
||||||
|
import numpy as np
|
||||||
|
chunk_samples = int(DEMUCS_SAMPLE_RATE * DEMUCS_CHUNK_SEC * DEMUCS_CHANNELS)
|
||||||
|
chunk_bytes = chunk_samples * BYTES_PER_SAMPLE
|
||||||
|
buf = b""
|
||||||
|
|
||||||
|
def read_audio(size):
|
||||||
|
if hasattr(audio_read_pipe, 'recv'):
|
||||||
|
return audio_read_pipe.recv(size)
|
||||||
|
return audio_read_pipe.read(size)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
data = read_audio(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buf += data
|
||||||
|
except (socket.error, OSError, ValueError):
|
||||||
|
break
|
||||||
|
while len(buf) >= chunk_bytes:
|
||||||
|
chunk = buf[:chunk_bytes]
|
||||||
|
buf = buf[chunk_bytes:]
|
||||||
|
# s16le -> float
|
||||||
|
arr = np.frombuffer(chunk, dtype=np.int16)
|
||||||
|
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
|
||||||
|
try:
|
||||||
|
vocals = _demucs_separate_vocals(model, arr, device, vocal_mix_ratio, mix_source)
|
||||||
|
# float -> s16le
|
||||||
|
out = (vocals * 32768).astype(np.int16).tobytes()
|
||||||
|
try:
|
||||||
|
audio_write_socket.sendall(out)
|
||||||
|
except (socket.error, OSError):
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Demucs 处理异常: {e}")
|
||||||
|
# 回退:直接透传原始音频
|
||||||
|
try:
|
||||||
|
audio_write_socket.sendall(chunk)
|
||||||
|
except (socket.error, OSError):
|
||||||
|
return
|
||||||
|
# 剩余 buffer
|
||||||
|
if buf and audio_write_socket:
|
||||||
|
try:
|
||||||
|
arr = np.frombuffer(buf, dtype=np.int16)
|
||||||
|
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
|
||||||
|
vocals = _demucs_separate_vocals(model, arr, device, vocal_mix_ratio, mix_source)
|
||||||
|
out = (vocals * 32768).astype(np.int16).tobytes()
|
||||||
|
audio_write_socket.sendall(out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Demucs worker 异常: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
audio_write_socket.shutdown(socket.SHUT_WR)
|
||||||
|
audio_write_socket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run_demucs_pipeline(
|
||||||
|
real_url: str,
|
||||||
|
youtube_rtmp: str,
|
||||||
|
douyin_headers: str,
|
||||||
|
nvenc_params: dict,
|
||||||
|
end_keywords: list,
|
||||||
|
stderr_queue_timeout: float,
|
||||||
|
no_frame_timeout: int,
|
||||||
|
start_check_after: int,
|
||||||
|
stale_output_seconds: int,
|
||||||
|
stream_ended_exception,
|
||||||
|
cleanup_proc_fn,
|
||||||
|
stderr_reader_fn,
|
||||||
|
vocal_mix_ratio: float = 0.75,
|
||||||
|
mix_source: str = "bass",
|
||||||
|
stats_interval_sec: int = 10,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
运行 Demucs 管道:拉流 → 人声分离 → 推流
|
||||||
|
若主播下播应停止则 raise stream_ended_exception
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 检查 Demucs
|
||||||
|
use_gpu = nvenc_params.get("demucs_use_gpu", True)
|
||||||
|
model = _load_demucs_model(use_gpu)
|
||||||
|
if model is None:
|
||||||
|
raise RuntimeError("Demucs 模型不可用,将回退原始推流")
|
||||||
|
|
||||||
|
device = next(model.parameters()).device
|
||||||
|
device_str = "cuda" if "cuda" in str(device) else "cpu"
|
||||||
|
src_name = "低音" if mix_source == "bass" else "其他"
|
||||||
|
mix_info = "纯人声" if vocal_mix_ratio >= 1.0 else f"{int(vocal_mix_ratio*100)}%人声+{int((1-vocal_mix_ratio)*100)}%{src_name}"
|
||||||
|
print(f"[INFO] Demucs 人声分离已启用 ({device_str}),模式: {mix_info},低通 9kHz 降尖锐")
|
||||||
|
|
||||||
|
# TCP 服务器用于音频(跨平台)
|
||||||
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
server.bind(("127.0.0.1", 0))
|
||||||
|
port = server.getsockname()[1]
|
||||||
|
server.listen(1)
|
||||||
|
|
||||||
|
tcp_audio_url = f"tcp://127.0.0.1:{port}"
|
||||||
|
stop_event = threading.Event()
|
||||||
|
conn = [None] # 用 list 以便在闭包中修改
|
||||||
|
|
||||||
|
def accept_conn():
|
||||||
|
server.settimeout(30)
|
||||||
|
try:
|
||||||
|
c, _ = server.accept()
|
||||||
|
conn[0] = c
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
server.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
accept_thread = threading.Thread(target=accept_conn, daemon=True)
|
||||||
|
accept_thread.start()
|
||||||
|
time.sleep(0.2) # 确保 server 已 listen
|
||||||
|
|
||||||
|
# FFmpeg 拉流(视频)
|
||||||
|
ffmpeg_v_cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||||
|
"-stats", "-stats_period", "1",
|
||||||
|
"-rw_timeout", "30000000",
|
||||||
|
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "5",
|
||||||
|
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||||
|
"-err_detect", "ignore_err",
|
||||||
|
"-headers", douyin_headers,
|
||||||
|
"-i", real_url,
|
||||||
|
"-map", "0:v", "-c:v", "copy", "-f", "nut", "pipe:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
# FFmpeg 拉流(音频)
|
||||||
|
ffmpeg_a_cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "error", "-nostdin", "-re",
|
||||||
|
"-rw_timeout", "30000000",
|
||||||
|
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||||
|
"-headers", douyin_headers,
|
||||||
|
"-i", real_url,
|
||||||
|
"-map", "0:a", "-f", "s16le", "-ac", str(DEMUCS_CHANNELS),
|
||||||
|
"-ar", str(DEMUCS_SAMPLE_RATE), "pipe:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
# FFmpeg 推流:音频 tcp 放第一(FFmpeg 按顺序打开输入,pipe:0 会阻塞直到有数据,
|
||||||
|
# 若 pipe 在前则永远连不上 tcp,导致 accept 超时)
|
||||||
|
vf = "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black"
|
||||||
|
ffmpeg_out_cmd = [
|
||||||
|
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
|
||||||
|
"-f", "s16le", "-ac", str(DEMUCS_CHANNELS), "-ar", str(DEMUCS_SAMPLE_RATE),
|
||||||
|
"-i", tcp_audio_url,
|
||||||
|
"-f", "nut", "-i", "pipe:0",
|
||||||
|
"-map", "1:v", "-map", "0:a",
|
||||||
|
"-vf", vf,
|
||||||
|
"-c:v", nvenc_params.get("codec_v", "libx264"),
|
||||||
|
"-preset", nvenc_params.get("preset_v", "fast"),
|
||||||
|
"-profile:v", "high",
|
||||||
|
"-b:v", f"{nvenc_params.get('video_bitrate_k', 4500)}k",
|
||||||
|
"-maxrate", f"{nvenc_params.get('video_bitrate_k', 4500)+500}k",
|
||||||
|
"-bufsize", f"{nvenc_params.get('video_bitrate_k', 4500)*2}k",
|
||||||
|
"-fps_mode", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
|
||||||
|
"-bf", "0",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||||||
|
"-af", "lowpass=f=9000,aresample=async=1:first_pts=0",
|
||||||
|
"-flags", "+global_header", "-flvflags", "no_duration_filesize",
|
||||||
|
"-f", "flv", youtube_rtmp,
|
||||||
|
]
|
||||||
|
# NVENC 用 tune_param;libx264 用 sc_threshold + x264-params
|
||||||
|
codec = nvenc_params.get("codec_v", "libx264")
|
||||||
|
if nvenc_params.get("tune_param") and codec == "h264_nvenc":
|
||||||
|
idx = ffmpeg_out_cmd.index("-profile:v")
|
||||||
|
for p in reversed(nvenc_params["tune_param"]):
|
||||||
|
ffmpeg_out_cmd.insert(idx, p)
|
||||||
|
elif codec == "libx264":
|
||||||
|
idx = ffmpeg_out_cmd.index("-bf")
|
||||||
|
ffmpeg_out_cmd.insert(idx + 2, "-sc_threshold")
|
||||||
|
ffmpeg_out_cmd.insert(idx + 3, "0")
|
||||||
|
idx = ffmpeg_out_cmd.index("-pix_fmt")
|
||||||
|
ffmpeg_out_cmd.insert(idx, "-x264-params")
|
||||||
|
ffmpeg_out_cmd.insert(idx + 1, "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0")
|
||||||
|
|
||||||
|
proc_v = None
|
||||||
|
proc_a = None
|
||||||
|
proc_out = None
|
||||||
|
demucs_thread = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc_v = subprocess.Popen(
|
||||||
|
ffmpeg_v_cmd,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=1024 * 1024,
|
||||||
|
)
|
||||||
|
proc_a = subprocess.Popen(
|
||||||
|
ffmpeg_a_cmd,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
bufsize=1024 * 1024,
|
||||||
|
)
|
||||||
|
proc_out = subprocess.Popen(
|
||||||
|
ffmpeg_out_cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=1024 * 1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 等待 TCP 连接(FFmpeg_out 会连接)
|
||||||
|
accept_thread.join(timeout=15)
|
||||||
|
if conn[0] is None:
|
||||||
|
raise RuntimeError("Demucs 管道: 音频 TCP 连接超时")
|
||||||
|
|
||||||
|
# 视频转发线程
|
||||||
|
def copy_video():
|
||||||
|
try:
|
||||||
|
while proc_v.poll() is None and proc_out.poll() is None:
|
||||||
|
chunk = proc_v.stdout.read(1024 * 256)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
proc_out.stdin.write(chunk)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc_out.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
copy_t = threading.Thread(target=copy_video, daemon=True)
|
||||||
|
copy_t.start()
|
||||||
|
|
||||||
|
# Demucs 工作线程(从 proc_a 读,处理后写 conn)
|
||||||
|
# proc_a 输出是 stdout,conn 是 socket。需要把 proc_a.stdout 的数据通过 socket 发到 FFmpeg。
|
||||||
|
# FFmpeg_out 的第二个输入是 tcp,已经连接了 conn。所以我们从 proc_a 读,Demucs 处理,写 conn。
|
||||||
|
# 但 proc_a.stdout 是 pipe,conn 是 socket。我们需要一个线程读 proc_a 并写 conn。
|
||||||
|
# 注意:proc_a 输出到 stdout,我们读。conn 是 FFmpeg 作为 client 连上来的,所以 FFmpeg 在读,我们写 conn。
|
||||||
|
demucs_thread = threading.Thread(
|
||||||
|
target=_run_demucs_worker,
|
||||||
|
args=(proc_a.stdout, conn[0], model, device, stop_event, vocal_mix_ratio, mix_source),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
demucs_thread.start()
|
||||||
|
|
||||||
|
# 监控 proc_v 的 stderr(与源连接,用于检测下播)
|
||||||
|
stderr_q = queue.Queue()
|
||||||
|
reader_t = threading.Thread(target=stderr_reader_fn, args=(proc_v, stderr_q))
|
||||||
|
reader_t.daemon = True
|
||||||
|
reader_t.start()
|
||||||
|
|
||||||
|
# 监控 proc_out 的 stderr(推流到 YouTube),解析状态用于详细日志
|
||||||
|
stream_stats = None
|
||||||
|
if _STREAM_STATS_AVAILABLE:
|
||||||
|
stream_stats = StreamStats(interval_sec=stats_interval_sec)
|
||||||
|
stream_stats.start_periodic_printer()
|
||||||
|
print(f"[INFO] 推流状态监控已启用,每 {stats_interval_sec}s 输出帧率/码率/网络等")
|
||||||
|
|
||||||
|
def _proc_out_stderr_reader():
|
||||||
|
try:
|
||||||
|
for line in iter(proc_out.stderr.readline, b""):
|
||||||
|
if line:
|
||||||
|
decoded = line.decode("utf-8", errors="replace").strip()
|
||||||
|
if decoded:
|
||||||
|
if stream_stats and parse_and_maybe_print(decoded, stream_stats, "[YouTube推流]", verbose=False):
|
||||||
|
pass # 已由 parse 处理,进度行不重复打印
|
||||||
|
else:
|
||||||
|
print(f"[YouTube推流] {decoded}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
proc_out_reader_t = threading.Thread(target=_proc_out_stderr_reader, daemon=True)
|
||||||
|
proc_out_reader_t.start()
|
||||||
|
|
||||||
|
start_time = last_frame_time = time.time()
|
||||||
|
proc_v.last_out_ts = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = stderr_q.get(timeout=stderr_queue_timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
# 若 proc_out(推流进程)已异常退出,立即报告
|
||||||
|
if proc_out.poll() is not None and proc_out.returncode != 0:
|
||||||
|
print(f"[ERROR] YouTube 推流进程已退出,返回码: {proc_out.returncode},可能是 Demucs 处理跟不上或网络问题")
|
||||||
|
print(f"[TIP] 若频繁断流,可尝试 config/youtube.ini 中设置 enable_demucs = 否 使用原始音频")
|
||||||
|
break
|
||||||
|
if time.time() - getattr(proc_v, "last_out_ts", time.time()) > stale_output_seconds:
|
||||||
|
print("[WARN] 长时间无任何日志,强制重启进程")
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line is None:
|
||||||
|
if proc_v.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
# proc_v 的 stderr 为 bytes,需解码为 str
|
||||||
|
if isinstance(line, bytes):
|
||||||
|
line = line.decode("utf-8", errors="replace")
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("__ERR__READER__"):
|
||||||
|
print(f"[ERROR] stderr reader 异常: {line}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line:
|
||||||
|
proc_v.last_out_ts = time.time()
|
||||||
|
if stream_stats:
|
||||||
|
stream_stats.parse_lag_event(line) # 源端抖动统计
|
||||||
|
print(f"[FFmpeg] {line}")
|
||||||
|
line_lower = line.lower()
|
||||||
|
if any(kw in line_lower for kw in end_keywords):
|
||||||
|
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||||
|
raise stream_ended_exception()
|
||||||
|
if "frame=" in line_lower or "fps=" in line_lower:
|
||||||
|
last_frame_time = time.time()
|
||||||
|
if time.time() - start_time > start_check_after:
|
||||||
|
if time.time() - last_frame_time > no_frame_timeout:
|
||||||
|
print(f"[WARN] 连续 {no_frame_timeout}s 无新帧,判定主播已下播")
|
||||||
|
raise stream_ended_exception()
|
||||||
|
|
||||||
|
except stream_ended_exception:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Demucs 管道异常: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
if stream_stats is not None:
|
||||||
|
stream_stats.stop()
|
||||||
|
if proc_out is not None and proc_out.poll() is not None and proc_out.returncode != 0:
|
||||||
|
print(f"[INFO] YouTube 推流进程退出码: {proc_out.returncode}")
|
||||||
|
cleanup_proc_fn(proc_v)
|
||||||
|
cleanup_proc_fn(proc_a)
|
||||||
|
cleanup_proc_fn(proc_out)
|
||||||
|
try:
|
||||||
|
if conn[0]:
|
||||||
|
conn[0].close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
server.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -11,6 +11,23 @@ import re
|
|||||||
import os
|
import os
|
||||||
import configparser
|
import configparser
|
||||||
|
|
||||||
|
# Demucs 管道(可选,用于防版权)
|
||||||
|
try:
|
||||||
|
from demucs_pipeline import (
|
||||||
|
_check_demucs_available,
|
||||||
|
run_demucs_pipeline,
|
||||||
|
)
|
||||||
|
_DEMUCS_MODULE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_DEMUCS_MODULE_AVAILABLE = False
|
||||||
|
_check_demucs_available = lambda: False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from stream_stats import StreamStats, parse_and_maybe_print
|
||||||
|
_STREAM_STATS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_STREAM_STATS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
class StreamEnded(Exception):
|
class StreamEnded(Exception):
|
||||||
"""主播真实下播的专用异常"""
|
"""主播真实下播的专用异常"""
|
||||||
@@ -85,19 +102,58 @@ def start_douyin_youtube_ffplay(
|
|||||||
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
|
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
|
||||||
youtube_rtmp = rtmp_base + default_key # 默认值
|
youtube_rtmp = rtmp_base + default_key # 默认值
|
||||||
|
|
||||||
|
enable_demucs = False
|
||||||
|
demucs_use_gpu = True
|
||||||
|
demucs_vocal_mix = 0.75 # 0.75=更自然少尖锐;1.0=纯人声
|
||||||
|
demucs_mix_source = "bass" # bass=无混响无重复;other=可能带混响
|
||||||
|
video_bitrate_mbps = 2.5 # YouTube 推荐 2.5,可设 4-4.5 追求更高画质
|
||||||
|
stats_interval_sec = 10 # 推流状态日志间隔
|
||||||
if os.path.exists(config_file):
|
if os.path.exists(config_file):
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
config.read(config_file, encoding="utf-8")
|
config.read(config_file, encoding="utf-8")
|
||||||
if config.has_section("youtube"):
|
if config.has_section("youtube"):
|
||||||
for key_name, value in config.items("youtube"):
|
for key_name, value in config.items("youtube"):
|
||||||
value = value.strip()
|
key_lower = key_name.strip().lower()
|
||||||
if value:
|
val = value.strip()
|
||||||
if value.startswith("rtmp://"):
|
if key_lower == "enable_demucs":
|
||||||
youtube_rtmp = value
|
enable_demucs = val.lower() in ("1", "true", "yes", "是")
|
||||||
else:
|
elif key_lower == "demucs_use_gpu":
|
||||||
youtube_rtmp = rtmp_base + value
|
demucs_use_gpu = val.lower() not in ("0", "false", "no", "否")
|
||||||
|
elif key_lower == "demucs_vocal_mix":
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
if 0.5 <= v <= 1.0:
|
||||||
|
demucs_vocal_mix = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif key_lower == "demucs_mix_source":
|
||||||
|
if val.lower() in ("bass", "other"):
|
||||||
|
demucs_mix_source = val.lower()
|
||||||
|
elif key_lower == "video_bitrate_mbps":
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
if 2.0 <= v <= 8.0:
|
||||||
|
video_bitrate_mbps = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif key_lower == "stats_interval_sec":
|
||||||
|
try:
|
||||||
|
v = int(val)
|
||||||
|
if 5 <= v <= 120:
|
||||||
|
stats_interval_sec = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif val and key_lower in ("key", "stream_key", "streamkey"):
|
||||||
|
youtube_rtmp = val if val.startswith("rtmp://") else rtmp_base + val
|
||||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||||
break # 只取第一个有效 key,保持单路推流(不加多路逻辑)
|
# 兼容:若上面未匹配到 key,取第一个非配置项的值作为 stream key
|
||||||
|
if youtube_rtmp == rtmp_base + default_key:
|
||||||
|
for k, v in config.items("youtube"):
|
||||||
|
if k.strip().lower() not in ("enable_demucs", "demucs_use_gpu", "demucs_vocal_mix", "demucs_mix_source", "video_bitrate_mbps", "stats_interval_sec") and v.strip():
|
||||||
|
v = v.strip()
|
||||||
|
youtube_rtmp = v if v.startswith("rtmp://") else rtmp_base + v
|
||||||
|
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||||
|
break
|
||||||
|
|
||||||
# ====================== 常量配置 ======================
|
# ====================== 常量配置 ======================
|
||||||
MAX_RETRY_DELAY = 90
|
MAX_RETRY_DELAY = 90
|
||||||
@@ -135,6 +191,7 @@ def start_douyin_youtube_ffplay(
|
|||||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||||
|
|
||||||
# ====================== NVENC 检测 ======================
|
# ====================== NVENC 检测 ======================
|
||||||
|
# FFmpeg NVENC 需驱动 570+,否则会报 "Driver does not support the required nvenc API version"
|
||||||
codec_v = "libx264"
|
codec_v = "libx264"
|
||||||
preset_v = "veryfast"
|
preset_v = "veryfast"
|
||||||
tune_param = ["-tune", "zerolatency"]
|
tune_param = ["-tune", "zerolatency"]
|
||||||
@@ -144,60 +201,41 @@ def start_douyin_youtube_ffplay(
|
|||||||
if check.returncode == 0:
|
if check.returncode == 0:
|
||||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||||
if "h264_nvenc" in (enc.stdout or ""):
|
if "h264_nvenc" in (enc.stdout or ""):
|
||||||
codec_v = "h264_nvenc"
|
# 检查驱动版本,NVENC API 13.0 需 570+
|
||||||
preset_v = "p5"
|
driver_ok = False
|
||||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
try:
|
||||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
ver_out = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if ver_out.returncode == 0 and ver_out.stdout:
|
||||||
|
ver_str = ver_out.stdout.strip().split("\n")[0].strip()
|
||||||
|
major = int(ver_str.split(".")[0]) if ver_str else 0
|
||||||
|
driver_ok = major >= 570
|
||||||
|
if not driver_ok:
|
||||||
|
print(f"[INFO] NVIDIA 驱动 {ver_str} 低于 570,使用软件编码 (libx264)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if driver_ok:
|
||||||
|
codec_v = "h264_nvenc"
|
||||||
|
preset_v = "p5"
|
||||||
|
br_k = int(video_bitrate_mbps * 1000)
|
||||||
|
tune_param = ["-tune", "ll", "-rc", "vbr", "-b:v", f"{br_k}k", "-maxrate", f"{br_k+500}k", "-bufsize", f"{br_k*2}k"]
|
||||||
|
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||||
|
|
||||||
|
br_k = int(video_bitrate_mbps * 1000)
|
||||||
|
nvenc_params = {
|
||||||
|
"codec_v": codec_v,
|
||||||
|
"preset_v": preset_v,
|
||||||
|
"tune_param": tune_param,
|
||||||
|
"demucs_use_gpu": demucs_use_gpu,
|
||||||
|
"video_bitrate_k": br_k,
|
||||||
|
}
|
||||||
|
use_demucs = enable_demucs and _DEMUCS_MODULE_AVAILABLE and _check_demucs_available()
|
||||||
|
|
||||||
# 720p (最原始)
|
# 720p (最原始)
|
||||||
# ffmpeg_command = [
|
|
||||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
|
||||||
# "-stats", "-stats_period", "1",
|
|
||||||
# "-rw_timeout", "30000000",
|
|
||||||
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
|
||||||
# "-reconnect_delay_max", "5",
|
|
||||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
|
||||||
# "-flags", "low_delay",
|
|
||||||
# "-err_detect", "ignore_err",
|
|
||||||
# "-max_delay", "80000",
|
|
||||||
# "-thread_queue_size", "2048",
|
|
||||||
|
|
||||||
# "-headers", douyin_headers,
|
|
||||||
# "-i", real_url,
|
|
||||||
# # 720p
|
|
||||||
# "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
|
||||||
# "-c:v", "libx264",
|
|
||||||
# "-preset", "fast",
|
|
||||||
# # "-tune", "zerolatency",
|
|
||||||
# "-profile:v", "high",
|
|
||||||
# # 720p
|
|
||||||
# # "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate,保持真 CBR
|
|
||||||
# "-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
|
|
||||||
# "-vsync", "cfr",
|
|
||||||
# "-g", "60", "-keyint_min", "60",
|
|
||||||
# "-r", "30",
|
|
||||||
# "-bf", "0",
|
|
||||||
# "-sc_threshold", "0",
|
|
||||||
# # "-nal-hrd", "cbr", # 改2:强制 CBR
|
|
||||||
# # 720p
|
|
||||||
# # "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3:简化 params,锁死 CBR
|
|
||||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
|
||||||
# "-pix_fmt", "yuv420p",
|
|
||||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改4:1080p可音频上 128k(YouTube 推荐)
|
|
||||||
# "-af", "aresample=async=1:first_pts=0",
|
|
||||||
|
|
||||||
# "-flags", "+global_header",
|
|
||||||
# "-flvflags", "no_duration_filesize",
|
|
||||||
# "-f", "flv", youtube_rtmp
|
|
||||||
# ]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
|
|
||||||
# 720p-低CPU + 背景音乐处理 + 防ContentID
|
|
||||||
ffmpeg_command = [
|
ffmpeg_command = [
|
||||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||||
"-stats", "-stats_period", "1",
|
"-stats", "-stats_period", "1",
|
||||||
@@ -212,43 +250,88 @@ def start_douyin_youtube_ffplay(
|
|||||||
|
|
||||||
"-headers", douyin_headers,
|
"-headers", douyin_headers,
|
||||||
"-i", real_url,
|
"-i", real_url,
|
||||||
|
# 720p
|
||||||
# 视频处理 720p
|
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||||
"-vf",
|
|
||||||
"fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
|
|
||||||
"pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
|
||||||
|
|
||||||
"-c:v", "libx264",
|
"-c:v", "libx264",
|
||||||
"-preset", "veryfast", # 降低CPU
|
"-preset", "fast",
|
||||||
|
# "-tune", "zerolatency",
|
||||||
"-profile:v", "high",
|
"-profile:v", "high",
|
||||||
"-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
|
# 720p,画质优先,YouTube 推荐 3-8 Mbps
|
||||||
"-vsync", "cfr",
|
"-b:v", f"{br_k}k", "-maxrate", f"{br_k+500}k", "-bufsize", f"{br_k*2}k",
|
||||||
"-g", "50", "-keyint_min", "50", # 与fps=25匹配
|
"-fps_mode", "cfr",
|
||||||
"-r", "25",
|
"-g", "60", "-keyint_min", "60",
|
||||||
|
"-r", "30",
|
||||||
"-bf", "0",
|
"-bf", "0",
|
||||||
"-sc_threshold", "0",
|
"-sc_threshold", "0",
|
||||||
|
# "-nal-hrd", "cbr", # 改2:强制 CBR
|
||||||
|
# 720p
|
||||||
|
# "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3:简化 params,锁死 CBR
|
||||||
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||||
"-pix_fmt", "yuv420p",
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改4:1080p可音频上 128k(YouTube 推荐)
|
||||||
|
"-af", "aresample=async=1:first_pts=0",
|
||||||
|
|
||||||
# 音频处理(轻量 + 背景音乐弱化 + 防Content ID)
|
"-flags", "+global_header",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
|
||||||
"-af",
|
|
||||||
"afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
|
|
||||||
"highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
|
|
||||||
"equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
|
|
||||||
"equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
|
|
||||||
"acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
|
|
||||||
"asetrate=48000*1.02,aresample=48000," # 音高微升
|
|
||||||
"afreqshift=shift=15," # 小幅频率偏移
|
|
||||||
"volume=1.1," # 音量补偿
|
|
||||||
"aresample=async=1:first_pts=0", # 音视频同步
|
|
||||||
|
|
||||||
# 推流参数
|
|
||||||
"-flvflags", "no_duration_filesize",
|
"-flvflags", "no_duration_filesize",
|
||||||
"-f", "flv", youtube_rtmp
|
"-f", "flv", youtube_rtmp
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
|
||||||
|
# 720p-低CPU + 背景音乐处理 + 防ContentID
|
||||||
|
# ffmpeg_command = [
|
||||||
|
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||||
|
# "-stats", "-stats_period", "1",
|
||||||
|
# "-rw_timeout", "30000000",
|
||||||
|
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||||
|
# "-reconnect_delay_max", "5",
|
||||||
|
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||||
|
# "-flags", "low_delay",
|
||||||
|
# "-err_detect", "ignore_err",
|
||||||
|
# "-max_delay", "80000",
|
||||||
|
# "-thread_queue_size", "2048",
|
||||||
|
|
||||||
|
# "-headers", douyin_headers,
|
||||||
|
# "-i", real_url,
|
||||||
|
|
||||||
|
# # 视频处理 720p
|
||||||
|
# "-vf",
|
||||||
|
# "fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||||
|
# "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||||
|
|
||||||
|
# "-c:v", "libx264",
|
||||||
|
# "-preset", "veryfast", # 降低CPU
|
||||||
|
# "-profile:v", "high",
|
||||||
|
# "-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
|
||||||
|
# "-vsync", "cfr",
|
||||||
|
# "-g", "50", "-keyint_min", "50", # 与fps=25匹配
|
||||||
|
# "-r", "25",
|
||||||
|
# "-bf", "0",
|
||||||
|
# "-sc_threshold", "0",
|
||||||
|
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||||
|
# "-pix_fmt", "yuv420p",
|
||||||
|
|
||||||
|
# # 音频处理(轻量 + 背景音乐弱化 + 防Content ID)
|
||||||
|
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||||||
|
# "-af",
|
||||||
|
# "afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
|
||||||
|
# "highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
|
||||||
|
# "equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
|
||||||
|
# "equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
|
||||||
|
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
|
||||||
|
# "asetrate=48000*1.02,aresample=48000," # 音高微升
|
||||||
|
# "afreqshift=shift=15," # 小幅频率偏移
|
||||||
|
# "volume=1.1," # 音量补偿
|
||||||
|
# "aresample=async=1:first_pts=0", # 音视频同步
|
||||||
|
|
||||||
|
# # 推流参数
|
||||||
|
# "-flvflags", "no_duration_filesize",
|
||||||
|
# "-f", "flv", youtube_rtmp
|
||||||
|
# ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -291,7 +374,6 @@ def start_douyin_youtube_ffplay(
|
|||||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
# "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||||
|
|
||||||
# "-af",
|
# "-af",
|
||||||
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.80,"
|
|
||||||
# "afftdn=nf=-18:tn=1,"
|
# "afftdn=nf=-18:tn=1,"
|
||||||
# "highpass=f=120,lowpass=f=4600,"
|
# "highpass=f=120,lowpass=f=4600,"
|
||||||
# "equalizer=f=250:width_type=o:width=2:g=-6,"
|
# "equalizer=f=250:width_type=o:width=2:g=-6,"
|
||||||
@@ -306,8 +388,120 @@ def start_douyin_youtube_ffplay(
|
|||||||
# ]
|
# ]
|
||||||
|
|
||||||
|
|
||||||
|
# 1080p - 极致优化版本(Ubuntu Server 专用,高质量 + YouTube直播推荐友好 + 强防Content ID)
|
||||||
|
# 针对Ubuntu server(通常CPU较强,无iOS硬件限制):
|
||||||
|
# - preset 升级到 medium 或 slow(如果CPU能承受,质量显著提升)
|
||||||
|
# - 比特率保持8500k-10000k峰值,符合YouTube 1080p直播高质量推荐
|
||||||
|
# - 音频防Content ID:音高+5%、频率偏移、多段EQ衰减背景音乐、强压缩、相位扰动(使用aphaser正确参数)、轻噪门
|
||||||
|
# - 移除无效参数,修复之前aphaser错误(aphaser支持in_gain/out_gain/delay/decay/speed/type,不支持feedback)
|
||||||
|
# - Ubuntu下推荐使用最新FFmpeg(apt install ffmpeg 或从源码编译支持libx264)
|
||||||
|
|
||||||
|
# ffmpeg_command = [
|
||||||
|
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||||
|
# "-stats", "-stats_period", "1",
|
||||||
|
# "-rw_timeout", "30000000",
|
||||||
|
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||||
|
# "-reconnect_delay_max", "5",
|
||||||
|
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||||
|
# "-flags", "low_delay",
|
||||||
|
# "-err_detect", "ignore_err",
|
||||||
|
# "-max_delay", "50000",
|
||||||
|
# "-thread_queue_size", "4096",
|
||||||
|
|
||||||
|
# "-headers", douyin_headers,
|
||||||
|
# "-i", real_url,
|
||||||
|
|
||||||
|
# # 视频处理:1080p 纵屏优化,黑边填充
|
||||||
|
# "-vf",
|
||||||
|
# "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||||
|
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||||
|
|
||||||
|
# "-c:v", "libx264",
|
||||||
|
# "-preset", "ultrafast", # Ubuntu server CPU通常能承受,质量比faster好很多;若掉帧可回退到faster或veryfast
|
||||||
|
# "-threads", "4",
|
||||||
|
# "-tune", "zerolatency",
|
||||||
|
# "-profile:v", "high",
|
||||||
|
# "-level", "4.2", # 支持1080p@25fps
|
||||||
|
# # "-b:v", "8500k", "-maxrate", "10000k", "-bufsize", "20000k",
|
||||||
|
# "-b:v", "4500k", "-maxrate", "5500k", "-bufsize", "9000k",
|
||||||
|
# "-vsync", "cfr",
|
||||||
|
# "-g", "50", "-keyint_min", "50",
|
||||||
|
# "-r", "25",
|
||||||
|
# "-bf", "0",
|
||||||
|
# "-sc_threshold", "0",
|
||||||
|
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0:rc-lookahead=0",
|
||||||
|
# "-pix_fmt", "yuv420p",
|
||||||
|
|
||||||
|
# # 音频处理 - 防Content ID 强化版(多维度修改音频指纹)
|
||||||
|
# "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
|
||||||
|
# "-af",
|
||||||
|
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," #神经网络
|
||||||
|
# "afftdn=nf=-24:tn=1," # 中等降噪,保留人声清晰度
|
||||||
|
# "highpass=f=100,lowpass=f=5200," # 切除低频鼓/贝斯 & 高频镲/噪音,聚焦人声区
|
||||||
|
# "equalizer=f=180:width_type=o:width=2.2:g=-12," # 强衰减低频(背景音乐基础层)
|
||||||
|
# "equalizer=f=350:width_type=o:width=1.8:g=-9," # 衰减低中频(常见旋律区)
|
||||||
|
# "equalizer=f=750:width_type=o:width=1.6:g=-7," # 中频衰减,破坏音乐结构
|
||||||
|
# "equalizer=f=2800:width_type=o:width=1.2:g=-5," # 高中频轻衰减,保持人声自然
|
||||||
|
# "acompressor=threshold=-26dB:ratio=5.5:attack=7:release=90:makeup=6," # 强压缩,改变动态指纹
|
||||||
|
# "asetrate=48000*1.05,atempo=1.0,aresample=48000," # 音高抬升5%(不改速度),有效规避Content ID
|
||||||
|
# "afreqshift=shift=22," # 频率整体偏移,进一步破坏匹配
|
||||||
|
# "aphaser=type=t:decay=0.35:delay=3.0:speed=0.5," # 正确参数:轻微相位扰动(triangular类型),防检测
|
||||||
|
# # "anlmdn=s=0.00012:p=0.0008:r=0.002," # 轻量非线性噪门,清理残余背景音乐
|
||||||
|
# "volume=1.08," # 补偿音量衰减
|
||||||
|
# "aresample=async=1:first_pts=0:min_hard_comp=0.100000:first_pts=0", # 强制音视频同步
|
||||||
|
|
||||||
|
# # 推流优化(FLV适合YouTube RTMP/RTMPS)
|
||||||
|
# "-flvflags", "no_duration_filesize",
|
||||||
|
# "-f", "flv", youtube_rtmp
|
||||||
|
# ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ffmpeg_command = [
|
||||||
|
# "ffmpeg","-y","-loglevel","info","-nostdin","-re",
|
||||||
|
# "-stats","-stats_period","1",
|
||||||
|
|
||||||
|
# # 输入稳定
|
||||||
|
# "-rw_timeout","30000000",
|
||||||
|
# "-reconnect","1","-reconnect_at_eof","1","-reconnect_streamed","1",
|
||||||
|
# "-reconnect_delay_max","5",
|
||||||
|
|
||||||
|
# # 关键:删除低延迟杀手参数
|
||||||
|
# "-fflags","+genpts+discardcorrupt",
|
||||||
|
# "-err_detect","ignore_err",
|
||||||
|
# "-max_delay","500000",
|
||||||
|
# "-thread_queue_size","512",
|
||||||
|
|
||||||
|
# # 抖音源
|
||||||
|
# "-headers", douyin_headers,
|
||||||
|
# "-i", real_url,
|
||||||
|
|
||||||
|
# # 视频处理
|
||||||
|
# "-vf","fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||||
|
|
||||||
|
# # 编码
|
||||||
|
# "-c:v","libx264",
|
||||||
|
# "-preset","veryfast",
|
||||||
|
# "-profile:v","high",
|
||||||
|
# "-level","4.2",
|
||||||
|
# "-b:v","4500k","-maxrate","4500k","-bufsize","9000k",
|
||||||
|
# "-g","50","-keyint_min","50",
|
||||||
|
# "-r","25",
|
||||||
|
# "-pix_fmt","yuv420p",
|
||||||
|
# "-bf","0",
|
||||||
|
# "-sc_threshold","0",
|
||||||
|
# "-x264-params","force-cfr=1:scenecut=0:open_gop=0",
|
||||||
|
|
||||||
|
# # 音频(强烈建议简化)
|
||||||
|
# "-c:a","aac","-b:a","128k","-ar","44100","-ac","2",
|
||||||
|
# "-af","aresample=async=1,volume=1.05",
|
||||||
|
|
||||||
|
# # 推流
|
||||||
|
# "-flvflags","no_duration_filesize",
|
||||||
|
# "-f","flv", youtube_rtmp
|
||||||
|
# ]
|
||||||
|
|
||||||
|
|
||||||
# ====================== 主重试循环(原样保留) ======================
|
|
||||||
proc = None
|
proc = None
|
||||||
stderr_q = None
|
stderr_q = None
|
||||||
reader_t = None
|
reader_t = None
|
||||||
@@ -324,22 +518,55 @@ def start_douyin_youtube_ffplay(
|
|||||||
stderr_q = queue.Queue()
|
stderr_q = queue.Queue()
|
||||||
|
|
||||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||||
proc = subprocess.Popen(
|
|
||||||
ffmpeg_command,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
universal_newlines=True
|
|
||||||
)
|
|
||||||
proc.last_out_ts = time.time()
|
|
||||||
|
|
||||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
proc = None
|
||||||
reader_t.daemon = True
|
if use_demucs:
|
||||||
reader_t.start()
|
try:
|
||||||
|
run_demucs_pipeline(
|
||||||
|
real_url=real_url,
|
||||||
|
youtube_rtmp=youtube_rtmp,
|
||||||
|
douyin_headers=douyin_headers,
|
||||||
|
nvenc_params=nvenc_params,
|
||||||
|
end_keywords=END_KEYWORDS,
|
||||||
|
stderr_queue_timeout=STDERR_QUEUE_TIMEOUT,
|
||||||
|
no_frame_timeout=NO_FRAME_TIMEOUT,
|
||||||
|
start_check_after=START_CHECK_AFTER,
|
||||||
|
stale_output_seconds=STALE_OUTPUT_SECONDS,
|
||||||
|
stream_ended_exception=StreamEnded,
|
||||||
|
cleanup_proc_fn=cleanup_proc,
|
||||||
|
stderr_reader_fn=_stderr_reader_thread,
|
||||||
|
vocal_mix_ratio=demucs_vocal_mix,
|
||||||
|
mix_source=demucs_mix_source,
|
||||||
|
stats_interval_sec=stats_interval_sec,
|
||||||
|
)
|
||||||
|
except StreamEnded:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Demucs 管道异常,回退原始推流: {e}")
|
||||||
|
use_demucs = False
|
||||||
|
|
||||||
|
if not use_demucs:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
ffmpeg_command,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
universal_newlines=True
|
||||||
|
)
|
||||||
|
proc.last_out_ts = time.time()
|
||||||
|
|
||||||
|
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||||
|
reader_t.daemon = True
|
||||||
|
reader_t.start()
|
||||||
|
|
||||||
start_time = last_frame_time = time.time()
|
start_time = last_frame_time = time.time()
|
||||||
|
stream_stats = None
|
||||||
|
if _STREAM_STATS_AVAILABLE:
|
||||||
|
stream_stats = StreamStats(interval_sec=stats_interval_sec)
|
||||||
|
stream_stats.start_periodic_printer()
|
||||||
|
print(f"[INFO] 推流状态监控已启用,每 {stats_interval_sec}s 输出帧率/码率/网络等")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -357,15 +584,22 @@ def start_douyin_youtube_ffplay(
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
line = line.strip()
|
line_str = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
|
||||||
if line.startswith("__ERR__READER__"):
|
if line_str.startswith("__ERR__READER__"):
|
||||||
print(f"[ERROR] stderr reader 异常: {line}")
|
print(f"[ERROR] stderr reader 异常: {line_str}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line:
|
if line_str:
|
||||||
proc.last_out_ts = time.time()
|
proc.last_out_ts = time.time()
|
||||||
print(f"[FFmpeg] {line}")
|
if stream_stats:
|
||||||
line_lower = line.lower()
|
stream_stats.parse_lag_event(line_str)
|
||||||
|
if stream_stats.parse_ffmpeg_progress(line_str):
|
||||||
|
pass # 进度行由周期摘要代替,不重复打印
|
||||||
|
else:
|
||||||
|
print(f"[FFmpeg] {line_str}")
|
||||||
|
else:
|
||||||
|
print(f"[FFmpeg] {line_str}")
|
||||||
|
line_lower = line_str.lower()
|
||||||
|
|
||||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||||
@@ -389,6 +623,9 @@ def start_douyin_youtube_ffplay(
|
|||||||
cleanup_proc(proc)
|
cleanup_proc(proc)
|
||||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||||
break
|
break
|
||||||
|
finally:
|
||||||
|
if stream_stats is not None:
|
||||||
|
stream_stats.stop()
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
14
index.html.1
Normal file
14
index.html.1
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for." name="description"><meta content="noodp, " name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"><meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image"><title>Google</title><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var _g={kEI:'gbObaeXAG-Gy5NoPmeGiyAg',kEXPI:'0,1304203,2935842,14111,64702,6398,354503,5532962,16,36811390,25228681,123988,28395,11943,53220,39775,61250,14067,23259,37800,28333,79315,7707,7,33385,4719,24374,1,2884,3086,16757,46340,9920,10754,11886,6436,4952,4791,2646,6292,12404,5550,14505,1383,1,2,1515,3355,196,5814,2,4205,1,16100,2,873,2,22,2208,5108,3008,24,771,4074,4,5393,18686,8466,5337,310,535,845,1201,2,11,2,1560,3,5189,1,13,738,1009,2,1746,4,4878,5773,4542,4,2243,240,297,1,1117,389,4,2647,5,2698,2870,298,9253,1275,7,1784,1050,2,1914,5,10456,2,5724,9,602,59,36,1554,4,2570,3341,932,3066,1,2,1402,3,1117,242,2,2356,2576,49,1,8,216,441,158,2,1645,191,4,1928,4,1634,544,4,1568,1032,1,1399,187,259,1846,5,259,5,197,1921,445,19,1421,119,4,2364,2363,5,39,633,1937,4,57,4,3784,4,76,1970,4,265,106,5,3,971,4,966,5,1125,1,2,2,815,3,2932,4,32,4,130,717,1856,4,40,4,2110,4,134,304,323,1,576,1776,1864,42,5,2,479,704,4,14,3,1,449,4,730,5,75,4,1918,1,21,4,465,429,537,1563,1,1090,2,265,702,4905,1788,14,507,823,747,4,331,4,959,452,57,258,615,80,7,2245,4,281,264,341,221,1,2976,508,339,3430,503,3,1412,1354,4,12,33,2093,4,945,16,4,612,1,20998529,1717,7635,5,2992,4,2960,3,8491,2,1562,3,2687,3,9181,536,2,2461,1194,3,746,6,918,231,3,1369,1494,2715,1,6494629,4214,2,3639,565,745,38,517,541,84,108428,25,36,10,3516226,209655,562,143,12493584,256605,48968,4054456,476,441,42,5,116,590,5,1023,1671,844,1,148,3,6,163,101,343,4,65,388,2,707,260,44,4,152,4,488,62,219,3,2,2,2,550,4,885,1050,2,50,641,5,429,6,2800,5,138,1,863,130,45,2,11,2,16,361,218,1148,1,2,642,214,1485,417,712,2695,1043,281,5,330,1255,292,114,289,54,161,5,142,4,587,412,322,525,1,361,503,213,249,99,472,165,7,51,90,174,436,472,3,289,379,458,583,331,601,587,556,100,949,821,118,4,177,37,7,12,3,6,25,1006,3,2,2,2,67,134,727,4,283,321,70,212,125,4,4,3,2,1,2,3,2,2,1,2,2,2,3,1,3,2,1,3,2,1,3,1,2,3,1,2,2,2,3,2,1,3,1,2,3,2,2,2,2,1,3,1,3,2,1,2,10,2,2,3,2,2,10,1,2,3,1,2,10,2,3,2,1,3,9,2,3,1,3,2,1,2,2,2,3,1,2,3,2,2,1,3,1,3,2,1,2,2,2,2,2,3,2,2,1,2,3,2,1,2,2,2,3,1,3,1,3,1,2,2,10,2,3,1,3,1,11,2,2,1,2,2,10,2,3,2,2,1,183,94,60,120,149,39,317,3,2,2,2,744,296,177,5,22,414,377,24,216,1579,8,293,133,136,134,83,118,2,3,2,2,2,956,1,2,785,3,2,2,2,348,295,4,106,229,180,5,62,2,878,573,260,185,936,640,1027,638,713,94,18,3,2,2,2,4,308,593,3,2,2,2,720,519,6,1,4357,427,3,1,370,435,364,71,61,442,901,4',kBL:'eaQ3',kOPI:89978449};(function(){var a;((a=window.google)==null?0:a.stvsc)?google.kEI=_g.kEI:window.google=_g;}).call(this);})();(function(){google.sn='webhp';google.kHL='en';google.rdn=true;})();(function(){
|
||||||
|
var g=this||self;function k(){return window.google&&window.google.kOPI||null};var l,m=[];function n(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||l}function p(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b}function q(a){/^http:/i.test(a)&&window.location.protocol==="https:"&&(google.ml&&google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a}
|
||||||
|
function r(a,b,d,c,h){var e="";b.search("&ei=")===-1&&(e="&ei="+n(c),b.search("&lei=")===-1&&(c=p(c))&&(e+="&lei="+c));var f=b.search("&cshid=")===-1&&a!=="slh";c="&zx="+Date.now().toString();g._cshid&&f&&(c+="&cshid="+g._cshid);(d=d())&&(c+="&opi="+d);return"/"+(h||"gen_204")+"?atyp=i&ct="+String(a)+"&cad="+(b+e+c)};l=google.kEI;google.getEI=n;google.getLEI=p;google.ml=function(){return null};google.log=function(a,b,d,c,h,e){e=e===void 0?k:e;d||(d=r(a,b,e,c,h));if(d=q(d)){a=new Image;var f=m.length;m[f]=a;a.onerror=a.onload=a.onabort=function(){delete m[f]};a.src=d}};google.logUrl=function(a,b){b=b===void 0?k:b;return r("",a,b)};}).call(this);(function(){google.y={};google.sy={};function e(a,b,d){if(a)var c=a.id;else{do c=Math.random();while(d[c])}d[c]=[a,b]}var f;(f=google).x||(f.x=function(a,b){e(a,b,google.y)});var g;(g=google).sx||(g.sx=function(a,b){e(a,b,google.sy)});google.bx=!1;var h;(h=google).lx||(h.lx=function(){});var k=[],l;(l=google).fce||(l.fce=function(a,b,d,c){k.push([a,b,d,c])});google.qce=k;google.adl=[];}).call(this);google.f={};(function(){
|
||||||
|
document.documentElement.addEventListener("submit",function(b){var a;if(a=b.target){var c=a.getAttribute("data-submitfalse");a=c==="1"||c==="q"&&!a.elements.q.value?!0:!1}else a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener("click",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(a.tagName==="A"){a=a.getAttribute("data-nohref")==="1";break a}a=!1}a&&b.preventDefault()},!0);}).call(this);</script><style>#gbar,#guser{font-size:13px;padding-top:1px !important;}#gbar{height:22px}#guser{padding-bottom:7px !important;text-align:right}.gbh,.gbd{border-top:1px solid #c9d7f1;font-size:1px}.gbh{height:0;position:absolute;top:24px;width:100%}@media all{.gb1{height:22px;margin-right:.5em;vertical-align:top}#gbar{float:left}}a.gb1,a.gb4{text-decoration:underline !important}a.gb1,a.gb4{color:#00c !important}.gbi .gb4{color:#dd8e27 !important}.gbf .gb4{color:#900 !important}
|
||||||
|
</style><style>body,td,a,p,.h{font-family:sans-serif}body{margin:0;overflow-y:scroll}#gog{padding:3px 8px 0}td{line-height:.8em}.gac_m td{line-height:17px}form{margin-bottom:20px}.h{color:#1967d2}em{font-weight:bold;font-style:normal}.lst{height:25px;width:496px}.gsfi,.lst{font:18px sans-serif}.gsfs{font:17px sans-serif}.ds{display:inline-box;display:inline-block;margin:3px 0 4px;margin-left:4px}input{font-family:inherit}body{background:#fff;color:#1f1f1f}a{color:#681da8;text-decoration:none}a:hover,a:active{text-decoration:underline}.fl a{color:#1967d2}a:visited{color:#681da8}.sblc{padding-top:5px}.sblc a{display:block;margin:2px 0;margin-left:13px;font-size:11px}.lsbb{background:#ecedee;border:solid 1px;border-color:#d2d2d2 #70757a #70757a #d2d2d2;height:30px}.lsbb{display:block}#WqQANb a{display:inline-block;margin:0 12px}.lsb{background:url(/images/nav_logo229.png) 0 -261px repeat-x;color:#1f1f1f;border:none;cursor:pointer;height:30px;margin:0;outline:0;font:15px sans-serif;vertical-align:top}.lsb:active{background:#dadce0}.lst:focus{outline:none}</style><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.erd={jsr:1,bv:2383,de:true,dpf:'UAON6G49novXd1LJI--iGd6VfzOOuK54njDRAqwNAgA'};
|
||||||
|
var g=this||self;var k,l=(k=g.mei)!=null?k:1,m,p=(m=g.diel)!=null?m:0,q,r=(q=g.sdo)!=null?q:!0,t=0,u,w=google.erd,x=w.jsr;google.ml=function(a,b,d,n,e){e=e===void 0?2:e;b&&(u=a&&a.message);d===void 0&&(d={});d.cad="ple_"+google.ple+".aple_"+google.aple;if(google.dl)return google.dl(a,e,d,!0),null;b=d;if(x<0){window.console&&console.error(a,b);if(x===-2)throw a;b=!1}else b=!a||!a.message||a.message==="Error loading script"||t>=l&&!n?!1:!0;if(!b)return null;t++;d=d||{};b=encodeURIComponent;var c="/gen_204?atyp=i&ei="+b(google.kEI);google.kEXPI&&(c+="&jexpid="+b(google.kEXPI));c+="&srcpg="+b(google.sn)+"&jsr="+b(w.jsr)+
|
||||||
|
"&bver="+b(w.bv);w.dpf&&(c+="&dpf="+b(w.dpf));var f=a.lineNumber;f!==void 0&&(c+="&line="+f);var h=a.fileName;h&&(h.indexOf("-extension:/")>0&&(e=3),c+="&script="+b(h),f&&h===window.location.href&&(f=document.documentElement.outerHTML.split("\n")[f],c+="&cad="+b(f?f.substring(0,300):"No script found.")));google.ple&&google.ple===1&&(e=2);c+="&jsel="+e;for(var v in d)c+="&",c+=b(v),c+="=",c+=b(d[v]);c=c+"&emsg="+b(a.name+": "+a.message);c=c+"&jsst="+b(a.stack||"N/A");c.length>=12288&&(c=c.substring(0,12288));a=c;n||google.log(0,"",a);return a};window.onerror=function(a,b,d,n,e){u!==a&&(a=e instanceof Error?e:Error(a),d===void 0||"lineNumber"in a||(a.lineNumber=d),b===void 0||"fileName"in a||(a.fileName=b),google.ml(a,!1,void 0,!1,a.name==="SyntaxError"||a.message.substring(0,11)==="SyntaxError"||a.message.indexOf("Script error")!==-1?3:p));u=null;r&&t>=l&&(window.onerror=null)};})();</script></head><body bgcolor="#fff"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var src='/images/nav_logo229.png';var iesg=false;document.body.onload = function(){window.n && window.n();if (document.images){new Image().src=src;}
|
||||||
|
if (!iesg){document.f&&document.f.q.focus();document.gbqf&&document.gbqf.q.focus();}
|
||||||
|
}
|
||||||
|
})();</script><div id="mngb"><div id=gbar><nobr><b class=gb1>Search</b> <a class=gb1 href="https://www.google.com/imghp?hl=en&tab=wi">Images</a> <a class=gb1 href="http://maps.google.com/maps?hl=en&tab=wl">Maps</a> <a class=gb1 href="https://play.google.com/?hl=en&tab=w8">Play</a> <a class=gb1 href="https://www.youtube.com/?tab=w1">YouTube</a> <a class=gb1 href="https://news.google.com/?tab=wn">News</a> <a class=gb1 href="https://mail.google.com/mail/?tab=wm">Gmail</a> <a class=gb1 href="https://drive.google.com/?tab=wo">Drive</a> <a class=gb1 style="text-decoration:none" href="https://www.google.com/intl/en/about/products?tab=wh"><u>More</u> »</a></nobr></div><div id=guser width=100%><nobr><span id=gbn class=gbi></span><span id=gbf class=gbf></span><span id=gbe></span><a href="http://www.google.com/history/optout?hl=en" class=gb4>Web History</a> | <a href="/preferences?hl=en" class=gb4>Settings</a> | <a target=_top id=gb_70 href="https://accounts.google.com/ServiceLogin?hl=en&passive=true&continue=http://www.google.com/&ec=GAZAAQ" class=gb4>Sign in</a></nobr></div><div class=gbh style=left:0></div><div class=gbh style=right:0></div></div><center><br clear="all" id="lgpd"><div><img alt="Google" height="92" src="/images/branding/googlelogo/1x/googlelogo_white_background_color_272x92dp.png" style="padding:28px 0 14px" width="272" id="hplogo"><br><br></div><form action="/search" name="f"><table cellpadding="0" cellspacing="0"><tr valign="top"><td width="25%"> </td><td align="center" nowrap=""><input name="ie" value="ISO-8859-1" type="hidden"><input value="en" name="hl" type="hidden"><input name="source" type="hidden" value="hp"><input name="biw" type="hidden"><input name="bih" type="hidden"><div class="ds" style="height:32px;margin:4px 0"><input class="lst" style="margin:0;padding:5px 8px 0 6px;vertical-align:top;color:#1f1f1f" autocomplete="off" value="" title="Google Search" maxlength="2048" name="q" size="57"></div><br style="line-height:0"><span class="ds"><span class="lsbb"><input class="lsb" value="Google Search" name="btnG" type="submit"></span></span><span class="ds"><span class="lsbb"><input class="lsb" id="tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1" value="I'm Feeling Lucky" name="btnI" type="submit"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var id='tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1';document.getElementById(id).onclick = function(){if (this.form.q.value){this.checked = 1;if (this.form.iflsig)this.form.iflsig.disabled = false;}
|
||||||
|
else top.location='/doodles/';};})();</script><input value="AFdpzrgAAAAAaZvBkZ6x6tP9yqY4FfFqM-NYRs9cR3zt" name="iflsig" type="hidden"></span></span></td><td class="fl sblc" align="left" nowrap="" width="25%"><a href="/advanced_search?hl=en&authuser=0">Advanced search</a></td></tr></table><input id="gbv" name="gbv" type="hidden" value="1"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var a,b="1";if(document&&document.getElementById)if(typeof XMLHttpRequest!="undefined")b="2";else if(typeof ActiveXObject!="undefined"){var c,d,e=["MSXML2.XMLHTTP.6.0","MSXML2.XMLHTTP.3.0","MSXML2.XMLHTTP","Microsoft.XMLHTTP"];for(c=0;d=e[c++];)try{new ActiveXObject(d),b="2"}catch(h){}}a=b;if(a=="2"&&location.search.indexOf("&gbv=2")==-1){var f=google.gbvu,g=document.getElementById("gbv");g&&(g.value=a);f&&window.setTimeout(function(){location.href=f},0)};}).call(this);</script></form><div style="font-size:83%;min-height:3.5em"><br></div><span id="footer"><div style="font-size:10pt"><div style="margin:19px auto;text-align:center" id="WqQANb"><a href="/intl/en/ads/">Advertising</a><a href="/services/">Business Solutions</a><a href="/intl/en/about.html">About Google</a></div></div><p style="font-size:8pt;color:#636363">© 2026 - <a href="/intl/en/policies/privacy/">Privacy</a> - <a href="/intl/en/policies/terms/">Terms</a></p></span></center><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.cdo={height:757,width:1440};(function(){var a=window.innerWidth,b=window.innerHeight;if(!a||!b){var c=window.document,d=c.compatMode=="CSS1Compat"?c.documentElement:c.body;a=d.clientWidth;b=d.clientHeight}if(a&&b&&(a!=google.cdo.width||b!=google.cdo.height)){var e=google,f=e.log,g="/client_204?&atyp=i&biw="+a+"&bih="+b+"&ei="+google.kEI,h="",k=window.google&&window.google.kOPI||null;k&&(h+="&opi="+k);f.call(e,"","",g+h)};}).call(this);})();(function(){google.xjs={basecomb:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/ck\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAICAQAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAIAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d0/ujg\x3d1/rs\x3dACT90oHYvixg5Dv0yQj-SB1JU6LIcj3yYQ',basecss:'/xjs/_/ss/k\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAI/rs\x3dACT90oFMtqhjIKGT4rLyv9oL3Z6ncHStwQ',basejs:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/dg\x3d0/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA',excm:[]};})();(function(){var u='/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d4/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA/m\x3dsb_he,d';var st=1;var amd=1000;var mmd=0;var pop=true;var povp=false;var raf=false;var lfpp=false;var ecb=false;var cst=false;var pxp=false;
|
||||||
|
var f=this||self;function g(){var a=google.ia&&google.ia.r.B2Jtyd;return a&&[1,5,6].indexOf(a.m)>=0&&a.cbfd&&a.cbvi?a:void 0};function h(a){var b=document.createElement("link");b.as="script";b.href=a;b.rel="preload";document.body.appendChild(b)}function k(){var a=[l];google.dp||(a.forEach(h),google.dp=!0)};google.ps=google.ps||[];function m(a){var b=l,c=function(){};google.lx=google.stvsc?c:function(){n(b,a&&raf,a&&lfpp);google.lx=c};google.bx||google.lx()}function p(a,b){b&&(a.src=b);var c=a.onload;a.onload=function(d){c&&c(d);google.ps=google.ps.filter(function(e){return a.src!==e})};google.ps.push(a.src);document.body.appendChild(a)}function q(a,b,c,d){c&&(a.fetchPriority="low");b?requestAnimationFrame(function(){p(a,d)}):p(a,d)}google.as=p;function n(a,b,c){google.tick&&google.tick("load","xjsls");var d=document.createElement("script");d.onerror=function(){google.ple=1};d.onload=function(){google.ple=0};google.xjsus=void 0;q(d,b,c,a);google.aple=-1;google.dp=!0};function r(){for(var a=document.getElementsByTagName("img"),b=0,c=a.length;b<c;b++){var d=a[b],e;if(e=d.hasAttribute("data-lzy_")&&Number(d.getAttribute("data-atf"))&1)e=d.getAttribute("jscontroller"),e=!((e==="UBXHI"||e==="R3fhkb"||e==="TSZEqd")&&d.hasAttribute("data-src"));if(e)return!0}return!1};var l,t,w,x,y,z,A,B,C,D,E="";function F(){l=pxp&&google.xjsup||u;var a=l.match("/cb=(loaded_h_\\d+)");a&&a[1]&&(E=a[1]);google.xjsu=l;f._F_jsUrl=l;z=function(c){m(c)};t=!1;w=(st===1||st===3)&&!!google.caft&&!r();x=g();y=!E&&(st===2||st===3)&&!!x&&!r();if(E){var b=(st===2||st===3)&&!!x;f[E]=function(c){var d=!1,e=function(){d||(d=!0,cst?setTimeout(function(){return void c.call(window,window._)},0):c.call(window,window._))};b&&google.ia.adls?(x.cbvi.push(function(){delete google.ia.adls}),x.cbvi.push(e),setTimeout(e,mmd)):e()}}A=
|
||||||
|
pop;B=povp;C=A&&document.prerendering||B&&document.hidden;D=B?"visibilitychange":"prerenderingchange"}function G(a){t||w||y||C||(z(a),t=!0)}
|
||||||
|
setTimeout(function(){google.tick&&google.tick("load","xjspls");F();if(w||y||C){if(w){var a=function(){w=!1;G()};google.caft(a);setTimeout(a,amd)}y&&(a=function(){y=!1;G()},x.cbvi.push(a),setTimeout(a,mmd));if(C){var b=function(){(B?document.hidden:document.prerendering)||(C=!1,G(!B),document.removeEventListener(D,b))};document.addEventListener(D,b,{passive:!0})}t||k()}else z()},0);})();window._ = window._ || {};window._DumpException = _._DumpException = function(e){throw e;};window._s = window._s || {};_s._DumpException = _._DumpException;window._qs = window._qs || {};_qs._DumpException = _._DumpException;window.loaded_h_0 = function(cb){cb.call(window,window._);};(function(){var t=[512,0,2048,0,0,0,4194304,33554432,268437004,0,8192,0,4096,4259020,137036800,134234113,548405252,4196417,8192,134217728,1048576,8,1170496,0,72908928,0,568328192];window._F_toggles = window._xjs_toggles = t;})();window._F_installCss = window._F_installCss || function(css){};(function(){var pmc='{\x22d\x22:{},\x22sb_he\x22:{\x22client\x22:\x22heirloom-hp\x22,\x22dh\x22:true,\x22ds\x22:\x22\x22,\x22host\x22:\x22google.com\x22,\x22jsonp\x22:true,\x22msgs\x22:{\x22cibl\x22:\x22Clear Search\x22,\x22dym\x22:\x22Did you mean:\x22,\x22lcky\x22:\x22I\\u0026#39;m Feeling Lucky\x22,\x22lml\x22:\x22Learn more\x22,\x22psrc\x22:\x22This search was removed from your \\u003Ca href\x3d\\\x22/history\\\x22\\u003EWeb History\\u003C/a\\u003E\x22,\x22psrl\x22:\x22Remove\x22,\x22sbit\x22:\x22Search by image\x22,\x22srch\x22:\x22Google Search\x22},\x22ovr\x22:{},\x22pq\x22:\x22\x22,\x22rfs\x22:[],\x22stok\x22:\x22VgWHNe9WWnZFTDWuPRUlX6IPsTw\x22}}';google.pmc=JSON.parse(pmc);})();</script></body></html>
|
||||||
12
requirements-youtube.txt
Normal file
12
requirements-youtube.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# YouTube 转播 + Demucs 人声分离(防版权)所需依赖
|
||||||
|
# 在 requirements.txt 基础上额外安装:
|
||||||
|
# pip install -r requirements.txt -r requirements-youtube.txt
|
||||||
|
|
||||||
|
# CPU 版本
|
||||||
|
demucs>=4.0.0
|
||||||
|
torch>=2.0.0
|
||||||
|
torchaudio>=2.0.0
|
||||||
|
|
||||||
|
# 若使用 NVIDIA GPU,请安装 CUDA 版 PyTorch 后覆盖:
|
||||||
|
# pip uninstall torch torchaudio
|
||||||
|
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||||
@@ -4,4 +4,11 @@ pycryptodome>=3.20.0
|
|||||||
distro>=1.9.0
|
distro>=1.9.0
|
||||||
tqdm>=4.67.1
|
tqdm>=4.67.1
|
||||||
httpx[http2]>=0.28.1
|
httpx[http2]>=0.28.1
|
||||||
PyExecJS>=1.5.1
|
PyExecJS>=1.5.1
|
||||||
|
|
||||||
|
# 可选:YouTube 转播时 Demucs 人声分离(防版权识别)
|
||||||
|
# pip install demucs torch torchaudio
|
||||||
|
# GPU 版本: pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||||
|
# demucs>=4.0.0
|
||||||
|
# torch>=2.0.0
|
||||||
|
# torchaudio>=2.0.0
|
||||||
174
stream_stats.py
Normal file
174
stream_stats.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# stream_stats.py
|
||||||
|
# 推流状态监控:解析 FFmpeg 输出,统计帧率、码率、网络等,便于调试
|
||||||
|
"""
|
||||||
|
推流状态监控模块
|
||||||
|
解析 FFmpeg stderr 输出,提取:帧率、码率、编码速度、源延迟事件
|
||||||
|
可选:Linux 网络上下行速度
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
|
class StreamStats:
|
||||||
|
"""推流状态统计,线程安全"""
|
||||||
|
|
||||||
|
def __init__(self, interval_sec=10):
|
||||||
|
self.interval_sec = interval_sec
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.frame = 0
|
||||||
|
self.fps = 0.0
|
||||||
|
self.bitrate_kbps = 0.0
|
||||||
|
self.speed = 0.0
|
||||||
|
self.encode_time = ""
|
||||||
|
self.size_kb = 0
|
||||||
|
self.quality_q = 0.0
|
||||||
|
self.lag_events = 0 # "Resumed reading" 次数,表示源端抖动
|
||||||
|
self.last_update_ts = 0.0
|
||||||
|
self.start_ts = time.time()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._printer_thread = None
|
||||||
|
# 网络统计 (Linux)
|
||||||
|
self._net_last = None
|
||||||
|
self._net_last_ts = 0
|
||||||
|
self.rx_mbps = 0.0
|
||||||
|
self.tx_mbps = 0.0
|
||||||
|
|
||||||
|
def parse_ffmpeg_progress(self, line: str) -> bool:
|
||||||
|
"""解析 FFmpeg 进度行,返回是否成功解析"""
|
||||||
|
# frame= 12345 fps= 30 q=28.0 size= 12345kB time=00:06:51.50 bitrate=2460.0kbits/s speed=1.00x
|
||||||
|
m = re.search(r'frame=\s*(\d+)', line, re.I)
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self.frame = int(m.group(1))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
m2 = re.search(r'fps=\s*([\d.]+)', line, re.I)
|
||||||
|
if m2:
|
||||||
|
self.fps = float(m2.group(1))
|
||||||
|
m3 = re.search(r'bitrate=\s*([\d.]+)\s*kbits/s', line, re.I)
|
||||||
|
if m3:
|
||||||
|
self.bitrate_kbps = float(m3.group(1))
|
||||||
|
m4 = re.search(r'speed=\s*([\d.]+)x', line, re.I)
|
||||||
|
if m4:
|
||||||
|
self.speed = float(m4.group(1))
|
||||||
|
m5 = re.search(r'time=([^\s]+)', line, re.I)
|
||||||
|
if m5:
|
||||||
|
self.encode_time = m5.group(1).strip()
|
||||||
|
m6 = re.search(r'size=\s*(\d+)\s*kB', line, re.I)
|
||||||
|
if m6:
|
||||||
|
self.size_kb = int(m6.group(1))
|
||||||
|
m7 = re.search(r'q=([\d.]+)', line, re.I)
|
||||||
|
if m7:
|
||||||
|
self.quality_q = float(m7.group(1))
|
||||||
|
self.last_update_ts = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def parse_lag_event(self, line: str) -> bool:
|
||||||
|
"""检测源端延迟事件 Resumed reading at pts"""
|
||||||
|
if "resumed reading at pts" in line.lower():
|
||||||
|
with self._lock:
|
||||||
|
self.lag_events += 1
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _read_net_bytes(self):
|
||||||
|
"""读取 /proc/net/dev 总字节数 (Linux)"""
|
||||||
|
try:
|
||||||
|
with open("/proc/net/dev", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
rx_total = tx_total = 0
|
||||||
|
for line in content.split("\n")[2:]: # 跳过表头
|
||||||
|
line = line.strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 10:
|
||||||
|
try:
|
||||||
|
rx_total += int(parts[1])
|
||||||
|
tx_total += int(parts[9])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return rx_total, tx_total
|
||||||
|
|
||||||
|
def update_network(self):
|
||||||
|
"""更新网络速度 (Linux)"""
|
||||||
|
if platform.system() != "Linux":
|
||||||
|
return
|
||||||
|
rx, tx = self._read_net_bytes()
|
||||||
|
if rx is None:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if self._net_last is not None:
|
||||||
|
dt = now - self._net_last_ts
|
||||||
|
if dt > 0.5: # 至少 0.5 秒间隔
|
||||||
|
drx = (rx - self._net_last[0]) / (1024 * 1024) / dt
|
||||||
|
dtx = (tx - self._net_last[1]) / (1024 * 1024) / dt
|
||||||
|
self.rx_mbps = round(drx, 2)
|
||||||
|
self.tx_mbps = round(dtx, 2)
|
||||||
|
self._net_last = (rx, tx)
|
||||||
|
self._net_last_ts = now
|
||||||
|
|
||||||
|
def format_summary(self) -> str:
|
||||||
|
"""生成状态摘要字符串"""
|
||||||
|
self.update_network()
|
||||||
|
with self._lock:
|
||||||
|
elapsed = time.time() - self.start_ts
|
||||||
|
parts = [
|
||||||
|
f"推流时长 {int(elapsed)}s",
|
||||||
|
f"帧率 {self.fps:.1f} fps",
|
||||||
|
f"已推帧 {self.frame}",
|
||||||
|
f"码率 {self.bitrate_kbps:.0f} kbps",
|
||||||
|
f"编码速度 {self.speed:.2f}x",
|
||||||
|
]
|
||||||
|
if self.lag_events > 0:
|
||||||
|
parts.append(f"源抖动 {self.lag_events} 次")
|
||||||
|
if platform.system() == "Linux":
|
||||||
|
parts.append(f"下行 {self.rx_mbps:.2f} MB/s 上行 {self.tx_mbps:.2f} MB/s")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
def start_periodic_printer(self):
|
||||||
|
"""启动周期性打印线程"""
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._stop.wait(self.interval_sec)
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
if self.last_update_ts > 0 and (time.time() - self.last_update_ts) < 60:
|
||||||
|
print(f"[推流状态] {self.format_summary()}")
|
||||||
|
|
||||||
|
self._printer_thread = threading.Thread(target=_run, daemon=True)
|
||||||
|
self._printer_thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止周期性打印"""
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_and_maybe_print(line: str, stats: StreamStats, prefix: str = "[FFmpeg]", verbose=True) -> bool:
|
||||||
|
"""
|
||||||
|
解析一行 FFmpeg 输出,更新 stats。
|
||||||
|
若为进度行则更新不打印;若为 lag 事件则更新并可选打印;其他行原样打印。
|
||||||
|
verbose: 是否打印非进度行
|
||||||
|
"""
|
||||||
|
line_stripped = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line_stripped:
|
||||||
|
return False
|
||||||
|
# 进度行:更新 stats,不重复打印(由周期摘要代替)
|
||||||
|
if stats.parse_ffmpeg_progress(line_stripped):
|
||||||
|
return True
|
||||||
|
# 源端延迟事件
|
||||||
|
if stats.parse_lag_event(line_stripped):
|
||||||
|
if verbose:
|
||||||
|
print(f"{prefix} {line_stripped}")
|
||||||
|
return True
|
||||||
|
if verbose:
|
||||||
|
print(f"{prefix} {line_stripped}")
|
||||||
|
return False
|
||||||
86
web.py
86
web.py
@@ -9,15 +9,15 @@ app = FastAPI(title="多进程录制控制台")
|
|||||||
|
|
||||||
# ---------------- 配置(根目录结构) ----------------
|
# ---------------- 配置(根目录结构) ----------------
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
CONFIG_DIR = BASE_DIR / "config" # config 文件夹在项目根目录
|
CONFIG_DIR = BASE_DIR / "config"
|
||||||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" # .pm2/logs 在项目根目录
|
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
||||||
|
|
||||||
MAX_LOG_LINES = 300
|
MAX_LOG_LINES = 300
|
||||||
|
|
||||||
# 支持的进程列表
|
# 支持的进程列表
|
||||||
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
|
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
|
||||||
|
|
||||||
# CORS(支持 GET 和 POST)
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -25,22 +25,45 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------- 工具 ----------------
|
# ---------------- 工具函数 ----------------
|
||||||
def strip_ansi_codes(text: str) -> str:
|
def strip_ansi_codes(text: str) -> str:
|
||||||
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
||||||
|
|
||||||
async def run_pm2(cmd: str) -> str:
|
|
||||||
|
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_shell(
|
||||||
f"pm2 {cmd}",
|
f"pm2 {cmd}",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
return (stdout or stderr).decode(errors="ignore").strip()
|
result = (stdout or stderr).decode(errors="ignore").strip()
|
||||||
|
return result if result else "命令执行完成(无输出)"
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
process.kill()
|
process.kill()
|
||||||
return "ERROR: PM2 命令超时"
|
await process.wait()
|
||||||
|
return "ERROR: PM2 命令超时,已强制终止"
|
||||||
|
|
||||||
|
|
||||||
|
async def force_kill_ffmpeg(process_name: str):
|
||||||
|
"""尝试强杀与该进程相关的 ffmpeg"""
|
||||||
|
cmds = [
|
||||||
|
# 优先杀带有进程名的 ffmpeg(最精准)
|
||||||
|
f"pkill -f 'ffmpeg.*{process_name}' || true",
|
||||||
|
# 再杀所有 ffmpeg(兜底,比较暴力)
|
||||||
|
"killall -9 ffmpeg 2>/dev/null || true",
|
||||||
|
]
|
||||||
|
|
||||||
|
for cmd in cmds:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
|
||||||
|
|
||||||
|
|
||||||
async def read_log_path(path: str, lines: int) -> str:
|
async def read_log_path(path: str, lines: int) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
@@ -57,21 +80,22 @@ async def read_log_path(path: str, lines: int) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||||
|
|
||||||
|
|
||||||
async def get_process_info(process: str) -> dict:
|
async def get_process_info(process: str) -> dict:
|
||||||
describe_raw = await run_pm2(f"describe {process}")
|
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
|
||||||
describe_clean = strip_ansi_codes(describe_raw)
|
describe_clean = strip_ansi_codes(describe_raw)
|
||||||
if ("No such process" in describe_clean or
|
|
||||||
"Unknown process" in describe_clean or
|
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
|
||||||
"not found" in describe_clean.lower() or
|
|
||||||
not describe_clean.strip()):
|
|
||||||
return {
|
return {
|
||||||
"process_status": "not_found",
|
"process_status": "not_found",
|
||||||
"out_path": None,
|
"out_path": None,
|
||||||
"err_path": None,
|
"err_path": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
out_path = None
|
out_path = None
|
||||||
err_path = None
|
err_path = None
|
||||||
|
|
||||||
for line in describe_raw.splitlines():
|
for line in describe_raw.splitlines():
|
||||||
line_clean = strip_ansi_codes(line)
|
line_clean = strip_ansi_codes(line)
|
||||||
if line_clean.count('│') < 2:
|
if line_clean.count('│') < 2:
|
||||||
@@ -86,11 +110,12 @@ async def get_process_info(process: str) -> dict:
|
|||||||
out_path = value
|
out_path = value
|
||||||
elif key == "err_log_path":
|
elif key == "err_log_path":
|
||||||
err_path = value
|
err_path = value
|
||||||
# 备用默认路径
|
|
||||||
if not out_path:
|
if not out_path:
|
||||||
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
||||||
if not err_path:
|
if not err_path:
|
||||||
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"process_status": status,
|
"process_status": status,
|
||||||
"out_path": out_path,
|
"out_path": out_path,
|
||||||
@@ -173,20 +198,41 @@ async def start(process: str = Query(..., description="进程名")):
|
|||||||
output = await run_pm2(f"start {process}")
|
output = await run_pm2(f"start {process}")
|
||||||
return JSONResponse({"output": output})
|
return JSONResponse({"output": output})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/stop")
|
@app.get("/stop")
|
||||||
async def stop(process: str = Query(..., description="进程名")):
|
async def stop(process: str = Query(..., description="进程名")):
|
||||||
if process not in VALID_PROCESSES:
|
if process not in VALID_PROCESSES:
|
||||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||||
output = await run_pm2(f"stop {process}")
|
|
||||||
return JSONResponse({"output": output})
|
# 第一步:尝试优雅停止
|
||||||
|
pm2_output = await run_pm2(f"stop {process}")
|
||||||
|
|
||||||
|
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
# 第三步:主动查找并杀死 ffmpeg
|
||||||
|
await force_kill_ffmpeg(process)
|
||||||
|
|
||||||
|
# 第四步:再等一小会儿,确认
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"message": "已执行 stop + 强杀 ffmpeg",
|
||||||
|
"pm2_output": pm2_output,
|
||||||
|
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/restart")
|
@app.get("/restart")
|
||||||
async def restart(process: str = Query(..., description="进程名")):
|
async def restart(process: str = Query(..., description="进程名")):
|
||||||
if process not in VALID_PROCESSES:
|
if process not in VALID_PROCESSES:
|
||||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||||
output = await run_pm2(f"restart {process}")
|
output = await run_pm2(f"restart {process}")
|
||||||
|
# 重启后也建议清理一次残留(视情况可选)
|
||||||
|
await force_kill_ffmpeg(process)
|
||||||
return JSONResponse({"output": output})
|
return JSONResponse({"output": output})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/status")
|
@app.get("/status")
|
||||||
async def status(process: str = Query(..., description="进程名")):
|
async def status(process: str = Query(..., description="进程名")):
|
||||||
if process not in VALID_PROCESSES:
|
if process not in VALID_PROCESSES:
|
||||||
@@ -196,14 +242,17 @@ async def status(process: str = Query(..., description="进程名")):
|
|||||||
"recent_log": f"无效进程名: {process}",
|
"recent_log": f"无效进程名: {process}",
|
||||||
"recent_error": ""
|
"recent_error": ""
|
||||||
})
|
})
|
||||||
full_status_raw = await run_pm2("status")
|
|
||||||
|
full_status_raw = await run_pm2("status", timeout=8.0)
|
||||||
full_status = strip_ansi_codes(full_status_raw)
|
full_status = strip_ansi_codes(full_status_raw)
|
||||||
info = await get_process_info(process)
|
info = await get_process_info(process)
|
||||||
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
||||||
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
||||||
|
|
||||||
if info["process_status"] == "not_found":
|
if info["process_status"] == "not_found":
|
||||||
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
||||||
recent_error = ""
|
recent_error = ""
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"raw_status": full_status,
|
"raw_status": full_status,
|
||||||
"process_status": info["process_status"],
|
"process_status": info["process_status"],
|
||||||
@@ -211,7 +260,8 @@ async def status(process: str = Query(..., description="进程名")):
|
|||||||
"recent_error": recent_error,
|
"recent_error": recent_error,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 首页 ----------------
|
# ---------------- 首页 ----------------
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(BASE_DIR / "index.html")
|
return FileResponse(BASE_DIR / "index.html")
|
||||||
Reference in New Issue
Block a user