Files
gitlab-instance-0a899031_do…/main.py
2025-09-27 11:07:42 +08:00

2278 lines
112 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- encoding: utf-8 -*-
"""
Author: Hmily
GitHub: https://github.com/ihmily
Date: 2023-07-17 23:52:05
Update: 2025-07-19 17:43:00
Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
Function: Record live stream video.
"""
import asyncio
import os
import sys
import builtins
import subprocess
import signal
import threading
import time
import datetime
import re
import shutil
import random
import uuid
from pathlib import Path
import urllib.request
from urllib.error import URLError, HTTPError
from typing import Any
import configparser
import httpx
from src import spider, stream
from src.proxy import ProxyDetector
from src.utils import logger
from src import utils
from msg_push import (
dingtalk, xizhi, tg_bot, send_email, bark, ntfy, pushplus
)
from ffmpeg_install import (
check_ffmpeg, ffmpeg_path, current_env_path
)
import configparser
import shlex
import subprocess
# 建议在程序初始化阶段全局加载一次
_config = configparser.ConfigParser()
with open("config/youtube.ini", "r", encoding="utf-8-sig") as f:
_config.read_file(f)
# _config.read("config/config.ini", encoding="utf-8")
def _yt_get(section: str, key: str, fallback=None, cast=str):
try:
if cast is bool:
return _config.getboolean(section, key, fallback=fallback)
if cast is int:
return _config.getint(section, key, fallback=fallback)
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)
if v < lo or v > hi:
return 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), ...]
anchor_hint 可能为空字符串
"""
items = []
try:
if not os.path.isfile(url_file):
return items
with open(url_file, 'r', encoding=text_encoding, errors='ignore') as f:
for origin_line in f:
line = origin_line.strip()
if not line or line.startswith('#'):
continue
# 兼容 "url,主播: 名字" / "清晰度,url,主播: 名字"
parts = re.split('[,]', line)
if len(parts) == 1:
url, anchor = parts[0], ''
elif len(parts) == 2:
if '://' in parts[0]:
url, anchor = parts
else:
# parts[0] 是清晰度,忽略它
url, anchor = parts[1], ''
else:
# 质量, url, 主播
_, url, anchor = parts[0], parts[1], parts[2] if len(parts) > 2 else ''
url = 'https://' + url if '://' not in url else url
items.append((url.strip(), anchor.strip()))
except Exception as e:
logger.error(f"读取 URL 列表失败: {e}")
return items
def probe_douyin_live_and_stream(url: str, proxy_address: str | None, record_quality: str | None):
"""
只针对抖音/抖音短链,复用你现有的 spider/stream返回 (is_live, real_url, port_info)
失败返回 (False, None, None)
"""
try:
if 'douyin.com/' not in url:
return False, None, None
if 'v.douyin.com' not in url and '/user/' not in url:
json_data = asyncio.run(spider.get_douyin_stream_data(url=url, proxy_addr=proxy_address, cookies=dy_cookie))
else:
json_data = asyncio.run(spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy_address, cookies=dy_cookie))
port_info = asyncio.run(stream.get_douyin_stream_url(json_data, record_quality, proxy_address))
if not port_info:
return False, None, None
if not port_info.get("is_live"):
return False, None, port_info
real_url = select_source_url(url, port_info)
return True, real_url, port_info
except Exception as e:
logger.debug(f"探测抖音直播失败: {e}")
return False, None, None
def find_next_live_source(current_url: str | None, proxy_address: str | None, record_quality: str | None) -> tuple[str, dict] | tuple[None, None]:
"""
从 URL_config.ini 找到第一条【正在直播】且 != current_url 的抖音源
返回 (real_url, port_info);找不到返回 (None, None)
"""
items = read_all_urls_from_list(url_config_file)
for (url, _anchor_hint) in items:
if current_url and url == current_url:
continue
is_live, real_url, port_info = probe_douyin_live_and_stream(url, proxy_address, record_quality)
if is_live and real_url:
return real_url, port_info
return None, None
def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None):
node = (ingest or "a").strip().lower()
node = node if node.isalpha() and len(node) == 1 else "a"
scheme = "rtmps" if secure else "rtmp"
host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com"
return f"{scheme}://{host}/live2/{key}"
def _can_copy_h264_aac(port_info: dict) -> bool:
"""
可选:根据你前面已解析到的源信息判断是否允许 copy。
如果你没有ffprobe这里保守返回False若你在port_info里塞了codec信息按需判断。
"""
# 示例:只有在明确知道是 h264 + aac 时才放行
v = (port_info or {}).get("vcodec", "").lower()
a = (port_info or {}).get("acodec", "").lower()
if not v or not a:
return False
if v != "h264":
return False
# YouTube 推荐 44.1k/48k立体声 OK
return a in ("aac", "mp4a")
version = "v4.0.6"
platforms = ("\n国内站点:抖音|快手|虎牙|斗鱼|YY|B站|小红书|bigo|blued|网易CC|千度热播|猫耳FM|Look|TwitCasting|百度|微博|"
"酷狗|花椒|流星|Acfun|畅聊|映客|音播|知乎|嗨秀|VV星球|17Live|浪Live|漂漂|六间房|乐嗨|花猫|淘宝|京东|咪咕|连接|来秀"
"\n海外站点TikTok|SOOP|PandaTV|WinkTV|FlexTV|PopkonTV|TwitchTV|LiveMe|ShowRoom|CHZZK|Shopee|"
"Youtube|Faceit|Picarto")
recording = set()
error_count = 0
pre_max_request = 10
max_request_lock = threading.Lock()
error_window = []
error_window_size = 10
error_threshold = 5
monitoring = 0
running_list = []
url_tuples_list = []
url_comments = []
text_no_repeat_url = []
create_var = locals()
first_start = True
exit_recording = False
need_update_line_list = []
first_run = True
not_record_list = []
start_display_time = datetime.datetime.now()
global_proxy = False
recording_time_list = {}
script_path = os.path.split(os.path.realpath(sys.argv[0]))[0]
config_file = f'{script_path}/config/config.ini'
url_config_file = f'{script_path}/config/URL_config.ini'
backup_dir = f'{script_path}/backup_config'
text_encoding = 'utf-8-sig'
rstr = r"[\/\\\:\*\?\"\<\>\|&#.。, ~!· ]"
default_path = f'{script_path}/downloads'
os.makedirs(default_path, exist_ok=True)
file_update_lock = threading.Lock()
os_type = os.name
clear_command = "cls" if os_type == 'nt' else "clear"
color_obj = utils.Color()
os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
def signal_handler(_signal, _frame):
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
def _fmt_td(td: datetime.timedelta) -> str:
# 统一格式HH:MM:SS超过24h也按总小时数显示
total = int(td.total_seconds())
if total < 0:
total = 0
h = total // 3600
m = (total % 3600) // 60
s = total % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _hr(char: str = "") -> str:
cols = shutil.get_terminal_size((100, 20)).columns
return char * max(20, cols)
def display_info() -> None:
"""控制台【直播动态】面板(定时清屏重绘)"""
global start_display_time
time.sleep(1.0) # 稍等程序其余线程初始化
start_display_time = datetime.datetime.now()
while True:
try:
# 清屏
if Path(sys.executable).name != 'pythonw.exe':
os.system(clear_command)
now_dt = datetime.datetime.now()
now_str = now_dt.strftime("%Y-%m-%d %H:%M:%S")
# 顶部抬头
print("【直播动态】")
print(_hr())
# 统计区
stat_parts = []
stat_parts.append(f"监测中: {monitoring}")
stat_parts.append(f"并发线程: {max_request}")
stat_parts.append(f"代理直播: {'' if use_proxy else ''}")
stat_parts.append(f"分段: {f'{split_time}s' if split_video_by_time else ''}")
if create_time_file:
stat_parts.append("时间字幕: 是")
stat_parts.append(f"质量: {video_record_quality}")
stat_parts.append(f"格式: {video_save_type}")
stat_parts.append(f"瞬时错误: {error_count}")
stat_line = " | ".join(stat_parts)
print(stat_line)
print(f"当前时间: {now_str}")
print(_hr())
# 列表区
if not recording:
if monitoring == 0:
print("暂无监测任务。")
else:
print(f"暂无正在直播的直播。下次检测间隔:{delay_default}")
else:
no_repeat = list(set(recording))
print(f"正在直播:{len(no_repeat)}")
print(_hr("·"))
# 标题行
print(f"{'序号/主播名':<28} {'清晰度':<6} {'开始时间':<19} {'已直播':<10}")
print(_hr("·"))
for name in sorted(no_repeat):
start_time, qa = recording_time_list.get(name, (now_dt, "未知"))
elapsed = _fmt_td(now_dt - start_time)
start_str = start_time.strftime("%H:%M:%S") if isinstance(start_time, datetime.datetime) else "--:--:--"
# name 可能很长,截断以适配终端宽度
cols = shutil.get_terminal_size((100, 20)).columns
name_col = 28
# 留余量28 + 1 + 6 + 1 + 19 + 1 + 10 ≈ 65 列基础宽度
disp_name = name if len(name) <= name_col else (name[:name_col-2] + "")
print(f"{disp_name:<28} {str(qa):<6} {start_str:<19} {elapsed:<10}")
print(_hr())
# 运行时长(可选)
uptime = _fmt_td(now_dt - start_display_time)
print(f"已运行时长:{uptime}")
# 降低刷新频率,避免刷屏
sys.stdout.flush()
time.sleep(2.0)
except KeyboardInterrupt:
# 主线程Ctrl+C时优雅退出这个展示线程
break
except Exception as e:
# 面板渲染异常不能影响主流程
try:
logger.error(f"显示面板异常: {e} | 行: {getattr(e, '__traceback__', None).tb_lineno if getattr(e, '__traceback__', None) else '未知'}")
except Exception:
pass
time.sleep(1.0)
def update_file(file_path: str, old_str: str, new_str: str, start_str: str = None) -> str | None:
if old_str == new_str and start_str is None:
return old_str
with file_update_lock:
file_data = []
with open(file_path, "r", encoding=text_encoding) as f:
try:
for text_line in f:
if old_str in text_line:
text_line = text_line.replace(old_str, new_str)
if start_str:
text_line = f'{start_str}{text_line}'
if text_line not in file_data:
file_data.append(text_line)
except RuntimeError as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
if ini_URL_content:
with open(file_path, "w", encoding=text_encoding) as f2:
f2.write(ini_URL_content)
return old_str
if file_data:
with open(file_path, "w", encoding=text_encoding) as f:
f.write(''.join(file_data))
return new_str
def delete_line(file_path: str, del_line: str, delete_all: bool = False) -> None:
with file_update_lock:
with open(file_path, 'r+', encoding=text_encoding) as f:
lines = f.readlines()
f.seek(0)
f.truncate()
skip_line = False
for txt_line in lines:
if del_line in txt_line:
if delete_all or not skip_line:
skip_line = True
continue
else:
skip_line = False
f.write(txt_line)
def get_startup_info(system_type: str):
if system_type == 'nt':
startup_info = subprocess.STARTUPINFO()
startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
else:
startup_info = None
return startup_info
def segment_video(converts_file_path: str, segment_save_file_path: str, segment_format: str, segment_time: str,
is_original_delete: bool = True) -> None:
try:
if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
ffmpeg_command = [
"ffmpeg",
"-i", converts_file_path,
"-c:v", "copy",
"-c:a", "copy",
"-map", "0",
"-f", "segment",
"-segment_time", segment_time,
"-segment_format", segment_format,
"-reset_timestamps", "1",
"-movflags", "+frag_keyframe+empty_moov",
segment_save_file_path,
]
_output = subprocess.check_output(
ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
)
if is_original_delete:
time.sleep(1)
if os.path.exists(converts_file_path):
os.remove(converts_file_path)
except subprocess.CalledProcessError as e:
logger.error(f'Error occurred during conversion: {e}')
except Exception as e:
logger.error(f'An unknown error occurred: {e}')
def converts_mp4(converts_file_path: str, is_original_delete: bool = True) -> None:
try:
if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
if converts_to_h264:
color_obj.print_colored("正在转码为MP4格式并重新编码为h264\n", color_obj.YELLOW)
ffmpeg_command = [
"ffmpeg", "-i", converts_file_path,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-vf", "format=yuv420p",
"-c:a", "copy",
"-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4",
]
else:
color_obj.print_colored("正在转码为MP4格式\n", color_obj.YELLOW)
ffmpeg_command = [
"ffmpeg", "-i", converts_file_path,
"-c:v", "copy",
"-c:a", "copy",
"-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4",
]
_output = subprocess.check_output(
ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
)
if is_original_delete:
time.sleep(1)
if os.path.exists(converts_file_path):
os.remove(converts_file_path)
except subprocess.CalledProcessError as e:
logger.error(f'Error occurred during conversion: {e}')
except Exception as e:
logger.error(f'An unknown error occurred: {e}')
def converts_m4a(converts_file_path: str, is_original_delete: bool = True) -> None:
try:
if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
_output = subprocess.check_output([
"ffmpeg", "-i", converts_file_path,
"-n", "-vn",
"-c:a", "aac", "-bsf:a", "aac_adtstoasc", "-ab", "320k",
converts_file_path.rsplit('.', maxsplit=1)[0] + ".m4a",
], stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type))
if is_original_delete:
time.sleep(1)
if os.path.exists(converts_file_path):
os.remove(converts_file_path)
except subprocess.CalledProcessError as e:
logger.error(f'Error occurred during conversion: {e}')
except Exception as e:
logger.error(f'An unknown error occurred: {e}')
def generate_subtitles(record_name: str, ass_filename: str, sub_format: str = 'srt') -> None:
index_time = 0
today = datetime.datetime.now()
re_datatime = today.strftime('%Y-%m-%d %H:%M:%S')
def transform_int_to_time(seconds: int) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
return f"{h:02d}:{m:02d}:{s:02d}"
while True:
index_time += 1
txt = str(index_time) + "\n" + transform_int_to_time(index_time) + ',000 --> ' + transform_int_to_time(
index_time + 1) + ',000' + "\n" + str(re_datatime) + "\n\n"
with open(f"{ass_filename}.{sub_format.lower()}", 'a', encoding=text_encoding) as f:
f.write(txt)
if record_name not in recording:
return
time.sleep(1)
today = datetime.datetime.now()
re_datatime = today.strftime('%Y-%m-%d %H:%M:%S')
def adjust_max_request() -> None:
global max_request, error_count, pre_max_request, error_window
preset = max_request
while True:
time.sleep(5)
with max_request_lock:
if error_window:
error_rate = sum(error_window) / len(error_window)
else:
error_rate = 0
if error_rate > error_threshold:
max_request = max(1, max_request - 1)
elif error_rate < error_threshold / 2 and max_request < preset:
max_request += 1
else:
pass
if pre_max_request != max_request:
pre_max_request = max_request
print(f"\r同一时间访问网络的线程数动态改为 {max_request}")
error_window.append(error_count)
if len(error_window) > error_window_size:
error_window.pop(0)
error_count = 0
def push_message(record_name: str, live_url: str, content: str) -> None:
msg_title = push_message_title.strip() or "直播间状态更新通知"
push_functions = {
'微信': lambda: xizhi(xizhi_api_url, msg_title, content),
'钉钉': lambda: dingtalk(dingtalk_api_url, content, dingtalk_phone_num, dingtalk_is_atall),
'邮箱': lambda: send_email(
email_host, login_email, email_password, sender_email, sender_name,
to_email, msg_title, content, smtp_port, open_smtp_ssl
),
'TG': lambda: tg_bot(tg_chat_id, tg_token, content),
'BARK': lambda: bark(
bark_msg_api, title=msg_title, content=content, level=bark_msg_level, sound=bark_msg_ring
),
'NTFY': lambda: ntfy(
ntfy_api, title=msg_title, content=content, tags=ntfy_tags, action_url=live_url, email=ntfy_email
),
'PUSHPLUS': lambda: pushplus(pushplus_token, msg_title, content),
}
for platform, func in push_functions.items():
if platform in live_status_push.upper():
try:
result = func()
print(f'提示信息:已经将[{record_name}]直播状态消息推送至你的{platform},'
f' 成功{len(result["success"])}, 失败{len(result["error"])}')
except Exception as e:
color_obj.print_colored(f"直播消息推送到{platform}失败: {e}", color_obj.RED)
def run_script(command: str) -> None:
try:
process = subprocess.Popen(
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=get_startup_info(os_type)
)
stdout, stderr = process.communicate()
stdout_decoded = stdout.decode('utf-8')
stderr_decoded = stderr.decode('utf-8')
if stdout_decoded.strip():
print(stdout_decoded)
if stderr_decoded.strip():
print(stderr_decoded)
except PermissionError as e:
logger.error(e)
logger.error('脚本无执行权限!, 若是Linux环境, 请先执行:chmod +x your_script.sh 授予脚本可执行权限')
except OSError as e:
logger.error(e)
logger.error('Please add `#!/bin/bash` at the beginning of your bash script file.')
def clear_record_info(record_name: str, record_url: str) -> None:
global monitoring
recording.discard(record_name)
if record_url in url_comments and record_url in running_list:
running_list.remove(record_url)
monitoring -= 1
color_obj.print_colored(f"[{record_name}]已经从直播列表中移除\n", color_obj.YELLOW)
def direct_download_stream(source_url: str, save_path: str, record_name: str, live_url: str, platform: str) -> bool:
try:
with open(save_path, 'wb') as f:
client = httpx.Client(timeout=None)
headers = {}
header_params = get_record_headers(platform, live_url)
if header_params:
key, value = header_params.split(":", 1)
headers[key] = value
with client.stream('GET', source_url, headers=headers, follow_redirects=True) as response:
if response.status_code != 200:
logger.error(f"请求直播流失败,状态码: {response.status_code}")
return False
downloaded = 0
chunk_size = 1024 * 16
for chunk in response.iter_bytes(chunk_size):
if live_url in url_comments or exit_recording:
color_obj.print_colored(f"[{record_name}]直播时已被注释或请求停止,下载中断", color_obj.YELLOW)
clear_record_info(record_name, live_url)
return False
if chunk:
f.write(chunk)
downloaded += len(chunk)
print()
return True
except Exception as e:
logger.error(f"FLV下载错误: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
return False
import asyncio
import datetime
import subprocess
async def check_subprocess_with_heartbeat(record_name: str,
record_url: str,
ffmpeg_command: list,
save_type: str,
script_command: str | None = None,
heartbeat_timeout: int = 10) -> bool:
"""
启动 ffmpeg 子进程并进行心跳检测:
- 每 heartbeat_timeout 秒内必须有输出,否则认为卡死,直接 kill。
- 进程退出 returncode=0 正常,否则异常。
"""
process = await asyncio.create_subprocess_exec(
*ffmpeg_command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
last_heartbeat = datetime.datetime.now()
async def read_stream(stream, name):
nonlocal last_heartbeat
async for line in stream:
decoded = line.decode(errors="ignore").strip()
if decoded:
last_heartbeat = datetime.datetime.now()
# 可根据需要过滤/解析码率、fps
print(f"[{record_name}][{name}] {decoded}")
# 并行读取 stdout 和 stderr
asyncio.create_task(read_stream(process.stdout, "stdout"))
asyncio.create_task(read_stream(process.stderr, "stderr"))
while True:
# 检查进程是否退出
if process.returncode is not None:
break
# 检查心跳超时
now = datetime.datetime.now()
if (now - last_heartbeat).total_seconds() > heartbeat_timeout:
print(f"[心跳超时] {record_name}: {heartbeat_timeout}s 无数据,强制重启 ffmpeg")
process.kill()
await process.wait()
return False
await asyncio.sleep(2)
return process.returncode == 0
def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, save_type: str,
script_command: str | None = None) -> bool:
save_file_path = ffmpeg_command[-1]
process = subprocess.Popen(
ffmpeg_command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
)
subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
subs_thread_name = f'subs_{Path(subs_file_path).name}'
if create_time_file and not split_video_by_time and '音频' not in save_type:
create_var[subs_thread_name] = threading.Thread(
target=generate_subtitles, args=(record_name, subs_file_path)
)
create_var[subs_thread_name].daemon = True
create_var[subs_thread_name].start()
while process.poll() is None:
if record_url in url_comments or exit_recording:
color_obj.print_colored(f"[{record_name}]直播时已被注释,本条线程将会退出", color_obj.YELLOW)
clear_record_info(record_name, record_url)
# process.terminate()
if os.name == 'nt':
if process.stdin:
process.stdin.write(b'q')
process.stdin.close()
else:
process.send_signal(signal.SIGINT)
process.wait()
return True
time.sleep(1)
return_code = process.returncode
stop_time = time.strftime('%Y-%m-%d %H:%M:%S')
if return_code == 0:
if converts_to_mp4 and save_type == 'TS':
if split_video_by_time:
file_paths = utils.get_file_paths(os.path.dirname(save_file_path))
prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0]
for path in file_paths:
if prefix in path:
threading.Thread(target=converts_mp4, args=(path, delete_origin_file)).start()
else:
threading.Thread(target=converts_mp4, args=(save_file_path, delete_origin_file)).start()
print(f"\n{record_name} {stop_time} 直播直播完成\n")
if script_command:
logger.debug("开始执行脚本命令!")
if "python" in script_command:
params = [
f'--record_name "{record_name}"',
f'--save_file_path "{save_file_path}"',
f'--save_type {save_type}',
f'--split_video_by_time {split_video_by_time}',
f'--converts_to_mp4 {converts_to_mp4}',
]
else:
params = [
f'"{record_name.split(" ", maxsplit=1)[-1]}"',
f'"{save_file_path}"',
save_type,
f'split_video_by_time:{split_video_by_time}',
f'converts_to_mp4:{converts_to_mp4}'
]
script_command = script_command.strip() + ' ' + ' '.join(params)
run_script(script_command)
logger.debug("脚本命令执行结束!")
else:
color_obj.print_colored(f"\n{record_name} {stop_time} 直播直播出错,返回码: {return_code}\n", color_obj.RED)
recording.discard(record_name)
return False
def clean_name(input_text):
cleaned_name = re.sub(rstr, "_", input_text.strip()).strip('_')
cleaned_name = cleaned_name.replace("", "(").replace("", ")")
if clean_emoji:
cleaned_name = utils.remove_emojis(cleaned_name, '_').strip('_')
return cleaned_name or '空白昵称'
def get_quality_code(qn):
QUALITY_MAPPING = {
"原画": "OD",
"蓝光": "BD",
"超清": "UHD",
"高清": "HD",
"标清": "SD",
"流畅": "LD"
}
return QUALITY_MAPPING.get(qn)
def get_record_headers(platform, live_url):
live_domain = '/'.join(live_url.split('/')[0:3])
record_headers = {
'PandaTV': 'origin:https://www.pandalive.co.kr',
'WinkTV': 'origin:https://www.winktv.co.kr',
'PopkonTV': 'origin:https://www.popkontv.com',
'FlexTV': 'origin:https://www.flextv.co.kr',
'千度热播': 'referer:https://qiandurebo.com',
'17Live': 'referer:https://17.live/en/live/6302408',
'浪Live': 'referer:https://www.lang.live',
'shopee': f'origin:{live_domain}',
'Blued直播': 'referer:https://app.blued.cn'
}
return record_headers.get(platform)
def is_flv_preferred_platform(link):
return any(i in link for i in ["douyin", "tiktok"])
def select_source_url(link, stream_info):
if is_flv_preferred_platform(link):
codec = utils.get_query_params(stream_info.get('flv_url'), "codec")
if codec and codec[0] == 'h265':
logger.warning("FLV is not supported for h265 codec, use HLS source instead")
else:
return stream_info.get('flv_url')
return stream_info.get('record_url')
def start_record(url_data: tuple, count_variable: int = -1) -> None:
global error_count
while True:
try:
record_finished = False
run_once = False
start_pushed = False
new_record_url = ''
count_time = time.time()
retry = 0
record_quality_zh, record_url, anchor_name = url_data
record_quality = get_quality_code(record_quality_zh)
proxy_address = proxy_addr
platform = '未知平台'
live_domain = '/'.join(record_url.split('/')[0:3])
if proxy_addr:
proxy_address = None
for platform in enable_proxy_platform_list:
if platform and platform.strip() in record_url:
proxy_address = proxy_addr
break
if not proxy_address:
if extra_enable_proxy_platform_list:
for pt in extra_enable_proxy_platform_list:
if pt and pt.strip() in record_url:
proxy_address = proxy_addr_bak or None
# print(f'\r代理地址:{proxy_address}')
# print(f'\r全局代理:{global_proxy}')
while True:
try:
port_info = []
if record_url.find("douyin.com/") > -1:
platform = '抖音直播'
with semaphore:
if 'v.douyin.com' not in record_url and '/user/' not in record_url:
json_data = asyncio.run(spider.get_douyin_stream_data(
url=record_url,
proxy_addr=proxy_address,
cookies=dy_cookie))
else:
json_data = asyncio.run(spider.get_douyin_app_stream_data(
url=record_url,
proxy_addr=proxy_address,
cookies=dy_cookie))
port_info = asyncio.run(
stream.get_douyin_stream_url(json_data, record_quality, proxy_address))
elif record_url.find("https://www.tiktok.com/") > -1:
platform = 'TikTok直播'
with semaphore:
if global_proxy or proxy_address:
json_data = asyncio.run(spider.get_tiktok_stream_data(
url=record_url,
proxy_addr=proxy_address,
cookies=tiktok_cookie))
port_info = asyncio.run(
stream.get_tiktok_stream_url(json_data, record_quality, proxy_address))
else:
logger.error("错误信息: 网络异常请检查网络是否能正常访问TikTok平台")
elif record_url.find(".m3u8") > -1 or record_url.find(".flv") > -1:
platform = '自定义直播直播'
port_info = {
"anchor_name": platform + '_' + str(uuid.uuid4())[:8],
"is_live": True,
"record_url": record_url,
}
if '.flv' in record_url:
port_info['flv_url'] = record_url
else:
port_info['m3u8_url'] = record_url
else:
logger.error(f'{record_url} {platform}直播地址')
return
if anchor_name:
if '主播:' in anchor_name:
anchor_split: list = anchor_name.split('主播:')
if len(anchor_split) > 1 and anchor_split[1].strip():
anchor_name = anchor_split[1].strip()
else:
anchor_name = port_info.get("anchor_name", '')
else:
anchor_name = port_info.get("anchor_name", '')
if not port_info.get("anchor_name", ''):
print(f'序号{count_variable} 网址内容获取失败,进行重试中...获取失败的地址是:{url_data}')
with max_request_lock:
error_count += 1
error_window.append(1)
else:
anchor_name = clean_name(anchor_name)
record_name = f'序号{count_variable} {anchor_name}'
if record_url in url_comments:
print(f"[{anchor_name}]已被注释,本条线程将会退出")
clear_record_info(record_name, record_url)
return
if not url_data[-1] and run_once is False:
if new_record_url:
need_update_line_list.append(
f'{record_url}|{new_record_url},主播: {anchor_name.strip()}')
not_record_list.append(new_record_url)
else:
need_update_line_list.append(f'{record_url}|{record_url},主播: {anchor_name.strip()}')
run_once = True
push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S')
if port_info['is_live'] is False:
print(f"\r{record_name} 等待直播... ")
if start_pushed:
if over_show_push:
push_content = "直播间状态更新:[直播间名称] 直播已结束!时间:[时间]"
if over_push_message_text:
push_content = over_push_message_text
push_content = (push_content.replace('[直播间名称]', record_name).
replace('[时间]', push_at))
threading.Thread(
target=push_message,
args=(record_name, record_url, push_content.replace(r'\n', '\n')),
daemon=True
).start()
start_pushed = False
else:
content = f"\r{record_name} 正在直播中..."
print(content)
if live_status_push and not start_pushed:
if begin_show_push:
push_content = "直播间状态更新:[直播间名称] 正在直播中,时间:[时间]"
if begin_push_message_text:
push_content = begin_push_message_text
push_content = (push_content.replace('[直播间名称]', record_name).
replace('[时间]', push_at))
threading.Thread(
target=push_message,
args=(record_name, record_url, push_content.replace(r'\n', '\n')),
daemon=True
).start()
start_pushed = True
if disable_record:
time.sleep(push_check_seconds)
continue
real_url = select_source_url(record_url, port_info)
full_path = f'{default_path}/{platform}'
if real_url:
now = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
live_title = port_info.get('title')
title_in_name = ''
if live_title:
live_title = clean_name(live_title)
title_in_name = live_title + '_' if filename_by_title else ''
try:
if len(video_save_path) > 0:
if not video_save_path.endswith(('/', '\\')):
full_path = f'{video_save_path}/{platform}'
else:
full_path = f'{video_save_path}{platform}'
full_path = full_path.replace("\\", '/')
if folder_by_author:
full_path = f'{full_path}/{anchor_name}'
if folder_by_time:
full_path = f'{full_path}/{now[:10]}'
if folder_by_title and port_info.get('title'):
if folder_by_time:
full_path = f'{full_path}/{live_title}_{anchor_name}'
else:
full_path = f'{full_path}/{now[:10]}_{live_title}'
if not os.path.exists(full_path):
os.makedirs(full_path)
except Exception as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
if platform != '自定义直播直播':
if enable_https_recording and real_url.startswith("http://"):
real_url = real_url.replace("http://", "https://")
http_record_list = ['shopee', "migu"]
if platform in http_record_list:
real_url = real_url.replace("https://", "http://")
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")
rw_timeout = "15000000"
analyzeduration = "20000000"
probesize = "10000000"
bufsize = "8000k"
max_muxing_queue_size = "1024"
for pt_host in overseas_platform_host:
if pt_host in record_url:
rw_timeout = "50000000"
analyzeduration = "40000000"
probesize = "20000000"
bufsize = "15000k"
max_muxing_queue_size = "2048"
break
ffmpeg_command = [
'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", real_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"
]
headers = get_record_headers(platform, record_url)
if headers:
ffmpeg_command.insert(11, "-headers")
ffmpeg_command.insert(12, headers)
if proxy_address:
ffmpeg_command.insert(1, "-http_proxy")
ffmpeg_command.insert(2, proxy_address)
recording.add(record_name)
start_record_time = datetime.datetime.now()
recording_time_list[record_name] = [start_record_time, record_quality_zh]
rec_info = f"\r{anchor_name} 准备开始直播视频: {full_path}"
if show_url:
re_plat = ('WinkTV', 'PandaTV', 'ShowRoom', 'CHZZK', 'Youtube')
if platform in re_plat:
logger.info(
f"{platform} | {anchor_name} | 直播源地址: {port_info.get('m3u8_url')}")
else:
logger.info(
f"{platform} | {anchor_name} | 直播源地址: {real_url}")
only_flv_record = False
only_flv_platform_list = ['shopee', '花椒直播']
if platform in only_flv_platform_list:
logger.debug(f"提示: {platform} 将强制使用FLV格式直播")
only_flv_record = True
only_audio_record = False
only_audio_platform_list = ['猫耳FM直播', 'Look直播']
if platform in only_audio_platform_list:
only_audio_record = True
record_save_type = video_save_type
if is_flv_preferred_platform(record_url) and port_info.get('flv_url'):
codec = utils.get_query_params(port_info['flv_url'], "codec")
if codec and codec[0] == 'h265':
logger.warning("FLV is not supported for h265 codec, use TS format instead")
record_save_type = "TS"
if only_audio_record or any(i in record_save_type for i in ['MP3', 'M4A']):
try:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
extension = "mp3" if "m4a" not in record_save_type.lower() else "m4a"
name_format = "_%03d" if split_video_by_time else ""
save_file_path = (f"{full_path}/{anchor_name}_{title_in_name}{now}"
f"{name_format}.{extension}")
if split_video_by_time:
print(f'\r{anchor_name} 准备开始直播音频: {save_file_path}')
if "MP3" in record_save_type:
command = [
"-map", "0:a",
"-c:a", "libmp3lame",
"-ab", "320k",
"-f", "segment",
"-segment_time", split_time,
"-reset_timestamps", "1",
save_file_path,
]
else:
command = [
"-map", "0:a",
"-c:a", "aac",
"-bsf:a", "aac_adtstoasc",
"-ab", "320k",
"-f", "segment",
"-segment_time", split_time,
"-segment_format", 'mpegts',
"-reset_timestamps", "1",
save_file_path,
]
else:
if "MP3" in record_save_type:
command = [
"-map", "0:a",
"-c:a", "libmp3lame",
"-ab", "320k",
save_file_path,
]
else:
command = [
"-map", "0:a",
"-c:a", "aac",
"-bsf:a", "aac_adtstoasc",
"-ab", "320k",
"-movflags", "+faststart",
save_file_path,
]
ffmpeg_command.extend(command)
# comment_end = check_subprocess(
# record_name,
# record_url,
# ffmpeg_command,
# record_save_type,
# custom_script
# )
comment_end = asyncio.run(
check_subprocess_with_heartbeat(
record_name, record_url, ffmpeg_command, record_save_type, custom_script, heartbeat_timeout=15
)
)
if comment_end:
return
except subprocess.CalledProcessError as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
if only_flv_record:
logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}")
filename = anchor_name + f'_{title_in_name}' + now + '.flv'
save_file_path = f'{full_path}/{filename}'
print(f'{rec_info}/{filename}')
subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
subs_thread_name = f'subs_{Path(subs_file_path).name}'
if create_time_file:
create_var[subs_thread_name] = threading.Thread(
target=generate_subtitles, args=(record_name, subs_file_path)
)
create_var[subs_thread_name].daemon = True
create_var[subs_thread_name].start()
try:
flv_url = port_info.get('flv_url')
if flv_url:
recording.add(record_name)
start_record_time = datetime.datetime.now()
recording_time_list[record_name] = [start_record_time, record_quality_zh]
download_success = direct_download_stream(
flv_url, save_file_path, record_name, record_url, platform
)
if download_success:
record_finished = True
print(f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播直播完成\n")
recording.discard(record_name)
else:
logger.debug("未找到FLV直播流跳过直播")
except Exception as e:
clear_record_info(record_name, record_url)
color_obj.print_colored(
f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播直播出错,请检查网络\n",
color_obj.RED)
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
elif record_save_type == "MP4直播":
print('---mp4------------')
filename = anchor_name + f'_{title_in_name}' + now + ".mp4"
print(f'{rec_info}/{filename}')
save_file_path = full_path + '/' + filename
try:
print('------------split_video_by_time------------------')
if split_video_by_time:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4"
command = [
"-c:v", "copy",
"-c:a", "aac",
"-map", "0",
"-f", "segment",
"-segment_time", split_time,
"-segment_format", "mp4",
"-reset_timestamps", "1",
"-movflags", "+frag_keyframe+empty_moov",
save_file_path,
]
else:
command = [
"-map", "0",
"-c:v", "copy",
"-c:a", "copy",
"-f", "mp4",
save_file_path,
]
ffmpeg_command.extend(command)
comment_end = check_subprocess(
record_name,
record_url,
ffmpeg_command,
record_save_type,
custom_script
)
if comment_end:
return
except subprocess.CalledProcessError as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
elif record_save_type == "MP4推流":
try:
print('youtube------------------')
# === 从 config.ini 读取 ===
key = _yt_get("youtube", "key", fallback="", cast=str)
if not key:
logger.error("错误: config.ini[youtube].key 未配置")
return
secure = _yt_get("youtube", "secure", fallback=False, cast=bool)
ingest = _yt_get("youtube", "ingest", fallback="a", cast=str)
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 = _build_youtube_rtmp_url(key, secure, ingest)
# === 输出端编码参数 ===
# 默认转码保证兼容若可安全copy再切换
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",
]
# 分辨率(如果填写,做等比缩放 + 留黑边)
# 例如 1280x720 -> scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2
vf_chain = []
if res_str:
try:
w, h = map(int, res_str.lower().split("x"))
vf_chain.append(
f"scale={w}:{h}:force_original_aspect_ratio=decrease"
)
vf_chain.append(
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2"
)
vf_chain.append("format=yuv420p")
except Exception:
logger.warning(f"警告: resolution '{res_str}' 非法,已忽略")
# 如果允许copy且源确实是 h264+aac且未强制改分辨率则走copy省CPU
use_copy = False
if allow_copy and not vf_chain and _can_copy_h264_aac(port_info):
use_copy = True
v_args = ["-c:v", "copy"]
a_args = ["-c:a", "copy"]
# 组装输出端参数
command = ["-map", "0"]
if vf_chain:
command += ["-vf", ",".join(vf_chain)]
command += v_args + a_args + ["-f", "flv", out_url]
# 不污染后续循环:复制一份,避免多次 extend 叠加
run_cmd = list(ffmpeg_command) + command
# 注意:不要把 key 打进日志
logger.info(f"{platform} | {anchor_name} | 准备推流 YouTube安全参数已加载")
comment_end = check_subprocess(
record_name,
record_url,
run_cmd,
"YTLIVE",
custom_script
)
if comment_end:
return
except subprocess.CalledProcessError as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
elif record_save_type == "MP4":
try:
print('youtube------------------')
# === 读取 YouTube 推流配置(你已在文件头加载了 _config===
key_raw = _yt_get("youtube", "key", fallback="", cast=str)
key = (key_raw or "").strip()
if not key:
logger.error("错误: youtube.ini[youtube].key 未配置或为空")
return
secure = _yt_get("youtube", "secure", fallback=True, cast=bool) # 建议默认 True=rtmps
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)
# 出口 URLrtmps 优先,失败时 check_subprocess 返回后再探测其他节点/协议)
out_url = _build_youtube_rtmp_url(key, secure, ingest)
# === 输出端参数 ===
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}' 非法,已忽略")
# 如果允许copy且源确实是 h264+aac且未强制改分辨率则走copy省CPU
use_copy = False
if allow_copy and not vf_chain and _can_copy_h264_aac(port_info):
use_copy = True
v_args = ["-c:v", "copy"]
a_args = ["-c:a", "copy"]
# 输出端公共部分(注意 flvflags 与 rtmp_live
base_cmd = ["-map", "0"]
if vf_chain:
base_cmd += ["-vf", ",".join(vf_chain)]
base_cmd += v_args + a_args + [
"-flvflags", "no_duration_filesize",
"-rtmp_live", "live",
"-f", "flv", out_url
]
# === Failover 主循环:当前源挂了,立刻找下一路在播,继续推到同一 out_url ===
current_input_url = real_url
current_source_url = record_url # 记原始页面URL用于排除自己
while True:
# 组装一次完整命令:输入端(你的 ffmpeg_command) + 输出端(base_cmd)
run_cmd = list(ffmpeg_command) # 不污染原始
# 替换输入URL确保 -re -i 的后面跟的是当前 real_url
try:
# 找到 "-re", "-i", <url> 这三元组中的 <url> 并替换
idx = run_cmd.index("-i")
run_cmd[idx + 1] = current_input_url
except ValueError:
# 兜底:直接塞在末尾前
run_cmd += ["-re", "-i", current_input_url]
run_cmd += base_cmd
logger.info(f"{platform} | {anchor_name} | 推流启动,源={current_input_url[:80]}...")
comment_end = check_subprocess(
record_name,
current_source_url,
run_cmd,
"YTLIVE",
custom_script
)
# 如果是被用户注释/请求停止check_subprocess 会 True直接退出
if comment_end:
return
# FFmpeg 退出:源可能下播/断线。快速找下一路在播
logger.info("当前源已结束或异常,尝试切换到下一路直播源...")
next_real, next_port = find_next_live_source(current_source_url, proxy_address, record_quality)
if not next_real:
logger.warning("未找到可用的下一路直播源,结束 YouTube 推流。")
break
# 替换为下一路
current_input_url = next_real
port_info = next_port
# 保持同一个 record_name/anchor_name如需也替换显示名可以在这里更新
continue # 立即重启下一路
# 主循环退出:没有可切的源
return
except subprocess.CalledProcessError as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
else:
if split_video_by_time:
now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
filename = anchor_name + f'_{title_in_name}' + now + ".ts"
print(f'{rec_info}/{filename}')
try:
save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.ts"
command = [
"-c:v", "copy",
"-c:a", "copy",
"-map", "0",
"-f", "segment",
"-segment_time", split_time,
"-segment_format", 'mpegts',
"-reset_timestamps", "1",
save_file_path,
]
ffmpeg_command.extend(command)
comment_end = check_subprocess(
record_name,
record_url,
ffmpeg_command,
record_save_type,
custom_script
)
if comment_end:
if converts_to_mp4:
file_paths = utils.get_file_paths(os.path.dirname(save_file_path))
prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0]
for path in file_paths:
if prefix in path:
try:
threading.Thread(
target=converts_mp4,
args=(path, delete_origin_file)
).start()
except subprocess.CalledProcessError as e:
logger.error(f"转码失败: {e} ")
return
except subprocess.CalledProcessError as e:
logger.error(
f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
else:
filename = anchor_name + f'_{title_in_name}' + now + ".ts"
print(f'{rec_info}/{filename}')
save_file_path = full_path + '/' + filename
try:
command = [
"-c:v", "copy",
"-c:a", "copy",
"-map", "0",
"-f", "mpegts",
save_file_path,
]
ffmpeg_command.extend(command)
comment_end = check_subprocess(
record_name,
record_url,
ffmpeg_command,
record_save_type,
custom_script
)
if comment_end:
threading.Thread(
target=converts_mp4, args=(save_file_path, delete_origin_file)
).start()
return
except subprocess.CalledProcessError as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
count_time = time.time()
except Exception as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
num = random.randint(-5, 5) + delay_default
if num < 0:
num = 0
x = num
if error_count > 20:
x = x + 60
color_obj.print_colored("\r瞬时错误太多,延迟加60秒", color_obj.YELLOW)
# 这里是.如果直播结束后,循环时间会暂时变成30s后检测一遍. 这样一定程度上防止主播卡顿造成少录
# 当30秒过后检测一遍后. 会回归正常设置的循环秒数
if record_finished:
count_time_end = time.time() - count_time
if count_time_end < 60:
x = 30
record_finished = False
else:
x = num
# 这里是正常循环
while x:
x = x - 1
if loop_time:
print(f'\r{anchor_name}循环等待{x}', end="")
time.sleep(1)
if loop_time:
print('\r检测直播间中...', end="")
except Exception as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock:
error_count += 1
error_window.append(1)
time.sleep(2)
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)
timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
backup_file_name = os.path.basename(file_path) + '_' + timestamp
backup_file_path = os.path.join(backup_dir_path, backup_file_name).replace("\\", "/")
shutil.copy2(file_path, backup_file_path)
files = os.listdir(backup_dir_path)
_files = [f for f in files if f.startswith(os.path.basename(file_path))]
_files.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir_path, x)))
while len(_files) > limit_counts:
oldest_file = _files[0]
os.remove(os.path.join(backup_dir_path, oldest_file))
_files = _files[1:]
except Exception as e:
logger.error(f'\r备份配置文件 {file_path} 失败:{str(e)}')
def backup_file_start() -> None:
config_md5 = ''
url_config_md5 = ''
while True:
try:
if os.path.exists(config_file):
new_config_md5 = utils.check_md5(config_file)
if new_config_md5 != config_md5:
backup_file(config_file, backup_dir)
config_md5 = new_config_md5
if os.path.exists(url_config_file):
new_url_config_md5 = utils.check_md5(url_config_file)
if new_url_config_md5 != url_config_md5:
backup_file(url_config_file, backup_dir)
url_config_md5 = new_url_config_md5
time.sleep(600)
except Exception as e:
logger.error(f"备份配置文件失败, 错误信息: {e}")
def check_ffmpeg_existence() -> bool:
try:
result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True)
if result.returncode == 0:
lines = result.stdout.splitlines()
version_line = lines[0]
built_line = lines[1]
print(version_line)
print(built_line)
except subprocess.CalledProcessError as e:
logger.error(e)
except FileNotFoundError:
pass
finally:
if check_ffmpeg():
time.sleep(1)
return True
return False
# --------------------------初始化程序-------------------------------------
print("-----------------------------------------------------")
print("| DouyinLiveRecorder |")
print("-----------------------------------------------------")
print(f"版本号: {version}")
print("GitHub: https://github.com/ihmily/DouyinLiveRecorder")
print(f'支持平台: {platforms}')
print('.....................................................')
if not check_ffmpeg_existence():
logger.error("缺少ffmpeg无法进行直播程序退出")
sys.exit(1)
os.makedirs(os.path.dirname(config_file), exist_ok=True)
t3 = threading.Thread(target=backup_file_start, args=(), daemon=True)
t3.start()
utils.remove_duplicate_lines(url_config_file)
def read_config_value(config_parser: configparser.RawConfigParser, section: str, option: str, default_value: Any) \
-> Any:
try:
config_parser.read(config_file, encoding=text_encoding)
if '直播设置' not in config_parser.sections():
config_parser.add_section('直播设置')
if '推送配置' not in config_parser.sections():
config_parser.add_section('推送配置')
if 'Cookie' not in config_parser.sections():
config_parser.add_section('Cookie')
if 'Authorization' not in config_parser.sections():
config_parser.add_section('Authorization')
if '账号密码' not in config_parser.sections():
config_parser.add_section('账号密码')
return config_parser.get(section, option)
except (configparser.NoSectionError, configparser.NoOptionError):
config_parser.set(section, option, str(default_value))
with open(config_file, 'w', encoding=text_encoding) as f:
config_parser.write(f)
return default_value
options = {"": True, "": False}
config = configparser.RawConfigParser()
language = read_config_value(config, '直播设置', 'language(zh_cn/en)', "zh_cn")
skip_proxy_check = options.get(read_config_value(config, '直播设置', '是否跳过代理检测(是/否)', ""), False)
if language and 'en' not in language.lower():
from i18n import translated_print
builtins.print = translated_print
try:
if skip_proxy_check:
global_proxy = True
else:
print('系统代理检测中,请耐心等待...')
response_g = urllib.request.urlopen("https://www.google.com/", timeout=15)
global_proxy = True
print('\r全局/规则网络代理已开启√')
pd = ProxyDetector()
if pd.is_proxy_enabled():
proxy_info = pd.get_proxy_info()
print("System Proxy: http://{}:{}".format(proxy_info.ip, proxy_info.port))
except HTTPError as err:
print(f"HTTP error occurred: {err.code} - {err.reason}")
except URLError:
color_obj.print_colored("INFO未检测到全局/规则网络代理,请检查代理配置(若无需直播海外直播请忽略此条提示)",
color_obj.YELLOW)
except Exception as err:
print("An unexpected error occurred:", err)
while True:
try:
if not os.path.isfile(config_file):
with open(config_file, 'w', encoding=text_encoding) as file:
pass
ini_URL_content = ''
if os.path.isfile(url_config_file):
with open(url_config_file, 'r', encoding=text_encoding) as file:
ini_URL_content = file.read().strip()
if not ini_URL_content.strip():
input_url = input('请输入要直播的主播直播间网址尽量使用PC网页端的直播间地址:\n')
with open(url_config_file, 'w', encoding=text_encoding) as file:
file.write(input_url)
except OSError as err:
logger.error(f"发生 I/O 错误: {err}")
video_save_path = read_config_value(config, '直播设置', '直播保存路径(不填则默认)', "")
folder_by_author = options.get(read_config_value(config, '直播设置', '保存文件夹是否以作者区分', ""), False)
folder_by_time = options.get(read_config_value(config, '直播设置', '保存文件夹是否以时间区分', ""), False)
folder_by_title = options.get(read_config_value(config, '直播设置', '保存文件夹是否以标题区分', ""), False)
filename_by_title = options.get(read_config_value(config, '直播设置', '保存文件名是否包含标题', ""), False)
clean_emoji = options.get(read_config_value(config, '直播设置', '是否去除名称中的表情符号', ""), True)
video_save_type = read_config_value(config, '直播设置', '视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频', "mp4")
video_record_quality = read_config_value(config, '直播设置', '原画|超清|高清|标清|流畅', "原画")
use_proxy = options.get(read_config_value(config, '直播设置', '是否使用代理ip(是/否)', ""), False)
proxy_addr_bak = read_config_value(config, '直播设置', '代理地址', "")
proxy_addr = None if not use_proxy else proxy_addr_bak
max_request = int(read_config_value(config, '直播设置', '同一时间访问网络的线程数', 3))
semaphore = threading.Semaphore(max_request)
delay_default = int(read_config_value(config, '直播设置', '循环时间(秒)', 120))
local_delay_default = int(read_config_value(config, '直播设置', '排队读取网址时间(秒)', 0))
loop_time = options.get(read_config_value(config, '直播设置', '是否显示循环秒数', ""), False)
show_url = options.get(read_config_value(config, '直播设置', '是否显示直播源地址', ""), False)
split_video_by_time = options.get(read_config_value(config, '直播设置', '分段直播是否开启', ""), False)
enable_https_recording = options.get(read_config_value(config, '直播设置', '是否强制启用https直播', ""), False)
disk_space_limit = float(read_config_value(config, '直播设置', '直播空间剩余阈值(gb)', 1.0))
split_time = str(read_config_value(config, '直播设置', '视频分段时间(秒)', 1800))
converts_to_mp4 = options.get(read_config_value(config, '直播设置', '直播完成后自动转为mp4格式', ""), False)
converts_to_h264 = options.get(read_config_value(config, '直播设置', 'mp4格式重新编码为h264', ""), False)
delete_origin_file = options.get(read_config_value(config, '直播设置', '追加格式后删除原文件', ""), False)
create_time_file = options.get(read_config_value(config, '直播设置', '生成时间字幕文件', ""), False)
is_run_script = options.get(read_config_value(config, '直播设置', '是否直播完成后执行自定义脚本', ""), False)
custom_script = read_config_value(config, '直播设置', '自定义脚本执行命令', "") if is_run_script else None
enable_proxy_platform = read_config_value(
config, '直播设置', '使用代理直播的平台(逗号分隔)',
'tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit'
)
enable_proxy_platform_list = enable_proxy_platform.replace('', ',').split(',') if enable_proxy_platform else None
extra_enable_proxy = read_config_value(config, '直播设置', '额外使用代理直播的平台(逗号分隔)', '')
extra_enable_proxy_platform_list = extra_enable_proxy.replace('', ',').split(',') if extra_enable_proxy else None
live_status_push = read_config_value(config, '推送配置', '直播状态推送渠道', "")
dingtalk_api_url = read_config_value(config, '推送配置', '钉钉推送接口链接', "")
xizhi_api_url = read_config_value(config, '推送配置', '微信推送接口链接', "")
bark_msg_api = read_config_value(config, '推送配置', 'bark推送接口链接', "")
bark_msg_level = read_config_value(config, '推送配置', 'bark推送中断级别', "active")
bark_msg_ring = read_config_value(config, '推送配置', 'bark推送铃声', "bell")
dingtalk_phone_num = read_config_value(config, '推送配置', '钉钉通知@对象(填手机号)', "")
dingtalk_is_atall = options.get(read_config_value(config, '推送配置', '钉钉通知@全体(是/否)', ""), False)
tg_token = read_config_value(config, '推送配置', 'tgapi令牌', "")
tg_chat_id = read_config_value(config, '推送配置', 'tg聊天id(个人或者群组id)', "")
email_host = read_config_value(config, '推送配置', 'SMTP邮件服务器', "")
open_smtp_ssl = options.get(read_config_value(config, '推送配置', '是否使用SMTP服务SSL加密(是/否)', ""), True)
smtp_port = read_config_value(config, '推送配置', 'SMTP邮件服务器端口', "")
login_email = read_config_value(config, '推送配置', '邮箱登录账号', "")
email_password = read_config_value(config, '推送配置', '发件人密码(授权码)', "")
sender_email = read_config_value(config, '推送配置', '发件人邮箱', "")
sender_name = read_config_value(config, '推送配置', '发件人显示昵称', "")
to_email = read_config_value(config, '推送配置', '收件人邮箱', "")
ntfy_api = read_config_value(config, '推送配置', 'ntfy推送地址', "")
ntfy_tags = read_config_value(config, '推送配置', 'ntfy推送标签', "tada")
ntfy_email = read_config_value(config, '推送配置', 'ntfy推送邮箱', "")
pushplus_token = read_config_value(config, '推送配置', 'pushplus推送token', "")
push_message_title = read_config_value(config, '推送配置', '自定义推送标题', "直播间状态更新通知")
begin_push_message_text = read_config_value(config, '推送配置', '自定义开播推送内容', "")
over_push_message_text = read_config_value(config, '推送配置', '自定义关播推送内容', "")
disable_record = options.get(read_config_value(config, '推送配置', '只推送通知不直播(是/否)', ""), False)
push_check_seconds = int(read_config_value(config, '推送配置', '直播推送检测频率(秒)', 1800))
begin_show_push = options.get(read_config_value(config, '推送配置', '开播推送开启(是/否)', ""), True)
over_show_push = options.get(read_config_value(config, '推送配置', '关播推送开启(是/否)', ""), False)
sooplive_username = read_config_value(config, '账号密码', 'sooplive账号', '')
sooplive_password = read_config_value(config, '账号密码', 'sooplive密码', '')
flextv_username = read_config_value(config, '账号密码', 'flextv账号', '')
flextv_password = read_config_value(config, '账号密码', 'flextv密码', '')
popkontv_username = read_config_value(config, '账号密码', 'popkontv账号', '')
popkontv_partner_code = read_config_value(config, '账号密码', 'partner_code', 'P-00001')
popkontv_password = read_config_value(config, '账号密码', 'popkontv密码', '')
twitcasting_account_type = read_config_value(config, '账号密码', 'twitcasting账号类型', 'normal')
twitcasting_username = read_config_value(config, '账号密码', 'twitcasting账号', '')
twitcasting_password = read_config_value(config, '账号密码', 'twitcasting密码', '')
popkontv_access_token = read_config_value(config, 'Authorization', 'popkontv_token', '')
dy_cookie = read_config_value(config, 'Cookie', '抖音cookie', '')
ks_cookie = read_config_value(config, 'Cookie', '快手cookie', '')
tiktok_cookie = read_config_value(config, 'Cookie', 'tiktok_cookie', '')
hy_cookie = read_config_value(config, 'Cookie', '虎牙cookie', '')
douyu_cookie = read_config_value(config, 'Cookie', '斗鱼cookie', '')
yy_cookie = read_config_value(config, 'Cookie', 'yy_cookie', '')
bili_cookie = read_config_value(config, 'Cookie', 'B站cookie', '')
xhs_cookie = read_config_value(config, 'Cookie', '小红书cookie', '')
bigo_cookie = read_config_value(config, 'Cookie', 'bigo_cookie', '')
blued_cookie = read_config_value(config, 'Cookie', 'blued_cookie', '')
sooplive_cookie = read_config_value(config, 'Cookie', 'sooplive_cookie', '')
netease_cookie = read_config_value(config, 'Cookie', 'netease_cookie', '')
qiandurebo_cookie = read_config_value(config, 'Cookie', '千度热播_cookie', '')
pandatv_cookie = read_config_value(config, 'Cookie', 'pandatv_cookie', '')
maoerfm_cookie = read_config_value(config, 'Cookie', '猫耳fm_cookie', '')
winktv_cookie = read_config_value(config, 'Cookie', 'winktv_cookie', '')
flextv_cookie = read_config_value(config, 'Cookie', 'flextv_cookie', '')
look_cookie = read_config_value(config, 'Cookie', 'look_cookie', '')
twitcasting_cookie = read_config_value(config, 'Cookie', 'twitcasting_cookie', '')
baidu_cookie = read_config_value(config, 'Cookie', 'baidu_cookie', '')
weibo_cookie = read_config_value(config, 'Cookie', 'weibo_cookie', '')
kugou_cookie = read_config_value(config, 'Cookie', 'kugou_cookie', '')
twitch_cookie = read_config_value(config, 'Cookie', 'twitch_cookie', '')
liveme_cookie = read_config_value(config, 'Cookie', 'liveme_cookie', '')
huajiao_cookie = read_config_value(config, 'Cookie', 'huajiao_cookie', '')
liuxing_cookie = read_config_value(config, 'Cookie', 'liuxing_cookie', '')
showroom_cookie = read_config_value(config, 'Cookie', 'showroom_cookie', '')
acfun_cookie = read_config_value(config, 'Cookie', 'acfun_cookie', '')
changliao_cookie = read_config_value(config, 'Cookie', 'changliao_cookie', '')
yinbo_cookie = read_config_value(config, 'Cookie', 'yinbo_cookie', '')
yingke_cookie = read_config_value(config, 'Cookie', 'yingke_cookie', '')
zhihu_cookie = read_config_value(config, 'Cookie', 'zhihu_cookie', '')
chzzk_cookie = read_config_value(config, 'Cookie', 'chzzk_cookie', '')
haixiu_cookie = read_config_value(config, 'Cookie', 'haixiu_cookie', '')
vvxqiu_cookie = read_config_value(config, 'Cookie', 'vvxqiu_cookie', '')
yiqilive_cookie = read_config_value(config, 'Cookie', '17live_cookie', '')
langlive_cookie = read_config_value(config, 'Cookie', 'langlive_cookie', '')
pplive_cookie = read_config_value(config, 'Cookie', 'pplive_cookie', '')
six_room_cookie = read_config_value(config, 'Cookie', '6room_cookie', '')
lehaitv_cookie = read_config_value(config, 'Cookie', 'lehaitv_cookie', '')
huamao_cookie = read_config_value(config, 'Cookie', 'huamao_cookie', '')
shopee_cookie = read_config_value(config, 'Cookie', 'shopee_cookie', '')
youtube_cookie = read_config_value(config, 'Cookie', 'youtube_cookie', '')
taobao_cookie = read_config_value(config, 'Cookie', 'taobao_cookie', '')
jd_cookie = read_config_value(config, 'Cookie', 'jd_cookie', '')
faceit_cookie = read_config_value(config, 'Cookie', 'faceit_cookie', '')
migu_cookie = read_config_value(config, 'Cookie', 'migu_cookie', '')
lianjie_cookie = read_config_value(config, 'Cookie', 'lianjie_cookie', '')
laixiu_cookie = read_config_value(config, 'Cookie', 'laixiu_cookie', '')
picarto_cookie = read_config_value(config, 'Cookie', 'picarto_cookie', '')
video_save_type_list = ("FLV", "MKV", "TS", "MP4", "MP3音频", "M4A音频", "MP3", "M4A")
if video_save_type and video_save_type.upper() in video_save_type_list:
video_save_type = video_save_type.upper()
else:
# video_save_type = "TS"
video_save_type = "MP4"
check_path = video_save_path or default_path
if utils.check_disk_capacity(check_path, show=first_run) < disk_space_limit:
exit_recording = True
if not recording:
logger.warning(f"Disk space remaining is below {disk_space_limit} GB. "
f"Exiting program due to the disk space limit being reached.")
sys.exit(-1)
def contains_url(string: str) -> bool:
pattern = r"(https?://)?(www\.)?[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:\d+)?(/.*)?"
return re.search(pattern, string) is not None
try:
url_comments, line_list, url_line_list = [[] for _ in range(3)]
with (open(url_config_file, "r", encoding=text_encoding, errors='ignore') as file):
for origin_line in file:
if origin_line in line_list:
delete_line(url_config_file, origin_line)
line_list.append(origin_line)
line = origin_line.strip()
if len(line) < 18:
continue
line_spilt = line.split('主播: ')
if len(line_spilt) > 2:
line = update_file(url_config_file, line, f'{line_spilt[0]}主播: {line_spilt[-1]}')
is_comment_line = line.startswith("#")
if is_comment_line:
line = line.lstrip('#')
if re.search('[,]', line):
split_line = re.split('[,]', line)
else:
split_line = [line, '']
if len(split_line) == 1:
url = split_line[0]
quality, name = [video_record_quality, '']
elif len(split_line) == 2:
if contains_url(split_line[0]):
quality = video_record_quality
url, name = split_line
else:
quality, url = split_line
name = ''
else:
quality, url, name = split_line
if quality not in ("原画", "蓝光", "超清", "高清", "标清", "流畅"):
quality = '原画'
if url not in url_line_list:
url_line_list.append(url)
else:
delete_line(url_config_file, origin_line)
url = 'https://' + url if '://' not in url else url
url_host = url.split('/')[2]
platform_host = [
'live.douyin.com',
'v.douyin.com',
'www.douyin.com',
'live.kuaishou.com',
'www.huya.com',
'www.douyu.com',
'www.yy.com',
'live.bilibili.com',
'www.redelight.cn',
'www.xiaohongshu.com',
'xhslink.com',
'www.bigo.tv',
'slink.bigovideo.tv',
'app.blued.cn',
'cc.163.com',
'qiandurebo.com',
'fm.missevan.com',
'look.163.com',
'twitcasting.tv',
'live.baidu.com',
'weibo.com',
'fanxing.kugou.com',
'fanxing2.kugou.com',
'mfanxing.kugou.com',
'www.huajiao.com',
'www.7u66.com',
'wap.7u66.com',
'live.acfun.cn',
'm.acfun.cn',
'live.tlclw.com',
'wap.tlclw.com',
'live.ybw1666.com',
'wap.ybw1666.com',
'www.inke.cn',
'www.zhihu.com',
'www.haixiutv.com',
"h5webcdnp.vvxqiu.com",
"17.live",
'www.lang.live',
"m.pp.weimipopo.com",
"v.6.cn",
"m.6.cn",
'www.lehaitv.com',
'h.catshow168.com',
'e.tb.cn',
'huodong.m.taobao.com',
'3.cn',
'eco.m.jd.com',
'www.miguvideo.com',
'm.miguvideo.com',
'show.lailianjie.com',
'www.imkktv.com',
'www.picarto.tv'
]
overseas_platform_host = [
'www.tiktok.com',
'play.sooplive.co.kr',
'm.sooplive.co.kr',
'www.pandalive.co.kr',
'www.winktv.co.kr',
'www.flextv.co.kr',
'www.ttinglive.com',
'www.popkontv.com',
'www.twitch.tv',
'www.liveme.com',
'www.showroom-live.com',
'chzzk.naver.com',
'm.chzzk.naver.com',
'live.shopee.',
'.shp.ee',
'www.youtube.com',
'youtu.be',
'www.faceit.com'
]
platform_host.extend(overseas_platform_host)
clean_url_host_list = (
"live.douyin.com",
"live.bilibili.com",
"www.huajiao.com",
"www.zhihu.com",
"www.huya.com",
"chzzk.naver.com",
"www.liveme.com",
"www.haixiutv.com",
"v.6.cn",
"m.6.cn",
'www.lehaitv.com'
)
if 'live.shopee.' in url_host or '.shp.ee' in url_host:
url_host = 'live.shopee.' if 'live.shopee.' in url_host else '.shp.ee'
if url_host in platform_host or any(ext in url for ext in (".flv", ".m3u8")):
if url_host in clean_url_host_list:
url = update_file(url_config_file, old_str=url, new_str=url.split('?')[0])
if 'xiaohongshu' in url:
host_id = re.search('&host_id=(.*?)(?=&|$)', url)
if host_id:
new_url = url.split('?')[0] + f'?host_id={host_id.group(1)}'
url = update_file(url_config_file, old_str=url, new_str=new_url)
url_comments = [i for i in url_comments if url not in i]
if is_comment_line:
url_comments.append(url)
else:
new_line = (quality, url, name)
url_tuples_list.append(new_line)
else:
if not origin_line.startswith('#'):
color_obj.print_colored(f"\r{origin_line.strip()} 本行包含未知链接.此条跳过", color_obj.YELLOW)
update_file(url_config_file, old_str=origin_line, new_str=origin_line, start_str='#')
while len(need_update_line_list):
a = need_update_line_list.pop()
replace_words = a.split('|')
if replace_words[0] != replace_words[1]:
if replace_words[1].startswith("#"):
start_with = '#'
new_word = replace_words[1][1:]
else:
start_with = None
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(dict.fromkeys(url_tuples_list))
# 监测数量显示为“URL 条数”(哪怕不启动直播线程)
monitoring = len(text_no_repeat_url)
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)
url_tuples_list = []
first_start = False
except Exception as err:
logger.error(f"错误信息: {err} 发生错误的行数: {err.__traceback__.tb_lineno}")
if first_run:
t = threading.Thread(target=display_info, args=(), daemon=True)
t.start()
t2 = threading.Thread(target=adjust_max_request, args=(), daemon=True)
t2.start()
first_run = False
time.sleep(3)