108 Commits

Author SHA1 Message Date
eric
cc552d49f0 's' 2026-03-22 23:29:04 -05:00
eric
a4b7edd153 's' 2026-03-22 11:36:22 -05:00
eric
746da3d0b6 s'' 2026-03-22 11:32:24 -05:00
eric
0b2ceb4e8c 's' 2026-03-22 11:27:14 -05:00
eric
587c0e10b4 's' 2026-03-19 23:32:57 -05:00
eric
77969c85fe 's' 2026-03-19 13:46:02 -05:00
eric
f0b1f9cf47 's' 2026-03-19 04:04:23 -05:00
eric
219a8cef61 's' 2026-03-19 03:51:16 -05:00
eric
fd3d952ab8 's' 2026-03-19 03:47:47 -05:00
eric
ab6a6cac65 's' 2026-03-19 03:43:15 -05:00
eric
032c75fb6f 's' 2026-03-01 09:58:58 -06:00
eric
950cb0ae4b 's' 2026-03-01 04:10:21 -06:00
eric
9700404418 's' 2026-03-01 04:08:57 -06:00
eric
1da3383735 's' 2026-03-01 04:07:26 -06:00
eric
b850d1a8bc 's' 2026-03-01 04:04:10 -06:00
eric
b6c5185a59 's' 2026-03-01 03:59:47 -06:00
eric
4030a4dc28 's' 2026-03-01 03:50:30 -06:00
eric
00bcaee9fa 's' 2026-03-01 03:47:09 -06:00
eric
54daccea6b '' 2026-03-01 03:39:27 -06:00
root
4eac033e93 s 2026-03-01 09:38:06 +00:00
root
17900701f1 cbnn 2026-03-01 05:29:02 +00:00
eric
033b76bda5 's' 2026-02-28 23:23:40 -06:00
eric
bf6b827e1f 's' 2026-02-28 09:07:11 -06:00
root
b4183ed85b s 2026-02-28 15:00:17 +00:00
Your Name
f0823c7593 s 2026-02-28 11:40:44 +00:00
eric
326ab8aea2 's' 2026-02-23 17:41:36 -06:00
eric
3842833ad0 's' 2026-02-23 17:34:48 -06:00
eric
9e589e6263 's' 2026-02-23 10:00:27 -06:00
eric
7090f4481a 's' 2026-02-23 09:55:21 -06:00
eric
eafcc2b7d0 's' 2026-02-23 09:53:32 -06:00
eric
e510e66c13 's' 2026-02-23 09:48:42 -06:00
eric
d86e8e339d 's' 2026-02-23 09:12:50 -06:00
eric
ba4aaa1b65 's' 2026-02-23 09:02:02 -06:00
eric
475e15bc16 's' 2026-02-23 08:52:35 -06:00
eric
31e77d5203 's' 2026-02-23 08:48:50 -06:00
Your Name
6c383ee489 s 2026-02-23 14:47:48 +00:00
eric
9d3ec4fa2c 's' 2026-02-23 08:25:19 -06:00
root
7402773a3e s 2026-02-23 01:55:23 +00:00
root
140790c4a4 s; 2026-02-23 01:52:32 +00:00
eric
1bdf41ac3f 's' 2026-02-22 06:40:05 -06:00
eric
6590a9c98d 's' 2026-02-01 02:05:43 -06:00
Your Name
07383d84ec ss 2026-02-01 08:02:33 +00:00
eric
0e679a4d7a '' 2026-01-31 22:27:15 -06:00
Your Name
abfa3a29c1 s 2026-02-01 04:20:56 +00:00
eric
dc4c1c514b 's' 2026-01-31 22:02:56 -06:00
eric
5722f2f6b3 's' 2026-01-31 21:46:18 -06:00
Your Name
d03001d446 s 2026-02-01 03:37:54 +00:00
eric
87964c66ba 's' 2026-01-31 21:36:15 -06:00
Your Name
b071b638fc s 2026-02-01 03:09:17 +00:00
eric
b502cdd8c9 's' 2026-01-31 21:00:05 -06:00
eric
8b1e504701 's' 2026-01-31 10:13:47 -06:00
Your Name
d80eb256e9 s 2026-01-27 09:25:52 +00:00
eric
313d705ce9 's' 2026-01-27 03:24:04 -06:00
eric
187f7a2ce8 '' 2026-01-27 03:21:13 -06:00
Your Name
7ade49e46d s 2026-01-27 09:17:04 +00:00
Your Name
3ccbf3d973 hdmi 2026-01-27 08:55:32 +00:00
eric
6fbd86bf43 's' 2026-01-26 23:53:59 -06:00
eric
20c1bbd12c 's' 2026-01-26 23:45:47 -06:00
eric
1595659dcf 's' 2026-01-26 23:16:25 -06:00
Your Name
c041570eef s 2026-01-27 04:45:56 +00:00
Your Name
0806545014 grok 2026-01-24 04:24:41 +00:00
Your Name
50b61d75fc s 2026-01-24 04:05:28 +00:00
eric
b349f8f405 's' 2026-01-22 02:52:01 -06:00
eric
9263110c08 's' 2026-01-22 02:46:25 -06:00
eric
6ea44eede3 's' 2026-01-22 02:43:48 -06:00
eric
f7536f21e2 's' 2026-01-22 02:38:47 -06:00
Your Name
2c6ad31ba7 s 2026-01-15 01:09:47 +00:00
Your Name
a5d3dd98fe s 2026-01-15 01:06:27 +00:00
Your Name
2375331973 srs-mpv 2026-01-02 04:37:59 +00:00
Your Name
041a402a73 mpv-grok 2025-12-30 14:00:02 +00:00
Your Name
a7b7761e78 lajilaji 2025-12-30 10:03:08 +00:00
Your Name
7ac4c7ff90 mpv设计ok 2025-12-30 06:42:07 +00:00
Your Name
d4158e7f1a mpv断线重启 2025-12-30 06:28:06 +00:00
Your Name
8953e2f4d4 ffmpeg=>pipe=>mpv 2025-12-30 04:37:35 +00:00
Your Name
88d54c1679 mpv检测第1版 2025-12-30 02:40:13 +00:00
Your Name
c630787719 mpv初始版本 2025-12-30 01:50:28 +00:00
Your Name
67bacc1c42 s 2025-12-29 06:11:30 +00:00
eric
53f2c77421 's' 2025-12-28 08:45:14 -06:00
eric
9107995c02 ';s' 2025-12-28 08:20:11 -06:00
eric
3ff4facd3d 's' 2025-12-28 07:52:07 -06:00
eric
34bb2b661e 's' 2025-12-28 07:50:05 -06:00
eric
6aeb1462dd 's' 2025-12-23 06:47:50 -06:00
eric
0c7437f9cc 's' 2025-12-20 09:30:30 -06:00
eric
9b23d9872b s'' 2025-12-20 09:27:06 -06:00
eric
303ece39c0 's' 2025-12-13 08:17:23 -06:00
eric
7c21e80eb6 s'' 2025-12-13 07:42:08 -06:00
eric
b4e76d74ca 's' 2025-12-13 07:34:08 -06:00
eric
bfc64b873a 's' 2025-12-13 07:26:45 -06:00
eric
bc2651c801 's' 2025-12-13 07:18:33 -06:00
eric
7c47c73d71 's' 2025-12-13 01:47:34 -06:00
eric
c6e2ea2e77 's' 2025-12-13 00:20:07 -06:00
eric
4e1b408e27 's' 2025-12-13 00:18:06 -06:00
eric
4a27659c99 's' 2025-12-13 00:11:42 -06:00
eric
ab8511de5a 's' 2025-12-12 23:24:30 -06:00
eric
90c3f193aa 's' 2025-12-12 23:22:02 -06:00
eric
f4a0c06ff6 's' 2025-12-12 23:20:05 -06:00
eric
2cb81ebb03 's' 2025-12-12 23:17:27 -06:00
eric
26db3d41ce '' 2025-12-12 23:07:03 -06:00
eric
ddadef2287 's' 2025-12-12 22:54:50 -06:00
eric
c717efe8c4 's' 2025-12-12 22:33:41 -06:00
eric
90bb33c9af 's' 2025-12-12 20:42:10 -06:00
eric
1ef411a2d0 's' 2025-12-12 20:33:19 -06:00
eric
27ad068712 's' 2025-12-12 20:29:59 -06:00
eric
dd6d9f7377 s 2025-12-13 02:22:47 +00:00
eric
b00663aa6c 's' 2025-12-12 20:22:18 -06:00
eric
8d3f950303 's' 2025-12-12 20:18:46 -06:00
eric
6d505c1452 's' 2025-12-12 14:11:38 -06:00
eric
c059f62d38 's' 2025-12-12 14:09:44 -06:00
28 changed files with 6027 additions and 1996 deletions

2
.gitignore vendored
View File

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

1
arnndn-models Submodule

Submodule arnndn-models added at 0fda24c46d

30
audio Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,4 @@
https://live.douyin.com/456576000931,主播: 介休〖烩饕〗大块牛肉饭「摆摊」
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/980855021360,主播: 图爸摆摊卖枣糕(日照首家)教学员
https://live.douyin.com/497155982953,主播: 想吃糖葫芦
https://live.douyin.com/349973241990,主播: 元气满满饭团(摆摊不摆烂版)
https://live.douyin.com/814839174401,主播: 飞飞摆摊不摆烂
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/486789870173,主播: 凉皮西施—豆豆
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/925673220466,主播: 兜有米麻糍
https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜)
https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog)
@@ -60,3 +29,11 @@ https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦)
https://live.douyin.com/690114366322,主播: 卤味鲜(东大)
https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_)
https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记
https://live.douyin.com/661694696687,主播: 小米摆摊记
https://live.douyin.com/228132412943,主播: 00后摆摊小可爱
https://live.douyin.com/876677865786,主播: 牛哄哄
https://live.douyin.com/573735790640,主播: 小董卤串(餐饮)
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
https://live.douyin.com/210443559964,主播: 小小诺
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠

View File

@@ -8,7 +8,7 @@ language(zh_cn/en) = zh_cn
保存文件名是否包含标题 =
是否去除名称中的表情符号 =
视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = ts
原画|超清|高清|标清|流畅 = 高清
原画|超清|高清|标清|流畅 = 原画
是否使用代理ip(是/否) =
代理地址 =
同一时间访问网络的线程数 = 3

13
config/youtube.ini Normal file
View File

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

View File

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

50
docs/YOUTUBE_LIVE_SEO.md Normal file
View File

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

368
douyin_youtube_ffplay.py Normal file
View File

@@ -0,0 +1,368 @@
# 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 配置 ======================
config_file = "config/youtube.ini"
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
use_rtmps = True
# 720p 竖屏 YouTube 建议 46 Mbps偏低易触发「数据不足/卡顿」提示
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
stream_key = None
fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ显著减轻 CPU
if os.path.exists(config_file):
config = configparser.ConfigParser()
config.read(config_file, encoding="utf-8")
if config.has_section("youtube"):
for k, v in config.items("youtube"):
v = (v or "").strip()
if k == "rtmps" and v.lower() in ("0", "false", ""):
use_rtmps = False
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "", "", "on"):
fast_audio = True
elif k == "bitrate" and v:
try:
n = int(v)
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
except ValueError:
pass
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
stream_key = v
if stream_key:
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
youtube_rtmp = stream_key
else:
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
else:
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key")
# ====================== 常量配置 ======================
MAX_RETRY_DELAY = 90
INITIAL_RETRY_DELAY = 3
STDERR_QUEUE_TIMEOUT = 1.0
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25
STALE_OUTPUT_SECONDS = 40
RECONNECT_DELAY_MAX = 10
END_KEYWORDS = [
"connection reset by peer",
"failed to write trailer",
"invalid data found",
"server returned 403",
"server returned 404",
"server returned 500",
"ingestion error",
"access denied",
"packet too short",
"client disconnected",
"live has ended",
"no route to host",
"connection timed out",
"connection refused",
"error writing output",
"broken pipe",
"i/o error",
]
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 检测(符合 YouTube 推荐比特率)======================
codec_v = "libx264"
# ultrafast+zerolatency在软编下尽量让 speed≥1.0;画质略降、直播可接受
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
if "h264_nvenc" in (enc.stdout or ""):
codec_v = "h264_nvenc"
video_args = ["-profile:v", "high", "-level", "4.0",
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
"-maxrate", maxrate, "-bufsize", bufsize]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e:
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
_script_dir = os.path.dirname(os.path.abspath(__file__))
_ffmpeg_cwd = None
if fast_audio:
print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ减轻 CPU 以利 speed≥1.0")
_af_chain = "afftdn=nf=-26:tn=1,aresample=async=1:first_pts=0"
else:
# 优先 RNNoise(arnndn),无模型则用 afftdnaphaser 相位扰动可干扰 Content ID 指纹
_arnndn_paths = [
os.path.join(_script_dir, "arnndn-models", "cb.rnnn"),
os.path.join(os.path.dirname(_script_dir), "arnndn-models", "cb.rnnn"),
"arnndn-models/cb.rnnn",
]
_arnndn_model = None
for _p in _arnndn_paths:
if os.path.isfile(_p):
_arnndn_model = os.path.normpath(_p)
break
if _arnndn_model:
print(f"[INFO] 使用 RNNoise 降噪模型: {_arnndn_model}")
_arnndn_base = os.path.dirname(os.path.dirname(os.path.abspath(_arnndn_model)))
_af_denoise = "arnndn=m=arnndn-models/cb.rnnn:mix=0.82,"
_ffmpeg_cwd = _arnndn_base
else:
print("[INFO] 未找到 arnndn 模型,使用 afftdn 降噪(可克隆 richardpl/arnndn-models 到 arnndn-models/")
_af_denoise = "afftdn=nf=-26:tn=1,"
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段EQ + 相位扰动(纯 FFmpeg 理论上限 ~70%
_af_chain = (
_af_denoise
+ "highpass=f=200,lowpass=f=3800,"
+ "equalizer=f=150:width_type=o:width=2.5:g=-14,"
+ "equalizer=f=350:width_type=o:width=2:g=-10,"
+ "equalizer=f=600:width_type=o:width=1.8:g=-8,"
+ "equalizer=f=1000:width_type=o:width=1.5:g=-6,"
+ "equalizer=f=2500:width_type=o:width=1.2:g=-4,"
+ "acompressor=threshold=-26dB:ratio=5:attack=8:release=80:makeup=5,"
+ "afreqshift=shift=22,"
+ "asetrate=48000*1.035,aresample=48000,"
+ "aphaser=type=t:decay=0.4:delay=3:speed=0.7,"
+ "volume=1.12,"
+ "aresample=async=1:first_pts=0"
)
# 720p 竖屏,符合 YouTube 推荐46 Mbps、2s 关键帧、AAC 128k
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
"-bf", "0", "-sc_threshold", "0"]
if codec_v == "libx264":
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
# -re按源时间戳节奏拉流避免突发过快不用 realtime/arealtime输入抖动时易让 YouTube 判为数据不足)
ffmpeg_command = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
"-stats", "-stats_period", "1",
"-rw_timeout", "50000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
"-fflags", "+genpts+discardcorrupt",
"-err_detect", "ignore_err",
"-max_delay", "500000",
"-thread_queue_size", "2048",
"-filter_threads", "4",
"-headers", douyin_headers,
"-re",
"-i", real_url,
# bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v,
*video_args,
*_video_mid,
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
"-af", _af_chain,
"-flags", "+global_header",
"-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}")
_popen_kw = dict(
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
)
if _ffmpeg_cwd:
_popen_kw["cwd"] = _ffmpeg_cwd
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
proc.last_out_ts = time.time()
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)

View File

@@ -1,215 +1,336 @@
<!--
Project: DouyinLiveRecorder
Author: Hmily
Build: 2023.08.14 - 20:24:05
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
-->
<!DOCTYPE html>
<html lang="en">
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="never">
<title>M3U8 视频播放器</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&amp;display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
<style>
body {
font-family: 'Roboto', Arial, sans-serif;
background-color: #1a237e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
padding: 0;
color: #ffffff;
background-image: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%);
}
.container {
max-width: 640px;
width: 80%;
padding: 20px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
}
#videoPlayer {
width: 100%;
height: 0;
padding-bottom: 56.25%;
position: relative;
background-color: #000;
border-radius: 5px;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
display: none;
}
video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#videoUrlInput{
display: block;
width: 100%;
margin: 10px 0;
padding: 8px;
border-radius: 5px;
border: 1px solid #ccc;
box-sizing: border-box;
}
#playButton {
display: block;
width: 100%;
padding: 10px;
background-color: #283593;
color: white;
font-weight: bold;
border-radius: 5px;
border: none;
cursor: pointer;
transition: background-color 0.3s;
margin: 0 0 10px 0;
box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.15);
}
#playButton:hover {
background-color: #1a237e;
}
.description {
margin-top: 20px;
line-height: 1.4;
font-size: 14px;
text-align: left;
background-color: #f8f9fa;
padding: 15px;
border-radius: 5px;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
display: block;
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 14px;
color: white;
}
p{
color: black;
}
a.no_style {
color: inherit;
text-decoration: none;
}
@media screen and (max-width: 768px) {
.container {
width: 90%;
border-radius: 0;
box-shadow: none;
margin-top:30px;
}
body {
overflow-y: scroll;
}
#videoUrlInput{
margin-top: 30px;
margin-bottom: 10px;
}
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>多进程录制控制台</title>
<style>
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
#statusIndicator{font-size:1.2em;font-weight:600}
h2{margin:24px 0 12px;font-size:1.4em}
h3{margin:16px 0 8px;font-size:1.2em}
input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace;overflow:hidden;resize:none;min-height:100px}
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
button:hover{background:#1a237e}
button.loading{opacity:.7;pointer-events:none}
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
.configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
.configStatus{color:#0f0;margin-top:8px;font-family:monospace;font-size:0.9em}
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="container">
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8或者FLV 视频链接">
<button id="playButton">播放视频</button>
<div id="videoPlayer">
<video controls></video>
</div>
<div class="description">
<p><strong>说明</strong><p>
<p>M3U8文件格式</p>
<p>M3U8文件是采用UTF-8编码格式的M3U文件。M3U文件本身是一个纯文本索引文件其核心功能是记录多媒体文件链接。当用户打开此类文件时播放软件会根据索引查找相应的音视频文件网络地址然后进行在线播放。</p>
<p>M3U最初设计用于播放音频文件例如MP3。但随着时间推移更多的播放器和软件开始使用M3U来播放视频文件列表同时也支持在线流媒体音频源的指定。目前许多播放器和软件都兼容M3U文件格式。</p>
<p>FLV文件格式Flash Video Format是Adobe公司开发的一种专门用于网页视频播放的文件格式。FLV格式的视频文件通常用于播放短视频和在线流媒体可以嵌入到网页中供用户观看。FLV视频通常由Adobe Flash Player播放器播放而其他第三方播放器也支持此格式。</p>
</div>
<div class="footer">
<p>&copy; 2023 <a href='https://github.com/ihmily/DouyinLiveRecorder' class="no_style" target="_blank">Hmily</a>. All rights reserved.</p>
</div>
<script>
function httpToHttps(url) {
if (url.startsWith("http://")) {
return url.replace("http://", "https://");
}
return url;
}
function playVideo() {
let videoUrl = document.getElementById('videoUrlInput').value;
const video = document.querySelector('#videoPlayer video');
const description = document.querySelector('.description');
if (videoUrl == ''){
alert('请输入视频链接');
return;
}
videoUrl = httpToHttps(videoUrl);
if (videoUrl.includes('.m3u8')) {
videoPlayer.style.display = 'block';
description.style.display = 'none';
if (Hls.isSupported()) {
const hls = new Hls();
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(videoUrl);
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoUrl;
} else {
alert('M3U8 格式不受您的浏览器支持。');
console.error('M3U8 格式不受您的浏览器支持。');
return;
}
} else if (videoUrl.includes('.flv')) {
if (flvjs.isSupported()) {
const flvPlayer = flvjs.createPlayer({
type: 'flv',
url: videoUrl
});
flvPlayer.attachMediaElement(video);
flvPlayer.load();
flvPlayer.play();
} else {
alert('FLV 格式不受您的浏览器支持。');
console.error('FLV 格式不受您的浏览器支持。');
return;
}
<div class="container">
<h1>
进程:
<select id="processSelect"></select>
<span id="statusIndicator">⚪ 加载中...</span>
</h1>
<p id="processScriptNote" style="font-size:0.88em;margin:-12px 0 20px;opacity:0.88;font-family:ui-monospace,monospace"></p>
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>
/** 进程列表仅来自 GET /process_monitorweb2.py 启动时扫描项目根目录自动识别PM2 名 = 脚本主文件名(无扩展名)。 */
let PROCESS_MONITOR = [];
function pm2NameFromScript(script) {
const base = String(script).split(/[/\\]/).pop() || '';
const i = base.lastIndexOf('.');
return i > 0 ? base.slice(0, i) : base;
}
function isYoutubeScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube');
}
function isTiktokScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
}
function isObsScript(script) {
return pm2NameFromScript(script).startsWith('obs');
}
async function loadProcessMonitor() {
const res = await fetch('/process_monitor');
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
const entries = data.entries || [];
PROCESS_MONITOR = entries.map((e) => ({
script: e.script,
label: e.label || e.script,
pm2: e.pm2 || pm2NameFromScript(e.script),
}));
}
function getEntryByPm2(name) {
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
}
function fillProcessSelect() {
processSelect.innerHTML = '';
PROCESS_MONITOR.forEach((e) => {
const opt = document.createElement('option');
opt.value = e.pm2;
opt.textContent = `${e.label}${e.script}`;
processSelect.appendChild(opt);
});
}
const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator');
const processSelect = document.getElementById('processSelect');
let currentProcess = '';
// 各专属区域
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');
function refreshProcessScriptNote() {
const el = document.getElementById('processScriptNote');
const ent = getEntryByPm2(currentProcess);
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
}
processSelect.addEventListener('change', () => {
currentProcess = processSelect.value;
refreshProcessScriptNote();
fetchStatus();
updateSectionsVisibility();
});
function updateSectionsVisibility() {
const ent = getEntryByPm2(currentProcess);
const s = ent ? ent.script : '';
const isYoutube = ent && isYoutubeScript(s);
const isTiktok = ent && isTiktokScript(s);
const isObs = ent && isObsScript(s);
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
obsSection.style.display = isObs ? 'block' : 'none';
if (isYoutube) loadYoutubeConfig();
if (isYoutube || isTiktok) loadUrlConfig();
}
// textarea 高度自适应
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = (textarea.scrollHeight) + 'px';
}
[youtubeConfig, urlConfig].forEach(ta => {
ta.addEventListener('input', () => autoResize(ta));
autoResize(ta);
});
// 通用加载/保存
async function loadConfig(endpoint, textarea, statusEl) {
statusEl.textContent = '加载中...';
try {
const res = await fetch(`${endpoint}?process=${currentProcess}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
textarea.value = data.content || '';
autoResize(textarea);
statusEl.textContent = '加载成功';
} catch (err) {
statusEl.textContent = `加载失败: ${err.message}`;
}
}
async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
statusEl.textContent = '保存中...';
const content = textarea.value;
try {
const res = await fetch(`${endpoint}?process=${currentProcess}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content})
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
statusEl.textContent = data.message || '保存成功';
} catch (err) {
statusEl.textContent = `保存失败: ${err.message}`;
} finally {
if (saveBtn) saveBtn.classList.remove('loading');
}
}
// youtube.ini仅 youtube
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
function saveYoutubeConfig() {
saveYoutubeBtn.classList.add('loading');
saveConfig('/save_config', youtubeConfig, youtubeStatus, saveYoutubeBtn);
}
loadYoutubeBtn.onclick = loadYoutubeConfig;
saveYoutubeBtn.onclick = saveYoutubeConfig;
// URL_config.iniyoutube 和 tiktok 共享)
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
function saveUrlConfig() {
saveUrlBtn.classList.add('loading');
saveConfig('/save_url_config', urlConfig, urlStatus, saveUrlBtn);
}
loadUrlBtn.onclick = loadUrlConfig;
saveUrlBtn.onclick = saveUrlConfig;
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
function updateStatus(process_status){
let emoji = '⚪', text = '未知';
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
statusIndicator.innerHTML = `${emoji} ${text}`;
}
async function pm2Action(action){
const btn = document.querySelector(`[data-action="${action}"]`);
if(btn) btn.classList.add('loading');
const url = `/${action}?process=${currentProcess}`;
try{
const res = await fetch(url);
if(!res.ok) throw new Error('网络错误');
const data = await res.json();
if(action === 'status'){
updateStatus(data.process_status);
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()}\n${data.raw_status}\n\n`;
if(data.process_status === 'not_found'){
const ent = getEntryByPm2(currentProcess);
const hint = ent ? `${ent.script}PM2 名: ${ent.pm2}` : currentProcess;
logText += `【提示】未在 PM2 中找到: ${hint}\n`;
}else{
logText += `【实时输出日志】`;
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
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{
const ent = getEntryByPm2(currentProcess);
const who = ent ? `${ent.script} (${ent.pm2})` : currentProcess;
logEl.textContent = `操作: ${action}${who}\n${data.output || '成功'}\n\n正在刷新状态...`;
scrollLog();
setTimeout(fetchStatus, 1500);
}
}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);
});
(async function boot() {
try {
await loadProcessMonitor();
} catch (e) {
logEl.textContent = '无法加载 /process_monitor请用本服务打开的页面访问或检查 web2 是否已启动)\n' + (e && e.message ? e.message : '');
statusIndicator.textContent = '❌ 未连接';
return;
}
if (!PROCESS_MONITOR.length) {
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh且 Web 为当前 web*.py';
return;
}
fillProcessSelect();
currentProcess = PROCESS_MONITOR[0].pm2;
processSelect.value = currentProcess;
refreshProcessScriptNote();
setInterval(fetchStatus, 1000);
fetchStatus();
updateSectionsVisibility();
})();
</script>
</body>
</html>

340
main.py
View File

@@ -1164,11 +1164,9 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S')
if port_info['is_live'] is False:
print(f"\r{record_name} 等待直播... ")
# 直接退出线程eric
# return
# 继续循环
continue
# continue # 继续循环
return # 线程彻底退出
if start_pushed:
if over_show_push:
@@ -1620,322 +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
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25.0
STALE_OUTPUT_SECONDS = 40
STDERR_QUEUE_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",
]
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
)
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
# "-f", "tee",
# "-map", "0:v", "-map", "0:a",
# tee_outputs
]
# 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:
@@ -1980,6 +1689,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
def backup_file(file_path: str, backup_dir_path: str, limit_counts: int = 6) -> None:
try:
if not os.path.exists(backup_dir_path):
os.makedirs(backup_dir_path)

5
obs2.sh Normal file
View File

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

175
tiktok/index.html Normal file
View File

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

142
tiktok/tiktok.py Normal file
View File

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

1
tiktok/tiktok.sh Normal file
View File

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

2204
tiktok2.py Normal file

File diff suppressed because it is too large Load Diff

353
web2.py Normal file
View File

@@ -0,0 +1,353 @@
from fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path
import asyncio
import re
from typing import Optional
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="多进程录制控制台")
# ---------------- 配置(根目录结构) ----------------
BASE_DIR = Path(__file__).parent
CONFIG_DIR = BASE_DIR / "config"
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
MAX_LOG_LINES = 300
def _label_short(prefix: str) -> str:
return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
def discover_script_entries() -> tuple:
"""扫描项目根目录自动识别入口tiktok*.py、youtube*.py、obs*.sh以及当前本文件Web 控制台。PM2 名 = 主文件名(无扩展名)。"""
base = BASE_DIR
web_path = Path(__file__).resolve()
entries: list[dict] = []
for path in sorted(base.glob("tiktok*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("tiktok")})
for path in sorted(base.glob("youtube*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("youtube")})
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
entries.append({"script": web_path.name, "label": _label_short("web")})
return tuple(entries)
SCRIPT_ENTRIES = discover_script_entries()
def pm2_name_from_script(script: str) -> str:
return Path(script).stem
def _stem(script: str) -> str:
return Path(script).stem
def is_youtube_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("youtube")
def is_tiktok_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("tiktok")
def _build_process_monitor():
return tuple(
{
"pm2": pm2_name_from_script(e["script"]),
"script": e["script"],
"label": e["label"],
}
for e in SCRIPT_ENTRIES
)
PROCESS_MONITOR = _build_process_monitor()
VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
URL_CONFIG_PM2 = frozenset(
pm2_name_from_script(e["script"])
for e in SCRIPT_ENTRIES
if is_tiktok_script(e["script"]) or is_youtube_script(e["script"])
)
def script_for_pm2(pm2: str) -> Optional[str]:
for p in PROCESS_MONITOR:
if p["pm2"] == pm2:
return p["script"]
return None
# CORS
app.add_middleware(
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="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists():
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="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
try:
data = await request.json()
content = data.get("content", "")
except:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- 配置路由URL_config.iniyoutube 和 tiktok 共享) ----------------
@app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini"
if not config_file.exists():
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 URL_CONFIG_PM2:
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, 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)
# 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 ffmpeg
if not process.startswith("web"):
await force_kill_ffmpeg(process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
})
@app.get("/restart")
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}")
if not process.startswith("web"):
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,
})
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
return {
"entries": [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
for p in PROCESS_MONITOR
]
}
# ---------------- 首页 ----------------
@app.get("/")
async def index():
return FileResponse(BASE_DIR / "index.html")

2
web2.sh Normal file
View File

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

2202
youtube2.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
ue78-1c3e-mr9g-14mz-9r4z # 没有ypp的美食
qxvb-r47b-r5ju-6ud3-6k7z # vlog频道
x04z-564w-aks7-embw-30y4 #美食ypp
; https://live.douyin.com/517715534931,主播: 一只羊
; https://live.douyin.com/460525712926,主播: 丽姐摆摊记
; https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
; https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜)
; https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog)
; https://live.douyin.com/835571459859,主播: 海绵饱饱
; https://live.douyin.com/152358755212,主播: 大美_红烧肉
; https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿)
; https://live.douyin.com/591442402624,主播: 76696515302
; https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记
; https://live.douyin.com/78012762575,主播: 摆摊儿去划水
; https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中)
; https://live.douyin.com/498368994814,主播: 剪头发的小帆帆
; https://live.douyin.com/617848099204,主播: 果妈要努力
; https://live.douyin.com/821525628944,主播: 李熙雨⭐
; https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官)
; https://live.douyin.com/701547125568,主播: 悠悠包
; https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记
; https://live.douyin.com/876468215361,主播: 晓晓和凡凡
; https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版)
; https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版)
; https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍
; https://live.douyin.com/8687122573,主播: 小彤炒粉
; https://live.douyin.com/642534242822,主播: (小王_)煎饼果子
; https://live.douyin.com/743565594721,主播: 汪汪汪小妞
; https://live.douyin.com/249578288248,主播: 思思努力版
; https://live.douyin.com/472140253414,主播: 戴盘龙
; https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号
; https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦)
; https://live.douyin.com/690114366322,主播: 卤味鲜(东大)
; https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_)
; https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记