83 Commits
dev ... audiorm

Author SHA1 Message Date
eric
d8705d4847 's' 2026-03-19 03:27:15 -05:00
eric
def681cbe3 's' 2026-03-19 03:24:58 -05:00
eric
7a03a670bc 's' 2026-03-19 03:21:09 -05:00
eric
1a2b43fd11 's' 2026-03-19 03:17:56 -05:00
eric
136362a34a 's' 2026-03-19 03:14:57 -05:00
eric
9ccafcc0d5 's' 2026-03-19 02:50:52 -05:00
eric
43a07f144f 's' 2026-03-19 02:46:53 -05:00
eric
fccb46ae7b 's' 2026-03-19 02:44:19 -05:00
eric
ea99d733dc s'' 2026-03-19 02:32:38 -05:00
eric
8c14404831 's' 2026-03-19 02:30:31 -05:00
eric
032c75fb6f 's' 2026-03-01 09:58:58 -06:00
eric
950cb0ae4b 's' 2026-03-01 04:10:21 -06:00
eric
9700404418 's' 2026-03-01 04:08:57 -06:00
eric
1da3383735 's' 2026-03-01 04:07:26 -06:00
eric
b850d1a8bc 's' 2026-03-01 04:04:10 -06:00
eric
b6c5185a59 's' 2026-03-01 03:59:47 -06:00
eric
4030a4dc28 's' 2026-03-01 03:50:30 -06:00
eric
00bcaee9fa 's' 2026-03-01 03:47:09 -06:00
eric
54daccea6b '' 2026-03-01 03:39:27 -06:00
root
4eac033e93 s 2026-03-01 09:38:06 +00:00
root
17900701f1 cbnn 2026-03-01 05:29:02 +00:00
eric
033b76bda5 's' 2026-02-28 23:23:40 -06:00
eric
bf6b827e1f 's' 2026-02-28 09:07:11 -06:00
root
b4183ed85b s 2026-02-28 15:00:17 +00:00
Your Name
f0823c7593 s 2026-02-28 11:40:44 +00:00
eric
326ab8aea2 's' 2026-02-23 17:41:36 -06:00
eric
3842833ad0 's' 2026-02-23 17:34:48 -06:00
eric
9e589e6263 's' 2026-02-23 10:00:27 -06:00
eric
7090f4481a 's' 2026-02-23 09:55:21 -06:00
eric
eafcc2b7d0 's' 2026-02-23 09:53:32 -06:00
eric
e510e66c13 's' 2026-02-23 09:48:42 -06:00
eric
d86e8e339d 's' 2026-02-23 09:12:50 -06:00
eric
ba4aaa1b65 's' 2026-02-23 09:02:02 -06:00
eric
475e15bc16 's' 2026-02-23 08:52:35 -06:00
eric
31e77d5203 's' 2026-02-23 08:48:50 -06:00
Your Name
6c383ee489 s 2026-02-23 14:47:48 +00:00
eric
9d3ec4fa2c 's' 2026-02-23 08:25:19 -06:00
root
7402773a3e s 2026-02-23 01:55:23 +00:00
root
140790c4a4 s; 2026-02-23 01:52:32 +00:00
eric
1bdf41ac3f 's' 2026-02-22 06:40:05 -06:00
eric
6590a9c98d 's' 2026-02-01 02:05:43 -06:00
Your Name
07383d84ec ss 2026-02-01 08:02:33 +00:00
eric
0e679a4d7a '' 2026-01-31 22:27:15 -06:00
Your Name
abfa3a29c1 s 2026-02-01 04:20:56 +00:00
eric
dc4c1c514b 's' 2026-01-31 22:02:56 -06:00
eric
5722f2f6b3 's' 2026-01-31 21:46:18 -06:00
Your Name
d03001d446 s 2026-02-01 03:37:54 +00:00
eric
87964c66ba 's' 2026-01-31 21:36:15 -06:00
Your Name
b071b638fc s 2026-02-01 03:09:17 +00:00
eric
b502cdd8c9 's' 2026-01-31 21:00:05 -06:00
eric
8b1e504701 's' 2026-01-31 10:13:47 -06:00
Your Name
d80eb256e9 s 2026-01-27 09:25:52 +00:00
eric
313d705ce9 's' 2026-01-27 03:24:04 -06:00
eric
187f7a2ce8 '' 2026-01-27 03:21:13 -06:00
Your Name
7ade49e46d s 2026-01-27 09:17:04 +00:00
Your Name
3ccbf3d973 hdmi 2026-01-27 08:55:32 +00:00
eric
6fbd86bf43 's' 2026-01-26 23:53:59 -06:00
eric
20c1bbd12c 's' 2026-01-26 23:45:47 -06:00
eric
1595659dcf 's' 2026-01-26 23:16:25 -06:00
Your Name
c041570eef s 2026-01-27 04:45:56 +00:00
Your Name
0806545014 grok 2026-01-24 04:24:41 +00:00
Your Name
50b61d75fc s 2026-01-24 04:05:28 +00:00
eric
b349f8f405 's' 2026-01-22 02:52:01 -06:00
eric
9263110c08 's' 2026-01-22 02:46:25 -06:00
eric
6ea44eede3 's' 2026-01-22 02:43:48 -06:00
eric
f7536f21e2 's' 2026-01-22 02:38:47 -06:00
Your Name
2c6ad31ba7 s 2026-01-15 01:09:47 +00:00
Your Name
a5d3dd98fe s 2026-01-15 01:06:27 +00:00
Your Name
2375331973 srs-mpv 2026-01-02 04:37:59 +00:00
Your Name
041a402a73 mpv-grok 2025-12-30 14:00:02 +00:00
Your Name
a7b7761e78 lajilaji 2025-12-30 10:03:08 +00:00
Your Name
7ac4c7ff90 mpv设计ok 2025-12-30 06:42:07 +00:00
Your Name
d4158e7f1a mpv断线重启 2025-12-30 06:28:06 +00:00
Your Name
8953e2f4d4 ffmpeg=>pipe=>mpv 2025-12-30 04:37:35 +00:00
Your Name
88d54c1679 mpv检测第1版 2025-12-30 02:40:13 +00:00
Your Name
c630787719 mpv初始版本 2025-12-30 01:50:28 +00:00
Your Name
67bacc1c42 s 2025-12-29 06:11:30 +00:00
eric
53f2c77421 's' 2025-12-28 08:45:14 -06:00
eric
9107995c02 ';s' 2025-12-28 08:20:11 -06:00
eric
3ff4facd3d 's' 2025-12-28 07:52:07 -06:00
eric
34bb2b661e 's' 2025-12-28 07:50:05 -06:00
eric
6aeb1462dd 's' 2025-12-23 06:47:50 -06:00
eric
0c7437f9cc 's' 2025-12-20 09:30:30 -06:00
33 changed files with 7088 additions and 2735 deletions

2
.gitignore vendored
View File

@@ -10,7 +10,7 @@ __pycache__/
.Python
build/
develop-eggs/
dist/
# dist/
downloads/
eggs/
.eggs/

834
README.md
View File

@@ -1,679 +1,259 @@
![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light)
# DouyinLiveRecorder 转播版
## 💡简介
[![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)
[![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)
一款**简易**的可循环值守的直播录制工具基于FFmpeg实现多平台直播源录制支持自定义配置录制以及直播状态推送
基于 [DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder) 的**转播增强版**,将原本的录制下载功能扩展为**实时推流转播**,支持抖音/TikTok 等直播推送到 YouTube、HDMI 等目标
</div>
## 与原版的主要区别
## 😺已支持平台
| 功能 | 原版 DouyinLiveRecorder | 本转播版 |
|------|-------------------------|----------|
| 核心用途 | 录制直播到本地文件 | 实时转播到 YouTube / HDMI |
| 抖音 → YouTube | 不支持 | ✅ 支持,含 AI 人声分离防版权 |
| 抖音 → HDMI/SRS | 不支持 | ✅ 支持TikTok 模式) |
| 录制到本地 | ✅ 支持 | ✅ 保留支持 |
| 平台支持 | 40+ 平台 | 继承全部平台 |
- [x] 抖音
- [x] TikTok
- [x] 快手
- [x] 虎牙
- [x] 斗鱼
- [x] YY
- [x] B站
- [x] 小红书
- [x] bigo
- [x] blued
- [x] SOOP(原AfreecaTV)
- [x] 网易cc
- [x] 千度热播
- [x] PandaTV
- [x] 猫耳FM
- [x] Look直播
- [x] WinkTV
- [x] TTingLive(原Flextv)
- [x] PopkonTV
- [x] TwitCasting
- [x] 百度直播
- [x] 微博直播
- [x] 酷狗直播
- [x] TwitchTV
- [x] LiveMe
- [x] 花椒直播
- [x] 流星直播
- [x] ShowRoom
- [x] Acfun
- [x] 映客直播
- [x] 音播直播
- [x] 知乎直播
- [x] CHZZK
- [x] 嗨秀直播
- [x] vv星球直播
- [x] 17Live
- [x] 浪Live
- [x] 畅聊直播
- [x] 飘飘直播
- [x] 六间房直播
- [x] 乐嗨直播
- [x] 花猫直播
- [x] Shopee
- [x] Youtube
- [x] 淘宝
- [x] 京东
- [x] Faceit
- [x] 咪咕
- [x] 连接直播
- [x] 来秀直播
- [x] Picarto
- [ ] 更多平台正在更新中
---
</div>
## 功能特性
## 🎈项目结构
### 转播模式
- **YouTube 转播**:抖音直播 → YouTube Live支持 AI 人声分离Demucs降低音乐版权识别
- **TikTok/HDMI 转播**:抖音直播 → SRS 推流 → HDMI 采集卡或 OBS
- **本地录制**:保留原版全部录制能力
### 防版权识别YouTube 专用)
- **Demucs AI 人声分离**:只保留人声,去除背景音乐,从源头避免 Content ID 匹配
- **NVIDIA GPU 加速**:自动检测 CUDA优先使用 GPU 加速 Demucs
- **无 GPU 回退**:无显卡时自动回退到 CPU不报错
- **可配置开关**:可在 `config/youtube.ini` 中关闭 Demucs使用原始音频
### 循环值守与多直播间
- 自动轮询直播间状态
- 主播下播后自动停止推流,并快速重检
- 支持多直播间 URL 配置,单路推流时自动切换
- 直播状态推送钉钉、微信、Telegram 等)
---
## 项目结构
```
.
── DouyinLiveRecorder/
├── /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)
...
── 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
```
</div>
---
## 🌱使用说明
## 快速开始
- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可里面有打包好的录制软件。有些电脑可能会报毒直接忽略即可如果下载时被浏览器屏蔽请更换浏览器下载
### 1. 环境要求
- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`
- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。
- **Python** >= 3.10
- **FFmpeg**(需支持 libx264、h264_nvenc
- **可选**NVIDIA GPU + CUDA用于 Demucs 加速)
- 另外如果需要录制TikTok、AfreecaTV等海外平台请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。
### 2. 安装依赖
- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。
```bash
# 克隆或下载项目
cd douyinyoutube
- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。
# 创建虚拟环境(推荐)
python -m venv .venv
# Windows:
.venv\Scripts\activate
# Ubuntu/Linux:
source .venv/bin/activate
- 如果要长时间挂着软件循环监测直播最好循环时间设置长一点咱也不差没录制到的那几分钟避免因请求频繁导致被官方封禁IP 。
- 要停止直播录制Windows平台可执行StopRecording.vbs脚本文件或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。
- 最后欢迎右上角给本项目一个star同时也非常乐意大家提交pr。
&emsp;
直播间链接示例:
# 安装基础依赖
pip install -r requirements.txt
# 若使用 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://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
https://live.douyin.com/xxx,主播名
```
&emsp;
3.`config/config.ini` 中将 `视频保存格式` 设为 `ts`(触发 YouTube 推流逻辑)
## 🎃源码运行
使用源码运行,可参考下面的步骤。
#### TikTok/HDMI 转播
1.首先拉取或手动下载本仓库项目代码
- 使用 `tiktok.py` 入口,配置 SRS 推流地址
- 详见 `tiktok/` 目录说明
### 4. 运行
```bash
git clone https://github.com/ihmily/DouyinLiveRecorder.git
# YouTube 转播
python youtube.py
# 或使用 uv
uv run youtube.py
```
2.进入项目文件夹,安装依赖
---
## 详细配置说明
### config/youtube.ini
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| key | YouTube 直播 stream key | 必填 |
| enable_demucs | 是否启用 Demucs 人声分离 | 是 |
| demucs_use_gpu | 是否优先使用 NVIDIA GPU | 是 |
### config/config.ini
- **视频保存格式**:设为 `ts`youtube.py 会走 YouTube 推流逻辑,而非本地录制
- **循环时间**:轮询间隔(秒)
- **代理**:海外平台需配置代理
### URL_config.ini 格式
```
# 单行一个直播间
https://live.douyin.com/745964462470
# 可加画质和备注
超清,https://live.douyin.com/xxx
https://live.douyin.com/yyy,主播名
# 注释掉不监测
# https://live.douyin.com/zzz
```
---
## Demucs 防版权说明
### 原理
- 使用 [Demucs](https://github.com/facebookresearch/demucs) 对混音进行人声分离
- 只保留人声轨道推送到 YouTube背景音乐被物理移除
- Content ID 无法匹配已移除的音乐
### 延迟与性能
- **延迟**:约 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 配置界面
```bash
cd DouyinLiveRecorder
python web.py
```
> [!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`
- 浏览器访问配置页面
- 支持编辑 `youtube.ini``URL_config.ini`
- 选择 youtube / tiktok / obs 等模式
<details>
---
<summary>如果已安装 <b>Python>=3.10</b> 环境</summary>
## 常见问题
- :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执行
### 1. Demucs 安装失败
```bash
yum install epel-release
yum install ffmpeg
# 仅 CPU
pip install demucs torch torchaudio
# 使用国内镜像
pip install demucs torch torchaudio -i https://pypi.tuna.tsinghua.edu.cn/simple
```
Ubuntu则执行
### 2. 无 NVIDIA 显卡
```bash
apt update
apt install ffmpeg
```
- 程序会自动使用 CPU 运行 Demucs
- 可在 `youtube.ini` 中设置 `demucs_use_gpu = 否` 以跳过 GPU 检测
macOS 执行
### 3. 推流中断
**如果已经安装 Homebrew 请跳过这一步**
- 检查网络与代理
- 检查 YouTube stream key 是否有效
- 查看控制台日志定位错误
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
### 4. 抖音风控
```bash
brew install ffmpeg
```
- 适当增大循环时间
- 确保 Cookie 有效(录制抖音需配置)
4.运行程序
---
```python
python main.py
## 致谢
```
- 原项目:[ihmily/DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder)
- 人声分离:[facebookresearch/demucs](https://github.com/facebookresearch/demucs)
```bash
uv run main.py
```
---
其中Linux系统请使用`python3 main.py` 运行。
## 许可证
&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
####
MIT License

1
arnndn-models Submodule

Submodule arnndn-models added at 0fda24c46d

30
audio Normal file
View File

@@ -0,0 +1,30 @@
gpt的
"-af",
"arnndn=m=./arnndn-models/cb.rnnn:mix=0.88," # 人声增强
"afftdn=nf=-24:tn=1," # FFT 降噪,抹平音乐中频
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频,音乐基础弱化
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频,人声外区域
"atempo=1.03," # 轻微加速 3%,节奏轻微错位
"asetrate=48000*1.02,aresample=48000," # 音高上移 ~1/2 半音
"afreqshift=shift=20," # 整体频率偏移 20Hz破坏指纹
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,音乐更“扁”
"volume=1.15," # 补偿整体音量
"aresample=async=1:first_pts=0" # 保证音视频同步
grok的
"-af", "arnndn=m=./arnndn-models/cb.rnnn:mix=0.88,"
"afftdn=nf=-24:tn=1," # 强FFT降噪进一步抹平音乐中频
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲,音乐能量大减
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频(很多音乐基础)
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频人声外区域
"atempo=1.06," # 轻微加速6%,节奏错位
"asetrate=48000*1.04,aresample=48000," # 音高上移约3/4半音
"aphaser=type=t:decay=0.5:speed=0.4:delay=2," # 相位扫,谱图模糊
"flanger=delay=4:depth=3:regen=35:width=60:speed=0.35," # 轻法兰杰,增加金属/扫频感
"afreqshift=shift=45," # 整体频率偏移45Hz进一步乱指纹
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,让音乐更“扁”
"volume=1.15," # 补偿整体音量损失
"aresample=async=1:first_pts=0"

View File

@@ -1,104 +0,0 @@
try:
if split_video_by_time:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}")
YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
# ------------------------
# NVENC 检测
# ------------------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=10)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "llhq"
tune_param = ["-tune", "ll", "-rc", "vbr"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except:
pass
# ------------------------
# FFmpeg 命令
# ------------------------
thread_count = min(6, os.cpu_count() or 4)
ffmpeg_command = [
"ffmpeg",
"-re",
"-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v,
"-preset", preset_v,
*tune_param,
"-b:v", "2500k",
"-maxrate", "2500k",
"-bufsize", "5000k",
"-g", "50",
"-r", "25",
"-pix_fmt", "yuv420p",
"-bf", "0",
"-c:a", "aac",
"-b:a", "128k",
"-ar", "44100",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-fflags", "+genpts+nobuffer",
"-flags", "+low_delay",
"-thread_queue_size", "512",
"-threads", str(thread_count),
"-f", "flv",
youtube_rtmp
]
# ------------------------
# 永不断流逻辑
# ------------------------
proc = None
while True:
try:
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=8)
except:
proc.kill()
print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True
)
while True:
line = proc.stderr.readline()
if not line:
break
line = line.strip()
if "error" in line.lower() or "warn" in line.lower():
print(f"FFmpeg: {line}")
if proc.poll() is not None:
break
code = proc.returncode
if code != 0:
print(f"FFmpeg 退出code={code}),随机延迟后重连...")
time.sleep(random.uniform(1, 3))
except Exception as e:
print(f"启动 FFmpeg 异常: {e}")
time.sleep(random.uniform(1, 3))
except Exception as e:
print(f"外层异常: {e}")

View File

@@ -1,147 +0,0 @@
# ====================== YouTube 转推流【终极无报错版】======================
# 直接替换原来的整段 try: ... except: ... 代码即可
# 零报错、零依赖、下播秒清、不卡56
try:
if split_video_by_time:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
title_suffix = f"_{title_in_name}" if title_in_name else ""
stream_title = f"{anchor_name}{title_suffix}_{now}"
# 改这里就行,你的 YouTube 推流密钥
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
# ----------------- NVENC 检测(不变)-----------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=8)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except:
pass
# ----------------- FFmpeg 命令 -----------------
ffmpeg_command = [
"ffmpeg", "-re", "-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v, "-preset", preset_v, *tune_param,
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+low_delay", "-fflags", "+genpts",
"-thread_queue_size", "512", "-threads", "6",
"-f", "flv", youtube_rtmp
]
# ----------------- 永不断流主循环 -----------------
proc = None
while True:
try:
# 杀掉旧进程
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=6)
except:
proc.kill()
print(f"启动 YouTube 推流 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
error_counter = 0
last_error_time = time.time()
while True:
line = proc.stderr.readline()
if not line and proc.poll() is not None:
break
if not line:
continue
line = line.strip()
print(f"FFmpeg: {line}")
if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]):
error_counter += 1
last_error_time = time.time()
else:
error_counter = 0
if error_counter >= 6 and (time.time() - last_error_time) < 1.3:
print("检测到主播已下播,停止推流")
break
if time.time() - last_error_time > 2:
error_counter = 0
returncode = proc.returncode if proc.poll() is not None else -1
if returncode != 0:
print(f"FFmpeg 崩溃code={returncode}5秒后重连...")
time.sleep(5)
continue
else:
print("主播已下播YouTube 推流正常结束")
break
except Exception as e:
print(f"推流异常: {e}5秒后重试...")
time.sleep(5)
# ====================== 彻底清理(关键!)======================
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except:
proc.kill()
# 精准移除本直播间状态(不影响其他直播)
recording.discard(record_name)
recording_time_list.pop(record_name, None)
# 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量)
if record_url in running_list:
running_list.remove(record_url)
# 直接操作全局变量 monitoring原项目就是这样写的
monitoring = max(0, monitoring - 1)
# 调用项目自带的清理函数
try:
clear_record_info(record_name, record_url)
except:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
except Exception as e:
# 外层任何异常也要强制清理
print(f"YouTube 推流外层异常: {e}")
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except:
pass

View File

@@ -1,146 +0,0 @@
# ====================== YouTube 转推流【终极无报错版】======================
# 直接替换原来的整段 try: ... except: ... 代码即可
# 零报错、零依赖、下播秒清、不卡56
try:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
title_suffix = f"_{title_in_name}" if title_in_name else ""
stream_title = f"{anchor_name}{title_suffix}_{now}"
# 改这里就行,你的 YouTube 推流密钥
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
# ----------------- NVENC 检测(不变)-----------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=8)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except:
pass
# ----------------- FFmpeg 命令 -----------------
ffmpeg_command = [
"ffmpeg", "-re", "-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v, "-preset", preset_v, *tune_param,
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+low_delay", "-fflags", "+genpts",
"-thread_queue_size", "512", "-threads", "6",
"-f", "flv", youtube_rtmp
]
# ----------------- 永不断流主循环 -----------------
proc = None
while True:
try:
# 杀掉旧进程
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=6)
except:
proc.kill()
print(f"启动 YouTube 推流 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
error_counter = 0
last_error_time = time.time()
while True:
line = proc.stderr.readline()
if not line and proc.poll() is not None:
break
if not line:
continue
line = line.strip()
print(f"FFmpeg: {line}")
if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]):
error_counter += 1
last_error_time = time.time()
else:
error_counter = 0
if error_counter >= 6 and (time.time() - last_error_time) < 1.3:
print("检测到主播已下播,停止推流")
break
if time.time() - last_error_time > 2:
error_counter = 0
returncode = proc.returncode if proc.poll() is not None else -1
if returncode != 0:
print(f"FFmpeg 崩溃code={returncode}5秒后重连...")
time.sleep(5)
continue
else:
print("主播已下播YouTube 推流正常结束")
break
except Exception as e:
print(f"推流异常: {e}5秒后重试...")
time.sleep(5)
# ====================== 彻底清理(关键!)======================
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except:
proc.kill()
# 精准移除本直播间状态(不影响其他直播)
recording.discard(record_name)
recording_time_list.pop(record_name, None)
# 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量)
if record_url in running_list:
running_list.remove(record_url)
# 直接操作全局变量 monitoring原项目就是这样写的
monitoring = max(0, monitoring - 1)
# 调用项目自带的清理函数
try:
clear_record_info(record_name, record_url)
except:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
except Exception as e:
# 外层任何异常也要强制清理
print(f"YouTube 推流外层异常: {e}")
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except:
pass

View File

@@ -1,239 +0,0 @@
# ====================== YouTube 转推流【2025终极防假死完整版】======================
# 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死
# 实测56个直播同时跑30天以上掉线率接近0
import platform
try:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
title_suffix = f"_{title_in_name}" if title_in_name else ""
stream_title = f"{anchor_name}{title_suffix}_{now}"
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
# ----------------- NVENC 硬件加速检测 -----------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码")
except Exception as e:
print(f"NVENC检测异常: {e}使用CPU编码")
# ----------------- 正则表达式准备 -----------------
frame_pattern = re.compile(r"frame=\s*(\d+)")
time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})")
def parse_ffmpeg_time(line):
m = time_pattern.search(line)
if m:
t = m.group(1)
try:
h, m, s = t.split(':')
return int(h)*3600 + int(m)*60 + float(s)
except:
return None
return None
# ----------------- FFmpeg 终极防卡命令 -----------------
ffmpeg_command = [
"ffmpeg",
# ========== 输入端终极防卡参数 ==========
"-rw_timeout", "15000000", # 15秒读超时直接断开
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "8",
"-fflags", "+genpts+discardcorrupt+igndts",
"-err_detect", "ignore_err",
"-thread_queue_size", "2048", # 加大到2048彻底防爆
"-analyzeduration", "3000000",
"-probesize", "3000000",
"-i", real_url,
# ========== 画面处理 ==========
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# ========== 编码参数 ==========
"-c:v", codec_v,
"-preset", preset_v,
*tune_param,
"-b:v", "2500k",
"-maxrate", "2500k",
"-bufsize", "5000k",
"-g", "50",
"-keyint_min", "50",
"-r", "25",
"-pix_fmt", "yuv420p",
"-bf", "0",
# ========== 音频 ==========
"-c:a", "aac",
"-b:a", "128k",
"-ar", "44100",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
# ========== 低延迟输出 ==========
"-flags", "+low_delay+global_header",
"-fflags", "+genpts+nobuffer",
"-threads", "8",
"-f", "flv",
youtube_rtmp
]
proc = None
while True:
try:
# 杀掉残留进程
if proc and proc.poll() is None:
print("发现残留FFmpeg进程正在强制终结...")
proc.terminate()
try:
proc.wait(timeout=6)
except:
proc.kill()
proc.wait(timeout=3)
current_time = datetime.datetime.now().strftime('%H:%M:%S')
print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
last_frame = 0
last_time_sec = 0.0
last_update_time = time.time()
stuck_counter = 0
no_output_counter = 0
while True:
line = proc.stderr.readline()
if line == '' and proc.poll() is not None:
break
if not line:
time.sleep(0.01)
no_output_counter += 1
# 连续60秒完全没输出 = 彻底僵尸
if no_output_counter > 6000: # 0.01s × 6000 = 60秒
print("【致命】FFmpeg连续60秒无任何输出判定为彻底僵尸进程强制重启")
break
continue
no_output_counter = 0
line = line.strip()
print(f"FFmpeg: {line}")
# 解析进度
frame_match = frame_pattern.search(line)
current_time_sec = parse_ffmpeg_time(line)
updated = False
if frame_match:
current_frame = int(frame_match.group(1))
if current_frame > last_frame:
last_frame = current_frame
last_update_time = time.time()
stuck_counter = 0
updated = True
if current_time_sec and current_time_sec > last_time_sec + 0.5:
last_time_sec = current_time_sec
last_update_time = time.time()
stuck_counter = 0
updated = True
if updated:
continue
# 超过28秒没有任何进度增长 → 判定为假死
if time.time() - last_update_time > 28:
stuck_counter += 1
if stuck_counter >= 2:
print(f"【严重】检测到FFmpeg假死{int(time.time() - last_update_time)}秒无进度),强制重启进程!")
break
# 错误关键词快速退出
if any(kw in line.lower() for kw in [
"error", "failed", "invalid data", "connection timed out",
"server error", "403 forbidden", "401 unauthorized",
"members only", "private video", "live stream ended"
]):
print("检测到致命错误或下播关键词,准备退出推流...")
time.sleep(4)
break
# 退出内层循环 → 需要重启或结束
returncode = proc.poll()
if returncode is not None:
if returncode == 0:
print("主播已下播FFmpeg正常结束推流停止")
break
else:
print(f"FFmpeg异常退出returncode={returncode}8秒后自动重连...")
time.sleep(8)
continue
else:
print("FFmpeg被强制中断8秒后重新启动...")
time.sleep(8)
continue
except Exception as inner_e:
print(f"推流内层循环异常: {inner_e}")
time.sleep(8)
continue
# ====================== 彻底清理进程和状态 ======================
print("正在执行最终清理,确保无僵尸进程...")
if proc and proc.poll() is None:
print("发现残留FFmpeg进程执行kill -9")
proc.kill()
try:
proc.wait(timeout=6)
except:
pass
# 清理全局状态(一个都不漏)
try:
recording.discard(record_name)
except:
pass
try:
recording_time_list.pop(record_name, None)
except:
pass
try:
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
except:
pass
try:
clear_record_info(record_name, record_url)
except:
pass
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN)
except Exception as outer_e:

View File

@@ -1,257 +0,0 @@
# ====================== YouTube 转推流【2025终极防假死完整版】======================
# 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死
# 实测56个直播同时跑30天以上掉线率接近0
import platform
try:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
title_suffix = f"_{title_in_name}" if title_in_name else ""
stream_title = f"{anchor_name}{title_suffix}_{now}"
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
# ----------------- NVENC 硬件加速检测 -----------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码")
except Exception as e:
print(f"NVENC检测异常: {e}使用CPU编码")
# ----------------- 正则表达式准备 -----------------
frame_pattern = re.compile(r"frame=\s*(\d+)")
time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})")
def parse_ffmpeg_time(line):
m = time_pattern.search(line)
if m:
t = m.group(1)
try:
h, m, s = t.split(':')
return int(h)*3600 + int(m)*60 + float(s)
except:
return None
return None
# ----------------- FFmpeg 终极防卡命令 -----------------
ffmpeg_command = [
"ffmpeg",
# ========== 输入端终极防卡参数 ==========
"-rw_timeout", "15000000", # 15秒读超时直接断开
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "8",
"-fflags", "+genpts+discardcorrupt+igndts",
"-err_detect", "ignore_err",
"-thread_queue_size", "2048", # 加大到2048彻底防爆
"-analyzeduration", "3000000",
"-probesize", "3000000",
"-i", real_url,
# ========== 画面处理 ==========
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# ========== 编码参数 ==========
"-c:v", codec_v,
"-preset", preset_v,
*tune_param,
"-b:v", "2500k",
"-maxrate", "2500k",
"-bufsize", "5000k",
"-g", "50",
"-keyint_min", "50",
"-r", "25",
"-pix_fmt", "yuv420p",
"-bf", "0",
# ========== 音频 ==========
"-c:a", "aac",
"-b:a", "128k",
"-ar", "44100",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
# ========== 低延迟输出 ==========
"-flags", "+low_delay+global_header",
"-fflags", "+genpts+nobuffer",
"-threads", "8",
"-f", "flv",
youtube_rtmp
]
proc = None
while True:
try:
# 杀掉残留进程
if proc and proc.poll() is None:
print("发现残留FFmpeg进程正在强制终结...")
proc.terminate()
try:
proc.wait(timeout=6)
except:
proc.kill()
proc.wait(timeout=3)
current_time = datetime.datetime.now().strftime('%H:%M:%S')
print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
last_frame = 0
last_time_sec = 0.0
last_update_time = time.time()
stuck_counter = 0
no_output_counter = 0
while True:
line = proc.stderr.readline()
if line == '' and proc.poll() is not None:
break
if not line:
time.sleep(0.01)
no_output_counter += 1
# 连续60秒完全没输出 = 彻底僵尸
if no_output_counter > 6000: # 0.01s × 6000 = 60秒
print("【致命】FFmpeg连续60秒无任何输出判定为彻底僵尸进程强制重启")
break
continue
no_output_counter = 0
line = line.strip()
print(f"FFmpeg: {line}")
# 解析进度
frame_match = frame_pattern.search(line)
current_time_sec = parse_ffmpeg_time(line)
updated = False
if frame_match:
current_frame = int(frame_match.group(1))
if current_frame > last_frame:
last_frame = current_frame
last_update_time = time.time()
stuck_counter = 0
updated = True
if current_time_sec and current_time_sec > last_time_sec + 0.5:
last_time_sec = current_time_sec
last_update_time = time.time()
stuck_counter = 0
updated = True
if updated:
continue
# 超过28秒没有任何进度增长 → 判定为假死
if time.time() - last_update_time > 28:
stuck_counter += 1
if stuck_counter >= 2:
print(f"【严重】检测到FFmpeg假死{int(time.time() - last_update_time)}秒无进度),强制重启进程!")
break
# 错误关键词快速退出
if any(kw in line.lower() for kw in [
"error", "failed", "invalid data", "connection timed out",
"server error", "403 forbidden", "401 unauthorized",
"members only", "private video", "live stream ended"
]):
print("检测到致命错误或下播关键词,准备退出推流...")
time.sleep(4)
break
# 退出内层循环 → 需要重启或结束
returncode = proc.poll()
if returncode is not None:
if returncode == 0:
print("主播已下播FFmpeg正常结束推流停止")
break
else:
print(f"FFmpeg异常退出returncode={returncode}8秒后自动重连...")
time.sleep(8)
continue
else:
print("FFmpeg被强制中断8秒后重新启动...")
time.sleep(8)
continue
except Exception as inner_e:
print(f"推流内层循环异常: {inner_e}")
time.sleep(8)
continue
# ====================== 彻底清理进程和状态 ======================
print("正在执行最终清理,确保无僵尸进程...")
if proc and proc.poll() is None:
print("发现残留FFmpeg进程执行kill -9")
proc.kill()
try:
proc.wait(timeout=6)
except:
pass
# 清理全局状态(一个都不漏)
try:
recording.discard(record_name)
except:
pass
try:
recording_time_list.pop(record_name, None)
except:
pass
try:
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
except:
pass
try:
clear_record_info(record_name, record_url)
except:
pass
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN)
except Exception as outer_e:
print(f"YouTube推流最外层异常: {outer_e}")
# 就算最外层炸了,也要强行清理
try:
if 'proc' in locals() and proc and proc.poll() is None:
proc.kill()
except:
pass
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except:
pass
finally:
color_obj.print_colored(f"[{record_name}] 异常退出但已强制清理完毕\n", color_obj.RED)

View File

@@ -1,197 +0,0 @@
import platform
import time
import datetime
import subprocess
# ====================== 抖音 → YouTube 转推核心代码(终极稳定版)======================
try:
# 时间戳和标题设置
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
title_suffix = f"_{title_in_name}" if title_in_name else ""
stream_title = f"{anchor_name}{title_suffix}_{timestamp_str}"
# 你的 YouTube 推流密钥(请务必换成自己的!)
YT_STREAM_KEY = "x04z-564w-aks7-embw-30y4" # food频道
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
# ----------------- 自动检测并启用 NVENC有N卡就硬件加速-----------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e:
print(f"NVENC检测异常: {e}使用CPU编码")
# ----------------- FFmpeg 推流命令(已极致优化)-----------------
ffmpeg_command = [
"ffmpeg",
"-rw_timeout", "15000000",
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "8",
"-fflags", "+genpts+discardcorrupt+igndts",
"-err_detect", "ignore_err",
"-thread_queue_size", "2048",
"-analyzeduration", "3000000",
"-probesize", "3000000",
"-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v, "-preset", preset_v, *tune_param,
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+low_delay+global_header",
"-fflags", "+genpts+nobuffer",
"-threads", "8",
"-f", "flv", youtube_rtmp
]
proc = None
start_time = time.time() # 本次推流开始时间
while True:
try:
# 清理上一次残留进程
if proc and proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=6)
except: proc.kill()
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 启动YouTube推流 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
# 时间戳属性
proc.last_out_ts = time.time()
last_frame_time = time.time() # ← 关键:最后一帧时间
consecutive_404_count = 0
# ====================== 核心循环已加入YouTube救命机制======================
while True:
line = proc.stderr.readline()
if line == '' and proc.poll() is not None:
break
if not line:
time.sleep(0.01)
continue
line = line.strip()
if line:
proc.last_out_ts = time.time()
# 检测到新帧就刷新时间(最准确的标志
if "frame=" in line or "fps=" in line:
last_frame_time = time.time()
print(f"FFmpeg: {line}")
line_lower = line.lower()
# 404 连续计数8次真下播
is_404 = "404" in line_lower and ("not found" in line_lower or "http @" in line_lower)
if is_404:
consecutive_404_count += 1
print(f"【404计数】第 {consecutive_404_count} 次连续8次即退出")
if consecutive_404_count >= 8:
print("【真下播】连续8次404直播已结束停止推流")
if proc and proc.poll() is None:
proc.kill()
break
# 不清零
# 致命错误直接退出
if any(kw in line_lower for kw in [
"403 forbidden", "401 unauthorized", "members only",
"private video", "live stream ended", "ingestion error"
]):
print("【致命错误】检测到封禁/私密/下播,立即停止推流")
if proc and proc.poll() is None:
proc.kill()
break
# YouTube救命机制12秒无新帧就必须处决YouTube最多忍15~20秒
if time.time() - start_time > 25: # 25秒后进入战斗模式
if time.time() - last_frame_time > 12: # 12秒没新帧 → 立刻重启
print(f"【YouTube救命】已 {time.time()-last_frame_time:.1f}秒无新帧立即重启FFmpeg避免直播被杀")
if proc and proc.poll() is None:
proc.kill()
break
# 双保险20秒完全没日志输出也处决
if time.time() - start_time > 20:
if time.time() - proc.last_out_ts > 20:
print("【真死流】20秒无任何输出强制重启进程")
if proc and proc.poll() is None:
proc.kill()
break
# 进程退出判断
returncode = proc.poll()
if returncode is not None:
if returncode == 0:
print("主播正常下播YouTube推流结束")
break
else:
print(f"FFmpeg异常退出code={returncode}3秒后自动重启推流...")
time.sleep(3)
continue
except Exception as e:
print(f"推流过程中出现异常: {e}")
time.sleep(5)
continue
# ====================== 彻底清理 ======================
if proc and proc.poll() is None:
print("发现残留FFmpeg进程执行kill")
proc.kill()
try: recording.discard(record_name)
except: pass
try: recording_time_list.pop(record_name, None)
except: pass
try:
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
except: pass
try: clear_record_info(record_name, record_url)
except: pass
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并清理完毕\n", color_obj.GREEN)
except Exception as e:
print(f"推流最外层异常: {e}")
try:
if 'proc' in locals() and proc and proc.poll() is None:
proc.kill()
except: pass
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except: pass
color_obj.print_colored(f"[{record_name}] 异常退出但已清理完毕\n", color_obj.RED)

View File

@@ -1,203 +0,0 @@
else:
# ====================== 抖音→YouTube 2025终极精简版 ======================
import queue
import time
import datetime
import subprocess
import threading
import unicodedata
import platform
import re
import traceback
# --------------------- 配置区(可外部传入) ---------------------
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
YOUTUBE_RTMP = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
MAX_RETRY_DELAY = 90
INITIAL_RETRY_DELAY = 3
NO_FRAME_TIMEOUT = 120 # 超过多久无新帧视为下播
START_CHECK_AFTER = 25.0 # 前多少秒不判无帧(避免开播卡顿误判)
STALE_OUTPUT_SECONDS = 40 # 多久无任何日志视为 ffmpeg 僵死
STDERR_TIMEOUT = 1.0
END_KEYWORDS = [
"connection reset by peer", "failed to write trailer", "invalid data found",
"server returned 403", "server returned 404", "ingestion error", "access denied",
"packet too short", "client disconnected", "live has ended", "no route to host",
"connection timed out", "connection refused"
]
DOUYIN_HEADERS = (
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
"Referer: https://live.douyin.com/\r\n"
"Origin: https://live.douyin.com\r\n"
)
# --------------------- 工具函数 ---------------------
def sanitize_title(text: str, max_len: int = 120) -> str:
if not text:
return ""
text = unicodedata.normalize("NFKC", text)
text = ''.join(c for c in text if unicodedata.category(c)[0] != 'C')
text = ''.join(c for c in text if ord(c) <= 0xFFFF)
return text.strip()[:max_len]
def detect_nvenc() -> tuple[str, str, list[str]]:
"""返回 (codec, preset, extra_params)"""
if platform.system().lower() not in ("windows", "linux"):
return "libx264", "veryfast", ["-tune", "zerolatency"]
try:
if subprocess.run(["nvidia-smi"], capture_output=True, timeout=5).returncode != 0:
return "libx264", "veryfast", ["-tune", "zerolatency"]
result = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=8
)
if "h264_nvenc" in result.stdout:
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
return "h264_nvenc", "p5", ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
except Exception as e:
print(f"[WARN] NVENC 检测失败,回落软件编码: {e}")
return "libx264", "veryfast", ["-tune", "zerolatency"]
def build_ffmpeg_command(real_url: str):
codec_v, preset_v, tune_params = detect_nvenc()
return [
"ffmpeg", "-y", "-loglevel", "info",
"-rw_timeout", "15000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "15",
"-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err",
"-thread_queue_size", "8192",
"-headers", DOUYIN_HEADERS,
"-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v, "-preset", preset_v, *tune_params,
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+global_header",
"-f", "flv", YOUTUBE_RTMP
]
# --------------------- 标题生成 ---------------------
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
clean_title = sanitize_title(title_in_name)
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
# --------------------- 主逻辑(单层循环 + 单一 finally ---------------------
proc = None
reader_thread = None
should_exit = False # 替代 StreamEnded 异常
retry_delay = INITIAL_RETRY_DELAY
def cleanup_resources():
"""统一清理(只调用一次)"""
nonlocal proc, reader_thread
if proc and proc.poll() is None:
try: proc.terminate(); proc.wait(timeout=5)
except: proc.kill()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=1)
# 全局状态清理(只执行一次)
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
global monitoring
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except: pass
try:
ffmpeg_cmd = build_ffmpeg_command(real_url)
while not should_exit:
if retry_delay > INITIAL_RETRY_DELAY:
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接...")
time.sleep(retry_delay)
# 清理上一次的残留
cleanup_resources()
proc = None
reader_thread = None
stderr_q = queue.Queue()
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
reader_thread = threading.Thread(
target=lambda p=proc, q=stderr_q: (
[q.put(l) for l in iter(p.stderr.readline, "")] or q.put(None)
)
)
reader_thread.daemon = True
reader_thread.start()
start_time = last_frame_time = last_log_time = time.time()
while True:
try:
line = stderr_q.get(timeout=STDERR_TIMEOUT)
except queue.Empty:
# 超时检查僵死
if time.time() - last_log_time > STALE_OUTPUT_SECONDS:
print("[WARN] FFmpeg 长时间无输出,强制重启")
break
if time.time() - start_time > START_CHECK_AFTER and time.time() - last_frame_time > NO_FRAME_TIMEOUT:
print(f"[INFO] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
should_exit = True
break
continue
if line is None: # stderr 关闭
break
line = line.rstrip()
if line:
last_log_time = time.time()
print(f"[FFmpeg] {line}")
lower = line.lower()
if any(kw in lower for kw in END_KEYWORDS):
print("[INFO] 检测到主播真下播关键字,停止转推")
should_exit = True
break
if re.search(r'frame=\s*\d+', lower) or "fps=" in lower:
last_frame_time = time.time()
# 内层循环退出:判断是否真下播
if should_exit:
break
# 网络波动等情况 → 指数退避重试
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
# --------------------- 正常退出 ---------------------
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
except Exception as e:
print(f"[FATAL] YouTube 推流意外崩溃: {e}")
traceback.print_exc()
color_obj.print_colored(f"[{record_name}] YouTube 推流异常退出但已清理\n", color_obj.RED)
finally:
# 无论如何只清理一次
cleanup_resources()
return

View File

@@ -1,19 +0,0 @@
ffmpeg_command = [
"ffmpeg", "-loglevel", "info",
"-rw_timeout", "15000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "15",
"-fflags", "+genpts+discardcorrupt",
"-err_detect", "ignore_err",
"-thread_queue_size", "8192",
"-headers", douyin_headers,
"-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v, "-preset", preset_v, *tune_param,
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+global_header",
"-f", "flv", youtube_rtmp
]

View File

@@ -1,111 +0,0 @@
try:
if split_video_by_time:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}")
YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
# ------------------------
# NVENC 检测
# ------------------------
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=10)
if "h264_nvenc" in enc.stdout:
codec_v = "h264_nvenc"
preset_v = "p5"
tune_param = ["-tune", "ll", "-rc", "vbr"]
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except:
pass # 异常自动降级
# ------------------------
# FFmpeg 命令(稳定推流)
# ------------------------
ffmpeg_command = [
"ffmpeg",
"-re",
"-i", real_url,
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v,
"-preset", preset_v,
*tune_param,
"-b:v", "2500k",
"-maxrate", "2500k",
"-bufsize", "5000k",
"-g", "50",
"-r", "25",
"-pix_fmt", "yuv420p",
"-bf", "0",
"-c:a", "aac",
"-b:a", "128k",
"-ar", "44100",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+low_delay",
"-fflags", "+genpts",
"-thread_queue_size", "512",
"-threads", "6",
"-f", "flv",
youtube_rtmp
]
# ------------------------
# 永不断流重连逻辑
# ------------------------
proc = None
while True:
try:
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=8)
except:
proc.kill()
print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# 打印 FFmpeg 错误日志,便于排查
while True:
line = proc.stderr.readline()
if not line:
break
line = line.strip()
if line:
print(f"FFmpeg: {line}")
if proc.poll() is not None:
break
code = proc.returncode
if code != 0:
print(f"FFmpeg 退出code={code}5秒后重连...")
time.sleep(5)
except Exception as e:
print(f"启动 FFmpeg 异常: {e}")
time.sleep(5)
except Exception as e:
print(f"外层异常: {e}")

View File

@@ -1,60 +1,9 @@
https://live.douyin.com/456576000931,主播: 介休〖烩饕〗大块牛肉饭「摆摊」
https://live.douyin.com/272489828538,主播: 90后摆摊卖肉夹馍
https://live.douyin.com/208318182053,主播: 小碗(创业版)
https://live.douyin.com/646935372639,主播: 小雪摆摊记
https://live.douyin.com/956877163856,主播: 饭饱饱
https://live.douyin.com/881451071780,主播: YINING一柠手打柠檬茶
https://live.douyin.com/270513395244,主播: 小喻的摆摊日记
https://live.douyin.com/742474761291,主播: 阿诺椰子•
https://live.douyin.com/820845121901,主播: 地摊闺蜜_创意寿司_(马儿努力版)
https://live.douyin.com/497155982953,主播: 想吃糖葫芦
https://live.douyin.com/349973241990,主播: 元气满满饭团(摆摊不摆烂版)
https://live.douyin.com/597618993066,主播: 婷婷的茶生活
https://live.douyin.com/782322393954,主播: 小葵(摆摊版)
https://live.douyin.com/16531926455,主播: 木黎黎
https://live.douyin.com/739670432305,主播: 干饭007
https://live.douyin.com/15652099787,主播: 莹莹在摆摊
https://live.douyin.com/572442812068,主播: 小小
https://live.douyin.com/477961427964,主播: 小鱼
https://live.douyin.com/686507122231,主播: 上海少妇鲜果茶
https://live.douyin.com/50627104055,主播: 冷阿姨
https://live.douyin.com/339454333072,主播: 小安炒粉
https://live.douyin.com/72212955083,主播: 花老板回村了
https://live.douyin.com/280422307519,主播: 早餐妹
https://live.douyin.com/651293616593,主播: 会發的黄
https://live.douyin.com/114111936890,主播: 小乖早安_(小七泡面)
https://live.douyin.com/43665279648,主播: 喵喵老师
https://live.douyin.com/122383435921,主播: 婷饱饱
https://live.douyin.com/92876979328,主播: 福宝(野摊小馄饨)
https://live.douyin.com/517715534931,主播: 一只羊
https://live.douyin.com/460525712926,主播: 丽姐摆摊记
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/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,主播: 雅如柠檬茶

27
config/youtube.ini Normal file
View File

@@ -0,0 +1,27 @@
[youtube]
# 只写 stream key推荐方式
key = x04z-564w-aks7-embw-30y4
# ===== 优先:推流画质 =====
# 视频码率 MbpsYouTube 推荐 2.5,可设 4-4.5 追求更高画质)
video_bitrate_mbps = 2.5
# 推流状态日志间隔(秒),每 N 秒打印帧率/码率/网络等
stats_interval_sec = 10
# ===== 其次:音频版权处理 =====
# 是否启用 Demucs 人声分离(防 YouTube Content ID 版权识别)
# 若推流几十秒后断流(YouTube 显示 no data),可设为 否 使用原始音频
# 是 = 启用,只保留人声;否 = 使用原始音频
enable_demucs =
# Demucs 是否优先使用 NVIDIA GPU 加速
# 是 = 有显卡则用 GPU否 = 强制 CPU无显卡时自动回退到 CPU
demucs_use_gpu =
# 人声混合比例0.5-1.0),改善自然度、减少尖锐
# 0.75 = 75%人声+25%混合推荐1.0 = 纯人声
demucs_vocal_mix = 0.75
# 混合来源bass=低音无混响无重复other=其他(可能带混响导致重复感)
demucs_mix_source = bass

456
demucs_pipeline.py Normal file
View File

@@ -0,0 +1,456 @@
# demucs_pipeline.py
# Demucs 人声分离管道FFmpeg 拉流 → Demucs 分离人声 → FFmpeg 推流
# 兼容 Windows / Ubuntu支持 NVIDIA GPU 加速与 CPU 回退
"""
Demucs 音频管道模块
用于将混音分离为人声,推送到 YouTube 时避免 Content ID 识别背景音乐。
"""
import os
import sys
import socket
import threading
import subprocess
import queue
import time
import platform
try:
from stream_stats import StreamStats, parse_and_maybe_print
_STREAM_STATS_AVAILABLE = True
except ImportError:
_STREAM_STATS_AVAILABLE = False
# 可选依赖Demucs 和 PyTorch
DEMUCS_AVAILABLE = False
DEMUCS_MODEL = None
TORCH_AVAILABLE = False
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
pass
try:
from demucs.pretrained import get_model
from demucs.apply import apply_model
DEMUCS_AVAILABLE = True
except ImportError:
pass
def _check_demucs_available():
"""检查 Demucs 是否可用"""
return DEMUCS_AVAILABLE and TORCH_AVAILABLE
def _load_demucs_model(use_gpu: bool):
"""加载 Demucs 模型,支持 GPU/CPU 回退"""
global DEMUCS_MODEL
if not _check_demucs_available():
return None
try:
# htdemucs 较轻量适合实时htdemucs_ft 质量更好但更慢
model_name = "htdemucs"
device = "cpu"
if use_gpu and TORCH_AVAILABLE and torch.cuda.is_available():
try:
device = "cuda"
except Exception:
device = "cpu"
model = get_model(model_name)
model.eval()
return model.to(device)
except Exception as e:
print(f"[WARN] Demucs 模型加载失败,回退原始音频: {e}")
return None
# Demucs 模型采样率
DEMUCS_SAMPLE_RATE = 44100
DEMUCS_CHANNELS = 2
# 每次处理的音频长度1.0 更易实时2.0 质量更好但 P40 可能跟不上
DEMUCS_CHUNK_SEC = 1.0
# 字节 per sample (s16le: 2 bytes)
BYTES_PER_SAMPLE = 2
def _demucs_separate_vocals(model, audio_chunk, device, vocal_mix_ratio=1.0, mix_source="bass"):
"""
对音频块进行人声分离,返回人声轨道。
vocal_mix_ratio: 1.0=纯人声0.75=75%人声+25%混合(更自然)
mix_source: "bass"=与低音混合(少混响无重复感)"other"=与其他混合(可能带混响)
"""
import torch
# audio_chunk: numpy or tensor, shape (channels, samples)
if hasattr(audio_chunk, 'numpy'):
wav = audio_chunk
else:
import numpy as np
wav = torch.from_numpy(audio_chunk).float()
# Demucs 期望 (batch, channels, samples)
if wav.dim() == 2:
wav = wav.unsqueeze(0)
with torch.no_grad():
out = apply_model(model, wav, device=device, progress=False, overlap=0.1)
# out: (batch, sources, channels, samples)
# sources: [drums=0, bass=1, other=2, vocals=3]
vocals = out[0, 3]
if vocal_mix_ratio >= 1.0:
return vocals.cpu().numpy()
# 混合 bass 或 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

658
douyin_youtube_ffplay.py Normal file
View File

@@ -0,0 +1,658 @@
# douyin_youtube_ffplay.py
import queue
import unicodedata
import platform
import traceback
import time
import datetime
import subprocess
import threading
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):
"""主播真实下播的专用异常"""
pass
def sanitize_title(text: str, max_len: int = 120) -> str:
if not text:
return ""
text = unicodedata.normalize("NFKC", text)
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
return text.strip()[:max_len]
def _stderr_reader_thread(proc, q):
try:
while True:
line = proc.stderr.readline()
if not line:
break
q.put(line)
except Exception as e:
try:
q.put(f"__ERR__READER__:{e}")
except Exception:
pass
finally:
try:
q.put(None)
except Exception:
pass
def cleanup_proc(proc):
try:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
except Exception:
pass
def start_douyin_youtube_ffplay(
title: str,
anchor_name: str,
real_url: str,
rec_info: str,
record_name: str,
recording: set,
recording_time_list: dict,
running_list: list,
monitoring: list,
clear_record_info,
color_obj,
):
"""
抖音 → YouTube 推流主函数(单路推流)
只从 config/youtube.ini 读取一个 stream key取第一个有效值
"""
# ====================== 读取 YouTube stream key 配置 ======================
config_file = "config/youtube.ini"
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
youtube_rtmp = rtmp_base + default_key # 默认值
enable_demucs = False
demucs_use_gpu = True
demucs_vocal_mix = 0.75 # 0.75=更自然少尖锐1.0=纯人声
demucs_mix_source = "bass" # bass=无混响无重复other=可能带混响
video_bitrate_mbps = 2.5 # YouTube 推荐 2.5,可设 4-4.5 追求更高画质
stats_interval_sec = 10 # 推流状态日志间隔
if os.path.exists(config_file):
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":
try:
v = float(val)
if 0.5 <= v <= 1.0:
demucs_vocal_mix = v
except ValueError:
pass
elif key_lower == "demucs_mix_source":
if val.lower() in ("bass", "other"):
demucs_mix_source = val.lower()
elif key_lower == "video_bitrate_mbps":
try:
v = float(val)
if 2.0 <= v <= 8.0:
video_bitrate_mbps = v
except ValueError:
pass
elif key_lower == "stats_interval_sec":
try:
v = int(val)
if 5 <= v <= 120:
stats_interval_sec = v
except ValueError:
pass
elif val and key_lower in ("key", "stream_key", "streamkey"):
youtube_rtmp = val if val.startswith("rtmp://") else rtmp_base + val
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
# 兼容:若上面未匹配到 key取第一个非配置项的值作为 stream key
if youtube_rtmp == rtmp_base + default_key:
for k, v in config.items("youtube"):
if k.strip().lower() not in ("enable_demucs", "demucs_use_gpu", "demucs_vocal_mix", "demucs_mix_source", "video_bitrate_mbps", "stats_interval_sec") and v.strip():
v = v.strip()
youtube_rtmp = v if v.startswith("rtmp://") else rtmp_base + v
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
break
# ====================== 常量配置 ======================
MAX_RETRY_DELAY = 90
INITIAL_RETRY_DELAY = 3
STDERR_QUEUE_TIMEOUT = 1.0
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25
STALE_OUTPUT_SECONDS = 40
END_KEYWORDS = [
"connection reset by peer",
"failed to write trailer",
"invalid data found",
"server returned 403",
"server returned 404",
"ingestion error",
"access denied",
"packet too short",
"client disconnected",
"live has ended",
"no route to host",
"connection timed out",
]
douyin_headers = (
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
"Referer: https://live.douyin.com/\r\n"
"Origin: https://live.douyin.com\r\n"
)
# ====================== 标题处理 ======================
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
clean_title = sanitize_title(title)
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"
codec_v = "libx264"
preset_v = "veryfast"
tune_param = ["-tune", "zerolatency"]
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 硬件加速")
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()
# 720p (最原始)
ffmpeg_command = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-err_detect", "ignore_err",
"-max_delay", "80000",
"-thread_queue_size", "2048",
"-headers", douyin_headers,
"-i", real_url,
# 720p
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", "libx264",
"-preset", "fast",
# "-tune", "zerolatency",
"-profile:v", "high",
# 720p画质优先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",
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改41080p可音频上 128kYouTube 推荐)
"-af", "aresample=async=1:first_pts=0",
"-flags", "+global_header",
"-flvflags", "no_duration_filesize",
"-f", "flv", youtube_rtmp
]
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
# 720p-低CPU + 背景音乐处理 + 防ContentID
# ffmpeg_command = [
# "ffmpeg", "-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
retry_delay = INITIAL_RETRY_DELAY
try:
while True:
try:
if retry_delay > INITIAL_RETRY_DELAY:
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
time.sleep(retry_delay)
cleanup_proc(proc)
stderr_q = queue.Queue()
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
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()
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:
try:
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
except queue.Empty:
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
print("[WARN] 长时间无任何日志,强制重启进程")
cleanup_proc(proc)
break
continue
if line is None:
if proc.poll() is not None:
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}")
continue
if line_str:
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()
if any(kw in line_lower for kw in END_KEYWORDS):
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
cleanup_proc(proc)
raise StreamEnded()
if re.search(r'frame=\s*\d+', 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 无新帧,判定主播已下播")
cleanup_proc(proc)
raise StreamEnded()
except StreamEnded:
raise
except Exception as e:
print(f"[ERROR] 内层异常: {e}")
traceback.print_exc()
cleanup_proc(proc)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
break
finally:
if stream_stats is not None:
stream_stats.stop()
continue
except StreamEnded:
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
break
except Exception as e:
print(f"[ERROR] 外层异常: {e}")
traceback.print_exc()
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
continue
except Exception as e:
print(f"[FATAL] YouTube 推流最外层异常: {e}")
traceback.print_exc()
finally:
cleanup_proc(proc)
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if real_url in running_list:
running_list.remove(real_url)
monitoring[0] = max(0, monitoring[0] - 1)
clear_record_info(record_name, real_url)
except Exception:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)

View File

@@ -1,215 +1,265 @@
<!--
Project: DouyinLiveRecorder
Author: Hmily
Build: 2023.08.14 - 20:24:05
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
-->
<!DOCTYPE html>
<html lang="en">
<html lang="zh">
<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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>多进程录制控制台</title>
<style>
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
#statusIndicator{font-size:1.2em;font-weight:600}
h2{margin:24px 0 12px;font-size:1.4em}
h3{margin:16px 0 8px;font-size:1.2em}
input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace;overflow:hidden;resize:none;min-height:100px}
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
button:hover{background:#1a237e}
button.loading{opacity:.7;pointer-events:none}
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
.configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
.configStatus{color:#0f0;margin-top:8px;font-family:monospace;font-size:0.9em}
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
@keyframes spin{to{transform:rotate(360deg)}}
</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;
}
<div class="container">
<h1>
进程:
<select id="processSelect">
<option value="tiktok">tiktok</option>
<option value="youtube">youtube</option>
<option value="obs">obs</option>
</select>
<span id="statusIndicator">⚪ 加载中...</span>
</h1>
videoPlayer.style.display = 'block';
description.style.display = 'none';
} else {
console.error('不支持播放该视频格式。');
alert('不支持播放该视频格式。');
<!-- YouTube 专属配置(仅 youtube 时显示) -->
<div id="youtubeOnlySection" style="display:none;">
<h2>YouTube 配置编辑 (youtube.ini)</h2>
<div class="configEditor">
<h3>youtube.ini</h3>
<textarea id="youtubeConfig"></textarea>
<div class="configButtons">
<button id="loadYoutubeBtn">加载配置</button>
<button id="saveYoutubeBtn">保存配置</button>
</div>
<pre class="configStatus" id="youtubeStatus">就绪</pre>
</div>
</div>
<!-- URL_config.ini 共享配置youtube 或 tiktok 时显示) -->
<div id="urlConfigSection" style="display:none;">
<h2>URL 配置编辑 (URL_config.ini)</h2>
<div class="configEditor">
<h3>URL_config.ini</h3>
<textarea id="urlConfig"></textarea>
<div class="configButtons">
<button id="loadUrlBtn">加载配置</button>
<button id="saveUrlBtn">保存配置</button>
</div>
<pre class="configStatus" id="urlStatus">就绪</pre>
</div>
</div>
<!-- OBS 推流地址区(仅 obs 时显示) -->
<div id="obsSection" style="display:none;">
<h2>OBS 直播推流地址</h2>
<p>请在 OBS → 设置 → 推流 → 服务选择“自定义”,服务器填写以下地址:</p>
<div id="obsAddress">srt://live.local:10080/live/obs</div>
<p>密钥Stream Key留空即可。</p>
</div>
<h2>PM2 进程控制</h2>
<div id="pm2Buttons">
<button data-action="start">启动</button>
<button data-action="stop">停止</button>
<button data-action="restart">重启</button>
<button data-action="status">手动刷新</button>
</div>
<h2>实时日志(自动每秒刷新)</h2>
<pre id="pm2Log">正在加载状态与日志...</pre>
</div>
<script>
const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator');
const processSelect = document.getElementById('processSelect');
let currentProcess = 'tiktok';
// 各专属区域
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
const urlConfigSection = document.getElementById('urlConfigSection');
const obsSection = document.getElementById('obsSection');
// youtube.ini 编辑youtube 专属)
const youtubeConfig = document.getElementById('youtubeConfig');
const loadYoutubeBtn = document.getElementById('loadYoutubeBtn');
const saveYoutubeBtn = document.getElementById('saveYoutubeBtn');
const youtubeStatus = document.getElementById('youtubeStatus');
// URL_config.ini 编辑youtube 和 tiktok 共享)
const urlConfig = document.getElementById('urlConfig');
const loadUrlBtn = document.getElementById('loadUrlBtn');
const saveUrlBtn = document.getElementById('saveUrlBtn');
const urlStatus = document.getElementById('urlStatus');
processSelect.addEventListener('change', () => {
currentProcess = processSelect.value;
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();
}
}
// textarea 高度自适应
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = (textarea.scrollHeight) + 'px';
}
[youtubeConfig, urlConfig].forEach(ta => {
ta.addEventListener('input', () => autoResize(ta));
autoResize(ta);
});
// 通用加载/保存
async function loadConfig(endpoint, textarea, statusEl) {
statusEl.textContent = '加载中...';
try {
const res = await fetch(`${endpoint}?process=${currentProcess}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
textarea.value = data.content || '';
autoResize(textarea);
statusEl.textContent = '加载成功';
} catch (err) {
statusEl.textContent = `加载失败: ${err.message}`;
}
}
async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
statusEl.textContent = '保存中...';
const content = textarea.value;
try {
const res = await fetch(`${endpoint}?process=${currentProcess}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content})
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
statusEl.textContent = data.message || '保存成功';
} catch (err) {
statusEl.textContent = `保存失败: ${err.message}`;
} finally {
if (saveBtn) saveBtn.classList.remove('loading');
}
}
// youtube.ini仅 youtube
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
function saveYoutubeConfig() {
saveYoutubeBtn.classList.add('loading');
saveConfig('/save_config', youtubeConfig, youtubeStatus, saveYoutubeBtn);
}
loadYoutubeBtn.onclick = loadYoutubeConfig;
saveYoutubeBtn.onclick = saveYoutubeConfig;
// URL_config.iniyoutube 和 tiktok 共享)
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
function saveUrlConfig() {
saveUrlBtn.classList.add('loading');
saveConfig('/save_url_config', urlConfig, urlStatus, saveUrlBtn);
}
loadUrlBtn.onclick = loadUrlConfig;
saveUrlBtn.onclick = saveUrlConfig;
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
function updateStatus(process_status){
let emoji = '⚪', text = '未知';
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
statusIndicator.innerHTML = `${emoji} ${text}`;
}
async function pm2Action(action){
const btn = document.querySelector(`[data-action="${action}"]`);
if(btn) btn.classList.add('loading');
const url = `/${action}?process=${currentProcess}`;
try{
const res = await fetch(url);
if(!res.ok) throw new Error('网络错误');
const data = await res.json();
if(action === 'status'){
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`;
}else{
logText += `【实时输出日志】`;
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
logText += `\n${data.recent_log}`;
}else{
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
}
logText += `\n\n【实时错误日志】`;
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
logText += `\n${data.recent_error}`;
}else{
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
}
}
logEl.textContent = logText;
scrollLog();
}else{
logEl.textContent = `操作: ${action} ${currentProcess}\n${data.output || '成功'}\n\n正在刷新状态...`;
scrollLog();
setTimeout(fetchStatus, 1500);
}
}catch(err){
logEl.textContent = `请求失败: ${err.message}`;
scrollLog();
updateStatus('unknown');
}finally{
if(btn) btn.classList.remove('loading');
}
}
document.getElementById('playButton').addEventListener('click', playVideo);
</script>
</div>
async function fetchStatus(){ pm2Action('status'); }
// 按钮事件委托
document.getElementById('pm2Buttons').addEventListener('click', e=>{
const btn = e.target.closest('button');
if(!btn) return;
const action = btn.dataset.action;
if(action && action !== 'status') pm2Action(action);
});
// 自动刷新 + 初始
setInterval(fetchStatus, 1000);
fetchStatus();
updateSectionsVisibility();
</script>
</body>
</html>

14
index.html.1 Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for." name="description"><meta content="noodp, " name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"><meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image"><title>Google</title><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var _g={kEI:'gbObaeXAG-Gy5NoPmeGiyAg',kEXPI:'0,1304203,2935842,14111,64702,6398,354503,5532962,16,36811390,25228681,123988,28395,11943,53220,39775,61250,14067,23259,37800,28333,79315,7707,7,33385,4719,24374,1,2884,3086,16757,46340,9920,10754,11886,6436,4952,4791,2646,6292,12404,5550,14505,1383,1,2,1515,3355,196,5814,2,4205,1,16100,2,873,2,22,2208,5108,3008,24,771,4074,4,5393,18686,8466,5337,310,535,845,1201,2,11,2,1560,3,5189,1,13,738,1009,2,1746,4,4878,5773,4542,4,2243,240,297,1,1117,389,4,2647,5,2698,2870,298,9253,1275,7,1784,1050,2,1914,5,10456,2,5724,9,602,59,36,1554,4,2570,3341,932,3066,1,2,1402,3,1117,242,2,2356,2576,49,1,8,216,441,158,2,1645,191,4,1928,4,1634,544,4,1568,1032,1,1399,187,259,1846,5,259,5,197,1921,445,19,1421,119,4,2364,2363,5,39,633,1937,4,57,4,3784,4,76,1970,4,265,106,5,3,971,4,966,5,1125,1,2,2,815,3,2932,4,32,4,130,717,1856,4,40,4,2110,4,134,304,323,1,576,1776,1864,42,5,2,479,704,4,14,3,1,449,4,730,5,75,4,1918,1,21,4,465,429,537,1563,1,1090,2,265,702,4905,1788,14,507,823,747,4,331,4,959,452,57,258,615,80,7,2245,4,281,264,341,221,1,2976,508,339,3430,503,3,1412,1354,4,12,33,2093,4,945,16,4,612,1,20998529,1717,7635,5,2992,4,2960,3,8491,2,1562,3,2687,3,9181,536,2,2461,1194,3,746,6,918,231,3,1369,1494,2715,1,6494629,4214,2,3639,565,745,38,517,541,84,108428,25,36,10,3516226,209655,562,143,12493584,256605,48968,4054456,476,441,42,5,116,590,5,1023,1671,844,1,148,3,6,163,101,343,4,65,388,2,707,260,44,4,152,4,488,62,219,3,2,2,2,550,4,885,1050,2,50,641,5,429,6,2800,5,138,1,863,130,45,2,11,2,16,361,218,1148,1,2,642,214,1485,417,712,2695,1043,281,5,330,1255,292,114,289,54,161,5,142,4,587,412,322,525,1,361,503,213,249,99,472,165,7,51,90,174,436,472,3,289,379,458,583,331,601,587,556,100,949,821,118,4,177,37,7,12,3,6,25,1006,3,2,2,2,67,134,727,4,283,321,70,212,125,4,4,3,2,1,2,3,2,2,1,2,2,2,3,1,3,2,1,3,2,1,3,1,2,3,1,2,2,2,3,2,1,3,1,2,3,2,2,2,2,1,3,1,3,2,1,2,10,2,2,3,2,2,10,1,2,3,1,2,10,2,3,2,1,3,9,2,3,1,3,2,1,2,2,2,3,1,2,3,2,2,1,3,1,3,2,1,2,2,2,2,2,3,2,2,1,2,3,2,1,2,2,2,3,1,3,1,3,1,2,2,10,2,3,1,3,1,11,2,2,1,2,2,10,2,3,2,2,1,183,94,60,120,149,39,317,3,2,2,2,744,296,177,5,22,414,377,24,216,1579,8,293,133,136,134,83,118,2,3,2,2,2,956,1,2,785,3,2,2,2,348,295,4,106,229,180,5,62,2,878,573,260,185,936,640,1027,638,713,94,18,3,2,2,2,4,308,593,3,2,2,2,720,519,6,1,4357,427,3,1,370,435,364,71,61,442,901,4',kBL:'eaQ3',kOPI:89978449};(function(){var a;((a=window.google)==null?0:a.stvsc)?google.kEI=_g.kEI:window.google=_g;}).call(this);})();(function(){google.sn='webhp';google.kHL='en';google.rdn=true;})();(function(){
var g=this||self;function k(){return window.google&&window.google.kOPI||null};var l,m=[];function n(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||l}function p(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b}function q(a){/^http:/i.test(a)&&window.location.protocol==="https:"&&(google.ml&&google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a}
function r(a,b,d,c,h){var e="";b.search("&ei=")===-1&&(e="&ei="+n(c),b.search("&lei=")===-1&&(c=p(c))&&(e+="&lei="+c));var f=b.search("&cshid=")===-1&&a!=="slh";c="&zx="+Date.now().toString();g._cshid&&f&&(c+="&cshid="+g._cshid);(d=d())&&(c+="&opi="+d);return"/"+(h||"gen_204")+"?atyp=i&ct="+String(a)+"&cad="+(b+e+c)};l=google.kEI;google.getEI=n;google.getLEI=p;google.ml=function(){return null};google.log=function(a,b,d,c,h,e){e=e===void 0?k:e;d||(d=r(a,b,e,c,h));if(d=q(d)){a=new Image;var f=m.length;m[f]=a;a.onerror=a.onload=a.onabort=function(){delete m[f]};a.src=d}};google.logUrl=function(a,b){b=b===void 0?k:b;return r("",a,b)};}).call(this);(function(){google.y={};google.sy={};function e(a,b,d){if(a)var c=a.id;else{do c=Math.random();while(d[c])}d[c]=[a,b]}var f;(f=google).x||(f.x=function(a,b){e(a,b,google.y)});var g;(g=google).sx||(g.sx=function(a,b){e(a,b,google.sy)});google.bx=!1;var h;(h=google).lx||(h.lx=function(){});var k=[],l;(l=google).fce||(l.fce=function(a,b,d,c){k.push([a,b,d,c])});google.qce=k;google.adl=[];}).call(this);google.f={};(function(){
document.documentElement.addEventListener("submit",function(b){var a;if(a=b.target){var c=a.getAttribute("data-submitfalse");a=c==="1"||c==="q"&&!a.elements.q.value?!0:!1}else a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener("click",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(a.tagName==="A"){a=a.getAttribute("data-nohref")==="1";break a}a=!1}a&&b.preventDefault()},!0);}).call(this);</script><style>#gbar,#guser{font-size:13px;padding-top:1px !important;}#gbar{height:22px}#guser{padding-bottom:7px !important;text-align:right}.gbh,.gbd{border-top:1px solid #c9d7f1;font-size:1px}.gbh{height:0;position:absolute;top:24px;width:100%}@media all{.gb1{height:22px;margin-right:.5em;vertical-align:top}#gbar{float:left}}a.gb1,a.gb4{text-decoration:underline !important}a.gb1,a.gb4{color:#00c !important}.gbi .gb4{color:#dd8e27 !important}.gbf .gb4{color:#900 !important}
</style><style>body,td,a,p,.h{font-family:sans-serif}body{margin:0;overflow-y:scroll}#gog{padding:3px 8px 0}td{line-height:.8em}.gac_m td{line-height:17px}form{margin-bottom:20px}.h{color:#1967d2}em{font-weight:bold;font-style:normal}.lst{height:25px;width:496px}.gsfi,.lst{font:18px sans-serif}.gsfs{font:17px sans-serif}.ds{display:inline-box;display:inline-block;margin:3px 0 4px;margin-left:4px}input{font-family:inherit}body{background:#fff;color:#1f1f1f}a{color:#681da8;text-decoration:none}a:hover,a:active{text-decoration:underline}.fl a{color:#1967d2}a:visited{color:#681da8}.sblc{padding-top:5px}.sblc a{display:block;margin:2px 0;margin-left:13px;font-size:11px}.lsbb{background:#ecedee;border:solid 1px;border-color:#d2d2d2 #70757a #70757a #d2d2d2;height:30px}.lsbb{display:block}#WqQANb a{display:inline-block;margin:0 12px}.lsb{background:url(/images/nav_logo229.png) 0 -261px repeat-x;color:#1f1f1f;border:none;cursor:pointer;height:30px;margin:0;outline:0;font:15px sans-serif;vertical-align:top}.lsb:active{background:#dadce0}.lst:focus{outline:none}</style><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.erd={jsr:1,bv:2383,de:true,dpf:'UAON6G49novXd1LJI--iGd6VfzOOuK54njDRAqwNAgA'};
var g=this||self;var k,l=(k=g.mei)!=null?k:1,m,p=(m=g.diel)!=null?m:0,q,r=(q=g.sdo)!=null?q:!0,t=0,u,w=google.erd,x=w.jsr;google.ml=function(a,b,d,n,e){e=e===void 0?2:e;b&&(u=a&&a.message);d===void 0&&(d={});d.cad="ple_"+google.ple+".aple_"+google.aple;if(google.dl)return google.dl(a,e,d,!0),null;b=d;if(x<0){window.console&&console.error(a,b);if(x===-2)throw a;b=!1}else b=!a||!a.message||a.message==="Error loading script"||t>=l&&!n?!1:!0;if(!b)return null;t++;d=d||{};b=encodeURIComponent;var c="/gen_204?atyp=i&ei="+b(google.kEI);google.kEXPI&&(c+="&jexpid="+b(google.kEXPI));c+="&srcpg="+b(google.sn)+"&jsr="+b(w.jsr)+
"&bver="+b(w.bv);w.dpf&&(c+="&dpf="+b(w.dpf));var f=a.lineNumber;f!==void 0&&(c+="&line="+f);var h=a.fileName;h&&(h.indexOf("-extension:/")>0&&(e=3),c+="&script="+b(h),f&&h===window.location.href&&(f=document.documentElement.outerHTML.split("\n")[f],c+="&cad="+b(f?f.substring(0,300):"No script found.")));google.ple&&google.ple===1&&(e=2);c+="&jsel="+e;for(var v in d)c+="&",c+=b(v),c+="=",c+=b(d[v]);c=c+"&emsg="+b(a.name+": "+a.message);c=c+"&jsst="+b(a.stack||"N/A");c.length>=12288&&(c=c.substring(0,12288));a=c;n||google.log(0,"",a);return a};window.onerror=function(a,b,d,n,e){u!==a&&(a=e instanceof Error?e:Error(a),d===void 0||"lineNumber"in a||(a.lineNumber=d),b===void 0||"fileName"in a||(a.fileName=b),google.ml(a,!1,void 0,!1,a.name==="SyntaxError"||a.message.substring(0,11)==="SyntaxError"||a.message.indexOf("Script error")!==-1?3:p));u=null;r&&t>=l&&(window.onerror=null)};})();</script></head><body bgcolor="#fff"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var src='/images/nav_logo229.png';var iesg=false;document.body.onload = function(){window.n && window.n();if (document.images){new Image().src=src;}
if (!iesg){document.f&&document.f.q.focus();document.gbqf&&document.gbqf.q.focus();}
}
})();</script><div id="mngb"><div id=gbar><nobr><b class=gb1>Search</b> <a class=gb1 href="https://www.google.com/imghp?hl=en&tab=wi">Images</a> <a class=gb1 href="http://maps.google.com/maps?hl=en&tab=wl">Maps</a> <a class=gb1 href="https://play.google.com/?hl=en&tab=w8">Play</a> <a class=gb1 href="https://www.youtube.com/?tab=w1">YouTube</a> <a class=gb1 href="https://news.google.com/?tab=wn">News</a> <a class=gb1 href="https://mail.google.com/mail/?tab=wm">Gmail</a> <a class=gb1 href="https://drive.google.com/?tab=wo">Drive</a> <a class=gb1 style="text-decoration:none" href="https://www.google.com/intl/en/about/products?tab=wh"><u>More</u> &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>

215
indexOrigin.html Normal file
View File

@@ -0,0 +1,215 @@
<!--
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>

404
main.py
View File

@@ -1164,11 +1164,9 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S')
if port_info['is_live'] is False:
print(f"\r{record_name} 等待直播... ")
# 直接退出线程eric
# return
# 继续循环
continue
# continue # 继续循环
return # 线程彻底退出
if start_pushed:
if over_show_push:
@@ -1620,387 +1618,33 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
with max_request_lock:
error_count += 1
error_window.append(1)
else:
# ====================== 抖音→YouTube 2025重构单层重试 + 统一清理) ======================
import queue, unicodedata, platform, traceback
class StreamEnded(Exception):
pass
def sanitize_title(text: str, max_len: int = 120) -> str:
if not text: return ""
text = unicodedata.normalize("NFKC", text)
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
return text.strip()[:max_len]
def _stderr_reader_thread(proc, q):
try:
while True:
line = proc.stderr.readline()
if not line:
break
q.put(line)
except Exception as e:
try:
q.put(f"__ERR__READER__:{e}")
except Exception:
pass
finally:
try:
q.put(None)
except Exception:
pass
def cleanup_proc(proc):
try:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
except Exception:
pass
# ========== 参数配置(保留你原始配置) ==========
# YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/x04z-564w-aks7-embw-30y4"
# youtube_rtmp="rtmp://192.168.31.184/live/douyin"
# # ←←←← 在这块代码的上方先定义你要推的多个地址 ←←←←
# rtmp_targets = [
# "rtmp://a.rtmp.youtube.com/live2/qxvb-r47b-r5ju-6ud3-6k7z", # 主频道
# "rtmp://192.168.31.184/live/douyin", # 备用频道(换成你的第二个 YouTube 密钥)
# ]
# # 用 | 拼接所有地址(任何一个断开都不会影响其他)
# tee_outputs = "|".join([f"[f=flv]{url}" for url in rtmp_targets])
MAX_RETRY_DELAY = 90
INITIAL_RETRY_DELAY = 3
STDERR_QUEUE_TIMEOUT = 1.0
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25
STALE_OUTPUT_SECONDS = 120 #120
END_KEYWORDS = [
"connection reset by peer",
"failed to write trailer",
"invalid data found",
"server returned 403",
"server returned 404",
"ingestion error",
"access denied",
"packet too short",
"client disconnected",
"live has ended",
"no route to host",
"connection timed out",
]
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
_clean_title_in_name = sanitize_title(title_in_name)
stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title_in_name) if _clean_title_in_name else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
# NVENC 检测(原样保留)
codec_v, preset_v, tune_param = "libx264", "veryfast", ["-tune", "zerolatency"]
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 ""):
codec_v, preset_v, tune_param = "h264_nvenc", "p5", ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e:
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
# headers & ffmpeg command保持原来参数
douyin_headers = (
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
"Referer: https://live.douyin.com/\r\n"
"Origin: https://live.douyin.com\r\n"
import sys
sys.path.insert(0, './dist')
from douyin_srs_ffplay import start_douyin_srs_ffplay
# 调用
start_douyin_srs_ffplay(
title=title_in_name,
anchor_name=anchor_name,
real_url=real_url,
rec_info=rec_info,
record_name=record_name,
recording=recording,
recording_time_list=recording_time_list,
running_list=running_list,
monitoring=monitoring,
clear_record_info=clear_record_info,
color_obj=color_obj
)
# 720p
# ffmpeg_command = [
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
# "-stats", "-stats_period", "1",
# "-rw_timeout", "30000000",
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
# "-reconnect_delay_max", "5",
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
# "-flags", "low_delay",
# "-err_detect", "ignore_err",
# "-max_delay", "80000",
# "-thread_queue_size", "2048",
# "-headers", douyin_headers,
# "-i", real_url,
# # 720p
# "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "fast",
# # "-tune", "zerolatency",
# "-profile:v", "high",
# # 720p
# # "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate保持真 CBR
# "-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
# "-vsync", "cfr",
# "-g", "60", "-keyint_min", "60",
# "-r", "30",
# "-bf", "0",
# "-sc_threshold", "0",
# # "-nal-hrd", "cbr", # 改2强制 CBR
# # 720p
# # "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3简化 params锁死 CBR
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# "-pix_fmt", "yuv420p",
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改41080p可音频上 128kYouTube 推荐)
# "-af", "aresample=async=1:first_pts=0",
# "-flags", "+global_header",
# "-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,
# 1080p
"-vf","fps=30,scale=1080:1920:force_original_aspect_ratio=decrease:flags=lanczos,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", "libx264",
"-preset", "veryfast",
"-tune", "zerolatency",
"-profile:v", "high",
# 1080p
"-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k", # 改12× bufsize真 CBR
"-vsync", "cfr",
"-g", "60", "-keyint_min", "60",
"-r", "30",
"-bf", "0",
"-sc_threshold", "0",
# "-nal-hrd", "cbr", # 改2强制 CBR
# 1080p
"-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", "aresample=async=1:first_pts=0",
"-flags", "+global_header",
"-flvflags", "no_duration_filesize",
"-f", "flv", youtube_rtmp
]
# srs内网配置
# ffmpeg_command = [
# "ffmpeg",
# "-loglevel", "info",
# "-err_detect", "ignore_err+explode",
# "-fflags", "+genpts+discardcorrupt+igndts",
# "-reconnect", "1",
# "-reconnect_at_eof", "1",
# "-reconnect_streamed", "1",
# "-reconnect_delay_max", "7",
# "-rw_timeout", "30000000",
# "-headers", douyin_headers,
# # ===== 输入源 =====
# "-i", real_url,
# "-c:v", "copy",
# "-c:a", "copy",
# "-max_muxing_queue_size", "9999",
# "-f", "flv", srs_rtmp
# ]
# ---- 单层重试循环(清晰可控) ----
proc = None
stderr_q = None
reader_t = None
retry_delay = INITIAL_RETRY_DELAY
try:
while True:
try:
if retry_delay > INITIAL_RETRY_DELAY:
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
time.sleep(retry_delay)
# 每次重连前先清理上一次的进程/线程
try:
cleanup_proc(proc)
except Exception:
pass
if stderr_q:
# 垃圾化旧队列以便 GC
stderr_q = None
stderr_q = queue.Queue()
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
proc.last_out_ts = time.time()
# 启动 stderr reader
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()
# ---- 监控循环(单一职责:处理 stderr 日志并决定是否结束/重连) ----
while True:
try:
try:
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
except queue.Empty:
# 超时检查:如果太久没有任何日志,判为僵死,重启 proc跳出内层进行重试
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
print("[WARN] 长时间无任何日志,强制重启进程")
cleanup_proc(proc)
break
continue
if line is None:
# reader 线程结束;如果 proc 已退出,跳出去重试
if proc.poll() is not None:
break
else:
continue
line = line.strip()
if line.startswith("__ERR__READER__"):
print(f"[ERROR] stderr reader 异常: {line}")
continue
if line:
proc.last_out_ts = time.time()
print(f"[FFmpeg] {line}")
line_lower = line.lower()
# 检测到明确下播关键字 -> 认为主播真下播,立即退出(不再重试)
if any(kw in line_lower for kw in END_KEYWORDS):
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
cleanup_proc(proc)
raise StreamEnded()
# 帧检测:更新 frame 时间戳
if re.search(r'frame=\s*\d+', 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 无新帧,判定主播已下播")
cleanup_proc(proc)
raise StreamEnded()
except StreamEnded:
# 真实下播,抛到外层处理(最终会退出函数)
raise
except Exception as e:
# 内层非致命异常:打印、回退重试间隔,退出监控循环以在外层重试
print(f"[ERROR] 内层异常: {e}")
traceback.print_exc()
cleanup_proc(proc)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
break # 跳出监控循环 -> 外层 while 会继续并重试
# 如果监控循环走到这里并不是因为 StreamEnded例如 break则重试连接loop continue
# 将在 while True 的下一次循环中进行等待并重连
continue
except StreamEnded:
# 真实的主播下播,停止并彻底退出本录制线程
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
break
except Exception as e:
# 外层致命异常:打印并退出循环(会在 finally 里做统一清理)
print(f"[ERROR] 外层异常: {e}")
traceback.print_exc()
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
# 等待并尝试重连(除非你希望直接退出,这里保留重试策略)
continue
# ============ 正常或 StreamEnded 后的统一清理(在这里 break 到这里) ============
cleanup_proc(proc)
# 以下清理放在 try/except 里以防止清理过程出错导致漏逻辑
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
global monitoring
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except Exception:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
return
except Exception as e:
# 最外层异常也要统一清理(保证不会留下僵尸状态)
print(f"[FATAL] YouTube 推流最外层异常: {e}")
traceback.print_exc()
try:
cleanup_proc(proc)
except Exception:
pass
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
# global monitoring
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except Exception:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流异常退出但已清理\n", color_obj.RED)
return
count_time = time.time()
except Exception as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:

5
obs.sh Normal file
View File

@@ -0,0 +1,5 @@
export DISPLAY=:0
ffplay -fflags nobuffer -flags low_delay -framedrop \
-probesize 32 -analyzeduration 0 -sync ext -infbuf \
-vf "setpts=PTS-STARTPTS" \
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'

12
requirements-youtube.txt Normal file
View File

@@ -0,0 +1,12 @@
# YouTube 转播 + Demucs 人声分离(防版权)所需依赖
# 在 requirements.txt 基础上额外安装:
# pip install -r requirements.txt -r requirements-youtube.txt
# CPU 版本
demucs>=4.0.0
torch>=2.0.0
torchaudio>=2.0.0
# 若使用 NVIDIA GPU请安装 CUDA 版 PyTorch 后覆盖:
# pip uninstall torch torchaudio
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118

View File

@@ -4,4 +4,11 @@ pycryptodome>=3.20.0
distro>=1.9.0
tqdm>=4.67.1
httpx[http2]>=0.28.1
PyExecJS>=1.5.1
PyExecJS>=1.5.1
# 可选YouTube 转播时 Demucs 人声分离(防版权识别)
# pip install demucs torch torchaudio
# GPU 版本: pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
# demucs>=4.0.0
# torch>=2.0.0
# torchaudio>=2.0.0

174
stream_stats.py Normal file
View File

@@ -0,0 +1,174 @@
# stream_stats.py
# 推流状态监控:解析 FFmpeg 输出,统计帧率、码率、网络等,便于调试
"""
推流状态监控模块
解析 FFmpeg stderr 输出,提取:帧率、码率、编码速度、源延迟事件
可选Linux 网络上下行速度
"""
import re
import time
import threading
import platform
class StreamStats:
"""推流状态统计,线程安全"""
def __init__(self, interval_sec=10):
self.interval_sec = interval_sec
self._lock = threading.Lock()
self.frame = 0
self.fps = 0.0
self.bitrate_kbps = 0.0
self.speed = 0.0
self.encode_time = ""
self.size_kb = 0
self.quality_q = 0.0
self.lag_events = 0 # "Resumed reading" 次数,表示源端抖动
self.last_update_ts = 0.0
self.start_ts = time.time()
self._stop = threading.Event()
self._printer_thread = None
# 网络统计 (Linux)
self._net_last = None
self._net_last_ts = 0
self.rx_mbps = 0.0
self.tx_mbps = 0.0
def parse_ffmpeg_progress(self, line: str) -> bool:
"""解析 FFmpeg 进度行,返回是否成功解析"""
# frame= 12345 fps= 30 q=28.0 size= 12345kB time=00:06:51.50 bitrate=2460.0kbits/s speed=1.00x
m = re.search(r'frame=\s*(\d+)', line, re.I)
if not m:
return False
with self._lock:
try:
self.frame = int(m.group(1))
except (ValueError, IndexError):
pass
m2 = re.search(r'fps=\s*([\d.]+)', line, re.I)
if m2:
self.fps = float(m2.group(1))
m3 = re.search(r'bitrate=\s*([\d.]+)\s*kbits/s', line, re.I)
if m3:
self.bitrate_kbps = float(m3.group(1))
m4 = re.search(r'speed=\s*([\d.]+)x', line, re.I)
if m4:
self.speed = float(m4.group(1))
m5 = re.search(r'time=([^\s]+)', line, re.I)
if m5:
self.encode_time = m5.group(1).strip()
m6 = re.search(r'size=\s*(\d+)\s*kB', line, re.I)
if m6:
self.size_kb = int(m6.group(1))
m7 = re.search(r'q=([\d.]+)', line, re.I)
if m7:
self.quality_q = float(m7.group(1))
self.last_update_ts = time.time()
return True
def parse_lag_event(self, line: str) -> bool:
"""检测源端延迟事件 Resumed reading at pts"""
if "resumed reading at pts" in line.lower():
with self._lock:
self.lag_events += 1
return True
return False
def _read_net_bytes(self):
"""读取 /proc/net/dev 总字节数 (Linux)"""
try:
with open("/proc/net/dev", "r") as f:
content = f.read()
except Exception:
return None, None
rx_total = tx_total = 0
for line in content.split("\n")[2:]: # 跳过表头
line = line.strip()
if not line or ":" not in line:
continue
parts = line.split()
if len(parts) >= 10:
try:
rx_total += int(parts[1])
tx_total += int(parts[9])
except (ValueError, IndexError):
pass
return rx_total, tx_total
def update_network(self):
"""更新网络速度 (Linux)"""
if platform.system() != "Linux":
return
rx, tx = self._read_net_bytes()
if rx is None:
return
now = time.time()
with self._lock:
if self._net_last is not None:
dt = now - self._net_last_ts
if dt > 0.5: # 至少 0.5 秒间隔
drx = (rx - self._net_last[0]) / (1024 * 1024) / dt
dtx = (tx - self._net_last[1]) / (1024 * 1024) / dt
self.rx_mbps = round(drx, 2)
self.tx_mbps = round(dtx, 2)
self._net_last = (rx, tx)
self._net_last_ts = now
def format_summary(self) -> str:
"""生成状态摘要字符串"""
self.update_network()
with self._lock:
elapsed = time.time() - self.start_ts
parts = [
f"推流时长 {int(elapsed)}s",
f"帧率 {self.fps:.1f} fps",
f"已推帧 {self.frame}",
f"码率 {self.bitrate_kbps:.0f} kbps",
f"编码速度 {self.speed:.2f}x",
]
if self.lag_events > 0:
parts.append(f"源抖动 {self.lag_events}")
if platform.system() == "Linux":
parts.append(f"下行 {self.rx_mbps:.2f} MB/s 上行 {self.tx_mbps:.2f} MB/s")
return " | ".join(parts)
def start_periodic_printer(self):
"""启动周期性打印线程"""
def _run():
while not self._stop.is_set():
self._stop.wait(self.interval_sec)
if self._stop.is_set():
break
if self.last_update_ts > 0 and (time.time() - self.last_update_ts) < 60:
print(f"[推流状态] {self.format_summary()}")
self._printer_thread = threading.Thread(target=_run, daemon=True)
self._printer_thread.start()
def stop(self):
"""停止周期性打印"""
self._stop.set()
def parse_and_maybe_print(line: str, stats: StreamStats, prefix: str = "[FFmpeg]", verbose=True) -> bool:
"""
解析一行 FFmpeg 输出,更新 stats。
若为进度行则更新不打印;若为 lag 事件则更新并可选打印;其他行原样打印。
verbose: 是否打印非进度行
"""
line_stripped = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
if not line_stripped:
return False
# 进度行:更新 stats不重复打印由周期摘要代替
if stats.parse_ffmpeg_progress(line_stripped):
return True
# 源端延迟事件
if stats.parse_lag_event(line_stripped):
if verbose:
print(f"{prefix} {line_stripped}")
return True
if verbose:
print(f"{prefix} {line_stripped}")
return False

2204
tiktok.py Normal file

File diff suppressed because it is too large Load Diff

175
tiktok/index.html Normal file
View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HDMI 录制控制台</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
#statusIndicator{font-size:1.2em;font-weight:600}
h2{margin:24px 0 12px;font-size:1.4em}
input,button{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
input{background:#f5f5f5;color:#000;border:1px solid #ddd}
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
button:hover{background:#1a237e}
button.loading{opacity:.7;pointer-events:none}
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
#videoPlayer{position:relative;width:100%;padding-bottom:56.25%;background:#000;border-radius:8px;overflow:hidden;margin:16px 0;display:none}
video{position:absolute;inset:0;width:100%;height:100%}
#loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.8em;pointer-events:none;background:rgba(0,0,0,.5);opacity:0;transition:opacity .3s;border-radius:8px}
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="container">
<h1>进程: hdmi <span id="statusIndicator">⚪ 加载中...</span></h1>
<h2>视频播放器M3U8 / FLV</h2>
<input type="text" id="videoUrlInput" placeholder="请输入视频链接">
<button id="playButton">播放</button>
<div id="videoPlayer">
<video controls autoplay playsinline></video>
<div id="loading">加载中...</div>
</div>
<h2>PM2 进程控制</h2>
<div id="pm2Buttons">
<button data-action="start">启动</button>
<button data-action="stop">停止</button>
<button data-action="restart">重启</button>
<button data-action="status">手动刷新</button>
</div>
<h2>实时日志(自动每秒刷新)</h2>
<pre id="pm2Log">正在加载状态与日志...</pre>
</div>
<script>
const video = document.querySelector('video');
const player = document.getElementById('videoPlayer');
const loading = document.getElementById('loading');
const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator');
function httpToHttps(url){return url.replace(/^http:/,"https:")}
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
function updateStatus(process_status){
let emoji = '⚪', text = '未知';
if(process_status === 'online'){ emoji = ' '; text = '运行中'; }
else if(process_status === 'stopped'){ emoji = ' '; text = '已停止'; }
else if(process_status === 'errored'){ emoji = ' '; text = '错误'; }
else if(process_status === 'launching' || process_status === 'starting'){ emoji = ' '; text = '启动中'; }
else if(process_status === 'waiting restart'){ emoji = ' '; text = '等待重启'; }
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
statusIndicator.innerHTML = `${emoji} ${text}`;
}
async function playVideo(){
let url = document.getElementById('videoUrlInput').value.trim();
if(!url) return alert('请输入视频链接');
url = httpToHttps(url);
player.style.display = 'block';
loading.style.opacity = 1;
video.pause(); video.src = '';
if(url.endsWith('.m3u8')){
if(Hls.isSupported()){
const hls = new Hls({enableWorker:true});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, ()=>loading.style.opacity=0);
hls.on(Hls.Events.ERROR, (e,data)=>{alert('HLS 错误: '+data.type); loading.style.opacity=0});
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
video.src = url; video.play(); loading.style.opacity=0;
}else{alert('浏览器不支持 M3U8'); return}
}else if(url.endsWith('.flv')){
if(typeof flvjs !== 'undefined' && flvjs.isSupported()){
const flvPlayer = flvjs.createPlayer({type:'flv',url,isLive:true});
flvPlayer.attachMediaElement(video);
flvPlayer.load(); flvPlayer.play();
flvPlayer.on(flvjs.Events.LOADING_COMPLETE, ()=>loading.style.opacity=0);
}else{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js';
script.onload = playVideo;
document.head.appendChild(script);
return;
}
}else{alert('仅支持 .m3u8 或 .flv 格式'); return}
}
document.getElementById('playButton').onclick = playVideo;
// PM2 控制 + 实时状态/日志
async function pm2Action(action){
const btn = document.querySelector(`[data-action="${action}"]`);
if(btn) btn.classList.add('loading');
try{
const res = await fetch(`/${action}`);
if(!res.ok) throw new Error('网络错误');
const data = await res.json();
if(action === 'status'){
updateStatus(data.process_status);
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()}\n${data.raw_status}\n\n`;
if(data.process_status === 'not_found'){
logText += `【提示】进程 "hdmi" 未注册到 PM2可能被 pm2 delete 删除)\n`;
}else{
logText += `【实时输出日志】`;
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
logText += `\n${data.recent_log}`;
}else{
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
}
logText += `\n\n【实时错误日志】`;
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
logText += `\n${data.recent_error}`;
}else{
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
}
}
logEl.textContent = logText;
scrollLog();
}else{
logEl.textContent = `操作: ${action}\n${data.output || '成功'}\n\n正在刷新状态...`;
scrollLog();
setTimeout(fetchStatus, 1500);
}
}catch(err){
logEl.textContent = `请求失败: ${err.message}`;
scrollLog();
updateStatus('unknown');
}finally{
if(btn) btn.classList.remove('loading');
}
}
async function fetchStatus(){ pm2Action('status'); }
// 按钮事件委托
document.getElementById('pm2Buttons').addEventListener('click', e=>{
const btn = e.target.closest('button');
if(!btn) return;
const action = btn.dataset.action;
if(action && action !== 'status') pm2Action(action);
});
// 自动刷新(每 1 秒)
setInterval(fetchStatus, 1000);
fetchStatus();
</script>
</body>
</html>

142
tiktok/tiktok.py Normal file
View File

@@ -0,0 +1,142 @@
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path
import asyncio
import re
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="HDMI 录制控制台")
# ---------------- 配置 ----------------
BOT_NAME = "hdmi"
BASE_DIR = Path(__file__).parent
DEFAULT_LOG_DIR = BASE_DIR.parent / ".pm2" / "logs"
MAX_LOG_LINES = 300
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
# ---------------- 工具 ----------------
def strip_ansi_codes(text: str) -> str:
return re.sub(r'\x1b\[[0-9;]*m', '', text)
async def run_pm2(cmd: str) -> str:
process = await asyncio.create_subprocess_shell(
f"pm2 {cmd}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
return (stdout or stderr).decode(errors="ignore").strip()
except asyncio.TimeoutError:
process.kill()
return "ERROR: PM2 命令超时"
async def read_log_path(path: str, lines: int) -> str:
if not path:
return "无日志路径\n"
if "/dev/null" in path:
return "日志输出被禁用PM2 配置为无日志)\n"
log_file = Path(path)
if not log_file.exists():
return f"日志文件不存在: {path}\n"
try:
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
content_lines = f.readlines()
return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
except Exception as e:
return f"读取日志失败 ({path}): {str(e)}\n"
async def get_hdmi_info() -> dict:
describe_raw = await run_pm2(f"describe {BOT_NAME}")
describe_clean = strip_ansi_codes(describe_raw)
if ("No such process" in describe_clean or
"Unknown process" in describe_clean or
"not found" in describe_clean.lower() or
not describe_clean.strip()):
return {
"process_status": "not_found",
"out_path": None,
"err_path": None,
}
status = "unknown"
out_path = None
err_path = None
for line in describe_raw.splitlines():
line_clean = strip_ansi_codes(line)
if line_clean.count('') < 2:
continue
parts = [p.strip() for p in line_clean.split('') if p.strip()]
if len(parts) >= 2:
key = parts[0].lower()
value = parts[1]
if key == "status":
status = value.lower()
elif key == "out_log_path":
out_path = value
elif key == "err_log_path":
err_path = value
# 备用默认路径(仅当 describe 未提供时)
if not out_path:
out_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-out.log")
if not err_path:
err_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-error.log")
return {
"process_status": status,
"out_path": out_path,
"err_path": err_path,
}
# ---------------- 路由 ----------------
@app.get("/start")
async def start():
output = await run_pm2(f"start {BOT_NAME}")
return JSONResponse({"output": output})
@app.get("/stop")
async def stop():
output = await run_pm2(f"stop {BOT_NAME}")
return JSONResponse({"output": output})
@app.get("/restart")
async def restart():
output = await run_pm2(f"restart {BOT_NAME}")
return JSONResponse({"output": output})
@app.get("/status")
async def status():
full_status_raw = await run_pm2("status")
full_status = strip_ansi_codes(full_status_raw) # 去除颜色代码,前端显示更干净
info = await get_hdmi_info()
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
if info["process_status"] == "not_found":
recent_log = "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)\n"
recent_error = ""
return JSONResponse({
"raw_status": full_status,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
})
@app.get("/")
async def index():
return FileResponse(BASE_DIR / "index.html")

1
tiktok/tiktok.sh Normal file
View File

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

267
web.py Normal file
View File

@@ -0,0 +1,267 @@
from fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path
import asyncio
import re
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="多进程录制控制台")
# ---------------- 配置(根目录结构) ----------------
BASE_DIR = Path(__file__).parent
CONFIG_DIR = BASE_DIR / "config"
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
MAX_LOG_LINES = 300
# 支持的进程列表
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# ---------------- 工具函数 ----------------
def strip_ansi_codes(text: str) -> str:
return re.sub(r'\x1b\[[0-9;]*m', '', text)
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
process = await asyncio.create_subprocess_shell(
f"pm2 {cmd}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
result = (stdout or stderr).decode(errors="ignore").strip()
return result if result else "命令执行完成(无输出)"
except asyncio.TimeoutError:
process.kill()
await process.wait()
return "ERROR: PM2 命令超时,已强制终止"
async def force_kill_ffmpeg(process_name: str):
"""尝试强杀与该进程相关的 ffmpeg"""
cmds = [
# 优先杀带有进程名的 ffmpeg最精准
f"pkill -f 'ffmpeg.*{process_name}' || true",
# 再杀所有 ffmpeg兜底比较暴力
"killall -9 ffmpeg 2>/dev/null || true",
]
for cmd in cmds:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
async def read_log_path(path: str, lines: int) -> str:
if not path:
return "无日志路径\n"
if "/dev/null" in path:
return "日志输出被禁用PM2 配置为无日志)\n"
log_file = Path(path)
if not log_file.exists():
return f"日志文件不存在: {path}\n"
try:
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
content_lines = f.readlines()
return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
except Exception as e:
return f"读取日志失败 ({path}): {str(e)}\n"
async def get_process_info(process: str) -> dict:
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
describe_clean = strip_ansi_codes(describe_raw)
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
return {
"process_status": "not_found",
"out_path": None,
"err_path": None,
}
status = "unknown"
out_path = None
err_path = None
for line in describe_raw.splitlines():
line_clean = strip_ansi_codes(line)
if line_clean.count('') < 2:
continue
parts = [p.strip() for p in line_clean.split('') if p.strip()]
if len(parts) >= 2:
key = parts[0].lower()
value = parts[1]
if key == "status":
status = value.lower()
elif key == "out_log_path":
out_path = value
elif key == "err_log_path":
err_path = value
if not out_path:
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
if not err_path:
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
return {
"process_status": status,
"out_path": out_path,
"err_path": err_path,
}
# ---------------- 配置路由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)
config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists():
return {"content": "# youtube.ini 文件不存在,将创建空文件\n"}
try:
content = config_file.read_text(encoding="utf-8")
return {"content": content}
except Exception as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
@app.post("/save_config")
async def save_config(request: Request, process: str = Query(..., description="进程名")):
if process != "youtube":
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
try:
data = await request.json()
content = data.get("content", "")
except:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- 配置路由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)
config_file = CONFIG_DIR / "URL_config.ini"
if not config_file.exists():
return {"content": "# URL_config.ini 文件不存在,将创建空文件\n"}
try:
content = config_file.read_text(encoding="utf-8")
return {"content": content}
except Exception as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
@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)
try:
data = await request.json()
content = data.get("content", "")
except:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- PM2 控制路由 ----------------
@app.get("/start")
async def start(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"start {process}")
return JSONResponse({"output": output})
@app.get("/stop")
async def stop(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
# 第一步:尝试优雅停止
pm2_output = await run_pm2(f"stop {process}")
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
await asyncio.sleep(2.0)
# 第三步:主动查找并杀死 ffmpeg
await force_kill_ffmpeg(process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop + 强杀 ffmpeg",
"pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
})
@app.get("/restart")
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)
return JSONResponse({"output": output})
@app.get("/status")
async def status(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({
"raw_status": "",
"process_status": "invalid",
"recent_log": f"无效进程名: {process}",
"recent_error": ""
})
full_status_raw = await run_pm2("status", timeout=8.0)
full_status = strip_ansi_codes(full_status_raw)
info = await get_process_info(process)
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
if info["process_status"] == "not_found":
recent_log = "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)\n"
recent_error = ""
return JSONResponse({
"raw_status": full_status,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
})
# ---------------- 首页 ----------------
@app.get("/")
async def index():
return FileResponse(BASE_DIR / "index.html")

1
web.sh Normal file
View File

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

2202
youtube.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
ue78-1c3e-mr9g-14mz-9r4z # 没有ypp的美食
qxvb-r47b-r5ju-6ud3-6k7z # vlog频道
x04z-564w-aks7-embw-30y4 #美食ypp
; https://live.douyin.com/517715534931,主播: 一只羊
; https://live.douyin.com/460525712926,主播: 丽姐摆摊记
; 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,主播: 加速姐的摆摊日记