s''
This commit is contained in:
582
back/back3
Normal file
582
back/back3
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
单进程 ffmpeg 推流:从 URL_config.ini 顺序检测直播源,
|
||||||
|
一旦当前源停播/异常,自动切到下一个正在直播的源并推流到 YouTube。
|
||||||
|
仅做“检测+转推”,不做录制、分段、通知等。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Tuple, Optional
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import urllib.request
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from src import spider, stream
|
||||||
|
from src.utils import logger, Color, check_md5
|
||||||
|
from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path
|
||||||
|
|
||||||
|
# --------------------- 基础环境 ---------------------
|
||||||
|
version = "relay-1.0.1"
|
||||||
|
os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
|
||||||
|
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'
|
||||||
|
text_encoding = 'utf-8-sig'
|
||||||
|
color = Color()
|
||||||
|
|
||||||
|
# ---------- YouTube 推流配置(读取 youtube.ini) ----------
|
||||||
|
_yt_cfg = configparser.ConfigParser()
|
||||||
|
with open(f"{script_path}/config/youtube.ini", "r", encoding="utf-8-sig") as f:
|
||||||
|
_yt_cfg.read_file(f)
|
||||||
|
|
||||||
|
def _yt_get(section: str, key: str, fallback=None, cast=str):
|
||||||
|
try:
|
||||||
|
if cast is bool:
|
||||||
|
return _yt_cfg.getboolean(section, key, fallback=fallback)
|
||||||
|
if cast is int:
|
||||||
|
return _yt_cfg.getint(section, key, fallback=fallback)
|
||||||
|
return _yt_cfg.get(section, key, fallback=fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
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 _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 read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any:
|
||||||
|
try:
|
||||||
|
cfg.read(config_file, encoding=text_encoding)
|
||||||
|
if section not in cfg.sections():
|
||||||
|
cfg.add_section(section)
|
||||||
|
return cfg.get(section, option)
|
||||||
|
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||||
|
cfg.set(section, option, str(default_value))
|
||||||
|
with open(config_file, 'w', encoding=text_encoding) as f:
|
||||||
|
cfg.write(f)
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
_CN_BOOL = {"是": True, "否": False}
|
||||||
|
|
||||||
|
cfg = configparser.RawConfigParser()
|
||||||
|
use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False)
|
||||||
|
proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "")
|
||||||
|
proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None
|
||||||
|
# Cookie(按需)
|
||||||
|
dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '')
|
||||||
|
tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '')
|
||||||
|
|
||||||
|
# 可选:启动时检测系统代理(仅提示)
|
||||||
|
try:
|
||||||
|
if not use_proxy:
|
||||||
|
urllib.request.urlopen("https://www.google.com/", timeout=5)
|
||||||
|
print("提示:你的网络似乎直连可用(无需代理)。")
|
||||||
|
except Exception:
|
||||||
|
if not use_proxy:
|
||||||
|
color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW)
|
||||||
|
|
||||||
|
# --------------------- URL 列表读取 ---------------------
|
||||||
|
def parse_urls_from_file(path: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
读取 URL_config.ini,返回去重且保持相对顺序的 URL 列表。
|
||||||
|
支持行内格式:
|
||||||
|
- 纯 URL
|
||||||
|
- quality,url
|
||||||
|
- url,主播: xxx
|
||||||
|
- # 注释行 忽略
|
||||||
|
"""
|
||||||
|
urls, seen = [], set()
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
open(path, 'w', encoding=text_encoding).close()
|
||||||
|
print("URL_config.ini 为空,请写入直播间地址后再运行。")
|
||||||
|
return urls
|
||||||
|
|
||||||
|
with open(path, "r", encoding=text_encoding, errors='ignore') as f:
|
||||||
|
for line in f:
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
# 拆分(最多3段,取包含 https 或域名的那段)
|
||||||
|
parts = re.split(r"[,,]\s*", s)
|
||||||
|
cand = None
|
||||||
|
for p in parts:
|
||||||
|
if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p):
|
||||||
|
cand = p
|
||||||
|
break
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
url = cand if '://' in cand else f"https://{cand}"
|
||||||
|
if url not in seen:
|
||||||
|
seen.add(url)
|
||||||
|
urls.append(url)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
# --------------------- 直播解析与选源 ---------------------
|
||||||
|
def is_flv_preferred_platform(link: str) -> bool:
|
||||||
|
# 抖音/TikTok 优先走 FLV;但 H265 不走 FLV
|
||||||
|
return any(i in link for i in ["douyin", "tiktok"])
|
||||||
|
|
||||||
|
def select_source_url(link: str, stream_info: dict) -> Optional[str]:
|
||||||
|
if not stream_info:
|
||||||
|
return None
|
||||||
|
if is_flv_preferred_platform(link):
|
||||||
|
flv = stream_info.get("flv_url")
|
||||||
|
if flv:
|
||||||
|
# 若 codec=h265 则不走 flv
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
qs = parse_qs(urlparse(flv).query or "")
|
||||||
|
if (qs.get("codec") or [""])[0].lower() == "h265":
|
||||||
|
logger.warning("FLV 不支持 h265,改用 HLS。")
|
||||||
|
else:
|
||||||
|
return flv
|
||||||
|
except Exception:
|
||||||
|
return flv
|
||||||
|
return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url")
|
||||||
|
|
||||||
|
async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict:
|
||||||
|
"""
|
||||||
|
调用项目现有 spider/stream,返回:
|
||||||
|
{
|
||||||
|
"anchor_name": str,
|
||||||
|
"is_live": bool,
|
||||||
|
"title": str?,
|
||||||
|
"record_url": "...",
|
||||||
|
"flv_url": "...",
|
||||||
|
"m3u8_url": "..."
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
proxy = proxy_addr
|
||||||
|
port_info = {}
|
||||||
|
try:
|
||||||
|
if "douyin.com/" in url:
|
||||||
|
if 'v.douyin.com' not in url and '/user/' not in url:
|
||||||
|
j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie)
|
||||||
|
else:
|
||||||
|
j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie)
|
||||||
|
port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy)
|
||||||
|
elif url.startswith("https://www.tiktok.com/"):
|
||||||
|
if proxy:
|
||||||
|
j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie)
|
||||||
|
port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy)
|
||||||
|
else:
|
||||||
|
logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。")
|
||||||
|
port_info = {"anchor_name": "", "is_live": False}
|
||||||
|
elif (".m3u8" in url) or (".flv" in url):
|
||||||
|
# 自定义直链
|
||||||
|
port_info = {
|
||||||
|
"anchor_name": "自定义_" + str(uuid.uuid4())[:8],
|
||||||
|
"is_live": True,
|
||||||
|
}
|
||||||
|
if url.endswith(".flv"):
|
||||||
|
port_info["flv_url"] = url
|
||||||
|
else:
|
||||||
|
port_info["m3u8_url"] = url
|
||||||
|
else:
|
||||||
|
# 其它站点可按需扩展
|
||||||
|
port_info = {"anchor_name": "", "is_live": False}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析失败: {e}")
|
||||||
|
port_info = {"anchor_name": "", "is_live": False}
|
||||||
|
return port_info
|
||||||
|
|
||||||
|
# --------------------- ffmpeg 单进程推流 ---------------------
|
||||||
|
def build_youtube_output() -> Tuple[str, list, list]:
|
||||||
|
key = _yt_get("youtube", "key", fallback="", cast=str)
|
||||||
|
if not key:
|
||||||
|
logger.error("错误: config/youtube.ini 未配置 key")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
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() # 例如 "1080x1920"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
out_url = _build_youtube_rtmp_url(key, secure, ingest)
|
||||||
|
|
||||||
|
# —— 默认强制转码(稳) —— #
|
||||||
|
v_args = [
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-tune", "zerolatency",
|
||||||
|
"-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",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 可选缩放/留黑边 + 避免 1088x1920(加 crop 强制精确尺寸)
|
||||||
|
if res_str:
|
||||||
|
try:
|
||||||
|
w, h = map(int, res_str.lower().split("x"))
|
||||||
|
vf = (
|
||||||
|
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
||||||
|
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,"
|
||||||
|
f"crop={w}:{h},"
|
||||||
|
f"format=yuv420p"
|
||||||
|
)
|
||||||
|
v_args = ["-vf", vf] + v_args
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"resolution '{res_str}' 非法,忽略")
|
||||||
|
|
||||||
|
return out_url, v_args, a_args
|
||||||
|
|
||||||
|
def launch_ffmpeg(input_url: str,
|
||||||
|
out_url: str,
|
||||||
|
extra_headers: Optional[str] = None,
|
||||||
|
v_args: Optional[list] = None,
|
||||||
|
a_args: Optional[list] = None) -> subprocess.Popen:
|
||||||
|
"""
|
||||||
|
启动一个 ffmpeg 进程:输入 input_url,输出 RTMP/RTMPS 到 out_url。
|
||||||
|
- v_args / a_args 由 build_youtube_output() 生成并传入
|
||||||
|
- 启动 stdout reader 线程以防缓冲阻塞
|
||||||
|
- 设置代理环境变量以兼容 https/hls
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/87.0.4280.141 Mobile Safari/537.36")
|
||||||
|
|
||||||
|
# 输入侧容错/重连/探测
|
||||||
|
base = [
|
||||||
|
"ffmpeg", "-nostdin", "-hide_banner",
|
||||||
|
"-stats", # 输出统计信息
|
||||||
|
"-stats_period", "60", # 每60秒一次
|
||||||
|
"-progress", "pipe:1", # 状态写到stdout,便于reader捕获
|
||||||
|
"-loglevel", "info",
|
||||||
|
|
||||||
|
# 输入容错 & 自动重连
|
||||||
|
"-rw_timeout", "15000000",
|
||||||
|
"-timeout", "10000000",
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_at_eof", "1",
|
||||||
|
"-reconnect_delay_max", "30",
|
||||||
|
|
||||||
|
# UA/协议白名单
|
||||||
|
"-user_agent", user_agent,
|
||||||
|
"-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy",
|
||||||
|
|
||||||
|
# 缓冲 & 探测
|
||||||
|
"-thread_queue_size", "4096",
|
||||||
|
"-analyzeduration", "10000000",
|
||||||
|
"-probesize", "10000000",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 平台 headers(referer/origin)要在 -i 之前
|
||||||
|
if extra_headers:
|
||||||
|
base += ["-headers", extra_headers]
|
||||||
|
|
||||||
|
# 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容)
|
||||||
|
env = os.environ.copy()
|
||||||
|
if proxy_addr:
|
||||||
|
env["http_proxy"] = proxy_addr
|
||||||
|
env["https_proxy"] = proxy_addr
|
||||||
|
base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:]
|
||||||
|
|
||||||
|
# 输入
|
||||||
|
base += ["-re", "-i", input_url]
|
||||||
|
|
||||||
|
# 通用容错
|
||||||
|
base += [
|
||||||
|
"-fflags", "+genpts+discardcorrupt+nobuffer",
|
||||||
|
"-use_wallclock_as_timestamps", "1",
|
||||||
|
"-avioflags", "direct",
|
||||||
|
"-rtbufsize", "100M",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 视频/音频编码参数(使用 build_youtube_output 的结果)
|
||||||
|
v_args = v_args or []
|
||||||
|
a_args = a_args or []
|
||||||
|
base += v_args + a_args
|
||||||
|
|
||||||
|
# 输出(flv 到 YT)
|
||||||
|
base += ["-f", "flv", out_url]
|
||||||
|
|
||||||
|
# 打印命令便于排障(截断)
|
||||||
|
cmd_str = " ".join(base)
|
||||||
|
print("ffmpeg cmd:", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...")
|
||||||
|
|
||||||
|
logger.info(f"启动推流:{input_url} -> {out_url}")
|
||||||
|
|
||||||
|
creationflags = 0
|
||||||
|
if os.name == "nt":
|
||||||
|
# 防止子进程挂住控制台(可选)
|
||||||
|
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
|
||||||
|
p = subprocess.Popen(
|
||||||
|
base,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
bufsize=0, # 避免行缓冲告警 & 尽快刷新
|
||||||
|
env=env,
|
||||||
|
creationflags=creationflags
|
||||||
|
)
|
||||||
|
|
||||||
|
# 最近输出时间戳,用于 watchdog
|
||||||
|
p.last_out_ts = time.time()
|
||||||
|
|
||||||
|
def _reader():
|
||||||
|
try:
|
||||||
|
for line in iter(p.stdout.readline, b''):
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
text = line.decode(errors="ignore").strip()
|
||||||
|
p.last_out_ts = time.time()
|
||||||
|
# 常见错误提示
|
||||||
|
if "Server disconnected" in text:
|
||||||
|
logger.error(f"[YouTube输出异常] {text}")
|
||||||
|
elif "not supported" in text or "Invalid" in text or "invalid" in text:
|
||||||
|
logger.error(f"[转码异常] {text}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
t = threading.Thread(target=_reader, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
def stop_ffmpeg(p: Optional[subprocess.Popen]):
|
||||||
|
if not p:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
if p.stdin:
|
||||||
|
try:
|
||||||
|
p.stdin.write(b"q")
|
||||||
|
p.stdin.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
p.wait(timeout=6)
|
||||||
|
else:
|
||||||
|
p.send_signal(signal.SIGINT)
|
||||||
|
p.wait(timeout=10)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --------------------- 主循环(单进程转推+切源) ---------------------
|
||||||
|
def check_ffmpeg_existence() -> bool:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(result.stdout.splitlines()[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
return bool(check_ffmpeg())
|
||||||
|
|
||||||
|
def now_str(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]:
|
||||||
|
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_name)
|
||||||
|
|
||||||
|
def detect_platform(url: str) -> str:
|
||||||
|
if "douyin.com" in url: return "抖音直播"
|
||||||
|
if "tiktok.com" in url: return "TikTok直播"
|
||||||
|
if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播"
|
||||||
|
return "未知平台"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("-----------------------------------------------------")
|
||||||
|
print("| DouyinLiveRecorder - 单进程转推版 |")
|
||||||
|
print("-----------------------------------------------------")
|
||||||
|
print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder")
|
||||||
|
print("模式:只检测 + 单进程推流(停播自动切下一个 URL)")
|
||||||
|
print(".....................................................")
|
||||||
|
|
||||||
|
if not check_ffmpeg_existence():
|
||||||
|
logger.error("缺少 ffmpeg,程序退出")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
urls = parse_urls_from_file(url_config_file)
|
||||||
|
if not urls:
|
||||||
|
logger.error("URL_config.ini 中没有可用 URL。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 顺序轮询索引
|
||||||
|
idx = 0
|
||||||
|
current_url = None
|
||||||
|
current_proc: Optional[subprocess.Popen] = None
|
||||||
|
current_platform = ""
|
||||||
|
current_anchor = ""
|
||||||
|
record_quality_code = "OD" # 固定“原画”;需要也可改为从 config.ini 读取
|
||||||
|
|
||||||
|
check_interval_live = 8 # 推流中检测当前源是否仍在直播的间隔秒
|
||||||
|
scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒
|
||||||
|
last_md5 = "" # URL 文件变化时热更新
|
||||||
|
|
||||||
|
# watchdog 参数
|
||||||
|
watchdog_grace = 60 # 推流开始 60s 内不做静默判定
|
||||||
|
watchdog_silence = 300 # 5 分钟完全无输出才重启
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# 动态热更新 URL 列表(URL_config.ini 有变更则重载)
|
||||||
|
try:
|
||||||
|
md5 = check_md5(url_config_file)
|
||||||
|
if md5 != last_md5:
|
||||||
|
urls = parse_urls_from_file(url_config_file) or urls
|
||||||
|
last_md5 = md5
|
||||||
|
print(f"[{now_str()}] 已重载 URL 列表,数量:{len(urls)}")
|
||||||
|
# 若当前 URL 已不在列表,强制切换
|
||||||
|
if current_url and current_url not in urls:
|
||||||
|
print(f"[{now_str()}] 当前URL已被移除,停止并重新选择。")
|
||||||
|
stop_ffmpeg(current_proc)
|
||||||
|
current_proc = None
|
||||||
|
current_url = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"URL 列表热更新失败:{e}")
|
||||||
|
|
||||||
|
if current_proc and current_url:
|
||||||
|
retcode = current_proc.poll()
|
||||||
|
if retcode is not None:
|
||||||
|
color.print_colored(f"[{now_str()}] ffmpeg 已退出 (code={retcode}),切源…", color.YELLOW)
|
||||||
|
stop_ffmpeg(current_proc)
|
||||||
|
current_proc = None
|
||||||
|
current_url = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否停播(依赖 spider)
|
||||||
|
try:
|
||||||
|
port = asyncio.run(fetch_port_info(current_url, record_quality_code))
|
||||||
|
still_live = bool(port.get("is_live"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"检测当前源失败:{e}")
|
||||||
|
still_live = True # 容错:无法判断时先认为还活着
|
||||||
|
|
||||||
|
if still_live:
|
||||||
|
elapsed = int(time.time() - start_time)
|
||||||
|
h, m = divmod(elapsed, 3600)
|
||||||
|
m, s = divmod(m, 60)
|
||||||
|
dur_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="")
|
||||||
|
|
||||||
|
# ✅ watchdog(推流开始 > 60s 且 5 分钟完全无输出)
|
||||||
|
silence = time.time() - getattr(current_proc, "last_out_ts", 0)
|
||||||
|
if elapsed >= watchdog_grace and silence > watchdog_silence:
|
||||||
|
color.print_colored(f"\n[{now_str()}] 超过5分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW)
|
||||||
|
stop_ffmpeg(current_proc)
|
||||||
|
current_proc = None
|
||||||
|
current_url = None
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
time.sleep(check_interval_live)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
color.print_colored(f"\n[{now_str()}] 主播停播,切换下一个源…", color.YELLOW)
|
||||||
|
stop_ffmpeg(current_proc)
|
||||||
|
current_proc = None
|
||||||
|
current_url = None
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 未在推流:从 urls 顺序找第一个 is_live 的
|
||||||
|
if not urls:
|
||||||
|
time.sleep(scan_interval_when_idle)
|
||||||
|
continue
|
||||||
|
|
||||||
|
tried = 0
|
||||||
|
found = False
|
||||||
|
start_idx = idx
|
||||||
|
while tried < len(urls):
|
||||||
|
url = urls[idx]
|
||||||
|
idx = (idx + 1) % len(urls)
|
||||||
|
tried += 1
|
||||||
|
|
||||||
|
port = asyncio.run(fetch_port_info(url, record_quality_code))
|
||||||
|
if not port.get("anchor_name"):
|
||||||
|
print(f"[{now_str()}] 获取失败,跳过:{url}")
|
||||||
|
continue
|
||||||
|
if not port.get("is_live"):
|
||||||
|
print(f"[{now_str()}] 未开播:{port.get('anchor_name','')} | {url}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 选取真实播放地址
|
||||||
|
real_url = select_source_url(url, port)
|
||||||
|
if not real_url:
|
||||||
|
print(f"[{now_str()}] 未得到播放地址,跳过:{url}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_platform = detect_platform(url)
|
||||||
|
current_anchor = port.get("anchor_name", "")
|
||||||
|
headers = headers_for_platform(current_platform, url)
|
||||||
|
|
||||||
|
# 启动 ffmpeg
|
||||||
|
out_url, v_args, a_args = build_youtube_output()
|
||||||
|
current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args)
|
||||||
|
current_url = url
|
||||||
|
start_time = time.time()
|
||||||
|
color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN)
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
# 没有任何源在播,过会儿再试
|
||||||
|
print(f"[{now_str()}] 当前无可用直播源,等待中…")
|
||||||
|
time.sleep(scan_interval_when_idle)
|
||||||
|
continue
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n收到中断,正在退出…")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"主循环异常:{e}")
|
||||||
|
finally:
|
||||||
|
stop_ffmpeg(current_proc)
|
||||||
|
print("已退出。")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
https://live.douyin.com/898038766356
|
https://live.douyin.com/60379224194
|
||||||
https://live.douyin.com/60379224194
|
|
||||||
https://live.douyin.com/72212955083
|
|
||||||
https://live.douyin.com/621118100231
|
https://live.douyin.com/621118100231
|
||||||
https://live.douyin.com/280422307519
|
https://live.douyin.com/280422307519
|
||||||
https://live.douyin.com/114111936890
|
https://live.douyin.com/114111936890
|
||||||
@@ -10,10 +8,7 @@ https://live.douyin.com/92876979328
|
|||||||
https://live.douyin.com/136996589868
|
https://live.douyin.com/136996589868
|
||||||
https://live.douyin.com/517715534931
|
https://live.douyin.com/517715534931
|
||||||
https://live.douyin.com/460525712926
|
https://live.douyin.com/460525712926
|
||||||
https://live.douyin.com/456576000931
|
|
||||||
https://live.douyin.com/745606325880
|
|
||||||
https://live.douyin.com/749687541944
|
https://live.douyin.com/749687541944
|
||||||
https://live.douyin.com/675343045974
|
|
||||||
https://live.douyin.com/673565298571
|
https://live.douyin.com/673565298571
|
||||||
https://live.douyin.com/835571459859
|
https://live.douyin.com/835571459859
|
||||||
https://live.douyin.com/152358755212
|
https://live.douyin.com/152358755212
|
||||||
|
|||||||
69
main.py
69
main.py
@@ -28,7 +28,7 @@ from src.utils import logger, Color, check_md5
|
|||||||
from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path
|
from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path
|
||||||
|
|
||||||
# --------------------- 基础环境 ---------------------
|
# --------------------- 基础环境 ---------------------
|
||||||
version = "relay-1.0.1"
|
version = "relay-1.1.0"
|
||||||
os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
|
os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
|
||||||
script_path = os.path.split(os.path.realpath(sys.argv[0]))[0]
|
script_path = os.path.split(os.path.realpath(sys.argv[0]))[0]
|
||||||
config_file = f'{script_path}/config/config.ini'
|
config_file = f'{script_path}/config/config.ini'
|
||||||
@@ -60,6 +60,10 @@ def _safe_int(v, lo, hi, default):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
def _round_res(w: int, h: int) -> Tuple[int, int]:
|
||||||
|
# 强制偶数,避免 1088/1918 这类非标准尺寸
|
||||||
|
return (w & ~1, h & ~1)
|
||||||
|
|
||||||
def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None):
|
def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None):
|
||||||
node = (ingest or "a").strip().lower()
|
node = (ingest or "a").strip().lower()
|
||||||
node = node if node.isalpha() and len(node) == 1 else "a"
|
node = node if node.isalpha() and len(node) == 1 else "a"
|
||||||
@@ -243,10 +247,11 @@ def build_youtube_output() -> Tuple[str, list, list]:
|
|||||||
"-ac", "2",
|
"-ac", "2",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 可选缩放/留黑边 + 避免 1088x1920(加 crop 强制精确尺寸)
|
# 可选缩放/留黑边 + 避免 1088x1920(crop 强制精确尺寸)+ 偶数对齐
|
||||||
if res_str:
|
if res_str:
|
||||||
try:
|
try:
|
||||||
w, h = map(int, res_str.lower().split("x"))
|
w, h = map(int, res_str.lower().split("x"))
|
||||||
|
w, h = _round_res(w, h)
|
||||||
vf = (
|
vf = (
|
||||||
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
||||||
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,"
|
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,"
|
||||||
@@ -278,9 +283,9 @@ def launch_ffmpeg(input_url: str,
|
|||||||
# 输入侧容错/重连/探测
|
# 输入侧容错/重连/探测
|
||||||
base = [
|
base = [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner",
|
"ffmpeg", "-nostdin", "-hide_banner",
|
||||||
"-stats", # 输出统计信息
|
"-stats",
|
||||||
"-stats_period", "60", # 每60秒一次
|
"-stats_period", "60",
|
||||||
"-progress", "pipe:1", # 状态写到stdout,便于reader捕获
|
"-progress", "pipe:1",
|
||||||
"-loglevel", "info",
|
"-loglevel", "info",
|
||||||
|
|
||||||
# 输入容错 & 自动重连
|
# 输入容错 & 自动重连
|
||||||
@@ -301,7 +306,6 @@ def launch_ffmpeg(input_url: str,
|
|||||||
"-probesize", "10000000",
|
"-probesize", "10000000",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# 平台 headers(referer/origin)要在 -i 之前
|
# 平台 headers(referer/origin)要在 -i 之前
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
base += ["-headers", extra_headers]
|
base += ["-headers", extra_headers]
|
||||||
@@ -316,9 +320,10 @@ def launch_ffmpeg(input_url: str,
|
|||||||
# 输入
|
# 输入
|
||||||
base += ["-re", "-i", input_url]
|
base += ["-re", "-i", input_url]
|
||||||
|
|
||||||
# 通用容错
|
# 通用容错 + 时间戳修复
|
||||||
base += [
|
base += [
|
||||||
"-fflags", "+genpts+discardcorrupt+nobuffer",
|
"-fflags", "+genpts+igndts+discardcorrupt+nobuffer",
|
||||||
|
"-xerror",
|
||||||
"-use_wallclock_as_timestamps", "1",
|
"-use_wallclock_as_timestamps", "1",
|
||||||
"-avioflags", "direct",
|
"-avioflags", "direct",
|
||||||
"-rtbufsize", "100M",
|
"-rtbufsize", "100M",
|
||||||
@@ -340,7 +345,6 @@ def launch_ffmpeg(input_url: str,
|
|||||||
|
|
||||||
creationflags = 0
|
creationflags = 0
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
# 防止子进程挂住控制台(可选)
|
|
||||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
@@ -348,7 +352,7 @@ def launch_ffmpeg(input_url: str,
|
|||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
bufsize=0, # 避免行缓冲告警 & 尽快刷新
|
bufsize=0, # 立即刷新,避免行缓冲告警
|
||||||
env=env,
|
env=env,
|
||||||
creationflags=creationflags
|
creationflags=creationflags
|
||||||
)
|
)
|
||||||
@@ -363,14 +367,20 @@ def launch_ffmpeg(input_url: str,
|
|||||||
break
|
break
|
||||||
text = line.decode(errors="ignore").strip()
|
text = line.decode(errors="ignore").strip()
|
||||||
p.last_out_ts = time.time()
|
p.last_out_ts = time.time()
|
||||||
|
|
||||||
|
# 输出关键统计,便于观察码率/帧率
|
||||||
|
if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text):
|
||||||
|
logger.info(f"[ffmpeg统计] {text}")
|
||||||
|
|
||||||
# 常见错误提示
|
# 常见错误提示
|
||||||
if "Server disconnected" in text:
|
if "Server disconnected" in text:
|
||||||
logger.error(f"[YouTube输出异常] {text}")
|
logger.error(f"[YouTube/RTMP输出异常] {text}")
|
||||||
elif "not supported" in text or "Invalid" in text or "invalid" in text:
|
elif "not supported" in text or "Invalid" in text or "invalid" in text:
|
||||||
logger.error(f"[转码异常] {text}")
|
logger.error(f"[转码异常] {text}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
import threading
|
||||||
t = threading.Thread(target=_reader, daemon=True)
|
t = threading.Thread(target=_reader, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
@@ -450,7 +460,8 @@ def main():
|
|||||||
|
|
||||||
# 顺序轮询索引
|
# 顺序轮询索引
|
||||||
idx = 0
|
idx = 0
|
||||||
current_url = None
|
current_url: Optional[str] = None
|
||||||
|
current_real_url: Optional[str] = None # 实际播放地址(flv/m3u8)
|
||||||
current_proc: Optional[subprocess.Popen] = None
|
current_proc: Optional[subprocess.Popen] = None
|
||||||
current_platform = ""
|
current_platform = ""
|
||||||
current_anchor = ""
|
current_anchor = ""
|
||||||
@@ -460,9 +471,10 @@ def main():
|
|||||||
scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒
|
scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒
|
||||||
last_md5 = "" # URL 文件变化时热更新
|
last_md5 = "" # URL 文件变化时热更新
|
||||||
|
|
||||||
# watchdog 参数
|
# watchdog 参数(更稳)
|
||||||
watchdog_grace = 60 # 推流开始 60s 内不做静默判定
|
watchdog_grace = 120 # 推流开始 120s 内不做静默判定
|
||||||
watchdog_silence = 300 # 5 分钟完全无输出才重启
|
watchdog_silence = 600 # 10 分钟完全无输出才重启
|
||||||
|
refresh_real_url_every = 300 # 每 5 分钟刷新一次真实播放地址,避免 token 过期
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -479,6 +491,7 @@ def main():
|
|||||||
stop_ffmpeg(current_proc)
|
stop_ffmpeg(current_proc)
|
||||||
current_proc = None
|
current_proc = None
|
||||||
current_url = None
|
current_url = None
|
||||||
|
current_real_url = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"URL 列表热更新失败:{e}")
|
logger.warning(f"URL 列表热更新失败:{e}")
|
||||||
|
|
||||||
@@ -489,6 +502,7 @@ def main():
|
|||||||
stop_ffmpeg(current_proc)
|
stop_ffmpeg(current_proc)
|
||||||
current_proc = None
|
current_proc = None
|
||||||
current_url = None
|
current_url = None
|
||||||
|
current_real_url = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查是否停播(依赖 spider)
|
# 检查是否停播(依赖 spider)
|
||||||
@@ -506,13 +520,32 @@ def main():
|
|||||||
dur_str = f"{h:02d}:{m:02d}:{s:02d}"
|
dur_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="")
|
print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="")
|
||||||
|
|
||||||
# ✅ watchdog(推流开始 > 60s 且 5 分钟完全无输出)
|
# 定期刷新真实播放地址,避免 token 过期
|
||||||
|
try:
|
||||||
|
if elapsed >= refresh_real_url_every and (elapsed % refresh_real_url_every) < check_interval_live:
|
||||||
|
new_port = asyncio.run(fetch_port_info(current_url, record_quality_code))
|
||||||
|
new_real_url = select_source_url(current_url, new_port)
|
||||||
|
if new_real_url and new_real_url != current_real_url:
|
||||||
|
color.print_colored(f"\n[{now_str()}] 刷新播放地址(token 变化),重启 ffmpeg…", color.YELLOW)
|
||||||
|
stop_ffmpeg(current_proc)
|
||||||
|
out_url, v_args, a_args = build_youtube_output()
|
||||||
|
headers = headers_for_platform(current_platform, current_url)
|
||||||
|
current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args=v_args, a_args=a_args)
|
||||||
|
current_real_url = new_real_url
|
||||||
|
start_time = time.time()
|
||||||
|
time.sleep(check_interval_live)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"刷新播放地址失败:{e}")
|
||||||
|
|
||||||
|
# ✅ watchdog(推流开始 > 120s 且 10 分钟完全无输出)
|
||||||
silence = time.time() - getattr(current_proc, "last_out_ts", 0)
|
silence = time.time() - getattr(current_proc, "last_out_ts", 0)
|
||||||
if elapsed >= watchdog_grace and silence > watchdog_silence:
|
if elapsed >= watchdog_grace and silence > watchdog_silence:
|
||||||
color.print_colored(f"\n[{now_str()}] 超过5分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW)
|
color.print_colored(f"\n[{now_str()}] 超过10分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW)
|
||||||
stop_ffmpeg(current_proc)
|
stop_ffmpeg(current_proc)
|
||||||
current_proc = None
|
current_proc = None
|
||||||
current_url = None
|
current_url = None
|
||||||
|
current_real_url = None
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -523,6 +556,7 @@ def main():
|
|||||||
stop_ffmpeg(current_proc)
|
stop_ffmpeg(current_proc)
|
||||||
current_proc = None
|
current_proc = None
|
||||||
current_url = None
|
current_url = None
|
||||||
|
current_real_url = None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 未在推流:从 urls 顺序找第一个 is_live 的
|
# 未在推流:从 urls 顺序找第一个 is_live 的
|
||||||
@@ -560,6 +594,7 @@ def main():
|
|||||||
out_url, v_args, a_args = build_youtube_output()
|
out_url, v_args, a_args = build_youtube_output()
|
||||||
current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args)
|
current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args)
|
||||||
current_url = url
|
current_url = url
|
||||||
|
current_real_url = real_url
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN)
|
color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN)
|
||||||
found = True
|
found = True
|
||||||
|
|||||||
Reference in New Issue
Block a user