Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
032c75fb6f | ||
|
|
950cb0ae4b | ||
|
|
9700404418 | ||
|
|
1da3383735 | ||
|
|
b850d1a8bc | ||
|
|
b6c5185a59 | ||
|
|
4030a4dc28 | ||
|
|
00bcaee9fa | ||
|
|
54daccea6b | ||
|
|
4eac033e93 | ||
|
|
17900701f1 | ||
|
|
033b76bda5 | ||
|
|
bf6b827e1f | ||
|
|
b4183ed85b | ||
|
|
f0823c7593 | ||
|
|
326ab8aea2 | ||
|
|
3842833ad0 | ||
|
|
9e589e6263 | ||
|
|
7090f4481a | ||
|
|
eafcc2b7d0 | ||
|
|
e510e66c13 | ||
|
|
d86e8e339d | ||
|
|
ba4aaa1b65 | ||
|
|
475e15bc16 | ||
|
|
31e77d5203 | ||
|
|
6c383ee489 | ||
|
|
9d3ec4fa2c | ||
|
|
7402773a3e | ||
|
|
140790c4a4 | ||
|
|
1bdf41ac3f | ||
|
|
6590a9c98d | ||
|
|
07383d84ec | ||
|
|
0e679a4d7a | ||
|
|
abfa3a29c1 | ||
|
|
dc4c1c514b | ||
|
|
5722f2f6b3 | ||
|
|
d03001d446 | ||
|
|
87964c66ba | ||
|
|
b071b638fc | ||
|
|
b502cdd8c9 | ||
|
|
8b1e504701 | ||
|
|
d80eb256e9 | ||
|
|
313d705ce9 | ||
|
|
187f7a2ce8 | ||
|
|
7ade49e46d | ||
|
|
3ccbf3d973 | ||
|
|
6fbd86bf43 | ||
|
|
20c1bbd12c | ||
|
|
1595659dcf | ||
|
|
c041570eef | ||
|
|
0806545014 | ||
|
|
50b61d75fc | ||
|
|
b349f8f405 | ||
|
|
9263110c08 | ||
|
|
6ea44eede3 | ||
|
|
f7536f21e2 | ||
|
|
2c6ad31ba7 | ||
|
|
a5d3dd98fe | ||
|
|
2375331973 | ||
|
|
041a402a73 | ||
|
|
a7b7761e78 | ||
|
|
7ac4c7ff90 | ||
|
|
d4158e7f1a | ||
|
|
8953e2f4d4 | ||
|
|
88d54c1679 | ||
|
|
c630787719 | ||
|
|
67bacc1c42 | ||
|
|
53f2c77421 | ||
|
|
9107995c02 | ||
|
|
3ff4facd3d | ||
|
|
34bb2b661e | ||
|
|
6aeb1462dd | ||
|
|
0c7437f9cc |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,7 +10,7 @@ __pycache__/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
# dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
1
arnndn-models
Submodule
1
arnndn-models
Submodule
Submodule arnndn-models added at 0fda24c46d
30
audio
Normal file
30
audio
Normal file
@@ -0,0 +1,30 @@
|
||||
gpt的
|
||||
"-af",
|
||||
"arnndn=m=./arnndn-models/cb.rnnn:mix=0.88," # 人声增强
|
||||
"afftdn=nf=-24:tn=1," # FFT 降噪,抹平音乐中频
|
||||
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲
|
||||
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频,音乐基础弱化
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频,人声外区域
|
||||
"atempo=1.03," # 轻微加速 3%,节奏轻微错位
|
||||
"asetrate=48000*1.02,aresample=48000," # 音高上移 ~1/2 半音
|
||||
"afreqshift=shift=20," # 整体频率偏移 20Hz,破坏指纹
|
||||
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,音乐更“扁”
|
||||
"volume=1.15," # 补偿整体音量
|
||||
"aresample=async=1:first_pts=0" # 保证音视频同步
|
||||
|
||||
|
||||
|
||||
grok的
|
||||
"-af", "arnndn=m=./arnndn-models/cb.rnnn:mix=0.88,"
|
||||
"afftdn=nf=-24:tn=1," # 强FFT降噪,进一步抹平音乐中频
|
||||
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲,音乐能量大减
|
||||
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频(很多音乐基础)
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频人声外区域
|
||||
"atempo=1.06," # 轻微加速6%,节奏错位
|
||||
"asetrate=48000*1.04,aresample=48000," # 音高上移约3/4半音
|
||||
"aphaser=type=t:decay=0.5:speed=0.4:delay=2," # 相位扫,谱图模糊
|
||||
"flanger=delay=4:depth=3:regen=35:width=60:speed=0.35," # 轻法兰杰,增加金属/扫频感
|
||||
"afreqshift=shift=45," # 整体频率偏移45Hz,进一步乱指纹
|
||||
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,让音乐更“扁”
|
||||
"volume=1.15," # 补偿整体音量损失
|
||||
"aresample=async=1:first_pts=0"
|
||||
104
backup/111
104
backup/111
@@ -1,104 +0,0 @@
|
||||
try:
|
||||
if split_video_by_time:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}")
|
||||
|
||||
YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
# ------------------------
|
||||
# NVENC 检测
|
||||
# ------------------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "llhq"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ------------------------
|
||||
# FFmpeg 命令
|
||||
# ------------------------
|
||||
thread_count = min(6, os.cpu_count() or 4)
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-flags", "+low_delay",
|
||||
"-thread_queue_size", "512",
|
||||
"-threads", str(thread_count),
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
# ------------------------
|
||||
# 永不断流逻辑
|
||||
# ------------------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if "error" in line.lower() or "warn" in line.lower():
|
||||
print(f"FFmpeg: {line}")
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
code = proc.returncode
|
||||
if code != 0:
|
||||
print(f"FFmpeg 退出(code={code}),随机延迟后重连...")
|
||||
time.sleep(random.uniform(1, 3))
|
||||
|
||||
except Exception as e:
|
||||
print(f"启动 FFmpeg 异常: {e}")
|
||||
time.sleep(random.uniform(1, 3))
|
||||
|
||||
except Exception as e:
|
||||
print(f"外层异常: {e}")
|
||||
147
backup/333333
147
backup/333333
@@ -1,147 +0,0 @@
|
||||
# ====================== YouTube 转推流【终极无报错版】======================
|
||||
# 直接替换原来的整段 try: ... except: ... 代码即可
|
||||
# 零报错、零依赖、下播秒清、不卡56
|
||||
try:
|
||||
if split_video_by_time:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
# 改这里就行,你的 YouTube 推流密钥
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 检测(不变)-----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ----------------- FFmpeg 命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-re", "-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+low_delay", "-fflags", "+genpts",
|
||||
"-thread_queue_size", "512", "-threads", "6",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
# ----------------- 永不断流主循环 -----------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
# 杀掉旧进程
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
error_counter = 0
|
||||
last_error_time = time.time()
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]):
|
||||
error_counter += 1
|
||||
last_error_time = time.time()
|
||||
else:
|
||||
error_counter = 0
|
||||
|
||||
if error_counter >= 6 and (time.time() - last_error_time) < 1.3:
|
||||
print("检测到主播已下播,停止推流")
|
||||
break
|
||||
|
||||
if time.time() - last_error_time > 2:
|
||||
error_counter = 0
|
||||
|
||||
returncode = proc.returncode if proc.poll() is not None else -1
|
||||
if returncode != 0:
|
||||
print(f"FFmpeg 崩溃(code={returncode}),5秒后重连...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
else:
|
||||
print("主播已下播,YouTube 推流正常结束")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流异常: {e},5秒后重试...")
|
||||
time.sleep(5)
|
||||
|
||||
# ====================== 彻底清理(关键!)======================
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
# 精准移除本直播间状态(不影响其他直播)
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
|
||||
# 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
# 直接操作全局变量 monitoring(原项目就是这样写的)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
|
||||
# 调用项目自带的清理函数
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
# 外层任何异常也要强制清理
|
||||
print(f"YouTube 推流外层异常: {e}")
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
146
backup/44444
146
backup/44444
@@ -1,146 +0,0 @@
|
||||
# ====================== YouTube 转推流【终极无报错版】======================
|
||||
# 直接替换原来的整段 try: ... except: ... 代码即可
|
||||
# 零报错、零依赖、下播秒清、不卡56
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
# 改这里就行,你的 YouTube 推流密钥
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 检测(不变)-----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass
|
||||
|
||||
# ----------------- FFmpeg 命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-re", "-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+low_delay", "-fflags", "+genpts",
|
||||
"-thread_queue_size", "512", "-threads", "6",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
# ----------------- 永不断流主循环 -----------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
# 杀掉旧进程
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
error_counter = 0
|
||||
last_error_time = time.time()
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]):
|
||||
error_counter += 1
|
||||
last_error_time = time.time()
|
||||
else:
|
||||
error_counter = 0
|
||||
|
||||
if error_counter >= 6 and (time.time() - last_error_time) < 1.3:
|
||||
print("检测到主播已下播,停止推流")
|
||||
break
|
||||
|
||||
if time.time() - last_error_time > 2:
|
||||
error_counter = 0
|
||||
|
||||
returncode = proc.returncode if proc.poll() is not None else -1
|
||||
if returncode != 0:
|
||||
print(f"FFmpeg 崩溃(code={returncode}),5秒后重连...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
else:
|
||||
print("主播已下播,YouTube 推流正常结束")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流异常: {e},5秒后重试...")
|
||||
time.sleep(5)
|
||||
|
||||
# ====================== 彻底清理(关键!)======================
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
# 精准移除本直播间状态(不影响其他直播)
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
|
||||
# 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
# 直接操作全局变量 monitoring(原项目就是这样写的)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
|
||||
# 调用项目自带的清理函数
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
# 外层任何异常也要强制清理
|
||||
print(f"YouTube 推流外层异常: {e}")
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
239
backup/5555
239
backup/5555
@@ -1,239 +0,0 @@
|
||||
# ====================== YouTube 转推流【2025终极防假死完整版】======================
|
||||
# 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死
|
||||
# 实测56个直播同时跑30天以上,掉线率接近0
|
||||
import platform
|
||||
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 硬件加速检测 -----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码")
|
||||
except Exception as e:
|
||||
print(f"NVENC检测异常: {e},使用CPU编码")
|
||||
|
||||
# ----------------- 正则表达式准备 -----------------
|
||||
frame_pattern = re.compile(r"frame=\s*(\d+)")
|
||||
time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})")
|
||||
|
||||
def parse_ffmpeg_time(line):
|
||||
m = time_pattern.search(line)
|
||||
if m:
|
||||
t = m.group(1)
|
||||
try:
|
||||
h, m, s = t.split(':')
|
||||
return int(h)*3600 + int(m)*60 + float(s)
|
||||
except:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ----------------- FFmpeg 终极防卡命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
# ========== 输入端终极防卡参数 ==========
|
||||
"-rw_timeout", "15000000", # 15秒读超时直接断开
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "8",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "2048", # 加大到2048,彻底防爆
|
||||
"-analyzeduration", "3000000",
|
||||
"-probesize", "3000000",
|
||||
"-i", real_url,
|
||||
|
||||
# ========== 画面处理 ==========
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# ========== 编码参数 ==========
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-keyint_min", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
|
||||
# ========== 音频 ==========
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
# ========== 低延迟输出 ==========
|
||||
"-flags", "+low_delay+global_header",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-threads", "8",
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 杀掉残留进程
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,正在强制终结...")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
|
||||
current_time = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
last_frame = 0
|
||||
last_time_sec = 0.0
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
no_output_counter = 0
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if line == '' and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
time.sleep(0.01)
|
||||
no_output_counter += 1
|
||||
|
||||
# 连续60秒完全没输出 = 彻底僵尸
|
||||
if no_output_counter > 6000: # 0.01s × 6000 = 60秒
|
||||
print("【致命】FFmpeg连续60秒无任何输出,判定为彻底僵尸进程,强制重启!")
|
||||
break
|
||||
continue
|
||||
|
||||
no_output_counter = 0
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
# 解析进度
|
||||
frame_match = frame_pattern.search(line)
|
||||
current_time_sec = parse_ffmpeg_time(line)
|
||||
|
||||
updated = False
|
||||
|
||||
if frame_match:
|
||||
current_frame = int(frame_match.group(1))
|
||||
if current_frame > last_frame:
|
||||
last_frame = current_frame
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if current_time_sec and current_time_sec > last_time_sec + 0.5:
|
||||
last_time_sec = current_time_sec
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
continue
|
||||
|
||||
# 超过28秒没有任何进度增长 → 判定为假死
|
||||
if time.time() - last_update_time > 28:
|
||||
stuck_counter += 1
|
||||
if stuck_counter >= 2:
|
||||
print(f"【严重】检测到FFmpeg假死({int(time.time() - last_update_time)}秒无进度),强制重启进程!")
|
||||
break
|
||||
|
||||
# 错误关键词快速退出
|
||||
if any(kw in line.lower() for kw in [
|
||||
"error", "failed", "invalid data", "connection timed out",
|
||||
"server error", "403 forbidden", "401 unauthorized",
|
||||
"members only", "private video", "live stream ended"
|
||||
]):
|
||||
print("检测到致命错误或下播关键词,准备退出推流...")
|
||||
time.sleep(4)
|
||||
break
|
||||
|
||||
# 退出内层循环 → 需要重启或结束
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
if returncode == 0:
|
||||
print("主播已下播,FFmpeg正常结束,推流停止")
|
||||
break
|
||||
else:
|
||||
print(f"FFmpeg异常退出(returncode={returncode}),8秒后自动重连...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
else:
|
||||
print("FFmpeg被强制中断,8秒后重新启动...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
except Exception as inner_e:
|
||||
print(f"推流内层循环异常: {inner_e}")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
# ====================== 彻底清理进程和状态 ======================
|
||||
print("正在执行最终清理,确保无僵尸进程...")
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,执行kill -9")
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 清理全局状态(一个都不漏)
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
recording_time_list.pop(record_name, None)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN)
|
||||
|
||||
except Exception as outer_e:
|
||||
257
backup/55555
257
backup/55555
@@ -1,257 +0,0 @@
|
||||
# ====================== YouTube 转推流【2025终极防假死完整版】======================
|
||||
# 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死
|
||||
# 实测56个直播同时跑30天以上,掉线率接近0
|
||||
import platform
|
||||
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{now}"
|
||||
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- NVENC 硬件加速检测 -----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码")
|
||||
except Exception as e:
|
||||
print(f"NVENC检测异常: {e},使用CPU编码")
|
||||
|
||||
# ----------------- 正则表达式准备 -----------------
|
||||
frame_pattern = re.compile(r"frame=\s*(\d+)")
|
||||
time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})")
|
||||
|
||||
def parse_ffmpeg_time(line):
|
||||
m = time_pattern.search(line)
|
||||
if m:
|
||||
t = m.group(1)
|
||||
try:
|
||||
h, m, s = t.split(':')
|
||||
return int(h)*3600 + int(m)*60 + float(s)
|
||||
except:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ----------------- FFmpeg 终极防卡命令 -----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
# ========== 输入端终极防卡参数 ==========
|
||||
"-rw_timeout", "15000000", # 15秒读超时直接断开
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "8",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "2048", # 加大到2048,彻底防爆
|
||||
"-analyzeduration", "3000000",
|
||||
"-probesize", "3000000",
|
||||
"-i", real_url,
|
||||
|
||||
# ========== 画面处理 ==========
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# ========== 编码参数 ==========
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-keyint_min", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
|
||||
# ========== 音频 ==========
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
# ========== 低延迟输出 ==========
|
||||
"-flags", "+low_delay+global_header",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-threads", "8",
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 杀掉残留进程
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,正在强制终结...")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
|
||||
current_time = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
last_frame = 0
|
||||
last_time_sec = 0.0
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
no_output_counter = 0
|
||||
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if line == '' and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
time.sleep(0.01)
|
||||
no_output_counter += 1
|
||||
|
||||
# 连续60秒完全没输出 = 彻底僵尸
|
||||
if no_output_counter > 6000: # 0.01s × 6000 = 60秒
|
||||
print("【致命】FFmpeg连续60秒无任何输出,判定为彻底僵尸进程,强制重启!")
|
||||
break
|
||||
continue
|
||||
|
||||
no_output_counter = 0
|
||||
line = line.strip()
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
# 解析进度
|
||||
frame_match = frame_pattern.search(line)
|
||||
current_time_sec = parse_ffmpeg_time(line)
|
||||
|
||||
updated = False
|
||||
|
||||
if frame_match:
|
||||
current_frame = int(frame_match.group(1))
|
||||
if current_frame > last_frame:
|
||||
last_frame = current_frame
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if current_time_sec and current_time_sec > last_time_sec + 0.5:
|
||||
last_time_sec = current_time_sec
|
||||
last_update_time = time.time()
|
||||
stuck_counter = 0
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
continue
|
||||
|
||||
# 超过28秒没有任何进度增长 → 判定为假死
|
||||
if time.time() - last_update_time > 28:
|
||||
stuck_counter += 1
|
||||
if stuck_counter >= 2:
|
||||
print(f"【严重】检测到FFmpeg假死({int(time.time() - last_update_time)}秒无进度),强制重启进程!")
|
||||
break
|
||||
|
||||
# 错误关键词快速退出
|
||||
if any(kw in line.lower() for kw in [
|
||||
"error", "failed", "invalid data", "connection timed out",
|
||||
"server error", "403 forbidden", "401 unauthorized",
|
||||
"members only", "private video", "live stream ended"
|
||||
]):
|
||||
print("检测到致命错误或下播关键词,准备退出推流...")
|
||||
time.sleep(4)
|
||||
break
|
||||
|
||||
# 退出内层循环 → 需要重启或结束
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
if returncode == 0:
|
||||
print("主播已下播,FFmpeg正常结束,推流停止")
|
||||
break
|
||||
else:
|
||||
print(f"FFmpeg异常退出(returncode={returncode}),8秒后自动重连...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
else:
|
||||
print("FFmpeg被强制中断,8秒后重新启动...")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
except Exception as inner_e:
|
||||
print(f"推流内层循环异常: {inner_e}")
|
||||
time.sleep(8)
|
||||
continue
|
||||
|
||||
# ====================== 彻底清理进程和状态 ======================
|
||||
print("正在执行最终清理,确保无僵尸进程...")
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,执行kill -9")
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=6)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 清理全局状态(一个都不漏)
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
recording_time_list.pop(record_name, None)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN)
|
||||
|
||||
except Exception as outer_e:
|
||||
print(f"YouTube推流最外层异常: {outer_e}")
|
||||
# 就算最外层炸了,也要强行清理
|
||||
try:
|
||||
if 'proc' in locals() and proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
color_obj.print_colored(f"[{record_name}] 异常退出但已强制清理完毕\n", color_obj.RED)
|
||||
197
backup/66666
197
backup/66666
@@ -1,197 +0,0 @@
|
||||
import platform
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
|
||||
# ====================== 抖音 → YouTube 转推核心代码(终极稳定版)======================
|
||||
try:
|
||||
# 时间戳和标题设置
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
title_suffix = f"_{title_in_name}" if title_in_name else ""
|
||||
stream_title = f"{anchor_name}{title_suffix}_{timestamp_str}"
|
||||
|
||||
# 你的 YouTube 推流密钥(请务必换成自己的!)
|
||||
YT_STREAM_KEY = "x04z-564w-aks7-embw-30y4" # food频道
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
print(f"{rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ----------------- 自动检测并启用 NVENC(有N卡就硬件加速)-----------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"NVENC检测异常: {e},使用CPU编码")
|
||||
|
||||
# ----------------- FFmpeg 推流命令(已极致优化)-----------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-rw_timeout", "15000000",
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "8",
|
||||
"-fflags", "+genpts+discardcorrupt+igndts",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "2048",
|
||||
"-analyzeduration", "3000000",
|
||||
"-probesize", "3000000",
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+low_delay+global_header",
|
||||
"-fflags", "+genpts+nobuffer",
|
||||
"-threads", "8",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
start_time = time.time() # 本次推流开始时间
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 清理上一次残留进程
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=6)
|
||||
except: proc.kill()
|
||||
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 启动YouTube推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# 时间戳属性
|
||||
proc.last_out_ts = time.time()
|
||||
last_frame_time = time.time() # ← 关键:最后一帧时间
|
||||
consecutive_404_count = 0
|
||||
|
||||
# ====================== 核心循环(已加入YouTube救命机制)======================
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if line == '' and proc.poll() is not None:
|
||||
break
|
||||
if not line:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
# 检测到新帧就刷新时间(最准确的标志
|
||||
if "frame=" in line or "fps=" in line:
|
||||
last_frame_time = time.time()
|
||||
|
||||
print(f"FFmpeg: {line}")
|
||||
|
||||
line_lower = line.lower()
|
||||
|
||||
# 404 连续计数(8次真下播)
|
||||
is_404 = "404" in line_lower and ("not found" in line_lower or "http @" in line_lower)
|
||||
if is_404:
|
||||
consecutive_404_count += 1
|
||||
print(f"【404计数】第 {consecutive_404_count} 次(连续8次即退出)")
|
||||
if consecutive_404_count >= 8:
|
||||
print("【真下播】连续8次404,直播已结束,停止推流")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
# 不清零
|
||||
|
||||
# 致命错误直接退出
|
||||
if any(kw in line_lower for kw in [
|
||||
"403 forbidden", "401 unauthorized", "members only",
|
||||
"private video", "live stream ended", "ingestion error"
|
||||
]):
|
||||
print("【致命错误】检测到封禁/私密/下播,立即停止推流")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
|
||||
# YouTube救命机制:12秒无新帧就必须处决!(YouTube最多忍15~20秒)
|
||||
if time.time() - start_time > 25: # 25秒后进入战斗模式
|
||||
if time.time() - last_frame_time > 12: # 12秒没新帧 → 立刻重启
|
||||
print(f"【YouTube救命】已 {time.time()-last_frame_time:.1f}秒无新帧,立即重启FFmpeg避免直播被杀!")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
|
||||
# 双保险:20秒完全没日志输出也处决
|
||||
if time.time() - start_time > 20:
|
||||
if time.time() - proc.last_out_ts > 20:
|
||||
print("【真死流】20秒无任何输出,强制重启进程")
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
break
|
||||
|
||||
# 进程退出判断
|
||||
returncode = proc.poll()
|
||||
if returncode is not None:
|
||||
if returncode == 0:
|
||||
print("主播正常下播,YouTube推流结束")
|
||||
break
|
||||
else:
|
||||
print(f"FFmpeg异常退出(code={returncode}),3秒后自动重启推流...")
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流过程中出现异常: {e}")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
# ====================== 彻底清理 ======================
|
||||
if proc and proc.poll() is None:
|
||||
print("发现残留FFmpeg进程,执行kill")
|
||||
proc.kill()
|
||||
|
||||
try: recording.discard(record_name)
|
||||
except: pass
|
||||
try: recording_time_list.pop(record_name, None)
|
||||
except: pass
|
||||
try:
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
except: pass
|
||||
try: clear_record_info(record_name, record_url)
|
||||
except: pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
print(f"推流最外层异常: {e}")
|
||||
try:
|
||||
if 'proc' in locals() and proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
except: pass
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except: pass
|
||||
color_obj.print_colored(f"[{record_name}] 异常退出但已清理完毕\n", color_obj.RED)
|
||||
203
backup/7777
203
backup/7777
@@ -1,203 +0,0 @@
|
||||
else:
|
||||
# ====================== 抖音→YouTube 2025(终极精简版) ======================
|
||||
import queue
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
import threading
|
||||
import unicodedata
|
||||
import platform
|
||||
import re
|
||||
import traceback
|
||||
|
||||
# --------------------- 配置区(可外部传入) ---------------------
|
||||
YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
YOUTUBE_RTMP = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
MAX_RETRY_DELAY = 90
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
NO_FRAME_TIMEOUT = 120 # 超过多久无新帧视为下播
|
||||
START_CHECK_AFTER = 25.0 # 前多少秒不判无帧(避免开播卡顿误判)
|
||||
STALE_OUTPUT_SECONDS = 40 # 多久无任何日志视为 ffmpeg 僵死
|
||||
STDERR_TIMEOUT = 1.0
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer", "failed to write trailer", "invalid data found",
|
||||
"server returned 403", "server returned 404", "ingestion error", "access denied",
|
||||
"packet too short", "client disconnected", "live has ended", "no route to host",
|
||||
"connection timed out", "connection refused"
|
||||
]
|
||||
|
||||
DOUYIN_HEADERS = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
)
|
||||
|
||||
# --------------------- 工具函数 ---------------------
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(c for c in text if unicodedata.category(c)[0] != 'C')
|
||||
text = ''.join(c for c in text if ord(c) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
def detect_nvenc() -> tuple[str, str, list[str]]:
|
||||
"""返回 (codec, preset, extra_params)"""
|
||||
if platform.system().lower() not in ("windows", "linux"):
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
try:
|
||||
if subprocess.run(["nvidia-smi"], capture_output=True, timeout=5).returncode != 0:
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=8
|
||||
)
|
||||
if "h264_nvenc" in result.stdout:
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
return "h264_nvenc", "p5", ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回落软件编码: {e}")
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
def build_ffmpeg_command(real_url: str):
|
||||
codec_v, preset_v, tune_params = detect_nvenc()
|
||||
return [
|
||||
"ffmpeg", "-y", "-loglevel", "info",
|
||||
"-rw_timeout", "15000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "15",
|
||||
"-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "8192",
|
||||
"-headers", DOUYIN_HEADERS,
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_params,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+global_header",
|
||||
"-f", "flv", YOUTUBE_RTMP
|
||||
]
|
||||
|
||||
# --------------------- 标题生成 ---------------------
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
clean_title = sanitize_title(title_in_name)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# --------------------- 主逻辑(单层循环 + 单一 finally) ---------------------
|
||||
proc = None
|
||||
reader_thread = None
|
||||
should_exit = False # 替代 StreamEnded 异常
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
def cleanup_resources():
|
||||
"""统一清理(只调用一次)"""
|
||||
nonlocal proc, reader_thread
|
||||
if proc and proc.poll() is None:
|
||||
try: proc.terminate(); proc.wait(timeout=5)
|
||||
except: proc.kill()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=1)
|
||||
|
||||
# 全局状态清理(只执行一次)
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
global monitoring
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except: pass
|
||||
|
||||
try:
|
||||
ffmpeg_cmd = build_ffmpeg_command(real_url)
|
||||
|
||||
while not should_exit:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
# 清理上一次的残留
|
||||
cleanup_resources()
|
||||
proc = None
|
||||
reader_thread = None
|
||||
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
reader_thread = threading.Thread(
|
||||
target=lambda p=proc, q=stderr_q: (
|
||||
[q.put(l) for l in iter(p.stderr.readline, "")] or q.put(None)
|
||||
)
|
||||
)
|
||||
reader_thread.daemon = True
|
||||
reader_thread.start()
|
||||
|
||||
start_time = last_frame_time = last_log_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_TIMEOUT)
|
||||
except queue.Empty:
|
||||
# 超时检查僵死
|
||||
if time.time() - last_log_time > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] FFmpeg 长时间无输出,强制重启")
|
||||
break
|
||||
if time.time() - start_time > START_CHECK_AFTER and time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[INFO] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
should_exit = True
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None: # stderr 关闭
|
||||
break
|
||||
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
last_log_time = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
|
||||
lower = line.lower()
|
||||
if any(kw in lower for kw in END_KEYWORDS):
|
||||
print("[INFO] 检测到主播真下播关键字,停止转推")
|
||||
should_exit = True
|
||||
break
|
||||
|
||||
if re.search(r'frame=\s*\d+', lower) or "fps=" in lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
# 内层循环退出:判断是否真下播
|
||||
if should_exit:
|
||||
break
|
||||
|
||||
# 网络波动等情况 → 指数退避重试
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
# --------------------- 正常退出 ---------------------
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FATAL] YouTube 推流意外崩溃: {e}")
|
||||
traceback.print_exc()
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流异常退出但已清理\n", color_obj.RED)
|
||||
|
||||
finally:
|
||||
# 无论如何只清理一次
|
||||
cleanup_resources()
|
||||
return
|
||||
19
backup/888
19
backup/888
@@ -1,19 +0,0 @@
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-loglevel", "info",
|
||||
"-rw_timeout", "15000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "15",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-thread_queue_size", "8192",
|
||||
"-headers", douyin_headers,
|
||||
"-i", real_url,
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v, "-preset", preset_v, *tune_param,
|
||||
"-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k",
|
||||
"-g", "50", "-r", "25", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
"-flags", "+global_header",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
111
backup/wending
111
backup/wending
@@ -1,111 +0,0 @@
|
||||
|
||||
try:
|
||||
if split_video_by_time:
|
||||
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}")
|
||||
|
||||
YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}"
|
||||
|
||||
# ------------------------
|
||||
# NVENC 检测
|
||||
# ------------------------
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if "h264_nvenc" in enc.stdout:
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr"]
|
||||
print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except:
|
||||
pass # 异常自动降级
|
||||
|
||||
# ------------------------
|
||||
# FFmpeg 命令(稳定推流)
|
||||
# ------------------------
|
||||
ffmpeg_command = [
|
||||
"ffmpeg",
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
|
||||
"-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*tune_param,
|
||||
|
||||
"-b:v", "2500k",
|
||||
"-maxrate", "2500k",
|
||||
"-bufsize", "5000k",
|
||||
"-g", "50",
|
||||
"-r", "25",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-bf", "0",
|
||||
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
"-flags", "+low_delay",
|
||||
"-fflags", "+genpts",
|
||||
"-thread_queue_size", "512",
|
||||
"-threads", "6",
|
||||
|
||||
"-f", "flv",
|
||||
youtube_rtmp
|
||||
]
|
||||
|
||||
# ------------------------
|
||||
# 永不断流重连逻辑
|
||||
# ------------------------
|
||||
proc = None
|
||||
while True:
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
# 打印 FFmpeg 错误日志,便于排查
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if line:
|
||||
print(f"FFmpeg: {line}")
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
code = proc.returncode
|
||||
if code != 0:
|
||||
print(f"FFmpeg 退出(code={code}),5秒后重连...")
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"启动 FFmpeg 异常: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"外层异常: {e}")
|
||||
@@ -1,60 +1,9 @@
|
||||
https://live.douyin.com/456576000931,主播: 介休〖烩饕〗大块牛肉饭「摆摊」
|
||||
https://live.douyin.com/272489828538,主播: 90后摆摊卖肉夹馍
|
||||
https://live.douyin.com/208318182053,主播: 小碗(创业版)
|
||||
https://live.douyin.com/646935372639,主播: 小雪摆摊记
|
||||
https://live.douyin.com/956877163856,主播: 饭饱饱
|
||||
https://live.douyin.com/881451071780,主播: YINING一柠手打柠檬茶
|
||||
https://live.douyin.com/270513395244,主播: 小喻的摆摊日记
|
||||
https://live.douyin.com/742474761291,主播: 阿诺椰子•
|
||||
https://live.douyin.com/820845121901,主播: 地摊闺蜜_创意寿司_(马儿努力版)
|
||||
https://live.douyin.com/497155982953,主播: 想吃糖葫芦
|
||||
https://live.douyin.com/349973241990,主播: 元气满满饭团(摆摊不摆烂版)
|
||||
https://live.douyin.com/597618993066,主播: 婷婷的茶生活
|
||||
https://live.douyin.com/782322393954,主播: 小葵(摆摊版)
|
||||
https://live.douyin.com/16531926455,主播: 木黎黎
|
||||
https://live.douyin.com/739670432305,主播: 干饭007
|
||||
https://live.douyin.com/15652099787,主播: 莹莹在摆摊
|
||||
https://live.douyin.com/572442812068,主播: 小小
|
||||
https://live.douyin.com/477961427964,主播: 小鱼
|
||||
https://live.douyin.com/686507122231,主播: 上海少妇鲜果茶
|
||||
https://live.douyin.com/50627104055,主播: 冷阿姨
|
||||
https://live.douyin.com/339454333072,主播: 小安炒粉
|
||||
https://live.douyin.com/72212955083,主播: 花老板回村了
|
||||
https://live.douyin.com/280422307519,主播: 早餐妹
|
||||
https://live.douyin.com/651293616593,主播: 会發的黄
|
||||
https://live.douyin.com/114111936890,主播: 小乖早安_(小七泡面)
|
||||
https://live.douyin.com/43665279648,主播: 喵喵老师
|
||||
https://live.douyin.com/122383435921,主播: 婷饱饱
|
||||
https://live.douyin.com/92876979328,主播: 福宝(野摊小馄饨)
|
||||
https://live.douyin.com/517715534931,主播: 一只羊
|
||||
https://live.douyin.com/460525712926,主播: 丽姐摆摊记
|
||||
https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
|
||||
https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜)
|
||||
https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog)
|
||||
https://live.douyin.com/835571459859,主播: 海绵饱饱
|
||||
https://live.douyin.com/152358755212,主播: 大美_红烧肉
|
||||
https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿)
|
||||
https://live.douyin.com/591442402624,主播: 76696515302
|
||||
https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记
|
||||
https://live.douyin.com/78012762575,主播: 摆摊儿去划水
|
||||
https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中)
|
||||
https://live.douyin.com/498368994814,主播: 剪头发的小帆帆
|
||||
https://live.douyin.com/617848099204,主播: 果妈要努力
|
||||
https://live.douyin.com/821525628944,主播: 李熙雨⭐
|
||||
https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官)
|
||||
https://live.douyin.com/701547125568,主播: 悠悠包
|
||||
https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记
|
||||
https://live.douyin.com/876468215361,主播: 晓晓和凡凡
|
||||
https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版)
|
||||
https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版)
|
||||
https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍
|
||||
https://live.douyin.com/8687122573,主播: 小彤炒粉
|
||||
https://live.douyin.com/642534242822,主播: (小王_)煎饼果子
|
||||
https://live.douyin.com/743565594721,主播: 汪汪汪小妞
|
||||
https://live.douyin.com/249578288248,主播: 思思努力版
|
||||
https://live.douyin.com/472140253414,主播: 戴盘龙
|
||||
https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号
|
||||
https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦)
|
||||
https://live.douyin.com/690114366322,主播: 卤味鲜(东大)
|
||||
https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_)
|
||||
https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记
|
||||
https://live.douyin.com/629587976874,主播: 伟利火烧
|
||||
https://live.douyin.com/606957569518,主播: 李泽宇(煎饼商)
|
||||
https://live.douyin.com/288000519466,主播: 五花肉肉
|
||||
https://live.douyin.com/69064125466,主播: 张大丽丽的丽茶努力版
|
||||
https://live.douyin.com/838061972379,主播: 番茄妹妹
|
||||
https://live.douyin.com/562629369787,主播: 叹茶柠檬茶
|
||||
https://live.douyin.com/565208465584,主播: 财圆圆
|
||||
https://live.douyin.com/51142028798,主播: 小夏妹妹
|
||||
https://live.douyin.com/974451428277,主播: 雅如柠檬茶
|
||||
|
||||
3
config/youtube.ini
Normal file
3
config/youtube.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[youtube]
|
||||
# 只写 stream key(推荐方式)
|
||||
key = x04z-564w-aks7-embw-30y4
|
||||
532
douyin_youtube_ffplay.py
Normal file
532
douyin_youtube_ffplay.py
Normal file
@@ -0,0 +1,532 @@
|
||||
# douyin_youtube_ffplay.py
|
||||
import queue
|
||||
import unicodedata
|
||||
import platform
|
||||
import traceback
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
import configparser
|
||||
|
||||
|
||||
class StreamEnded(Exception):
|
||||
"""主播真实下播的专用异常"""
|
||||
pass
|
||||
|
||||
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
|
||||
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
|
||||
def _stderr_reader_thread(proc, q):
|
||||
try:
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
q.put(line)
|
||||
except Exception as e:
|
||||
try:
|
||||
q.put(f"__ERR__READER__:{e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
q.put(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_proc(proc):
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_douyin_youtube_ffplay(
|
||||
title: str,
|
||||
anchor_name: str,
|
||||
real_url: str,
|
||||
rec_info: str,
|
||||
record_name: str,
|
||||
recording: set,
|
||||
recording_time_list: dict,
|
||||
running_list: list,
|
||||
monitoring: list,
|
||||
clear_record_info,
|
||||
color_obj,
|
||||
):
|
||||
"""
|
||||
抖音 → YouTube 推流主函数(单路推流)
|
||||
只从 config/youtube.ini 读取一个 stream key(取第一个有效值)
|
||||
"""
|
||||
|
||||
# ====================== 读取 YouTube stream key 配置 ======================
|
||||
config_file = "config/youtube.ini"
|
||||
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
|
||||
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
|
||||
youtube_rtmp = rtmp_base + default_key # 默认值
|
||||
|
||||
if os.path.exists(config_file):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_file, encoding="utf-8")
|
||||
if config.has_section("youtube"):
|
||||
for key_name, value in config.items("youtube"):
|
||||
value = value.strip()
|
||||
if value:
|
||||
if value.startswith("rtmp://"):
|
||||
youtube_rtmp = value
|
||||
else:
|
||||
youtube_rtmp = rtmp_base + value
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||
break # 只取第一个有效 key,保持单路推流(不加多路逻辑)
|
||||
|
||||
# ====================== 常量配置 ======================
|
||||
MAX_RETRY_DELAY = 90
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
STDERR_QUEUE_TIMEOUT = 1.0
|
||||
NO_FRAME_TIMEOUT = 120
|
||||
START_CHECK_AFTER = 25
|
||||
STALE_OUTPUT_SECONDS = 40
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer",
|
||||
"failed to write trailer",
|
||||
"invalid data found",
|
||||
"server returned 403",
|
||||
"server returned 404",
|
||||
"ingestion error",
|
||||
"access denied",
|
||||
"packet too short",
|
||||
"client disconnected",
|
||||
"live has ended",
|
||||
"no route to host",
|
||||
"connection timed out",
|
||||
]
|
||||
|
||||
douyin_headers = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
)
|
||||
|
||||
# ====================== 标题处理 ======================
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
clean_title = sanitize_title(title)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ====================== NVENC 检测 ======================
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
|
||||
# 720p (最原始)
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
"-stats", "-stats_period", "1",
|
||||
"-rw_timeout", "30000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
"-flags", "low_delay",
|
||||
"-err_detect", "ignore_err",
|
||||
"-max_delay", "80000",
|
||||
"-thread_queue_size", "2048",
|
||||
|
||||
"-headers", douyin_headers,
|
||||
"-i", real_url,
|
||||
# 720p
|
||||
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
# "-tune", "zerolatency",
|
||||
"-profile:v", "high",
|
||||
# 720p
|
||||
# "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate,保持真 CBR
|
||||
"-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
|
||||
"-vsync", "cfr",
|
||||
"-g", "60", "-keyint_min", "60",
|
||||
"-r", "30",
|
||||
"-bf", "0",
|
||||
"-sc_threshold", "0",
|
||||
# "-nal-hrd", "cbr", # 改2:强制 CBR
|
||||
# 720p
|
||||
# "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3:简化 params,锁死 CBR
|
||||
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改4:1080p可音频上 128k(YouTube 推荐)
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
"-flags", "+global_header",
|
||||
"-flvflags", "no_duration_filesize",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
|
||||
# 720p-低CPU + 背景音乐处理 + 防ContentID
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
# "-stats", "-stats_period", "1",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "5",
|
||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
# "-flags", "low_delay",
|
||||
# "-err_detect", "ignore_err",
|
||||
# "-max_delay", "80000",
|
||||
# "-thread_queue_size", "2048",
|
||||
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
|
||||
# # 视频处理 720p
|
||||
# "-vf",
|
||||
# "fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||
# "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# "-c:v", "libx264",
|
||||
# "-preset", "veryfast", # 降低CPU
|
||||
# "-profile:v", "high",
|
||||
# "-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
|
||||
# "-vsync", "cfr",
|
||||
# "-g", "50", "-keyint_min", "50", # 与fps=25匹配
|
||||
# "-r", "25",
|
||||
# "-bf", "0",
|
||||
# "-sc_threshold", "0",
|
||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
# "-pix_fmt", "yuv420p",
|
||||
|
||||
# # 音频处理(轻量 + 背景音乐弱化 + 防Content ID)
|
||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||||
# "-af",
|
||||
# "afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
|
||||
# "highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
|
||||
# "equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
|
||||
# "equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
|
||||
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
|
||||
# "asetrate=48000*1.02,aresample=48000," # 音高微升
|
||||
# "afreqshift=shift=15," # 小幅频率偏移
|
||||
# "volume=1.1," # 音量补偿
|
||||
# "aresample=async=1:first_pts=0", # 音视频同步
|
||||
|
||||
# # 推流参数
|
||||
# "-flvflags", "no_duration_filesize",
|
||||
# "-f", "flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 1080p
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
# "-stats", "-stats_period", "1",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-reconnect", "1", "-reconnect_at_eof", "1",
|
||||
# "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "5",
|
||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
# "-flags", "low_delay",
|
||||
# "-err_detect", "ignore_err",
|
||||
# "-max_delay", "80000",
|
||||
# "-thread_queue_size", "2048",
|
||||
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
|
||||
# # 视频:不动你原策略,只给编码喘口气
|
||||
# "-vf", "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# "-c:v", "libx264",
|
||||
# "-preset", "superfast",
|
||||
# "-tune", "zerolatency",
|
||||
# "-profile:v", "high",
|
||||
# "-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
|
||||
# "-vsync", "cfr",
|
||||
# "-g", "50", "-keyint_min", "50",
|
||||
# "-r", "25",
|
||||
# "-bf", "0",
|
||||
# "-sc_threshold", "0",
|
||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
# "-pix_fmt", "yuv420p",
|
||||
|
||||
# # 音频
|
||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
|
||||
# "-af",
|
||||
# "afftdn=nf=-18:tn=1,"
|
||||
# "highpass=f=120,lowpass=f=4600,"
|
||||
# "equalizer=f=250:width_type=o:width=2:g=-6,"
|
||||
# "equalizer=f=900:width_type=o:width=1.4:g=-4,"
|
||||
# "asetrate=48000*1.012,aresample=48000,"
|
||||
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=120:makeup=4,"
|
||||
# "volume=1.10,"
|
||||
# "aresample=async=1:first_pts=0",
|
||||
|
||||
# "-flvflags", "no_duration_filesize",
|
||||
# "-f", "flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
# 1080p - 极致优化版本(Ubuntu Server 专用,高质量 + YouTube直播推荐友好 + 强防Content ID)
|
||||
# 针对Ubuntu server(通常CPU较强,无iOS硬件限制):
|
||||
# - preset 升级到 medium 或 slow(如果CPU能承受,质量显著提升)
|
||||
# - 比特率保持8500k-10000k峰值,符合YouTube 1080p直播高质量推荐
|
||||
# - 音频防Content ID:音高+5%、频率偏移、多段EQ衰减背景音乐、强压缩、相位扰动(使用aphaser正确参数)、轻噪门
|
||||
# - 移除无效参数,修复之前aphaser错误(aphaser支持in_gain/out_gain/delay/decay/speed/type,不支持feedback)
|
||||
# - Ubuntu下推荐使用最新FFmpeg(apt install ffmpeg 或从源码编译支持libx264)
|
||||
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
# "-stats", "-stats_period", "1",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "5",
|
||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
# "-flags", "low_delay",
|
||||
# "-err_detect", "ignore_err",
|
||||
# "-max_delay", "50000",
|
||||
# "-thread_queue_size", "4096",
|
||||
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
|
||||
# # 视频处理:1080p 纵屏优化,黑边填充
|
||||
# "-vf",
|
||||
# "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
|
||||
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# "-c:v", "libx264",
|
||||
# "-preset", "ultrafast", # Ubuntu server CPU通常能承受,质量比faster好很多;若掉帧可回退到faster或veryfast
|
||||
# "-threads", "4",
|
||||
# "-tune", "zerolatency",
|
||||
# "-profile:v", "high",
|
||||
# "-level", "4.2", # 支持1080p@25fps
|
||||
# # "-b:v", "8500k", "-maxrate", "10000k", "-bufsize", "20000k",
|
||||
# "-b:v", "4500k", "-maxrate", "5500k", "-bufsize", "9000k",
|
||||
# "-vsync", "cfr",
|
||||
# "-g", "50", "-keyint_min", "50",
|
||||
# "-r", "25",
|
||||
# "-bf", "0",
|
||||
# "-sc_threshold", "0",
|
||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0:rc-lookahead=0",
|
||||
# "-pix_fmt", "yuv420p",
|
||||
|
||||
# # 音频处理 - 防Content ID 强化版(多维度修改音频指纹)
|
||||
# "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
|
||||
# "-af",
|
||||
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," #神经网络
|
||||
# "afftdn=nf=-24:tn=1," # 中等降噪,保留人声清晰度
|
||||
# "highpass=f=100,lowpass=f=5200," # 切除低频鼓/贝斯 & 高频镲/噪音,聚焦人声区
|
||||
# "equalizer=f=180:width_type=o:width=2.2:g=-12," # 强衰减低频(背景音乐基础层)
|
||||
# "equalizer=f=350:width_type=o:width=1.8:g=-9," # 衰减低中频(常见旋律区)
|
||||
# "equalizer=f=750:width_type=o:width=1.6:g=-7," # 中频衰减,破坏音乐结构
|
||||
# "equalizer=f=2800:width_type=o:width=1.2:g=-5," # 高中频轻衰减,保持人声自然
|
||||
# "acompressor=threshold=-26dB:ratio=5.5:attack=7:release=90:makeup=6," # 强压缩,改变动态指纹
|
||||
# "asetrate=48000*1.05,atempo=1.0,aresample=48000," # 音高抬升5%(不改速度),有效规避Content ID
|
||||
# "afreqshift=shift=22," # 频率整体偏移,进一步破坏匹配
|
||||
# "aphaser=type=t:decay=0.35:delay=3.0:speed=0.5," # 正确参数:轻微相位扰动(triangular类型),防检测
|
||||
# # "anlmdn=s=0.00012:p=0.0008:r=0.002," # 轻量非线性噪门,清理残余背景音乐
|
||||
# "volume=1.08," # 补偿音量衰减
|
||||
# "aresample=async=1:first_pts=0:min_hard_comp=0.100000:first_pts=0", # 强制音视频同步
|
||||
|
||||
# # 推流优化(FLV适合YouTube RTMP/RTMPS)
|
||||
# "-flvflags", "no_duration_filesize",
|
||||
# "-f", "flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg","-y","-loglevel","info","-nostdin","-re",
|
||||
# "-stats","-stats_period","1",
|
||||
|
||||
# # 输入稳定
|
||||
# "-rw_timeout","30000000",
|
||||
# "-reconnect","1","-reconnect_at_eof","1","-reconnect_streamed","1",
|
||||
# "-reconnect_delay_max","5",
|
||||
|
||||
# # 关键:删除低延迟杀手参数
|
||||
# "-fflags","+genpts+discardcorrupt",
|
||||
# "-err_detect","ignore_err",
|
||||
# "-max_delay","500000",
|
||||
# "-thread_queue_size","512",
|
||||
|
||||
# # 抖音源
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
|
||||
# # 视频处理
|
||||
# "-vf","fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||
|
||||
# # 编码
|
||||
# "-c:v","libx264",
|
||||
# "-preset","veryfast",
|
||||
# "-profile:v","high",
|
||||
# "-level","4.2",
|
||||
# "-b:v","4500k","-maxrate","4500k","-bufsize","9000k",
|
||||
# "-g","50","-keyint_min","50",
|
||||
# "-r","25",
|
||||
# "-pix_fmt","yuv420p",
|
||||
# "-bf","0",
|
||||
# "-sc_threshold","0",
|
||||
# "-x264-params","force-cfr=1:scenecut=0:open_gop=0",
|
||||
|
||||
# # 音频(强烈建议简化)
|
||||
# "-c:a","aac","-b:a","128k","-ar","44100","-ac","2",
|
||||
# "-af","aresample=async=1,volume=1.05",
|
||||
|
||||
# # 推流
|
||||
# "-flvflags","no_duration_filesize",
|
||||
# "-f","flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
proc = None
|
||||
stderr_q = None
|
||||
reader_t = None
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
cleanup_proc(proc)
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
start_time = last_frame_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
|
||||
except queue.Empty:
|
||||
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] 长时间无任何日志,强制重启进程")
|
||||
cleanup_proc(proc)
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
continue
|
||||
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
|
||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
if time.time() - start_time > START_CHECK_AFTER:
|
||||
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 内层异常: {e}")
|
||||
traceback.print_exc()
|
||||
cleanup_proc(proc)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break
|
||||
|
||||
continue
|
||||
|
||||
except StreamEnded:
|
||||
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FATAL] YouTube 推流最外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
cleanup_proc(proc)
|
||||
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if real_url in running_list:
|
||||
running_list.remove(real_url)
|
||||
monitoring[0] = max(0, monitoring[0] - 1)
|
||||
clear_record_info(record_name, real_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
460
index.html
460
index.html
@@ -1,215 +1,265 @@
|
||||
<!--
|
||||
Project: DouyinLiveRecorder
|
||||
Author: Hmily
|
||||
Build: 2023.08.14 - 20:24:05
|
||||
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="never">
|
||||
<title>M3U8 视频播放器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', Arial, sans-serif;
|
||||
background-color: #1a237e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background-image: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%);
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
width: 80%;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#videoPlayer {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
||||
display: none;
|
||||
}
|
||||
|
||||
video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#videoUrlInput{
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #283593;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
margin: 0 0 10px 0;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#playButton:hover {
|
||||
background-color: #1a237e;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 20px;
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
p{
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.no_style {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin-top:30px;
|
||||
}
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
#videoUrlInput{
|
||||
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>多进程录制控制台</title>
|
||||
<style>
|
||||
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
|
||||
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
|
||||
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
|
||||
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||||
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
|
||||
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
|
||||
#statusIndicator{font-size:1.2em;font-weight:600}
|
||||
h2{margin:24px 0 12px;font-size:1.4em}
|
||||
h3{margin:16px 0 8px;font-size:1.2em}
|
||||
input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
|
||||
input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace;overflow:hidden;resize:none;min-height:100px}
|
||||
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
|
||||
button:hover{background:#1a237e}
|
||||
button.loading{opacity:.7;pointer-events:none}
|
||||
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
|
||||
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
|
||||
.configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
|
||||
.configStatus{color:#0f0;margin-top:8px;font-family:monospace;font-size:0.9em}
|
||||
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8或者FLV 视频链接">
|
||||
<button id="playButton">播放视频</button>
|
||||
<div id="videoPlayer">
|
||||
<video controls></video>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p><strong>说明</strong><p>
|
||||
<p>M3U8文件格式</p>
|
||||
<p>M3U8文件是采用UTF-8编码格式的M3U文件。M3U文件本身是一个纯文本索引文件,其核心功能是记录多媒体文件链接。当用户打开此类文件时,播放软件会根据索引查找相应的音视频文件网络地址,然后进行在线播放。</p>
|
||||
<p>M3U最初设计用于播放音频文件,例如MP3。但随着时间推移,更多的播放器和软件开始使用M3U来播放视频文件列表,同时也支持在线流媒体音频源的指定。目前,许多播放器和软件都兼容M3U文件格式。</p>
|
||||
<p>FLV文件格式(Flash Video Format)是Adobe公司开发的一种专门用于网页视频播放的文件格式。FLV格式的视频文件通常用于播放短视频和在线流媒体,可以嵌入到网页中供用户观看。FLV视频通常由Adobe Flash Player播放器播放,而其他第三方播放器也支持此格式。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2023 <a href='https://github.com/ihmily/DouyinLiveRecorder' class="no_style" target="_blank">Hmily</a>. All rights reserved.</p>
|
||||
</div>
|
||||
<script>
|
||||
function httpToHttps(url) {
|
||||
if (url.startsWith("http://")) {
|
||||
return url.replace("http://", "https://");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
function playVideo() {
|
||||
let videoUrl = document.getElementById('videoUrlInput').value;
|
||||
const video = document.querySelector('#videoPlayer video');
|
||||
const description = document.querySelector('.description');
|
||||
if (videoUrl == ''){
|
||||
alert('请输入视频链接');
|
||||
return;
|
||||
}
|
||||
videoUrl = httpToHttps(videoUrl);
|
||||
if (videoUrl.includes('.m3u8')) {
|
||||
videoPlayer.style.display = 'block';
|
||||
description.style.display = 'none';
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
hls.loadSource(videoUrl);
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = videoUrl;
|
||||
} else {
|
||||
alert('M3U8 格式不受您的浏览器支持。');
|
||||
console.error('M3U8 格式不受您的浏览器支持。');
|
||||
return;
|
||||
}
|
||||
} else if (videoUrl.includes('.flv')) {
|
||||
if (flvjs.isSupported()) {
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url: videoUrl
|
||||
});
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load();
|
||||
flvPlayer.play();
|
||||
} else {
|
||||
alert('FLV 格式不受您的浏览器支持。');
|
||||
console.error('FLV 格式不受您的浏览器支持。');
|
||||
return;
|
||||
}
|
||||
<div class="container">
|
||||
<h1>
|
||||
进程:
|
||||
<select id="processSelect">
|
||||
<option value="tiktok">tiktok</option>
|
||||
<option value="youtube">youtube</option>
|
||||
<option value="obs">obs</option>
|
||||
</select>
|
||||
<span id="statusIndicator">⚪ 加载中...</span>
|
||||
</h1>
|
||||
|
||||
videoPlayer.style.display = 'block';
|
||||
description.style.display = 'none';
|
||||
} else {
|
||||
console.error('不支持播放该视频格式。');
|
||||
alert('不支持播放该视频格式。');
|
||||
<!-- YouTube 专属配置(仅 youtube 时显示) -->
|
||||
<div id="youtubeOnlySection" style="display:none;">
|
||||
<h2>YouTube 配置编辑 (youtube.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>youtube.ini</h3>
|
||||
<textarea id="youtubeConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadYoutubeBtn">加载配置</button>
|
||||
<button id="saveYoutubeBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="youtubeStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL_config.ini 共享配置(youtube 或 tiktok 时显示) -->
|
||||
<div id="urlConfigSection" style="display:none;">
|
||||
<h2>URL 配置编辑 (URL_config.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>URL_config.ini</h3>
|
||||
<textarea id="urlConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadUrlBtn">加载配置</button>
|
||||
<button id="saveUrlBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="urlStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OBS 推流地址区(仅 obs 时显示) -->
|
||||
<div id="obsSection" style="display:none;">
|
||||
<h2>OBS 直播推流地址</h2>
|
||||
<p>请在 OBS → 设置 → 推流 → 服务选择“自定义”,服务器填写以下地址:</p>
|
||||
<div id="obsAddress">srt://live.local:10080/live/obs</div>
|
||||
<p>密钥(Stream Key)留空即可。</p>
|
||||
</div>
|
||||
|
||||
<h2>PM2 进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button data-action="start">启动</button>
|
||||
<button data-action="stop">停止</button>
|
||||
<button data-action="restart">重启</button>
|
||||
<button data-action="status">手动刷新</button>
|
||||
</div>
|
||||
<h2>实时日志(自动每秒刷新)</h2>
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
<script>
|
||||
const logEl = document.getElementById('pm2Log');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const processSelect = document.getElementById('processSelect');
|
||||
let currentProcess = 'tiktok';
|
||||
|
||||
// 各专属区域
|
||||
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
|
||||
const urlConfigSection = document.getElementById('urlConfigSection');
|
||||
const obsSection = document.getElementById('obsSection');
|
||||
|
||||
// youtube.ini 编辑(youtube 专属)
|
||||
const youtubeConfig = document.getElementById('youtubeConfig');
|
||||
const loadYoutubeBtn = document.getElementById('loadYoutubeBtn');
|
||||
const saveYoutubeBtn = document.getElementById('saveYoutubeBtn');
|
||||
const youtubeStatus = document.getElementById('youtubeStatus');
|
||||
|
||||
// URL_config.ini 编辑(youtube 和 tiktok 共享)
|
||||
const urlConfig = document.getElementById('urlConfig');
|
||||
const loadUrlBtn = document.getElementById('loadUrlBtn');
|
||||
const saveUrlBtn = document.getElementById('saveUrlBtn');
|
||||
const urlStatus = document.getElementById('urlStatus');
|
||||
|
||||
processSelect.addEventListener('change', () => {
|
||||
currentProcess = processSelect.value;
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
});
|
||||
|
||||
function updateSectionsVisibility() {
|
||||
youtubeOnlySection.style.display = currentProcess === 'youtube' ? 'block' : 'none';
|
||||
urlConfigSection.style.display = (currentProcess === 'youtube' || currentProcess === 'tiktok') ? 'block' : 'none';
|
||||
obsSection.style.display = currentProcess === 'obs' ? 'block' : 'none';
|
||||
|
||||
if (currentProcess === 'youtube') {
|
||||
loadYoutubeConfig();
|
||||
}
|
||||
if (currentProcess === 'youtube' || currentProcess === 'tiktok') {
|
||||
loadUrlConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// textarea 高度自适应
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||||
}
|
||||
[youtubeConfig, urlConfig].forEach(ta => {
|
||||
ta.addEventListener('input', () => autoResize(ta));
|
||||
autoResize(ta);
|
||||
});
|
||||
|
||||
// 通用加载/保存
|
||||
async function loadConfig(endpoint, textarea, statusEl) {
|
||||
statusEl.textContent = '加载中...';
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?process=${currentProcess}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
textarea.value = data.content || '';
|
||||
autoResize(textarea);
|
||||
statusEl.textContent = '加载成功';
|
||||
} catch (err) {
|
||||
statusEl.textContent = `加载失败: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
|
||||
statusEl.textContent = '保存中...';
|
||||
const content = textarea.value;
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?process=${currentProcess}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({content})
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.message || '保存成功';
|
||||
} catch (err) {
|
||||
statusEl.textContent = `保存失败: ${err.message}`;
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// youtube.ini(仅 youtube)
|
||||
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
|
||||
function saveYoutubeConfig() {
|
||||
saveYoutubeBtn.classList.add('loading');
|
||||
saveConfig('/save_config', youtubeConfig, youtubeStatus, saveYoutubeBtn);
|
||||
}
|
||||
loadYoutubeBtn.onclick = loadYoutubeConfig;
|
||||
saveYoutubeBtn.onclick = saveYoutubeConfig;
|
||||
|
||||
// URL_config.ini(youtube 和 tiktok 共享)
|
||||
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
|
||||
function saveUrlConfig() {
|
||||
saveUrlBtn.classList.add('loading');
|
||||
saveConfig('/save_url_config', urlConfig, urlStatus, saveUrlBtn);
|
||||
}
|
||||
loadUrlBtn.onclick = loadUrlConfig;
|
||||
saveUrlBtn.onclick = saveUrlConfig;
|
||||
|
||||
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
|
||||
|
||||
function updateStatus(process_status){
|
||||
let emoji = '⚪', text = '未知';
|
||||
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
|
||||
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
|
||||
else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
|
||||
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
|
||||
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
|
||||
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
|
||||
statusIndicator.innerHTML = `${emoji} ${text}`;
|
||||
}
|
||||
|
||||
async function pm2Action(action){
|
||||
const btn = document.querySelector(`[data-action="${action}"]`);
|
||||
if(btn) btn.classList.add('loading');
|
||||
const url = `/${action}?process=${currentProcess}`;
|
||||
try{
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) throw new Error('网络错误');
|
||||
const data = await res.json();
|
||||
if(action === 'status'){
|
||||
updateStatus(data.process_status);
|
||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
if(data.process_status === 'not_found'){
|
||||
logText += `【提示】进程 "${currentProcess}" 未注册到 PM2(可能被 pm2 delete 删除)\n`;
|
||||
}else{
|
||||
logText += `【实时输出日志】`;
|
||||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||||
logText += `\n${data.recent_log}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
|
||||
}
|
||||
logText += `\n\n【实时错误日志】`;
|
||||
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
|
||||
logText += `\n${data.recent_error}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
|
||||
}
|
||||
}
|
||||
logEl.textContent = logText;
|
||||
scrollLog();
|
||||
}else{
|
||||
logEl.textContent = `操作: ${action} ${currentProcess}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
||||
scrollLog();
|
||||
setTimeout(fetchStatus, 1500);
|
||||
}
|
||||
}catch(err){
|
||||
logEl.textContent = `请求失败: ${err.message}`;
|
||||
scrollLog();
|
||||
updateStatus('unknown');
|
||||
}finally{
|
||||
if(btn) btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('playButton').addEventListener('click', playVideo);
|
||||
</script>
|
||||
</div>
|
||||
async function fetchStatus(){ pm2Action('status'); }
|
||||
|
||||
// 按钮事件委托
|
||||
document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
const btn = e.target.closest('button');
|
||||
if(!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if(action && action !== 'status') pm2Action(action);
|
||||
});
|
||||
|
||||
// 自动刷新 + 初始
|
||||
setInterval(fetchStatus, 1000);
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14
index.html.1
Normal file
14
index.html.1
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for." name="description"><meta content="noodp, " name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"><meta content="/images/branding/googleg/1x/googleg_standard_color_128dp.png" itemprop="image"><title>Google</title><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var _g={kEI:'gbObaeXAG-Gy5NoPmeGiyAg',kEXPI:'0,1304203,2935842,14111,64702,6398,354503,5532962,16,36811390,25228681,123988,28395,11943,53220,39775,61250,14067,23259,37800,28333,79315,7707,7,33385,4719,24374,1,2884,3086,16757,46340,9920,10754,11886,6436,4952,4791,2646,6292,12404,5550,14505,1383,1,2,1515,3355,196,5814,2,4205,1,16100,2,873,2,22,2208,5108,3008,24,771,4074,4,5393,18686,8466,5337,310,535,845,1201,2,11,2,1560,3,5189,1,13,738,1009,2,1746,4,4878,5773,4542,4,2243,240,297,1,1117,389,4,2647,5,2698,2870,298,9253,1275,7,1784,1050,2,1914,5,10456,2,5724,9,602,59,36,1554,4,2570,3341,932,3066,1,2,1402,3,1117,242,2,2356,2576,49,1,8,216,441,158,2,1645,191,4,1928,4,1634,544,4,1568,1032,1,1399,187,259,1846,5,259,5,197,1921,445,19,1421,119,4,2364,2363,5,39,633,1937,4,57,4,3784,4,76,1970,4,265,106,5,3,971,4,966,5,1125,1,2,2,815,3,2932,4,32,4,130,717,1856,4,40,4,2110,4,134,304,323,1,576,1776,1864,42,5,2,479,704,4,14,3,1,449,4,730,5,75,4,1918,1,21,4,465,429,537,1563,1,1090,2,265,702,4905,1788,14,507,823,747,4,331,4,959,452,57,258,615,80,7,2245,4,281,264,341,221,1,2976,508,339,3430,503,3,1412,1354,4,12,33,2093,4,945,16,4,612,1,20998529,1717,7635,5,2992,4,2960,3,8491,2,1562,3,2687,3,9181,536,2,2461,1194,3,746,6,918,231,3,1369,1494,2715,1,6494629,4214,2,3639,565,745,38,517,541,84,108428,25,36,10,3516226,209655,562,143,12493584,256605,48968,4054456,476,441,42,5,116,590,5,1023,1671,844,1,148,3,6,163,101,343,4,65,388,2,707,260,44,4,152,4,488,62,219,3,2,2,2,550,4,885,1050,2,50,641,5,429,6,2800,5,138,1,863,130,45,2,11,2,16,361,218,1148,1,2,642,214,1485,417,712,2695,1043,281,5,330,1255,292,114,289,54,161,5,142,4,587,412,322,525,1,361,503,213,249,99,472,165,7,51,90,174,436,472,3,289,379,458,583,331,601,587,556,100,949,821,118,4,177,37,7,12,3,6,25,1006,3,2,2,2,67,134,727,4,283,321,70,212,125,4,4,3,2,1,2,3,2,2,1,2,2,2,3,1,3,2,1,3,2,1,3,1,2,3,1,2,2,2,3,2,1,3,1,2,3,2,2,2,2,1,3,1,3,2,1,2,10,2,2,3,2,2,10,1,2,3,1,2,10,2,3,2,1,3,9,2,3,1,3,2,1,2,2,2,3,1,2,3,2,2,1,3,1,3,2,1,2,2,2,2,2,3,2,2,1,2,3,2,1,2,2,2,3,1,3,1,3,1,2,2,10,2,3,1,3,1,11,2,2,1,2,2,10,2,3,2,2,1,183,94,60,120,149,39,317,3,2,2,2,744,296,177,5,22,414,377,24,216,1579,8,293,133,136,134,83,118,2,3,2,2,2,956,1,2,785,3,2,2,2,348,295,4,106,229,180,5,62,2,878,573,260,185,936,640,1027,638,713,94,18,3,2,2,2,4,308,593,3,2,2,2,720,519,6,1,4357,427,3,1,370,435,364,71,61,442,901,4',kBL:'eaQ3',kOPI:89978449};(function(){var a;((a=window.google)==null?0:a.stvsc)?google.kEI=_g.kEI:window.google=_g;}).call(this);})();(function(){google.sn='webhp';google.kHL='en';google.rdn=true;})();(function(){
|
||||
var g=this||self;function k(){return window.google&&window.google.kOPI||null};var l,m=[];function n(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute("eid")));)a=a.parentNode;return b||l}function p(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute("leid")));)a=a.parentNode;return b}function q(a){/^http:/i.test(a)&&window.location.protocol==="https:"&&(google.ml&&google.ml(Error("a"),!1,{src:a,glmm:1}),a="");return a}
|
||||
function r(a,b,d,c,h){var e="";b.search("&ei=")===-1&&(e="&ei="+n(c),b.search("&lei=")===-1&&(c=p(c))&&(e+="&lei="+c));var f=b.search("&cshid=")===-1&&a!=="slh";c="&zx="+Date.now().toString();g._cshid&&f&&(c+="&cshid="+g._cshid);(d=d())&&(c+="&opi="+d);return"/"+(h||"gen_204")+"?atyp=i&ct="+String(a)+"&cad="+(b+e+c)};l=google.kEI;google.getEI=n;google.getLEI=p;google.ml=function(){return null};google.log=function(a,b,d,c,h,e){e=e===void 0?k:e;d||(d=r(a,b,e,c,h));if(d=q(d)){a=new Image;var f=m.length;m[f]=a;a.onerror=a.onload=a.onabort=function(){delete m[f]};a.src=d}};google.logUrl=function(a,b){b=b===void 0?k:b;return r("",a,b)};}).call(this);(function(){google.y={};google.sy={};function e(a,b,d){if(a)var c=a.id;else{do c=Math.random();while(d[c])}d[c]=[a,b]}var f;(f=google).x||(f.x=function(a,b){e(a,b,google.y)});var g;(g=google).sx||(g.sx=function(a,b){e(a,b,google.sy)});google.bx=!1;var h;(h=google).lx||(h.lx=function(){});var k=[],l;(l=google).fce||(l.fce=function(a,b,d,c){k.push([a,b,d,c])});google.qce=k;google.adl=[];}).call(this);google.f={};(function(){
|
||||
document.documentElement.addEventListener("submit",function(b){var a;if(a=b.target){var c=a.getAttribute("data-submitfalse");a=c==="1"||c==="q"&&!a.elements.q.value?!0:!1}else a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener("click",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(a.tagName==="A"){a=a.getAttribute("data-nohref")==="1";break a}a=!1}a&&b.preventDefault()},!0);}).call(this);</script><style>#gbar,#guser{font-size:13px;padding-top:1px !important;}#gbar{height:22px}#guser{padding-bottom:7px !important;text-align:right}.gbh,.gbd{border-top:1px solid #c9d7f1;font-size:1px}.gbh{height:0;position:absolute;top:24px;width:100%}@media all{.gb1{height:22px;margin-right:.5em;vertical-align:top}#gbar{float:left}}a.gb1,a.gb4{text-decoration:underline !important}a.gb1,a.gb4{color:#00c !important}.gbi .gb4{color:#dd8e27 !important}.gbf .gb4{color:#900 !important}
|
||||
</style><style>body,td,a,p,.h{font-family:sans-serif}body{margin:0;overflow-y:scroll}#gog{padding:3px 8px 0}td{line-height:.8em}.gac_m td{line-height:17px}form{margin-bottom:20px}.h{color:#1967d2}em{font-weight:bold;font-style:normal}.lst{height:25px;width:496px}.gsfi,.lst{font:18px sans-serif}.gsfs{font:17px sans-serif}.ds{display:inline-box;display:inline-block;margin:3px 0 4px;margin-left:4px}input{font-family:inherit}body{background:#fff;color:#1f1f1f}a{color:#681da8;text-decoration:none}a:hover,a:active{text-decoration:underline}.fl a{color:#1967d2}a:visited{color:#681da8}.sblc{padding-top:5px}.sblc a{display:block;margin:2px 0;margin-left:13px;font-size:11px}.lsbb{background:#ecedee;border:solid 1px;border-color:#d2d2d2 #70757a #70757a #d2d2d2;height:30px}.lsbb{display:block}#WqQANb a{display:inline-block;margin:0 12px}.lsb{background:url(/images/nav_logo229.png) 0 -261px repeat-x;color:#1f1f1f;border:none;cursor:pointer;height:30px;margin:0;outline:0;font:15px sans-serif;vertical-align:top}.lsb:active{background:#dadce0}.lst:focus{outline:none}</style><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.erd={jsr:1,bv:2383,de:true,dpf:'UAON6G49novXd1LJI--iGd6VfzOOuK54njDRAqwNAgA'};
|
||||
var g=this||self;var k,l=(k=g.mei)!=null?k:1,m,p=(m=g.diel)!=null?m:0,q,r=(q=g.sdo)!=null?q:!0,t=0,u,w=google.erd,x=w.jsr;google.ml=function(a,b,d,n,e){e=e===void 0?2:e;b&&(u=a&&a.message);d===void 0&&(d={});d.cad="ple_"+google.ple+".aple_"+google.aple;if(google.dl)return google.dl(a,e,d,!0),null;b=d;if(x<0){window.console&&console.error(a,b);if(x===-2)throw a;b=!1}else b=!a||!a.message||a.message==="Error loading script"||t>=l&&!n?!1:!0;if(!b)return null;t++;d=d||{};b=encodeURIComponent;var c="/gen_204?atyp=i&ei="+b(google.kEI);google.kEXPI&&(c+="&jexpid="+b(google.kEXPI));c+="&srcpg="+b(google.sn)+"&jsr="+b(w.jsr)+
|
||||
"&bver="+b(w.bv);w.dpf&&(c+="&dpf="+b(w.dpf));var f=a.lineNumber;f!==void 0&&(c+="&line="+f);var h=a.fileName;h&&(h.indexOf("-extension:/")>0&&(e=3),c+="&script="+b(h),f&&h===window.location.href&&(f=document.documentElement.outerHTML.split("\n")[f],c+="&cad="+b(f?f.substring(0,300):"No script found.")));google.ple&&google.ple===1&&(e=2);c+="&jsel="+e;for(var v in d)c+="&",c+=b(v),c+="=",c+=b(d[v]);c=c+"&emsg="+b(a.name+": "+a.message);c=c+"&jsst="+b(a.stack||"N/A");c.length>=12288&&(c=c.substring(0,12288));a=c;n||google.log(0,"",a);return a};window.onerror=function(a,b,d,n,e){u!==a&&(a=e instanceof Error?e:Error(a),d===void 0||"lineNumber"in a||(a.lineNumber=d),b===void 0||"fileName"in a||(a.fileName=b),google.ml(a,!1,void 0,!1,a.name==="SyntaxError"||a.message.substring(0,11)==="SyntaxError"||a.message.indexOf("Script error")!==-1?3:p));u=null;r&&t>=l&&(window.onerror=null)};})();</script></head><body bgcolor="#fff"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var src='/images/nav_logo229.png';var iesg=false;document.body.onload = function(){window.n && window.n();if (document.images){new Image().src=src;}
|
||||
if (!iesg){document.f&&document.f.q.focus();document.gbqf&&document.gbqf.q.focus();}
|
||||
}
|
||||
})();</script><div id="mngb"><div id=gbar><nobr><b class=gb1>Search</b> <a class=gb1 href="https://www.google.com/imghp?hl=en&tab=wi">Images</a> <a class=gb1 href="http://maps.google.com/maps?hl=en&tab=wl">Maps</a> <a class=gb1 href="https://play.google.com/?hl=en&tab=w8">Play</a> <a class=gb1 href="https://www.youtube.com/?tab=w1">YouTube</a> <a class=gb1 href="https://news.google.com/?tab=wn">News</a> <a class=gb1 href="https://mail.google.com/mail/?tab=wm">Gmail</a> <a class=gb1 href="https://drive.google.com/?tab=wo">Drive</a> <a class=gb1 style="text-decoration:none" href="https://www.google.com/intl/en/about/products?tab=wh"><u>More</u> »</a></nobr></div><div id=guser width=100%><nobr><span id=gbn class=gbi></span><span id=gbf class=gbf></span><span id=gbe></span><a href="http://www.google.com/history/optout?hl=en" class=gb4>Web History</a> | <a href="/preferences?hl=en" class=gb4>Settings</a> | <a target=_top id=gb_70 href="https://accounts.google.com/ServiceLogin?hl=en&passive=true&continue=http://www.google.com/&ec=GAZAAQ" class=gb4>Sign in</a></nobr></div><div class=gbh style=left:0></div><div class=gbh style=right:0></div></div><center><br clear="all" id="lgpd"><div><img alt="Google" height="92" src="/images/branding/googlelogo/1x/googlelogo_white_background_color_272x92dp.png" style="padding:28px 0 14px" width="272" id="hplogo"><br><br></div><form action="/search" name="f"><table cellpadding="0" cellspacing="0"><tr valign="top"><td width="25%"> </td><td align="center" nowrap=""><input name="ie" value="ISO-8859-1" type="hidden"><input value="en" name="hl" type="hidden"><input name="source" type="hidden" value="hp"><input name="biw" type="hidden"><input name="bih" type="hidden"><div class="ds" style="height:32px;margin:4px 0"><input class="lst" style="margin:0;padding:5px 8px 0 6px;vertical-align:top;color:#1f1f1f" autocomplete="off" value="" title="Google Search" maxlength="2048" name="q" size="57"></div><br style="line-height:0"><span class="ds"><span class="lsbb"><input class="lsb" value="Google Search" name="btnG" type="submit"></span></span><span class="ds"><span class="lsbb"><input class="lsb" id="tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1" value="I'm Feeling Lucky" name="btnI" type="submit"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var id='tsuid_gbObaeXAG-Gy5NoPmeGiyAg_1';document.getElementById(id).onclick = function(){if (this.form.q.value){this.checked = 1;if (this.form.iflsig)this.form.iflsig.disabled = false;}
|
||||
else top.location='/doodles/';};})();</script><input value="AFdpzrgAAAAAaZvBkZ6x6tP9yqY4FfFqM-NYRs9cR3zt" name="iflsig" type="hidden"></span></span></td><td class="fl sblc" align="left" nowrap="" width="25%"><a href="/advanced_search?hl=en&authuser=0">Advanced search</a></td></tr></table><input id="gbv" name="gbv" type="hidden" value="1"><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){var a,b="1";if(document&&document.getElementById)if(typeof XMLHttpRequest!="undefined")b="2";else if(typeof ActiveXObject!="undefined"){var c,d,e=["MSXML2.XMLHTTP.6.0","MSXML2.XMLHTTP.3.0","MSXML2.XMLHTTP","Microsoft.XMLHTTP"];for(c=0;d=e[c++];)try{new ActiveXObject(d),b="2"}catch(h){}}a=b;if(a=="2"&&location.search.indexOf("&gbv=2")==-1){var f=google.gbvu,g=document.getElementById("gbv");g&&(g.value=a);f&&window.setTimeout(function(){location.href=f},0)};}).call(this);</script></form><div style="font-size:83%;min-height:3.5em"><br></div><span id="footer"><div style="font-size:10pt"><div style="margin:19px auto;text-align:center" id="WqQANb"><a href="/intl/en/ads/">Advertising</a><a href="/services/">Business Solutions</a><a href="/intl/en/about.html">About Google</a></div></div><p style="font-size:8pt;color:#636363">© 2026 - <a href="/intl/en/policies/privacy/">Privacy</a> - <a href="/intl/en/policies/terms/">Terms</a></p></span></center><script nonce="mkn-uSdWx2SoIvEJXJWf7Q">(function(){window.google.cdo={height:757,width:1440};(function(){var a=window.innerWidth,b=window.innerHeight;if(!a||!b){var c=window.document,d=c.compatMode=="CSS1Compat"?c.documentElement:c.body;a=d.clientWidth;b=d.clientHeight}if(a&&b&&(a!=google.cdo.width||b!=google.cdo.height)){var e=google,f=e.log,g="/client_204?&atyp=i&biw="+a+"&bih="+b+"&ei="+google.kEI,h="",k=window.google&&window.google.kOPI||null;k&&(h+="&opi="+k);f.call(e,"","",g+h)};}).call(this);})();(function(){google.xjs={basecomb:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/ck\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAICAQAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAIAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d0/ujg\x3d1/rs\x3dACT90oHYvixg5Dv0yQj-SB1JU6LIcj3yYQ',basecss:'/xjs/_/ss/k\x3dxjs.hp.KF58xD7gYsk.L.X.O/am\x3dAAIAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEgBAAAAAAAEAAAAgAAAAAgAAAQAAI/rs\x3dACT90oFMtqhjIKGT4rLyv9oL3Z6ncHStwQ',basejs:'/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/dg\x3d0/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA',excm:[]};})();(function(){var u='/xjs/_/js/k\x3dxjs.hp.en.p4I6t01-OYQ.es5.O/am\x3dAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAABAAAAAAAABAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEHQEAAAAAgABYBAAAAAAAAB4C/d\x3d1/ed\x3d1/dg\x3d4/rs\x3dACT90oE3jOtIqglc8PGrnS3gM3dxeZxTQA/m\x3dsb_he,d';var st=1;var amd=1000;var mmd=0;var pop=true;var povp=false;var raf=false;var lfpp=false;var ecb=false;var cst=false;var pxp=false;
|
||||
var f=this||self;function g(){var a=google.ia&&google.ia.r.B2Jtyd;return a&&[1,5,6].indexOf(a.m)>=0&&a.cbfd&&a.cbvi?a:void 0};function h(a){var b=document.createElement("link");b.as="script";b.href=a;b.rel="preload";document.body.appendChild(b)}function k(){var a=[l];google.dp||(a.forEach(h),google.dp=!0)};google.ps=google.ps||[];function m(a){var b=l,c=function(){};google.lx=google.stvsc?c:function(){n(b,a&&raf,a&&lfpp);google.lx=c};google.bx||google.lx()}function p(a,b){b&&(a.src=b);var c=a.onload;a.onload=function(d){c&&c(d);google.ps=google.ps.filter(function(e){return a.src!==e})};google.ps.push(a.src);document.body.appendChild(a)}function q(a,b,c,d){c&&(a.fetchPriority="low");b?requestAnimationFrame(function(){p(a,d)}):p(a,d)}google.as=p;function n(a,b,c){google.tick&&google.tick("load","xjsls");var d=document.createElement("script");d.onerror=function(){google.ple=1};d.onload=function(){google.ple=0};google.xjsus=void 0;q(d,b,c,a);google.aple=-1;google.dp=!0};function r(){for(var a=document.getElementsByTagName("img"),b=0,c=a.length;b<c;b++){var d=a[b],e;if(e=d.hasAttribute("data-lzy_")&&Number(d.getAttribute("data-atf"))&1)e=d.getAttribute("jscontroller"),e=!((e==="UBXHI"||e==="R3fhkb"||e==="TSZEqd")&&d.hasAttribute("data-src"));if(e)return!0}return!1};var l,t,w,x,y,z,A,B,C,D,E="";function F(){l=pxp&&google.xjsup||u;var a=l.match("/cb=(loaded_h_\\d+)");a&&a[1]&&(E=a[1]);google.xjsu=l;f._F_jsUrl=l;z=function(c){m(c)};t=!1;w=(st===1||st===3)&&!!google.caft&&!r();x=g();y=!E&&(st===2||st===3)&&!!x&&!r();if(E){var b=(st===2||st===3)&&!!x;f[E]=function(c){var d=!1,e=function(){d||(d=!0,cst?setTimeout(function(){return void c.call(window,window._)},0):c.call(window,window._))};b&&google.ia.adls?(x.cbvi.push(function(){delete google.ia.adls}),x.cbvi.push(e),setTimeout(e,mmd)):e()}}A=
|
||||
pop;B=povp;C=A&&document.prerendering||B&&document.hidden;D=B?"visibilitychange":"prerenderingchange"}function G(a){t||w||y||C||(z(a),t=!0)}
|
||||
setTimeout(function(){google.tick&&google.tick("load","xjspls");F();if(w||y||C){if(w){var a=function(){w=!1;G()};google.caft(a);setTimeout(a,amd)}y&&(a=function(){y=!1;G()},x.cbvi.push(a),setTimeout(a,mmd));if(C){var b=function(){(B?document.hidden:document.prerendering)||(C=!1,G(!B),document.removeEventListener(D,b))};document.addEventListener(D,b,{passive:!0})}t||k()}else z()},0);})();window._ = window._ || {};window._DumpException = _._DumpException = function(e){throw e;};window._s = window._s || {};_s._DumpException = _._DumpException;window._qs = window._qs || {};_qs._DumpException = _._DumpException;window.loaded_h_0 = function(cb){cb.call(window,window._);};(function(){var t=[512,0,2048,0,0,0,4194304,33554432,268437004,0,8192,0,4096,4259020,137036800,134234113,548405252,4196417,8192,134217728,1048576,8,1170496,0,72908928,0,568328192];window._F_toggles = window._xjs_toggles = t;})();window._F_installCss = window._F_installCss || function(css){};(function(){var pmc='{\x22d\x22:{},\x22sb_he\x22:{\x22client\x22:\x22heirloom-hp\x22,\x22dh\x22:true,\x22ds\x22:\x22\x22,\x22host\x22:\x22google.com\x22,\x22jsonp\x22:true,\x22msgs\x22:{\x22cibl\x22:\x22Clear Search\x22,\x22dym\x22:\x22Did you mean:\x22,\x22lcky\x22:\x22I\\u0026#39;m Feeling Lucky\x22,\x22lml\x22:\x22Learn more\x22,\x22psrc\x22:\x22This search was removed from your \\u003Ca href\x3d\\\x22/history\\\x22\\u003EWeb History\\u003C/a\\u003E\x22,\x22psrl\x22:\x22Remove\x22,\x22sbit\x22:\x22Search by image\x22,\x22srch\x22:\x22Google Search\x22},\x22ovr\x22:{},\x22pq\x22:\x22\x22,\x22rfs\x22:[],\x22stok\x22:\x22VgWHNe9WWnZFTDWuPRUlX6IPsTw\x22}}';google.pmc=JSON.parse(pmc);})();</script></body></html>
|
||||
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>
|
||||
404
main.py
404
main.py
@@ -1164,11 +1164,9 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
|
||||
push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S')
|
||||
if port_info['is_live'] is False:
|
||||
print(f"\r{record_name} 等待直播... ")
|
||||
|
||||
# 直接退出线程eric
|
||||
# return
|
||||
# 继续循环
|
||||
continue
|
||||
# continue # 继续循环
|
||||
return # 线程彻底退出
|
||||
|
||||
if start_pushed:
|
||||
if over_show_push:
|
||||
@@ -1620,387 +1618,33 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
|
||||
with max_request_lock:
|
||||
error_count += 1
|
||||
error_window.append(1)
|
||||
|
||||
|
||||
else:
|
||||
|
||||
|
||||
# ====================== 抖音→YouTube 2025(重构:单层重试 + 统一清理) ======================
|
||||
import queue, unicodedata, platform, traceback
|
||||
|
||||
class StreamEnded(Exception):
|
||||
pass
|
||||
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text: return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
|
||||
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
def _stderr_reader_thread(proc, q):
|
||||
try:
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
q.put(line)
|
||||
except Exception as e:
|
||||
try:
|
||||
q.put(f"__ERR__READER__:{e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
q.put(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup_proc(proc):
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ========== 参数配置(保留你原始配置) ==========
|
||||
# YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/x04z-564w-aks7-embw-30y4"
|
||||
# youtube_rtmp="rtmp://192.168.31.184/live/douyin"
|
||||
|
||||
# # ←←←← 在这块代码的上方先定义你要推的多个地址 ←←←←
|
||||
# rtmp_targets = [
|
||||
# "rtmp://a.rtmp.youtube.com/live2/qxvb-r47b-r5ju-6ud3-6k7z", # 主频道
|
||||
# "rtmp://192.168.31.184/live/douyin", # 备用频道(换成你的第二个 YouTube 密钥)
|
||||
# ]
|
||||
|
||||
# # 用 | 拼接所有地址(任何一个断开都不会影响其他)
|
||||
# tee_outputs = "|".join([f"[f=flv]{url}" for url in rtmp_targets])
|
||||
|
||||
|
||||
|
||||
MAX_RETRY_DELAY = 90
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
STDERR_QUEUE_TIMEOUT = 1.0
|
||||
|
||||
|
||||
NO_FRAME_TIMEOUT = 120
|
||||
START_CHECK_AFTER = 25
|
||||
STALE_OUTPUT_SECONDS = 120 #120
|
||||
|
||||
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer",
|
||||
"failed to write trailer",
|
||||
"invalid data found",
|
||||
"server returned 403",
|
||||
"server returned 404",
|
||||
"ingestion error",
|
||||
"access denied",
|
||||
"packet too short",
|
||||
"client disconnected",
|
||||
"live has ended",
|
||||
"no route to host",
|
||||
"connection timed out",
|
||||
]
|
||||
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
_clean_title_in_name = sanitize_title(title_in_name)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title_in_name) if _clean_title_in_name else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# NVENC 检测(原样保留)
|
||||
codec_v, preset_v, tune_param = "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
codec_v, preset_v, tune_param = "h264_nvenc", "p5", ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
|
||||
# headers & ffmpeg command(保持原来参数)
|
||||
douyin_headers = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
import sys
|
||||
sys.path.insert(0, './dist')
|
||||
from douyin_srs_ffplay import start_douyin_srs_ffplay
|
||||
# 调用
|
||||
start_douyin_srs_ffplay(
|
||||
title=title_in_name,
|
||||
anchor_name=anchor_name,
|
||||
real_url=real_url,
|
||||
rec_info=rec_info,
|
||||
record_name=record_name,
|
||||
recording=recording,
|
||||
recording_time_list=recording_time_list,
|
||||
running_list=running_list,
|
||||
monitoring=monitoring,
|
||||
clear_record_info=clear_record_info,
|
||||
color_obj=color_obj
|
||||
)
|
||||
|
||||
# 720p
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
# "-stats", "-stats_period", "1",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "5",
|
||||
# "-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
# "-flags", "low_delay",
|
||||
# "-err_detect", "ignore_err",
|
||||
# "-max_delay", "80000",
|
||||
# "-thread_queue_size", "2048",
|
||||
|
||||
# "-headers", douyin_headers,
|
||||
# "-i", real_url,
|
||||
# # 720p
|
||||
# "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
# "-c:v", "libx264",
|
||||
# "-preset", "fast",
|
||||
# # "-tune", "zerolatency",
|
||||
# "-profile:v", "high",
|
||||
# # 720p
|
||||
# # "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate,保持真 CBR
|
||||
# "-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
|
||||
# "-vsync", "cfr",
|
||||
# "-g", "60", "-keyint_min", "60",
|
||||
# "-r", "30",
|
||||
# "-bf", "0",
|
||||
# "-sc_threshold", "0",
|
||||
# # "-nal-hrd", "cbr", # 改2:强制 CBR
|
||||
# # 720p
|
||||
# # "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3:简化 params,锁死 CBR
|
||||
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
# "-pix_fmt", "yuv420p",
|
||||
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改4:1080p可音频上 128k(YouTube 推荐)
|
||||
# "-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
# "-flags", "+global_header",
|
||||
# "-flvflags", "no_duration_filesize",
|
||||
# "-f", "flv", youtube_rtmp
|
||||
# ]
|
||||
|
||||
|
||||
# 1080p
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
"-stats", "-stats_period", "1",
|
||||
"-rw_timeout", "30000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
|
||||
"-flags", "low_delay",
|
||||
"-err_detect", "ignore_err",
|
||||
"-max_delay", "80000",
|
||||
"-thread_queue_size", "2048",
|
||||
|
||||
"-headers", douyin_headers,
|
||||
"-i", real_url,
|
||||
# 1080p
|
||||
"-vf","fps=30,scale=1080:1920:force_original_aspect_ratio=decrease:flags=lanczos,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "high",
|
||||
# 1080p
|
||||
"-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k", # 改1:2× bufsize,真 CBR
|
||||
"-vsync", "cfr",
|
||||
"-g", "60", "-keyint_min", "60",
|
||||
"-r", "30",
|
||||
"-bf", "0",
|
||||
"-sc_threshold", "0",
|
||||
# "-nal-hrd", "cbr", # 改2:强制 CBR
|
||||
# 1080p
|
||||
"-x264-params","force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
"-flags", "+global_header",
|
||||
"-flvflags", "no_duration_filesize",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
|
||||
# srs内网配置
|
||||
# ffmpeg_command = [
|
||||
# "ffmpeg",
|
||||
# "-loglevel", "info",
|
||||
# "-err_detect", "ignore_err+explode",
|
||||
# "-fflags", "+genpts+discardcorrupt+igndts",
|
||||
# "-reconnect", "1",
|
||||
# "-reconnect_at_eof", "1",
|
||||
# "-reconnect_streamed", "1",
|
||||
# "-reconnect_delay_max", "7",
|
||||
# "-rw_timeout", "30000000",
|
||||
# "-headers", douyin_headers,
|
||||
|
||||
# # ===== 输入源 =====
|
||||
# "-i", real_url,
|
||||
# "-c:v", "copy",
|
||||
# "-c:a", "copy",
|
||||
# "-max_muxing_queue_size", "9999",
|
||||
# "-f", "flv", srs_rtmp
|
||||
# ]
|
||||
|
||||
# ---- 单层重试循环(清晰可控) ----
|
||||
proc = None
|
||||
stderr_q = None
|
||||
reader_t = None
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
# 每次重连前先清理上一次的进程/线程
|
||||
try:
|
||||
cleanup_proc(proc)
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_q:
|
||||
# 垃圾化旧队列以便 GC
|
||||
stderr_q = None
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
# 启动 stderr reader
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
start_time = last_frame_time = time.time()
|
||||
|
||||
# ---- 监控循环(单一职责:处理 stderr 日志并决定是否结束/重连) ----
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
|
||||
except queue.Empty:
|
||||
# 超时检查:如果太久没有任何日志,判为僵死,重启 proc(跳出内层,进行重试)
|
||||
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] 长时间无任何日志,强制重启进程")
|
||||
cleanup_proc(proc)
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None:
|
||||
# reader 线程结束;如果 proc 已退出,跳出去重试
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
continue
|
||||
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
|
||||
# 检测到明确下播关键字 -> 认为主播真下播,立即退出(不再重试)
|
||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
# 帧检测:更新 frame 时间戳
|
||||
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
# 超时无新帧 => 认为下播
|
||||
if time.time() - start_time > START_CHECK_AFTER:
|
||||
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
# 真实下播,抛到外层处理(最终会退出函数)
|
||||
raise
|
||||
except Exception as e:
|
||||
# 内层非致命异常:打印、回退重试间隔,退出监控循环以在外层重试
|
||||
print(f"[ERROR] 内层异常: {e}")
|
||||
traceback.print_exc()
|
||||
cleanup_proc(proc)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break # 跳出监控循环 -> 外层 while 会继续并重试
|
||||
|
||||
# 如果监控循环走到这里并不是因为 StreamEnded(例如 break),则重试连接(loop continue)
|
||||
# 将在 while True 的下一次循环中进行等待并重连
|
||||
continue
|
||||
|
||||
except StreamEnded:
|
||||
# 真实的主播下播,停止并彻底退出本录制线程
|
||||
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# 外层致命异常:打印并退出循环(会在 finally 里做统一清理)
|
||||
print(f"[ERROR] 外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
# 等待并尝试重连(除非你希望直接退出,这里保留重试策略)
|
||||
continue
|
||||
|
||||
# ============ 正常或 StreamEnded 后的统一清理(在这里 break 到这里) ============
|
||||
cleanup_proc(proc)
|
||||
|
||||
# 以下清理放在 try/except 里以防止清理过程出错导致漏逻辑
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
global monitoring
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
# 最外层异常也要统一清理(保证不会留下僵尸状态)
|
||||
print(f"[FATAL] YouTube 推流最外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
try:
|
||||
cleanup_proc(proc)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if record_url in running_list:
|
||||
running_list.remove(record_url)
|
||||
# global monitoring
|
||||
monitoring = max(0, monitoring - 1)
|
||||
clear_record_info(record_name, record_url)
|
||||
except Exception:
|
||||
pass
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流异常退出但已清理\n", color_obj.RED)
|
||||
return
|
||||
|
||||
count_time = time.time()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
|
||||
with max_request_lock:
|
||||
|
||||
5
obs.sh
Normal file
5
obs.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
export DISPLAY=:0
|
||||
ffplay -fflags nobuffer -flags low_delay -framedrop \
|
||||
-probesize 32 -analyzeduration 0 -sync ext -infbuf \
|
||||
-vf "setpts=PTS-STARTPTS" \
|
||||
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'
|
||||
175
tiktok/index.html
Normal file
175
tiktok/index.html
Normal file
@@ -0,0 +1,175 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HDMI 录制控制台</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<style>
|
||||
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
|
||||
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
|
||||
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
|
||||
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||||
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
|
||||
#statusIndicator{font-size:1.2em;font-weight:600}
|
||||
h2{margin:24px 0 12px;font-size:1.4em}
|
||||
input,button{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
|
||||
input{background:#f5f5f5;color:#000;border:1px solid #ddd}
|
||||
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
|
||||
button:hover{background:#1a237e}
|
||||
button.loading{opacity:.7;pointer-events:none}
|
||||
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
|
||||
#videoPlayer{position:relative;width:100%;padding-bottom:56.25%;background:#000;border-radius:8px;overflow:hidden;margin:16px 0;display:none}
|
||||
video{position:absolute;inset:0;width:100%;height:100%}
|
||||
#loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.8em;pointer-events:none;background:rgba(0,0,0,.5);opacity:0;transition:opacity .3s;border-radius:8px}
|
||||
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>进程: hdmi <span id="statusIndicator">⚪ 加载中...</span></h1>
|
||||
|
||||
<h2>视频播放器(M3U8 / FLV)</h2>
|
||||
<input type="text" id="videoUrlInput" placeholder="请输入视频链接">
|
||||
<button id="playButton">播放</button>
|
||||
<div id="videoPlayer">
|
||||
<video controls autoplay playsinline></video>
|
||||
<div id="loading">加载中...</div>
|
||||
</div>
|
||||
|
||||
<h2>PM2 进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button data-action="start">启动</button>
|
||||
<button data-action="stop">停止</button>
|
||||
<button data-action="restart">重启</button>
|
||||
<button data-action="status">手动刷新</button>
|
||||
</div>
|
||||
|
||||
<h2>实时日志(自动每秒刷新)</h2>
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const video = document.querySelector('video');
|
||||
const player = document.getElementById('videoPlayer');
|
||||
const loading = document.getElementById('loading');
|
||||
const logEl = document.getElementById('pm2Log');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
|
||||
function httpToHttps(url){return url.replace(/^http:/,"https:")}
|
||||
|
||||
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
|
||||
|
||||
function updateStatus(process_status){
|
||||
let emoji = '⚪', text = '未知';
|
||||
if(process_status === 'online'){ emoji = ' '; text = '运行中'; }
|
||||
else if(process_status === 'stopped'){ emoji = ' '; text = '已停止'; }
|
||||
else if(process_status === 'errored'){ emoji = ' '; text = '错误'; }
|
||||
else if(process_status === 'launching' || process_status === 'starting'){ emoji = ' '; text = '启动中'; }
|
||||
else if(process_status === 'waiting restart'){ emoji = ' '; text = '等待重启'; }
|
||||
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
|
||||
statusIndicator.innerHTML = `${emoji} ${text}`;
|
||||
}
|
||||
|
||||
async function playVideo(){
|
||||
let url = document.getElementById('videoUrlInput').value.trim();
|
||||
if(!url) return alert('请输入视频链接');
|
||||
url = httpToHttps(url);
|
||||
player.style.display = 'block';
|
||||
loading.style.opacity = 1;
|
||||
video.pause(); video.src = '';
|
||||
|
||||
if(url.endsWith('.m3u8')){
|
||||
if(Hls.isSupported()){
|
||||
const hls = new Hls({enableWorker:true});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, ()=>loading.style.opacity=0);
|
||||
hls.on(Hls.Events.ERROR, (e,data)=>{alert('HLS 错误: '+data.type); loading.style.opacity=0});
|
||||
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
||||
video.src = url; video.play(); loading.style.opacity=0;
|
||||
}else{alert('浏览器不支持 M3U8'); return}
|
||||
}else if(url.endsWith('.flv')){
|
||||
if(typeof flvjs !== 'undefined' && flvjs.isSupported()){
|
||||
const flvPlayer = flvjs.createPlayer({type:'flv',url,isLive:true});
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load(); flvPlayer.play();
|
||||
flvPlayer.on(flvjs.Events.LOADING_COMPLETE, ()=>loading.style.opacity=0);
|
||||
}else{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js';
|
||||
script.onload = playVideo;
|
||||
document.head.appendChild(script);
|
||||
return;
|
||||
}
|
||||
}else{alert('仅支持 .m3u8 或 .flv 格式'); return}
|
||||
}
|
||||
document.getElementById('playButton').onclick = playVideo;
|
||||
|
||||
// PM2 控制 + 实时状态/日志
|
||||
async function pm2Action(action){
|
||||
const btn = document.querySelector(`[data-action="${action}"]`);
|
||||
if(btn) btn.classList.add('loading');
|
||||
|
||||
try{
|
||||
const res = await fetch(`/${action}`);
|
||||
if(!res.ok) throw new Error('网络错误');
|
||||
const data = await res.json();
|
||||
|
||||
if(action === 'status'){
|
||||
updateStatus(data.process_status);
|
||||
|
||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
|
||||
if(data.process_status === 'not_found'){
|
||||
logText += `【提示】进程 "hdmi" 未注册到 PM2(可能被 pm2 delete 删除)\n`;
|
||||
}else{
|
||||
logText += `【实时输出日志】`;
|
||||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||||
logText += `\n${data.recent_log}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
|
||||
}
|
||||
|
||||
logText += `\n\n【实时错误日志】`;
|
||||
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
|
||||
logText += `\n${data.recent_error}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
|
||||
}
|
||||
}
|
||||
|
||||
logEl.textContent = logText;
|
||||
scrollLog();
|
||||
}else{
|
||||
logEl.textContent = `操作: ${action}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
||||
scrollLog();
|
||||
setTimeout(fetchStatus, 1500);
|
||||
}
|
||||
}catch(err){
|
||||
logEl.textContent = `请求失败: ${err.message}`;
|
||||
scrollLog();
|
||||
updateStatus('unknown');
|
||||
}finally{
|
||||
if(btn) btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(){ pm2Action('status'); }
|
||||
|
||||
// 按钮事件委托
|
||||
document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
const btn = e.target.closest('button');
|
||||
if(!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if(action && action !== 'status') pm2Action(action);
|
||||
});
|
||||
|
||||
// 自动刷新(每 1 秒)
|
||||
setInterval(fetchStatus, 1000);
|
||||
fetchStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
tiktok/tiktok.py
Normal file
142
tiktok/tiktok.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import re
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="HDMI 录制控制台")
|
||||
|
||||
# ---------------- 配置 ----------------
|
||||
BOT_NAME = "hdmi"
|
||||
BASE_DIR = Path(__file__).parent
|
||||
DEFAULT_LOG_DIR = BASE_DIR.parent / ".pm2" / "logs"
|
||||
MAX_LOG_LINES = 300
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------- 工具 ----------------
|
||||
def strip_ansi_codes(text: str) -> str:
|
||||
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
||||
|
||||
async def run_pm2(cmd: str) -> str:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"pm2 {cmd}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
|
||||
return (stdout or stderr).decode(errors="ignore").strip()
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return "ERROR: PM2 命令超时"
|
||||
|
||||
async def read_log_path(path: str, lines: int) -> str:
|
||||
if not path:
|
||||
return "无日志路径\n"
|
||||
if "/dev/null" in path:
|
||||
return "日志输出被禁用(PM2 配置为无日志)\n"
|
||||
log_file = Path(path)
|
||||
if not log_file.exists():
|
||||
return f"日志文件不存在: {path}\n"
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content_lines = f.readlines()
|
||||
return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
|
||||
except Exception as e:
|
||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||
|
||||
async def get_hdmi_info() -> dict:
|
||||
describe_raw = await run_pm2(f"describe {BOT_NAME}")
|
||||
describe_clean = strip_ansi_codes(describe_raw)
|
||||
|
||||
if ("No such process" in describe_clean or
|
||||
"Unknown process" in describe_clean or
|
||||
"not found" in describe_clean.lower() or
|
||||
not describe_clean.strip()):
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
status = "unknown"
|
||||
out_path = None
|
||||
err_path = None
|
||||
|
||||
for line in describe_raw.splitlines():
|
||||
line_clean = strip_ansi_codes(line)
|
||||
if line_clean.count('│') < 2:
|
||||
continue
|
||||
parts = [p.strip() for p in line_clean.split('│') if p.strip()]
|
||||
|
||||
if len(parts) >= 2:
|
||||
key = parts[0].lower()
|
||||
value = parts[1]
|
||||
|
||||
if key == "status":
|
||||
status = value.lower()
|
||||
elif key == "out_log_path":
|
||||
out_path = value
|
||||
elif key == "err_log_path":
|
||||
err_path = value
|
||||
|
||||
# 备用默认路径(仅当 describe 未提供时)
|
||||
if not out_path:
|
||||
out_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-error.log")
|
||||
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
}
|
||||
|
||||
# ---------------- 路由 ----------------
|
||||
@app.get("/start")
|
||||
async def start():
|
||||
output = await run_pm2(f"start {BOT_NAME}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
@app.get("/stop")
|
||||
async def stop():
|
||||
output = await run_pm2(f"stop {BOT_NAME}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
@app.get("/restart")
|
||||
async def restart():
|
||||
output = await run_pm2(f"restart {BOT_NAME}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
full_status_raw = await run_pm2("status")
|
||||
full_status = strip_ansi_codes(full_status_raw) # 去除颜色代码,前端显示更干净
|
||||
|
||||
info = await get_hdmi_info()
|
||||
|
||||
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
||||
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
||||
|
||||
if info["process_status"] == "not_found":
|
||||
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
||||
recent_error = ""
|
||||
|
||||
return JSONResponse({
|
||||
"raw_status": full_status,
|
||||
"process_status": info["process_status"],
|
||||
"recent_log": recent_log,
|
||||
"recent_error": recent_error,
|
||||
})
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(BASE_DIR / "index.html")
|
||||
1
tiktok/tiktok.sh
Normal file
1
tiktok/tiktok.sh
Normal file
@@ -0,0 +1 @@
|
||||
uvicorn web:app --host 0.0.0.0 --port 8000 --reload
|
||||
267
web.py
Normal file
267
web.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import re
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="多进程录制控制台")
|
||||
|
||||
# ---------------- 配置(根目录结构) ----------------
|
||||
BASE_DIR = Path(__file__).parent
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
||||
|
||||
MAX_LOG_LINES = 300
|
||||
|
||||
# 支持的进程列表
|
||||
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------- 工具函数 ----------------
|
||||
def strip_ansi_codes(text: str) -> str:
|
||||
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
||||
|
||||
|
||||
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"pm2 {cmd}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
result = (stdout or stderr).decode(errors="ignore").strip()
|
||||
return result if result else "命令执行完成(无输出)"
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return "ERROR: PM2 命令超时,已强制终止"
|
||||
|
||||
|
||||
async def force_kill_ffmpeg(process_name: str):
|
||||
"""尝试强杀与该进程相关的 ffmpeg"""
|
||||
cmds = [
|
||||
# 优先杀带有进程名的 ffmpeg(最精准)
|
||||
f"pkill -f 'ffmpeg.*{process_name}' || true",
|
||||
# 再杀所有 ffmpeg(兜底,比较暴力)
|
||||
"killall -9 ffmpeg 2>/dev/null || true",
|
||||
]
|
||||
|
||||
for cmd in cmds:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
|
||||
|
||||
|
||||
async def read_log_path(path: str, lines: int) -> str:
|
||||
if not path:
|
||||
return "无日志路径\n"
|
||||
if "/dev/null" in path:
|
||||
return "日志输出被禁用(PM2 配置为无日志)\n"
|
||||
log_file = Path(path)
|
||||
if not log_file.exists():
|
||||
return f"日志文件不存在: {path}\n"
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content_lines = f.readlines()
|
||||
return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
|
||||
except Exception as e:
|
||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||
|
||||
|
||||
async def get_process_info(process: str) -> dict:
|
||||
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
|
||||
describe_clean = strip_ansi_codes(describe_raw)
|
||||
|
||||
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
status = "unknown"
|
||||
out_path = None
|
||||
err_path = None
|
||||
|
||||
for line in describe_raw.splitlines():
|
||||
line_clean = strip_ansi_codes(line)
|
||||
if line_clean.count('│') < 2:
|
||||
continue
|
||||
parts = [p.strip() for p in line_clean.split('│') if p.strip()]
|
||||
if len(parts) >= 2:
|
||||
key = parts[0].lower()
|
||||
value = parts[1]
|
||||
if key == "status":
|
||||
status = value.lower()
|
||||
elif key == "out_log_path":
|
||||
out_path = value
|
||||
elif key == "err_log_path":
|
||||
err_path = value
|
||||
|
||||
if not out_path:
|
||||
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
||||
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
}
|
||||
|
||||
# ---------------- 配置路由(youtube.ini,仅 youtube) ----------------
|
||||
@app.get("/get_config")
|
||||
async def get_config(process: str = Query(..., description="进程名")):
|
||||
if process != "youtube":
|
||||
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
|
||||
|
||||
config_file = CONFIG_DIR / "youtube.ini"
|
||||
if not config_file.exists():
|
||||
return {"content": "# youtube.ini 文件不存在,将创建空文件\n"}
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
return {"content": content}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
|
||||
|
||||
@app.post("/save_config")
|
||||
async def save_config(request: Request, process: str = Query(..., description="进程名")):
|
||||
if process != "youtube":
|
||||
return JSONResponse({"error": "仅 youtube 支持此配置编辑"}, status_code=400)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
content = data.get("content", "")
|
||||
except:
|
||||
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
|
||||
|
||||
config_file = CONFIG_DIR / "youtube.ini"
|
||||
try:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text(content, encoding="utf-8")
|
||||
return {"message": "配置保存成功"}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
|
||||
|
||||
# ---------------- 配置路由(URL_config.ini,youtube 和 tiktok 共享) ----------------
|
||||
@app.get("/get_url_config")
|
||||
async def get_url_config(process: str = Query(..., description="进程名")):
|
||||
if process not in ["youtube", "tiktok"]:
|
||||
return JSONResponse({"error": "仅 youtube 和 tiktok 支持此配置编辑"}, status_code=400)
|
||||
|
||||
config_file = CONFIG_DIR / "URL_config.ini"
|
||||
if not config_file.exists():
|
||||
return {"content": "# URL_config.ini 文件不存在,将创建空文件\n"}
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
return {"content": content}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
|
||||
|
||||
@app.post("/save_url_config")
|
||||
async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
|
||||
if process not in ["youtube", "tiktok"]:
|
||||
return JSONResponse({"error": "仅 youtube 和 tiktok 支持此配置编辑"}, status_code=400)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
content = data.get("content", "")
|
||||
except:
|
||||
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
|
||||
|
||||
config_file = CONFIG_DIR / "URL_config.ini"
|
||||
try:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text(content, encoding="utf-8")
|
||||
return {"message": "配置保存成功"}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
|
||||
|
||||
# ---------------- PM2 控制路由 ----------------
|
||||
@app.get("/start")
|
||||
async def start(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
output = await run_pm2(f"start {process}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
|
||||
@app.get("/stop")
|
||||
async def stop(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
|
||||
# 第一步:尝试优雅停止
|
||||
pm2_output = await run_pm2(f"stop {process}")
|
||||
|
||||
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
# 第三步:主动查找并杀死 ffmpeg
|
||||
await force_kill_ffmpeg(process)
|
||||
|
||||
# 第四步:再等一小会儿,确认
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return JSONResponse({
|
||||
"message": "已执行 stop + 强杀 ffmpeg",
|
||||
"pm2_output": pm2_output,
|
||||
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
|
||||
})
|
||||
|
||||
|
||||
@app.get("/restart")
|
||||
async def restart(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
output = await run_pm2(f"restart {process}")
|
||||
# 重启后也建议清理一次残留(视情况可选)
|
||||
await force_kill_ffmpeg(process)
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def status(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({
|
||||
"raw_status": "",
|
||||
"process_status": "invalid",
|
||||
"recent_log": f"无效进程名: {process}",
|
||||
"recent_error": ""
|
||||
})
|
||||
|
||||
full_status_raw = await run_pm2("status", timeout=8.0)
|
||||
full_status = strip_ansi_codes(full_status_raw)
|
||||
info = await get_process_info(process)
|
||||
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
||||
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
||||
|
||||
if info["process_status"] == "not_found":
|
||||
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
||||
recent_error = ""
|
||||
|
||||
return JSONResponse({
|
||||
"raw_status": full_status,
|
||||
"process_status": info["process_status"],
|
||||
"recent_log": recent_log,
|
||||
"recent_error": recent_error,
|
||||
})
|
||||
|
||||
|
||||
# ---------------- 首页 ----------------
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(BASE_DIR / "index.html")
|
||||
2202
youtube.py
Normal file
2202
youtube.py
Normal file
File diff suppressed because it is too large
Load Diff
38
youtubeKey
38
youtubeKey
@@ -1,38 +0,0 @@
|
||||
ue78-1c3e-mr9g-14mz-9r4z # 没有ypp的美食
|
||||
qxvb-r47b-r5ju-6ud3-6k7z # vlog频道
|
||||
x04z-564w-aks7-embw-30y4 #美食ypp
|
||||
|
||||
|
||||
|
||||
; https://live.douyin.com/517715534931,主播: 一只羊
|
||||
; https://live.douyin.com/460525712926,主播: 丽姐摆摊记
|
||||
; https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
|
||||
; https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜)
|
||||
; https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog)
|
||||
; https://live.douyin.com/835571459859,主播: 海绵饱饱
|
||||
; https://live.douyin.com/152358755212,主播: 大美_红烧肉
|
||||
; https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿)
|
||||
; https://live.douyin.com/591442402624,主播: 76696515302
|
||||
; https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记
|
||||
; https://live.douyin.com/78012762575,主播: 摆摊儿去划水
|
||||
; https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中)
|
||||
; https://live.douyin.com/498368994814,主播: 剪头发的小帆帆
|
||||
; https://live.douyin.com/617848099204,主播: 果妈要努力
|
||||
; https://live.douyin.com/821525628944,主播: 李熙雨⭐
|
||||
; https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官)
|
||||
; https://live.douyin.com/701547125568,主播: 悠悠包
|
||||
; https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记
|
||||
; https://live.douyin.com/876468215361,主播: 晓晓和凡凡
|
||||
; https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版)
|
||||
; https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版)
|
||||
; https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍
|
||||
; https://live.douyin.com/8687122573,主播: 小彤炒粉
|
||||
; https://live.douyin.com/642534242822,主播: (小王_)煎饼果子
|
||||
; https://live.douyin.com/743565594721,主播: 汪汪汪小妞
|
||||
; https://live.douyin.com/249578288248,主播: 思思努力版
|
||||
; https://live.douyin.com/472140253414,主播: 戴盘龙
|
||||
; https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号
|
||||
; https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦)
|
||||
; https://live.douyin.com/690114366322,主播: 卤味鲜(东大)
|
||||
; https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_)
|
||||
; https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记
|
||||
Reference in New Issue
Block a user