10 Commits

Author SHA1 Message Date
eric
cc552d49f0 's' 2026-03-22 23:29:04 -05:00
eric
a4b7edd153 's' 2026-03-22 11:36:22 -05:00
eric
746da3d0b6 s'' 2026-03-22 11:32:24 -05:00
eric
0b2ceb4e8c 's' 2026-03-22 11:27:14 -05:00
eric
587c0e10b4 's' 2026-03-19 23:32:57 -05:00
eric
77969c85fe 's' 2026-03-19 13:46:02 -05:00
eric
f0b1f9cf47 's' 2026-03-19 04:04:23 -05:00
eric
219a8cef61 's' 2026-03-19 03:51:16 -05:00
eric
fd3d952ab8 's' 2026-03-19 03:47:47 -05:00
eric
ab6a6cac65 's' 2026-03-19 03:43:15 -05:00
19 changed files with 1165 additions and 1570 deletions

834
README.md
View File

@@ -1,259 +1,679 @@
# DouyinLiveRecorder 转播版
![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light)
[![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![Supported Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Ubuntu-blue.svg)](https://github.com/ihmily/DouyinLiveRecorder)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
## 💡简介
[![Python Version](https://img.shields.io/badge/python-3.11.6-blue.svg)](https://www.python.org/downloads/release/python-3116/)
[![Supported Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Linux-blue.svg)](https://github.com/ihmily/DouyinLiveRecorder)
[![Docker Pulls](https://img.shields.io/docker/pulls/ihmily/douyin-live-recorder?label=Docker%20Pulls&color=blue&logo=docker)](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags)
![GitHub issues](https://img.shields.io/github/issues/ihmily/DouyinLiveRecorder.svg)
[![Latest Release](https://img.shields.io/github/v/release/ihmily/DouyinLiveRecorder)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/ihmily/DouyinLiveRecorder/total)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
基于 [DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder) 的**转播增强版**,将原本的录制下载功能扩展为**实时推流转播**,支持抖音/TikTok 等直播推送到 YouTube、HDMI 等目标
一款**简易**的可循环值守的直播录制工具基于FFmpeg实现多平台直播源录制支持自定义配置录制以及直播状态推送
## 与原版的主要区别
</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 等)
---
## 项目结构
## 🎈项目结构
```
.
── main.py # 主入口(本地录制 + SRS 转播)
├── youtube.py # YouTube 转播入口
├── tiktok.py # TikTok/HDMI 转播入口
├── douyin_youtube_ffplay.py # 抖音 → YouTube 推流(含 Demucs 管道)
├── douyin_srs_ffplay.py # 抖音 → SRS/HDMI 推流
├── web.py # Web 配置界面
├── config/
├── config.ini # 通用配置
├── youtube.ini # YouTube 推流配置stream key、Demucs 开关)
── URL_config.ini # 直播间 URL 列表
├── src/ # 爬虫、流解析等核心逻辑
├── backup/ # 备份与辅助模块
├── requirements.txt
├── pyproject.toml
└── README.md
── DouyinLiveRecorder/
├── /config -> (config record)
├── /logs -> (save runing log file)
├── /backup_config -> (backup file)
├── /douyinliverecorder -> (package)
├── initializer.py-> (check and install nodejs)
├── spider.py-> (get live data)
├── stream.py-> (get live stream address)
├── utils.py -> (contains utility functions)
── logger.py -> (logger handdle)
├── room.py -> (get room info)
├── ab_sign.py-> (generate dy token)
├── /javascript -> (some decrypt code)
├── main.py -> (main file)
├── ffmpeg_install.py -> (ffmpeg install script)
├── demo.py -> (call package test demo)
├── msg_push.py -> (send live status update message)
├── ffmpeg.exe -> (record video)
├── index.html -> (play m3u8 and flv video)
├── requirements.txt -> (library dependencies)
├── docker-compose.yaml -> (Container Orchestration File)
├── Dockerfile -> (Application Build Recipe)
├── StopRecording.vbs -> (stop recording script on Windows)
...
```
---
</div>
## 快速开始
## 🌱使用说明
### 1. 环境要求
- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可里面有打包好的录制软件。有些电脑可能会报毒直接忽略即可如果下载时被浏览器屏蔽请更换浏览器下载
- **Python** >= 3.10
- **FFmpeg**(需支持 libx264、h264_nvenc
- **可选**NVIDIA GPU + CUDA用于 Demucs 加速)
- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`
- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。
### 2. 安装依赖
- 另外如果需要录制TikTok、AfreecaTV等海外平台请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。
```bash
# 克隆或下载项目
cd douyinyoutube
- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。
# 创建虚拟环境(推荐)
python -m venv .venv
# Windows:
.venv\Scripts\activate
# Ubuntu/Linux:
source .venv/bin/activate
- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。
# 安装基础依赖
pip install -r requirements.txt
- 如果要长时间挂着软件循环监测直播最好循环时间设置长一点咱也不差没录制到的那几分钟避免因请求频繁导致被官方封禁IP 。
- 要停止直播录制Windows平台可执行StopRecording.vbs脚本文件或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。
- 最后欢迎右上角给本项目一个star同时也非常乐意大家提交pr。
&emsp;
直播间链接示例:
# 若使用 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/xxx,主播名
https://v.douyin.com/iQFeBnt/
https://live.douyin.com/yall1102 (链接+抖音号)
https://v.douyin.com/CeiU5cbX (主播主页地址)
TikTok:
https://www.tiktok.com/@pearlgaga88/live
快手:
https://live.kuaishou.com/u/yall1102
虎牙:
https://www.huya.com/52333
斗鱼:
https://www.douyu.com/3637778?dyshid=
https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid=
YY:
https://www.yy.com/22490906/22490906
B站:
https://live.bilibili.com/320
小红书(直播间分享地址):
http://xhslink.com/xpJpfM
bigo直播:
https://www.bigo.tv/cn/716418802
buled直播:
https://app.blued.cn/live?id=Mp6G2R
SOOP:
https://play.sooplive.co.kr/sw7love
网易cc:
https://cc.163.com/583946984
千度热播:
https://qiandurebo.com/web/video.php?roomnumber=33333
PandaTV:
https://www.pandalive.co.kr/live/play/bara0109
猫耳FM:
https://fm.missevan.com/live/868895007
Look直播:
https://look.163.com/live?id=65108820&position=3
WinkTV:
https://www.winktv.co.kr/live/play/anjer1004
FlexTV(TTinglive)::
https://www.flextv.co.kr/channels/593127/live
PopkonTV:
https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117
https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117
TwitCasting:
https://twitcasting.tv/c:uonq
百度直播:
https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category
微博直播:
https://weibo.com/l/wblive/p/show/1022:2321325026370190442592
酷狗直播:
https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom=
TwitchTV:
https://www.twitch.tv/gamerbee
LiveMe:
https://www.liveme.com/zh/v/17141543493018047815/index.html
花椒直播:
https://www.huajiao.com/l/345096174
流星直播:
https://www.7u66.com/100960
ShowRoom:
https://www.showroom-live.com/room/profile?room_id=480206 (主播主页地址)
Acfun:
https://live.acfun.cn/live/179922
映客直播:
https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904
音播直播:
https://live.ybw1666.com/800002949
知乎直播:
https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址)
CHZZK:
https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2
嗨秀直播:
https://www.haixiutv.com/6095106
VV星球直播:
https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar
17Live:
https://17.live/en/live/6302408
浪Live:
https://www.lang.live/en-US/room/3349463
畅聊直播:
https://live.tlclw.com/106188
飘飘直播:
https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl
六间房直播:
https://v.6.cn/634435
乐嗨直播:
https://www.lehaitv.com/8059096
花猫直播:
https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331
Shopee:
https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458
Youtube:
https://www.youtube.com/watch?v=cS6zS5hi1w0
淘宝(需cookie):
https://tbzb.taobao.com/live?liveId=532359023188
https://m.tb.cn/h.TWp0HTd
京东:
https://3.cn/28MLBy-E
Faceit:
https://www.faceit.com/zh/players/Compl1/stream
连接直播:
https://show.lailianjie.com/10000258
咪咕直播:
https://www.miguvideo.com/p/live/120000541321
来秀直播:
https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496
Picarto:
https://www.picarto.tv/cuteavalanche
```
3.`config/config.ini` 中将 `视频保存格式` 设为 `ts`(触发 YouTube 推流逻辑)
&emsp;
#### TikTok/HDMI 转播
## 🎃源码运行
使用源码运行,可参考下面的步骤。
- 使用 `tiktok.py` 入口,配置 SRS 推流地址
- 详见 `tiktok/` 目录说明
### 4. 运行
1.首先拉取或手动下载本仓库项目代码
```bash
# YouTube 转播
python youtube.py
# 或使用 uv
uv run youtube.py
git clone https://github.com/ihmily/DouyinLiveRecorder.git
```
---
## 详细配置说明
### 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 无法匹配已移除的音乐
### 延迟与性能
- **延迟**:约 48 秒(与 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 配置界面
2.进入项目文件夹,安装依赖
```bash
python web.py
cd DouyinLiveRecorder
```
- 浏览器访问配置页面
- 支持编辑 `youtube.ini``URL_config.ini`
- 选择 youtube / tiktok / obs 等模式
> [!TIP]
> - 不论你是否已安装 **Python>=3.10** 环境, 都推荐使用 [**uv**](https://github.com/astral-sh/uv) 运行, 因为它可以自动管理虚拟环境和方便地管理 **Python** 版本, **不过这完全是可选的**<br />
> 使用以下命令安装
> ```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>
### 1. Demucs 安装失败
- :white_check_mark: 在虚拟环境中安装 (推荐)
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
# 仅 CPU
pip install demucs torch torchaudio
# 使用国内镜像
pip install demucs torch torchaudio -i https://pypi.tuna.tsinghua.edu.cn/simple
yum install epel-release
yum install ffmpeg
```
### 2. 无 NVIDIA 显卡
Ubuntu则执行
- 程序会自动使用 CPU 运行 Demucs
- 可在 `youtube.ini` 中设置 `demucs_use_gpu = 否` 以跳过 GPU 检测
```bash
apt update
apt install ffmpeg
```
### 3. 推流中断
macOS 执行
- 检查网络与代理
- 检查 YouTube stream key 是否有效
- 查看控制台日志定位错误
**如果已经安装 Homebrew 请跳过这一步**
### 4. 抖音风控
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
- 适当增大循环时间
- 确保 Cookie 有效(录制抖音需配置)
```bash
brew install ffmpeg
```
---
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
&emsp;
## 🐋容器运行
在运行命令之前,请确保您的机器上安装了 [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` 格式保存**。
&emsp;
## 🤖相关项目
- StreamCap: https://github.com/ihmily/StreamCap
- streamget: https://github.com/ihmily/streamget
&emsp;
## ❤️贡献者
&ensp;&ensp; [![Hmily](https://github.com/ihmily.png?size=50)](https://github.com/ihmily)
[![iridescentGray](https://github.com/iridescentGray.png?size=50)](https://github.com/iridescentGray)
[![annidy](https://github.com/annidy.png?size=50)](https://github.com/annidy)
[![wwkk2580](https://github.com/wwkk2580.png?size=50)](https://github.com/wwkk2580)
[![missuo](https://github.com/missuo.png?size=50)](https://github.com/missuo)
<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>
[![justdoiting](https://github.com/justdoiting.png?size=50)](https://github.com/justdoiting)
[![dhbxs](https://github.com/dhbxs.png?size=50)](https://github.com/dhbxs)
[![wujiyu115](https://github.com/wujiyu115.png?size=50)](https://github.com/wujiyu115)
[![zhanghao333](https://github.com/zhanghao333.png?size=50)](https://github.com/zhanghao333)
<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>
&ensp;&ensp; [![HoratioShaw](https://github.com/HoratioShaw.png?size=50)](https://github.com/HoratioShaw)
[![nov30th](https://github.com/nov30th.png?size=50)](https://github.com/nov30th)
[![727155455](https://github.com/727155455.png?size=50)](https://github.com/727155455)
[![nixingshiguang](https://github.com/nixingshiguang.png?size=50)](https://github.com/nixingshiguang)
[![1411430556](https://github.com/1411430556.png?size=50)](https://github.com/1411430556)
[![Ovear](https://github.com/Ovear.png?size=50)](https://github.com/Ovear)
&emsp;
## ⏳提交日志
- 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>
&emsp;
## 有问题可以提issue, 我会在这里持续添加更多直播平台的录制 欢迎Star
####

View File

@@ -1,9 +1,39 @@
https://live.douyin.com/629587976874,主播: 伟利火烧
https://live.douyin.com/606957569518,主播: 李泽宇(煎饼商)
https://live.douyin.com/288000519466,主播: 五花肉肉
https://live.douyin.com/69064125466,主播: 张大丽丽的丽茶努力版
https://live.douyin.com/838061972379,主播: 番茄妹妹
https://live.douyin.com/562629369787,主播: 叹茶柠檬茶
https://live.douyin.com/565208465584,主播: 财圆圆
https://live.douyin.com/51142028798,主播: 小夏妹妹
https://live.douyin.com/974451428277,主播: 雅如柠檬茶
https://live.douyin.com/925673220466,主播: 兜有米麻糍
https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜)
https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog)
https://live.douyin.com/835571459859,主播: 海绵饱饱
https://live.douyin.com/152358755212,主播: 大美_红烧肉
https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿)
https://live.douyin.com/591442402624,主播: 76696515302
https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记
https://live.douyin.com/78012762575,主播: 摆摊儿去划水
https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中)
https://live.douyin.com/498368994814,主播: 剪头发的小帆帆
https://live.douyin.com/617848099204,主播: 果妈要努力
https://live.douyin.com/821525628944,主播: 李熙雨⭐
https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官)
https://live.douyin.com/701547125568,主播: 悠悠包
https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记
https://live.douyin.com/876468215361,主播: 晓晓和凡凡
https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版)
https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版)
https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍
https://live.douyin.com/8687122573,主播: 小彤炒粉
https://live.douyin.com/642534242822,主播: (小王_)煎饼果子
https://live.douyin.com/743565594721,主播: 汪汪汪小妞
https://live.douyin.com/249578288248,主播: 思思努力版
https://live.douyin.com/472140253414,主播: 戴盘龙
https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号
https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦)
https://live.douyin.com/690114366322,主播: 卤味鲜(东大)
https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_)
https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记
https://live.douyin.com/661694696687,主播: 小米摆摊记
https://live.douyin.com/228132412943,主播: 00后摆摊小可爱
https://live.douyin.com/876677865786,主播: 牛哄哄
https://live.douyin.com/573735790640,主播: 小董卤串(餐饮)
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
https://live.douyin.com/210443559964,主播: 小小诺
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠

View File

@@ -1,27 +1,13 @@
[youtube]
# 只写 stream key推荐方式
# 必填YouTube 直播推流 key在 YouTube 工作室 → 创建 → 流式传输 获取
; key = qxvb-r47b-r5ju-6ud3-6k7z
key = x04z-564w-aks7-embw-30y4
# ===== 优先:推流画质 =====
# 视频码率 MbpsYouTube 推荐 2.5,可设 4-4.5 追求更高画质)
video_bitrate_mbps = 2.5
# 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP默认 RTMPS
; rtmps = 否
# 推流状态日志间隔(秒),每 N 秒打印帧率/码率/网络等
stats_interval_sec = 10
# 可选:视频比特率 kbpsYouTube 推荐 2500可填 3000-4500 提高画质
; bitrate = 2500
# ===== 其次:音频版权处理 =====
# 是否启用 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
# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ减轻 CPU。填 1 或 是
fast_audio = 1

View File

@@ -1,456 +0,0 @@
# 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 或 otherbass 无混响、无重复感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_paramlibx264 用 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 输出是 stdoutconn 是 socket。需要把 proc_a.stdout 的数据通过 socket 发到 FFmpeg。
# FFmpeg_out 的第二个输入是 tcp已经连接了 conn。所以我们从 proc_a 读Demucs 处理,写 conn。
# 但 proc_a.stdout 是 pipeconn 是 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

View File

@@ -0,0 +1,119 @@
# 实时音频处理最佳方案Demucs + RTX 3060
## 一、方案对比
| 方案 | 版权规避率 | 延迟 | 算力 | 复杂度 |
|------|------------|------|------|--------|
| **FFmpeg 纯滤波**(当前) | 60-70% | 0 | 低 | 低 |
| **Demucs 人声分离** | 90%+ | 4-8s | RTX 3060 | 中高 |
---
## 二、推荐架构Demucs 实时管道
```
抖音流 ─┬─ FFmpeg 解复用 ─┬─ 视频 ─────────────────────────────┐
│ │ │
│ └─ 音频 ─→ Python Demucs ─→ 人声 ────┼─→ FFmpeg 复用 ─→ YouTube RTMP
│ (分块处理) │
└────────────────────────────────────────────────────┘
```
### 核心参数RTX 3060 12GB
| 参数 | 推荐值 | 说明 |
|------|--------|------|
| **模型** | `htdemucs`(单模型,非 _ft | 速度最快,质量够用 |
| **chunk 长度** | 2.5-3 秒 | 延迟 ≈ 2×chunk ≈ 5-6s |
| **overlap** | 0.25 | 减少接缝,质量更好 |
| **shifts** | 0 | 降低算力,实时必需 |
| **segment** | 7.8(默认)或更小 | 显存不足时减小 |
### RTX 3060 算力
- 约 13 TFLOPS高于 Demucs 实时所需 2 TFLOPS
- 12GB 显存足够 htdemucs约 6-8GB
- 预期:**2-4× 实时速度**,可满足直播
---
## 三、实现方式
### 方式 APython 自建管道(推荐)
```python
# 伪代码流程
# 1. FFmpeg 从抖音拉流,输出: -vn -acodec pcm_s16le -ar 44100 -ac 1 pipe:1
# 2. 主进程读取音频块2.5s = 110250 samples @ 44.1kHz
# 3. 调用 demucs.separate_audio(model, chunk) 得到 vocals
# 4. 人声写入 pipeFFmpeg 另一进程读取并与视频复用推流
```
**依赖**
```
demucs
torch
torchaudio
```
**模型加载**
```python
from demucs.pretrained import get_model
model = get_model('htdemucs') # 单模型,非 htdemucs_ft
```
### 方式 BGStreamer若可用
GStreamer 1.28+ 内置 demucs 元素CPU 约 8× 实时:
```bash
# 需安装 gst-plugins-rs (含 demucs)
gst-launch-1.0 uridecodebin uri=抖音流 ! audioconvert ! demucs ! ...
```
---
## 四、分块处理要点
1. **重叠**chunk 之间 25% 重叠,避免接缝
2. **队列**:双缓冲,一块处理时另一块接收
3. **采样率**Demucs 默认 44100 Hz与 FFmpeg 输出一致
4. **延迟**:总延迟 ≈ 2 × chunk 长度(约 5-6 秒可接受)
---
## 五、快速验证(离线测试)
```bash
# 安装
pip install demucs torch torchaudio
# 测试 30 秒音频分离速度
demucs -n htdemucs --two-stems=vocals -j 1 test_30s.mp3 -o output/
# 若 30s 在 10s 内完成 → 可实时
```
---
## 六、与当前方案的关系
- **当前**`douyin_youtube_ffplay.py` 纯 FFmpeg60-70% 规避率
- **升级**:可新增 `douyin_youtube_demucs.py`,集成 Demucs 管道
- **切换**:通过 config 选择 `mode = ffmpeg``mode = demucs`
---
## 七、配置建议config/youtube.ini
```ini
[youtube]
key = xxx
# 音频模式: ffmpeg | demucs
; audio_mode = ffmpeg
# Demucs 专用
; demucs_chunk = 2.5
; demucs_model = htdemucs
```

50
docs/YOUTUBE_LIVE_SEO.md Normal file
View File

@@ -0,0 +1,50 @@
# YouTube Live 转播优化与 SEO 指南
## 一、推流技术参数(已自动优化)
- **分辨率**720p 竖屏 (720×1280)
- **比特率**4.5 Mbps可在 config/youtube.ini 的 bitrate 调整)
- **关键帧**2 秒(符合 YouTube 推荐)
- **音频**AAC 128kbps 48kHz 立体声
- **协议**RTMPS 加密推流(推荐)
- **防版权**:开源音频处理链(降噪 + 带通 + EQ + 相位扰动)
## 二、YouTube 直播 SEO 与推荐权重
直播标题、描述、标签在 **YouTube 工作室** 创建直播时设置,推流程序无法修改。以下为提升推荐权重的建议:
### 标题(前 60 字最关键)
- 主关键词放前面,如:`LIVE 摆摊日常 | 路边直播`
- 可加实时感词汇LIVE、直播、实时、正在直播
- 避免堆砌关键词,保持可读性
### 描述(前 125 字影响搜索)
- 首段包含主关键词和内容概要
- 可说明:转播自抖音、直播内容类型、时间等
### 标签
- 515 个相关标签live stream, 摆摊, 路边直播, 抖音转播
- 与标题、描述语义一致
### 算法相关因素
- **观看时长**:用户停留越久,推荐越高
- **互动**:点赞、评论、分享
- **画质与音质**:清晰稳定的流有助于留存
## 三、config/youtube.ini 配置说明
| 配置项 | 说明 | 示例 |
|--------|------|------|
| key | 必填YouTube stream key | key = xxxx-xxxx-xxxx |
| rtmps | 是否用 RTMPS默认是 | rtmps = 否 |
| bitrate | 视频比特率 kbps | bitrate = 4500 |
## 四、稳定性建议
1. 网络:上行带宽 ≥ 6 Mbps建议有线
2. 推流前在 YouTube 工作室完成标题、描述、标签设置
3. 定期检查 config/youtube.ini 中的 stream key 是否有效

View File

@@ -11,23 +11,6 @@ import re
import os
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):
"""主播真实下播的专用异常"""
@@ -96,64 +79,42 @@ def start_douyin_youtube_ffplay(
只从 config/youtube.ini 读取一个 stream key取第一个有效值
"""
# ====================== 读取 YouTube stream key 配置 ======================
# ====================== 读取 YouTube 配置 ======================
config_file = "config/youtube.ini"
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
youtube_rtmp = rtmp_base + default_key # 默认值
use_rtmps = True
# 720p 竖屏 YouTube 建议 46 Mbps偏低易触发「数据不足/卡顿」提示
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
stream_key = None
fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ显著减轻 CPU
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):
config = configparser.ConfigParser()
config.read(config_file, encoding="utf-8")
if config.has_section("youtube"):
for key_name, value in config.items("youtube"):
key_lower = key_name.strip().lower()
val = value.strip()
if key_lower == "enable_demucs":
enable_demucs = val.lower() in ("1", "true", "yes", "")
elif key_lower == "demucs_use_gpu":
demucs_use_gpu = val.lower() not in ("0", "false", "no", "")
elif key_lower == "demucs_vocal_mix":
for k, v in config.items("youtube"):
v = (v or "").strip()
if k == "rtmps" and v.lower() in ("0", "false", ""):
use_rtmps = False
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "", "", "on"):
fast_audio = True
elif k == "bitrate" and v:
try:
v = float(val)
if 0.5 <= v <= 1.0:
demucs_vocal_mix = v
n = int(v)
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
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}")
# 兼容:若上面未匹配到 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
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
stream_key = v
if stream_key:
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
youtube_rtmp = stream_key
else:
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
else:
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key")
# ====================== 常量配置 ======================
MAX_RETRY_DELAY = 90
@@ -162,6 +123,7 @@ def start_douyin_youtube_ffplay(
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25
STALE_OUTPUT_SECONDS = 40
RECONNECT_DELAY_MAX = 10
END_KEYWORDS = [
"connection reset by peer",
@@ -169,6 +131,7 @@ def start_douyin_youtube_ffplay(
"invalid data found",
"server returned 403",
"server returned 404",
"server returned 500",
"ingestion error",
"access denied",
"packet too short",
@@ -176,6 +139,10 @@ def start_douyin_youtube_ffplay(
"live has ended",
"no route to host",
"connection timed out",
"connection refused",
"error writing output",
"broken pipe",
"i/o error",
]
douyin_headers = (
@@ -190,318 +157,102 @@ def start_douyin_youtube_ffplay(
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
# ====================== NVENC 检测 ======================
# FFmpeg NVENC 需驱动 570+,否则会报 "Driver does not support the required nvenc API version"
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
# ultrafast+zerolatency在软编下尽量让 speed≥1.0;画质略降、直播可接受
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
if "h264_nvenc" in (enc.stdout or ""):
# 检查驱动版本NVENC API 13.0 需 570+
driver_ok = False
try:
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 硬件加速")
codec_v = "h264_nvenc"
video_args = ["-profile:v", "high", "-level", "4.0",
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
"-maxrate", maxrate, "-bufsize", bufsize]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as 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()
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
_script_dir = os.path.dirname(os.path.abspath(__file__))
_ffmpeg_cwd = None
if fast_audio:
print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ减轻 CPU 以利 speed≥1.0")
_af_chain = "afftdn=nf=-26:tn=1,aresample=async=1:first_pts=0"
else:
# 优先 RNNoise(arnndn),无模型则用 afftdnaphaser 相位扰动可干扰 Content ID 指纹
_arnndn_paths = [
os.path.join(_script_dir, "arnndn-models", "cb.rnnn"),
os.path.join(os.path.dirname(_script_dir), "arnndn-models", "cb.rnnn"),
"arnndn-models/cb.rnnn",
]
_arnndn_model = None
for _p in _arnndn_paths:
if os.path.isfile(_p):
_arnndn_model = os.path.normpath(_p)
break
if _arnndn_model:
print(f"[INFO] 使用 RNNoise 降噪模型: {_arnndn_model}")
_arnndn_base = os.path.dirname(os.path.dirname(os.path.abspath(_arnndn_model)))
_af_denoise = "arnndn=m=arnndn-models/cb.rnnn:mix=0.82,"
_ffmpeg_cwd = _arnndn_base
else:
print("[INFO] 未找到 arnndn 模型,使用 afftdn 降噪(可克隆 richardpl/arnndn-models 到 arnndn-models/")
_af_denoise = "afftdn=nf=-26:tn=1,"
# 720p (最原始)
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段EQ + 相位扰动(纯 FFmpeg 理论上限 ~70%
_af_chain = (
_af_denoise
+ "highpass=f=200,lowpass=f=3800,"
+ "equalizer=f=150:width_type=o:width=2.5:g=-14,"
+ "equalizer=f=350:width_type=o:width=2:g=-10,"
+ "equalizer=f=600:width_type=o:width=1.8:g=-8,"
+ "equalizer=f=1000:width_type=o:width=1.5:g=-6,"
+ "equalizer=f=2500:width_type=o:width=1.2:g=-4,"
+ "acompressor=threshold=-26dB:ratio=5:attack=8:release=80:makeup=5,"
+ "afreqshift=shift=22,"
+ "asetrate=48000*1.035,aresample=48000,"
+ "aphaser=type=t:decay=0.4:delay=3:speed=0.7,"
+ "volume=1.12,"
+ "aresample=async=1:first_pts=0"
)
# 720p 竖屏,符合 YouTube 推荐46 Mbps、2s 关键帧、AAC 128k
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
"-bf", "0", "-sc_threshold", "0"]
if codec_v == "libx264":
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
# -re按源时间戳节奏拉流避免突发过快不用 realtime/arealtime输入抖动时易让 YouTube 判为数据不足)
ffmpeg_command = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-rw_timeout", "50000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
"-fflags", "+genpts+discardcorrupt",
"-err_detect", "ignore_err",
"-max_delay", "80000",
"-max_delay", "500000",
"-thread_queue_size", "2048",
"-filter_threads", "4",
"-headers", douyin_headers,
"-re",
"-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画质优先YouTube 推荐 3-8 Mbps
"-b:v", f"{br_k}k", "-maxrate", f"{br_k+500}k", "-bufsize", f"{br_k*2}k",
"-fps_mode", "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",
# bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v,
*video_args,
*_video_mid,
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改41080p可音频上 128kYouTube 推荐)
"-af", "aresample=async=1:first_pts=0",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
"-af", _af_chain,
"-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", "-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
# ]
# 1080p
# 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,
# # 视频:不动你原策略,只给编码喘口气
# "-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", "superfast",
# "-tune", "zerolatency",
# "-profile:v", "high",
# "-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
# "-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",
# "-pix_fmt", "yuv420p",
# # 音频
# "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
# "-af",
# "afftdn=nf=-18:tn=1,"
# "highpass=f=120,lowpass=f=4600,"
# "equalizer=f=250:width_type=o:width=2:g=-6,"
# "equalizer=f=900:width_type=o:width=1.4:g=-4,"
# "asetrate=48000*1.012,aresample=48000,"
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=120:makeup=4,"
# "volume=1.10,"
# "aresample=async=1:first_pts=0",
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# 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下推荐使用最新FFmpegapt 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
stderr_q = None
reader_t = None
@@ -518,55 +269,24 @@ def start_douyin_youtube_ffplay(
stderr_q = queue.Queue()
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
_popen_kw = dict(
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
)
if _ffmpeg_cwd:
_popen_kw["cwd"] = _ffmpeg_cwd
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
proc.last_out_ts = time.time()
proc = None
if use_demucs:
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()
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()
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:
try:
@@ -584,22 +304,15 @@ def start_douyin_youtube_ffplay(
break
continue
line_str = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
if line_str.startswith("__ERR__READER__"):
print(f"[ERROR] stderr reader 异常: {line_str}")
line = line.strip()
if line.startswith("__ERR__READER__"):
print(f"[ERROR] stderr reader 异常: {line}")
continue
if line_str:
if line:
proc.last_out_ts = time.time()
if stream_stats:
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()
print(f"[FFmpeg] {line}")
line_lower = line.lower()
if any(kw in line_lower for kw in END_KEYWORDS):
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
@@ -623,9 +336,6 @@ def start_douyin_youtube_ffplay(
cleanup_proc(proc)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
break
finally:
if stream_stats is not None:
stream_stats.stop()
continue

View File

@@ -32,13 +32,10 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
<div class="container">
<h1>
进程:
<select id="processSelect">
<option value="tiktok">tiktok</option>
<option value="youtube">youtube</option>
<option value="obs">obs</option>
</select>
<select id="processSelect"></select>
<span id="statusIndicator">⚪ 加载中...</span>
</h1>
<p id="processScriptNote" style="font-size:0.88em;margin:-12px 0 20px;opacity:0.88;font-family:ui-monospace,monospace"></p>
<!-- YouTube 专属配置(仅 youtube 时显示) -->
<div id="youtubeOnlySection" style="display:none;">
@@ -87,10 +84,55 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
<pre id="pm2Log">正在加载状态与日志...</pre>
</div>
<script>
/** 进程列表仅来自 GET /process_monitorweb2.py 启动时扫描项目根目录自动识别PM2 名 = 脚本主文件名(无扩展名)。 */
let PROCESS_MONITOR = [];
function pm2NameFromScript(script) {
const base = String(script).split(/[/\\]/).pop() || '';
const i = base.lastIndexOf('.');
return i > 0 ? base.slice(0, i) : base;
}
function isYoutubeScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube');
}
function isTiktokScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
}
function isObsScript(script) {
return pm2NameFromScript(script).startsWith('obs');
}
async function loadProcessMonitor() {
const res = await fetch('/process_monitor');
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
const entries = data.entries || [];
PROCESS_MONITOR = entries.map((e) => ({
script: e.script,
label: e.label || e.script,
pm2: e.pm2 || pm2NameFromScript(e.script),
}));
}
function getEntryByPm2(name) {
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
}
function fillProcessSelect() {
processSelect.innerHTML = '';
PROCESS_MONITOR.forEach((e) => {
const opt = document.createElement('option');
opt.value = e.pm2;
opt.textContent = `${e.label}${e.script}`;
processSelect.appendChild(opt);
});
}
const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator');
const processSelect = document.getElementById('processSelect');
let currentProcess = 'tiktok';
let currentProcess = '';
// 各专属区域
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
@@ -109,23 +151,32 @@ const loadUrlBtn = document.getElementById('loadUrlBtn');
const saveUrlBtn = document.getElementById('saveUrlBtn');
const urlStatus = document.getElementById('urlStatus');
function refreshProcessScriptNote() {
const el = document.getElementById('processScriptNote');
const ent = getEntryByPm2(currentProcess);
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
}
processSelect.addEventListener('change', () => {
currentProcess = processSelect.value;
refreshProcessScriptNote();
fetchStatus();
updateSectionsVisibility();
});
function updateSectionsVisibility() {
youtubeOnlySection.style.display = currentProcess === 'youtube' ? 'block' : 'none';
urlConfigSection.style.display = (currentProcess === 'youtube' || currentProcess === 'tiktok') ? 'block' : 'none';
obsSection.style.display = currentProcess === 'obs' ? 'block' : 'none';
if (currentProcess === 'youtube') {
loadYoutubeConfig();
}
if (currentProcess === 'youtube' || currentProcess === 'tiktok') {
loadUrlConfig();
}
const ent = getEntryByPm2(currentProcess);
const s = ent ? ent.script : '';
const isYoutube = ent && isYoutubeScript(s);
const isTiktok = ent && isTiktokScript(s);
const isObs = ent && isObsScript(s);
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
obsSection.style.display = isObs ? 'block' : 'none';
if (isYoutube) loadYoutubeConfig();
if (isYoutube || isTiktok) loadUrlConfig();
}
// textarea 高度自适应
@@ -215,7 +266,9 @@ async function pm2Action(action){
updateStatus(data.process_status);
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()}\n${data.raw_status}\n\n`;
if(data.process_status === 'not_found'){
logText += `【提示】进程 "${currentProcess}" 未注册到 PM2可能被 pm2 delete 删除)\n`;
const ent = getEntryByPm2(currentProcess);
const hint = ent ? `${ent.script}PM2 名: ${ent.pm2}` : currentProcess;
logText += `【提示】未在 PM2 中找到: ${hint}\n`;
}else{
logText += `【实时输出日志】`;
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
@@ -233,7 +286,9 @@ async function pm2Action(action){
logEl.textContent = logText;
scrollLog();
}else{
logEl.textContent = `操作: ${action} ${currentProcess}\n${data.output || '成功'}\n\n正在刷新状态...`;
const ent = getEntryByPm2(currentProcess);
const who = ent ? `${ent.script} (${ent.pm2})` : currentProcess;
logEl.textContent = `操作: ${action}${who}\n${data.output || '成功'}\n\n正在刷新状态...`;
scrollLog();
setTimeout(fetchStatus, 1500);
}
@@ -256,10 +311,26 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
if(action && action !== 'status') pm2Action(action);
});
// 自动刷新 + 初始
setInterval(fetchStatus, 1000);
fetchStatus();
updateSectionsVisibility();
(async function boot() {
try {
await loadProcessMonitor();
} catch (e) {
logEl.textContent = '无法加载 /process_monitor请用本服务打开的页面访问或检查 web2 是否已启动)\n' + (e && e.message ? e.message : '');
statusIndicator.textContent = '❌ 未连接';
return;
}
if (!PROCESS_MONITOR.length) {
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh且 Web 为当前 web*.py';
return;
}
fillProcessSelect();
currentProcess = PROCESS_MONITOR[0].pm2;
processSelect.value = currentProcess;
refreshProcessScriptNote();
setInterval(fetchStatus, 1000);
fetchStatus();
updateSectionsVisibility();
})();
</script>
</body>
</html>

View File

@@ -1,14 +0,0 @@
<!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> &raquo;</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%">&nbsp;</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&amp;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">&copy; 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>

View File

@@ -1,215 +0,0 @@
<!--
Project: DouyinLiveRecorder
Author: Hmily
Build: 2023.08.14 - 20:24:05
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="never">
<title>M3U8 视频播放器</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&amp;display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
<style>
body {
font-family: 'Roboto', Arial, sans-serif;
background-color: #1a237e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
padding: 0;
color: #ffffff;
background-image: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%);
}
.container {
max-width: 640px;
width: 80%;
padding: 20px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
}
#videoPlayer {
width: 100%;
height: 0;
padding-bottom: 56.25%;
position: relative;
background-color: #000;
border-radius: 5px;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
display: none;
}
video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#videoUrlInput{
display: block;
width: 100%;
margin: 10px 0;
padding: 8px;
border-radius: 5px;
border: 1px solid #ccc;
box-sizing: border-box;
}
#playButton {
display: block;
width: 100%;
padding: 10px;
background-color: #283593;
color: white;
font-weight: bold;
border-radius: 5px;
border: none;
cursor: pointer;
transition: background-color 0.3s;
margin: 0 0 10px 0;
box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.15);
}
#playButton:hover {
background-color: #1a237e;
}
.description {
margin-top: 20px;
line-height: 1.4;
font-size: 14px;
text-align: left;
background-color: #f8f9fa;
padding: 15px;
border-radius: 5px;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
display: block;
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 14px;
color: white;
}
p{
color: black;
}
a.no_style {
color: inherit;
text-decoration: none;
}
@media screen and (max-width: 768px) {
.container {
width: 90%;
border-radius: 0;
box-shadow: none;
margin-top:30px;
}
body {
overflow-y: scroll;
}
#videoUrlInput{
margin-top: 30px;
margin-bottom: 10px;
}
}
</style>
</head>
<body>
<div class="container">
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8或者FLV 视频链接">
<button id="playButton">播放视频</button>
<div id="videoPlayer">
<video controls></video>
</div>
<div class="description">
<p><strong>说明</strong><p>
<p>M3U8文件格式</p>
<p>M3U8文件是采用UTF-8编码格式的M3U文件。M3U文件本身是一个纯文本索引文件其核心功能是记录多媒体文件链接。当用户打开此类文件时播放软件会根据索引查找相应的音视频文件网络地址然后进行在线播放。</p>
<p>M3U最初设计用于播放音频文件例如MP3。但随着时间推移更多的播放器和软件开始使用M3U来播放视频文件列表同时也支持在线流媒体音频源的指定。目前许多播放器和软件都兼容M3U文件格式。</p>
<p>FLV文件格式Flash Video Format是Adobe公司开发的一种专门用于网页视频播放的文件格式。FLV格式的视频文件通常用于播放短视频和在线流媒体可以嵌入到网页中供用户观看。FLV视频通常由Adobe Flash Player播放器播放而其他第三方播放器也支持此格式。</p>
</div>
<div class="footer">
<p>&copy; 2023 <a href='https://github.com/ihmily/DouyinLiveRecorder' class="no_style" target="_blank">Hmily</a>. All rights reserved.</p>
</div>
<script>
function httpToHttps(url) {
if (url.startsWith("http://")) {
return url.replace("http://", "https://");
}
return url;
}
function playVideo() {
let videoUrl = document.getElementById('videoUrlInput').value;
const video = document.querySelector('#videoPlayer video');
const description = document.querySelector('.description');
if (videoUrl == ''){
alert('请输入视频链接');
return;
}
videoUrl = httpToHttps(videoUrl);
if (videoUrl.includes('.m3u8')) {
videoPlayer.style.display = 'block';
description.style.display = 'none';
if (Hls.isSupported()) {
const hls = new Hls();
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(videoUrl);
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoUrl;
} else {
alert('M3U8 格式不受您的浏览器支持。');
console.error('M3U8 格式不受您的浏览器支持。');
return;
}
} else if (videoUrl.includes('.flv')) {
if (flvjs.isSupported()) {
const flvPlayer = flvjs.createPlayer({
type: 'flv',
url: videoUrl
});
flvPlayer.attachMediaElement(video);
flvPlayer.load();
flvPlayer.play();
} else {
alert('FLV 格式不受您的浏览器支持。');
console.error('FLV 格式不受您的浏览器支持。');
return;
}
videoPlayer.style.display = 'block';
description.style.display = 'none';
} else {
console.error('不支持播放该视频格式。');
alert('不支持播放该视频格式。');
}
}
document.getElementById('playButton').addEventListener('click', playVideo);
</script>
</div>
</body>
</html>

View File

View File

@@ -1,12 +0,0 @@
# 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

View File

@@ -4,11 +4,4 @@ pycryptodome>=3.20.0
distro>=1.9.0
tqdm>=4.67.1
httpx[http2]>=0.28.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
PyExecJS>=1.5.1

View File

@@ -1,174 +0,0 @@
# 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

1
web.sh
View File

@@ -1 +0,0 @@
uvicorn web:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -3,6 +3,7 @@ from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path
import asyncio
import re
from typing import Optional
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="多进程录制控制台")
@@ -14,8 +15,79 @@ DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
MAX_LOG_LINES = 300
# 支持的进程列表
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
def _label_short(prefix: str) -> str:
return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
def discover_script_entries() -> tuple:
"""扫描项目根目录自动识别入口tiktok*.py、youtube*.py、obs*.sh以及当前本文件Web 控制台。PM2 名 = 主文件名(无扩展名)。"""
base = BASE_DIR
web_path = Path(__file__).resolve()
entries: list[dict] = []
for path in sorted(base.glob("tiktok*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("tiktok")})
for path in sorted(base.glob("youtube*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("youtube")})
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
entries.append({"script": web_path.name, "label": _label_short("web")})
return tuple(entries)
SCRIPT_ENTRIES = discover_script_entries()
def pm2_name_from_script(script: str) -> str:
return Path(script).stem
def _stem(script: str) -> str:
return Path(script).stem
def is_youtube_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("youtube")
def is_tiktok_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("tiktok")
def _build_process_monitor():
return tuple(
{
"pm2": pm2_name_from_script(e["script"]),
"script": e["script"],
"label": e["label"],
}
for e in SCRIPT_ENTRIES
)
PROCESS_MONITOR = _build_process_monitor()
VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
URL_CONFIG_PM2 = frozenset(
pm2_name_from_script(e["script"])
for e in SCRIPT_ENTRIES
if is_tiktok_script(e["script"]) or is_youtube_script(e["script"])
)
def script_for_pm2(pm2: str) -> Optional[str]:
for p in PROCESS_MONITOR:
if p["pm2"] == pm2:
return p["script"]
return None
# CORS
app.add_middleware(
@@ -125,8 +197,9 @@ async def get_process_info(process: str) -> dict:
# ---------------- 配置路由youtube.ini仅 youtube ----------------
@app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")):
if process != "youtube":
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists():
@@ -139,8 +212,9 @@ async def get_config(process: str = Query(..., description="进程名")):
@app.post("/save_config")
async def save_config(request: Request, process: str = Query(..., description="进程名")):
if process != "youtube":
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
try:
data = await request.json()
@@ -159,8 +233,8 @@ async def save_config(request: Request, process: str = Query(..., description="
# ---------------- 配置路由URL_config.iniyoutube 和 tiktok 共享) ----------------
@app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")):
if process not in ["youtube", "tiktok"]:
return JSONResponse({"error": "youtube 和 tiktok 支持此配置编辑"}, status_code=400)
if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini"
if not config_file.exists():
@@ -173,8 +247,8 @@ async def get_url_config(process: str = Query(..., description="进程名")):
@app.post("/save_url_config")
async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
if process not in ["youtube", "tiktok"]:
return JSONResponse({"error": "youtube 和 tiktok 支持此配置编辑"}, status_code=400)
if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
try:
data = await request.json()
@@ -210,14 +284,15 @@ async def stop(process: str = Query(..., description="进程名")):
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
await asyncio.sleep(2.0)
# 第三步:主动查找并杀死 ffmpeg
await force_kill_ffmpeg(process)
# 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 ffmpeg
if not process.startswith("web"):
await force_kill_ffmpeg(process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop + 强杀 ffmpeg",
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
})
@@ -228,8 +303,8 @@ async def restart(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"restart {process}")
# 重启后也建议清理一次残留(视情况可选)
await force_kill_ffmpeg(process)
if not process.startswith("web"):
await force_kill_ffmpeg(process)
return JSONResponse({"output": output})
@@ -261,6 +336,17 @@ async def status(process: str = Query(..., description="进程名")):
})
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
return {
"entries": [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
for p in PROCESS_MONITOR
]
}
# ---------------- 首页 ----------------
@app.get("/")
async def index():

2
web2.sh Normal file
View File

@@ -0,0 +1,2 @@
# Ubuntu本分支控制台入口与无「2」分支的 web / youtube.py 等区分)
uvicorn web2:app --host 0.0.0.0 --port 8001 --reload