Compare commits
18 Commits
ffmpegAudi
...
hdmi2vlog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1544db0593 | ||
|
|
c1e2a039ac | ||
|
|
c6a180c38b | ||
|
|
f4044c0455 | ||
|
|
391f0f2b40 | ||
|
|
96a183f350 | ||
|
|
c6889d4917 | ||
|
|
4c9ed17420 | ||
|
|
f9b1639f3f | ||
|
|
03b8d52326 | ||
|
|
89037d6786 | ||
|
|
509b0f2455 | ||
|
|
994c0c4c1b | ||
|
|
7d54c60c40 | ||
|
|
ebec307628 | ||
|
|
93874a5c0e | ||
|
|
0e8f1947ee | ||
|
|
fe7de8ce23 |
@@ -36,4 +36,6 @@ https://live.douyin.com/573735790640,主播: 小董卤串(餐饮)
|
|||||||
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
|
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
|
||||||
https://live.douyin.com/210443559964,主播: 小小诺
|
https://live.douyin.com/210443559964,主播: 小小诺
|
||||||
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
|
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
|
||||||
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠
|
https://live.douyin.com/737395543665,主播: 西施天馋土豆(努力摆摊)
|
||||||
|
https://live.douyin.com/109205360966,主播: 郸城胖小胖荷叶馍炸串
|
||||||
|
https://live.douyin.com/783406789448,主播: 摆摊小天才
|
||||||
|
|||||||
@@ -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 = 否
|
|
||||||
|
|
||||||
# 可选:视频比特率 kbps,YouTube 推荐 2500,可填 3000-4500 提高画质
|
|
||||||
; bitrate = 2500
|
|
||||||
|
|
||||||
# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ),减轻 CPU。填 1 或 是
|
|
||||||
fast_audio = 1
|
|
||||||
@@ -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× 实时速度**,可满足直播
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、实现方式
|
|
||||||
|
|
||||||
### 方式 A:Python 自建管道(推荐)
|
|
||||||
|
|
||||||
```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. 人声写入 pipe,FFmpeg 另一进程读取并与视频复用推流
|
|
||||||
```
|
|
||||||
|
|
||||||
**依赖**:
|
|
||||||
```
|
|
||||||
demucs
|
|
||||||
torch
|
|
||||||
torchaudio
|
|
||||||
```
|
|
||||||
|
|
||||||
**模型加载**:
|
|
||||||
```python
|
|
||||||
from demucs.pretrained import get_model
|
|
||||||
model = get_model('htdemucs') # 单模型,非 htdemucs_ft
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式 B:GStreamer(若可用)
|
|
||||||
|
|
||||||
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` 纯 FFmpeg,60-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
|
|
||||||
```
|
|
||||||
@@ -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 字影响搜索)
|
|
||||||
|
|
||||||
- 首段包含主关键词和内容概要
|
|
||||||
- 可说明:转播自抖音、直播内容类型、时间等
|
|
||||||
|
|
||||||
### 标签
|
|
||||||
|
|
||||||
- 5–15 个相关标签,如: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 是否有效
|
|
||||||
@@ -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 建议 4–6 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),无模型则用 afftdn;aphaser 相位扰动可干扰 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 推荐:4–6 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", # 改4:1080p可音频上 128k(YouTube 推荐)
|
||||||
"-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))
|
||||||
|
|||||||
115
index.html
115
index.html
@@ -32,10 +32,13 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>
|
<h1>
|
||||||
进程:
|
进程:
|
||||||
<select id="processSelect"></select>
|
<select id="processSelect">
|
||||||
|
<option value="tiktok">tiktok</option>
|
||||||
|
<option value="youtube">youtube</option>
|
||||||
|
<option value="obs">obs</option>
|
||||||
|
</select>
|
||||||
<span id="statusIndicator">⚪ 加载中...</span>
|
<span id="statusIndicator">⚪ 加载中...</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p id="processScriptNote" style="font-size:0.88em;margin:-12px 0 20px;opacity:0.88;font-family:ui-monospace,monospace"></p>
|
|
||||||
|
|
||||||
<!-- YouTube 专属配置(仅 youtube 时显示) -->
|
<!-- YouTube 专属配置(仅 youtube 时显示) -->
|
||||||
<div id="youtubeOnlySection" style="display:none;">
|
<div id="youtubeOnlySection" style="display:none;">
|
||||||
@@ -84,55 +87,10 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
|||||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
/** 进程列表仅来自 GET /process_monitor(web2.py 启动时扫描项目根目录自动识别);PM2 名 = 脚本主文件名(无扩展名)。 */
|
|
||||||
let PROCESS_MONITOR = [];
|
|
||||||
|
|
||||||
function pm2NameFromScript(script) {
|
|
||||||
const base = String(script).split(/[/\\]/).pop() || '';
|
|
||||||
const i = base.lastIndexOf('.');
|
|
||||||
return i > 0 ? base.slice(0, i) : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isYoutubeScript(script) {
|
|
||||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube');
|
|
||||||
}
|
|
||||||
function isTiktokScript(script) {
|
|
||||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
|
|
||||||
}
|
|
||||||
function isObsScript(script) {
|
|
||||||
return pm2NameFromScript(script).startsWith('obs');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProcessMonitor() {
|
|
||||||
const res = await fetch('/process_monitor');
|
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const data = await res.json();
|
|
||||||
const entries = data.entries || [];
|
|
||||||
PROCESS_MONITOR = entries.map((e) => ({
|
|
||||||
script: e.script,
|
|
||||||
label: e.label || e.script,
|
|
||||||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryByPm2(name) {
|
|
||||||
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillProcessSelect() {
|
|
||||||
processSelect.innerHTML = '';
|
|
||||||
PROCESS_MONITOR.forEach((e) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = e.pm2;
|
|
||||||
opt.textContent = `${e.label}(${e.script})`;
|
|
||||||
processSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const logEl = document.getElementById('pm2Log');
|
const logEl = document.getElementById('pm2Log');
|
||||||
const statusIndicator = document.getElementById('statusIndicator');
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
const processSelect = document.getElementById('processSelect');
|
const processSelect = document.getElementById('processSelect');
|
||||||
let currentProcess = '';
|
let currentProcess = 'tiktok';
|
||||||
|
|
||||||
// 各专属区域
|
// 各专属区域
|
||||||
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
|
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
|
||||||
@@ -151,32 +109,23 @@ const loadUrlBtn = document.getElementById('loadUrlBtn');
|
|||||||
const saveUrlBtn = document.getElementById('saveUrlBtn');
|
const saveUrlBtn = document.getElementById('saveUrlBtn');
|
||||||
const urlStatus = document.getElementById('urlStatus');
|
const urlStatus = document.getElementById('urlStatus');
|
||||||
|
|
||||||
function refreshProcessScriptNote() {
|
|
||||||
const el = document.getElementById('processScriptNote');
|
|
||||||
const ent = getEntryByPm2(currentProcess);
|
|
||||||
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
processSelect.addEventListener('change', () => {
|
processSelect.addEventListener('change', () => {
|
||||||
currentProcess = processSelect.value;
|
currentProcess = processSelect.value;
|
||||||
refreshProcessScriptNote();
|
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
updateSectionsVisibility();
|
updateSectionsVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateSectionsVisibility() {
|
function updateSectionsVisibility() {
|
||||||
const ent = getEntryByPm2(currentProcess);
|
youtubeOnlySection.style.display = currentProcess === 'youtube' ? 'block' : 'none';
|
||||||
const s = ent ? ent.script : '';
|
urlConfigSection.style.display = (currentProcess === 'youtube' || currentProcess === 'tiktok') ? 'block' : 'none';
|
||||||
const isYoutube = ent && isYoutubeScript(s);
|
obsSection.style.display = currentProcess === 'obs' ? 'block' : 'none';
|
||||||
const isTiktok = ent && isTiktokScript(s);
|
|
||||||
const isObs = ent && isObsScript(s);
|
if (currentProcess === 'youtube') {
|
||||||
|
loadYoutubeConfig();
|
||||||
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
|
}
|
||||||
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
|
if (currentProcess === 'youtube' || currentProcess === 'tiktok') {
|
||||||
obsSection.style.display = isObs ? 'block' : 'none';
|
loadUrlConfig();
|
||||||
|
}
|
||||||
if (isYoutube) loadYoutubeConfig();
|
|
||||||
if (isYoutube || isTiktok) loadUrlConfig();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// textarea 高度自适应
|
// textarea 高度自适应
|
||||||
@@ -266,9 +215,7 @@ async function pm2Action(action){
|
|||||||
updateStatus(data.process_status);
|
updateStatus(data.process_status);
|
||||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||||
if(data.process_status === 'not_found'){
|
if(data.process_status === 'not_found'){
|
||||||
const ent = getEntryByPm2(currentProcess);
|
logText += `【提示】进程 "${currentProcess}" 未注册到 PM2(可能被 pm2 delete 删除)\n`;
|
||||||
const hint = ent ? `${ent.script}(PM2 名: ${ent.pm2})` : currentProcess;
|
|
||||||
logText += `【提示】未在 PM2 中找到: ${hint}\n`;
|
|
||||||
}else{
|
}else{
|
||||||
logText += `【实时输出日志】`;
|
logText += `【实时输出日志】`;
|
||||||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||||||
@@ -286,9 +233,7 @@ async function pm2Action(action){
|
|||||||
logEl.textContent = logText;
|
logEl.textContent = logText;
|
||||||
scrollLog();
|
scrollLog();
|
||||||
}else{
|
}else{
|
||||||
const ent = getEntryByPm2(currentProcess);
|
logEl.textContent = `操作: ${action} ${currentProcess}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
||||||
const who = ent ? `${ent.script} (${ent.pm2})` : currentProcess;
|
|
||||||
logEl.textContent = `操作: ${action} → ${who}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
|
||||||
scrollLog();
|
scrollLog();
|
||||||
setTimeout(fetchStatus, 1500);
|
setTimeout(fetchStatus, 1500);
|
||||||
}
|
}
|
||||||
@@ -311,26 +256,10 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
|||||||
if(action && action !== 'status') pm2Action(action);
|
if(action && action !== 'status') pm2Action(action);
|
||||||
});
|
});
|
||||||
|
|
||||||
(async function boot() {
|
// 自动刷新 + 初始
|
||||||
try {
|
setInterval(fetchStatus, 1000);
|
||||||
await loadProcessMonitor();
|
fetchStatus();
|
||||||
} catch (e) {
|
updateSectionsVisibility();
|
||||||
logEl.textContent = '无法加载 /process_monitor(请用本服务打开的页面访问,或检查 web2 是否已启动)\n' + (e && e.message ? e.message : '');
|
|
||||||
statusIndicator.textContent = '❌ 未连接';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!PROCESS_MONITOR.length) {
|
|
||||||
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh,且 Web 为当前 web*.py)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fillProcessSelect();
|
|
||||||
currentProcess = PROCESS_MONITOR[0].pm2;
|
|
||||||
processSelect.value = currentProcess;
|
|
||||||
refreshProcessScriptNote();
|
|
||||||
setInterval(fetchStatus, 1000);
|
|
||||||
fetchStatus();
|
|
||||||
updateSectionsVisibility();
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
215
indexOrigin.html
Normal file
215
indexOrigin.html
Normal 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&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>© 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>
|
||||||
192
web2.py → web.py
192
web2.py → web.py
@@ -3,93 +3,21 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
app = FastAPI(title="多进程录制控制台")
|
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"}
|
||||||
|
|
||||||
def _label_short(prefix: str) -> str:
|
# CORS(支持 GET 和 POST)
|
||||||
return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
|
|
||||||
|
|
||||||
|
|
||||||
def discover_script_entries() -> tuple:
|
|
||||||
"""扫描项目根目录自动识别入口:tiktok*.py、youtube*.py、obs*.sh,以及当前本文件(Web 控制台)。PM2 名 = 主文件名(无扩展名)。"""
|
|
||||||
base = BASE_DIR
|
|
||||||
web_path = Path(__file__).resolve()
|
|
||||||
entries: list[dict] = []
|
|
||||||
|
|
||||||
for path in sorted(base.glob("tiktok*.py")):
|
|
||||||
if path.is_file() and path.resolve() != web_path:
|
|
||||||
entries.append({"script": path.name, "label": _label_short("tiktok")})
|
|
||||||
|
|
||||||
for path in sorted(base.glob("youtube*.py")):
|
|
||||||
if path.is_file() and path.resolve() != web_path:
|
|
||||||
entries.append({"script": path.name, "label": _label_short("youtube")})
|
|
||||||
|
|
||||||
for path in sorted(base.glob("obs*.sh")):
|
|
||||||
if path.is_file():
|
|
||||||
entries.append({"script": path.name, "label": _label_short("obs")})
|
|
||||||
|
|
||||||
entries.append({"script": web_path.name, "label": _label_short("web")})
|
|
||||||
|
|
||||||
return tuple(entries)
|
|
||||||
|
|
||||||
|
|
||||||
SCRIPT_ENTRIES = discover_script_entries()
|
|
||||||
|
|
||||||
|
|
||||||
def pm2_name_from_script(script: str) -> str:
|
|
||||||
return Path(script).stem
|
|
||||||
|
|
||||||
|
|
||||||
def _stem(script: str) -> str:
|
|
||||||
return Path(script).stem
|
|
||||||
|
|
||||||
|
|
||||||
def is_youtube_script(script: str) -> bool:
|
|
||||||
return script.endswith(".py") and _stem(script).startswith("youtube")
|
|
||||||
|
|
||||||
|
|
||||||
def is_tiktok_script(script: str) -> bool:
|
|
||||||
return script.endswith(".py") and _stem(script).startswith("tiktok")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_process_monitor():
|
|
||||||
return tuple(
|
|
||||||
{
|
|
||||||
"pm2": pm2_name_from_script(e["script"]),
|
|
||||||
"script": e["script"],
|
|
||||||
"label": e["label"],
|
|
||||||
}
|
|
||||||
for e in SCRIPT_ENTRIES
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PROCESS_MONITOR = _build_process_monitor()
|
|
||||||
VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
|
|
||||||
|
|
||||||
URL_CONFIG_PM2 = frozenset(
|
|
||||||
pm2_name_from_script(e["script"])
|
|
||||||
for e in SCRIPT_ENTRIES
|
|
||||||
if is_tiktok_script(e["script"]) or is_youtube_script(e["script"])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def script_for_pm2(pm2: str) -> Optional[str]:
|
|
||||||
for p in PROCESS_MONITOR:
|
|
||||||
if p["pm2"] == pm2:
|
|
||||||
return p["script"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -97,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:
|
||||||
@@ -152,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:
|
||||||
@@ -182,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,
|
||||||
@@ -197,9 +100,8 @@ async def get_process_info(process: str) -> dict:
|
|||||||
# ---------------- 配置路由(youtube.ini,仅 youtube) ----------------
|
# ---------------- 配置路由(youtube.ini,仅 youtube) ----------------
|
||||||
@app.get("/get_config")
|
@app.get("/get_config")
|
||||||
async def get_config(process: str = Query(..., description="进程名")):
|
async def get_config(process: str = Query(..., description="进程名")):
|
||||||
sc = script_for_pm2(process)
|
if process != "youtube":
|
||||||
if not sc or not is_youtube_script(sc):
|
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
|
||||||
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
|
|
||||||
|
|
||||||
config_file = CONFIG_DIR / "youtube.ini"
|
config_file = CONFIG_DIR / "youtube.ini"
|
||||||
if not config_file.exists():
|
if not config_file.exists():
|
||||||
@@ -212,9 +114,8 @@ async def get_config(process: str = Query(..., description="进程名")):
|
|||||||
|
|
||||||
@app.post("/save_config")
|
@app.post("/save_config")
|
||||||
async def save_config(request: Request, process: str = Query(..., description="进程名")):
|
async def save_config(request: Request, process: str = Query(..., description="进程名")):
|
||||||
sc = script_for_pm2(process)
|
if process != "youtube":
|
||||||
if not sc or not is_youtube_script(sc):
|
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
|
||||||
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -233,8 +134,8 @@ async def save_config(request: Request, process: str = Query(..., description="
|
|||||||
# ---------------- 配置路由(URL_config.ini,youtube 和 tiktok 共享) ----------------
|
# ---------------- 配置路由(URL_config.ini,youtube 和 tiktok 共享) ----------------
|
||||||
@app.get("/get_url_config")
|
@app.get("/get_url_config")
|
||||||
async def get_url_config(process: str = Query(..., description="进程名")):
|
async def get_url_config(process: str = Query(..., description="进程名")):
|
||||||
if process not in URL_CONFIG_PM2:
|
if process not in ["youtube", "tiktok"]:
|
||||||
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
|
return JSONResponse({"error": "仅 youtube 和 tiktok 支持此配置编辑"}, status_code=400)
|
||||||
|
|
||||||
config_file = CONFIG_DIR / "URL_config.ini"
|
config_file = CONFIG_DIR / "URL_config.ini"
|
||||||
if not config_file.exists():
|
if not config_file.exists():
|
||||||
@@ -247,8 +148,8 @@ async def get_url_config(process: str = Query(..., description="进程名")):
|
|||||||
|
|
||||||
@app.post("/save_url_config")
|
@app.post("/save_url_config")
|
||||||
async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
|
async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
|
||||||
if process not in URL_CONFIG_PM2:
|
if process not in ["youtube", "tiktok"]:
|
||||||
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
|
return JSONResponse({"error": "仅 youtube 和 tiktok 支持此配置编辑"}, status_code=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -272,42 +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(web* 控制台不杀 ffmpeg)
|
|
||||||
if not process.startswith("web"):
|
|
||||||
await force_kill_ffmpeg(process)
|
|
||||||
|
|
||||||
# 第四步:再等一小会儿,确认
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
return JSONResponse({
|
|
||||||
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 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}")
|
||||||
if not process.startswith("web"):
|
|
||||||
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:
|
||||||
@@ -317,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"],
|
||||||
@@ -335,19 +211,7 @@ async def status(process: str = Query(..., description="进程名")):
|
|||||||
"recent_error": recent_error,
|
"recent_error": recent_error,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
|
|
||||||
@app.get("/process_monitor")
|
|
||||||
async def process_monitor():
|
|
||||||
return {
|
|
||||||
"entries": [
|
|
||||||
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
|
|
||||||
for p in PROCESS_MONITOR
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 首页 ----------------
|
# ---------------- 首页 ----------------
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(BASE_DIR / "index.html")
|
return FileResponse(BASE_DIR / "index.html")
|
||||||
2
web2.sh
2
web2.sh
@@ -1,2 +0,0 @@
|
|||||||
# Ubuntu:本分支控制台入口(与无「2」分支的 web / youtube.py 等区分)
|
|
||||||
uvicorn web2:app --host 0.0.0.0 --port 8001 --reload
|
|
||||||
Reference in New Issue
Block a user