18 Commits

Author SHA1 Message Date
Your Name
1544db0593 s 2026-03-19 07:31:40 +00:00
eric
c1e2a039ac 's' 2026-03-01 09:55:59 -06:00
eric
c6a180c38b 's' 2026-03-01 09:53:54 -06:00
eric
f4044c0455 's' 2026-03-01 09:52:08 -06:00
eric
391f0f2b40 's' 2026-03-01 09:36:26 -06:00
eric
96a183f350 's' 2026-03-01 09:28:40 -06:00
eric
c6889d4917 's' 2026-03-01 09:26:14 -06:00
eric
4c9ed17420 's' 2026-03-01 09:24:07 -06:00
eric
f9b1639f3f 's' 2026-03-01 09:22:05 -06:00
eric
03b8d52326 's' 2026-03-01 09:12:41 -06:00
eric
89037d6786 '' 2026-03-01 03:36:11 -06:00
Your Name
509b0f2455 s 2026-03-01 09:34:44 +00:00
Your Name
994c0c4c1b s 2026-03-01 05:49:49 +00:00
eric
7d54c60c40 '' 2026-02-28 23:36:05 -06:00
eric
ebec307628 's' 2026-02-28 07:17:06 -06:00
eric
93874a5c0e 's' 2026-02-28 07:16:16 -06:00
Your Name
0e8f1947ee s 2026-02-23 01:56:25 +00:00
eric
fe7de8ce23 's' 2026-02-22 06:38:22 -06:00
7 changed files with 280 additions and 384 deletions

View File

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

View File

@@ -1,13 +1,3 @@
[youtube] [youtube]
# 必填YouTube 直播推流 key在 YouTube 工作室 → 创建 → 流式传输 获取 # 只写 stream key推荐方式
key = qxvb-r47b-r5ju-6ud3-6k7z key = qxvb-r47b-r5ju-6ud3-6k7z
; key = x04z-564w-aks7-embw-30y4
# 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP默认 RTMPS
; rtmps = 否
# 可选:视频比特率 kbpsYouTube 推荐 2500可填 3000-4500 提高画质
; bitrate = 2500
# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ减轻 CPU。填 1 或 是
fast_audio = 1

View File

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

View File

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

View File

@@ -79,42 +79,25 @@ def start_douyin_youtube_ffplay(
只从 config/youtube.ini 读取一个 stream key取第一个有效值 只从 config/youtube.ini 读取一个 stream key取第一个有效值
""" """
# ====================== 读取 YouTube 配置 ====================== # ====================== 读取 YouTube stream key 配置 ======================
config_file = "config/youtube.ini" config_file = "config/youtube.ini"
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/" rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
use_rtmps = True default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
# 720p 竖屏 YouTube 建议 46 Mbps偏低易触发「数据不足/卡顿」提示 youtube_rtmp = rtmp_base + default_key # 默认值
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
stream_key = None
fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ显著减轻 CPU
if os.path.exists(config_file): if os.path.exists(config_file):
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read(config_file, encoding="utf-8") config.read(config_file, encoding="utf-8")
if config.has_section("youtube"): if config.has_section("youtube"):
for k, v in config.items("youtube"): for key_name, value in config.items("youtube"):
v = (v or "").strip() value = value.strip()
if k == "rtmps" and v.lower() in ("0", "false", ""): if value:
use_rtmps = False if value.startswith("rtmp://"):
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "", "", "on"): youtube_rtmp = value
fast_audio = True else:
elif k == "bitrate" and v: youtube_rtmp = rtmp_base + value
try: print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
n = int(v) break # 只取第一个有效 key保持单路推流不加多路逻辑
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
except ValueError:
pass
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
stream_key = v
if stream_key:
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
youtube_rtmp = stream_key
else:
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
else:
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key")
# ====================== 常量配置 ====================== # ====================== 常量配置 ======================
MAX_RETRY_DELAY = 90 MAX_RETRY_DELAY = 90
@@ -123,7 +106,6 @@ def start_douyin_youtube_ffplay(
NO_FRAME_TIMEOUT = 120 NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25 START_CHECK_AFTER = 25
STALE_OUTPUT_SECONDS = 40 STALE_OUTPUT_SECONDS = 40
RECONNECT_DELAY_MAX = 10
END_KEYWORDS = [ END_KEYWORDS = [
"connection reset by peer", "connection reset by peer",
@@ -131,7 +113,6 @@ def start_douyin_youtube_ffplay(
"invalid data found", "invalid data found",
"server returned 403", "server returned 403",
"server returned 404", "server returned 404",
"server returned 500",
"ingestion error", "ingestion error",
"access denied", "access denied",
"packet too short", "packet too short",
@@ -139,10 +120,6 @@ def start_douyin_youtube_ffplay(
"live has ended", "live has ended",
"no route to host", "no route to host",
"connection timed out", "connection timed out",
"connection refused",
"error writing output",
"broken pipe",
"i/o error",
] ]
douyin_headers = ( douyin_headers = (
@@ -157,11 +134,10 @@ def start_douyin_youtube_ffplay(
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}") stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}") print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
# ====================== NVENC 检测(符合 YouTube 推荐比特率)====================== # ====================== NVENC 检测 ======================
codec_v = "libx264" codec_v = "libx264"
# ultrafast+zerolatency在软编下尽量让 speed≥1.0;画质略降、直播可接受 preset_v = "veryfast"
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0", tune_param = ["-tune", "zerolatency"]
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
try: try:
if platform.system().lower() in ["windows", "linux"]: if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5) check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
@@ -169,90 +145,224 @@ def start_douyin_youtube_ffplay(
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5) enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
if "h264_nvenc" in (enc.stdout or ""): if "h264_nvenc" in (enc.stdout or ""):
codec_v = "h264_nvenc" codec_v = "h264_nvenc"
video_args = ["-profile:v", "high", "-level", "4.0", preset_v = "p5"
"-tune", "ll", "-rc", "cbr", "-b:v", b_v, tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
"-maxrate", maxrate, "-bufsize", bufsize]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速") print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e: except Exception as e:
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}") print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ====================== # 720p (最原始)
_script_dir = os.path.dirname(os.path.abspath(__file__))
_ffmpeg_cwd = None
if fast_audio:
print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ减轻 CPU 以利 speed≥1.0")
_af_chain = "afftdn=nf=-26:tn=1,aresample=async=1:first_pts=0"
else:
# 优先 RNNoise(arnndn),无模型则用 afftdnaphaser 相位扰动可干扰 Content ID 指纹
_arnndn_paths = [
os.path.join(_script_dir, "arnndn-models", "cb.rnnn"),
os.path.join(os.path.dirname(_script_dir), "arnndn-models", "cb.rnnn"),
"arnndn-models/cb.rnnn",
]
_arnndn_model = None
for _p in _arnndn_paths:
if os.path.isfile(_p):
_arnndn_model = os.path.normpath(_p)
break
if _arnndn_model:
print(f"[INFO] 使用 RNNoise 降噪模型: {_arnndn_model}")
_arnndn_base = os.path.dirname(os.path.dirname(os.path.abspath(_arnndn_model)))
_af_denoise = "arnndn=m=arnndn-models/cb.rnnn:mix=0.82,"
_ffmpeg_cwd = _arnndn_base
else:
print("[INFO] 未找到 arnndn 模型,使用 afftdn 降噪(可克隆 richardpl/arnndn-models 到 arnndn-models/")
_af_denoise = "afftdn=nf=-26:tn=1,"
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段EQ + 相位扰动(纯 FFmpeg 理论上限 ~70%
_af_chain = (
_af_denoise
+ "highpass=f=200,lowpass=f=3800,"
+ "equalizer=f=150:width_type=o:width=2.5:g=-14,"
+ "equalizer=f=350:width_type=o:width=2:g=-10,"
+ "equalizer=f=600:width_type=o:width=1.8:g=-8,"
+ "equalizer=f=1000:width_type=o:width=1.5:g=-6,"
+ "equalizer=f=2500:width_type=o:width=1.2:g=-4,"
+ "acompressor=threshold=-26dB:ratio=5:attack=8:release=80:makeup=5,"
+ "afreqshift=shift=22,"
+ "asetrate=48000*1.035,aresample=48000,"
+ "aphaser=type=t:decay=0.4:delay=3:speed=0.7,"
+ "volume=1.12,"
+ "aresample=async=1:first_pts=0"
)
# 720p 竖屏,符合 YouTube 推荐46 Mbps、2s 关键帧、AAC 128k
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
"-bf", "0", "-sc_threshold", "0"]
if codec_v == "libx264":
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
# -re按源时间戳节奏拉流避免突发过快不用 realtime/arealtime输入抖动时易让 YouTube 判为数据不足)
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
"-stats", "-stats_period", "1", "-stats", "-stats_period", "1",
"-rw_timeout", "50000000", "-rw_timeout", "30000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX), "-reconnect_delay_max", "5",
"-fflags", "+genpts+discardcorrupt", "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-err_detect", "ignore_err", "-err_detect", "ignore_err",
"-max_delay", "500000", "-max_delay", "80000",
"-thread_queue_size", "2048", "-thread_queue_size", "2048",
"-filter_threads", "4",
"-headers", douyin_headers, "-headers", douyin_headers,
"-re",
"-i", real_url, "-i", real_url,
# bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x # 720p
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", "-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", codec_v, "-c:v", "libx264",
*video_args, "-preset", "fast",
*_video_mid, # "-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", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改41080p可音频上 128kYouTube 推荐)
"-af", _af_chain, "-af", "aresample=async=1:first_pts=0",
"-flags", "+global_header", "-flags", "+global_header",
"-flvflags", "no_duration_filesize", "-flvflags", "no_duration_filesize",
"-f", "flv", youtube_rtmp "-f", "flv", youtube_rtmp
] ]
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
# 720p-低CPU + 背景音乐处理 + 防ContentID
# ffmpeg_command = [
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
# "-stats", "-stats_period", "1",
# "-rw_timeout", "30000000",
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
# "-reconnect_delay_max", "5",
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
# "-flags", "low_delay",
# "-err_detect", "ignore_err",
# "-max_delay", "80000",
# "-thread_queue_size", "2048",
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频处理 720p
# "-vf",
# "fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
# "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "veryfast", # 降低CPU
# "-profile:v", "high",
# "-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50", # 与fps=25匹配
# "-r", "25",
# "-bf", "0",
# "-sc_threshold", "0",
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# "-pix_fmt", "yuv420p",
# # 音频处理(轻量 + 背景音乐弱化 + 防Content ID
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
# "-af",
# "afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
# "highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
# "equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
# "equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
# "asetrate=48000*1.02,aresample=48000," # 音高微升
# "afreqshift=shift=15," # 小幅频率偏移
# "volume=1.1," # 音量补偿
# "aresample=async=1:first_pts=0", # 音视频同步
# # 推流参数
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# 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", "medium", # Ubuntu server CPU通常能承受质量比faster好很多若掉帧可回退到faster或veryfast
# "-tune", "zerolatency",
# "-profile:v", "high",
# "-level", "4.2", # 支持1080p@25fps
# # "-b:v", "8500k", "-maxrate", "10000k", "-bufsize", "20000k",
# "-b:v", "4500k", "-maxrate", "5500k", "-bufsize", "9000k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50",
# "-r", "25",
# "-bf", "0",
# "-sc_threshold", "0",
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0:rc-lookahead=0",
# "-pix_fmt", "yuv420p",
# # 音频处理 - 防Content ID 强化版(多维度修改音频指纹)
# "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
# "-af",
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85,"
# "afftdn=nf=-24:tn=1," # 中等降噪,保留人声清晰度
# "highpass=f=100,lowpass=f=5200," # 切除低频鼓/贝斯 & 高频镲/噪音,聚焦人声区
# "equalizer=f=180:width_type=o:width=2.2:g=-12," # 强衰减低频(背景音乐基础层)
# "equalizer=f=350:width_type=o:width=1.8:g=-9," # 衰减低中频(常见旋律区)
# "equalizer=f=750:width_type=o:width=1.6:g=-7," # 中频衰减,破坏音乐结构
# "equalizer=f=2800:width_type=o:width=1.2:g=-5," # 高中频轻衰减,保持人声自然
# "acompressor=threshold=-26dB:ratio=5.5:attack=7:release=90:makeup=6," # 强压缩,改变动态指纹
# "asetrate=48000*1.05,atempo=1.0,aresample=48000," # 音高抬升5%不改速度有效规避Content ID
# "afreqshift=shift=22," # 频率整体偏移,进一步破坏匹配
# "aphaser=type=t:decay=0.35:delay=3.0:speed=0.5," # 正确参数轻微相位扰动triangular类型防检测
# # "anlmdn=s=0.00012:p=0.0008:r=0.002," # 轻量非线性噪门,清理残余背景音乐
# "volume=1.08," # 补偿音量衰减
# "aresample=async=1:first_pts=0:min_hard_comp=0.100000:first_pts=0", # 强制音视频同步
# # 推流优化FLV适合YouTube RTMP/RTMPS
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# ====================== 主重试循环(原样保留) ======================
# ffmpeg_command = [
# "ffmpeg","-y","-loglevel","info","-nostdin","-re",
# "-stats","-stats_period","1",
# # 输入稳定
# "-rw_timeout","30000000",
# "-reconnect","1","-reconnect_at_eof","1","-reconnect_streamed","1",
# "-reconnect_delay_max","5",
# # 关键:删除低延迟杀手参数
# "-fflags","+genpts+discardcorrupt",
# "-err_detect","ignore_err",
# "-max_delay","500000",
# "-thread_queue_size","512",
# # 抖音源
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频处理
# "-vf","fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
# # 编码
# "-c:v","libx264",
# "-preset","veryfast",
# "-profile:v","high",
# "-level","4.2",
# "-b:v","4500k","-maxrate","4500k","-bufsize","9000k",
# "-g","50","-keyint_min","50",
# "-r","25",
# "-pix_fmt","yuv420p",
# "-bf","0",
# "-sc_threshold","0",
# "-x264-params","force-cfr=1:scenecut=0:open_gop=0",
# # 音频(强烈建议简化)
# "-c:a","aac","-b:a","128k","-ar","44100","-ac","2",
# "-af","aresample=async=1,volume=1.05",
# # 推流
# "-flvflags","no_duration_filesize",
# "-f","flv", youtube_rtmp
# ]
proc = None proc = None
stderr_q = None stderr_q = None
reader_t = None reader_t = None
@@ -269,17 +379,15 @@ def start_douyin_youtube_ffplay(
stderr_q = queue.Queue() stderr_q = queue.Queue()
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}") print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
_popen_kw = dict( proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True
) )
if _ffmpeg_cwd:
_popen_kw["cwd"] = _ffmpeg_cwd
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
proc.last_out_ts = time.time() proc.last_out_ts = time.time()
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q)) reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))

View File

@@ -1,14 +0,0 @@
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for." name="description"><meta content="noodp, " name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"><meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image"><title>Google</title><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var _g={kEI:'gbObaeXAG-Gy5NoPmeGiyAg',kEXPI:'0,1304203,2935842,14111,64702,6398,354503,5532962,16,36811390,25228681,123988,28395,11943,53220,39775,61250,14067,23259,37800,28333,79315,7707,7,33385,4719,24374,1,2884,3086,16757,46340,9920,10754,11886,6436,4952,4791,2646,6292,12404,5550,14505,1383,1,2,1515,3355,196,5814,2,4205,1,16100,2,873,2,22,2208,5108,3008,24,771,4074,4,5393,18686,8466,5337,310,535,845,1201,2,11,2,1560,3,5189,1,13,738,1009,2,1746,4,4878,5773,4542,4,2243,240,297,1,1117,389,4,2647,5,2698,2870,298,9253,1275,7,1784,1050,2,1914,5,10456,2,5724,9,602,59,36,1554,4,2570,3341,932,3066,1,2,1402,3,1117,242,2,2356,2576,49,1,8,216,441,158,2,1645,191,4,1928,4,1634,544,4,1568,1032,1,1399,187,259,1846,5,259,5,197,1921,445,19,1421,119,4,2364,2363,5,39,633,1937,4,57,4,3784,4,76,1970,4,265,106,5,3,971,4,966,5,1125,1,2,2,815,3,2932,4,32,4,130,717,1856,4,40,4,2110,4,134,304,323,1,576,1776,1864,42,5,2,479,704,4,14,3,1,449,4,730,5,75,4,1918,1,21,4,465,429,537,1563,1,1090,2,265,702,4905,1788,14,507,823,747,4,331,4,959,452,57,258,615,80,7,2245,4,281,264,341,221,1,2976,508,339,3430,503,3,1412,1354,4,12,33,2093,4,945,16,4,612,1,20998529,1717,7635,5,2992,4,2960,3,8491,2,1562,3,2687,3,9181,536,2,2461,1194,3,746,6,918,231,3,1369,1494,2715,1,6494629,4214,2,3639,565,745,38,517,541,84,108428,25,36,10,3516226,209655,562,143,12493584,256605,48968,4054456,476,441,42,5,116,590,5,1023,1671,844,1,148,3,6,163,101,343,4,65,388,2,707,260,44,4,152,4,488,62,219,3,2,2,2,550,4,885,1050,2,50,641,5,429,6,2800,5,138,1,863,130,45,2,11,2,16,361,218,1148,1,2,642,214,1485,417,712,2695,1043,281,5,330,1255,292,114,289,54,161,5,142,4,587,412,322,525,1,361,503,213,249,99,472,165,7,51,90,174,436,472,3,289,379,458,583,331,601,587,556,100,949,821,118,4,177,37,7,12,3,6,25,1006,3,2,2,2,67,134,727,4,283,321,70,212,125,4,4,3,2,1,2,3,2,2,1,2,2,2,3,1,3,2,1,3,2,1,3,1,2,3,1,2,2,2,3,2,1,3,1,2,3,2,2,2,2,1,3,1,3,2,1,2,10,2,2,3,2,2,10,1,2,3,1,2,10,2,3,2,1,3,9,2,3,1,3,2,1,2,2,2,3,1,2,3,2,2,1,3,1,3,2,1,2,2,2,2,2,3,2,2,1,2,3,2,1,2,2,2,3,1,3,1,3,1,2,2,10,2,3,1,3,1,11,2,2,1,2,2,10,2,3,2,2,1,183,94,60,120,149,39,317,3,2,2,2,744,296,177,5,22,414,377,24,216,1579,8,293,133,136,134,83,118,2,3,2,2,2,956,1,2,785,3,2,2,2,348,295,4,106,229,180,5,62,2,878,573,260,185,936,640,1027,638,713,94,18,3,2,2,2,4,308,593,3,2,2,2,720,519,6,1,4357,427,3,1,370,435,364,71,61,442,901,4',kBL:'eaQ3',kOPI:89978449};(function(){var a;((a=window.google)==null?0:a.stvsc)?google.kEI=_g.kEI:window.google=_g;}).call(this);})();(function(){google.sn='webhp';google.kHL='en';google.rdn=true;})();(function(){
var g=this||self;function k(){return window.google&&window.google.kOPI||null};var l,m=[];function n(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||l}function p(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b}function q(a){/^http:/i.test(a)&&window.location.protocol==="https:"&&(google.ml&&google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a}
function r(a,b,d,c,h){var e="";b.search("&ei=")===-1&&(e="&ei="+n(c),b.search("&lei=")===-1&&(c=p(c))&&(e+="&lei="+c));var f=b.search("&cshid=")===-1&&a!=="slh";c="&zx="+Date.now().toString();g._cshid&&f&&(c+="&cshid="+g._cshid);(d=d())&&(c+="&opi="+d);return"/"+(h||"gen_204")+"?atyp=i&ct="+String(a)+"&cad="+(b+e+c)};l=google.kEI;google.getEI=n;google.getLEI=p;google.ml=function(){return null};google.log=function(a,b,d,c,h,e){e=e===void 0?k:e;d||(d=r(a,b,e,c,h));if(d=q(d)){a=new Image;var f=m.length;m[f]=a;a.onerror=a.onload=a.onabort=function(){delete m[f]};a.src=d}};google.logUrl=function(a,b){b=b===void 0?k:b;return r("",a,b)};}).call(this);(function(){google.y={};google.sy={};function e(a,b,d){if(a)var c=a.id;else{do c=Math.random();while(d[c])}d[c]=[a,b]}var f;(f=google).x||(f.x=function(a,b){e(a,b,google.y)});var g;(g=google).sx||(g.sx=function(a,b){e(a,b,google.sy)});google.bx=!1;var h;(h=google).lx||(h.lx=function(){});var k=[],l;(l=google).fce||(l.fce=function(a,b,d,c){k.push([a,b,d,c])});google.qce=k;google.adl=[];}).call(this);google.f={};(function(){
document.documentElement.addEventListener("submit",function(b){var a;if(a=b.target){var c=a.getAttribute("data-submitfalse");a=c==="1"||c==="q"&&!a.elements.q.value?!0:!1}else a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener("click",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(a.tagName==="A"){a=a.getAttribute("data-nohref")==="1";break a}a=!1}a&&b.preventDefault()},!0);}).call(this);</script><style>#gbar,#guser{font-size:13px;padding-top:1px !important;}#gbar{height:22px}#guser{padding-bottom:7px !important;text-align:right}.gbh,.gbd{border-top:1px solid #c9d7f1;font-size:1px}.gbh{height:0;position:absolute;top:24px;width:100%}@media all{.gb1{height:22px;margin-right:.5em;vertical-align:top}#gbar{float:left}}a.gb1,a.gb4{text-decoration:underline !important}a.gb1,a.gb4{color:#00c !important}.gbi .gb4{color:#dd8e27 !important}.gbf .gb4{color:#900 !important}
</style><style>body,td,a,p,.h{font-family:sans-serif}body{margin:0;overflow-y:scroll}#gog{padding:3px 8px 0}td{line-height:.8em}.gac_m td{line-height:17px}form{margin-bottom:20px}.h{color:#1967d2}em{font-weight:bold;font-style:normal}.lst{height:25px;width:496px}.gsfi,.lst{font:18px sans-serif}.gsfs{font:17px sans-serif}.ds{display:inline-box;display:inline-block;margin:3px 0 4px;margin-left:4px}input{font-family:inherit}body{background:#fff;color:#1f1f1f}a{color:#681da8;text-decoration:none}a:hover,a:active{text-decoration:underline}.fl a{color:#1967d2}a:visited{color:#681da8}.sblc{padding-top:5px}.sblc a{display:block;margin:2px 0;margin-left:13px;font-size:11px}.lsbb{background:#ecedee;border:solid 1px;border-color:#d2d2d2 #70757a #70757a #d2d2d2;height:30px}.lsbb{display:block}#WqQANb a{display:inline-block;margin:0 12px}.lsb{background:url(/images/nav_logo229.png) 0 -261px repeat-x;color:#1f1f1f;border:none;cursor:pointer;height:30px;margin:0;outline:0;font:15px sans-serif;vertical-align:top}.lsb:active{background:#dadce0}.lst:focus{outline:none}</style><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.erd={jsr:1,bv:2383,de:true,dpf:'UAON6G49novXd1LJI--iGd6VfzOOuK54njDRAqwNAgA'};
var g=this||self;var k,l=(k=g.mei)!=null?k:1,m,p=(m=g.diel)!=null?m:0,q,r=(q=g.sdo)!=null?q:!0,t=0,u,w=google.erd,x=w.jsr;google.ml=function(a,b,d,n,e){e=e===void 0?2:e;b&&(u=a&&a.message);d===void 0&&(d={});d.cad="ple_"+google.ple+".aple_"+google.aple;if(google.dl)return google.dl(a,e,d,!0),null;b=d;if(x<0){window.console&&console.error(a,b);if(x===-2)throw a;b=!1}else b=!a||!a.message||a.message==="Error loading script"||t>=l&&!n?!1:!0;if(!b)return null;t++;d=d||{};b=encodeURIComponent;var c="/gen_204?atyp=i&ei="+b(google.kEI);google.kEXPI&&(c+="&jexpid="+b(google.kEXPI));c+="&srcpg="+b(google.sn)+"&jsr="+b(w.jsr)+
"&bver="+b(w.bv);w.dpf&&(c+="&dpf="+b(w.dpf));var f=a.lineNumber;f!==void 0&&(c+="&line="+f);var h=a.fileName;h&&(h.indexOf("-extension:/")>0&&(e=3),c+="&script="+b(h),f&&h===window.location.href&&(f=document.documentElement.outerHTML.split("\n")[f],c+="&cad="+b(f?f.substring(0,300):"No script found.")));google.ple&&google.ple===1&&(e=2);c+="&jsel="+e;for(var v in d)c+="&",c+=b(v),c+="=",c+=b(d[v]);c=c+"&emsg="+b(a.name+": "+a.message);c=c+"&jsst="+b(a.stack||"N/A");c.length>=12288&&(c=c.substring(0,12288));a=c;n||google.log(0,"",a);return a};window.onerror=function(a,b,d,n,e){u!==a&&(a=e instanceof Error?e:Error(a),d===void 0||"lineNumber"in a||(a.lineNumber=d),b===void 0||"fileName"in a||(a.fileName=b),google.ml(a,!1,void 0,!1,a.name==="SyntaxError"||a.message.substring(0,11)==="SyntaxError"||a.message.indexOf("Script error")!==-1?3:p));u=null;r&&t>=l&&(window.onerror=null)};})();</script></head><body bgcolor="#fff"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var src='/images/nav_logo229.png';var iesg=false;document.body.onload = function(){window.n && window.n();if (document.images){new Image().src=src;}
if (!iesg){document.f&&document.f.q.focus();document.gbqf&&document.gbqf.q.focus();}
}
})();</script><div id="mngb"><div id=gbar><nobr><b class=gb1>Search</b> <a class=gb1 href="https://www.google.com/imghp?hl=en&tab=wi">Images</a> <a class=gb1 href="http://maps.google.com/maps?hl=en&tab=wl">Maps</a> <a class=gb1 href="https://play.google.com/?hl=en&tab=w8">Play</a> <a class=gb1 href="https://www.youtube.com/?tab=w1">YouTube</a> <a class=gb1 href="https://news.google.com/?tab=wn">News</a> <a class=gb1 href="https://mail.google.com/mail/?tab=wm">Gmail</a> <a class=gb1 href="https://drive.google.com/?tab=wo">Drive</a> <a class=gb1 style="text-decoration:none" href="https://www.google.com/intl/en/about/products?tab=wh"><u>More</u> &raquo;</a></nobr></div><div id=guser width=100%><nobr><span id=gbn class=gbi></span><span id=gbf class=gbf></span><span id=gbe></span><a href="http://www.google.com/history/optout?hl=en" class=gb4>Web History</a> | <a href="/preferences?hl=en" class=gb4>Settings</a> | <a target=_top id=gb_70 href="https://accounts.google.com/ServiceLogin?hl=en&passive=true&continue=http://www.google.com/&ec=GAZAAQ" class=gb4>Sign in</a></nobr></div><div class=gbh style=left:0></div><div class=gbh style=right:0></div></div><center><br clear="all" id="lgpd"><div><img alt="Google" height="92" src="/images/branding/googlelogo/1x/googlelogo_white_background_color_272x92dp.png" style="padding:28px 0 14px" width="272" id="hplogo"><br><br></div><form action="/search" name="f"><table cellpadding="0" cellspacing="0"><tr valign="top"><td width="25%">&nbsp;</td><td align="center" nowrap=""><input name="ie" value="ISO-8859-1" type="hidden"><input value="en" name="hl" type="hidden"><input name="source" type="hidden" value="hp"><input name="biw" type="hidden"><input name="bih" type="hidden"><div class="ds" style="height:32px;margin:4px 0"><input class="lst" style="margin:0;padding:5px 8px 0 6px;vertical-align:top;color:#1f1f1f" autocomplete="off" value="" title="Google Search" maxlength="2048" name="q" size="57"></div><br style="line-height:0"><span class="ds"><span class="lsbb"><input class="lsb" value="Google Search" name="btnG" type="submit"></span></span><span class="ds"><span class="lsbb"><input class="lsb" id="tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1" value="I'm Feeling Lucky" name="btnI" type="submit"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var id='tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1';document.getElementById(id).onclick = function(){if (this.form.q.value){this.checked = 1;if (this.form.iflsig)this.form.iflsig.disabled = false;}
else top.location='/doodles/';};})();</script><input value="AFdpzrgAAAAAaZvBkZ6x6tP9yqY4FfFqM-NYRs9cR3zt" name="iflsig" type="hidden"></span></span></td><td class="fl sblc" align="left" nowrap="" width="25%"><a href="/advanced_search?hl=en&amp;authuser=0">Advanced search</a></td></tr></table><input id="gbv" name="gbv" type="hidden" value="1"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var a,b="1";if(document&&document.getElementById)if(typeof XMLHttpRequest!="undefined")b="2";else if(typeof ActiveXObject!="undefined"){var c,d,e=["MSXML2.XMLHTTP.6.0","MSXML2.XMLHTTP.3.0","MSXML2.XMLHTTP","Microsoft.XMLHTTP"];for(c=0;d=e[c++];)try{new ActiveXObject(d),b="2"}catch(h){}}a=b;if(a=="2"&&location.search.indexOf("&gbv=2")==-1){var f=google.gbvu,g=document.getElementById("gbv");g&&(g.value=a);f&&window.setTimeout(function(){location.href=f},0)};}).call(this);</script></form><div style="font-size:83%;min-height:3.5em"><br></div><span id="footer"><div style="font-size:10pt"><div style="margin:19px auto;text-align:center" id="WqQANb"><a href="/intl/en/ads/">Advertising</a><a href="/services/">Business Solutions</a><a href="/intl/en/about.html">About Google</a></div></div><p style="font-size:8pt;color:#636363">&copy; 2026 - <a href="/intl/en/policies/privacy/">Privacy</a> - <a href="/intl/en/policies/terms/">Terms</a></p></span></center><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.cdo={height:757,width:1440};(function(){var a=window.innerWidth,b=window.innerHeight;if(!a||!b){var c=window.document,d=c.compatMode=="CSS1Compat"?c.documentElement:c.body;a=d.clientWidth;b=d.clientHeight}if(a&&b&&(a!=google.cdo.width||b!=google.cdo.height)){var e=google,f=e.log,g="/client_204?&atyp=i&biw="+a+"&bih="+b+"&ei="+google.kEI,h="",k=window.google&&window.google.kOPI||null;k&&(h+="&opi="+k);f.call(e,"","",g+h)};}).call(this);})();(function(){google.xjs={basecomb:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/ck\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAICAQAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAIAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d0/ujg\x3d1/rs\x3dACT90oHYvixg5Dv0yQj-SB1JU6LIcj3yYQ',basecss:'/xjs/_/ss/k\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAI/rs\x3dACT90oFMtqhjIKGT4rLyv9oL3Z6ncHStwQ',basejs:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/dg\x3d0/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA',excm:[]};})();(function(){var u='/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d4/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA/m\x3dsb_he,d';var st=1;var amd=1000;var mmd=0;var pop=true;var povp=false;var raf=false;var lfpp=false;var ecb=false;var cst=false;var pxp=false;
var f=this||self;function g(){var a=google.ia&&google.ia.r.B2Jtyd;return a&&[1,5,6].indexOf(a.m)>=0&&a.cbfd&&a.cbvi?a:void 0};function h(a){var b=document.createElement("link");b.as="script";b.href=a;b.rel="preload";document.body.appendChild(b)}function k(){var a=[l];google.dp||(a.forEach(h),google.dp=!0)};google.ps=google.ps||[];function m(a){var b=l,c=function(){};google.lx=google.stvsc?c:function(){n(b,a&&raf,a&&lfpp);google.lx=c};google.bx||google.lx()}function p(a,b){b&&(a.src=b);var c=a.onload;a.onload=function(d){c&&c(d);google.ps=google.ps.filter(function(e){return a.src!==e})};google.ps.push(a.src);document.body.appendChild(a)}function q(a,b,c,d){c&&(a.fetchPriority="low");b?requestAnimationFrame(function(){p(a,d)}):p(a,d)}google.as=p;function n(a,b,c){google.tick&&google.tick("load","xjsls");var d=document.createElement("script");d.onerror=function(){google.ple=1};d.onload=function(){google.ple=0};google.xjsus=void 0;q(d,b,c,a);google.aple=-1;google.dp=!0};function r(){for(var a=document.getElementsByTagName("img"),b=0,c=a.length;b<c;b++){var d=a[b],e;if(e=d.hasAttribute("data-lzy_")&&Number(d.getAttribute("data-atf"))&1)e=d.getAttribute("jscontroller"),e=!((e==="UBXHI"||e==="R3fhkb"||e==="TSZEqd")&&d.hasAttribute("data-src"));if(e)return!0}return!1};var l,t,w,x,y,z,A,B,C,D,E="";function F(){l=pxp&&google.xjsup||u;var a=l.match("/cb=(loaded_h_\\d+)");a&&a[1]&&(E=a[1]);google.xjsu=l;f._F_jsUrl=l;z=function(c){m(c)};t=!1;w=(st===1||st===3)&&!!google.caft&&!r();x=g();y=!E&&(st===2||st===3)&&!!x&&!r();if(E){var b=(st===2||st===3)&&!!x;f[E]=function(c){var d=!1,e=function(){d||(d=!0,cst?setTimeout(function(){return void c.call(window,window._)},0):c.call(window,window._))};b&&google.ia.adls?(x.cbvi.push(function(){delete google.ia.adls}),x.cbvi.push(e),setTimeout(e,mmd)):e()}}A=
pop;B=povp;C=A&&document.prerendering||B&&document.hidden;D=B?"visibilitychange":"prerenderingchange"}function G(a){t||w||y||C||(z(a),t=!0)}
setTimeout(function(){google.tick&&google.tick("load","xjspls");F();if(w||y||C){if(w){var a=function(){w=!1;G()};google.caft(a);setTimeout(a,amd)}y&&(a=function(){y=!1;G()},x.cbvi.push(a),setTimeout(a,mmd));if(C){var b=function(){(B?document.hidden:document.prerendering)||(C=!1,G(!B),document.removeEventListener(D,b))};document.addEventListener(D,b,{passive:!0})}t||k()}else z()},0);})();window._ = window._ || {};window._DumpException = _._DumpException = function(e){throw e;};window._s = window._s || {};_s._DumpException = _._DumpException;window._qs = window._qs || {};_qs._DumpException = _._DumpException;window.loaded_h_0 = function(cb){cb.call(window,window._);};(function(){var t=[512,0,2048,0,0,0,4194304,33554432,268437004,0,8192,0,4096,4259020,137036800,134234113,548405252,4196417,8192,134217728,1048576,8,1170496,0,72908928,0,568328192];window._F_toggles = window._xjs_toggles = t;})();window._F_installCss = window._F_installCss || function(css){};(function(){var pmc='{\x22d\x22:{},\x22sb_he\x22:{\x22client\x22:\x22heirloom-hp\x22,\x22dh\x22:true,\x22ds\x22:\x22\x22,\x22host\x22:\x22google.com\x22,\x22jsonp\x22:true,\x22msgs\x22:{\x22cibl\x22:\x22Clear Search\x22,\x22dym\x22:\x22Did you mean:\x22,\x22lcky\x22:\x22I\\u0026#39;m Feeling Lucky\x22,\x22lml\x22:\x22Learn more\x22,\x22psrc\x22:\x22This search was removed from your \\u003Ca href\x3d\\\x22/history\\\x22\\u003EWeb History\\u003C/a\\u003E\x22,\x22psrl\x22:\x22Remove\x22,\x22sbit\x22:\x22Search by image\x22,\x22srch\x22:\x22Google Search\x22},\x22ovr\x22:{},\x22pq\x22:\x22\x22,\x22rfs\x22:[],\x22stok\x22:\x22VgWHNe9WWnZFTDWuPRUlX6IPsTw\x22}}';google.pmc=JSON.parse(pmc);})();</script></body></html>

86
web.py
View File

@@ -9,15 +9,15 @@ app = FastAPI(title="多进程录制控制台")
# ---------------- 配置(根目录结构) ---------------- # ---------------- 配置(根目录结构) ----------------
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
CONFIG_DIR = BASE_DIR / "config" CONFIG_DIR = BASE_DIR / "config" # config 文件夹在项目根目录
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" # .pm2/logs 在项目根目录
MAX_LOG_LINES = 300 MAX_LOG_LINES = 300
# 支持的进程列表 # 支持的进程列表
VALID_PROCESSES = {"tiktok", "youtube", "obs"} VALID_PROCESSES = {"tiktok", "youtube", "obs"}
# CORS # CORS(支持 GET 和 POST
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -25,45 +25,22 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ---------------- 工具函数 ---------------- # ---------------- 工具 ----------------
def strip_ansi_codes(text: str) -> str: def strip_ansi_codes(text: str) -> str:
return re.sub(r'\x1b\[[0-9;]*m', '', text) return re.sub(r'\x1b\[[0-9;]*m', '', text)
async def run_pm2(cmd: str) -> str:
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
f"pm2 {cmd}", f"pm2 {cmd}",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
try: try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
result = (stdout or stderr).decode(errors="ignore").strip() return (stdout or stderr).decode(errors="ignore").strip()
return result if result else "命令执行完成(无输出)"
except asyncio.TimeoutError: except asyncio.TimeoutError:
process.kill() process.kill()
await process.wait() return "ERROR: PM2 命令超时"
return "ERROR: PM2 命令超时,已强制终止"
async def force_kill_ffmpeg(process_name: str):
"""尝试强杀与该进程相关的 ffmpeg"""
cmds = [
# 优先杀带有进程名的 ffmpeg最精准
f"pkill -f 'ffmpeg.*{process_name}' || true",
# 再杀所有 ffmpeg兜底比较暴力
"killall -9 ffmpeg 2>/dev/null || true",
]
for cmd in cmds:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
async def read_log_path(path: str, lines: int) -> str: async def read_log_path(path: str, lines: int) -> str:
if not path: if not path:
@@ -80,22 +57,21 @@ async def read_log_path(path: str, lines: int) -> str:
except Exception as e: except Exception as e:
return f"读取日志失败 ({path}): {str(e)}\n" return f"读取日志失败 ({path}): {str(e)}\n"
async def get_process_info(process: str) -> dict: async def get_process_info(process: str) -> dict:
describe_raw = await run_pm2(f"describe {process}", timeout=10.0) describe_raw = await run_pm2(f"describe {process}")
describe_clean = strip_ansi_codes(describe_raw) describe_clean = strip_ansi_codes(describe_raw)
if ("No such process" in describe_clean or
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip(): "Unknown process" in describe_clean or
"not found" in describe_clean.lower() or
not describe_clean.strip()):
return { return {
"process_status": "not_found", "process_status": "not_found",
"out_path": None, "out_path": None,
"err_path": None, "err_path": None,
} }
status = "unknown" status = "unknown"
out_path = None out_path = None
err_path = None err_path = None
for line in describe_raw.splitlines(): for line in describe_raw.splitlines():
line_clean = strip_ansi_codes(line) line_clean = strip_ansi_codes(line)
if line_clean.count('') < 2: if line_clean.count('') < 2:
@@ -110,12 +86,11 @@ async def get_process_info(process: str) -> dict:
out_path = value out_path = value
elif key == "err_log_path": elif key == "err_log_path":
err_path = value err_path = value
# 备用默认路径
if not out_path: if not out_path:
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log") out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
if not err_path: if not err_path:
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log") err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
return { return {
"process_status": status, "process_status": status,
"out_path": out_path, "out_path": out_path,
@@ -198,41 +173,20 @@ async def start(process: str = Query(..., description="进程名")):
output = await run_pm2(f"start {process}") output = await run_pm2(f"start {process}")
return JSONResponse({"output": output}) return JSONResponse({"output": output})
@app.get("/stop") @app.get("/stop")
async def stop(process: str = Query(..., description="进程名")): async def stop(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES: if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"stop {process}")
# 第一步:尝试优雅停止 return JSONResponse({"output": output})
pm2_output = await run_pm2(f"stop {process}")
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
await asyncio.sleep(2.0)
# 第三步:主动查找并杀死 ffmpeg
await force_kill_ffmpeg(process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop + 强杀 ffmpeg",
"pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
})
@app.get("/restart") @app.get("/restart")
async def restart(process: str = Query(..., description="进程名")): async def restart(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES: if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"restart {process}") output = await run_pm2(f"restart {process}")
# 重启后也建议清理一次残留(视情况可选)
await force_kill_ffmpeg(process)
return JSONResponse({"output": output}) return JSONResponse({"output": output})
@app.get("/status") @app.get("/status")
async def status(process: str = Query(..., description="进程名")): async def status(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES: if process not in VALID_PROCESSES:
@@ -242,17 +196,14 @@ async def status(process: str = Query(..., description="进程名")):
"recent_log": f"无效进程名: {process}", "recent_log": f"无效进程名: {process}",
"recent_error": "" "recent_error": ""
}) })
full_status_raw = await run_pm2("status")
full_status_raw = await run_pm2("status", timeout=8.0)
full_status = strip_ansi_codes(full_status_raw) full_status = strip_ansi_codes(full_status_raw)
info = await get_process_info(process) info = await get_process_info(process)
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES) recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES) recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
if info["process_status"] == "not_found": if info["process_status"] == "not_found":
recent_log = "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)\n" recent_log = "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)\n"
recent_error = "" recent_error = ""
return JSONResponse({ return JSONResponse({
"raw_status": full_status, "raw_status": full_status,
"process_status": info["process_status"], "process_status": info["process_status"],
@@ -260,8 +211,7 @@ async def status(process: str = Query(..., description="进程名")):
"recent_error": recent_error, "recent_error": recent_error,
}) })
# ---------------- 首页 ---------------- # ---------------- 首页 ----------------
@app.get("/") @app.get("/")
async def index(): async def index():
return FileResponse(BASE_DIR / "index.html") return FileResponse(BASE_DIR / "index.html")