This commit is contained in:
eric
2025-09-27 10:47:31 +08:00
parent 9f64fed2ec
commit 7a0207f8ee
5 changed files with 237 additions and 25 deletions

View File

@@ -1 +1,4 @@
https://live.douyin.com/956616126418,主播: 糕冷小橘
https://live.douyin.com/956616126418
https://live.douyin.com/754546227599
https://live.douyin.com/382919014818
https://live.douyin.com/591442402624

View File

@@ -12,7 +12,7 @@ language(zh_cn/en) = zh_cn
是否使用代理ip(是/否) =
代理地址 =
同一时间访问网络的线程数 = 1
循环时间(秒) = 10
循环时间(秒) = 60
排队读取网址时间(秒) = 0
是否显示循环秒数 =
是否显示直播源地址 =

View File

@@ -1 +0,0 @@
https://live.douyin.com/956616126418

View File

@@ -1,10 +1,11 @@
[youtube]
key = ue78-1c3e-mr9g-14mz-9r4z
secure = false ; true=rtmps, false=rtmp
bitrate = 5000 ; 视频码率(kbps)
audio_bitrate = 128 ; 音频码率(kbps)
secure = true
ingest = a
bitrate = 5000
audio_bitrate = 128
fps = 30
gop = 60
resolution = ; 为空=不改分辨率;如 720x1280 或 1280x720
ingest = a ; 谷歌分区 a/b/c...,留空默认 a
copy_when_safe = true ; 源满足 h264+aac 且参数合规时尝试 -c copy
copy_when_safe = true
resolution =
single_mode = true

241
main.py
View File

@@ -56,7 +56,10 @@ def _yt_get(section: str, key: str, fallback=None, cast=str):
return _config.get(section, key, fallback=fallback)
except Exception:
return fallback
# ===【单进程推流协调模式】全局控制 ===
yt_single_mode = _yt_get("youtube", "single_mode", fallback=True, cast=bool) # 建议默认 True
yt_thread_started = False
yt_coordinator_lock = threading.Lock()
def _safe_int(v, lo, hi, default):
try:
v = int(v)
@@ -65,6 +68,196 @@ def _safe_int(v, lo, hi, default):
return v
except Exception:
return default
def _yt_build_output_args(port_info, res_str, bv, ba, fps, gop, allow_copy):
# 输出端(向 YouTube编码链
v_args = [
"-c:v", "libx264",
"-preset", "veryfast",
"-profile:v", "high",
"-level:v", "4.2",
"-pix_fmt", "yuv420p",
"-r", str(fps),
"-g", str(gop),
"-b:v", f"{bv}k",
"-maxrate", f"{bv}k",
"-bufsize", f"{max(2*bv, 4000)}k",
]
a_args = [
"-c:a", "aac",
"-b:a", f"{ba}k",
"-ar", "44100",
"-ac", "2",
]
vf_chain = []
if res_str:
try:
w, h = map(int, res_str.lower().split("x"))
vf_chain += [
f"scale={w}:{h}:force_original_aspect_ratio=decrease",
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2",
"format=yuv420p",
]
except Exception:
logger.warning(f"警告: resolution '{res_str}' 非法,已忽略")
# 源若已是 h264+aac 且未强制缩放,尝试零转码 copy
if allow_copy and not vf_chain and _can_copy_h264_aac(port_info):
v_args = ["-c:v", "copy"]
a_args = ["-c:a", "copy"]
base = ["-map", "0"]
if vf_chain:
base += ["-vf", ",".join(vf_chain)]
base += v_args + a_args + [
"-flvflags", "no_duration_filesize",
"-rtmp_live", "live",
]
return base
def _yt_make_input_chain(input_url, proxy_address, headers,
rw_timeout="15000000", analyzeduration="20000000",
probesize="10000000", bufsize="8000k",
max_muxing_queue_size="1024"):
# 输入端(从抖音等源拉流)
user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 "
"(KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36")
cmd = [
"ffmpeg", "-y",
"-v", "verbose",
"-rw_timeout", rw_timeout,
"-loglevel", "error",
"-hide_banner",
"-user_agent", user_agent,
"-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy",
"-thread_queue_size", "1024",
"-analyzeduration", analyzeduration,
"-probesize", probesize,
"-fflags", "+discardcorrupt",
"-re", "-i", input_url,
"-bufsize", bufsize,
"-sn", "-dn",
"-reconnect_delay_max", "60",
"-reconnect_streamed", "-reconnect_at_eof",
"-max_muxing_queue_size", max_muxing_queue_size,
"-correct_ts_overflow", "1",
"-avoid_negative_ts", "1",
]
if headers:
cmd.insert(11, "-headers")
cmd.insert(12, headers)
if proxy_address:
cmd.insert(1, "-http_proxy")
cmd.insert(2, proxy_address)
return cmd
def _try_open_rtmp_out(key, secure, ingest):
# 优先 rtMPS失败自动轮换节点与协议
nodes = [ingest] if ingest and len(ingest) == 1 else ["a"]
nodes += [c for c in "bcdef" if c not in nodes]
schemes = ["rtmps", "rtmp"] if secure else ["rtmp", "rtmps"]
for sch in schemes:
for node in nodes:
yield _build_youtube_rtmp_url(key, sch == "rtmps", node)
def yt_single_coordinator(record_quality, proxy_address):
"""
单进程 YouTube 转播协调器:
- 轮询 URL_config.ini找“第一条在播”的源
- 找到就启动 ffmpeg 推到同一条 YouTube live
- 源断/下播 -> 立刻切到下一路在播;
- 没有任何在播源 -> 不在面板里显示录制项(保持空)。
"""
key = (_yt_get("youtube", "key", fallback="", cast=str) or "").strip()
if not key:
logger.error("错误: youtube.ini[youtube].key 未配置或为空")
return
secure = _yt_get("youtube", "secure", fallback=True, cast=bool)
ingest = (_yt_get("youtube", "ingest", fallback="a", cast=str) or "a").strip().lower()
res_str = (_yt_get("youtube", "resolution", fallback="", cast=str) or "").strip()
bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000)
ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128)
fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30)
gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps)
allow_copy = _yt_get("youtube", "copy_when_safe", fallback=True, cast=bool)
out_url_candidates = list(_try_open_rtmp_out(key, secure, ingest))
out_url_idx = 0
# 输入端时序阈值(海外可加大)
rw_timeout = "15000000"; analyzeduration = "20000000"; probesize = "10000000"
bufsize = "8000k"; max_muxing_queue_size = "1024"
current_display_name = None # 面板上当前显示的条目名(带主播)
current_anchor = None
while True:
# 1) 找到“第一条在播”的源
real_url, port_info = find_next_live_source(None, proxy_address, record_quality)
if not real_url:
# 没有任何在播源 -> 清理面板显示
if current_display_name:
recording.discard(current_display_name)
recording_time_list.pop(current_display_name, None)
current_display_name = None
current_anchor = None
time.sleep(3)
continue
# 2) 更新面板显示名称为“转播·主播名”
anchor = clean_name(port_info.get("anchor_name") or "直播")
display_name = f"转播·{anchor}"
if display_name != current_display_name:
if current_display_name:
recording.discard(current_display_name)
recording_time_list.pop(current_display_name, None)
recording.add(display_name)
recording_time_list[display_name] = [datetime.datetime.now(), video_record_quality]
current_display_name = display_name
current_anchor = anchor
headers = None # 如需平台定制 headers 可在此设置
in_cmd = _yt_make_input_chain(real_url, proxy_address, headers,
rw_timeout, analyzeduration, probesize,
bufsize, max_muxing_queue_size)
out_args = _yt_build_output_args(port_info, res_str, bv, ba, fps, gop, allow_copy)
# 3) 逐个尝试 YouTube 出口(节点/协议)
tried = 0
while tried < len(out_url_candidates):
out_url = out_url_candidates[out_url_idx % len(out_url_candidates)]
out_url_idx += 1
tried += 1
run_cmd = list(in_cmd) + out_args + ["-f", "flv", out_url]
logger.info(f"[YT协调] {anchor} -> {out_url}")
proc = subprocess.Popen(
run_cmd, stdin=subprocess.PIPE, stderr=subprocess.STDOUT,
startupinfo=get_startup_info(os_type)
)
# 等待 ffmpeg若源断/异常 -> 进程退出
while proc.poll() is None:
# 如需全局停止/注释判断可在此处加判断后优雅退出
time.sleep(1)
rc = proc.returncode
if rc == 0:
# 正常退出(极少)-> 直接跳出去重新找源
break
else:
logger.warning(f"[YT协调] 推流退出 rc={rc},更换 YouTube 节点/协议重试…")
# 换节点继续 while tried…
# 4) 当前源结束/异常:回到循环顶部去找下一路在播;若没有,则上面的分支会清空面板
time.sleep(1)
def read_all_urls_from_list(url_file: str) -> list[tuple[str, str]]:
"""
读取 URL_config.ini返回 [(url, anchor_hint), ...]
@@ -1975,27 +2168,43 @@ while True:
new_word = replace_words[1]
update_file(url_config_file, old_str=replace_words[0], new_str=new_word, start_str=start_with)
text_no_repeat_url = list(set(url_tuples_list))
# 保序去重(文件顺序 = 备源优先级)
text_no_repeat_url = list(dict.fromkeys(url_tuples_list))
if len(text_no_repeat_url) > 0:
for url_tuple in text_no_repeat_url:
monitoring = len(running_list)
# 监测数量显示为“URL 条数”(哪怕不启动录制线程)
monitoring = len(text_no_repeat_url)
if url_tuple[1] in not_record_list:
continue
if yt_single_mode and video_save_type.upper() == "MP4":
# ===== 单进程协调模式:只启 1 个协调器,不再为每个 URL 启动 start_record =====
with yt_coordinator_lock:
# global yt_thread_started
if not yt_thread_started:
print("\n启用【单进程 YouTube 转播协调模式】(只保留 1 个 ffmpeg 推流进程)")
record_quality = get_quality_code(video_record_quality)
args = (record_quality, proxy_addr if use_proxy else None)
t_yt = threading.Thread(target=yt_single_coordinator, args=args, daemon=True)
t_yt.start()
yt_thread_started = True
else:
# ===== 传统模式:按原先逻辑为每个 URL 启动录制(本地落地 / 其他保存格式)=====
if len(text_no_repeat_url) > 0:
for url_tuple in text_no_repeat_url:
if url_tuple[1] in not_record_list:
continue
if url_tuple[1] not in running_list:
print(f"\r{'新增' if not first_start else '传入'}地址: {url_tuple[1]}")
idx = len(running_list) + 1
args = [url_tuple, idx]
create_var[f'thread_{idx}'] = threading.Thread(target=start_record, args=args)
create_var[f'thread_{idx}'].daemon = True
create_var[f'thread_{idx}'].start()
running_list.append(url_tuple[1])
time.sleep(local_delay_default)
if url_tuple[1] not in running_list:
print(f"\r{'新增' if not first_start else '传入'}地址: {url_tuple[1]}")
monitoring += 1
args = [url_tuple, monitoring]
create_var[f'thread_{monitoring}'] = threading.Thread(target=start_record, args=args)
create_var[f'thread_{monitoring}'].daemon = True
create_var[f'thread_{monitoring}'].start()
running_list.append(url_tuple[1])
time.sleep(local_delay_default)
url_tuples_list = []
first_start = False
except Exception as err:
logger.error(f"错误信息: {err} 发生错误的行数: {err.__traceback__.tb_lineno}")