diff --git a/config/URL_config.ini b/config/URL_config.ini index 528d5ff..f434396 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -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 diff --git a/config/config.ini b/config/config.ini index bf4252d..f73bd05 100644 --- a/config/config.ini +++ b/config/config.ini @@ -12,7 +12,7 @@ language(zh_cn/en) = zh_cn 是否使用代理ip(是/否) = 是 代理地址 = 同一时间访问网络的线程数 = 1 -循环时间(秒) = 10 +循环时间(秒) = 60 排队读取网址时间(秒) = 0 是否显示循环秒数 = 否 是否显示直播源地址 = 否 diff --git a/config/list.ini b/config/list.ini deleted file mode 100644 index 154f084..0000000 --- a/config/list.ini +++ /dev/null @@ -1 +0,0 @@ -https://live.douyin.com/956616126418 \ No newline at end of file diff --git a/config/youtube.ini b/config/youtube.ini index d123990..77b4a2e 100644 --- a/config/youtube.ini +++ b/config/youtube.ini @@ -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 \ No newline at end of file diff --git a/main.py b/main.py index 854ffe9..ed6857e 100644 --- a/main.py +++ b/main.py @@ -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}")