From 924d05d06e281d71637bde698009970709604a6d Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 10 Jul 2026 05:15:29 -0500 Subject: [PATCH] 's' --- config/URL_config.ini | 2 +- config/relay_pro/url_config.ch_1774436412.ini | 2 +- config/relay_pro/url_config.ch_1774436414.ini | 1 - config/relay_pro/url_config.ch_1774436422.ini | 1 - config/relay_pro/url_config.ch_1774436429.ini | 1 - config/relay_pro/url_config.ch_1774436432.ini | 1 - config/relay_pro/url_config.ch_1774436839.ini | 1 - config/relay_pro/url_config.ch_1774438186.ini | 1 - config/relay_pro/url_config.ch_1774438189.ini | 1 - config/relay_pro/url_config.ch_1774438190.ini | 1 - .../relay_pro/url_config.ch_1774438190_10.ini | 1 - config/relay_pro/url_config.ch_1774438193.ini | 1 - config/relay_pro/url_config.ch_1774438194.ini | 1 - .../relay_pro/url_config.ch_1774438194_13.ini | 1 - config/relay_pro/url_config.ch_1774438199.ini | 1 - .../relay_pro/url_config.ch_1774438199_15.ini | 1 - config/relay_pro/url_config.ch_1774439571.ini | 1 - config/relay_pro/url_config.ch_1779328176.ini | 2 +- config/relay_pro/youtube.ch_1774436412.ini | 2 +- config/relay_pro/youtube.ch_1779328176.ini | 7 + config/youtube.ini | 2 +- config/youtube_channels.json | 2 +- douyin_youtube_ffplay.py | 323 ++++++- electron/electron.vite.config.ts | 37 + electron/package.json | 4 +- electron/pnpm-lock.yaml | 16 + electron/src/main/clientDb.ts | 27 +- electron/src/main/index.ts | 5 +- electron/src/renderer/src/App.css | 323 ++++++- electron/src/renderer/src/App.tsx | 591 ++++++++++-- .../src/renderer/src/NetworkDashboard.tsx | 35 +- electron/src/renderer/src/api.ts | 3 +- .../src/components/ConfigAdvisorPanel.tsx | 132 +++ .../src/components/LiveEventsPanel.tsx | 176 ++++ .../src/components/LiveYoutubeMonitor.tsx | 38 +- .../src/components/QualityGuardBanner.tsx | 115 +-- .../renderer/src/components/RelayLogPage.tsx | 37 + .../renderer/src/components/RelayLogPanel.tsx | 545 ++++++++--- .../components/YoutubeDiagnosticsPanel.tsx | 137 +++ .../src/components/icons/NavIcons.tsx | 18 +- .../src/components/live/LiveProLaneRow.tsx | 46 +- .../live/LiveProcessLiveLogCard.tsx | 18 +- .../components/live/LiveYoutubeOverview.tsx | 316 ++++--- .../network/NetworkToolkitPanel.tsx | 245 +++++ electron/src/renderer/src/constants.ts | 39 + .../src/hooks/useBrowserFingerprint.ts | 61 ++ .../src/renderer/src/hooks/useIsChinaUser.ts | 73 ++ electron/src/renderer/src/live-polish.css | 10 + electron/src/renderer/src/network-polish.css | 40 + electron/src/renderer/src/youtubeLiveUtils.ts | 12 +- electron/src/shared/featureFlags.ts | 4 +- web2.py | 362 +++++++- web2_client_overview.py | 10 +- web2_config_governance.py | 40 + web2_config_insights.py | 313 +++++++ web2_network.py | 310 +++++++ web2_network_routes.py | 35 +- web2_push_proxy.py | 208 +++++ web2_quality_guard.py | 87 +- web2_relay_log.py | 874 +++++++++++++++--- web2_relay_pro.py | 78 +- web2_supervisor.py | 32 +- web2_youtube_encoder_cache.py | 63 ++ web2_youtube_ingest_auth.py | 62 ++ web2_youtube_live_store.py | 868 +++++++++++++++++ web2_youtube_preflight.py | 152 ++- web2_youtube_pro_runtime.py | 239 +++++ web2_youtube_relay_heartbeat.py | 56 ++ web2_youtube_relay_slot.py | 116 +++ web2_youtube_runtime.py | 68 ++ youtube2.py | 62 +- 71 files changed, 6815 insertions(+), 680 deletions(-) create mode 100644 config/relay_pro/youtube.ch_1779328176.ini create mode 100644 electron/src/renderer/src/components/ConfigAdvisorPanel.tsx create mode 100644 electron/src/renderer/src/components/LiveEventsPanel.tsx create mode 100644 electron/src/renderer/src/components/RelayLogPage.tsx create mode 100644 electron/src/renderer/src/components/YoutubeDiagnosticsPanel.tsx create mode 100644 electron/src/renderer/src/components/network/NetworkToolkitPanel.tsx create mode 100644 electron/src/renderer/src/hooks/useBrowserFingerprint.ts create mode 100644 electron/src/renderer/src/hooks/useIsChinaUser.ts create mode 100644 web2_config_insights.py create mode 100644 web2_push_proxy.py create mode 100644 web2_youtube_encoder_cache.py create mode 100644 web2_youtube_ingest_auth.py create mode 100644 web2_youtube_live_store.py create mode 100644 web2_youtube_pro_runtime.py create mode 100644 web2_youtube_relay_heartbeat.py create mode 100644 web2_youtube_relay_slot.py create mode 100644 web2_youtube_runtime.py diff --git a/config/URL_config.ini b/config/URL_config.ini index 8491383..84b3d97 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1 +1 @@ -https://live.douyin.com/11664523242 \ No newline at end of file +https://live.douyin.com/11664523242 diff --git a/config/relay_pro/url_config.ch_1774436412.ini b/config/relay_pro/url_config.ch_1774436412.ini index cea48af..dd8a264 100644 --- a/config/relay_pro/url_config.ch_1774436412.ini +++ b/config/relay_pro/url_config.ch_1774436412.ini @@ -1,2 +1,2 @@ -https://live.douyin.com/510162746831,主播: 海阳市叁娃面食店 +https://live.douyin.com/510162746831,主播: 海阳市叁娃面食店 https://live.douyin.com/622059369101,主播: 于小胖的小摊日记 diff --git a/config/relay_pro/url_config.ch_1774436414.ini b/config/relay_pro/url_config.ch_1774436414.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774436414.ini +++ b/config/relay_pro/url_config.ch_1774436414.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774436422.ini b/config/relay_pro/url_config.ch_1774436422.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774436422.ini +++ b/config/relay_pro/url_config.ch_1774436422.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774436429.ini b/config/relay_pro/url_config.ch_1774436429.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774436429.ini +++ b/config/relay_pro/url_config.ch_1774436429.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774436432.ini b/config/relay_pro/url_config.ch_1774436432.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774436432.ini +++ b/config/relay_pro/url_config.ch_1774436432.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774436839.ini b/config/relay_pro/url_config.ch_1774436839.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774436839.ini +++ b/config/relay_pro/url_config.ch_1774436839.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438186.ini b/config/relay_pro/url_config.ch_1774438186.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438186.ini +++ b/config/relay_pro/url_config.ch_1774438186.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438189.ini b/config/relay_pro/url_config.ch_1774438189.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438189.ini +++ b/config/relay_pro/url_config.ch_1774438189.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438190.ini b/config/relay_pro/url_config.ch_1774438190.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438190.ini +++ b/config/relay_pro/url_config.ch_1774438190.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438190_10.ini b/config/relay_pro/url_config.ch_1774438190_10.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438190_10.ini +++ b/config/relay_pro/url_config.ch_1774438190_10.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438193.ini b/config/relay_pro/url_config.ch_1774438193.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438193.ini +++ b/config/relay_pro/url_config.ch_1774438193.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438194.ini b/config/relay_pro/url_config.ch_1774438194.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438194.ini +++ b/config/relay_pro/url_config.ch_1774438194.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438194_13.ini b/config/relay_pro/url_config.ch_1774438194_13.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438194_13.ini +++ b/config/relay_pro/url_config.ch_1774438194_13.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438199.ini b/config/relay_pro/url_config.ch_1774438199.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438199.ini +++ b/config/relay_pro/url_config.ch_1774438199.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774438199_15.ini b/config/relay_pro/url_config.ch_1774438199_15.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774438199_15.ini +++ b/config/relay_pro/url_config.ch_1774438199_15.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1774439571.ini b/config/relay_pro/url_config.ch_1774439571.ini index f9fe112..f31c2d2 100644 --- a/config/relay_pro/url_config.ch_1774439571.ini +++ b/config/relay_pro/url_config.ch_1774439571.ini @@ -1,3 +1,2 @@ # 每行一个抖音直播间地址,例如: # https://live.douyin.com/123456789 - diff --git a/config/relay_pro/url_config.ch_1779328176.ini b/config/relay_pro/url_config.ch_1779328176.ini index f690860..b8e029f 100644 --- a/config/relay_pro/url_config.ch_1779328176.ini +++ b/config/relay_pro/url_config.ch_1779328176.ini @@ -1,2 +1,2 @@ https://live.douyin.com/29155980665,主播: 摆摊的小楠 -https://live.douyin.com/709421244480,主播: 阿彪(阿超盖浇饭) \ No newline at end of file +https://live.douyin.com/709421244480,主播: 阿彪(阿超盖浇饭) diff --git a/config/relay_pro/youtube.ch_1774436412.ini b/config/relay_pro/youtube.ch_1774436412.ini index 4dd77ee..f3ee8e8 100644 --- a/config/relay_pro/youtube.ch_1774436412.ini +++ b/config/relay_pro/youtube.ch_1774436412.ini @@ -1,5 +1,5 @@ [youtube] -key = 112313123124124124124124124df +key = hey4-d3mu-77k3-zedz-2p56 fast_audio = 1 output_resolution = 720p use_gpu = 无 diff --git a/config/relay_pro/youtube.ch_1779328176.ini b/config/relay_pro/youtube.ch_1779328176.ini new file mode 100644 index 0000000..0d2be2c --- /dev/null +++ b/config/relay_pro/youtube.ch_1779328176.ini @@ -0,0 +1,7 @@ +[youtube] +key = 3pj0-q9k7-surm-kgc0-ce3p +fast_audio = 1 +output_resolution = 720p +use_gpu = 无 +audio_denoise = 否 + diff --git a/config/youtube.ini b/config/youtube.ini index fe96b16..179d714 100644 --- a/config/youtube.ini +++ b/config/youtube.ini @@ -1,5 +1,5 @@ [youtube] -key = 112313123124124124124124124df +key = hey4-d3mu-77k3-zedz-2p56 fast_audio = 1 output_resolution = 720p use_gpu = 无 diff --git a/config/youtube_channels.json b/config/youtube_channels.json index ba5bc70..4482446 100644 --- a/config/youtube_channels.json +++ b/config/youtube_channels.json @@ -6,7 +6,7 @@ "id": "ch_1774436412", "name": "频道 2", "douyinUrl": "", - "youtubeKey": "3pj0-q9k7-surm-kgc0-ce3p2" + "youtubeKey": "hey4-d3mu-77k3-zedz-2p56" }, { "id": "ch_1774436414", diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py index c0b07b2..906be3a 100644 --- a/douyin_youtube_ffplay.py +++ b/douyin_youtube_ffplay.py @@ -11,8 +11,20 @@ import re import os import json import configparser +from pathlib import Path from web2_proxy_config import merge_child_env +from web2_push_proxy import ( + apply_push_proxy_to_ffmpeg_cmd, + ffmpeg_env_with_push_proxy, + normalize_push_proxy_mode, + redact_proxy_url, + resolve_youtube_push_proxy, +) +from web2_youtube_encoder_cache import get_cached_encoder, store_cached_encoder +from web2_youtube_runtime import resolve_live_video_encoder +from web2_youtube_relay_heartbeat import clear_relay_heartbeat, write_relay_heartbeat +from web2_youtube_relay_slot import release_relay_slot class StreamEnded(Exception): @@ -20,6 +32,38 @@ class StreamEnded(Exception): pass +class SourceStalled(Exception): + """源流卡死/无进度,交回监控循环或切换备用源""" + pass + + +def _ffmpeg_speed_value(line: str) -> float | None: + match = re.search(r"speed\s*=\s*([0-9.]+)x", line, re.IGNORECASE) + if not match: + return None + try: + return float(match.group(1)) + except ValueError: + return None + + +def _ffmpeg_progress_values(line: str) -> tuple[int | None, float | None]: + frame = None + media_time = None + frame_match = re.search(r"frame\s*=\s*(\d+)", line, re.IGNORECASE) + if frame_match: + frame = int(frame_match.group(1)) + time_match = re.search(r"time\s*=\s*(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)", line, re.IGNORECASE) + if time_match: + h, m, s = time_match.groups() + media_time = int(h) * 3600 + int(m) * 60 + float(s) + return frame, media_time + + +def _looks_like_invalid_ts_drop(line: str) -> bool: + return "invalid dropping" in line or "non monotonically increasing dts" in line + + def sanitize_title(text: str, max_len: int = 120) -> str: if not text: return "" @@ -76,6 +120,10 @@ def start_douyin_youtube_ffplay( monitoring: list, clear_record_info, color_obj, + *, + record_url: str = "", + config_file: str = "", + fallback_urls: list[str] | None = None, ): """ 抖音 → YouTube 推流主函数(单路推流) @@ -83,7 +131,7 @@ def start_douyin_youtube_ffplay( """ # ====================== 读取 YouTube 配置 ====================== - config_file = os.environ.get("YOUTUBE_INI_PATH", "config/youtube.ini") + config_file = (config_file or os.environ.get("YOUTUBE_INI_PATH") or "config/youtube.ini").strip() rtmps_base = "rtmps://a.rtmp.youtube.com/live2/" rtmp_base = "rtmp://a.rtmp.youtube.com/live2/" use_rtmps = True @@ -136,6 +184,26 @@ def start_douyin_youtube_ffplay( raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key") # ====================== 常量配置 ====================== + _script_dir = os.path.dirname(os.path.abspath(__file__)) + _relay_pm2 = ( + os.environ.get("LIVE_PM2_PROCESS_NAME") or os.environ.get("PM2_SERVICE_NAME") or "youtube" + ).strip() or "youtube" + + def _emit_relay_heartbeat(state: str, **extra: object) -> None: + write_relay_heartbeat( + _relay_pm2, + { + "state": state, + "stream_title": stream_title, + "source_url": real_url, + "codec": codec_v, + "record_name": record_name, + "anchor_name": anchor_name, + **extra, + }, + base_dir=_script_dir, + ) + MAX_RETRY_DELAY = 90 INITIAL_RETRY_DELAY = 3 STDERR_QUEUE_TIMEOUT = 1.0 @@ -143,25 +211,34 @@ def start_douyin_youtube_ffplay( START_CHECK_AFTER = 25 STALE_OUTPUT_SECONDS = 40 RECONNECT_DELAY_MAX = 10 + FAST_SPEED_THRESHOLD = 1.08 + FAST_SPEED_STRIKES_NEEDED = 3 + INVALID_DROP_WINDOW_SECONDS = 30 + INVALID_DROP_LIMIT = 80 - END_KEYWORDS = [ + TERMINAL_KEYWORDS = [ + "live has ended", + "server returned 404", + "ingestion error", + "access denied", + "server returned 403", + ] + TRANSIENT_KEYWORDS = [ "connection reset by peer", "failed to write trailer", "invalid data found", - "server returned 403", - "server returned 404", "server returned 500", - "ingestion error", - "access denied", "packet too short", - "client disconnected", - "live has ended", "no route to host", "connection timed out", "connection refused", "error writing output", "broken pipe", "i/o error", + "client disconnected", + "error opening output file", + "error opening output files", + "error opening output", ] douyin_headers = ( @@ -193,6 +270,45 @@ def start_douyin_youtube_ffplay( except Exception: pass + try: + from web2_relay_log import on_relay_push_start, relay_db_path + + ini_text = "" + if os.path.exists(config_file): + with open(config_file, encoding="utf-8-sig") as ini_fp: + ini_text = ini_fp.read() + on_relay_push_start( + process=_relay_pm2, + anchor_name=anchor_name, + record_url=record_url or record_name, + stream_title=stream_title, + real_url=real_url, + youtube_ini_text=ini_text, + db_path=relay_db_path(Path(_script_dir)), + base_dir=Path(_script_dir), + ) + except Exception as relay_log_exc: + print(f"[WARN] relay session log start skipped: {relay_log_exc}") + + source_candidates: list[str] = [] + _seen_source: set[str] = set() + for item in [real_url, *(fallback_urls or [])]: + u = (item or "").strip() + if not u or u in _seen_source: + continue + if not u.startswith("http"): + continue + _seen_source.add(u) + source_candidates.append(u) + if not source_candidates: + source_candidates = [real_url] + source_index = 0 + + push_proxy_mode = normalize_push_proxy_mode() + push_proxy = resolve_youtube_push_proxy() if push_proxy_mode != "off" else "" + if push_proxy: + print(f"[INFO] YouTube push proxy ({push_proxy_mode}): {redact_proxy_url(push_proxy)}") + if "1080" in output_resolution: _sw, _sh = "1080", "1920" else: @@ -202,13 +318,36 @@ def start_douyin_youtube_ffplay( f"pad={_sw}:{_sh}:(ow-iw)/2:(oh-ih)/2:black" ) - # ====================== NVENC 检测(符合 YouTube 推荐比特率)====================== + # ====================== NVENC / 编码器选择 ====================== codec_v = "libx264" - # ultrafast+zerolatency:在软编下尽量让 speed≥1.0;画质略降、直播可接受 video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0", "-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize] + _machine = platform.machine().lower() + _want_enc = (os.environ.get("LIVE_VIDEO_ENCODER") or "").strip() try: - if ( + if platform.system().lower() == "linux": + _cached_enc = get_cached_encoder(_machine, "ffmpeg", base_dir=_script_dir) + if _cached_enc: + codec_v = _cached_enc + if _cached_enc == "h264_nvenc": + video_args = ["-profile:v", "high", "-level", "4.0", + "-tune", "ll", "-rc", "cbr", "-b:v", b_v, + "-maxrate", maxrate, "-bufsize", bufsize] + print(f"[INFO] 使用编码器缓存: {codec_v}") + elif not force_software_video: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=6) + blob = (enc.stdout or "") + (enc.stderr or "") + selected, note = resolve_live_video_encoder(_want_enc, blob, _machine) + if note: + print(f"[INFO] {note}") + if selected: + codec_v = selected + if selected == "h264_nvenc": + video_args = ["-profile:v", "high", "-level", "4.0", + "-tune", "ll", "-rc", "cbr", "-b:v", b_v, + "-maxrate", maxrate, "-bufsize", bufsize] + store_cached_encoder(_machine, "ffmpeg", codec_v, base_dir=_script_dir) + elif ( not force_software_video and platform.system().lower() in ["windows", "linux"] ): @@ -224,10 +363,9 @@ def start_douyin_youtube_ffplay( elif force_software_video: print("[INFO] use_gpu=否/无:强制软件编码 libx264") except Exception as e: - print(f"[WARN] NVENC 检测失败,回退软件编码: {e}") + print(f"[WARN] 编码器检测失败,回退软件编码: {e}") # ====================== 开源音频处理:背景音乐弱化 + 防版权 ====================== - _script_dir = os.path.dirname(os.path.abspath(__file__)) _ffmpeg_cwd = None if fast_audio: print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ),减轻 CPU 以利 speed≥1.0") @@ -275,38 +413,46 @@ def start_douyin_youtube_ffplay( "-bf", "0", "-sc_threshold", "0"] if codec_v == "libx264": _video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"] - # -re:按源时间戳节奏拉流,避免突发过快;不用 realtime/arealtime(输入抖动时易让 YouTube 判为数据不足) - ffmpeg_command = [ - "ffmpeg", "-y", "-loglevel", "info", "-nostdin", - "-stats", "-stats_period", "1", - "-rw_timeout", "50000000", - "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", - "-reconnect_delay_max", str(RECONNECT_DELAY_MAX), - "-fflags", "+genpts+discardcorrupt", - "-err_detect", "ignore_err", - "-max_delay", "500000", - "-thread_queue_size", "2048", - "-filter_threads", "4", - "-headers", douyin_headers, - "-re", - "-i", real_url, - # bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x - "-vf", scale_vf, - "-c:v", codec_v, - *video_args, - *_video_mid, - "-pix_fmt", "yuv420p", - "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", - "-af", _af_chain, - "-flags", "+global_header", - "-flvflags", "no_duration_filesize", - "-f", "flv", youtube_rtmp - ] + def _build_ffmpeg_command(input_url: str) -> list[str]: + cmd = [ + "ffmpeg", "-y", "-loglevel", "info", "-nostdin", + "-stats", "-stats_period", "1", + "-rw_timeout", "50000000", + "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", + "-reconnect_delay_max", str(RECONNECT_DELAY_MAX), + "-fflags", "+genpts+discardcorrupt", + "-err_detect", "ignore_err", + "-max_delay", "500000", + "-thread_queue_size", "2048", + "-filter_threads", "4", + "-headers", douyin_headers, + "-re", + "-i", input_url, + "-vf", scale_vf, + "-c:v", codec_v, + *video_args, + *_video_mid, + "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", + "-af", _af_chain, + "-flags", "+global_header", + "-flvflags", "no_duration_filesize", + "-f", "flv", youtube_rtmp, + ] + return apply_push_proxy_to_ffmpeg_cmd(cmd, push_proxy, push_proxy_mode, base_dir=_script_dir) + + ffmpeg_command, _uses_proxychains = _build_ffmpeg_command(source_candidates[source_index]) + _emit_relay_heartbeat("starting") proc = None stderr_q = None reader_t = None retry_delay = INITIAL_RETRY_DELAY + fast_speed_strikes = 0 + invalid_drop_count = 0 + invalid_drop_window_start = 0.0 + last_progress_frame = 0 + last_progress_time = 0.0 try: while True: @@ -329,7 +475,13 @@ def start_douyin_youtube_ffplay( ) if _ffmpeg_cwd: _popen_kw["cwd"] = _ffmpeg_cwd - _popen_kw["env"] = merge_child_env() + base_env = merge_child_env() + _popen_kw["env"] = ffmpeg_env_with_push_proxy( + push_proxy, + push_proxy_mode, + use_proxychains=_uses_proxychains, + base_env=base_env, + ) proc = subprocess.Popen(ffmpeg_command, **_popen_kw) proc.last_out_ts = time.time() @@ -338,6 +490,7 @@ def start_douyin_youtube_ffplay( reader_t.start() start_time = last_frame_time = time.time() + fast_speed_strikes = 0 while True: try: @@ -347,7 +500,7 @@ def start_douyin_youtube_ffplay( if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS: print("[WARN] 长时间无任何日志,强制重启进程") cleanup_proc(proc) - break + raise SourceStalled() continue if line is None: @@ -364,22 +517,69 @@ def start_douyin_youtube_ffplay( proc.last_out_ts = time.time() print(f"[FFmpeg] {line}") line_lower = line.lower() + is_progress_line = "frame=" in line_lower and "speed=" in line_lower - if any(kw in line_lower for kw in END_KEYWORDS): + if any(kw in line_lower for kw in TERMINAL_KEYWORDS): print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流") cleanup_proc(proc) raise StreamEnded() - if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower: - last_frame_time = time.time() + if any(kw in line_lower for kw in TRANSIENT_KEYWORDS): + print("[WARN] 检测到瞬时网络/IO 错误,准备重连") + cleanup_proc(proc) + retry_delay = min(max(retry_delay * 2, INITIAL_RETRY_DELAY), MAX_RETRY_DELAY) + break + + now_ts = time.time() + if _looks_like_invalid_ts_drop(line_lower): + if now_ts - invalid_drop_window_start > INVALID_DROP_WINDOW_SECONDS: + invalid_drop_window_start = now_ts + invalid_drop_count = 0 + invalid_drop_count += 1 + if invalid_drop_count >= INVALID_DROP_LIMIT: + print("[WARN] 源流时间戳异常过多,重启 FFmpeg") + cleanup_proc(proc) + raise SourceStalled() + + if is_progress_line: + speed = _ffmpeg_speed_value(line_lower) + frame, media_time = _ffmpeg_progress_values(line_lower) + _emit_relay_heartbeat( + "streaming", + frame=frame, + media_time=media_time, + speed=speed, + source_index=source_index, + ) + if speed is not None and time.time() - start_time > START_CHECK_AFTER: + if speed > FAST_SPEED_THRESHOLD: + fast_speed_strikes += 1 + if fast_speed_strikes >= FAST_SPEED_STRIKES_NEEDED: + print(f"[WARN] 推流 speed={speed:.2f}x 异常,重启 FFmpeg") + cleanup_proc(proc) + raise SourceStalled() + else: + fast_speed_strikes = 0 + + frame, media_time = _ffmpeg_progress_values(line_lower) + if frame is not None or media_time is not None: + frame_advanced = frame is not None and frame > last_progress_frame + time_advanced = media_time is not None and media_time > last_progress_time + 0.01 + if frame_advanced or time_advanced: + last_frame_time = time.time() + retry_delay = INITIAL_RETRY_DELAY + if frame is not None: + last_progress_frame = frame + if media_time is not None: + last_progress_time = media_time if time.time() - start_time > START_CHECK_AFTER: if time.time() - last_frame_time > NO_FRAME_TIMEOUT: - print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播") + print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,交回监控循环重新获取直播源") cleanup_proc(proc) - raise StreamEnded() + raise SourceStalled() - except StreamEnded: + except (StreamEnded, SourceStalled): raise except Exception as e: print(f"[ERROR] 内层异常: {e}") @@ -394,6 +594,20 @@ def start_douyin_youtube_ffplay( print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}") break + except SourceStalled: + if source_index + 1 < len(source_candidates): + source_index += 1 + ffmpeg_command, _uses_proxychains = _build_ffmpeg_command(source_candidates[source_index]) + print( + f"[WARN] 切换备用源 URL ({source_index + 1}/{len(source_candidates)}): " + f"{source_candidates[source_index][:120]}" + ) + _emit_relay_heartbeat("source_failover", source_index=source_index) + retry_delay = INITIAL_RETRY_DELAY + continue + print(f"[WARN] 当前直播源无进度,返回监控循环刷新源地址 → {record_name}") + break + except Exception as e: print(f"[ERROR] 外层异常: {e}") traceback.print_exc() @@ -405,6 +619,19 @@ def start_douyin_youtube_ffplay( traceback.print_exc() finally: cleanup_proc(proc) + clear_relay_heartbeat(_relay_pm2, base_dir=_script_dir) + release_relay_slot(record_name, pm2=_relay_pm2, base_dir=_script_dir) + try: + from web2_relay_log import on_relay_push_end, relay_db_path + + on_relay_push_end( + _relay_pm2, + anchor_name=anchor_name, + reason="push_stopped", + db_path=relay_db_path(Path(_script_dir)), + ) + except Exception as relay_log_exc: + print(f"[WARN] relay session log end skipped: {relay_log_exc}") try: recording.discard(record_name) diff --git a/electron/electron.vite.config.ts b/electron/electron.vite.config.ts index d147d82..53d70da 100644 --- a/electron/electron.vite.config.ts +++ b/electron/electron.vite.config.ts @@ -2,6 +2,35 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' import { resolve } from 'path' +/** 与 d2ypp2 next.config dev rewrites 对齐:开发时同源反代 web2,避免 localhost→127.0.0.1 跨域 */ +const WEB2_DEV_PROXY_TARGET = process.env.API_PROXY_TARGET || 'http://127.0.0.1:8001' +const WEB2_DEV_PROXY_PREFIXES = [ + '/client', + '/relay_pro', + '/douyin', + '/host', + '/get_config', + '/save_config', + '/youtube', + '/get_url_config', + '/save_url_config', + '/camera', + '/pull_stream', + '/start', + '/stop', + '/restart', + '/status', + '/health', + '/proxy_config', + '/system_metrics', + '/config', + '/process_monitor', + '/network_status', + '/network_toolkit', + '/ip_profile', + '/ip_quality', +] + export default defineConfig({ main: { resolve: { @@ -35,5 +64,13 @@ export default defineConfig({ }, }, plugins: [react()], + server: { + proxy: Object.fromEntries( + WEB2_DEV_PROXY_PREFIXES.map((prefix) => [ + prefix, + { target: WEB2_DEV_PROXY_TARGET, changeOrigin: true }, + ]), + ), + }, }, }) diff --git a/electron/package.json b/electron/package.json index 37e5103..626614d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -16,10 +16,12 @@ "dist": "pnpm run prepare:runtime && pnpm run build && pnpm run gen:icons && pnpm run verify:runtime && electron-builder --win --publish never" }, "dependencies": { + "@fingerprintjs/fingerprintjs": "^5.2.0", "d3": "^7.9.0", "drizzle-orm": "^0.44.2", "echarts": "^6.0.0", "framer-motion": "^12.23.12", + "is-china-user": "^1.7.0", "lucide-react": "^0.525.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -29,13 +31,13 @@ "youtubei.js": "^17.2.0" }, "devDependencies": { + "@resvg/resvg-js": "^2.6.2", "@types/d3": "^7.4.3", "@types/node": "^22.10.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", - "@resvg/resvg-js": "^2.6.2", "electron": "^33.2.0", "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml index 72b98f5..06d69cd 100644 --- a/electron/pnpm-lock.yaml +++ b/electron/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@fingerprintjs/fingerprintjs': + specifier: ^5.2.0 + version: 5.2.0 d3: specifier: ^7.9.0 version: 7.9.0 @@ -20,6 +23,9 @@ importers: framer-motion: specifier: ^12.23.12 version: 12.42.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + is-china-user: + specifier: ^1.7.0 + version: 1.7.0 lucide-react: specifier: ^0.525.0 version: 0.525.0(react@18.3.1) @@ -348,6 +354,9 @@ packages: cpu: [x64] os: [win32] + '@fingerprintjs/fingerprintjs@5.2.0': + resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -1684,6 +1693,9 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + is-china-user@1.7.0: + resolution: {integrity: sha512-qVhmz+TwK8+WWtEILYZd9tgt7oNsVj0T6VaMqiUlGOltZ64/HTJ4iszNu2KBRM5marxN9BNiMcJjRENsJau6Aw==} + is-ci@3.0.1: resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} hasBin: true @@ -2711,6 +2723,8 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@fingerprintjs/fingerprintjs@5.2.0': {} + '@gar/promisify@1.1.3': {} '@isaacs/cliui@8.0.2': @@ -4145,6 +4159,8 @@ snapshots: ip-address@10.1.0: {} + is-china-user@1.7.0: {} + is-ci@3.0.1: dependencies: ci-info: 3.9.0 diff --git a/electron/src/main/clientDb.ts b/electron/src/main/clientDb.ts index b5adca6..12f6f79 100644 --- a/electron/src/main/clientDb.ts +++ b/electron/src/main/clientDb.ts @@ -294,6 +294,19 @@ function safeChannelFileId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default' } +function urlConfigHasLiveUrls(text: string): boolean { + return /live\.douyin\.com|v\.douyin\.com/i.test(text || '') +} + +function readUrlConfigFileIfPresent(filePath: string): string { + if (!fs.existsSync(filePath)) return '' + try { + return fs.readFileSync(filePath, 'utf-8') + } catch { + return '' + } +} + function importRelayFromYoutubeJson(configDir: string): boolean { if (!db) return false const p = path.join(configDir, 'youtube_channels.json') @@ -369,7 +382,19 @@ export function exportRelayChannelsToConfigDir(configDir: string): void { youtubeKey, }) const uf = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`) - fs.writeFileSync(uf, urlList || '# 每行一个源站直播间地址\n', 'utf-8') + let urlToWrite = urlList.trim() + if (!urlConfigHasLiveUrls(urlToWrite)) { + const onDisk = readUrlConfigFileIfPresent(uf) + if (urlConfigHasLiveUrls(onDisk)) { + urlToWrite = onDisk.trim() + } + } + if (urlConfigHasLiveUrls(urlToWrite)) { + const body = urlToWrite.endsWith('\n') ? urlToWrite : `${urlToWrite}\n` + fs.writeFileSync(uf, body, 'utf-8') + } else if (!fs.existsSync(uf)) { + fs.writeFileSync(uf, '# 每行一个源站直播间地址\n', 'utf-8') + } } const doc = { version: 1, diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 1b8a1e1..992a98a 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -601,7 +601,10 @@ if (!gotLock) { ipcMain.handle('web2:get-api-token', () => resolveWeb2ApiTokenForChild() ?? null) ipcMain.removeHandler('web2:get-api-base') - ipcMain.handle('web2:get-api-base', () => `http://${healthCheckHost()}:${PORT}`) + ipcMain.handle('web2:get-api-base', () => { + if (!app.isPackaged) return '' + return `http://${healthCheckHost()}:${PORT}` + }) ipcMain.removeHandler('app:is-ready') ipcMain.handle('app:is-ready', () => appUiReadyFlag) diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 46e935f..5bb7498 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1360,6 +1360,11 @@ body.ready { min-width: 10rem; } +.relay-log-card__process { + flex: 0 1 14rem; + min-width: 11rem; +} + .relay-log-card__status { min-width: 7rem; } @@ -1707,6 +1712,13 @@ body.ready { box-shadow: var(--shadow-inset-top), 0 4px 14px rgba(195, 90, 18, 0.28); } +.ctrl-btn--log { + background: linear-gradient(165deg, #8b6cf0 0%, #6d4ed8 45%, #5b3cc4 100%); + border-color: rgba(0, 0, 0, 0.1); + color: #f3e8ff; + box-shadow: var(--shadow-inset-top), 0 4px 14px rgba(109, 78, 216, 0.28); +} + .ctrl-btn--status { background: linear-gradient(180deg, #323842 0%, #2a2f38 100%); color: var(--text); @@ -1860,14 +1872,26 @@ body.ready { color: #5bd38a; } -.pro-live-switch-tag.is-running { - color: #c4b5fd; +.pro-live-switch-tag.is-ready { + color: #7dd3fc; +} + +.pro-live-switch-tag.is-warn { + color: #fbbf24; +} + +.pro-live-switch-tag.is-stalled { + color: #f87171; } .pro-live-switch-tag.is-running { color: #c4b5fd; } +.pro-live-switch-tag.is-idle { + color: var(--text-muted); +} + .pro-live-switch-emoji { font-size: 12px; line-height: 1; @@ -1930,6 +1954,42 @@ body.ready { justify-content: flex-end; } +.live-control-notice { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border-subtle)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated)); +} + +.card--accent > .live-control-notice { + margin-top: 4px; +} + +.live-control-notice__text { + flex: 1; + margin: 0; + font-family: inherit; + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + +.live-control-notice__dismiss { + flex-shrink: 0; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + padding: 2px 4px; +} + .pro-channel-live-btn--restart { border-color: rgba(91, 157, 255, 0.4); color: var(--accent); @@ -4836,14 +4896,77 @@ button.net-badge { padding: 0; } +.relay-log-card__date { + max-width: 10.5rem; +} + .relay-log-list__row { display: flex; justify-content: space-between; + align-items: center; gap: 0.75rem; padding: 0.55rem 0; border-top: 1px solid var(--border-subtle); } +.relay-log-list__cover { + width: 52px; + height: 72px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; + background: var(--surface-elevated); +} + +.live-events-card__section { + margin-top: 0.75rem; +} + +.live-events-card__item { + display: block; + width: 100%; + text-align: left; + padding: 0.45rem 0; + border: 0; + background: transparent; + cursor: pointer; + color: inherit; +} + +.live-events-card__item--active strong { + color: var(--accent); +} + +.live-events-card__samples { + margin: 0.5rem 0 0; + padding-left: 1rem; +} + +.relay-log-list__row--btn { + display: flex; + width: 100%; + text-align: left; + border: 0; + background: transparent; + cursor: pointer; + color: inherit; + align-items: center; +} + +.relay-log-list__row--active { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.relay-log-card__detail { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-subtle); +} + +.quality-guard__lane { + margin: 0.25rem 0 0; +} + .relay-log-list__sub { display: block; font-size: 0.78rem; @@ -4862,6 +4985,202 @@ button.net-badge { color: var(--text-muted); } +.relay-log-table-wrap { + overflow-x: auto; + margin-top: 0.5rem; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} + +.relay-log-table { + width: 100%; + min-width: 960px; + border-collapse: collapse; + font-size: 0.86rem; +} + +.relay-log-table thead { + background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); +} + +.relay-log-table th, +.relay-log-table td { + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); + text-align: left; + vertical-align: middle; +} + +.relay-log-table th { + font-size: 0.72rem; + font-weight: 650; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.relay-log-table__row { + cursor: pointer; + transition: background 0.15s ease; +} + +.relay-log-table__row:hover { + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.relay-log-table__row--active { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.relay-log-table__cover { + display: block; + width: 2.5rem; + height: 4rem; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border-subtle); +} + +.relay-log-table__cover--empty { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + color: var(--text-muted); + background: var(--bg-elevated); +} + +.relay-log-table__url { + max-width: 14rem; +} + +.relay-log-table__link { + margin: 0; + padding: 0; + border: none; + background: none; + color: var(--accent); + font: inherit; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.78rem; + text-align: left; + cursor: pointer; + text-decoration: underline; + word-break: break-all; +} + +.relay-log-table__ts { + white-space: nowrap; + color: var(--text-muted); +} + +.relay-log-table__mono { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8rem; +} + +.relay-log-table__lane { + font-size: 0.8rem; + color: var(--text-muted); +} + +.relay-log-table__empty { + padding: 2.5rem 1rem !important; + text-align: center; + color: var(--text-muted); +} + +.relay-log-modal { + position: fixed; + inset: 0; + z-index: 120; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(8, 10, 16, 0.72); +} + +.relay-log-modal__panel { + width: min(42rem, 100%); + max-height: 90vh; + overflow: auto; + padding: 1.25rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} + +.relay-log-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.relay-log-modal__title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; +} + +.relay-log-modal__sub { + margin: 0.35rem 0 0; + font-size: 0.86rem; + color: var(--text-muted); +} + +.relay-log-modal__close { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; +} + +.relay-log-modal__body { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +@media (min-width: 720px) { + .relay-log-modal__body { + grid-template-columns: 8rem 1fr; + align-items: start; + } +} + +.relay-log-modal__cover { + width: 100%; + height: 9rem; + object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); +} + +.relay-log-modal__dl { + display: grid; + gap: 0.65rem; + margin: 0; +} + +.relay-log-modal__dl dt { + font-size: 0.75rem; + color: var(--text-muted); +} + +.relay-log-modal__dl dd { + margin: 0.15rem 0 0; + font-size: 0.88rem; + word-break: break-word; +} + +.relay-log-page .relay-log-card { + margin: 0; +} + .config-backups-inline { margin-top: 0.45rem; } diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 3905d55..c177f03 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -21,7 +21,9 @@ import { import CameraLivePage from './CameraLivePage' import PullStreamPage from './PullStreamPage' import SidebarBrand from './components/SidebarBrand' -import RelayLogPanel from './components/RelayLogPanel' +import RelayLogPage from './components/RelayLogPage' +import ConfigAdvisorPanel from './components/ConfigAdvisorPanel' +import LiveEventsPanel from './components/LiveEventsPanel' import RelayValidationBanner from './components/RelayValidationBanner' import ConfigBackupsInline from './components/ConfigBackupsInline' import LiveProLaneRow from './components/live/LiveProLaneRow' @@ -38,6 +40,9 @@ import { logLiveEvent, logUserAction } from './workspace' import { STORAGE_MULTI_CHANNEL_PRO, UI_DROPDOWN_YOUTUBE_ONLY, + UI_SHOW_CONFIG_ADVISOR, + UI_SHOW_LIVE_EVENTS_PANEL, + UI_SHOW_LIVE_PROCESS_LOG, UI_SHOW_YOUTUBE_GOOGLE_OAUTH, } from './constants' import { @@ -69,6 +74,7 @@ import { douyinUrlListDisplayFromServer, formatControlOutput, mergeYoutubeOptionsForUi, + normalizeRelayLogProcess, proChannelPm2Process, validateStreamKeysUnique, youtubeIniActiveKey, @@ -104,6 +110,7 @@ type NavId = AppNavId const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ { id: 'record', label: '直播', hint: '抖音转播推流与直播控制' }, + { id: 'relay-log', label: '日志', hint: 'YouTube 推流会话记录与 PM2 日志导入' }, { id: 'camera', label: '摄像头', hint: '本机采集直推 YouTube' }, { id: 'pull', label: '拉流', hint: 'RTMP / SRT / HLS 拉流转推' }, { id: 'network', label: '网络', hint: '链路检测、分流判断与录制代理' }, @@ -148,6 +155,7 @@ export default function App() { const [liveTick, setLiveTick] = useState(0) const [procBusy, setProcBusy] = useState(null) + const [relayLogProcess, setRelayLogProcess] = useState(null) const [statusPollPaused, setStatusPollPaused] = useState(false) const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) const [saveUrlLoading, setSaveUrlLoading] = useState(false) @@ -167,6 +175,8 @@ export default function App() { douyinUrl?: string configured?: boolean youtubeWatchUrl?: string + pm2Name?: string + notes?: string }[] >([]) const [proChannelsError, setProChannelsError] = useState(null) @@ -177,6 +187,11 @@ export default function App() { const [highlightedChannelId, setHighlightedChannelId] = useState('') const highlightedChannelIdRef = useRef('') const proUrlLoadedChannelIdsRef = useRef>(new Set()) + const [proUrlLoadedVersion, setProUrlLoadedVersion] = useState(0) + const markProUrlLoaded = useCallback((channelId: string) => { + proUrlLoadedChannelIdsRef.current.add(channelId) + setProUrlLoadedVersion((v) => v + 1) + }, []) /** 已保存到服务端的密钥列表;空白占位频道不作为可直播线路展示 */ const [committedChannelKeys, setCommittedChannelKeys] = useState>({}) const [addChannelBusy, setAddChannelBusy] = useState(false) @@ -199,6 +214,21 @@ export default function App() { const [relayValidationErrors, setRelayValidationErrors] = useState([]) const [relayValidationNotes, setRelayValidationNotes] = useState([]) const [liveProcessRows, setLiveProcessRows] = useState([]) + type YoutubeNetworkPreflight = { + ok?: boolean + message?: string + checks?: { + google?: boolean + youtube?: boolean + douyin?: boolean + global_direct?: boolean + global_proxy?: boolean + proxy_configured?: boolean + } + } + const [youtubeNetworkPreflight, setYoutubeNetworkPreflight] = useState( + null, + ) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -324,10 +354,23 @@ export default function App() { const monitorProcess = useMemo(() => { if (isYoutube && multiChannelPro && highlightedChannelId) { - return proChannelPm2Process(highlightedChannelId) + const ch = proChannelsList.find((c) => c.id === highlightedChannelId) + const ed = proChannelEditors[highlightedChannelId] + return proChannelPm2Process(highlightedChannelId, ed?.pm2Name ?? ch?.pm2Name) } return currentProcess - }, [isYoutube, multiChannelPro, highlightedChannelId, currentProcess]) + }, [isYoutube, multiChannelPro, highlightedChannelId, currentProcess, proChannelsList, proChannelEditors]) + + const relayLogPanelProcess = relayLogProcess ?? monitorProcess + + const resolveChannelPm2 = useCallback( + (channelId: string) => { + const ch = proChannelsList.find((c) => c.id === channelId) + const ed = proChannelEditors[channelId] + return proChannelPm2Process(channelId, ed?.pm2Name ?? ch?.pm2Name) + }, + [proChannelsList, proChannelEditors], + ) useEffect(() => { let cancelled = false @@ -371,6 +414,7 @@ export default function App() { const loadConfig = useCallback( async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => { + if (currentProcess.startsWith('youtube2__')) return try { const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`) if (!res.ok) throw new Error(`连接失败 ${res.status}`) @@ -552,27 +596,59 @@ export default function App() { if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as { content?: string } const content = douyinUrlListDisplayFromServer(String(data.content ?? '')) - proUrlLoadedChannelIdsRef.current.add(channelId) + markProUrlLoaded(channelId) setProChannelEditors((prev) => { const cur = prev[channelId] if (cur) return { ...prev, [channelId]: { ...cur, urlText: content } } return { ...prev, [channelId]: { keys: [''], activeIndex: 0, urlText: content } } }) - setUrlStatus('已读取') + if (channelId === highlightedChannelIdRef.current) { + setUrlStatus('已读取') + } return content } catch (err) { - setUrlStatus(`读取失败:${err instanceof Error ? err.message : String(err)}`) + if (channelId === highlightedChannelIdRef.current) { + setUrlStatus(`读取失败:${err instanceof Error ? err.message : String(err)}`) + } return null } }, - [proChannelEditors], + [proChannelEditors, markProUrlLoaded], ) const saveProUrlFor = useCallback( async (channelId: string, contentOverride?: string): Promise => { const ed = proChannelEditors[channelId] - if (!ed) return false - const content = contentOverride ?? ed.urlText + if (!ed && contentOverride === undefined) return false + let content = contentOverride ?? ed?.urlText ?? '' + if (countConfigUrlLines(content) === 0) { + try { + const res = await apiFetch( + apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(channelId)}`), + ) + if (res.ok) { + const data = (await res.json()) as { content?: string } + const loaded = douyinUrlListDisplayFromServer(String(data.content ?? '')) + if (countConfigUrlLines(loaded) > 0) { + content = loaded + markProUrlLoaded(channelId) + setProChannelEditors((prev) => { + const cur = prev[channelId] + if (cur) return { ...prev, [channelId]: { ...cur, urlText: loaded } } + return { ...prev, [channelId]: { keys: [''], activeIndex: 0, urlText: loaded } } + }) + } + } + } catch { + /* fall through to empty check */ + } + } + if (countConfigUrlLines(content) === 0) { + const msg = '源站地址为空:请先填写直播地址列表后再保存或开播。' + setUrlStatus(msg) + setControlNotice(msg) + return false + } setSaveUrlLoadingId(channelId) try { const res = await apiFetch( @@ -585,7 +661,7 @@ export default function App() { ) const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) - proUrlLoadedChannelIdsRef.current.add(channelId) + markProUrlLoaded(channelId) setUrlStatus(data.message || '已保存') void window.relayConfig?.importFromConfig?.() return true @@ -596,7 +672,7 @@ export default function App() { setSaveUrlLoadingId(null) } }, - [proChannelEditors], + [proChannelEditors, markProUrlLoaded], ) const saveProWatchFor = useCallback( @@ -615,23 +691,111 @@ export default function App() { [proChannelEditors], ) + const saveProChannelMeta = useCallback( + async (channelId: string): Promise => { + if (!channelId || !proChannelsList.length) return true + const ed = proChannelEditors[channelId] + try { + const details = proChannelsList.map((c) => { + const editor = c.id === channelId ? ed : proChannelEditors[c.id] + const committed = committedChannelKeys[c.id] + const keyText = ((committed?.[0] ?? editor?.keys?.[0] ?? c.youtubeKey ?? '') || '').trim() + const keysList = committed?.length + ? committed + : editor?.keys?.some((k) => k.trim()) + ? editor.keys + : undefined + return { + id: c.id, + name: (c.id === channelId ? editor?.channelName?.trim() : undefined) ?? c.name, + pm2Name: + (c.id === channelId ? editor?.pm2Name?.trim() : undefined) ?? + c.pm2Name ?? + proChannelPm2Process(c.id), + notes: (c.id === channelId ? editor?.notes : undefined) ?? c.notes ?? '', + douyinUrl: c.douyinUrl ?? '', + youtubeKey: keyText, + youtubeKeys: keysList, + } + }) + const res = await apiFetch(apiUrl('/relay_pro/channels_save'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channels: details }), + }) + const data = (await res.json()) as { error?: string; message?: string; runtime_errors?: string[] } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + if (data.runtime_errors?.length) { + setYoutubeStatus(`${data.message || '已保存'}(运行时同步:${data.runtime_errors.join(';')})`) + } + setProChannelsList((prev) => + prev.map((c) => + c.id === channelId + ? { + ...c, + name: ed?.channelName?.trim() || c.name, + pm2Name: ed?.pm2Name?.trim() || c.pm2Name, + notes: ed?.notes ?? c.notes, + } + : c, + ), + ) + return true + } catch (err) { + setYoutubeStatus(`频道元数据保存失败:${err instanceof Error ? err.message : String(err)}`) + return false + } + }, + [proChannelsList, proChannelEditors, committedChannelKeys], + ) + const saveProChannelBundle = useCallback( - async (channelId: string, opts?: { urlOverride?: string }): Promise => { + async ( + channelId: string, + opts?: { urlOverride?: string; lightweight?: boolean }, + ): Promise => { if (!channelId) return false setSaveProBundleLoadingId(channelId) try { - const synced = await apiFetchJson<{ message?: string }>( - `${apiUrl('/relay_pro/sync_active')}?channel_id=${encodeURIComponent(channelId)}`, - { method: 'POST' }, - ) - if (synced.message) setYoutubeStatus(synced.message) + const savedMeta = await saveProChannelMeta(channelId) + if (!savedMeta) { + setControlNotice('保存线路配置失败:频道元数据未写入。') + return false + } + if (!opts?.lightweight) { + try { + const synced = await apiFetchJson<{ message?: string; error?: string }>( + `${apiUrl('/relay_pro/sync_active')}?channel_id=${encodeURIComponent(channelId)}`, + { method: 'POST' }, + ) + if (synced.message) setYoutubeStatus(synced.message) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setYoutubeStatus(`同步到单路配置失败:${msg}`) + setControlNotice(`同步到单路配置失败:${msg}`) + return false + } + } const savedKeys = await saveProChannelKeysFor(channelId) - if (!savedKeys) return false + if (!savedKeys) { + setControlNotice(`保存线路配置失败:${youtubeStatus || '串流密钥未保存'}`) + return false + } const savedUrl = await saveProUrlFor(channelId, opts?.urlOverride) - if (!savedUrl) return false + if (!savedUrl) { + setControlNotice(`保存线路配置失败:${youtubeStatus || '源站地址未保存'}`) + return false + } const savedWatch = await saveProWatchFor(channelId) - if (!savedWatch) return false - setYoutubeStatus('线路配置已保存(密钥、源站地址、YouTube 监听地址)') + if (!savedWatch) { + setControlNotice(`保存线路配置失败:${youtubeStatus || 'YouTube 监听地址未保存'}`) + return false + } + setYoutubeStatus( + opts?.lightweight + ? '开播前已保存当前线路(密钥、源站地址、监听地址)' + : '线路配置已保存(密钥、源站地址、YouTube 监听地址)', + ) try { const overview = await apiFetchJson<{ relayValidationErrors?: string[]; relayNotes?: string[] }>( apiUrl('/client/overview'), @@ -651,7 +815,7 @@ export default function App() { setSaveProBundleLoadingId(null) } }, - [saveProChannelKeysFor, saveProUrlFor, saveProWatchFor], + [saveProChannelMeta, saveProChannelKeysFor, saveProUrlFor, saveProWatchFor], ) const saveConfig = useCallback( @@ -756,11 +920,40 @@ export default function App() { } }, [isYoutube]) + const refreshYoutubePreflight = useCallback(async () => { + if (!isYoutube) return + try { + const data = await apiFetchJson(apiUrl('/youtube/preflight')) + setYoutubeNetworkPreflight(data) + } catch { + setYoutubeNetworkPreflight(null) + } + }, [isYoutube]) + + const dedupeUrlConfig = useCallback( + async (content: string, relFile: string, apply: (next: string, msg: string) => void) => { + try { + const data = await apiFetchJson<{ content?: string; message?: string }>(apiUrl('/config_optimize'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'dedupe_urls', content, file: relFile, save: false }), + }) + apply(data.content || content, data.message || '已去重') + } catch (e) { + apply(content, `去重失败:${e instanceof Error ? e.message : String(e)}`) + } + }, + [], + ) + const runPm2Action = useCallback( async (action: string, processOverride?: string) => { const proc = processOverride ?? currentProcess const isStatus = action === 'status' if (!isStatus) { + if (action === 'start' || action === 'restart') { + await refreshYoutubePreflight() + } if (action === 'start' || action === 'restart') { const allowed = await ensureCanControlLive() if (!allowed) return @@ -848,18 +1041,18 @@ export default function App() { if (!isStatus) setProcBusy(null) } }, - [currentProcess, getEntry, isYoutube, ytIniModel, urlText, multiChannelPro, ensureCanControlLive, refreshLiveProcessMonitor], + [currentProcess, getEntry, isYoutube, ytIniModel, urlText, multiChannelPro, ensureCanControlLive, refreshLiveProcessMonitor, refreshYoutubePreflight], ) const pollCurrentStatus = useCallback(() => { if (!entries.length || !currentProcess) return if (isYoutube && multiChannelPro) { if (!highlightedChannelId) return - void runPm2Action('status', proChannelPm2Process(highlightedChannelId)) + void runPm2Action('status', resolveChannelPm2(highlightedChannelId)) return } void runPm2Action('status') - }, [entries.length, currentProcess, runPm2Action, isYoutube, multiChannelPro, highlightedChannelId]) + }, [entries.length, currentProcess, runPm2Action, isYoutube, multiChannelPro, highlightedChannelId, resolveChannelPm2]) useEffect(() => { pollCurrentStatus() @@ -901,6 +1094,11 @@ export default function App() { !!(isYoutube && nav === 'record'), ) + useEffect(() => { + if (!isYoutube || !backendReady || nav !== 'record') return + void refreshYoutubePreflight() + }, [isYoutube, backendReady, nav, refreshYoutubePreflight]) + useEffect(() => { document.body.classList.add('ready') }, []) @@ -1121,6 +1319,7 @@ export default function App() { if (!multiChannelPro) { highlightedChannelIdRef.current = '' proUrlLoadedChannelIdsRef.current.clear() + setProUrlLoadedVersion((v) => v + 1) setHighlightedChannelId('') setCommittedChannelKeys({}) } @@ -1131,21 +1330,31 @@ export default function App() { if (!channelId) return const allowed = await ensureCanControlLive() if (!allowed) return - const pm = proChannelPm2Process(channelId) + const pm = resolveChannelPm2(channelId) setProcBusy(makeProcBusy(action, pm)) try { - const edBeforeUrlSave = proChannelEditors[channelId] + if (!proChannelEditors[channelId]) { + setControlNotice('线路配置加载中,请稍候再点开始。') + return + } let urlOverride: string | undefined - if ( - !proUrlLoadedChannelIdsRef.current.has(channelId) && - countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0 - ) { + const edBeforeUrlSave = proChannelEditors[channelId] + if (countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0) { const loadedUrl = await loadProUrlTextFor(channelId, true) - if (loadedUrl === null) return + if (!loadedUrl || countConfigUrlLines(loadedUrl) === 0) { + setControlNotice('源站地址为空:请先在「当前频道」填写直播地址列表并保存。') + return + } urlOverride = loadedUrl } - const saved = await saveProChannelBundle(channelId, { urlOverride }) - if (!saved) return + const urlForSave = urlOverride ?? edBeforeUrlSave?.urlText ?? '' + const saved = await saveProChannelBundle(channelId, { urlOverride, lightweight: true }) + if (!saved) { + setControlNotice( + `开播前保存线路配置失败。${youtubeStatus && youtubeStatus !== '就绪' ? `\n${youtubeStatus}` : ''}`, + ) + return + } const data = await apiFetchJson( `${apiUrl(`/${action}`)}?process=${encodeURIComponent(pm)}`, ) @@ -1164,7 +1373,7 @@ export default function App() { const ed = proChannelEditors[channelId] const keySrc = (ed?.keys[0] ?? '').trim() const suffix = parseYoutubeStreamKeySuffix(`key = ${keySrc}`) - const rooms = extractDouyinRoomHints(ed?.urlText ?? '') + const rooms = extractDouyinRoomHints(urlForSave) logLiveEvent({ eventType: 'relay_intent', process: pm, @@ -1189,7 +1398,7 @@ export default function App() { setProcBusy(null) } }, - [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelBundle, ensureCanControlLive, refreshLiveProcessMonitor], + [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelBundle, ensureCanControlLive, refreshLiveProcessMonitor, resolveChannelPm2, youtubeStatus], ) const runProChannelStart = useCallback( @@ -1251,6 +1460,9 @@ export default function App() { youtubeKeys?: string[] youtubeActiveIndex?: number youtubeWatchUrl?: string + name?: string + pm2Name?: string + notes?: string } const ksRaw = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length ? cd.youtubeKeys.map(String) @@ -1263,6 +1475,9 @@ export default function App() { activeIndex: 0, urlText: '', youtubeWatchText: watchText, + channelName: cd.name || c.name, + pm2Name: cd.pm2Name || c.pm2Name || proChannelPm2Process(c.id), + notes: cd.notes || c.notes || '', } committed[c.id] = k0 ? [k0] : [] } catch { @@ -1277,6 +1492,9 @@ export default function App() { ...next[id], urlText: prev[id]?.urlText ?? '', youtubeWatchText: prev[id]?.youtubeWatchText ?? next[id].youtubeWatchText, + channelName: prev[id]?.channelName ?? next[id].channelName, + pm2Name: prev[id]?.pm2Name ?? next[id].pm2Name, + notes: prev[id]?.notes ?? next[id].notes, } } return merged @@ -1303,7 +1521,7 @@ export default function App() { const j = (await ur.json()) as { content?: string } const content = douyinUrlListDisplayFromServer(String(j.content ?? '')) if (cid !== highlightedChannelIdRef.current) return - proUrlLoadedChannelIdsRef.current.add(cid) + markProUrlLoaded(cid) setProChannelEditors((prev) => { const cur = prev[cid] if (cur) { @@ -1319,7 +1537,7 @@ export default function App() { return () => { cancelled = true } - }, [highlightedChannelId, isYoutube, multiChannelPro]) + }, [highlightedChannelId, isYoutube, multiChannelPro, markProUrlLoaded]) useEffect(() => { if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return @@ -1371,6 +1589,11 @@ export default function App() { if (open) void open(href) else window.open(href, '_blank', 'noopener,noreferrer') }, []) + + const handleOpenRelayLog = useCallback((process: string) => { + setRelayLogProcess(normalizeRelayLogProcess(process)) + setNav('relay-log') + }, []) const keySuffixForUi = useMemo(() => { if (!isYoutube) return '' if (multiChannelPro && highlightedChannelId) { @@ -1420,32 +1643,25 @@ export default function App() { const saved = committedChannelKeys[c.id] const ed = proChannelEditors[c.id] const keyText = ((saved?.[0] ?? ed?.keys?.[0] ?? '') || '').trim() - const sourceText = proUrlLoadedChannelIdsRef.current.has(c.id) - ? ed?.urlText ?? '' - : ed?.urlText || c.douyinUrl || '' - const sourceCount = countConfigUrlLines(sourceText) + const urlLoaded = proUrlLoadedChannelIdsRef.current.has(c.id) + const sourceText = ed?.urlText ?? '' + const sourceCount = + urlLoaded || sourceText.trim() ? countConfigUrlLines(sourceText) : 0 const keyConfigured = !!keyText - const pm = proChannelPm2Process(c.id) + const pm = resolveChannelPm2(c.id) const processRow = liveProcByPm2.get(pm) const online = pm2StatusOnline(processRow?.process_status) const pushing = isLivePushingRow(processRow) const ready = keyConfigured && sourceCount > 0 - const isCurrentEditor = c.id === highlightedChannelId - const serverConfigured = c.configured ?? (keyConfigured || sourceCount > 0) - if (!serverConfigured && !online && !isCurrentEditor) continue const readyReason = !keyConfigured ? '缺少 YouTube 串流密钥' : sourceCount <= 0 ? '缺少源站直播地址' : '预检通过' - const statusLabel = processRow - ? proLaneStatusLabel(processRow) - : ready - ? '可启动' - : '待配置' + const statusLabel = proLaneStatusLabel(processRow) rows.push({ channelId: c.id, - channelName: c.name, + channelName: ed?.channelName?.trim() || c.name, douyinUrl: c.douyinUrl ?? '', online, pushing, @@ -1461,14 +1677,42 @@ export default function App() { }) } return rows - }, [proChannelsList, committedChannelKeys, proChannelEditors, highlightedChannelId, liveProcByPm2]) + }, [ + proChannelsList, + committedChannelKeys, + proChannelEditors, + liveProcByPm2, + resolveChannelPm2, + proUrlLoadedVersion, + ]) + + const relayLogProcessOptions = useMemo(() => { + if (isYoutube && multiChannelPro && proLiveRows.length > 0) { + return proLiveRows.map((row) => ({ + value: resolveChannelPm2(row.channelId), + label: row.channelName, + })) + } + const youtubeEntries = entries.filter((e) => isYoutubeScript(e.script)) + if (youtubeEntries.length > 0) { + return youtubeEntries.map((e) => ({ + value: e.pm2, + label: e.label || e.pm2, + })) + } + if (currentProcess) { + const cur = getEntry(currentProcess) + return [{ value: currentProcess, label: cur?.label || currentProcess }] + } + return [{ value: 'youtube', label: 'youtube' }] + }, [isYoutube, multiChannelPro, proLiveRows, entries, currentProcess, getEntry, resolveChannelPm2]) const youtubeRelayWatchItems = useMemo(() => { if (!isYoutube) return [] if (multiChannelPro) { return proChannelsList.map((c) => ({ relayId: c.id, - label: c.name, + label: proChannelEditors[c.id]?.channelName?.trim() || c.name, input: (proChannelEditors[c.id]?.youtubeWatchText ?? c.youtubeWatchUrl ?? '').trim(), })) } @@ -1479,11 +1723,11 @@ export default function App() { useEffect(() => { if (!isYoutube || !multiChannelPro) return if (highlightedChannelId) return - if (proLiveRows.length === 0) return - const r0 = proLiveRows[0] - highlightedChannelIdRef.current = r0.channelId - setHighlightedChannelId(r0.channelId) - }, [isYoutube, multiChannelPro, highlightedChannelId, proLiveRows]) + const firstId = proLiveRows[0]?.channelId ?? proChannelsList[0]?.id + if (!firstId) return + highlightedChannelIdRef.current = firstId + setHighlightedChannelId(firstId) + }, [isYoutube, multiChannelPro, highlightedChannelId, proLiveRows, proChannelsList]) const liveDurationLabel = useMemo(() => { if (liveStartedAtMs == null) return '—' @@ -1502,15 +1746,31 @@ export default function App() { const liveReady = multiChannelPro ? proReadyCount > 0 : singleKeyConfigured && singleSourceCount > 0 - const livePreflightText = multiChannelPro - ? proVisibleLineCount === 0 - ? '还没有 Pro 频道' - : `${proReadyCount}/${proVisibleLineCount} 条线路可启动` - : liveReady - ? '单频道预检通过' - : !singleKeyConfigured - ? '缺少 YouTube 串流密钥' - : '缺少源站直播地址' + const livePreflightText = useMemo(() => { + const base = multiChannelPro + ? proVisibleLineCount === 0 + ? '还没有 Pro 频道' + : `${proReadyCount}/${proVisibleLineCount} 条线路可启动` + : liveReady + ? '单频道预检通过' + : !singleKeyConfigured + ? '缺少 YouTube 串流密钥' + : '缺少源站直播地址' + if (youtubeNetworkPreflight?.ok === false) { + return youtubeNetworkPreflight.message || base + } + if (youtubeNetworkPreflight?.ok && liveReady) { + return `${base} · 网络预检通过` + } + return base + }, [ + multiChannelPro, + proVisibleLineCount, + proReadyCount, + liveReady, + singleKeyConfigured, + youtubeNetworkPreflight, + ]) const gpuStatusText = gpuCaps === null ? '检测中' : gpuCaps.nvenc_available @@ -1567,6 +1827,33 @@ export default function App() { ok: gpuCaps !== null, detail: gpuStatusText, }) + if (youtubeNetworkPreflight?.checks) { + const c = youtubeNetworkPreflight.checks + const globalOk = !!(c.global_direct || c.global_proxy) + items.push({ + label: 'Google/YouTube 出口', + ok: globalOk, + detail: globalOk + ? c.global_direct + ? '直连可达' + : '代理可达' + : youtubeNetworkPreflight.message || '不可达,开播将被拦截', + }) + items.push({ + label: '抖音源站可达', + ok: !!c.douyin, + detail: c.douyin ? '国内源站可达' : '当前不可达,可能无法拉流', + }) + if (!globalOk) { + items.push({ + label: '录制代理', + ok: !!c.proxy_configured, + detail: c.proxy_configured + ? '已配置,请确认混合端口可用' + : '未配置,请先在「网络」页填写代理', + }) + } + } items.push({ label: '运行态', ok: liveDetail.isPushing === true || liveDetail.processStatus !== 'errored', @@ -1590,6 +1877,7 @@ export default function App() { singleKeyConfigured, singleSourceCount, singleYoutubeWatchText, + youtubeNetworkPreflight, ]) if (bootError) { @@ -1801,6 +2089,15 @@ export default function App() { )} + {nav === 'relay-log' && ( + setRelayLogProcess(normalizeRelayLogProcess(p))} + backendReady={backendReady} + /> + )} + {nav === 'record' && (
{emptyHint && ( @@ -1846,12 +2143,25 @@ export default function App() {

直播开关

+ {controlNotice ? ( +
+
{controlNotice}
+ +
+ ) : null} {isYoutube && multiChannelPro && proLiveRows.length > 0 ? (
{proLiveRows.map((row) => { const ed = proChannelEditors[row.channelId] const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') - const pm = proChannelPm2Process(row.channelId) + const pm = resolveChannelPm2(row.channelId) return ( void runProChannelStart(row.channelId)} onStop={() => void runPm2Action('stop', pm)} onRestart={() => void runProChannelRestart(row.channelId)} + onOpenRelayLog={() => handleOpenRelayLog(pm)} onSelect={() => { const fromId = highlightedChannelIdRef.current const toId = row.channelId highlightedChannelIdRef.current = toId setHighlightedChannelId(toId) if (toId !== fromId) { - setProChannelEditors((prev) => { - const cur = prev[toId] - if (!cur) return prev - return { ...prev, [toId]: { ...cur, urlText: '' } } - }) + void loadProUrlTextFor(toId, true) } }} /> @@ -1931,18 +2238,17 @@ export default function App() { />
)}
- {entries.length > 0 && currentProcess ? ( + {UI_SHOW_LIVE_PROCESS_LOG && entries.length > 0 && currentProcess ? ( 加载中…

) : ( <> +
+ + { + const v = e.target.value + setProChannelEditors((prev) => { + const cur = prev[ch.id] + if (!cur) return prev + return { ...prev, [ch.id]: { ...cur, channelName: v } } + }) + }} + /> + + { + const v = e.target.value + setProChannelEditors((prev) => { + const cur = prev[ch.id] + if (!cur) return prev + return { ...prev, [ch.id]: { ...cur, pm2Name: v } } + }) + }} + /> + + { + const v = e.target.value + setProChannelEditors((prev) => { + const cur = prev[ch.id] + if (!cur) return prev + return { ...prev, [ch.id]: { ...cur, notes: v } } + }) + }} + /> +
@@ -2123,6 +2485,7 @@ export default function App() { if (!ch) return null const ed = proChannelEditors[ch.id] return ( + <>

直播地址列表

{!ed ? ( @@ -2187,7 +2550,7 @@ export default function App() { ) if (ur.ok) { const u = (await ur.json()) as { content?: string } - proUrlLoadedChannelIdsRef.current.add(ch.id) + markProUrlLoaded(ch.id) setProChannelEditors((prev) => { const cur = prev[ch.id] if (!cur) return prev @@ -2209,6 +2572,28 @@ export default function App() { > 重新读取 +
+ {UI_SHOW_CONFIG_ADVISOR ? ( + { + setProChannelEditors((prev) => { + const cur = prev[ch.id] + if (!cur) return prev + return { + ...prev, + [ch.id]: { ...cur, urlText: douyinUrlListDisplayFromServer(content) }, + } + }) + setUrlStatus('配置顾问已应用优化') + }} + /> + ) : null} + ) })()}
{youtubeStatus}
@@ -2402,6 +2805,18 @@ export default function App() { > 重新读取 +
diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx index cd250ab..8075a72 100644 --- a/electron/src/renderer/src/NetworkDashboard.tsx +++ b/electron/src/renderer/src/NetworkDashboard.tsx @@ -9,6 +9,9 @@ import NetworkPipeline from './components/network/NetworkPipeline' import NetworkSpeedChart from './components/network/NetworkSpeedChart' import IpProfilePanel, { type IpProfilePayload } from './components/network/IpProfilePanel' import IpQualityPanel, { type IpQualityPayload } from './components/network/IpQualityPanel' +import NetworkToolkitPanel, { type NetworkToolkitPayload } from './components/network/NetworkToolkitPanel' +import { useBrowserFingerprint } from './hooks/useBrowserFingerprint' +import { useIsChinaUser } from './hooks/useIsChinaUser' import { useWebRtcLeak } from './hooks/useWebRtcLeak' import { IconDomestic, @@ -354,11 +357,16 @@ export default function NetworkDashboard() { const [ipQualityErr, setIpQualityErr] = useState(null) const [ipQualityLoading, setIpQualityLoading] = useState(false) const [proxyCfg, setProxyCfg] = useState({ enabled: false, http: '', socks: '' }) + const [toolkit, setToolkit] = useState(null) + const [toolkitErr, setToolkitErr] = useState(null) + const [toolkitLoading, setToolkitLoading] = useState(false) const [err, setErr] = useState(null) const [loading, setLoading] = useState(false) const [refreshing, setRefreshing] = useState(false) const [hasCachedData, setHasCachedData] = useState(false) - const { leak: webrtcLeak } = useWebRtcLeak(true) + const { leak: webrtcLeak, locals: webrtcLocals, error: webrtcError } = useWebRtcLeak(true) + const fingerprint = useBrowserFingerprint(true) + const isChinaUser = useIsChinaUser(true, true) const load = useCallback(async (opts?: { silent?: boolean; forceRefresh?: boolean }) => { const silent = opts?.silent ?? false @@ -369,9 +377,10 @@ export default function NetworkDashboard() { else setRefreshing(true) setIpProfileLoading(true) setIpQualityLoading(true) + setToolkitLoading(true) try { const q = '?force_refresh=1' - const [netRes, proxyRes, profileRes, qualityRes] = await Promise.all([ + const [netRes, proxyRes, profileRes, qualityRes, toolkitRes] = await Promise.all([ apiFetch(apiUrl(`/network_status${q}`)), apiFetch(apiUrl('/proxy_config')), apiFetch( @@ -385,6 +394,7 @@ export default function NetworkDashboard() { ), ), apiFetch(apiUrl('/ip_quality?force_refresh=1')), + apiFetch(apiUrl('/network_toolkit?force_refresh=1')), ]) const json = (await netRes.json()) as NetworkPayload & { error?: string } @@ -417,6 +427,15 @@ export default function NetworkDashboard() { saveWorkspaceSnapshot('ip_quality', qualityJson) } + const toolkitJson = (await toolkitRes.json()) as NetworkToolkitPayload & { error?: string } + if (!toolkitRes.ok) { + setToolkitErr(toolkitJson.error || `工具箱请求失败 ${toolkitRes.status}`) + setToolkit(null) + } else { + setToolkit(toolkitJson) + setToolkitErr(null) + } + if (proxyRes.ok) { const proxyJson = (await proxyRes.json()) as ProxyCfg setProxyCfg({ @@ -433,6 +452,7 @@ export default function NetworkDashboard() { setRefreshing(false) setIpProfileLoading(false) setIpQualityLoading(false) + setToolkitLoading(false) } }, [webrtcLeak]) @@ -591,6 +611,17 @@ export default function NetworkDashboard() { + +
void +} + +export default function ConfigAdvisorPanel({ configFile, enabled, onApply }: Props) { + const [report, setReport] = useState(null) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + + const load = useCallback(async () => { + if (!enabled || !configFile.trim()) return + try { + const data = await apiFetchJson( + apiUrl(`/config_insights?file=${encodeURIComponent(configFile)}`), + ) + setReport(data) + setMsg('') + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } + }, [configFile, enabled]) + + useEffect(() => { + void load() + }, [load]) + + const runAction = useCallback( + async (action: string) => { + if (!configFile.trim()) return + setBusy(true) + setMsg('') + try { + const current = await apiFetchJson<{ message?: string; content?: string; error?: string }>( + apiUrl('/config_optimize'), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file: configFile, + action, + content: '', + save: true, + }), + }, + ) + const res = current + if (res.error) throw new Error(res.error) + setMsg(res.message || '已应用优化') + if (res.content && onApply) onApply(res.content) + await load() + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + }, + [configFile, load, onApply], + ) + + if (!enabled || !configFile.trim()) return null + + const issues = report?.issues ?? [] + const actions = report?.actions ?? [] + + return ( +
+
+
+

+ 🛠 + 配置顾问 +

+

{report?.summary || '分析当前配置文件…'}

+
+ +
+ + {msg ?

{msg}

: null} + + {issues.length > 0 ? ( +
    + {issues.map((item, i) => ( +
  • + {item.level === 'error' ? '🔴 ' : '⚠️ '} + {item.text} +
  • + ))} +
+ ) : report ? ( +

✅ 未发现明显配置问题。

+ ) : null} + + {actions.length > 0 ? ( +
+ {actions.map((a) => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/electron/src/renderer/src/components/LiveEventsPanel.tsx b/electron/src/renderer/src/components/LiveEventsPanel.tsx new file mode 100644 index 0000000..20d9792 --- /dev/null +++ b/electron/src/renderer/src/components/LiveEventsPanel.tsx @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useState } from 'react' + +import { apiFetchJson, apiUrl } from '../api' +import { formatDurationCn } from '../liveStatusLabels' +import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval' + +type LiveEvent = { + id: number + process?: string + source_title?: string + source_room_id?: string + source_url?: string + channel_name?: string + status?: string + quality_score?: number + duration_seconds?: number + last_live_status?: string + viewer_peak?: number + last_popup_text?: string + last_page_url?: string + last_page_title?: string +} + +type LiveSample = { + id: number + ts: number + live_status?: string + viewer_count?: number + health?: string + popup_text?: string +} + +type LiveSummary = { + active?: LiveEvent[] + latest?: LiveEvent[] + stats?: { + active_events?: number + total_events?: number + total_duration_seconds?: number + sample_count?: number + } +} + +type Props = { + process: string + enabled: boolean +} + +type LiveEventDetail = LiveEvent & { samples?: LiveSample[]; alerts?: unknown[] } + +function asEventList(value: unknown): LiveEvent[] { + return Array.isArray(value) ? value : [] +} + +export default function LiveEventsPanel({ process, enabled }: Props) { + const pageVisible = usePageVisible() + const [summary, setSummary] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [detail, setDetail] = useState(null) + const [err, setErr] = useState(null) + + const load = useCallback(async () => { + if (!enabled) return + try { + const data = await apiFetchJson(apiUrl('/youtube/live/summary')) + setSummary(data) + const pool = [...asEventList(data.active), ...asEventList(data.latest)] + const match = process ? pool.find((e) => e.process === process) ?? pool[0] : pool[0] + setSelectedId((prev) => { + if (prev && pool.some((e) => e.id === prev)) return prev + return match?.id ?? null + }) + setErr(null) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + }, [enabled, process]) + + const loadDetail = useCallback(async (eventId: number) => { + try { + const data = await apiFetchJson<{ event?: LiveEvent & { samples?: LiveSample[] } }>( + apiUrl(`/youtube/live/events/${eventId}`), + ) + setDetail(data.event ?? null) + } catch { + setDetail(null) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (selectedId) void loadDetail(selectedId) + else setDetail(null) + }, [selectedId, loadDetail]) + + useAdaptiveInterval(() => void load(), pageVisible && enabled ? 15_000 : 0, enabled) + + if (!enabled) return null + + const active = asEventList(summary?.active) + const latest = asEventList(summary?.latest).filter((e) => !active.some((a) => a.id === e.id)) + + return ( +
+
+
+

+ 📊 + 直播事件时间线 +

+

+ 活跃 {summary?.stats?.active_events ?? active.length} 场 · 采样{' '} + {summary?.stats?.sample_count ?? 0} 条 · 累计{' '} + {formatDurationCn(summary?.stats?.total_duration_seconds ?? 0)} +

+
+ +
+ + {err ?

{err}

: null} + + {(active.length > 0 || latest.length > 0) && ( +
    + {[...active, ...latest.slice(0, 8)].map((e) => ( +
  • + +
  • + ))} +
+ )} + + {detail ? ( +
+

事件 #{detail.id}

+

+ {detail.source_url || '无源 URL'} + {detail.source_room_id ? ` · 房间 ${detail.source_room_id}` : ''} +

+ {detail.last_page_title ? ( +

Studio: {detail.last_page_title}

+ ) : null} + {detail.last_popup_text ? ( +

弹窗: {detail.last_popup_text}

+ ) : null} + {(detail.samples ?? []).length > 0 ? ( +
    + {detail.samples!.slice(0, 6).map((s) => ( +
  • + {new Date(s.ts * 1000).toLocaleTimeString()} · {s.live_status || s.health || '采样'} + {s.viewer_count != null ? ` · 观众 ${s.viewer_count}` : ''} +
  • + ))} +
+ ) : null} +
+ ) : ( +

暂无事件(启动直播并轮询状态后会自动写入)。

+ )} +
+ ) +} diff --git a/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx b/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx index 72e6065..95d093b 100644 --- a/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx +++ b/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { apiFetchJson, apiUrl } from '../api' import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval' type RelayWatchItem = { relayId: string; label: string; input: string } @@ -19,6 +20,7 @@ type ChatMsgLite = { id: string; author: string; text: string; timestamp: string type Props = { enabled: boolean + monitorProcess?: string relayItems: RelayWatchItem[] activeRelayId?: string | null isPushing?: boolean @@ -29,6 +31,7 @@ const PUSH_MISMATCH_MS = 45_000 export default function LiveYoutubeMonitor({ enabled, + monitorProcess = '', relayItems, activeRelayId, isPushing = false, @@ -85,6 +88,33 @@ export default function LiveYoutubeMonitor({ } }, [api, enabled, relayItems]) + const postIngest = useCallback( + async (session: LiveSessionLite, relayId: string) => { + if (!enabled || !monitorProcess.trim()) return + try { + await apiFetchJson(apiUrl('/youtube/live/ingest'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + process: monitorProcess, + mode: 'youlive', + channel_id: relayId, + channel_name: session.channelName || session.title, + source_title: session.title, + live_status: session.status, + viewer_count: session.viewerCount, + viewer_peak: session.peakViewerCount, + page_title: session.title, + url: session.videoId ? `https://www.youtube.com/watch?v=${session.videoId}` : '', + }), + }) + } catch { + /* best-effort telemetry */ + } + }, + [enabled, monitorProcess], + ) + const refresh = useCallback(async () => { if (!api || !enabled) return try { @@ -102,10 +132,16 @@ export default function LiveYoutubeMonitor({ } setWatchRelayMap(map) setErr(null) + if (monitorProcess.trim()) { + for (const s of ss as LiveSessionLite[]) { + const relayId = map[s.watchId] || activeRelayId || 'single' + if (relayId) void postIngest(s, relayId) + } + } } catch (e) { setErr(e instanceof Error ? e.message : String(e)) } - }, [api, enabled]) + }, [api, enabled, monitorProcess, activeRelayId, postIngest]) useEffect(() => { setReady(!!api) diff --git a/electron/src/renderer/src/components/QualityGuardBanner.tsx b/electron/src/renderer/src/components/QualityGuardBanner.tsx index 6d18b7f..06667a5 100644 --- a/electron/src/renderer/src/components/QualityGuardBanner.tsx +++ b/electron/src/renderer/src/components/QualityGuardBanner.tsx @@ -3,51 +3,37 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { apiFetchJson, apiUrl } from '../api' import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval' import { qualityGuardPollMs } from '../adaptivePoll' +import { formatFfmpegStatsBrief } from '../lib/ffmpegMetrics' import ScoreRing from './ui/ScoreRing' type GuardIssue = { level: string; text: string } -type DiagnosticLayer = { - ok?: boolean - hint?: string - watch_url?: string - pushing?: boolean -} -type DiagnosticLane = { - process: string - label: string + +type GuardReport = { score: number - is_pushing?: boolean - issues?: GuardIssue[] - layers?: { - source?: DiagnosticLayer - ffmpeg?: DiagnosticLayer - target?: DiagnosticLayer + summary?: { + pushing_lanes?: number + lanes?: number + active_events?: number + sample_count?: number + viewer_peak?: number } -} -type DiagnosticsReport = { - score: number - summary?: { pushing_lanes?: number; lanes?: number } - lanes?: DiagnosticLane[] + lanes?: Array<{ + process: string + label: string + score: number + is_pushing?: boolean + metrics?: { speed?: number; fps?: number; bitrate?: number } + issues?: GuardIssue[] + source_display?: string + rtmps?: boolean + }> recommendations?: GuardIssue[] - diagnostics?: { - summary?: { - source_configured?: number - target_configured?: number - ffmpeg_active?: number - } - } } function qualityGuardPollInterval(pushing: number, visible: boolean): number { return qualityGuardPollMs({ pushingLanes: pushing, pageVisible: visible }) } -function layerLabel(key: 'source' | 'ffmpeg' | 'target'): string { - if (key === 'source') return '源站' - if (key === 'ffmpeg') return '推流' - return '目标' -} - type Props = { process: string enabled: boolean @@ -55,14 +41,14 @@ type Props = { export default function QualityGuardBanner({ process, enabled }: Props) { const pageVisible = usePageVisible() - const [report, setReport] = useState(null) + const [report, setReport] = useState(null) const [err, setErr] = useState(null) const load = useCallback(async () => { if (!enabled) return try { const q = process ? `?process=${encodeURIComponent(process)}` : '' - const data = await apiFetchJson(apiUrl(`/youtube/diagnostics${q}`)) + const data = await apiFetchJson(apiUrl(`/youtube/quality_guard${q}`)) setReport(data) setErr(null) } catch (e) { @@ -86,58 +72,51 @@ export default function QualityGuardBanner({ process, enabled }: Props) { const score = report?.score ?? 0 const tone = score >= 80 ? 'ok' : score >= 50 ? 'warn' : 'bad' - const topIssues = (report?.lanes ?? []) - .flatMap((lane) => (lane.issues ?? []).map((i) => ({ ...i, lane: lane.label }))) + const topIssues = [ + ...(report?.recommendations ?? []).filter((i) => i.level !== 'ok'), + ...(report?.lanes ?? []).flatMap((lane) => + (lane.issues ?? []).map((i) => ({ ...i, lane: lane.label })), + ), + ] .filter((i) => i.level !== 'ok') - .slice(0, 3) + .slice(0, 4) - const primaryLane = (report?.lanes ?? [])[0] - const layers = primaryLane?.layers + const primaryLane = (report?.lanes ?? []).find((l) => l.is_pushing) ?? (report?.lanes ?? [])[0] return (
- +
- 链路诊断 + 质量守卫 {report ? `${score} 分` : '采集中…'} {report?.summary ? ( - {report.summary.pushing_lanes ?? 0}/{report.summary.lanes ?? 0} 路推流中 + {report.summary.pushing_lanes ?? 0}/{report.summary.lanes ?? 0} 路推流 + {typeof report.summary.active_events === 'number' + ? ` · 事件 ${report.summary.active_events}` + : ''} + {typeof report.summary.viewer_peak === 'number' && report.summary.viewer_peak > 0 + ? ` · 峰值观众 ${report.summary.viewer_peak}` + : ''} ) : null}
- {layers ? ( -
- {(['source', 'ffmpeg', 'target'] as const).map((key) => { - const layer = layers[key] - const ok = !!layer?.ok - return ( -
- {layerLabel(key)} - {ok ? '正常' : '待检查'} -
- ) - })} -
+ {primaryLane ? ( +

+ {primaryLane.label} + {primaryLane.source_display ? ` · ${primaryLane.source_display}` : ''} + {primaryLane.is_pushing ? ` · ${formatFfmpegStatsBrief(primaryLane.metrics)}` : ''} + {primaryLane.rtmps === false ? ' · 未启用 RTMPS' : ''} +

) : null} {err ?

⚠️ {err}

: null} {!err && topIssues.length > 0 ? (
    {topIssues.map((item, i) => ( -
  • +
  • {item.level === 'critical' ? '🔴 ' : '⚠️ '} {item.lane ? `[${item.lane}] ` : ''} {item.text} @@ -146,7 +125,7 @@ export default function QualityGuardBanner({ process, enabled }: Props) {
) : null} {!err && topIssues.length === 0 && report ? ( -

✅ 源站、推流与目标端检查通过,继续监控即可。

+

✅ 推流质量良好,继续监控即可。

) : null}
diff --git a/electron/src/renderer/src/components/RelayLogPage.tsx b/electron/src/renderer/src/components/RelayLogPage.tsx new file mode 100644 index 0000000..7c7d31b --- /dev/null +++ b/electron/src/renderer/src/components/RelayLogPage.tsx @@ -0,0 +1,37 @@ +import RelayLogPanel from './RelayLogPanel' + +export type RelayLogProcessOption = { + value: string + label: string +} + +type Props = { + process: string + processOptions: RelayLogProcessOption[] + onProcessChange: (process: string) => void + backendReady: boolean +} + +export default function RelayLogPage({ + process, + processOptions, + onProcessChange, + backendReady, +}: Props) { + return ( +
+ {!backendReady ? ( +

+ 本地控制服务连接中…推流日志暂不可用,连接成功后自动恢复。 +

+ ) : null} + +
+ ) +} diff --git a/electron/src/renderer/src/components/RelayLogPanel.tsx b/electron/src/renderer/src/components/RelayLogPanel.tsx index 817e064..9f46b66 100644 --- a/electron/src/renderer/src/components/RelayLogPanel.tsx +++ b/electron/src/renderer/src/components/RelayLogPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { apiFetchJson, apiUrl } from '../api' import { formatDurationCn } from '../liveStatusLabels' @@ -7,26 +7,75 @@ import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterva type RelaySession = { id: number process: string + channel_name?: string anchor_name?: string source_room_id?: string source_url?: string + started_at?: number + ended_at?: number | null duration_seconds?: number status?: string end_reason?: string push_date?: string + cover_url?: string + youtube_key_suffix?: string + stream_title?: string + metadata?: Record } type StatusFilter = '' | 'active' | 'ended' +export type RelayLogProcessOption = { + value: string + label: string +} + type Props = { process: string enabled: boolean + pageMode?: boolean + processOptions?: RelayLogProcessOption[] + onProcessChange?: (process: string) => void } -const PAGE_SIZE = 15 +function relayDouyinUrl(session: RelaySession): string { + if (session.source_url?.includes('live.douyin.com')) return session.source_url + if (session.source_room_id) return `https://live.douyin.com/${session.source_room_id}` + return session.source_url || '' +} -export default function RelayLogPanel({ process, enabled }: Props) { +function fmtRelayTs(ts?: number | null): string { + if (!ts) return '—' + try { + return new Date(ts * 1000).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + } catch { + return String(ts) + } +} + +function openExternalUrl(href: string) { + const open = window.recorderConsole?.openExternal + if (open) void open(href) + else window.open(href, '_blank', 'noopener,noreferrer') +} + +export default function RelayLogPanel({ + process, + enabled, + pageMode = false, + processOptions = [], + onProcessChange, +}: Props) { const pageVisible = usePageVisible() + const pageSize = pageMode ? 50 : 15 const [sessions, setSessions] = useState([]) const [total, setTotal] = useState(0) const [busy, setBusy] = useState(false) @@ -35,18 +84,26 @@ export default function RelayLogPanel({ process, enabled }: Props) { const [search, setSearch] = useState('') const [searchDraft, setSearchDraft] = useState('') const [statusFilter, setStatusFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [offset, setOffset] = useState(0) + const [selectedId, setSelectedId] = useState(null) + const [detail, setDetail] = useState(null) + const [replaceImported, setReplaceImported] = useState(false) const load = useCallback(async () => { if (!enabled) return + setBusy(true) try { const q = new URLSearchParams({ - limit: String(PAGE_SIZE), + limit: String(pageSize), offset: String(offset), }) if (process) q.set('process', process) if (search.trim()) q.set('q', search.trim()) if (statusFilter) q.set('status', statusFilter) + if (dateFrom.trim()) q.set('date_from', dateFrom.trim()) + if (dateTo.trim()) q.set('date_to', dateTo.trim()) const data = await apiFetchJson<{ sessions?: RelaySession[]; total?: number }>( apiUrl(`/youtube/relay/sessions?${q}`), ) @@ -55,15 +112,38 @@ export default function RelayLogPanel({ process, enabled }: Props) { setErr(null) } catch (e) { setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) } - }, [enabled, process, search, statusFilter, offset]) + }, [enabled, process, search, statusFilter, dateFrom, dateTo, offset, pageSize]) useEffect(() => { void load() }, [load]) + useEffect(() => { + setOffset(0) + setSelectedId(null) + }, [process]) + useAdaptiveInterval(() => void load(), pageVisible && enabled ? 12_000 : 0, enabled) + const loadDetail = useCallback(async (sessionId: number) => { + try { + const data = await apiFetchJson<{ session?: RelaySession }>( + apiUrl(`/youtube/relay/sessions/${sessionId}`), + ) + setDetail(data.session ?? null) + } catch { + setDetail(null) + } + }, []) + + useEffect(() => { + if (selectedId) void loadDetail(selectedId) + else setDetail(null) + }, [selectedId, loadDetail]) + const importLog = useCallback(async () => { if (!process) { setMsg('请先选择进程后再导入日志') @@ -74,7 +154,11 @@ export default function RelayLogPanel({ process, enabled }: Props) { try { const data = await apiFetchJson<{ imported?: number; error?: string }>( `${apiUrl('/youtube/relay/sessions/import')}?process=${encodeURIComponent(process)}`, - { method: 'POST' }, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace_imported: replaceImported }), + }, ) setMsg(`已从 PM2 日志导入 ${data.imported ?? 0} 条会话记录`) setOffset(0) @@ -84,7 +168,7 @@ export default function RelayLogPanel({ process, enabled }: Props) { } finally { setBusy(false) } - }, [process, load]) + }, [process, load, replaceImported]) const applySearch = () => { setOffset(0) @@ -94,116 +178,367 @@ export default function RelayLogPanel({ process, enabled }: Props) { const activeCount = sessions.filter((s) => s.status === 'active').length const pageStart = total === 0 ? 0 : offset + 1 const pageEnd = Math.min(offset + sessions.length, total) + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + const pageIndex = Math.floor(offset / pageSize) + 1 + + const selectedSession = useMemo(() => { + if (detail) return detail + if (selectedId == null) return null + return sessions.find((s) => s.id === selectedId) ?? null + }, [detail, selectedId, sessions]) if (!enabled) return null + const title = pageMode ? 'YouTube 推流日志' : '转播历史' + const desc = pageMode + ? '记录每次抖音源转播到 YouTube 的时段、主播昵称、直播间 URL 与开播截图,便于追溯音乐版权等问题。' + : '自动记录 PM2 在线/离线与会话;支持搜索、筛选与分页。' + const showProcessSelect = processOptions.length > 0 + const emptyHint = pageMode + ? '暂无推流记录。可先点击「从 PM2 日志导入历史」。' + : '暂无会话记录(启动直播后会自动写入)。' + return ( -
-
-
-

- 📜 - 转播历史 -

-

- 自动记录 PM2 在线/离线与会话;支持搜索、筛选与分页。 - {statusFilter === 'active' && activeCount > 0 ? ` 当前页 ${activeCount} 条进行中。` : ''} -

-
-
- - -
-
- -
- setSearchDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') applySearch() - }} - /> - - -
- - {err ?

{err}

: null} - {msg ?

{msg}

: null} - {sessions.length === 0 ? ( -

暂无会话记录(启动直播后会自动写入)。

- ) : ( -
    - {sessions.map((s) => ( -
  • -
    - {s.anchor_name || s.source_room_id || '未知源站'} - - {s.process} - {s.push_date ? ` · ${s.push_date}` : ''} - {s.source_url ? ` · ${s.source_url}` : ''} - -
    -
    - {formatDurationCn(s.duration_seconds ?? 0)} - - {s.status === 'active' ? '进行中' : s.end_reason || '已结束'} - -
    -
  • - ))} -
- )} - {total > 0 ? ( -
- - 第 {pageStart}–{pageEnd} 条,共 {total} 条 - -
-
+ +
+ {showProcessSelect ? ( + + ) : null} + setSearchDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') applySearch() + }} + /> + + + { + setDateFrom(e.target.value) + setOffset(0) + }} + aria-label="起始日期" + /> + { + setDateTo(e.target.value) + setOffset(0) + }} + aria-label="结束日期" + /> +
+ + {err ?

{err}

: null} + {msg ?

{msg}

: null} + + {pageMode ? ( +
+ + + + + + + + + + + + + + + + {sessions.map((s) => { + const url = relayDouyinUrl(s) + return ( + setSelectedId(s.id)} + > + + + + + + + + + + + ) + })} + {!busy && sessions.length === 0 ? ( + + + + ) : null} + +
截图主播昵称直播间 URL推流开始推流结束总时长推流日期YT 密钥线路
+ {s.cover_url ? ( + + ) : ( + N/A + )} + {s.anchor_name || '—'} + {url ? ( + + ) : ( + '—' + )} + {fmtRelayTs(s.started_at)} + {s.ended_at ? fmtRelayTs(s.ended_at) : s.status === 'active' ? '进行中' : '—'} + {formatDurationCn(s.duration_seconds ?? 0)}{s.push_date || '—'} + {s.youtube_key_suffix ? `…${s.youtube_key_suffix}` : '—'} + {s.channel_name || s.process || '—'}
+ {emptyHint} +
+
+ ) : sessions.length === 0 ? ( +

{emptyHint}

+ ) : ( +
    + {sessions.map((s) => ( +
  • + +
  • + ))} +
+ )} + + {!pageMode && detail ? ( +
+

会话 #{detail.id}

+

+ {detail.stream_title || detail.anchor_name} + {detail.youtube_key_suffix ? ` · key …${detail.youtube_key_suffix}` : ''} +

+ {detail.metadata && Object.keys(detail.metadata).length > 0 ? ( +

{JSON.stringify(detail.metadata)}

+ ) : null} +
+ ) : null} + + {total > 0 || sessions.length > 0 ? ( +
+ + {pageMode + ? `共 ${total} 条 · 第 ${pageIndex}/${pageCount}` + : `第 ${pageStart}–${pageEnd} 条,共 ${total} 条`} + +
+ + +
+
+ ) : null} +
+ + {pageMode && selectedSession ? ( +
setSelectedId(null)}> +
e.stopPropagation()}> +
+
+

{selectedSession.anchor_name || '推流详情'}

+

{selectedSession.stream_title || '—'}

+
+ +
+
+ {selectedSession.cover_url ? ( + + ) : null} +
+
+
直播间 URL
+
+ {relayDouyinUrl(selectedSession) ? ( + + ) : ( + '—' + )} +
+
+
+
房间号
+
{selectedSession.source_room_id || '—'}
+
+
+
推流开始
+
{fmtRelayTs(selectedSession.started_at)}
+
+
+
推流结束
+
+ {selectedSession.ended_at + ? fmtRelayTs(selectedSession.ended_at) + : selectedSession.status === 'active' + ? '进行中' + : '—'} +
+
+
+
总时长
+
{formatDurationCn(selectedSession.duration_seconds ?? 0)}
+
+
+
推流日期
+
{selectedSession.push_date || '—'}
+
+
+
线路 / 进程
+
+ {selectedSession.channel_name ? `${selectedSession.channel_name} · ` : ''} + {selectedSession.process || '—'} +
+
+
+
结束原因
+
{selectedSession.end_reason || '—'}
+
+
+
YouTube 推流密钥(后6位)
+
+ {selectedSession.youtube_key_suffix ? `…${selectedSession.youtube_key_suffix}` : '—'} +
+
+
+
+
+
) : null} - + ) } diff --git a/electron/src/renderer/src/components/YoutubeDiagnosticsPanel.tsx b/electron/src/renderer/src/components/YoutubeDiagnosticsPanel.tsx new file mode 100644 index 0000000..0e2915c --- /dev/null +++ b/electron/src/renderer/src/components/YoutubeDiagnosticsPanel.tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useState } from 'react' + +import { apiFetchJson, apiUrl } from '../api' +import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval' + +type DiagIssue = { level: string; text: string } + +type DiagLane = { + process?: string + label?: string + score?: number + source_display?: string + issues?: DiagIssue[] + layers?: { + source?: { ok?: boolean; hint?: string } + ffmpeg?: { ok?: boolean; pushing?: boolean } + target?: { ok?: boolean; watch_url?: string } + } +} + +type GuardSummary = { + lanes?: number + pushing_lanes?: number + active_events?: number + sample_count?: number + viewer_peak?: number +} + +type DiagReport = { + score?: number + summary?: GuardSummary + diagnostics?: { + summary?: { + lanes?: number + source_configured?: number + target_configured?: number + ffmpeg_active?: number + } + } + lanes?: DiagLane[] +} + +function formatSummaryLine(report: DiagReport | null): string { + if (!report) return '源站、FFmpeg 与 YouTube 目标端三层诊断' + const gs = report.summary + const ds = report.diagnostics?.summary + const parts: string[] = [] + if (gs) { + parts.push(`${gs.pushing_lanes ?? 0}/${gs.lanes ?? 0} 路推流`) + if (typeof gs.active_events === 'number') parts.push(`事件 ${gs.active_events}`) + } else if (ds) { + parts.push(`${ds.lanes ?? 0} 条线路`) + parts.push(`源站 ${ds.source_configured ?? 0} · 目标 ${ds.target_configured ?? 0} · FFmpeg ${ds.ffmpeg_active ?? 0}`) + } + return parts.length ? parts.join(' · ') : '源站、FFmpeg 与 YouTube 目标端三层诊断' +} + +type Props = { + process: string + enabled: boolean +} + +export default function YoutubeDiagnosticsPanel({ process, enabled }: Props) { + const pageVisible = usePageVisible() + const [report, setReport] = useState(null) + const [err, setErr] = useState(null) + + const load = useCallback(async () => { + if (!enabled) return + try { + const q = process ? `?process=${encodeURIComponent(process)}` : '' + const data = await apiFetchJson(apiUrl(`/youtube/diagnostics${q}`)) + setReport(data) + setErr(null) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + }, [enabled, process]) + + useEffect(() => { + void load() + }, [load]) + + useAdaptiveInterval(() => void load(), pageVisible && enabled ? 20_000 : 0, enabled) + + if (!enabled) return null + + const lanes = report?.lanes ?? [] + + return ( +
+
+
+

+ + 🔍 + + 转播诊断 +

+

+ {formatSummaryLine(report)} + {typeof report?.score === 'number' ? ` · 综合 ${report.score} 分` : ''} +

+
+ +
+ {err ?

{err}

: null} + {lanes.length > 0 ? ( +
    + {lanes.map((lane) => { + const sourceOk = lane.layers?.source?.ok + const ffmpegOk = lane.layers?.ffmpeg?.ok + const targetOk = lane.layers?.target?.ok + return ( +
  • + {lane.label || lane.process} + {lane.source_display ? ` · ${lane.source_display}` : ''} + {' · '} + 源站{sourceOk ? '✓' : '✗'} / FFmpeg{ffmpegOk ? '✓' : '✗'} / 目标{targetOk ? '✓' : '✗'} + {typeof lane.score === 'number' ? ` · ${lane.score} 分` : ''} + {(lane.issues ?? []) + .filter((i) => i.level !== 'ok') + .slice(0, 2) + .map((i) => ` · ${i.text}`) + .join('')} +
  • + ) + })} +
+ ) : ( +

暂无诊断数据。

+ )} +
+ ) +} diff --git a/electron/src/renderer/src/components/icons/NavIcons.tsx b/electron/src/renderer/src/components/icons/NavIcons.tsx index 0f5070b..5151106 100644 --- a/electron/src/renderer/src/components/icons/NavIcons.tsx +++ b/electron/src/renderer/src/components/icons/NavIcons.tsx @@ -1,4 +1,4 @@ -type NavId = 'record' | 'camera' | 'pull' | 'network' | 'system' +type NavId = 'record' | 'relay-log' | 'camera' | 'pull' | 'network' | 'system' type IconProps = { className?: string } @@ -63,8 +63,22 @@ function IconSystem({ className }: IconProps) { ) } +function IconRelayLog({ className }: IconProps) { + return ( + + + + + ) +} + const NAV_ICON_MAP: Record JSX.Element> = { record: IconRecord, + 'relay-log': IconRelayLog, camera: IconCamera, pull: IconPull, network: IconNetwork, @@ -76,4 +90,4 @@ export function NavIcon({ id, className }: { id: NavId; className?: string }) { return } -export { IconRecord, IconCamera, IconPull, IconNetwork, IconSystem } +export { IconRecord, IconCamera, IconPull, IconNetwork, IconSystem, IconRelayLog } diff --git a/electron/src/renderer/src/components/live/LiveProLaneRow.tsx b/electron/src/renderer/src/components/live/LiveProLaneRow.tsx index 36faa2e..9f5882b 100644 --- a/electron/src/renderer/src/components/live/LiveProLaneRow.tsx +++ b/electron/src/renderer/src/components/live/LiveProLaneRow.tsx @@ -1,8 +1,10 @@ import LiveStreamActionBtn from './LiveStreamActionBtn' +import { UI_SHOW_PRO_LANE_OPEN_SOURCE } from '../../constants' import { formatFfmpegStatsBrief, laneStatusEmoji, proLanePrimaryTitle, + proLaneStatusBadgeMod, type LiveProcRow, } from '../../lib/liveProcessRows' @@ -36,6 +38,7 @@ type Props = { onStart: () => void onStop: () => void onRestart: () => void + onOpenRelayLog: () => void room: string href: string } @@ -53,12 +56,14 @@ export default function LiveProLaneRow({ onStart, onStop, onRestart, + onOpenRelayLog, room, href, }: Props) { const laneTitle = proLanePrimaryTitle(row.channelName, row.processRow, row.urlText) const statsText = formatFfmpegStatsBrief(row.processRow) const sourceUrl = row.processRow?.source_url || href + const statusBadgeMod = proLaneStatusBadgeMod(row.processRow) return (
{row.keyDisplay} -
+
{laneStatusEmoji(row.processRow)} @@ -87,17 +90,24 @@ export default function LiveProLaneRow({ ) : null}
{sourceUrl ? ( - + UI_SHOW_PRO_LANE_OPEN_SOURCE ? ( + + ) : ( + + {room !== '—' ? `${room} · ` : ''} + {row.channelName} + + ) ) : ( {room} · {row.channelName} @@ -149,6 +159,14 @@ export default function LiveProLaneRow({ onNotify={onNotify} onRun={onRestart} /> +
) diff --git a/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx b/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx index 2d436bf..7d8f640 100644 --- a/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx +++ b/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { formatPollBadge } from '../../adaptivePoll' -import { businessStatusLabel, formatFfmpegSpeed } from '../../liveStatusLabels' +import { businessStatusLabel } from '../../liveStatusLabels' +import { formatFfmpegStatsBrief, mergeFfmpegMetrics } from '../../lib/ffmpegMetrics' import type { LiveDetailState } from '../../types/live' import { IconRefresh } from '../network/NetworkIcons' @@ -88,6 +89,17 @@ export default function LiveProcessLiveLogCard({ ? '转推活跃(PID 未采集)' : '无' const pollBadge = formatPollBadge(pollPaused ? 0 : pollIntervalMs) + const ffmpegMetrics = mergeFfmpegMetrics( + { + ffmpeg_speed: liveDetail.ffmpegSpeed, + ffmpeg_frame: liveDetail.ffmpegFrame, + is_pushing: pushingNow, + ffmpeg_speed_low: liveDetail.ffmpegSpeedLow, + ffmpeg_progress_stalled: liveDetail.ffmpegProgressStalled, + }, + undefined, + liveDetail.recentLog, + ) useEffect(() => { if (pollPaused) return @@ -157,9 +169,7 @@ export default function LiveProcessLiveLogCard({
FFmpeg - {liveDetail.ffmpegFrame != null ? `f${liveDetail.ffmpegFrame}` : '—'} - {' · '} - {formatFfmpegSpeed(liveDetail.ffmpegSpeed)} + {formatFfmpegStatsBrief(ffmpegMetrics, { live: pushingNow })} {liveDetail.ffmpegSpeedLow ? ' 偏低' : ''} {liveDetail.ffmpegProgressStalled ? ' 停滞' : ''} diff --git a/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx b/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx index 755c7dd..f3ff1de 100644 --- a/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx +++ b/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx @@ -1,7 +1,19 @@ +import { + UI_SHOW_LIVE_COMMAND_DESC, + UI_SHOW_LIVE_COMMAND_STATS, + UI_SHOW_LIVE_PREFLIGHT_BADGE, + UI_SHOW_LIVE_PREFLIGHT_GRID, + UI_SHOW_LIVE_QUICK_ACTIONS, + UI_SHOW_QUALITY_GUARD, + UI_SHOW_YOUTUBE_DIAGNOSTICS, + UI_SHOW_YOUTUBE_PIPELINE_STRIP, + UI_SHOW_YOUTUBE_TARGET_MONITOR, +} from '../../constants' import type { AppNavId } from '@shared/featureFlags' import LivePipeline from '../LivePipeline' import LiveYoutubeMonitor from '../LiveYoutubeMonitor' import QualityGuardBanner from '../QualityGuardBanner' +import YoutubeDiagnosticsPanel from '../YoutubeDiagnosticsPanel' import LivePulse from '../ui/LivePulse' import LivePipelineStrip from './LivePipelineStrip' import type { LiveDetailState } from '../../types/live' @@ -78,39 +90,41 @@ export default function LiveYoutubeOverview({ return ( <> - + {UI_SHOW_YOUTUBE_PIPELINE_STRIP ? ( + + ) : null}
@@ -119,128 +133,150 @@ export default function LiveYoutubeOverview({

{multiChannelPro ? '多频道 Pro 控制台' : '单频道直播控制台'}

-

- {multiChannelPro - ? '每个频道独立保存串流密钥、源站地址和 PM2 进程;启动前自动保存当前草稿并执行服务端预检。' - : '适合一个源站直播间推送到一个 YouTube 直播间;需要多路并发时打开右上角 Pro。'} -

-
-
- - - {livePreflightText} - - - - - {douyinHint ? ( - + {UI_SHOW_LIVE_COMMAND_DESC ? ( +

+ {multiChannelPro + ? '每个频道独立保存串流密钥、源站地址和 PM2 进程;启动前自动保存当前草稿并执行服务端预检。' + : '适合一个源站直播间推送到一个 YouTube 直播间;需要多路并发时打开右上角 Pro。'} +

) : null}
-
- -
-
- - ⚙️ - - 工作模式 - {multiChannelPro ? '多频道 Pro' : '单频道'} -
-
- - 📡 - - 运行状态 - - {multiChannelPro - ? `${proLiveCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 路推流中` - : statusLine} - -
-
- - 🔑 - - 密钥 - - {multiChannelPro - ? `${proKeyCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 已配置` - : singleKeyConfigured - ? '已配置' - : '未配置'} - -
-
- - 🔗 - - 源站 - {multiChannelPro ? `${proSourceCount} 条` : `${singleSourceCount} 条`} -
-
- - 🖥️ - - 编码 - {gpuStatusText} -
- {multiChannelPro && selectedProRow ? ( -
- - 📺 - - 当前频道 - {selectedProRow.channelName} + {UI_SHOW_LIVE_PREFLIGHT_BADGE || UI_SHOW_LIVE_QUICK_ACTIONS ? ( +
+ {UI_SHOW_LIVE_PREFLIGHT_BADGE ? ( + + + {livePreflightText} + + ) : null} + {UI_SHOW_LIVE_QUICK_ACTIONS ? ( + <> + + + + {douyinHint ? ( + + ) : null} + + ) : null}
) : null}
+ + + {UI_SHOW_LIVE_COMMAND_STATS ? ( +
+
+ + ⚙️ + + 工作模式 + {multiChannelPro ? '多频道 Pro' : '单频道'} +
+
+ + 📡 + + 运行状态 + + {multiChannelPro + ? `${proLiveCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 路推流中` + : statusLine} + +
+
+ + 🔑 + + 密钥 + + {multiChannelPro + ? `${proKeyCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 已配置` + : singleKeyConfigured + ? '已配置' + : '未配置'} + +
+
+ + 🔗 + + 源站 + {multiChannelPro ? `${proSourceCount} 条` : `${singleSourceCount} 条`} +
+
+ + 🖥️ + + 编码 + {gpuStatusText} +
+ {multiChannelPro && selectedProRow ? ( +
+ + 📺 + + 当前频道 + {selectedProRow.channelName} +
+ ) : null} +
+ ) : null} + {!backendReady ? (

本地控制服务连接中…直播开关暂不可用,连接成功后自动恢复。

) : null} - + {UI_SHOW_QUALITY_GUARD ? ( + + ) : null} + {UI_SHOW_YOUTUBE_DIAGNOSTICS ? ( + + ) : null} - + {UI_SHOW_YOUTUBE_TARGET_MONITOR ? ( + + ) : null} - - -
- {preflightItems.map((item) => ( -
- -
- {item.label} - {item.detail} + {UI_SHOW_LIVE_PREFLIGHT_GRID ? ( +
+ {preflightItems.map((item) => ( +
+ +
+ {item.label} + {item.detail} +
-
- ))} -
+ ))} +
+ ) : null} ) diff --git a/electron/src/renderer/src/components/network/NetworkToolkitPanel.tsx b/electron/src/renderer/src/components/network/NetworkToolkitPanel.tsx new file mode 100644 index 0000000..482febd --- /dev/null +++ b/electron/src/renderer/src/components/network/NetworkToolkitPanel.tsx @@ -0,0 +1,245 @@ +import type { BrowserFingerprintState } from '../../hooks/useBrowserFingerprint' +import type { IsChinaUserState } from '../../hooks/useIsChinaUser' + +type ToolkitSummary = { + download_mbps?: number | null + upload_mbps?: number | null + ping_ms?: number | null + hops?: number | null + last_hop_avg_ms?: number | null + ip?: string | null + city?: string | null + region?: string | null + country?: string | null + org?: string | null + headline?: string | null + proxy_like?: boolean | null + organization?: string | null + youtube?: string | null + tiktok?: string | null +} + +type ToolkitNode = { + provider?: string + ok?: boolean + note?: string + command?: string | null + error?: string | null + raw?: unknown + summary?: ToolkitSummary + country_code?: string | null + reason?: string | null + scope?: string | null + script?: string | null + script_exists?: boolean + host?: string + platform?: string + cpu_percent?: number + memory_percent?: number + memory_used_gb?: number + memory_total_gb?: number + cpu_logical?: number + disks?: { mount?: string; percent?: number; free_gb?: number }[] + net_io?: { bytes_sent_mb?: number; bytes_recv_mb?: number } +} + +export type NetworkToolkitPayload = { + providers?: { + systeminformation?: ToolkitNode + librespeed_speedtest?: ToolkitNode + mtr?: ToolkitNode + ipinfo_cli?: ToolkitNode + ipquality?: ToolkitNode + is_china_user?: ToolkitNode + webrtc?: ToolkitNode + fingerprintjs?: ToolkitNode + } + meta?: { at?: number; logic_version?: number } +} + +function flag(ok: boolean | undefined): string { + if (ok === true) return '可用' + if (ok === false) return '不可用' + return '未知' +} + +function signalMark(v: boolean | null | undefined): string { + if (v === true) return '✓' + if (v === false) return '○' + return '—' +} + +function fmtMbps(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '—' + return `${v.toFixed(2)} Mbps` +} + +function ToolkitCard({ + title, + status, + lines, + error, +}: { + title: string + status: string + lines: string[] + error?: string | null +}) { + return ( +
+

{title}

+

{status}

+ {lines.map((line) => ( + {line} + ))} + {error ? {error} : null} +
+ ) +} + +export default function NetworkToolkitPanel({ + data, + loading, + error, + webrtcLeak, + webrtcLocals, + webrtcError, + fingerprint, + isChinaUser, +}: { + data: NetworkToolkitPayload | null + loading: boolean + error: string | null + webrtcLeak: boolean | null + webrtcLocals: string[] + webrtcError: string | null + fingerprint: BrowserFingerprintState + isChinaUser: IsChinaUserState +}) { + const p = data?.providers || {} + const sys = p.systeminformation + const speed = p.librespeed_speedtest + const mtr = p.mtr + const ipinfo = p.ipinfo_cli + const ipq = p.ipquality + const chinaServer = p.is_china_user + const chinaClient = isChinaUser.signals + + return ( +
+
+
+

网络增强工具

+

+ 已接入 systeminformation、speedtest、mtr、webrtc、ipinfo、FingerprintJS、is-china-user、IPQuality +

+
+
+ {error ?

{error}

: null} + {loading ?

检测中…

: null} +
+ + + + + + + + +
+
+ ) +} diff --git a/electron/src/renderer/src/constants.ts b/electron/src/renderer/src/constants.ts index 437038d..e54357e 100644 --- a/electron/src/renderer/src/constants.ts +++ b/electron/src/renderer/src/constants.ts @@ -4,5 +4,44 @@ export const UI_DROPDOWN_YOUTUBE_ONLY = true /** 为 true 时:YouTube 卡片显示「Google 授权 / 拉取串流密钥」;false 仅保留手动编辑配置(后端路由仍保留) */ export const UI_SHOW_YOUTUBE_GOOGLE_OAUTH = false +/** 为 true 时:直播页顶部显示「YouTube 转播链路」步骤条 */ +export const UI_SHOW_YOUTUBE_PIPELINE_STRIP = false + +/** 为 true 时:直播页显示「当前直播日志」卡片 */ +export const UI_SHOW_LIVE_PROCESS_LOG = false + +/** 为 true 时:直播页显示「直播事件时间线」面板 */ +export const UI_SHOW_LIVE_EVENTS_PANEL = false + +/** 为 true 时:直播控制台显示 Pro 说明文案 */ +export const UI_SHOW_LIVE_COMMAND_DESC = false + +/** 为 true 时:直播控制台显示预检徽章(如「1/2 条线路可启动」) */ +export const UI_SHOW_LIVE_PREFLIGHT_BADGE = false + +/** 为 true 时:直播控制台显示快捷按钮(Studio / 网络 / 系统 / 源站) */ +export const UI_SHOW_LIVE_QUICK_ACTIONS = false + +/** 为 true 时:直播控制台显示统计网格(密钥 / 源站 / 编码等) */ +export const UI_SHOW_LIVE_COMMAND_STATS = false + +/** 为 true 时:直播控制台显示开播前体检网格 */ +export const UI_SHOW_LIVE_PREFLIGHT_GRID = false + +/** 为 true 时:Pro 线路行显示「打开源站」链接 */ +export const UI_SHOW_PRO_LANE_OPEN_SOURCE = false + +/** 为 true 时:直播页显示「转播诊断」面板 */ +export const UI_SHOW_YOUTUBE_DIAGNOSTICS = false + +/** 为 true 时:直播页显示「YouTube 目标端监控」面板 */ +export const UI_SHOW_YOUTUBE_TARGET_MONITOR = false + +/** 为 true 时:直播页显示「配置顾问」面板 */ +export const UI_SHOW_CONFIG_ADVISOR = false + +/** 为 true 时:直播页显示「质量守卫」模块 */ +export const UI_SHOW_QUALITY_GUARD = false + /** localStorage:多路转播 Pro 偏好(多组「源站↔YouTube」一对一并行;禁止一对多/共用密钥) */ export const STORAGE_MULTI_CHANNEL_PRO = 'recording_multi_channel_pro' diff --git a/electron/src/renderer/src/hooks/useBrowserFingerprint.ts b/electron/src/renderer/src/hooks/useBrowserFingerprint.ts new file mode 100644 index 0000000..c907f7d --- /dev/null +++ b/electron/src/renderer/src/hooks/useBrowserFingerprint.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import FingerprintJS from '@fingerprintjs/fingerprintjs' + +export type BrowserFingerprintState = { + visitorId: string | null + confidence: number | null + componentsCount: number + loading: boolean + error: string | null +} + +export async function collectBrowserFingerprint(): Promise<{ + visitorId: string + confidence: number | null + componentsCount: number +}> { + const agent = await FingerprintJS.load() + const result = await agent.get() + return { + visitorId: result.visitorId, + confidence: result.confidence?.score ?? null, + componentsCount: Object.keys(result.components || {}).length, + } +} + +export function useBrowserFingerprint(enabled = true): BrowserFingerprintState { + const [visitorId, setVisitorId] = useState(null) + const [confidence, setConfidence] = useState(null) + const [componentsCount, setComponentsCount] = useState(0) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(null) + + useEffect(() => { + if (!enabled) return + let cancelled = false + setLoading(true) + void collectBrowserFingerprint() + .then((r) => { + if (cancelled) return + setVisitorId(r.visitorId) + setConfidence(r.confidence) + setComponentsCount(r.componentsCount) + setError(null) + }) + .catch((e) => { + if (cancelled) return + setError(e instanceof Error ? e.message : String(e)) + setVisitorId(null) + setConfidence(null) + setComponentsCount(0) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [enabled]) + + return { visitorId, confidence, componentsCount, loading, error } +} diff --git a/electron/src/renderer/src/hooks/useIsChinaUser.ts b/electron/src/renderer/src/hooks/useIsChinaUser.ts new file mode 100644 index 0000000..81e89bd --- /dev/null +++ b/electron/src/renderer/src/hooks/useIsChinaUser.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import { + isChinaByEmoji, + isChinaByFont, + isChinaByLanguage, + isChinaByNetwork, + isChinaByTimeZone, + isChinaUser, + type IsChinaByNetworkDetailResult, +} from 'is-china-user' + +export type IsChinaUserSignals = { + combined: boolean + mainland: boolean + language: boolean + timezone: boolean + emoji: boolean | null + font: boolean + network: boolean | null + networkDetail: IsChinaByNetworkDetailResult | null +} + +export type IsChinaUserState = { + signals: IsChinaUserSignals | null + loading: boolean + error: string | null +} + +export async function collectIsChinaUserSignals(mainland = true): Promise { + const opts = { mainland } + const networkDetail = await isChinaByNetwork({ ...opts, detail: true }) + return { + combined: isChinaUser(opts), + mainland, + language: isChinaByLanguage(opts), + timezone: isChinaByTimeZone(opts), + emoji: isChinaByEmoji(), + font: isChinaByFont(), + network: networkDetail?.result ?? null, + networkDetail: networkDetail ?? null, + } +} + +export function useIsChinaUser(enabled = true, mainland = true): IsChinaUserState { + const [signals, setSignals] = useState(null) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(null) + + useEffect(() => { + if (!enabled) return + let cancelled = false + setLoading(true) + void collectIsChinaUserSignals(mainland) + .then((r) => { + if (cancelled) return + setSignals(r) + setError(null) + }) + .catch((e) => { + if (cancelled) return + setSignals(null) + setError(e instanceof Error ? e.message : String(e)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [enabled, mainland]) + + return { signals, loading, error } +} diff --git a/electron/src/renderer/src/live-polish.css b/electron/src/renderer/src/live-polish.css index e9523b3..adcc24b 100644 --- a/electron/src/renderer/src/live-polish.css +++ b/electron/src/renderer/src/live-polish.css @@ -43,6 +43,16 @@ color: var(--accent-2); } +.live-action-btn--log { + border-color: rgba(167, 139, 250, 0.35); + color: #c4b5fd; + background: rgba(139, 92, 246, 0.12); +} + +.live-action-btn--log:hover:not(.is-locked):not(:disabled) { + background: rgba(139, 92, 246, 0.2); +} + .live-action-btn:hover:not(.is-locked):not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-sm); diff --git a/electron/src/renderer/src/network-polish.css b/electron/src/renderer/src/network-polish.css index 132919b..b7783a4 100644 --- a/electron/src/renderer/src/network-polish.css +++ b/electron/src/renderer/src/network-polish.css @@ -958,6 +958,46 @@ background: rgba(255, 255, 255, 0.02); } +.net-block--toolkit { + border-color: rgba(56, 189, 248, 0.22); + background: linear-gradient(165deg, rgba(56, 189, 248, 0.08), var(--bg-card-elevated) 62%); +} + +.net-toolkit-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 10px; +} + +.net-toolkit-item { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.03); +} + +.net-toolkit-item h4 { + margin: 0 0 4px; + font-size: 12px; +} + +.net-toolkit-item p { + margin: 0; + font-size: 12px; + font-weight: 700; +} + +.net-toolkit-item small { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.net-toolkit-item__err { + color: var(--warn); +} + .net-aside-card { padding: 14px; border-radius: 14px; diff --git a/electron/src/renderer/src/youtubeLiveUtils.ts b/electron/src/renderer/src/youtubeLiveUtils.ts index 721677c..670db8a 100644 --- a/electron/src/renderer/src/youtubeLiveUtils.ts +++ b/electron/src/renderer/src/youtubeLiveUtils.ts @@ -12,6 +12,9 @@ export type ProChannelEditor = { keys: string[] activeIndex: number urlText: string + channelName?: string + pm2Name?: string + notes?: string /** YouTube 频道/直播页地址,供目标端监听(弹幕、观众数) */ youtubeWatchText?: string } @@ -87,7 +90,9 @@ export function youtubeIniActiveKey(m: YoutubeIniModel): string { return (m.keys[i] || '').trim() } -export function proChannelPm2Process(channelId: string): string { +export function proChannelPm2Process(channelId: string, pm2Name?: string): string { + const custom = (pm2Name || '').trim() + if (custom) return custom return `youtube2__${channelId}` } @@ -167,3 +172,8 @@ export function formatControlOutput(data: Record): string { .join('\n') || '已完成' ) } + +/** 对齐 d2ypp2:从直播开关跳转到推流日志页时规范化进程名 */ +export function normalizeRelayLogProcess(process: string): string { + return (process || 'youtube').trim() || 'youtube' +} diff --git a/electron/src/shared/featureFlags.ts b/electron/src/shared/featureFlags.ts index 97061e7..e781683 100644 --- a/electron/src/shared/featureFlags.ts +++ b/electron/src/shared/featureFlags.ts @@ -4,7 +4,7 @@ export const SHOW_CAMERA_UI = false /** 是否在导航中展示「拉流」功能页 */ export const SHOW_PULL_STREAM_UI = false -export type AppNavId = 'record' | 'camera' | 'pull' | 'network' | 'system' +export type AppNavId = 'record' | 'relay-log' | 'camera' | 'pull' | 'network' | 'system' const DISABLED_NAV_IDS = new Set( [ @@ -21,7 +21,7 @@ export function isNavEnabled(id: AppNavId): boolean { export function sanitizeNavId(raw: string | null | undefined): AppNavId | null { if (!raw) return null const id = raw === 'proxy' ? 'network' : raw - if (id !== 'record' && id !== 'camera' && id !== 'pull' && id !== 'network' && id !== 'system') { + if (id !== 'record' && id !== 'relay-log' && id !== 'camera' && id !== 'pull' && id !== 'network' && id !== 'system') { return null } if (!isNavEnabled(id)) return 'record' diff --git a/web2.py b/web2.py index a3673d0..38c351f 100644 --- a/web2.py +++ b/web2.py @@ -31,14 +31,22 @@ from web2_youtube_oauth import ( from web2_client_overview import build_client_overview from web2_process_runtime import process_family, summarize_process_activity from web2_douyin_source_display import extract_douyin_source_meta +from web2_douyin_preview import fetch_douyin_anchor_name from web2_config_governance import ( list_config_backups, restore_config_backup, write_config_text, ) +from web2_config_insights import analyze_config_content, optimize_config_content, read_config_file +from web2_youtube_ingest_auth import ingest_config_payload, verify_ingest_payload +from web2_youtube_pro_runtime import sync_all_youtube_pro_runtime_files, validate_youtube_runtime_files +from web2_relay_pro import channel_id_for_pm2_name, load_channels_document, save_channels_document from web2_quality_guard import build_youtube_quality_guard_report from web2_youtube_diagnostics import build_youtube_diagnostics_report from web2_relay_log import ( + close_active_relay_sessions, + get_relay_cover_path, + get_relay_session, import_relay_sessions_from_log, list_relay_sessions, relay_db_path, @@ -57,6 +65,7 @@ from web2_relay_pro import ( get_single_youtube_watch_url, get_youtube_watch_url, list_channels_for_pro, + relay_pm2_name_for_channel, materialize_relay_channel_youtube_ini, read_url_config_for_channel, relay_pm2_name_for_channel, @@ -68,7 +77,19 @@ from web2_relay_pro import ( watch_url_for_relay_process, write_url_config_for_channel, ) -from web2_youtube_preflight import youtube_start_preflight_message +from web2_youtube_preflight import run_youtube_preflight, youtube_start_preflight_message +from web2_youtube_relay_heartbeat import read_relay_heartbeat +from web2_youtube_live_store import ( + close_live_event, + extract_youtube_key_suffix, + get_live_event, + ingest_browser_telemetry, + list_live_events, + live_db_path, + record_control_action, + record_process_observation, + youtube_live_summary, +) from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini, validate_youtube_keys from web2_host_caps import gpu_status_payload, machine_summary_payload from web2_camera import camera_config_ready, list_capture_devices, load_camera_config, save_camera_config @@ -99,7 +120,10 @@ BASE_DIR = Path(__file__).parent CONFIG_DIR = BASE_DIR / "config" DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" RELAY_DB = relay_db_path(BASE_DIR) +LIVE_DB = live_db_path(BASE_DIR) _PROCESS_STATUS_CACHE: dict[str, str] = {} +_LIVE_OBSERVE_LAST: dict[str, float] = {} +YOUTUBE_EVENT_STATUS_OBSERVE_SECONDS = 5.0 _SYSTEM_METRICS_TTL_SECONDS = 10.0 _SYSTEM_METRICS_CACHE: tuple[float, dict] | None = None _SYSTEM_METRICS_CACHE_LOCK = asyncio.Lock() @@ -107,6 +131,50 @@ _SYSTEM_METRICS_CACHE_LOCK = asyncio.Lock() MAX_LOG_LINES = 300 +def _relay_control_meta(process: str) -> dict[str, str]: + cid = relay_channel_id_from_pm2_name(process) + channel_name = "" + if cid: + d = get_channel_detail(CONFIG_DIR, cid) + channel_name = str((d or {}).get("name") or "") + cfg = _config_snapshot_for_process(process) + return { + "channel_id": cid or "", + "channel_name": channel_name, + "url_lines": cfg.get("url_lines") or _url_lines_for_process(process), + "youtube_ini_text": cfg.get("youtube_ini_text") or "", + "script": script_for_pm2(process) or "", + } + + +async def _record_youtube_control(process: str, action: str, *, note: str = "") -> None: + if not is_youtube_script(script_for_pm2(process) or ""): + return + meta = _relay_control_meta(process) + try: + await asyncio.to_thread( + record_control_action, + process, + action, + script=meta["script"], + url_lines=meta["url_lines"], + youtube_ini_text=meta["youtube_ini_text"], + channel_id=meta["channel_id"], + channel_name=meta["channel_name"], + note=note, + db_path=LIVE_DB, + ) + except Exception: + pass + + +async def _close_youtube_relay_sessions(process: str, *, reason: str) -> None: + try: + await asyncio.to_thread(close_active_relay_sessions, process, reason=reason, db_path=RELAY_DB) + except Exception: + pass + + def _label_short(prefix: str) -> str: return { "tiktok": "TikTok", @@ -198,10 +266,16 @@ VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR) def relay_channel_id_from_pm2_name(name: str) -> Optional[str]: + cid = channel_id_for_pm2_name(CONFIG_DIR, name) + if cid: + return cid prefix = "youtube2__" if not name.startswith(prefix): return None - return name[len(prefix) :] + legacy = name[len(prefix) :] + if get_channel_by_id(CONFIG_DIR, legacy): + return legacy + return None def process_valid(name: str) -> bool: @@ -446,7 +520,10 @@ async def relay_pro_save_channel_keys(request: Request): @app.post("/relay_pro/sync_active") async def relay_pro_sync_active(channel_id: str = Query(...)): - ok, msg = sync_channel_to_legacy_files(CONFIG_DIR, channel_id) + try: + ok, msg = await asyncio.to_thread(sync_channel_to_legacy_files, CONFIG_DIR, channel_id) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=500) if not ok: return JSONResponse({"error": msg}, status_code=400) return {"message": msg} @@ -494,10 +571,27 @@ async def douyin_room_previews(request: Request): for u in urls[:16]: if not isinstance(u, str) or not u.strip(): continue - out.append(fetch_douyin_anchor_name(u.strip())) + try: + out.append(fetch_douyin_anchor_name(u.strip())) + except Exception as exc: + out.append( + { + "ok": False, + "error": str(exc), + "url": u.strip(), + "room_id": "", + "anchor_name": "", + } + ) return {"previews": out} +@app.exception_handler(Exception) +async def web2_uncaught_exception_handler(request: Request, exc: Exception): + """未捕获异常仍返回 JSON,便于 CORS 与前端展示。""" + return JSONResponse({"error": str(exc) or exc.__class__.__name__}, status_code=500) + + # ---------------- 工具函数 ---------------- async def read_log_path(path: str, lines: int) -> str: if not path: @@ -651,6 +745,13 @@ async def _youtube_guard_rows(targets: list[str]) -> tuple[list[dict], dict[str, if d: label = str(d.get("name") or label) row["label"] = label + hb = read_relay_heartbeat(proc, base_dir=BASE_DIR) + if hb.get("state") in {"starting", "streaming", "source_failover"}: + if hb.get("speed") is not None: + row["ffmpeg_speed"] = hb.get("speed") + if hb.get("frame") is not None: + row["ffmpeg_frame"] = hb.get("frame") + row["relay_heartbeat"] = hb rows.append(row) config_by_process[proc] = _config_snapshot_for_process(proc) return rows, config_by_process @@ -1025,7 +1126,16 @@ async def start(request: Request, process: str = Query(..., description="进程 preflight_msg = await youtube_start_preflight_message() if preflight_msg: return JSONResponse({"output": preflight_msg}, status_code=400) + ch_doc, _ = load_channels_document(CONFIG_DIR) + channels = (ch_doc or {}).get("channels") or [] + runtime_errors = await asyncio.to_thread( + validate_youtube_runtime_files, CONFIG_DIR, process, channels + ) + if runtime_errors: + return JSONResponse({"output": "\n".join(runtime_errors)}, status_code=400) + await asyncio.to_thread(sync_all_youtube_pro_runtime_files, CONFIG_DIR, channels) output = await control_start(process) + await _record_youtube_control(process, "start") return JSONResponse({"output": output}) @@ -1047,6 +1157,9 @@ async def stop(process: str = Query(..., description="进程名")): # 第四步:再等一小会儿,确认 await asyncio.sleep(1.0) + await _close_youtube_relay_sessions(process, reason="stopped") + await _record_youtube_control(process, "stop", note="manual stop") + return JSONResponse({ "message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"), "pm2_output": ctrl_output, @@ -1075,6 +1188,9 @@ async def restart(request: Request, process: str = Query(..., description="进 await force_kill_ffmpeg_cross_platform(process) await asyncio.sleep(0.35) + await _close_youtube_relay_sessions(process, reason="restart") + await _record_youtube_control(process, "stop", note="restart pre-start") + if cid: ready_msg = _relay_channel_ready_message(cid) if ready_msg: @@ -1091,7 +1207,19 @@ async def restart(request: Request, process: str = Query(..., description="进 preflight_msg = await youtube_start_preflight_message() if preflight_msg: return JSONResponse({"output": f"{stop_output}\n{preflight_msg}"}, status_code=400) + ch_doc, _ = load_channels_document(CONFIG_DIR) + channels = (ch_doc or {}).get("channels") or [] + runtime_errors = await asyncio.to_thread( + validate_youtube_runtime_files, CONFIG_DIR, process, channels + ) + if runtime_errors: + return JSONResponse( + {"output": f"{stop_output}\n" + "\n".join(runtime_errors)}, + status_code=400, + ) + await asyncio.to_thread(sync_all_youtube_pro_runtime_files, CONFIG_DIR, channels) start_output = await control_start(process) + await _record_youtube_control(process, "restart") return JSONResponse({"output": f"{stop_output}\n{start_output}"}) @@ -1133,10 +1261,40 @@ async def status( channel_name=channel_name, url_lines=url_lines, db_path=RELAY_DB, + base_dir=BASE_DIR, ) except Exception: pass + try: + hb = read_relay_heartbeat(process, base_dir=BASE_DIR) + observe_payload = { + "process_status": ps, + "recent_log": str(payload.get("recent_log") or ""), + "recent_error": str(payload.get("recent_error") or ""), + "is_pushing": ps == "online" and bool(hb.get("state") in {"starting", "streaming", "source_failover"}), + "live_status": str(hb.get("state") or ""), + "business_status": "streaming" if hb.get("state") == "streaming" else ps, + } + if hb: + observe_payload["relay_heartbeat"] = hb + if hb.get("speed") is not None: + observe_payload["ffmpeg_speed"] = hb.get("speed") + if hb.get("frame") is not None: + observe_payload["ffmpeg_frame"] = hb.get("frame") + now_ts = time.time() + last_ob = _LIVE_OBSERVE_LAST.get(process, 0.0) + if now_ts - last_ob >= YOUTUBE_EVENT_STATUS_OBSERVE_SECONDS: + _LIVE_OBSERVE_LAST[process] = now_ts + record_process_observation( + process, + observe_payload, + url_lines=url_lines, + db_path=LIVE_DB, + ) + except Exception: + pass + return JSONResponse({ "raw_status": full_status, **payload, @@ -1255,7 +1413,10 @@ async def youtube_quality_guard(process: str = Query("", description="进程名 if err is not None: return err rows, config_by_process = await _youtube_guard_rows(targets) - return build_youtube_quality_guard_report(rows, config_by_process=config_by_process) + live = await asyncio.to_thread(youtube_live_summary, db_path=LIVE_DB) + return build_youtube_quality_guard_report( + rows, live_summary=live, config_by_process=config_by_process + ) @app.get("/youtube/diagnostics") @@ -1267,10 +1428,21 @@ async def youtube_diagnostics(process: str = Query("", description="进程名, return build_youtube_diagnostics_report(rows, config_by_process=config_by_process) +@app.get("/youtube/preflight") +async def youtube_preflight(): + """开播前网络体检(Google / YouTube / 抖音 + 录制代理)。""" + try: + return await run_youtube_preflight() + except Exception as e: + return JSONResponse({"error": str(e), "ok": False}, status_code=500) + + @app.get("/youtube/relay/sessions") async def youtube_relay_sessions( process: str = Query("", description="PM2 进程名"), q: str = Query("", description="搜索主播/URL/房间"), + date_from: str = Query("", description="起始日期 YYYY-MM-DD"), + date_to: str = Query("", description="结束日期 YYYY-MM-DD"), status: str = Query("", description="active | ended"), limit: int = Query(30, ge=1, le=200), offset: int = Query(0, ge=0), @@ -1278,6 +1450,8 @@ async def youtube_relay_sessions( return list_relay_sessions( process=process, q=q, + date_from=date_from, + date_to=date_to, status=status, limit=limit, offset=offset, @@ -1285,19 +1459,118 @@ async def youtube_relay_sessions( ) -@app.post("/youtube/relay/sessions/import") -async def youtube_relay_sessions_import(process: str = Query(..., description="进程名")): +@app.get("/youtube/relay/sessions/{session_id}") +async def youtube_relay_session_detail(session_id: str): + session = get_relay_session(session_id, db_path=RELAY_DB) + if not session: + return JSONResponse({"error": "会话不存在"}, status_code=404) + return {"session": session} + + +@app.get("/youtube/relay/sessions/{session_id}/cover") +async def youtube_relay_session_cover(session_id: int): + path = get_relay_cover_path(session_id, db_path=RELAY_DB) + if not path: + return JSONResponse({"error": "封面不存在"}, status_code=404) + return FileResponse(path, media_type="image/jpeg") + + +@app.get("/youtube/relay/heartbeat") +async def youtube_relay_heartbeat(process: str = Query(..., description="PM2 进程名")): if not process_valid(process): return JSONResponse({"error": "无效进程名"}, status_code=400) + hb = read_relay_heartbeat(process, base_dir=BASE_DIR) + return {"process": process, "heartbeat": hb, "fresh": bool(hb.get("updated_at"))} + + +@app.get("/youtube/live/summary") +async def youtube_live_summary_route(): + try: + return youtube_live_summary(db_path=LIVE_DB) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + +@app.get("/youtube/live/events") +async def youtube_live_events( + process: str = Query("", description="PM2 进程名"), + status: str = Query("", description="active | ended | error"), + limit: int = Query(30, ge=1, le=200), + offset: int = Query(0, ge=0), +): + return list_live_events( + process=process, + status=status, + limit=limit, + offset=offset, + db_path=LIVE_DB, + ) + + +@app.get("/youtube/live/events/{event_id}") +async def youtube_live_event_detail(event_id: str): + event = get_live_event(event_id, db_path=LIVE_DB) + if not event: + return JSONResponse({"error": "事件不存在"}, status_code=404) + return {"event": event} + + +@app.post("/youtube/live/events/{event_id}/close") +async def youtube_live_event_close(event_id: str, request: Request): + try: + data = await request.json() + except Exception: + data = {} + reason = str(data.get("reason") or "manual") + result = close_live_event(event_id, reason=reason, db_path=LIVE_DB) + if not result: + return JSONResponse({"error": "事件不存在"}, status_code=404) + return result + + +@app.post("/youtube/live/ingest") +async def youtube_live_ingest(request: Request): + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + if not isinstance(data, dict): + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + ok, msg = verify_ingest_payload(data) + if not ok: + return JSONResponse({"error": msg}, status_code=403) + return ingest_browser_telemetry(data, db_path=LIVE_DB) + + +@app.get("/youtube/live/ingest_config") +async def youtube_live_ingest_config(): + return ingest_config_payload() + + +@app.post("/youtube/relay/sessions/import") +async def youtube_relay_sessions_import(request: Request, process: str = Query(..., description="进程名")): + if not process_valid(process): + return JSONResponse({"error": "无效进程名"}, status_code=400) + replace_imported = False + log_path_override = "" + try: + body = await request.json() + if isinstance(body, dict): + replace_imported = bool(body.get("replace_imported")) + log_path_override = str(body.get("log_path") or "").strip() + except Exception: + pass info = await control_get_process_info(process) - log_path = info.get("out_path") or "" + log_path = log_path_override or info.get("out_path") or "" if not log_path: return JSONResponse({"error": "无法定位 PM2 日志路径"}, status_code=400) result = import_relay_sessions_from_log( Path(log_path), process=process, url_lines=_url_lines_for_process(process), + youtube_key_suffix=extract_youtube_key_suffix(_youtube_ini_text_for_process(process)), db_path=RELAY_DB, + replace_imported=replace_imported, ) if result.get("error"): return JSONResponse(result, status_code=400) @@ -1328,6 +1601,72 @@ async def config_restore(request: Request): return {"message": msg} +@app.post("/config_optimize") +async def config_optimize(request: Request): + """配置优化(参照 d2ypp2 config_governance.optimize_content)。""" + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + if not isinstance(data, dict): + return JSONResponse({"error": "无效数据"}, status_code=400) + action = str(data.get("action") or "").strip() + content = str(data.get("content") or "") + rel = str(data.get("file") or "").strip().replace("\\", "/").lstrip("/") + if ".." in rel.split("/"): + return JSONResponse({"error": "非法路径"}, status_code=400) + if not content and rel: + content = read_config_file(CONFIG_DIR, rel) + try: + result = optimize_config_content(action, rel, content) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + if data.get("save") and rel: + write_config_text(CONFIG_DIR, rel, result["content"], reason=action) + result["message"] = f"{result['message']},并已保存" + return result + + +@app.get("/config_insights") +async def config_insights_get(file: str = Query(..., description="相对 config 的路径")): + rel = file.strip().replace("\\", "/").lstrip("/") + if ".." in rel.split("/"): + return JSONResponse({"error": "非法路径"}, status_code=400) + content = read_config_file(CONFIG_DIR, rel) + if not content and not (CONFIG_DIR / rel).is_file(): + return JSONResponse({"error": "文件不存在"}, status_code=404) + return analyze_config_content(rel, content) + + +@app.post("/config_insights") +async def config_insights_post(request: Request): + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + rel = str(data.get("file") or "").strip().replace("\\", "/").lstrip("/") + content = str(data.get("content") or "") + if ".." in rel.split("/"): + return JSONResponse({"error": "非法路径"}, status_code=400) + return analyze_config_content(rel, content) + + +@app.post("/relay_pro/channels_save") +async def relay_pro_channels_save(request: Request): + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + channels = data.get("channels") if isinstance(data.get("channels"), list) else data + if not isinstance(channels, list): + return JSONResponse({"error": "channels 必须是数组"}, status_code=400) + ok, msg = save_channels_document(CONFIG_DIR, channels) + if not ok: + return JSONResponse({"error": msg}, status_code=400) + sync_errors = sync_all_youtube_pro_runtime_files(CONFIG_DIR, channels) + return {"message": msg, "runtime_errors": sync_errors} + + def _process_monitor_family_matches(entry: dict, family: str | None) -> bool: target = (family or "").strip().lower() if not target or target == "all": @@ -1381,11 +1720,14 @@ async def process_monitor( if doc.get("ok"): for ch in doc.get("channels") or []: cid = str(ch.get("id") or "").strip() - if not cid or not channel_configured_for_monitor(CONFIG_DIR, cid): + if not cid: + continue + pm2 = relay_pm2_name_for_channel(cid, CONFIG_DIR) + if any(e.get("pm2") == pm2 for e in entries): continue entries.append( { - "pm2": f"youtube2__{cid}", + "pm2": pm2, "script": "youtube2.py", "label": f"YouTube {ch.get('name', cid)}", } diff --git a/web2_client_overview.py b/web2_client_overview.py index ce1d872..fa3b3a1 100644 --- a/web2_client_overview.py +++ b/web2_client_overview.py @@ -12,6 +12,8 @@ import re from pathlib import Path from typing import Any +from web2_relay_pro import extract_douyin_url_from_config_line + def _mask_key(key: str) -> str: k = (key or "").strip() @@ -78,11 +80,9 @@ def _first_douyin_line_from_url_config(config_dir: Path) -> str: line = raw.strip() if not line or line.startswith("#"): continue - if "douyin" not in line.lower(): - continue - if "," in line: - line = line.split(",")[-1].strip() - return line.strip() + url = extract_douyin_url_from_config_line(line) + if url: + return url return "" diff --git a/web2_config_governance.py b/web2_config_governance.py index f820ca6..38767cb 100644 --- a/web2_config_governance.py +++ b/web2_config_governance.py @@ -115,3 +115,43 @@ def write_config_text(config_dir: Path, rel_name: str, content: str, *, reason: target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") return target + + +def _normalize_url_line_for_dedupe(line: str) -> str: + from web2_relay_pro import extract_douyin_url_from_config_line + + url = extract_douyin_url_from_config_line(line) + if url: + return url.rstrip("/").lower() + t = (line or "").strip() + if not t or t.startswith("#"): + return "" + return t.rstrip("/").lower() + + +def dedupe_url_config_content(content: str) -> tuple[str, int]: + """去除 URL_config 中重复的抖音直播间行(参照 d2ypp2 config_governance dedupe_urls)。""" + seen: set[str] = set() + removed = 0 + out: list[str] = [] + for raw in content.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + out.append(raw.rstrip()) + continue + key = _normalize_url_line_for_dedupe(line) + if not key: + out.append(raw.rstrip()) + continue + if "douyin" not in key: + out.append(raw.rstrip()) + continue + if key in seen: + removed += 1 + continue + seen.add(key) + out.append(raw.rstrip()) + text = "\n".join(out).rstrip() + if text: + text += "\n" + return text, removed diff --git a/web2_config_insights.py b/web2_config_insights.py new file mode 100644 index 0000000..0b5b5e5 --- /dev/null +++ b/web2_config_insights.py @@ -0,0 +1,313 @@ +"""Config insights and optimize presets (from d2ypp2 config_governance, Windows/Electron adapted).""" + +from __future__ import annotations + +import configparser +import json +import re +from collections import Counter +from pathlib import Path +from typing import Any + +from web2_config_governance import dedupe_url_config_content + +_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE) +_RECORDER_SECTION = "录制设置" + + +def _issue(level: str, code: str, text: str, **extra: Any) -> dict[str, Any]: + row = {"level": level, "code": code, "text": text} + row.update(extra) + return row + + +def _action(action: str, label: str, description: str) -> dict[str, str]: + return {"action": action, "label": label, "description": description} + + +def _stat(key: str, label: str, value: Any) -> dict[str, Any]: + return {"key": key, "label": label, "value": value} + + +def _file_kind(rel_path: str) -> str: + name = rel_path.replace("\\", "/").lower() + if "url_config" in name or name.endswith("url_config.ini"): + return "url_config" + if name.endswith("youtube.ini") or "/youtube." in name: + return "youtube_ini" + if name.endswith("config.ini"): + return "recorder_ini" + if name.endswith(".json"): + return "json" + return "generic" + + +def _extract_url_from_line(line: str) -> str | None: + match = _URL_RE.search(line) + return match.group(0).rstrip(")]") if match else None + + +def _normalize_url(url: str) -> str: + return url.strip().rstrip("/").lower() + + +def analyze_url_config(content: str) -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + stats: list[dict[str, Any]] = [] + actions: list[dict[str, str]] = [] + urls: list[tuple[int, str]] = [] + invalid_lines: list[int] = [] + disabled = 0 + provider_counter: Counter[str] = Counter() + + for index, raw in enumerate(content.splitlines(), start=1): + line = raw.strip() + if not line: + continue + if line.startswith("#"): + disabled += 1 + continue + url = _extract_url_from_line(line) + if not url: + invalid_lines.append(index) + continue + urls.append((index, url)) + host = url.lower() + if "douyin.com" in host: + provider_counter["Douyin"] += 1 + elif "tiktok.com" in host: + provider_counter["TikTok"] += 1 + elif "youtube.com" in host or "youtu.be" in host: + provider_counter["YouTube"] += 1 + else: + provider_counter["Other"] += 1 + + normalized = [_normalize_url(url) for _, url in urls] + counts = Counter(normalized) + duplicates = [url for url, count in counts.items() if count > 1] + + if not urls: + issues.append(_issue("error", "empty_active_urls", "没有有效的直播源 URL")) + if duplicates: + issues.append(_issue("warning", "duplicate_urls", f"检测到 {len(duplicates)} 个重复 URL")) + actions.append(_action("dedupe_urls", "智能去重", "去除重复源站地址,保留注释与顺序")) + if invalid_lines: + issues.append( + _issue("warning", "invalid_lines", f"{len(invalid_lines)} 行非空内容不含有效 http(s) URL", line=invalid_lines[0]) + ) + + stats.extend( + [ + _stat("active_urls", "有效 URL", len(urls)), + _stat("disabled_urls", "注释行", disabled), + _stat("duplicate_urls", "重复 URL", len(duplicates)), + ] + ) + for provider, count in provider_counter.most_common(): + stats.append(_stat(f"provider_{provider.lower()}", provider, count)) + + return { + "summary": f"{len(urls)} 条有效 URL,{len(duplicates)} 个重复,{disabled} 行注释", + "issues": issues, + "stats": stats, + "actions": actions, + } + + +def _parse_ini(content: str) -> tuple[configparser.ConfigParser | None, str | None]: + parser = configparser.ConfigParser() + parser.optionxform = str + try: + parser.read_string(content) + except (configparser.Error, UnicodeDecodeError) as exc: + return None, str(exc) + return parser, None + + +def analyze_youtube_ini(content: str) -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + stats: list[dict[str, Any]] = [] + actions = [ + _action("youtube_balanced_preset", "平衡推流预设", "启用 RTMPS、4500k 码率、fast_audio,保留当前 stream key") + ] + parser, parse_error = _parse_ini(content or "[youtube]\n") + if parse_error or parser is None: + issues.append(_issue("error", "parse_error", f"无法解析 youtube.ini: {parse_error}")) + return {"summary": "youtube.ini 解析失败", "issues": issues, "stats": stats, "actions": actions} + + key = parser.get("youtube", "key", fallback="").strip() if parser.has_section("youtube") else "" + rtmps = parser.get("youtube", "rtmps", fallback="是").strip() + bitrate = parser.get("youtube", "bitrate", fallback="").strip() + fast_audio = parser.get("youtube", "fast_audio", fallback="1").strip() + + if not key: + issues.append(_issue("error", "missing_stream_key", "YouTube stream key 为空")) + if rtmps and rtmps.lower() in {"否", "0", "false", "no"}: + issues.append(_issue("warning", "rtmps_disabled", "未启用 RTMPS,建议加密推流")) + bitrate_num = int(bitrate) if bitrate.isdigit() else None + if bitrate and bitrate_num is None: + issues.append(_issue("warning", "bitrate_invalid", "bitrate 不是有效整数")) + elif bitrate_num is not None and not (1500 <= bitrate_num <= 9000): + issues.append(_issue("warning", "bitrate_out_of_range", "bitrate 建议在 1500–9000 kbps")) + + stats.extend( + [ + _stat("stream_key", "Stream key", "已配置" if key else "缺失"), + _stat("rtmps", "RTMPS", rtmps or "是"), + _stat("bitrate", "Bitrate", bitrate or "auto"), + _stat("fast_audio", "fast_audio", fast_audio or "1"), + ] + ) + return {"summary": "YouTube 推流配置已分析", "issues": issues, "stats": stats, "actions": actions} + + +def _find_kv(content: str, section: str, key: str) -> tuple[str | None, int | None]: + current_section = "" + for index, raw in enumerate(content.splitlines(), start=1): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + current_section = line.strip("[]").strip() + continue + if current_section != section or "=" not in raw: + continue + left, _, right = raw.partition("=") + if left.strip() == key: + return right.strip(), index + return None, None + + +def analyze_recorder_ini(content: str) -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + stats: list[dict[str, Any]] = [] + actions = [_action("recording_stability_preset", "稳定录制预设", "TS 分段录制、30 分钟切片、磁盘阈值 2GB")] + if f"[{_RECORDER_SECTION}]" not in content: + issues.append(_issue("error", "missing_record_section", f"缺少 [{_RECORDER_SECTION}] 段")) + return {"summary": "录制配置解析失败", "issues": issues, "stats": stats, "actions": actions} + + save_format, save_format_line = _find_kv(content, _RECORDER_SECTION, "视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频") + threads, threads_line = _find_kv(content, _RECORDER_SECTION, "同一时间访问网络的线程数") + split_enabled, split_enabled_line = _find_kv(content, _RECORDER_SECTION, "分段录制是否开启") + + threads_num = int(threads) if threads and threads.isdigit() else None + if save_format and save_format.lower() == "mp4": + issues.append(_issue("warning", "unsafe_container", "直接录 MP4 中断恢复较差,建议 ts/mkv", line=save_format_line)) + if threads_num is not None and threads_num > 8: + issues.append(_issue("warning", "threads_high", "并发线程数偏高", line=threads_line)) + if split_enabled and split_enabled.strip() in {"否", "0", "false"}: + issues.append(_issue("warning", "split_disabled", "未开启分段录制", line=split_enabled_line)) + + stats.extend( + [ + _stat("format", "保存格式", save_format or "unknown"), + _stat("threads", "线程数", threads or "unknown"), + _stat("split_recording", "分段录制", split_enabled or "unknown"), + ] + ) + return {"summary": "录制轮询与容器安全性已分析", "issues": issues, "stats": stats, "actions": actions} + + +def analyze_config_content(rel_path: str, content: str) -> dict[str, Any]: + kind = _file_kind(rel_path) + if kind == "url_config": + report = analyze_url_config(content) + elif kind == "youtube_ini": + report = analyze_youtube_ini(content) + elif kind == "recorder_ini": + report = analyze_recorder_ini(content) + elif kind == "json": + try: + json.loads(content or "{}") + report = {"summary": "JSON 语法有效", "issues": [], "stats": [], "actions": []} + except json.JSONDecodeError as exc: + report = { + "summary": "JSON 解析失败", + "issues": [_issue("error", "json_parse_error", str(exc.msg), line=exc.lineno)], + "stats": [], + "actions": [], + } + else: + report = { + "summary": "文本文件", + "issues": [], + "stats": [_stat("lines", "行数", len(content.splitlines()))], + "actions": [], + } + report["file"] = rel_path.replace("\\", "/") + report["kind"] = kind + return report + + +def _set_value_in_section(content: str, section: str, key: str, value: str) -> str: + header = f"[{section}]" + lines = (content or "").splitlines() + out: list[str] = [] + in_section = False + inserted = False + section_found = False + pattern = re.compile(rf"^\s*{re.escape(key)}\s*=", re.IGNORECASE) + for raw in lines: + stripped = raw.strip() + if stripped == header: + section_found = True + in_section = True + out.append(raw) + continue + if in_section and stripped.startswith("[") and stripped.endswith("]"): + if not inserted: + out.append(f"{key} = {value}") + inserted = True + in_section = False + if in_section and pattern.match(raw): + out.append(f"{key} = {value}") + inserted = True + continue + out.append(raw) + if section_found and in_section and not inserted: + out.append(f"{key} = {value}") + elif not section_found: + if out and out[-1].strip(): + out.append("") + out.extend([header, f"{key} = {value}"]) + return "\n".join(out).rstrip() + "\n" + + +def optimize_config_content(action: str, rel_path: str, content: str) -> dict[str, Any]: + kind = _file_kind(rel_path) + if action == "dedupe_urls" and kind == "url_config": + next_content, removed = dedupe_url_config_content(content) + message = f"已去除 {removed} 条重复 URL" if removed else "未发现重复 URL" + elif action == "youtube_balanced_preset" and kind == "youtube_ini": + parser, _ = _parse_ini(content or "[youtube]\n") + key = "" + if parser and parser.has_section("youtube"): + key = parser.get("youtube", "key", fallback="").strip() + next_content = content or "[youtube]\n" + next_content = _set_value_in_section(next_content, "youtube", "key", key) + next_content = _set_value_in_section(next_content, "youtube", "rtmps", "是") + next_content = _set_value_in_section(next_content, "youtube", "bitrate", "4500") + next_content = _set_value_in_section(next_content, "youtube", "fast_audio", "1") + message = "已应用 YouTube 平衡推流预设" + elif action == "recording_stability_preset" and kind == "recorder_ini": + next_content = content + next_content = _set_value_in_section( + next_content, _RECORDER_SECTION, "视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频", "ts" + ) + next_content = _set_value_in_section(next_content, _RECORDER_SECTION, "分段录制是否开启", "是") + next_content = _set_value_in_section(next_content, _RECORDER_SECTION, "视频分段时间(秒)", "1800") + next_content = _set_value_in_section(next_content, _RECORDER_SECTION, "录制空间剩余阈值(gb)", "2.0") + message = "已应用稳定录制预设" + else: + raise ValueError(f"Unsupported optimization action: {action}") + + return { + "message": message, + "content": next_content, + "insights": analyze_config_content(rel_path, next_content), + } + + +def read_config_file(config_dir: Path, rel_path: str) -> str: + path = config_dir / rel_path.replace("\\", "/").lstrip("/") + if not path.is_file(): + return "" + return path.read_text(encoding="utf-8", errors="replace") diff --git a/web2_network.py b/web2_network.py index e57ce6b..7d8fb96 100644 --- a/web2_network.py +++ b/web2_network.py @@ -6,11 +6,20 @@ from __future__ import annotations import asyncio +import json import socket +import platform +import subprocess import time +import shutil +from pathlib import Path from typing import Any, Optional import httpx +import psutil + +from web2_host_caps import machine_summary_payload +from web2_ip_quality import run_ip_quality # ---------- 目标 ---------- GOOGLE_HOST = "www.google.com" @@ -361,3 +370,304 @@ async def run_network_dashboard() -> dict[str, Any]: "logic_version": NETWORK_LOGIC_VERSION, }, } + + +def _cli_available(command: str) -> bool: + return shutil.which(command) is not None + + +def _run_cli(command: list[str], timeout: float = 10.0) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "capture_output": True, + "text": True, + "timeout": timeout, + } + if hasattr(subprocess, "CREATE_NO_WINDOW"): + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + try: + r = subprocess.run(command, **kwargs) + return { + "ok": r.returncode == 0, + "code": r.returncode, + "stdout": (r.stdout or "").strip(), + "stderr": (r.stderr or "").strip(), + } + except Exception as e: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(e)} + + +def _safe_json_parse(text: str) -> Optional[dict[str, Any]]: + if not text: + return None + try: + data = json.loads(text) + if isinstance(data, dict): + return data + except Exception: + return None + return None + + +def _bytes_per_sec_to_mbps(value: Any) -> Optional[float]: + try: + n = float(value) + except (TypeError, ValueError): + return None + if n <= 0: + return None + # Ookla speedtest: bandwidth 为 bytes/s;speedtest-cli: download 为 bits/s + if n > 50_000_000: + return round(n / 1_000_000, 2) + return round(n * 8 / 1_000_000, 2) + + +def _summarize_speedtest(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + return {} + download = raw.get("download") + upload = raw.get("upload") + ping = raw.get("ping") + down_mbps = None + up_mbps = None + ping_ms = None + if isinstance(download, dict): + down_mbps = _bytes_per_sec_to_mbps(download.get("bandwidth")) + elif download is not None: + down_mbps = _bytes_per_sec_to_mbps(download) + if isinstance(upload, dict): + up_mbps = _bytes_per_sec_to_mbps(upload.get("bandwidth")) + elif upload is not None: + up_mbps = _bytes_per_sec_to_mbps(upload) + if isinstance(ping, dict): + try: + ping_ms = round(float(ping.get("latency")), 1) + except (TypeError, ValueError): + ping_ms = None + elif ping is not None: + try: + ping_ms = round(float(ping), 1) + except (TypeError, ValueError): + ping_ms = None + return { + "download_mbps": down_mbps, + "upload_mbps": up_mbps, + "ping_ms": ping_ms, + } + + +def _summarize_mtr(raw: Any) -> dict[str, Any]: + if isinstance(raw, dict): + hubs = raw.get("report", {}).get("hubs") if isinstance(raw.get("report"), dict) else raw.get("hubs") + if not isinstance(hubs, list): + hubs = [] + last_avg = None + if hubs: + try: + last_avg = float(hubs[-1].get("Avg", hubs[-1].get("avg", 0)) or 0) + except (TypeError, ValueError): + last_avg = None + return {"hops": len(hubs), "last_hop_avg_ms": last_avg} + if isinstance(raw, str) and raw.strip(): + lines = [ln for ln in raw.splitlines() if ln.strip() and not ln.startswith("Start:")] + return {"hops": max(0, len(lines) - 1), "last_hop_avg_ms": None} + return {} + + +def _summarize_ipinfo(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + return {} + return { + "ip": raw.get("ip"), + "city": raw.get("city"), + "region": raw.get("region"), + "country": raw.get("country"), + "org": raw.get("org") or raw.get("asn"), + } + + +def _systeminformation_payload() -> dict[str, Any]: + vm = psutil.virtual_memory() + cpu = psutil.cpu_percent(interval=0.2) + machine = machine_summary_payload() + disks: list[dict[str, Any]] = [] + try: + for part in psutil.disk_partitions(all=False)[:4]: + try: + usage = psutil.disk_usage(part.mountpoint) + disks.append( + { + "mount": part.mountpoint, + "percent": usage.percent, + "free_gb": round(usage.free / (1024**3), 2), + } + ) + except (PermissionError, OSError): + continue + except Exception: + pass + net_io: dict[str, Any] = {} + try: + io = psutil.net_io_counters() + net_io = { + "bytes_sent_mb": round(io.bytes_sent / (1024**2), 1), + "bytes_recv_mb": round(io.bytes_recv / (1024**2), 1), + } + except Exception: + pass + return { + "provider": "sebhildebrandt/systeminformation", + "ok": True, + "host": platform.node(), + "platform": platform.platform(), + "machine": machine, + "cpu_logical": psutil.cpu_count(logical=True), + "cpu_percent": round(float(cpu), 1), + "memory_total_gb": round(vm.total / (1024**3), 2), + "memory_used_gb": round(vm.used / (1024**3), 2), + "memory_percent": round(float(vm.percent), 1), + "disks": disks, + "net_io": net_io, + } + + +def _speedtest_payload() -> dict[str, Any]: + if _cli_available("speedtest"): + result = _run_cli( + ["speedtest", "--accept-license", "--accept-gdpr", "-f", "json"], + timeout=18.0, + ) + parsed = _safe_json_parse(result.get("stdout", "")) + summary = _summarize_speedtest(parsed) + return { + "provider": "librespeed/speedtest", + "command": "speedtest", + "ok": result.get("ok", False), + "raw": parsed or result.get("stdout", ""), + "summary": summary, + "error": None if result.get("ok", False) else result.get("stderr", "") or "speedtest failed", + } + if _cli_available("speedtest-cli"): + result = _run_cli(["speedtest-cli", "--json"], timeout=18.0) + parsed = _safe_json_parse(result.get("stdout", "")) + summary = _summarize_speedtest(parsed) + return { + "provider": "librespeed/speedtest", + "command": "speedtest-cli", + "ok": result.get("ok", False), + "raw": parsed or result.get("stdout", ""), + "summary": summary, + "error": None if result.get("ok", False) else result.get("stderr", "") or "speedtest-cli failed", + } + return { + "provider": "librespeed/speedtest", + "command": None, + "ok": False, + "raw": None, + "error": "未检测到 speedtest 或 speedtest-cli", + } + + +def _mtr_payload() -> dict[str, Any]: + if not _cli_available("mtr"): + return { + "provider": "traviscross/mtr", + "ok": False, + "raw": None, + "error": "未检测到 mtr 命令", + } + result = _run_cli(["mtr", "--report", "--json", "-c", "5", "1.1.1.1"], timeout=18.0) + parsed = _safe_json_parse(result.get("stdout", "")) + summary = _summarize_mtr(parsed or result.get("stdout", "")) + return { + "provider": "traviscross/mtr", + "ok": result.get("ok", False), + "raw": parsed or result.get("stdout", ""), + "summary": summary, + "error": None if result.get("ok", False) else result.get("stderr", "") or "mtr failed", + } + + +def _ipinfo_payload() -> dict[str, Any]: + if not _cli_available("ipinfo"): + return { + "provider": "ipinfo/cli", + "ok": False, + "raw": None, + "error": "未检测到 ipinfo 命令", + } + result = _run_cli(["ipinfo", "--json"], timeout=10.0) + parsed = _safe_json_parse(result.get("stdout", "")) + summary = _summarize_ipinfo(parsed) + return { + "provider": "ipinfo/cli", + "ok": result.get("ok", False), + "raw": parsed or result.get("stdout", ""), + "summary": summary, + "error": None if result.get("ok", False) else result.get("stderr", "") or "ipinfo failed", + } + + +def _ipquality_toolkit_payload(quality: dict[str, Any]) -> dict[str, Any]: + script = Path(__file__).resolve().parent / "third_party" / "IPQuality" / "ip.sh" + err = quality.get("error") + info = quality.get("info") if isinstance(quality.get("info"), dict) else {} + summary = quality.get("summary") if isinstance(quality.get("summary"), dict) else {} + media = quality.get("media") if isinstance(quality.get("media"), dict) else {} + head = quality.get("head") if isinstance(quality.get("head"), dict) else {} + return { + "provider": "xykt/IPQuality", + "ok": not err, + "script": str(script), + "script_exists": script.is_file(), + "endpoint": "/ip_quality", + "summary": { + "ip": info.get("ip") or head.get("ip"), + "headline": summary.get("headline"), + "proxy_like": summary.get("proxy_like"), + "country": info.get("country"), + "organization": info.get("organization"), + "youtube": (media.get("Youtube") or {}).get("status_label"), + "tiktok": (media.get("TikTok") or {}).get("status_label"), + }, + "error": err, + } + + +async def run_network_toolkit() -> dict[str, Any]: + timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) + async with httpx.AsyncClient(timeout=timeout, verify=True) as client: + egress, quality = await asyncio.gather(_probe_default_egress(client), run_ip_quality()) + country_code = str(egress.get("country_code") or "").upper() + speed = _speedtest_payload() + mtr = _mtr_payload() + ipinfo = _ipinfo_payload() + return { + "providers": { + "systeminformation": _systeminformation_payload(), + "librespeed_speedtest": speed, + "mtr": mtr, + "ipinfo_cli": ipinfo, + "ipquality": _ipquality_toolkit_payload(quality), + "is_china_user": { + "provider": "yArna/isChinaUser", + "scope": "server_egress", + "ok": bool(country_code == "CN"), + "country_code": country_code or None, + "reason": "服务端出口 IP 国家码(前端 is-china-user 为浏览器侧信号)", + }, + "webrtc": { + "provider": "pion/webrtc", + "ok": True, + "note": "前端执行 WebRTC candidate 泄漏检测", + }, + "fingerprintjs": { + "provider": "fingerprintjs/fingerprintjs", + "ok": True, + "note": "前端 @fingerprintjs/fingerprintjs 生成 visitorId", + }, + }, + "meta": { + "at": time.time(), + "logic_version": 2, + }, + } diff --git a/web2_network_routes.py b/web2_network_routes.py index 65ad2f3..44c1699 100644 --- a/web2_network_routes.py +++ b/web2_network_routes.py @@ -9,18 +9,20 @@ from fastapi.responses import JSONResponse from web2_ip_profile import run_ip_profile from web2_ip_quality import run_ip_quality -from web2_network import run_network_dashboard +from web2_network import run_network_dashboard, run_network_toolkit router = APIRouter() _PROFILE_TTL_SECONDS = 300.0 _QUALITY_TTL_SECONDS = 600.0 _NETWORK_TTL_SECONDS = 300.0 +_NETWORK_TOOLKIT_TTL_SECONDS = 300.0 _cache_lock = asyncio.Lock() _profile_cache: dict[str, tuple[float, dict[str, Any]]] = {} _quality_cache: tuple[float, dict[str, Any]] | None = None _network_cache: tuple[float, dict[str, Any]] | None = None +_network_toolkit_cache: tuple[float, dict[str, Any]] | None = None _inflight: dict[str, asyncio.Task[dict[str, Any]]] = {} @@ -93,6 +95,15 @@ def _load_network(_key: str) -> Optional[tuple[float, dict[str, Any]]]: return _network_cache +def _store_network_toolkit(_key: str, at: float, payload: dict[str, Any]) -> None: + global _network_toolkit_cache + _network_toolkit_cache = (at, payload) + + +def _load_network_toolkit(_key: str) -> Optional[tuple[float, dict[str, Any]]]: + return _network_toolkit_cache + + async def _get_ip_profile_cached( *, tz: str, @@ -133,6 +144,17 @@ async def _get_network_status_cached(*, force_refresh: bool) -> dict[str, Any]: ) +async def _get_network_toolkit_cached(*, force_refresh: bool) -> dict[str, Any]: + return await _cached_fetch( + "network_toolkit_v2", + ttl=_NETWORK_TOOLKIT_TTL_SECONDS, + force_refresh=force_refresh, + fetcher=run_network_toolkit, + store=_store_network_toolkit, + load=_load_network_toolkit, + ) + + @router.get("/network_status") async def network_status( force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"), @@ -144,6 +166,17 @@ async def network_status( return JSONResponse({"error": str(e)}, status_code=500) +@router.get("/network_toolkit") +async def network_toolkit( + force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"), +): + """网络增强工具聚合:speedtest、mtr、ipinfo、system 信息等。""" + try: + return await _get_network_toolkit_cached(force_refresh=force_refresh) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.get("/ip_profile") async def ip_profile( tz: str = Query("", description="客户端 IANA 时区,如 Asia/Shanghai"), diff --git a/web2_push_proxy.py b/web2_push_proxy.py new file mode 100644 index 0000000..83069e4 --- /dev/null +++ b/web2_push_proxy.py @@ -0,0 +1,208 @@ +"""YouTube push proxy resolution (from d2ypp2 douyin_youtube_ffplay, simplified).""" + +from __future__ import annotations + +import os +import re +import shutil +import socket +from pathlib import Path +from urllib.parse import urlparse + +_PROXY_ENV_KEYS = ( + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "all_proxy", +) + + +def _is_disabled_value(value: str) -> bool: + return (value or "").strip().lower() in {"0", "false", "no", "off", "none", "direct"} + + +def _is_local_tcp_open(host: str, port: int, timeout: float = 0.35) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def normalize_push_proxy_mode(configured: str | None = None) -> str: + value = configured or os.environ.get("YOUTUBE_PUSH_PROXY_MODE") or os.environ.get("LIVE_YOUTUBE_PUSH_PROXY_MODE") + if not value: + value = "auto" + low = (value or "").strip().lower() + if low in {"0", "false", "no", "off", "none", "direct"}: + return "off" + if low in {"env", "environment", "http_proxy", "https_proxy"}: + return "env" + if low in {"proxychains", "proxy", "chain", "proxychains4"}: + return "proxychains" + return "auto" + + +def resolve_youtube_push_proxy(configured: str | None = None) -> str: + for value in ( + configured, + os.environ.get("YOUTUBE_PUSH_PROXY"), + os.environ.get("LIVE_YOUTUBE_PUSH_PROXY"), + ): + value = (value or "").strip() + if _is_disabled_value(value): + return "" + if value: + return value + + auto = (os.environ.get("YOUTUBE_PUSH_PROXY_AUTO") or "1").strip().lower() + port_raw = os.environ.get("YOUTUBE_PUSH_PROXY_PORT") or os.environ.get("LIVE_YOUTUBE_PUSH_PROXY_PORT") or "7890" + try: + proxy_port = int(str(port_raw).strip()) + except (TypeError, ValueError): + proxy_port = 7890 + if auto not in {"0", "false", "no", "off"} and _is_local_tcp_open("127.0.0.1", proxy_port): + return f"socks5://127.0.0.1:{proxy_port}" + + for key in ("https_proxy", "http_proxy", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "all_proxy"): + value = (os.environ.get(key) or "").strip() + if _is_disabled_value(value): + return "" + if value: + return value + return "" + + +def redact_proxy_url(value: str) -> str: + return re.sub(r"(://[^:/@\s]+:)[^@/\s]+(@)", r"\1\2", value or "") + + +def should_use_proxychains(push_proxy: str, proxy_mode: str = "auto") -> bool: + if not push_proxy: + return False + mode = normalize_push_proxy_mode(proxy_mode) + if mode in {"off", "env"}: + return False + if mode == "proxychains": + return bool(shutil.which("proxychains4")) + scheme = (urlparse(push_proxy if "://" in push_proxy else f"socks5://{push_proxy}").scheme or "socks5").lower() + if scheme.startswith("socks"): + return bool(shutil.which("proxychains4")) + return False + + +def proxychains_proxy_line(push_proxy: str) -> str | None: + value = (push_proxy or "").strip() + if not value: + return None + parsed = urlparse(value if "://" in value else f"socks5://{value}") + host = parsed.hostname + port = parsed.port + if not host or not port: + return None + scheme = (parsed.scheme or "socks5").lower() + if scheme in {"socks", "socks5", "socks5h"}: + proxy_type = "socks5" + elif scheme == "socks4": + proxy_type = "socks4" + elif scheme in {"http", "https"}: + proxy_type = "http" + else: + return None + parts = [proxy_type, host, str(port)] + if parsed.username: + parts.append(parsed.username) + if parsed.password: + parts.append(parsed.password) + return " ".join(parts) + + +def proxychains_config_for_push(push_proxy: str, *, base_dir: str | Path | None = None) -> str | None: + explicit = ( + os.environ.get("YOUTUBE_PROXYCHAINS_CONF") + or os.environ.get("LIVE_YOUTUBE_PROXYCHAINS_CONF") + or "" + ).strip() + if explicit and os.path.isfile(explicit): + return explicit + + proxy_line = proxychains_proxy_line(push_proxy) + root = Path(base_dir or os.path.dirname(os.path.abspath(__file__))) + if proxy_line: + runtime_dir = root / ".process_runner" + try: + runtime_dir.mkdir(parents=True, exist_ok=True) + conf = runtime_dir / "proxychains-youtube.conf" + conf.write_text( + "strict_chain\n" + "proxy_dns\n" + "tcp_read_time_out 30000\n" + "tcp_connect_time_out 8000\n\n" + "[ProxyList]\n" + f"{proxy_line}\n", + encoding="utf-8", + ) + try: + os.chmod(conf, 0o600) + except OSError: + pass + return str(conf) + except OSError: + pass + + bundled = root / "config" / "proxychains-youtube.conf" + if bundled.is_file(): + return str(bundled) + return None + + +def ffmpeg_env_with_push_proxy( + push_proxy: str, + proxy_mode: str = "auto", + *, + use_proxychains: bool = False, + base_env: dict[str, str] | None = None, +) -> dict[str, str]: + env = dict(base_env or os.environ) + for key in _PROXY_ENV_KEYS: + env.pop(key, None) + push_proxy = (push_proxy or "").strip() + if not push_proxy: + return env + mode = normalize_push_proxy_mode(proxy_mode) + if mode == "off" or use_proxychains: + return env + for key in _PROXY_ENV_KEYS: + env[key] = push_proxy + return env + + +def proxychains_command(ffmpeg_command: list[str], push_proxy: str, proxy_mode: str = "auto", *, base_dir: str | Path | None = None) -> list[str] | None: + if not should_use_proxychains(push_proxy, proxy_mode): + return None + conf = proxychains_config_for_push(push_proxy, base_dir=base_dir) + if not conf: + return None + return ["proxychains4", "-q", "-f", conf, *ffmpeg_command] + + +def apply_push_proxy_to_ffmpeg_cmd( + ffmpeg_command: list[str], + push_proxy: str, + proxy_mode: str = "auto", + *, + base_dir: str | Path | None = None, +) -> tuple[list[str], bool]: + """Return (command, used_proxychains). Falls back to -http_proxy when proxychains unavailable.""" + wrapped = proxychains_command(ffmpeg_command, push_proxy, proxy_mode, base_dir=base_dir) + if wrapped: + return wrapped, True + mode = normalize_push_proxy_mode(proxy_mode) + if push_proxy and mode != "off": + cmd = list(ffmpeg_command) + cmd.insert(1, "-http_proxy") + cmd.insert(2, push_proxy) + return cmd, False + return ffmpeg_command, False diff --git a/web2_quality_guard.py b/web2_quality_guard.py index 65b833f..85c5e60 100644 --- a/web2_quality_guard.py +++ b/web2_quality_guard.py @@ -6,6 +6,8 @@ import re import time from typing import Any +from web2_douyin_source_display import extract_douyin_source_meta + _FFMPEG_KV_RE = re.compile( r"(?Pframe|fps|bitrate|speed|drop|dup)\s*=\s*(?P[^\s]+)", re.IGNORECASE ) @@ -31,16 +33,50 @@ def _as_int(value: Any) -> int | None: return None if parsed is None else max(0, int(parsed)) +_IDLE_MARKERS = ( + "等待直播", + "waiting", + "获取失败", + "douyin web api failed", + "无录制任务", + "网址内容获取失败", + "没有正在监测的直播", +) + + +def _line_implies_relay_idle(line: str) -> bool: + lower = line.lower() + if "frame=" in lower and "speed=" in lower: + return False + return any(marker in lower for marker in _IDLE_MARKERS) or "error" in lower or "failed" in lower + + +def _latest_ffmpeg_stats_line(text: str, *, lookback: int = 12) -> str: + lines = [line.strip() for line in str(text or "").splitlines() if line.strip()] + if not lines: + return "" + tail = lines[-lookback:] + stats_idx = -1 + stats_line = "" + for idx in range(len(tail) - 1, -1, -1): + line = tail[idx] + lower = line.lower() + if "frame=" in lower and "speed=" in lower: + stats_idx = idx + stats_line = line + break + if stats_idx < 0: + return "" + if any(_line_implies_relay_idle(line) for line in tail[stats_idx + 1 :]): + return "" + return stats_line + + def parse_ffmpeg_metrics(*texts: str) -> dict[str, Any]: raw = "\n".join(str(item or "") for item in texts) tail = raw[-16000:] metrics: dict[str, Any] = {} - stats_line = "" - for line in reversed([ln.strip() for ln in tail.splitlines() if ln.strip()]): - lower = line.lower() - if "frame=" in lower and "speed=" in lower: - stats_line = line - break + stats_line = _latest_ffmpeg_stats_line(tail) if stats_line: for match in _FFMPEG_KV_RE.finditer(stats_line): key = match.group("key").lower() @@ -153,8 +189,10 @@ def _lane_score(row: dict[str, Any], metrics: dict[str, Any], cfg: dict[str, str def build_youtube_quality_guard_report( rows: list[dict[str, Any]], *, + live_summary: dict[str, Any] | None = None, config_by_process: dict[str, dict[str, str]] | None = None, ) -> dict[str, Any]: + live_summary = live_summary or {} config_by_process = config_by_process or {} lanes: list[dict[str, Any]] = [] recommendations: list[dict[str, str]] = [] @@ -168,28 +206,51 @@ def build_youtube_quality_guard_report( else {} ) cfg = config_by_process.get(process) or {} - hint = _source_hint(cfg.get("url_lines", ""), str(row.get("recent_log") or "")) + source_meta = extract_douyin_source_meta(cfg.get("url_lines", ""), str(row.get("recent_log") or "")) + source_title = str(source_meta.get("source_title") or row.get("source_title") or "").strip() + source_room_id = str(source_meta.get("source_room_id") or row.get("source_room_id") or "").strip() + source_url = str(source_meta.get("source_url") or row.get("source_url") or "").strip() + source_display = str( + source_meta.get("source_display") + or row.get("source_display") + or source_title + or (f"抖音直播间 {source_room_id}" if source_room_id else "") + ).strip() score, issues = _lane_score(row, metrics, cfg) + rtmps = _ini_has_rtmps(cfg.get("youtube_ini_text", "")) lanes.append( { "process": process, "label": row.get("label") or process, - "source_url": hint["source_url"], - "source_room_id": hint["source_room_id"], + "source_title": source_title, + "source_room_id": source_room_id, + "source_url": source_url, + "source_display": source_display, "status": row.get("business_status") or row.get("process_status") or "", "is_pushing": pushing, "ffmpeg_active": bool(row.get("ffmpeg_active") or row.get("ffmpeg_pids")), "score": score, "metrics": metrics, + "rtmps": rtmps, + "has_stream_key": _ini_has_key(cfg.get("youtube_ini_text", "")), "issues": issues, } ) pushing_count = sum(1 for lane in lanes if lane["is_pushing"]) + active_events = int( + ((live_summary.get("stats") or {}).get("active_events") or len(live_summary.get("active") or [])) or 0 + ) + sample_count = int(((live_summary.get("stats") or {}).get("sample_count") or 0) or 0) + viewer_peak = int(((live_summary.get("stats") or {}).get("viewer_peak") or 0) or 0) score = int(sum(lane["score"] for lane in lanes) / len(lanes)) if lanes else 0 if pushing_count == 0: recommendations.append({"level": "critical", "text": "没有正在推流的线路"}) + if active_events == 0 and pushing_count > 0: + recommendations.append({"level": "warning", "text": "推流已存在但事件中心未形成活动事件"}) + if sample_count == 0 and pushing_count > 0: + recommendations.append({"level": "info", "text": "尚无 Studio/浏览器采样,可安装 ingest 扩展增强观测"}) elif all(lane["score"] >= 80 for lane in lanes if lane["is_pushing"]): recommendations.append({"level": "ok", "text": "推流质量良好,保持监控即可"}) if any(lane["status"] == "relay_slow" for lane in lanes): @@ -198,7 +259,13 @@ def build_youtube_quality_guard_report( return { "generated_at": time.time(), "score": max(0, min(100, score)), - "summary": {"lanes": len(lanes), "pushing_lanes": pushing_count}, + "summary": { + "lanes": len(lanes), + "pushing_lanes": pushing_count, + "active_events": active_events, + "sample_count": sample_count, + "viewer_peak": viewer_peak, + }, "lanes": lanes, "recommendations": recommendations[:8], } diff --git a/web2_relay_log.py b/web2_relay_log.py index 2f7b072..289a852 100644 --- a/web2_relay_log.py +++ b/web2_relay_log.py @@ -5,6 +5,8 @@ from __future__ import annotations import json import re import sqlite3 +import subprocess +import threading import time import uuid from datetime import datetime @@ -20,8 +22,216 @@ _PUSH_START_RE = re.compile(r"开始推流到 YouTube:\s*(.+?)\s*$") _INCOMING_URL_RE = re.compile(r"传入地址:\s*(https?://\S+)", re.IGNORECASE) _ROOM_RE = re.compile(r"live\.douyin\.com/([a-zA-Z0-9_-]+)", re.IGNORECASE) +from web2_youtube_live_store import ( + extract_first_url, + extract_source_room_id, + extract_youtube_key_suffix, +) + +_STREAM_TITLE_TS_RE = re.compile(r"_(?P\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})$") +_LOG_TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}):") +_FAIL_ADDR_RE = re.compile( + r"获取失败的地址是:\('原画',\s*'(https?://[^']+)',\s*'主播:\s*([^']+)'\)" +) +_ANCHOR_LABEL_RE = re.compile(r"主播[::]\s*([^\s'\"()]+)") +_JUNK_END_REASONS = frozenset({"not_found", "idle", "pm2_online", "stopped", "errored", "error"}) _ACTIVE_BY_PROCESS: dict[str, int] = {} +from web2_douyin_source_display import ( + _entry_for_title, + _url_entries_from_lines, + extract_douyin_source_meta, +) + + +def _normalize_process(process: str) -> str: + return (process or "").strip() or "youtube" + + +def _push_date_from_ts(ts: float) -> str: + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + + +def _parse_stream_title(stream_title: str) -> tuple[str, str]: + title = (stream_title or "").strip() + match = _STREAM_TITLE_TS_RE.search(title) + if not match: + return title, "" + anchor = title[: match.start()].rstrip("_") + stamp = match.group("ts") + push_date = stamp.split("_", 1)[0] if "_" in stamp else "" + return anchor, push_date + + +def _timestamp_from_stream_title(stream_title: str) -> float | None: + match = _STREAM_TITLE_TS_RE.search(stream_title or "") + if not match: + return None + try: + return datetime.strptime(match.group("ts"), "%Y-%m-%d_%H-%M-%S").timestamp() + except ValueError: + return None + + +def _parse_log_line_ts(line: str) -> float | None: + match = _LOG_TS_RE.match(line.strip()) + if not match: + return None + try: + return datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S").timestamp() + except ValueError: + return None + + +def _parse_local_wall_time(line: str, log_ts: float | None) -> float | None: + if log_ts is None: + return None + local_hms = "" + for part in reversed(line.split("|")): + token = part.strip() + if re.fullmatch(r"\d{2}:\d{2}:\d{2}", token): + local_hms = token + break + if not local_hms: + return log_ts + try: + base = datetime.fromtimestamp(log_ts) + hour, minute, second = (int(x) for x in local_hms.split(":")) + return base.replace(hour=hour, minute=minute, second=second, microsecond=0).timestamp() + except (ValueError, OSError): + return log_ts + + +def _clean_douyin_url(url: str) -> str: + return extract_first_url(url) or "" + + +def _build_anchor_url_index(url_lines: str) -> dict[str, tuple[str, str]]: + index: dict[str, tuple[str, str]] = {} + for entry in _url_entries_from_lines(url_lines): + title = (entry.get("source_title") or "").strip() + url = _clean_douyin_url(entry.get("source_url") or "") + room_id = entry.get("room_id") or "" + if title and url: + index[title] = (url, room_id) + return index + + +def _lookup_room_id(anchor_name: str, url_lines: str) -> tuple[str, str]: + anchor = (anchor_name or "").strip() + for line in (url_lines or "").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if anchor and anchor not in stripped: + continue + url = extract_first_url(stripped) + room_id = extract_source_room_id(stripped, url) + if url: + return url, room_id + meta = extract_douyin_source_meta(url_lines, anchor) + return str(meta.get("source_url") or ""), str(meta.get("source_room_id") or "") + + +def _resolve_anchor_url( + anchor: str, + url_index: dict[str, tuple[str, str]], + url_lines: str, + log_url_index: dict[str, tuple[str, str]] | None = None, +) -> tuple[str, str]: + name = (anchor or "").strip() + if not name: + return "", "" + if log_url_index and name in log_url_index: + return log_url_index[name] + entry = _entry_for_title(_url_entries_from_lines(url_lines), name) + if entry: + return _clean_douyin_url(entry["source_url"]), entry["room_id"] + if name in url_index: + url, room_id = url_index[name] + return _clean_douyin_url(url), room_id + meta = extract_douyin_source_meta(url_lines, name) + url = _clean_douyin_url(str(meta.get("source_url") or "")) + room_id = str(meta.get("source_room_id") or "") + if url: + return url, room_id + return _lookup_room_id(name, url_lines) + + +def _session_has_signal(row: sqlite3.Row | dict[str, Any]) -> bool: + data = dict(row) if not isinstance(row, dict) else row + anchor = str(data.get("anchor_name") or "").strip() + source_url = str(data.get("source_url") or "").strip() + stream_title = str(data.get("stream_title") or "").strip() + room_id = str(data.get("source_room_id") or "").strip() + return bool(anchor or source_url or stream_title or room_id) + + +def _is_junk_session_row(row: sqlite3.Row | dict[str, Any]) -> bool: + if _session_has_signal(row): + return False + data = dict(row) if not isinstance(row, dict) else row + end_reason = str(data.get("end_reason") or "").strip().lower() + if end_reason in _JUNK_END_REASONS: + return True + meta_raw = data.get("metadata_json") or data.get("metadata") or {} + if isinstance(meta_raw, str): + try: + meta = json.loads(meta_raw) if meta_raw else {} + except json.JSONDecodeError: + meta = {} + else: + meta = meta_raw if isinstance(meta_raw, dict) else {} + if str(meta.get("trigger") or "") == "pm2_online": + return True + return not end_reason and str(data.get("status") or "") == "active" + + +def purge_junk_relay_sessions(*, process: str = "", db_path: Path) -> int: + proc = _normalize_process(process) if process else "" + conn = _connect(db_path) + removed = 0 + try: + rows = conn.execute(f"SELECT * FROM {_TABLE}").fetchall() + for row in rows: + if proc and str(row["process"] or "") != proc: + continue + if _is_junk_session_row(row): + conn.execute(f"DELETE FROM {_TABLE} WHERE id = ?", (int(row["id"]),)) + removed += 1 + conn.commit() + finally: + conn.close() + return removed + + +def _close_session_row( + conn: sqlite3.Connection, + row_id: int, + *, + ended_at: float | None = None, + reason: str = "", + duration_seconds: int | None = None, +) -> None: + row = conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (row_id,)).fetchone() + if not row or row["ended_at"]: + return + end = float(ended_at or time.time()) + duration = duration_seconds + if duration is None: + duration = int(max(0, end - float(row["started_at"]))) + conn.execute( + f""" + UPDATE {_TABLE} + SET ended_at = ?, duration_seconds = ?, status = 'ended', end_reason = ?, updated_at = ? + WHERE id = ? + """, + (end, int(duration), (reason or "")[:500], end, row_id), + ) + process = str(row["process"] or "") + if _ACTIVE_BY_PROCESS.get(process) == row_id: + _ACTIVE_BY_PROCESS.pop(process, None) + def relay_db_path(base_dir: Path) -> Path: path = base_dir / "runtime" / "youtube_relay" / "sessions.sqlite" @@ -29,6 +239,12 @@ def relay_db_path(base_dir: Path) -> Path: return path +def _covers_dir(base_dir: Path) -> Path: + path = base_dir / "runtime" / "youtube_relay" / "covers" + path.mkdir(parents=True, exist_ok=True) + return path + + def _connect(db_path: Path) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path), check_same_thread=False) conn.row_factory = sqlite3.Row @@ -48,6 +264,7 @@ def _connect(db_path: Path) -> sqlite3.Connection: ended_at REAL, duration_seconds REAL NOT NULL DEFAULT 0, push_date TEXT NOT NULL DEFAULT '', + cover_path TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active', end_reason TEXT NOT NULL DEFAULT '', metadata_json TEXT NOT NULL DEFAULT '{{}}', @@ -59,6 +276,12 @@ def _connect(db_path: Path) -> sqlite3.Connection: conn.execute( f"CREATE INDEX IF NOT EXISTS idx_relay_sess_proc ON {_TABLE}(process, started_at DESC)" ) + try: + cols = {row[1] for row in conn.execute(f"PRAGMA table_info({_TABLE})").fetchall()} + if "cover_path" not in cols: + conn.execute(f"ALTER TABLE {_TABLE} ADD COLUMN cover_path TEXT NOT NULL DEFAULT ''") + except sqlite3.OperationalError: + pass conn.commit() return conn @@ -76,6 +299,9 @@ def _row_to_session(row: sqlite3.Row) -> dict[str, Any]: data["duration_seconds"] = int(float(data["duration_seconds"])) else: data["duration_seconds"] = int(max(0, time.time() - float(data.get("started_at") or time.time()))) + data["cover_url"] = ( + f"/youtube/relay/sessions/{data['id']}/cover" if data.get("cover_path") else "" + ) return data @@ -119,30 +345,58 @@ def start_relay_session( source_room_id: str = "", channel_name: str = "", stream_title: str = "", + youtube_key_suffix: str = "", metadata: dict[str, Any] | None = None, + capture_cover_url: str = "", db_path: Path, + base_dir: Path | None = None, ) -> dict[str, Any]: - proc = (process or "youtube").strip() + proc = _normalize_process(process) now = time.time() - session_uuid = uuid.uuid4().hex - push_date = datetime.now().strftime("%Y-%m-%d") + anchor = (anchor_name or "").strip() + if not source_url: + source_url = extract_first_url(stream_title) + if not source_room_id: + source_room_id = extract_source_room_id(source_url, anchor_name, stream_title) conn = _connect(db_path) try: - cur = conn.execute( + open_rows = conn.execute( + f"SELECT id, anchor_name FROM {_TABLE} WHERE process = ? AND ended_at IS NULL", + (proc,), + ).fetchall() + for row in open_rows: + if str(row["anchor_name"] or "").strip() == anchor and anchor: + session = _row_to_session( + conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (int(row["id"]),)).fetchone() + ) + conn.commit() + return session + _close_session_row(conn, int(row["id"]), ended_at=now, reason="source_switch") + session_uuid = uuid.uuid4().hex + push_date = _push_date_from_ts(now) + _, title_date = _parse_stream_title(stream_title) + if title_date: + push_date = title_date + title_started = _timestamp_from_stream_title(stream_title) + if title_started: + now = title_started + conn.execute( f""" INSERT INTO {_TABLE} ( session_uuid, process, channel_name, anchor_name, source_url, source_room_id, - stream_title, started_at, push_date, status, metadata_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) + stream_title, youtube_key_suffix, started_at, duration_seconds, push_date, + cover_path, status, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, '', 'active', ?, ?, ?) """, ( session_uuid, proc, - channel_name, - anchor_name, - source_url, - source_room_id or _room_from_text(source_url), - stream_title, + channel_name or "", + anchor, + source_url or "", + source_room_id or "", + stream_title or "", + youtube_key_suffix or "", now, push_date, json.dumps(metadata or {}, ensure_ascii=False), @@ -150,54 +404,148 @@ def start_relay_session( now, ), ) + session_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + _ACTIVE_BY_PROCESS[proc] = session_id conn.commit() - sid = int(cur.lastrowid) - _ACTIVE_BY_PROCESS[proc] = sid - row = conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (sid,)).fetchone() - return _row_to_session(row) if row else {} + session = _row_to_session( + conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (session_id,)).fetchone() + ) finally: conn.close() + cover_url = capture_cover_url or source_url + if cover_url and session.get("id") and base_dir: + threading.Thread( + target=_capture_cover_safe, + args=(int(session["id"]), cover_url, db_path, base_dir), + daemon=True, + ).start() + return session def end_relay_session( process: str, *, + anchor_name: str = "", reason: str = "stopped", duration_seconds: int | None = None, db_path: Path, ) -> dict[str, Any] | None: - proc = (process or "youtube").strip() + proc = _normalize_process(process) conn = _connect(db_path) try: - sid = _ACTIVE_BY_PROCESS.get(proc) - if not sid: + if anchor_name.strip(): row = conn.execute( - f"SELECT id FROM {_TABLE} WHERE process = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1", + f""" + SELECT id FROM {_TABLE} + WHERE process = ? AND ended_at IS NULL AND anchor_name = ? + ORDER BY started_at DESC LIMIT 1 + """, + (proc, anchor_name.strip()), + ).fetchone() + else: + row = conn.execute( + f""" + SELECT id FROM {_TABLE} + WHERE process = ? AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1 + """, (proc,), ).fetchone() - sid = int(row["id"]) if row else None - if not sid: + if not row: + conn.commit() return None - now = time.time() - dur = duration_seconds - if dur is None: - row = conn.execute(f"SELECT started_at, duration_seconds FROM {_TABLE} WHERE id = ?", (sid,)).fetchone() - if row: - stored = float(row["duration_seconds"] or 0) - dur = int(stored) if stored > 0 else int(max(0, now - float(row["started_at"]))) - else: - dur = 0 - conn.execute( - f""" - UPDATE {_TABLE} - SET ended_at = ?, duration_seconds = ?, status = 'ended', end_reason = ?, updated_at = ? - WHERE id = ? - """, - (now, dur, reason, now, sid), + _close_session_row( + conn, + int(row["id"]), + reason=reason or "ended", + duration_seconds=duration_seconds, ) conn.commit() - _ACTIVE_BY_PROCESS.pop(proc, None) - row = conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (sid,)).fetchone() + closed = conn.execute(f"SELECT * FROM {_TABLE} WHERE id = ?", (int(row["id"]),)).fetchone() + return _row_to_session(closed) if closed else None + finally: + conn.close() + + +def close_active_relay_sessions( + process: str, + *, + reason: str = "", + ended_at: float | None = None, + db_path: Path, +) -> list[dict[str, Any]]: + proc = _normalize_process(process) + conn = _connect(db_path) + try: + rows = conn.execute( + f"SELECT id FROM {_TABLE} WHERE process = ? AND ended_at IS NULL ORDER BY started_at DESC", + (proc,), + ).fetchall() + for row in rows: + _close_session_row(conn, int(row["id"]), ended_at=ended_at, reason=reason) + conn.commit() + if not rows: + return [] + ids = [int(row["id"]) for row in rows] + return [ + _row_to_session(item) + for item in conn.execute( + f"SELECT * FROM {_TABLE} WHERE id IN ({','.join('?' * len(ids))})", + ids, + ).fetchall() + ] + finally: + conn.close() + + +def on_relay_push_start( + *, + process: str, + anchor_name: str, + record_url: str | None, + stream_title: str, + real_url: str, + channel_name: str = "", + youtube_ini_text: str = "", + db_path: Path, + base_dir: Path | None = None, +) -> dict[str, Any]: + source_url = (record_url or "").strip() or extract_first_url(record_url or "") + if not source_url and real_url: + source_url = real_url + room_id = extract_source_room_id(record_url or "", real_url or "", anchor_name) + return start_relay_session( + process=process, + anchor_name=anchor_name, + source_url=source_url, + source_room_id=room_id, + stream_title=stream_title, + channel_name=channel_name, + youtube_key_suffix=extract_youtube_key_suffix(youtube_ini_text), + metadata={"real_url": real_url, "record_url": record_url or ""}, + capture_cover_url=real_url or source_url, + db_path=db_path, + base_dir=base_dir, + ) + + +def on_relay_push_end( + process: str, + *, + anchor_name: str, + reason: str = "push_stopped", + db_path: Path, +) -> dict[str, Any] | None: + return end_relay_session(process, anchor_name=anchor_name, reason=reason, db_path=db_path) + + +def get_relay_session(session_id: int | str, *, db_path: Path) -> dict[str, Any] | None: + conn = _connect(db_path) + try: + row = conn.execute( + f"SELECT * FROM {_TABLE} WHERE id = ? OR session_uuid = ?", + (session_id, str(session_id)), + ).fetchone() return _row_to_session(row) if row else None finally: conn.close() @@ -210,6 +558,7 @@ def sync_relay_from_recent_log( channel_name: str = "", url_lines: str = "", db_path: Path, + base_dir: Path | None = None, ) -> dict[str, Any] | None: proc = (process or "youtube").strip() anchor = "" @@ -222,11 +571,15 @@ def sync_relay_from_recent_log( break if not anchor: if "无录制任务" in (recent_log or "")[-2000:]: - closed = end_relay_session(proc, reason="idle", db_path=db_path) + closed = close_active_relay_sessions(proc, reason="idle", db_path=db_path) return {"closed": closed} if closed else None return None - source_url, room_id = _lookup_url(anchor, url_lines) + source_url, room_id = _resolve_anchor_url(anchor, _build_anchor_url_index(url_lines), url_lines) + if not source_url: + meta = extract_douyin_source_meta(url_lines, recent_log) + source_url = str(meta.get("source_url") or "") + room_id = str(meta.get("source_room_id") or room_id) row = None conn = _connect(db_path) try: @@ -250,7 +603,7 @@ def sync_relay_from_recent_log( conn.close() if row: - end_relay_session(proc, reason="source_switch", db_path=db_path) + end_relay_session(proc, anchor_name=str(row["anchor_name"] or ""), reason="source_switch", db_path=db_path) session = start_relay_session( process=proc, anchor_name=anchor, @@ -258,7 +611,9 @@ def sync_relay_from_recent_log( source_room_id=room_id, channel_name=channel_name, metadata={"sync": "recent_log"}, + capture_cover_url=source_url, db_path=db_path, + base_dir=base_dir or db_path.parent.parent.parent, ) if duration_token and session.get("id"): duration = _parse_duration_token(duration_token) @@ -278,6 +633,8 @@ def list_relay_sessions( *, process: str = "", q: str = "", + date_from: str = "", + date_to: str = "", status: str = "", limit: int = 50, offset: int = 0, @@ -292,6 +649,12 @@ def list_relay_sessions( clauses.append("ended_at IS NULL") elif status == "ended": clauses.append("ended_at IS NOT NULL") + if date_from: + clauses.append("push_date >= ?") + params.append(date_from.strip()) + if date_to: + clauses.append("push_date <= ?") + params.append(date_to.strip()) if q.strip(): like = f"%{q.strip()}%" clauses.append( @@ -301,15 +664,20 @@ def list_relay_sessions( where = ("WHERE " + " AND ".join(clauses)) if clauses else "" safe_limit = max(1, min(int(limit), 200)) safe_offset = max(0, int(offset)) + purge_junk_relay_sessions(process=process, db_path=db_path) conn = _connect(db_path) try: rows = conn.execute( f"SELECT * FROM {_TABLE} {where} ORDER BY started_at DESC LIMIT ? OFFSET ?", (*params, safe_limit, safe_offset), ).fetchall() - total = conn.execute(f"SELECT COUNT(*) FROM {_TABLE} {where}", params).fetchone()[0] + sessions = [_row_to_session(r) for r in rows if _session_has_signal(r)] + total = conn.execute( + f"SELECT COUNT(*) FROM {_TABLE} {where}", + params, + ).fetchone()[0] return { - "sessions": [_row_to_session(r) for r in rows], + "sessions": sessions, "total": int(total), "limit": safe_limit, "offset": safe_offset, @@ -318,101 +686,305 @@ def list_relay_sessions( conn.close() +def clear_imported_relay_sessions(process: str, *, db_path: Path) -> int: + proc = _normalize_process(process) + conn = _connect(db_path) + try: + cur = conn.execute( + f"DELETE FROM {_TABLE} WHERE process = ? AND end_reason = 'imported_from_log'", + (proc,), + ) + conn.commit() + return int(cur.rowcount or 0) + finally: + conn.close() + + +def _finalize_import_payload(payload: dict[str, Any]) -> dict[str, Any]: + started_at = float(payload.get("started_at") or 0) + duration = int(payload.get("duration_seconds") or 0) + ended_at = payload.get("ended_at") + if ended_at is None and duration > 0 and started_at > 0: + ended_at = started_at + duration + payload["ended_at"] = ended_at + payload["duration_seconds"] = duration + return payload + + +def _new_import_payload( + *, + process: str, + anchor_name: str, + stream_title: str = "", + started_at: float, + push_date: str = "", + source_url: str = "", + source_room_id: str = "", + duration_seconds: int = 0, + youtube_key_suffix: str = "", +) -> dict[str, Any]: + anchor = (anchor_name or "").strip() + title = (stream_title or "").strip() + if not push_date: + _, push_date = _parse_stream_title(title) + if not push_date and started_at: + push_date = _push_date_from_ts(started_at) + return { + "process": process, + "anchor_name": anchor, + "stream_title": title, + "started_at": started_at, + "push_date": push_date, + "source_url": source_url, + "source_room_id": source_room_id, + "duration_seconds": duration_seconds, + "youtube_key_suffix": youtube_key_suffix, + "ended_at": None, + } + + +def _persist_import_row( + conn: sqlite3.Connection, + payload: dict[str, Any], + *, + started_at: float, + ended_at: float | None, + duration_seconds: int, +) -> None: + anchor = str(payload.get("anchor_name") or "").strip() + stream_title = str(payload.get("stream_title") or "").strip() + if not anchor and not stream_title: + return + if duration_seconds > 0 and started_at > 0: + computed_end = started_at + duration_seconds + if ended_at is None or ended_at < started_at: + ended_at = computed_end + dedupe_started = _timestamp_from_stream_title(stream_title) or started_at + existing = conn.execute( + f""" + SELECT id FROM {_TABLE} + WHERE process = ? AND anchor_name = ? AND stream_title = ? AND ABS(started_at - ?) < 180 + LIMIT 1 + """, + (payload.get("process") or "youtube", anchor, stream_title, dedupe_started), + ).fetchone() + if not existing and stream_title: + existing = conn.execute( + f""" + SELECT id FROM {_TABLE} + WHERE process = ? AND anchor_name = ? AND stream_title = ? + LIMIT 1 + """, + (payload.get("process") or "youtube", anchor, stream_title), + ).fetchone() + if existing: + return + now = time.time() + source_url = _clean_douyin_url(str(payload.get("source_url") or "")) + source_room_id = str( + payload.get("source_room_id") or extract_source_room_id(source_url, stream_title, anchor) + ) + conn.execute( + f""" + INSERT INTO {_TABLE} ( + session_uuid, process, channel_name, anchor_name, source_url, source_room_id, + stream_title, youtube_key_suffix, started_at, ended_at, duration_seconds, push_date, + cover_path, status, end_reason, metadata_json, created_at, updated_at + ) VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'ended', 'imported_from_log', ?, ?, ?) + """, + ( + uuid.uuid4().hex, + payload.get("process") or "youtube", + anchor, + source_url, + source_room_id, + stream_title, + payload.get("youtube_key_suffix") or "", + started_at, + ended_at, + duration_seconds, + payload.get("push_date") or _push_date_from_ts(started_at), + json.dumps({"import": True}, ensure_ascii=False), + now, + now, + ), + ) + + def import_relay_sessions_from_log( log_path: Path, *, process: str = "youtube", url_lines: str = "", + youtube_key_suffix: str = "", db_path: Path, + replace_imported: bool = False, ) -> dict[str, Any]: - if not log_path.is_file(): - return {"imported": 0, "error": "日志文件不存在"} - text = log_path.read_text(encoding="utf-8", errors="replace") - proc = (process or "youtube").strip() + path = Path(log_path) + if not path.is_file(): + return {"imported": 0, "closed": 0, "error": "日志文件不存在"} + text = path.read_text(encoding="utf-8", errors="replace") + proc = _normalize_process(process) + removed = clear_imported_relay_sessions(proc, db_path=db_path) if replace_imported else 0 + purge_junk_relay_sessions(process=proc, db_path=db_path) + url_index = _build_anchor_url_index(url_lines) + log_url_index: dict[str, tuple[str, str]] = {} + for raw_line in text.splitlines(): + fail_match = _FAIL_ADDR_RE.search(raw_line) + if fail_match: + url = _clean_douyin_url(fail_match.group(1)) + anchor = fail_match.group(2).strip() + if url and anchor: + log_url_index[anchor] = (url, extract_source_room_id(url)) + incoming = _INCOMING_URL_RE.search(raw_line) + if incoming: + url = _clean_douyin_url(incoming.group(1)) + if url: + log_url_index["__last_incoming__"] = (url, extract_source_room_id(url)) imported = 0 + closed = 0 active: dict[str, Any] | None = None + sessions: list[dict[str, Any]] = [] conn = _connect(db_path) try: for raw_line in text.splitlines(): line = raw_line.strip() if not line: continue + log_ts = _parse_log_line_ts(line) + event_ts = _parse_local_wall_time(line, log_ts) or log_ts + + incoming = _INCOMING_URL_RE.search(line) + if incoming: + url = _clean_douyin_url(incoming.group(1)) + room_id = extract_source_room_id(url) + log_url_index["__last_incoming__"] = (url, room_id) + label = _ANCHOR_LABEL_RE.search(line) + if label: + log_url_index[label.group(1).strip()] = (url, room_id) + elif active and not active.get("source_url"): + active["source_url"] = url + active["source_room_id"] = room_id + continue + + fail_match = _FAIL_ADDR_RE.search(line) + if fail_match: + url = _clean_douyin_url(fail_match.group(1)) + anchor = fail_match.group(2).strip() + if url and anchor: + log_url_index[anchor] = (url, extract_source_room_id(url)) + continue + push_match = _PUSH_START_RE.search(line) if push_match: + stream_title = push_match.group(1).strip() + anchor, push_date = _parse_stream_title(stream_title) + started_at = _timestamp_from_stream_title(stream_title) or event_ts or time.time() if active: - _insert_imported(conn, active) - imported += 1 - title = push_match.group(1).strip() - active = { - "process": proc, - "stream_title": title, - "anchor_name": title.split("_")[0][:64], - "started_at": time.time(), - "source_url": "", - "source_room_id": "", - } + sessions.append(_finalize_import_payload(active)) + closed += 1 + source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index) + active = _new_import_payload( + process=proc, + anchor_name=anchor, + stream_title=stream_title, + started_at=started_at, + push_date=push_date, + source_url=source_url, + source_room_id=source_room_id, + youtube_key_suffix=youtube_key_suffix, + ) continue + rec_match = _RECORDING_LINE_RE.search(line) if rec_match: anchor = rec_match.group(1).strip(" :?") duration = _parse_duration_token(rec_match.group(2).strip()) - url, room = _lookup_url(anchor, url_lines) - if active: - active["anchor_name"] = anchor - active["duration_seconds"] = duration - active["source_url"] = url - active["source_room_id"] = room + if active and str(active.get("anchor_name") or "") == anchor: + active["duration_seconds"] = max(int(active.get("duration_seconds") or 0), duration) + if not active.get("source_url"): + source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index) + active["source_url"] = source_url + active["source_room_id"] = source_room_id + elif active and str(active.get("anchor_name") or "") != anchor: + sessions.append(_finalize_import_payload(active)) + closed += 1 + started_at = (event_ts or time.time()) - duration if duration else (event_ts or time.time()) + source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index) + active = _new_import_payload( + process=proc, + anchor_name=anchor, + started_at=started_at, + source_url=source_url, + source_room_id=source_room_id, + duration_seconds=duration, + youtube_key_suffix=youtube_key_suffix, + ) else: - active = { - "process": proc, - "anchor_name": anchor, - "duration_seconds": duration, - "started_at": time.time() - duration, - "source_url": url, - "source_room_id": room, - "stream_title": "", - } - incoming = _INCOMING_URL_RE.search(line) - if incoming and active: - url = incoming.group(1).strip() - active["source_url"] = url - active["source_room_id"] = _room_from_text(url) + started_at = (event_ts or time.time()) - duration if duration else (event_ts or time.time()) + source_url, source_room_id = _resolve_anchor_url(anchor, url_index, url_lines, log_url_index) + active = _new_import_payload( + process=proc, + anchor_name=anchor, + started_at=started_at, + source_url=source_url, + source_room_id=source_room_id, + duration_seconds=duration, + youtube_key_suffix=youtube_key_suffix, + ) + continue + + if "推流已彻底停止" in line and active: + if event_ts and int(active.get("duration_seconds") or 0) > 0: + active["ended_at"] = float(active["started_at"]) + int(active["duration_seconds"]) + elif event_ts: + active["ended_at"] = event_ts + sessions.append(_finalize_import_payload(active)) + active = None + closed += 1 + if active: - _insert_imported(conn, active) + sessions.append(_finalize_import_payload(active)) + + for payload in sessions: + if not str(payload.get("anchor_name") or "").strip() and not str(payload.get("stream_title") or "").strip(): + continue + if not payload.get("source_url"): + source_url, source_room_id = _resolve_anchor_url( + str(payload.get("anchor_name") or ""), + url_index, + url_lines, + log_url_index, + ) + payload["source_url"] = source_url + payload["source_room_id"] = source_room_id or payload.get("source_room_id") or "" + _persist_import_row( + conn, + payload, + started_at=float(payload["started_at"]), + ended_at=payload.get("ended_at"), + duration_seconds=int(payload.get("duration_seconds") or 0), + ) imported += 1 + if youtube_key_suffix: + conn.execute( + f""" + UPDATE {_TABLE} + SET youtube_key_suffix = ?, updated_at = ? + WHERE process = ? AND COALESCE(youtube_key_suffix, '') = '' + """, + (youtube_key_suffix, time.time(), proc), + ) conn.commit() finally: conn.close() - return {"imported": imported} - - -def _insert_imported(conn: sqlite3.Connection, payload: dict[str, Any]) -> None: - now = time.time() - started = float(payload.get("started_at") or now) - conn.execute( - f""" - INSERT INTO {_TABLE} ( - session_uuid, process, anchor_name, source_url, source_room_id, stream_title, - started_at, ended_at, duration_seconds, push_date, status, end_reason, - metadata_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ended', 'imported_from_log', ?, ?, ?) - """, - ( - uuid.uuid4().hex, - payload.get("process") or "youtube", - payload.get("anchor_name") or "", - payload.get("source_url") or "", - payload.get("source_room_id") or "", - payload.get("stream_title") or "", - started, - now, - int(payload.get("duration_seconds") or 0), - datetime.fromtimestamp(started).strftime("%Y-%m-%d"), - json.dumps({"import": True}, ensure_ascii=False), - now, - now, - ), - ) + return { + "imported": imported, + "closed": closed, + "removed": removed, + "process": proc, + "log_path": str(path), + } def track_process_status_transition( @@ -422,12 +994,76 @@ def track_process_status_transition( *, db_path: Path, ) -> None: - old_s = (old_status or "").strip().lower() - new_s = (new_status or "").strip().lower() - proc = (process or "").strip() - if not proc: - return - if old_s != "online" and new_s == "online": - start_relay_session(process=proc, metadata={"trigger": "pm2_online"}, db_path=db_path) - elif old_s == "online" and new_s in {"stopped", "errored", "not_found"}: - end_relay_session(proc, reason=new_s, db_path=db_path) + """d2ypp2 不在 PM2 状态切换时写会话;保留空实现避免脏数据。""" + return + + +def _capture_cover_safe(session_id: int, stream_url: str, db_path: Path, base_dir: Path) -> None: + try: + capture_relay_cover(session_id, stream_url, db_path=db_path, base_dir=base_dir) + except Exception: + pass + + +def capture_relay_cover( + session_id: int, + stream_url: str, + *, + db_path: Path, + base_dir: Path, +) -> str: + url = (stream_url or "").strip() + if not url or not url.startswith("http"): + return "" + covers = _covers_dir(base_dir) + out_path = covers / f"{session_id}.jpg" + headers = ( + "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n" + "Referer: https://live.douyin.com/\r\n" + ) + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-headers", + headers, + "-rw_timeout", + "15000000", + "-i", + url, + "-frames:v", + "1", + "-q:v", + "3", + str(out_path), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=25) + if proc.returncode != 0 or not out_path.is_file() or out_path.stat().st_size < 128: + return "" + conn = _connect(db_path) + try: + conn.execute( + f"UPDATE {_TABLE} SET cover_path = ?, updated_at = ? WHERE id = ?", + (str(out_path), time.time(), session_id), + ) + conn.commit() + finally: + conn.close() + return str(out_path) + + +def get_relay_cover_path(session_id: int | str, *, db_path: Path) -> Path | None: + conn = _connect(db_path) + try: + row = conn.execute( + f"SELECT cover_path FROM {_TABLE} WHERE id = ? OR session_uuid = ?", + (session_id, str(session_id)), + ).fetchone() + if not row: + return None + path = Path(str(row["cover_path"] or "")) + return path if path.is_file() else None + finally: + conn.close() diff --git a/web2_relay_pro.py b/web2_relay_pro.py index 647e93f..9f7b0cc 100644 --- a/web2_relay_pro.py +++ b/web2_relay_pro.py @@ -10,8 +10,13 @@ import time from pathlib import Path from typing import Any, Callable, Awaitable -from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini +from web2_youtube_pro_runtime import ( + channel_pm2_name, + default_youtube_pro_pm2_name, + normalize_youtube_pro_channels, +) from web2_config_governance import backup_config_file +from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini # 与 web2_client_overview 一致的去重键 def _norm_douyin_url(url: str) -> str: @@ -181,6 +186,8 @@ def list_channels_for_pro(config_dir: Path) -> dict[str, Any]: { "id": cid, "name": name, + "pm2Name": channel_pm2_name(item), + "notes": str(item.get("notes") or "").strip(), "douyinUrl": durl, "keyPreview": kp, "youtubeWatchUrl": get_youtube_watch_url(config_dir, cid), @@ -312,6 +319,9 @@ def get_channel_detail(config_dir: Path, channel_id: str) -> dict[str, Any] | No return { "id": str(ch.get("id") or "").strip(), "name": str(ch.get("name") or "").strip() or "未命名", + "pm2Name": channel_pm2_name(ch), + "notes": str(ch.get("notes") or "").strip(), + "urlLines": str(ch.get("urlLines") or "").strip(), "douyinUrl": str(ch.get("douyinUrl") or ch.get("douyin_url") or "").strip(), "youtubeKey": active, "youtubeKeys": keys, @@ -368,16 +378,30 @@ def update_channel_keys_list( return False, "未找到该频道" +_DOUYIN_LIVE_ROOM_RE = re.compile( + r"(?:https?://(?:www\.)?)?live\.douyin\.com/([a-zA-Z0-9_-]+)", re.IGNORECASE +) + + +def extract_douyin_url_from_config_line(line: str) -> str: + """从 URL_config 行解析抖音直播间 URL(兼容 d2ypp2:url,主播: 名 / 原画,url,主播: 名)。""" + raw = (line or "").strip().lstrip("\ufeff") + if not raw or raw.startswith("#"): + return "" + m = _DOUYIN_LIVE_ROOM_RE.search(raw) + if m: + return f"https://live.douyin.com/{m.group(1)}" + return "" + + def extract_url_lines_from_ini(text: str) -> list[str]: lines: list[str] = [] + seen: set[str] = set() for raw in text.splitlines(): - t = raw.strip() - if not t or t.startswith("#"): - continue - if "," in t: - t = t.split(",")[-1].strip() - if "douyin" in t.lower() or "v.douyin.com" in t.lower() or "live.douyin.com" in t.lower(): - lines.append(t) + url = extract_douyin_url_from_config_line(raw) + if url and url not in seen: + seen.add(url) + lines.append(url) return lines @@ -448,6 +472,10 @@ def read_url_config_for_channel(config_dir: Path, channel_id: str) -> str: def write_url_config_for_channel(config_dir: Path, channel_id: str, content: str) -> tuple[bool, str]: + if not extract_url_lines_from_ini(content): + existing = read_url_config_for_channel(config_dir, channel_id) + if extract_url_lines_from_ini(existing): + return False, "源站地址为空,已拒绝覆盖已有配置" errs = validate_url_config_no_cross_duplicate(config_dir, channel_id, content) if errs: return False, errs[0] @@ -517,8 +545,34 @@ def parse_relay_meta_lines(log_text: str) -> list[dict[str, Any]]: return out -def relay_pm2_name_for_channel(channel_id: str) -> str: - return f"youtube2__{channel_id}" +def relay_pm2_name_for_channel(channel_id: str, config_dir: Path | None = None) -> str: + cfg = config_dir or Path("config") + ch = get_channel_by_id(cfg, channel_id) + if ch: + return channel_pm2_name(ch) + return default_youtube_pro_pm2_name(channel_id) + + +def save_channels_document(config_dir: Path, channels: list[dict[str, Any]]) -> tuple[bool, str]: + try: + normalized = normalize_youtube_pro_channels(channels) + except ValueError as exc: + return False, str(exc) + doc, errs = load_channels_document(config_dir) + if doc is None: + return False, errs[0] if errs else "无法读取配置" + doc["channels"] = normalized + channels_json_path(config_dir).write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") + return True, "已保存频道配置" + + +def channel_id_for_pm2_name(config_dir: Path, pm2: str) -> str: + doc, _ = load_channels_document(config_dir) + if not doc or not isinstance(doc.get("channels"), list): + return "" + from web2_youtube_pro_runtime import channel_id_for_pm2_name as _cid_for_pm2 + + return _cid_for_pm2(doc["channels"], pm2) async def build_relay_live_status( @@ -540,7 +594,7 @@ async def build_relay_live_status( if not cid: continue cname = str(c.get("name") or "").strip() or "未命名" - pname = relay_pm2_name_for_channel(cid) + pname = relay_pm2_name_for_channel(cid, config_dir) try: info = await get_process_info(pname) except Exception: @@ -650,6 +704,8 @@ def add_relay_channel( row: dict[str, Any] = { "id": new_id, "name": nm, + "pm2Name": default_youtube_pro_pm2_name(new_id), + "notes": "", "douyinUrl": "", "youtubeKey": key, } diff --git a/web2_supervisor.py b/web2_supervisor.py index 25a08eb..728acad 100644 --- a/web2_supervisor.py +++ b/web2_supervisor.py @@ -47,14 +47,23 @@ def _truncate_text_log(path: str | Path | None) -> None: pass -def _child_env_for_process(process_name: str) -> dict[str, str]: +def _child_env_for_process(process_name: str, base_dir: Path) -> dict[str, str]: extra: dict[str, str] = { "LIVE_PM2_PROCESS_NAME": process_name, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8", } - if process_name.startswith("youtube2__"): - extra["RELAY_CHANNEL_ID"] = process_name[len("youtube2__") :] + relay_ch = "" + try: + from web2_relay_pro import channel_id_for_pm2_name + + relay_ch = channel_id_for_pm2_name(base_dir / "config", process_name) + except Exception: + relay_ch = "" + if not relay_ch and process_name.startswith("youtube2__"): + relay_ch = process_name[len("youtube2__") :] + if relay_ch: + extra["RELAY_CHANNEL_ID"] = relay_ch return merge_child_env(extra) @@ -150,7 +159,7 @@ class BuiltinSupervisor: stdout=out_f, stderr=err_f, creationflags=0, - env=_child_env_for_process(process_name), + env=_child_env_for_process(process_name, self.base_dir), ) except Exception as e: out_f.close() @@ -293,18 +302,25 @@ async def run_pm2_shell(cmd: str, timeout: float = 15.0) -> str: async def force_kill_ffmpeg_cross_platform(process_name: str) -> None: - """按命令行匹配业务进程名,结束相关 ffmpeg。""" + """按 LIVE_PM2_PROCESS_NAME 环境变量或命令行匹配业务进程名,结束相关 ffmpeg。""" def _sync_kill() -> None: + needle = (process_name or "").strip().lower() for proc in psutil.process_iter(["pid", "name", "cmdline"]): try: name = (proc.info.get("name") or "").lower() if "ffmpeg" not in name: continue cmdline = proc.info.get("cmdline") or [] - flat = " ".join(str(x) for x in cmdline) - if process_name and process_name not in flat: - continue + flat = " ".join(str(x) for x in cmdline).lower() + env_name = "" + try: + env_name = str(proc.environ().get("LIVE_PM2_PROCESS_NAME") or "").strip().lower() + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + env_name = "" + if needle: + if env_name != needle and needle not in flat: + continue p = psutil.Process(proc.info["pid"]) p.kill() except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): diff --git a/web2_youtube_encoder_cache.py b/web2_youtube_encoder_cache.py new file mode 100644 index 0000000..fbba472 --- /dev/null +++ b/web2_youtube_encoder_cache.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + +_CACHE_TTL_SECONDS = max(3600, int(os.environ.get("YOUTUBE_ENCODER_CACHE_TTL", str(24 * 3600)))) + + +def _cache_path(base_dir: str | Path | None = None) -> Path: + root = Path(base_dir or os.environ.get("LIVE_PROJECT_ROOT") or Path(__file__).resolve().parent) + path = root / "runtime" + path.mkdir(parents=True, exist_ok=True) + return path / "encoder_cache.json" + + +def load_encoder_cache(base_dir: str | Path | None = None) -> dict[str, Any]: + path = _cache_path(base_dir) + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def get_cached_encoder( + machine: str, + ffmpeg_bin: str, + *, + base_dir: str | Path | None = None, +) -> str | None: + blob = load_encoder_cache(base_dir) + key = f"{machine}|{ffmpeg_bin}" + row = blob.get(key) + if not isinstance(row, dict): + return None + if time.time() - float(row.get("ts") or 0) > _CACHE_TTL_SECONDS: + return None + name = str(row.get("encoder") or "").strip() + return name or None + + +def store_cached_encoder( + machine: str, + ffmpeg_bin: str, + encoder: str, + *, + base_dir: str | Path | None = None, +) -> None: + if not encoder: + return + path = _cache_path(base_dir) + blob = load_encoder_cache(base_dir) + key = f"{machine}|{ffmpeg_bin}" + blob[key] = {"encoder": encoder, "ts": time.time()} + try: + path.write_text(json.dumps(blob, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + except OSError: + pass diff --git a/web2_youtube_ingest_auth.py b/web2_youtube_ingest_auth.py new file mode 100644 index 0000000..5210922 --- /dev/null +++ b/web2_youtube_ingest_auth.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import time +from typing import Any + + +def ingest_token() -> str: + return ( + os.environ.get("YOUTUBE_INGEST_TOKEN") + or os.environ.get("LIVE_YOUTUBE_INGEST_TOKEN") + or "" + ).strip() + + +def ingest_auth_required() -> bool: + raw = ( + os.environ.get("YOUTUBE_INGEST_REQUIRE_AUTH") + or os.environ.get("LIVE_YOUTUBE_INGEST_REQUIRE_AUTH") + or "1" + ).strip().lower() + if raw in {"0", "false", "no", "off"}: + return False + return bool(ingest_token()) + + +def verify_ingest_payload(payload: dict[str, Any]) -> tuple[bool, str]: + if not ingest_auth_required(): + return True, "" + token = ingest_token() + if not token: + return False, "ingest auth enabled but YOUTUBE_INGEST_TOKEN is empty" + + provided = str(payload.get("ingest_token") or payload.get("token") or "").strip() + if provided and hmac.compare_digest(provided, token): + return True, "" + + ts_raw = payload.get("ingest_ts") or payload.get("ts") + sig = str(payload.get("ingest_sig") or payload.get("sig") or "").strip() + if sig and ts_raw is not None: + try: + ts = int(float(ts_raw)) + except (TypeError, ValueError): + return False, "invalid ingest timestamp" + if abs(time.time() - ts) > 900: + return False, "ingest signature expired" + body = f"{ts}|{payload.get('process') or payload.get('lane') or 'chrome_extension'}" + expected = hmac.new(token.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest() + if hmac.compare_digest(sig, expected): + return True, "" + + return False, "invalid or missing ingest credentials" + + +def ingest_config_payload() -> dict[str, Any]: + return { + "require_auth": ingest_auth_required(), + "has_token": bool(ingest_token()), + "endpoint": "/youtube/live/ingest", + } diff --git a/web2_youtube_live_store.py b/web2_youtube_live_store.py new file mode 100644 index 0000000..8cb0b10 --- /dev/null +++ b/web2_youtube_live_store.py @@ -0,0 +1,868 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import sqlite3 +import time +import uuid +from pathlib import Path +from typing import Any + +from web2_douyin_source_display import extract_douyin_source_meta + +_DB_NAME = "hub_state.sqlite" + + +def live_db_path(base_dir: Path) -> Path: + path = Path(base_dir) / "runtime" / "youtube_live" / _DB_NAME + path.parent.mkdir(parents=True, exist_ok=True) + return path +_DOUYIN_ROOM_RE = re.compile(r"(?:https?://)?(?:www\.)?live\.douyin\.com/([A-Za-z0-9_-]+)", re.IGNORECASE) +_ROOM_HINT_RE = re.compile(r"(?:room[_\s-]?id|roomId|webcast_id)\D{0,8}([A-Za-z0-9_-]{4,})", re.IGNORECASE) +_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE) +_KEY_RE = re.compile(r"^\s*key\s*=\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE) + + +def _db_path(db_path: Path | None = None) -> Path: + if db_path: + return db_path + root = Path(__file__).resolve().parent + return live_db_path(root) + + +def _chmod_runtime_files(path: Path) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(path.parent, 0o775) + except OSError: + pass + for item in (path, path.with_name(f"{path.name}-wal"), path.with_name(f"{path.name}-shm")): + try: + if item.exists(): + os.chmod(item, 0o664) + except OSError: + pass + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = _db_path(db_path) + _chmod_runtime_files(path) + conn = sqlite3.connect(str(path), timeout=12) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=12000") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA synchronous=NORMAL") + try: + conn.execute("PRAGMA journal_mode=WAL") + except sqlite3.OperationalError: + pass + conn.execute( + """ + CREATE TABLE IF NOT EXISTS youtube_live_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_uuid TEXT NOT NULL UNIQUE, + process TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT '', + channel_id TEXT NOT NULL DEFAULT '', + channel_name TEXT NOT NULL DEFAULT '', + source_platform TEXT NOT NULL DEFAULT 'douyin', + source_url TEXT NOT NULL DEFAULT '', + source_room_id TEXT NOT NULL DEFAULT '', + source_title TEXT NOT NULL DEFAULT '', + target_platform TEXT NOT NULL DEFAULT 'youtube', + youtube_key_suffix TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + started_at REAL NOT NULL, + ended_at REAL, + duration_seconds REAL NOT NULL DEFAULT 0, + last_seen_at REAL NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + quality_score INTEGER NOT NULL DEFAULT 0, + viewer_peak INTEGER NOT NULL DEFAULT 0, + last_viewer_count INTEGER, + last_live_status TEXT NOT NULL DEFAULT '', + last_health TEXT NOT NULL DEFAULT '', + last_popup_text TEXT NOT NULL DEFAULT '', + last_page_url TEXT NOT NULL DEFAULT '', + last_page_title TEXT NOT NULL DEFAULT '', + control_actions_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + snapshot_json TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS youtube_live_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + ts REAL NOT NULL, + source TEXT NOT NULL DEFAULT '', + live_status TEXT NOT NULL DEFAULT '', + viewer_count INTEGER, + like_count INTEGER, + chat_rate REAL, + health TEXT NOT NULL DEFAULT '', + popup_text TEXT NOT NULL DEFAULT '', + page_url TEXT NOT NULL DEFAULT '', + page_title TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(event_id) REFERENCES youtube_live_events(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS youtube_live_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + ts REAL NOT NULL, + severity TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(event_id) REFERENCES youtube_live_events(id) ON DELETE CASCADE + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_youtube_live_events_status ON youtube_live_events(status, updated_at DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_youtube_live_events_process ON youtube_live_events(process, updated_at DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_youtube_live_samples_event ON youtube_live_samples(event_id, ts DESC)") + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_youtube_live_alert_fingerprint " + "ON youtube_live_alerts(event_id, fingerprint)" + ) + conn.commit() + _chmod_runtime_files(path) + return conn + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def _json_loads(raw: str | None, fallback: Any) -> Any: + if not raw: + return fallback + try: + return json.loads(raw) + except json.JSONDecodeError: + return fallback + + +def _row_to_event(row: sqlite3.Row) -> dict[str, Any]: + data = dict(row) + data["control_actions"] = _json_loads(data.pop("control_actions_json", "[]"), []) + data["tags"] = _json_loads(data.pop("tags_json", "[]"), []) + data["snapshot"] = _json_loads(data.pop("snapshot_json", "{}"), {}) + if data.get("ended_at"): + data["duration_seconds"] = int(float(data.get("duration_seconds") or 0)) + else: + data["duration_seconds"] = int(max(0, time.time() - float(data.get("started_at") or time.time()))) + return data + + +def _row_to_sample(row: sqlite3.Row) -> dict[str, Any]: + data = dict(row) + data["payload"] = _json_loads(data.pop("payload_json", "{}"), {}) + return data + + +def _row_to_alert(row: sqlite3.Row) -> dict[str, Any]: + data = dict(row) + data["payload"] = _json_loads(data.pop("payload_json", "{}"), {}) + return data + + +def extract_source_room_id(*values: str | None) -> str: + for value in values: + text = str(value or "") + match = _DOUYIN_ROOM_RE.search(text) + if match: + return match.group(1) + match = _ROOM_HINT_RE.search(text) + if match: + return match.group(1) + return "" + + +def extract_first_url(text: str | None) -> str: + for line in str(text or "").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + match = _URL_RE.search(stripped) + if match: + return match.group(0).rstrip(";,") + match = _URL_RE.search(str(text or "")) + return match.group(0).rstrip(";,") if match else "" + + +def extract_youtube_key_suffix(text: str | None) -> str: + match = _KEY_RE.search(str(text or "")) + if not match: + return "" + key = match.group(1).strip() + if not key: + return "" + return key[-6:] if len(key) > 6 else key + + +def _normalize_process(value: str | None) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(value or "").strip()) + return cleaned[:96] or "youtube" + + +def _status_from_payload(payload: dict[str, Any]) -> str: + raw = " ".join( + str(payload.get(key) or "") + for key in ("live_status", "status", "business_status", "process_status", "health", "page_title") + ).lower() + if any(token in raw for token in ("error", "failed", "断开", "异常", "错误")): + return "error" + if any(token in raw for token in ("live", "streaming", "excellent", "healthy", "直播中", "已开始", "正在直播")): + return "active" + if any(token in raw for token in ("waiting", "idle", "offline", "stopped", "等待", "未开始", "停止")): + return "idle" + return "active" + + +def _quality_score(payload: dict[str, Any]) -> int: + score = 60 + raw = " ".join(str(payload.get(key) or "") for key in ("live_status", "health", "business_status", "process_status")).lower() + if any(token in raw for token in ("excellent", "healthy", "streaming", "直播中", "良好")): + score += 25 + if any(token in raw for token in ("warning", "unstable", "poor", "buffer", "警告", "不佳", "卡顿")): + score -= 20 + if any(token in raw for token in ("error", "failed", "disconnect", "异常", "错误", "断开")): + score -= 35 + viewers = _coerce_int(payload.get("viewer_count")) + if viewers and viewers > 0: + score += min(10, viewers // 10 + 1) + return max(0, min(100, score)) + + +def _coerce_int(value: Any) -> int | None: + if value is None or value == "": + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return max(0, int(value)) + text = str(value).strip().replace(",", "") + match = re.search(r"\d+", text) + if not match: + return None + return max(0, int(match.group(0))) + + +def _action_history(row: sqlite3.Row | None, action: str, ts: float, note: str = "") -> str: + current = _json_loads(row["control_actions_json"] if row else "[]", []) + if not isinstance(current, list): + current = [] + current.append({"action": action, "ts": ts, "note": note}) + return _json_dumps(current[-80:]) + + +def _active_event_row( + conn: sqlite3.Connection, + *, + process: str, + source_room_id: str = "", + source_url: str = "", +) -> sqlite3.Row | None: + if source_room_id: + row = conn.execute( + """ + SELECT * FROM youtube_live_events + WHERE ended_at IS NULL AND status IN ('active', 'error') AND source_room_id = ? + ORDER BY updated_at DESC LIMIT 1 + """, + (source_room_id,), + ).fetchone() + if row: + return row + if source_url: + row = conn.execute( + """ + SELECT * FROM youtube_live_events + WHERE ended_at IS NULL AND status IN ('active', 'error') AND source_url = ? + ORDER BY updated_at DESC LIMIT 1 + """, + (source_url,), + ).fetchone() + if row: + return row + return conn.execute( + """ + SELECT * FROM youtube_live_events + WHERE ended_at IS NULL AND status IN ('active', 'error') AND process = ? + ORDER BY updated_at DESC LIMIT 1 + """, + (process,), + ).fetchone() + + +def _upsert_event( + conn: sqlite3.Connection, + *, + process: str, + mode: str = "", + channel_id: str = "", + channel_name: str = "", + source_url: str = "", + source_room_id: str = "", + source_title: str = "", + youtube_key_suffix: str = "", + payload: dict[str, Any] | None = None, + action: str = "observe", + note: str = "", +) -> sqlite3.Row: + now = time.time() + process = _normalize_process(process) + payload = payload or {} + source_room_id = source_room_id or extract_source_room_id(source_url, source_title, payload.get("page_title"), payload.get("text")) + source_url = source_url or extract_first_url(str(payload.get("url") or "")) + live_status = str(payload.get("live_status") or payload.get("business_status") or payload.get("status") or "") + health = str(payload.get("health") or "") + page_url = str(payload.get("url") or payload.get("page_url") or "") + page_title = str(payload.get("page_title") or payload.get("title") or "") + popup_text = _primary_popup(payload) + viewer_count = _coerce_int(payload.get("viewer_count")) + score = _quality_score(payload) + status = _status_from_payload(payload) + row = _active_event_row(conn, process=process, source_room_id=source_room_id, source_url=source_url) + snapshot = { + "last_payload": payload, + "source_room_id": source_room_id, + "source_url": source_url, + "source_title": source_title, + "updated_at": now, + } + if row: + conn.execute( + """ + UPDATE youtube_live_events + SET mode = COALESCE(NULLIF(?, ''), mode), + channel_id = COALESCE(NULLIF(?, ''), channel_id), + channel_name = COALESCE(NULLIF(?, ''), channel_name), + source_url = COALESCE(NULLIF(?, ''), source_url), + source_room_id = COALESCE(NULLIF(?, ''), source_room_id), + source_title = COALESCE(NULLIF(?, ''), source_title), + youtube_key_suffix = COALESCE(NULLIF(?, ''), youtube_key_suffix), + status = CASE WHEN ? = 'idle' THEN status ELSE ? END, + last_seen_at = ?, + updated_at = ?, + quality_score = ?, + viewer_peak = CASE + WHEN ? IS NOT NULL AND ? > viewer_peak THEN ? + ELSE viewer_peak + END, + last_viewer_count = COALESCE(?, last_viewer_count), + last_live_status = COALESCE(NULLIF(?, ''), last_live_status), + last_health = COALESCE(NULLIF(?, ''), last_health), + last_popup_text = COALESCE(NULLIF(?, ''), last_popup_text), + last_page_url = COALESCE(NULLIF(?, ''), last_page_url), + last_page_title = COALESCE(NULLIF(?, ''), last_page_title), + control_actions_json = ?, + snapshot_json = ? + WHERE id = ? + """, + ( + mode, + channel_id, + channel_name, + source_url, + source_room_id, + source_title, + youtube_key_suffix, + status, + status, + now, + now, + score, + viewer_count, + viewer_count, + viewer_count, + viewer_count, + live_status, + health, + popup_text, + page_url, + page_title, + _action_history(row, action, now, note), + _json_dumps(snapshot), + row["id"], + ), + ) + event_id = int(row["id"]) + else: + conn.execute( + """ + INSERT INTO youtube_live_events( + event_uuid, process, mode, channel_id, channel_name, source_url, source_room_id, source_title, + youtube_key_suffix, status, started_at, last_seen_at, created_at, updated_at, quality_score, + viewer_peak, last_viewer_count, last_live_status, last_health, last_popup_text, + last_page_url, last_page_title, control_actions_json, snapshot_json + ) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + str(uuid.uuid4()), + process, + mode, + channel_id, + channel_name, + source_url, + source_room_id, + source_title, + youtube_key_suffix, + "active" if status == "idle" else status, + now, + now, + now, + now, + score, + viewer_count or 0, + viewer_count, + live_status, + health, + popup_text, + page_url, + page_title, + _action_history(None, action, now, note), + _json_dumps(snapshot), + ), + ) + event_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + row = conn.execute("SELECT * FROM youtube_live_events WHERE id = ?", (event_id,)).fetchone() + assert row is not None + return row + + +def _primary_popup(payload: dict[str, Any]) -> str: + raw = payload.get("popups") or payload.get("dialogs") or payload.get("alerts") or [] + if isinstance(raw, str): + return raw.strip()[:500] + if isinstance(raw, list): + texts = [str(item).strip() for item in raw if str(item).strip()] + return " | ".join(texts)[:500] + return "" + + +def _payload_indicates_process_inactive(payload: dict[str, Any]) -> bool: + business_status = str(payload.get("business_status") or "").strip().lower() + if bool(payload.get("is_pushing")) or business_status in {"streaming", "relay_slow", "source_stalled"}: + return False + + process_status = str(payload.get("process_status") or "").strip().lower() + raw = " ".join( + str(payload.get(key) or "") + for key in ("raw_status", "business_note", "recent_log", "recent_error", "live_status", "status") + ).lower() + if any(token in raw for token in ("starting", "launching", "restarting", "\u542f\u52a8\u4e2d", "\u91cd\u542f\u4e2d")): + return False + + stopped_process = { + "configured", + "disabled", + "idle", + "missing", + "not_running", + "ready", + "stopped", + "unknown", + } + active_business = { + "relay_starting", + "source_live", + "monitoring", + } + if business_status in active_business and process_status in {"online", "running", "launching"}: + return False + + stopped_business = { + "configured", + "idle", + "inactive", + "misconfigured", + "not_running", + "ready", + "stale", + "stopped", + "unknown", + "waiting_source", + "youtube_key_rejected", + } + if process_status in stopped_process: + return True + if business_status in stopped_business and payload.get("is_pushing") is False: + return True + if payload.get("is_pushing") is False and any( + token in raw + for token in ( + "not running", + "not started", + "configured but", + "process is configured", + "\u672a\u542f\u52a8", + "\u672a\u8fd0\u884c", + "\u5f53\u524d\u672a\u542f\u52a8", + ) + ): + return True + return False + + +def _insert_sample(conn: sqlite3.Connection, event_id: int, payload: dict[str, Any], source: str) -> None: + popup_text = _primary_popup(payload) + conn.execute( + """ + INSERT INTO youtube_live_samples( + event_id, ts, source, live_status, viewer_count, like_count, chat_rate, health, + popup_text, page_url, page_title, payload_json + ) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + event_id, + float(payload.get("captured_at") or time.time()), + source, + str(payload.get("live_status") or payload.get("business_status") or payload.get("status") or ""), + _coerce_int(payload.get("viewer_count")), + _coerce_int(payload.get("like_count")), + float(payload.get("chat_rate") or 0) if payload.get("chat_rate") not in (None, "") else None, + str(payload.get("health") or ""), + popup_text, + str(payload.get("url") or payload.get("page_url") or ""), + str(payload.get("page_title") or payload.get("title") or ""), + _json_dumps(payload), + ), + ) + + +def _insert_alerts(conn: sqlite3.Connection, event_id: int, payload: dict[str, Any], source: str) -> None: + popup_text = _primary_popup(payload) + messages: list[tuple[str, str, str]] = [] + if popup_text: + messages.append(("warning", "Browser popup", popup_text)) + health = str(payload.get("health") or "") + if health and any(token in health.lower() for token in ("error", "warning", "poor", "unstable", "异常", "错误", "警告", "不佳")): + messages.append(("warning", "Stream health", health)) + live_status = str(payload.get("live_status") or "") + if live_status and any(token in live_status.lower() for token in ("error", "failed", "异常", "错误")): + messages.append(("critical", "Live status", live_status)) + for severity, title, message in messages: + fingerprint = hashlib.sha1(f"{event_id}|{title}|{message[:240]}".encode("utf-8", "ignore")).hexdigest() + conn.execute( + """ + INSERT OR IGNORE INTO youtube_live_alerts(event_id, ts, severity, title, message, source, fingerprint, payload_json) + VALUES(?,?,?,?,?,?,?,?) + """, + (event_id, time.time(), severity, title, message[:1000], source, fingerprint, _json_dumps(payload)), + ) + + +def record_control_action( + process: str, + action: str, + *, + script: str = "", + url_lines: str = "", + youtube_ini_text: str = "", + channel_id: str = "", + channel_name: str = "", + mode: str = "", + note: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + source_url = extract_first_url(url_lines) + source_room_id = extract_source_room_id(url_lines) + payload = { + "source": "control", + "status": "active", + "process_status": "running", + "script": script, + "action": action, + } + conn = _connect(db_path) + try: + if action in {"stop", "finish", "end"}: + return finish_active_events(process, reason=note or action, db_path=db_path) + if action == "restart": + finish_active_events(process, reason="restart", db_path=db_path) + row = _upsert_event( + conn, + process=process, + mode=mode, + channel_id=channel_id, + channel_name=channel_name, + source_url=source_url, + source_room_id=source_room_id, + youtube_key_suffix=extract_youtube_key_suffix(youtube_ini_text), + payload=payload, + action=action, + note=note, + ) + _insert_sample(conn, int(row["id"]), payload, "control") + conn.commit() + return {"event": _row_to_event(row)} + finally: + conn.close() + + +def record_process_observation( + process: str, + payload: dict[str, Any], + *, + url_lines: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + if _payload_indicates_process_inactive(payload): + return finish_active_events(process, reason=str(payload.get("business_note") or payload.get("raw_status") or "process inactive"), db_path=db_path) + + source_meta = extract_douyin_source_meta( + url_lines, + "\n".join(str(payload.get(key) or "") for key in ("recent_log", "recent_error")), + ) + source_url = str(source_meta.get("source_url") or extract_first_url(url_lines)) + source_room_id = str(source_meta.get("source_room_id") or extract_source_room_id(url_lines, payload.get("recent_log"), payload.get("recent_error"))) + conn = _connect(db_path) + try: + row = _upsert_event( + conn, + process=process, + source_url=source_url, + source_room_id=source_room_id, + payload=payload, + action="process_observe", + ) + event_id = int(row["id"]) + last = conn.execute("SELECT ts FROM youtube_live_samples WHERE event_id = ? ORDER BY ts DESC LIMIT 1", (event_id,)).fetchone() + if not last or time.time() - float(last["ts"]) >= 5: + _insert_sample(conn, event_id, payload, "process") + conn.commit() + return {"event": _row_to_event(conn.execute("SELECT * FROM youtube_live_events WHERE id = ?", (event_id,)).fetchone())} + finally: + conn.close() + + +def ingest_browser_telemetry(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]: + process = _normalize_process(str(payload.get("process") or payload.get("lane") or "chrome_extension")) + source_url = str(payload.get("source_url") or "") or extract_first_url( + " ".join(str(payload.get(key) or "") for key in ("url", "page_title", "text", "source_text")) + ) + source_room_id = str(payload.get("source_room_id") or "") or extract_source_room_id( + source_url, + payload.get("url"), + payload.get("page_title"), + payload.get("text"), + payload.get("source_text"), + ) + conn = _connect(db_path) + try: + row = _upsert_event( + conn, + process=process, + mode=str(payload.get("mode") or "browser"), + channel_id=str(payload.get("channel_id") or ""), + channel_name=str(payload.get("channel_name") or ""), + source_url=source_url, + source_room_id=source_room_id, + source_title=str(payload.get("source_title") or payload.get("stream_title") or ""), + payload=payload, + action="browser_telemetry", + note=str(payload.get("url") or ""), + ) + event_id = int(row["id"]) + _insert_sample(conn, event_id, payload, str(payload.get("source") or "chrome_extension")) + _insert_alerts(conn, event_id, payload, str(payload.get("source") or "chrome_extension")) + prune_old_samples(conn) + conn.commit() + return { + "message": "telemetry stored", + "event": _row_to_event(conn.execute("SELECT * FROM youtube_live_events WHERE id = ?", (event_id,)).fetchone()), + } + finally: + conn.close() + + +def finish_active_events(process: str | None = None, *, reason: str = "manual", db_path: Path | None = None) -> dict[str, Any]: + now = time.time() + conn = _connect(db_path) + try: + if process: + rows = conn.execute( + "SELECT * FROM youtube_live_events WHERE ended_at IS NULL AND process = ?", + (_normalize_process(process),), + ).fetchall() + else: + rows = conn.execute("SELECT * FROM youtube_live_events WHERE ended_at IS NULL").fetchall() + closed: list[dict[str, Any]] = [] + for row in rows: + duration = max(0, now - float(row["started_at"])) + conn.execute( + """ + UPDATE youtube_live_events + SET ended_at = ?, duration_seconds = ?, status = 'ended', updated_at = ?, + control_actions_json = ? + WHERE id = ? + """, + (now, int(duration), now, _action_history(row, "finish", now, reason), int(row["id"])), + ) + closed.append(_row_to_event(conn.execute("SELECT * FROM youtube_live_events WHERE id = ?", (int(row["id"]),)).fetchone())) + conn.commit() + return {"message": "events closed", "count": len(closed), "events": closed} + finally: + conn.close() + + +def list_live_events( + *, + status: str = "", + process: str = "", + limit: int = 50, + offset: int = 0, + db_path: Path | None = None, +) -> dict[str, Any]: + clauses: list[str] = [] + params: list[Any] = [] + if status: + if status == "active": + clauses.append("ended_at IS NULL") + else: + clauses.append("status = ?") + params.append(status) + if process: + clauses.append("process = ?") + params.append(_normalize_process(process)) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + safe_limit = max(1, min(int(limit), 200)) + safe_offset = max(0, int(offset)) + conn = _connect(db_path) + try: + rows = conn.execute( + f"SELECT * FROM youtube_live_events {where} ORDER BY updated_at DESC LIMIT ? OFFSET ?", + (*params, safe_limit, safe_offset), + ).fetchall() + total = conn.execute(f"SELECT COUNT(*) FROM youtube_live_events {where}", params).fetchone()[0] + return {"events": [_row_to_event(row) for row in rows], "total": int(total), "limit": safe_limit, "offset": safe_offset} + finally: + conn.close() + + +def get_live_event(event_id: int | str, *, db_path: Path | None = None) -> dict[str, Any]: + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM youtube_live_events WHERE id = ? OR event_uuid = ?", (event_id, str(event_id))).fetchone() + if not row: + return {} + samples = conn.execute( + "SELECT * FROM youtube_live_samples WHERE event_id = ? ORDER BY ts DESC LIMIT 240", + (int(row["id"]),), + ).fetchall() + alerts = conn.execute( + "SELECT * FROM youtube_live_alerts WHERE event_id = ? ORDER BY ts DESC LIMIT 120", + (int(row["id"]),), + ).fetchall() + event = _row_to_event(row) + event["samples"] = [_row_to_sample(item) for item in samples] + event["alerts"] = [_row_to_alert(item) for item in alerts] + event["sample_count"] = int(conn.execute("SELECT COUNT(*) FROM youtube_live_samples WHERE event_id = ?", (int(row["id"]),)).fetchone()[0]) + return event + finally: + conn.close() + + +def youtube_live_summary(*, db_path: Path | None = None) -> dict[str, Any]: + conn = _connect(db_path) + try: + active_rows = conn.execute( + "SELECT * FROM youtube_live_events WHERE ended_at IS NULL ORDER BY updated_at DESC LIMIT 12" + ).fetchall() + latest_rows = conn.execute("SELECT * FROM youtube_live_events ORDER BY updated_at DESC LIMIT 16").fetchall() + alert_rows = conn.execute("SELECT * FROM youtube_live_alerts ORDER BY ts DESC LIMIT 20").fetchall() + total = int(conn.execute("SELECT COUNT(*) FROM youtube_live_events").fetchone()[0]) + ended = int(conn.execute("SELECT COUNT(*) FROM youtube_live_events WHERE ended_at IS NOT NULL").fetchone()[0]) + sample_count = int(conn.execute("SELECT COUNT(*) FROM youtube_live_samples").fetchone()[0]) + total_duration = int(conn.execute("SELECT COALESCE(SUM(duration_seconds),0) FROM youtube_live_events").fetchone()[0] or 0) + active = [_row_to_event(row) for row in active_rows] + latest = [_row_to_event(row) for row in latest_rows] + return { + "active": active, + "latest": latest, + "alerts": [_row_to_alert(row) for row in alert_rows], + "stats": { + "total_events": total, + "ended_events": ended, + "active_events": len(active), + "sample_count": sample_count, + "total_duration_seconds": total_duration, + "viewer_peak": max([int(item.get("viewer_peak") or 0) for item in latest] or [0]), + }, + "capabilities": [ + "event_db", + "event_detail", + "source_room_trace", + "duration_trace", + "browser_extension_telemetry", + "viewer_count_capture", + "live_status_capture", + "popup_alert_capture", + "health_score", + "process_control_audit", + "sample_timeline", + "multi_lane_history", + "tampermonkey_compat", + ], + } + finally: + conn.close() + + +def close_live_event(event_id: int | str, *, reason: str = "manual", db_path: Path | None = None) -> dict[str, Any]: + event = get_live_event(event_id, db_path=db_path) + if not event: + return {} + if event.get("ended_at"): + return {"event": event, "message": "already closed"} + now = time.time() + duration = max(0, now - float(event["started_at"])) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM youtube_live_events WHERE id = ?", (int(event["id"]),)).fetchone() + conn.execute( + """ + UPDATE youtube_live_events + SET ended_at = ?, duration_seconds = ?, status = 'ended', updated_at = ?, + control_actions_json = ? + WHERE id = ? + """, + (now, int(duration), now, _action_history(row, "close", now, reason), int(event["id"])), + ) + conn.commit() + return {"message": "event closed", "event": get_live_event(event_id, db_path=db_path)} + finally: + conn.close() + + +def prune_old_samples(conn: sqlite3.Connection, *, keep_days: int = 14, keep_per_event: int = 2000) -> None: + cutoff = time.time() - max(1, keep_days) * 86400 + conn.execute("DELETE FROM youtube_live_samples WHERE ts < ?", (cutoff,)) + event_ids = [int(row[0]) for row in conn.execute("SELECT id FROM youtube_live_events ORDER BY updated_at DESC LIMIT 200").fetchall()] + for event_id in event_ids: + conn.execute( + """ + DELETE FROM youtube_live_samples + WHERE event_id = ? + AND id NOT IN ( + SELECT id FROM youtube_live_samples WHERE event_id = ? ORDER BY ts DESC LIMIT ? + ) + """, + (event_id, event_id, keep_per_event), + ) diff --git a/web2_youtube_preflight.py b/web2_youtube_preflight.py index 50f5694..71ec7b3 100644 --- a/web2_youtube_preflight.py +++ b/web2_youtube_preflight.py @@ -1,20 +1,150 @@ -"""YouTube 开播前网络预检(参照 sh2 network_preflight,Windows 版仅检 Google 可达)。""" +"""YouTube 开播前网络预检(参照 d2ypp2 network_preflight,Windows/Electron 适配)。""" from __future__ import annotations +import os +from typing import Any, Optional + import httpx -from web2_network import CONNECT_TIMEOUT, GOOGLE_CHECK_URL, READ_TIMEOUT, _latency_get +from web2_network import ( + CONNECT_TIMEOUT, + DOUYIN_CHECK_URL, + GOOGLE_CHECK_URL, + READ_TIMEOUT, + _latency_get, +) +from web2_proxy_config import read_proxy_config + +YOUTUBE_CHECK_URL = "https://www.youtube.com/generate_204" + +_PROBE_TARGETS: tuple[dict[str, str], ...] = ( + {"id": "google", "label": "Google", "url": GOOGLE_CHECK_URL, "role": "global"}, + {"id": "youtube", "label": "YouTube", "url": YOUTUBE_CHECK_URL, "role": "global"}, + {"id": "douyin", "label": "抖音源站", "url": DOUYIN_CHECK_URL, "role": "source"}, +) + + +def _preflight_disabled() -> bool: + raw = os.environ.get("YOUTUBE_NETWORK_PREFLIGHT", "1").strip().lower() + return raw in {"0", "false", "no", "off"} + + +def _global_ok(probes: list[dict[str, Any]]) -> bool: + return any(p.get("ok") for p in probes if p.get("role") == "global") + + +def _douyin_ok(probes: list[dict[str, Any]]) -> bool: + return any(p.get("ok") for p in probes if p.get("id") == "douyin") + + +async def _probe_all(client: httpx.AsyncClient) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for target in _PROBE_TARGETS: + ok, ms, err = await _latency_get(client, target["url"]) + rows.append( + { + **target, + "ok": ok, + "latency_ms": round(ms, 1) if ms >= 0 else None, + "error": err, + } + ) + return rows + + +async def run_youtube_preflight() -> dict[str, Any]: + """返回完整预检报告,供 /youtube/preflight 与开播拦截共用。""" + if _preflight_disabled(): + return { + "ok": True, + "mode": "disabled", + "message": "网络预检已关闭(YOUTUBE_NETWORK_PREFLIGHT=0)", + "checks": {}, + "direct": [], + "proxy": [], + "proxy_config": read_proxy_config(), + } + + timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) + proxy_cfg = read_proxy_config() + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + direct = await _probe_all(client) + + proxy_rows: list[dict[str, Any]] = [] + proxy_http = (proxy_cfg.get("http") or "").strip() + if proxy_cfg.get("enabled") and proxy_http: + try: + async with httpx.AsyncClient( + timeout=timeout, + follow_redirects=True, + proxy=proxy_http, + ) as client: + proxy_rows = await _probe_all(client) + except Exception as e: + proxy_rows = [ + {**t, "ok": False, "latency_ms": None, "error": str(e)} + for t in _PROBE_TARGETS + ] + + global_direct = _global_ok(direct) + global_proxy = _global_ok(proxy_rows) + douyin_direct = _douyin_ok(direct) + + ok = bool(global_direct or global_proxy) + mode = "direct" if global_direct else ("proxy" if global_proxy else "failed") + + checks = { + "google": next((p for p in direct if p.get("id") == "google"), {}).get("ok", False), + "youtube": next((p for p in direct if p.get("id") == "youtube"), {}).get("ok", False), + "douyin": douyin_direct, + "global_direct": global_direct, + "global_proxy": global_proxy, + "proxy_configured": bool(proxy_cfg.get("enabled") and proxy_http), + } + + if ok: + if global_direct: + message = "直连可达 Google/YouTube,可尝试开播" + else: + message = f"直连失败,但录制代理 {proxy_http} 可达 Google/YouTube" + if not douyin_direct: + message += ";抖音源站当前不可达,可能无法拉流" + else: + parts = [] + if not global_direct: + parts.append("Google/YouTube 直连不可达") + if proxy_cfg.get("enabled"): + if not global_proxy: + parts.append("录制代理也未打通 Google/YouTube") + else: + parts.append("未启用录制代理") + if not douyin_direct: + parts.append("抖音源站不可达") + message = ";".join(parts) + + blocking_message: Optional[str] = None + if not ok: + blocking_message = ( + f"{message}。请先在「网络」页检测并配置代理,或在下方「录制代理」中填写 Clash 混合端口后再开播。" + ) + + return { + "ok": ok, + "mode": mode, + "message": message, + "blocking_message": blocking_message, + "checks": checks, + "direct": direct, + "proxy": proxy_rows, + "proxy_config": proxy_cfg, + "douyin_direct_ok": douyin_direct, + } async def youtube_start_preflight_message() -> str | None: - timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: - ok, _, err = await _latency_get(client, GOOGLE_CHECK_URL) - if ok: + result = await run_youtube_preflight() + if result.get("ok"): return None - detail = err or "timeout" - return ( - f"Google 出口不可达({detail}),YouTube 推流可能失败。" - "请先在「网络」页检测并配置代理/TUN,再开始直播。" - ) + return str(result.get("blocking_message") or result.get("message") or "网络预检未通过") diff --git a/web2_youtube_pro_runtime.py b/web2_youtube_pro_runtime.py new file mode 100644 index 0000000..4cc265b --- /dev/null +++ b/web2_youtube_pro_runtime.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Iterable, Optional + +_SAFE_PM2_SEGMENT = re.compile(r"[^a-zA-Z0-9_.-]+") +_YOUTUBE_PRO_PM2_PREFIX = "youtube2__" +_RESERVED_PM2_NAMES = frozenset({"douyin_youtube", "tiktok", "obs", "web"}) +_YOUTUBE_HEADER = re.compile(r"^\s*\[youtube\]\s*$", re.IGNORECASE) +_ANY_HEADER = re.compile(r"^\s*\[[^\]]+\]\s*$") +_KEY_LINE = re.compile(r"^\s*(?:key|stream_key)\s*=", re.IGNORECASE) +_BROKEN_LANE_NAME_RE = re.compile(r"(?:\?|\uff1f){2,}|\ufffd|锟|绾胯矾|çº|è·|Â", re.IGNORECASE) + + +def sanitize_pm2_segment(value: str) -> str: + return _SAFE_PM2_SEGMENT.sub("_", (value or "").strip()) + + +def default_youtube_pro_pm2_name(channel_id: str) -> str: + safe = sanitize_pm2_segment(channel_id) + return f"{_YOUTUBE_PRO_PM2_PREFIX}{safe or 'channel'}" + + +def normalize_youtube_pro_pm2_name(raw_value: str, channel_id: str) -> str: + raw = sanitize_pm2_segment(raw_value) + if not raw or raw in _RESERVED_PM2_NAMES or raw == _YOUTUBE_PRO_PM2_PREFIX: + return default_youtube_pro_pm2_name(channel_id) + if raw == "youtube": + return raw + if raw.startswith(_YOUTUBE_PRO_PM2_PREFIX): + return raw + return default_youtube_pro_pm2_name(raw) + + +def channel_pm2_name(channel: dict[str, Any]) -> str: + return normalize_youtube_pro_pm2_name( + str(channel.get("pm2Name") or channel.get("pm2_name") or "").strip(), + str(channel.get("id") or "").strip(), + ) + + +def is_broken_youtube_pro_lane_name(value: str) -> bool: + s = (value or "").strip() + return not s or s.lower() in {"undefined", "null", "nan"} or bool(_BROKEN_LANE_NAME_RE.search(s)) + + +def clean_youtube_pro_lane_name(value: str, index: int) -> str: + raw = (value or "").strip() + if not is_broken_youtube_pro_lane_name(raw): + return raw + match = re.search(r"\d+", raw) + lane_no = match.group(0) if match else str(max(0, index) + 1) + return f"线路 {lane_no}" + + +def normalize_youtube_pro_channels(channels: Iterable[Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + seen_pm2: set[str] = set() + for index, item in enumerate(channels): + if not isinstance(item, dict): + raise ValueError("each channel must be an object") + row = dict(item) + cid = str(row.get("id", "")).strip() + if not cid: + raise ValueError("channel.id required") + if cid in seen_ids: + raise ValueError(f"duplicate channel.id: {cid}") + seen_ids.add(cid) + + row["id"] = cid + row["name"] = clean_youtube_pro_lane_name(str(row.get("name", "")), index) + pm2 = channel_pm2_name(row) + if not pm2: + raise ValueError(f"channel.pm2Name invalid for {cid}") + if pm2 in _RESERVED_PM2_NAMES: + raise ValueError(f"channel.pm2Name reserved: {pm2}") + if pm2 in seen_pm2: + raise ValueError(f"duplicate channel.pm2Name: {pm2}") + seen_pm2.add(pm2) + row["pm2Name"] = pm2 + + for key in ("streamKeySuffix", "streamKey", "urlLines", "notes"): + if key in row and row[key] is not None and not isinstance(row[key], str): + row[key] = str(row[key]) + + out.append(row) + return out + + +def youtube_pro_channel_for_pm2(channels: Iterable[dict[str, Any]], pm2: str) -> Optional[dict[str, Any]]: + target = (pm2 or "").strip() + if not target: + return None + for channel in channels: + if not isinstance(channel, dict): + continue + if channel_pm2_name(channel) == target: + return dict(channel) + return None + + +def channel_id_for_pm2_name(channels: Iterable[dict[str, Any]], pm2: str) -> str: + ch = youtube_pro_channel_for_pm2(channels, pm2) + return str(ch.get("id") or "").strip() if ch else "" + + +def _read_text_if_file(path: Path) -> Optional[str]: + try: + if path.is_file(): + return path.read_text(encoding="utf-8") + except OSError: + return None + return None + + +def _normalize_url_lines(text: str) -> str: + raw = (text or "").replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff") + if not raw.strip(): + return "" + lines = [line.rstrip() for line in raw.split("\n")] + while lines and not lines[-1].strip(): + lines.pop() + body = "\n".join(lines).strip() + return f"{body}\n" if body else "" + + +def _has_noncomment_url(text: str) -> bool: + return any(line.strip() and not line.strip().startswith("#") for line in (text or "").splitlines()) + + +def _extract_stream_key(text: str) -> str: + in_youtube = False + for raw_line in (text or "").splitlines(): + line = raw_line.strip() + if _YOUTUBE_HEADER.match(line): + in_youtube = True + continue + if _ANY_HEADER.match(line): + in_youtube = False + continue + if not in_youtube: + continue + if not line or line.startswith("#") or line.startswith(";"): + continue + if _KEY_LINE.match(line): + _, _, value = raw_line.partition("=") + return value.strip().split("#", 1)[0].split(";", 1)[0].strip() + return "" + + +def ensure_youtube_pro_channel_runtime_files( + config_dir: Path, + process: str, + channels: Iterable[dict[str, Any]], +) -> tuple[bool, list[str]]: + channel = youtube_pro_channel_for_pm2(channels, process) + if channel is None: + return False, [] + + cid = str(channel.get("id") or "").strip() + if not cid: + return False, [] + + pm2 = channel_pm2_name(channel) + from web2_relay_pro import ( + materialize_relay_channel_youtube_ini, + url_config_path_for_channel, + youtube_ini_path_for_channel, + ) + + ok, msg = materialize_relay_channel_youtube_ini(config_dir, cid) + if not ok: + return True, [f"{pm2}: {msg}"] + + url_path = url_config_path_for_channel(config_dir, cid) + seed_url = _read_text_if_file(url_path) or "" + effective_url = _normalize_url_lines(str(channel.get("urlLines") or "")) or _normalize_url_lines(seed_url) + douyin = str(channel.get("douyinUrl") or channel.get("douyin_url") or "").strip() + if not _has_noncomment_url(effective_url) and douyin: + effective_url = _normalize_url_lines(douyin) + + errors: list[str] = [] + yt_path = youtube_ini_path_for_channel(config_dir, cid) + youtube_text = _read_text_if_file(yt_path) or "" + if not _extract_stream_key(youtube_text): + errors.append(f"{pm2}: stream key missing") + if not _has_noncomment_url(effective_url): + errors.append(f"{pm2}: source URL missing") + if errors: + return True, errors + + try: + url_path.parent.mkdir(parents=True, exist_ok=True) + url_path.write_text(effective_url, encoding="utf-8") + except OSError as exc: + return True, [f"{pm2}: failed to write url config ({exc})"] + return True, [] + + +def validate_youtube_runtime_files( + config_dir: Path, + process: str, + channels: Iterable[dict[str, Any]], +) -> list[str]: + handled, errors = ensure_youtube_pro_channel_runtime_files(config_dir, process, channels) + if handled: + return errors + + youtube_path = config_dir / "youtube.ini" + url_path = config_dir / "URL_config.ini" + youtube_text = _read_text_if_file(youtube_path) or "" + url_text = _read_text_if_file(url_path) or "" + + fallback_errors: list[str] = [] + if not _extract_stream_key(youtube_text): + fallback_errors.append(f"{process}: stream key missing") + if not _has_noncomment_url(url_text): + fallback_errors.append(f"{process}: source URL missing") + return fallback_errors + + +def sync_all_youtube_pro_runtime_files( + config_dir: Path, + channels: Iterable[dict[str, Any]], +) -> list[str]: + errors: list[str] = [] + seen: set[str] = set() + for channel in channels: + if not isinstance(channel, dict): + continue + pm2 = channel_pm2_name(channel) + if not pm2 or pm2 in seen: + continue + seen.add(pm2) + _, row_errors = ensure_youtube_pro_channel_runtime_files(config_dir, pm2, channels) + errors.extend(row_errors) + return errors diff --git a/web2_youtube_relay_heartbeat.py b/web2_youtube_relay_heartbeat.py new file mode 100644 index 0000000..faad3c4 --- /dev/null +++ b/web2_youtube_relay_heartbeat.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + + +def _runtime_dir(base_dir: str | Path | None = None) -> Path: + root = Path(base_dir or os.environ.get("LIVE_PROJECT_ROOT") or Path(__file__).resolve().parent) + path = root / "runtime" / "youtube_relay" + path.mkdir(parents=True, exist_ok=True) + return path + + +def heartbeat_path(pm2: str, *, base_dir: str | Path | None = None) -> Path: + safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in (pm2 or "youtube").strip()) or "youtube" + return _runtime_dir(base_dir) / f"{safe}.json" + + +def write_relay_heartbeat( + pm2: str, + payload: dict[str, Any], + *, + base_dir: str | Path | None = None, +) -> None: + data = dict(payload) + data["updated_at"] = time.time() + data["pm2"] = (pm2 or "").strip() or "youtube" + path = heartbeat_path(data["pm2"], base_dir=base_dir) + tmp = path.with_suffix(".json.tmp") + try: + tmp.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + tmp.replace(path) + except OSError: + pass + + +def read_relay_heartbeat(pm2: str, *, base_dir: str | Path | None = None) -> dict[str, Any]: + path = heartbeat_path(pm2, base_dir=base_dir) + if not path.is_file(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def clear_relay_heartbeat(pm2: str, *, base_dir: str | Path | None = None) -> None: + path = heartbeat_path(pm2, base_dir=base_dir) + try: + if path.is_file(): + path.unlink() + except OSError: + pass diff --git a/web2_youtube_relay_slot.py b/web2_youtube_relay_slot.py new file mode 100644 index 0000000..f8cf558 --- /dev/null +++ b/web2_youtube_relay_slot.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +"""YouTube 单槽推流协调:与 youtube2 / douyin_youtube_ffplay 语义对齐。""" + +import threading +import time +from typing import Any + +from web2_youtube_relay_heartbeat import clear_relay_heartbeat, read_relay_heartbeat, write_relay_heartbeat + +_slot_lock = threading.Lock() +_slot_owner: str | None = None +_CLAIM_GRACE_SECONDS = 15.0 + + +def _heartbeat_claim_fresh(hb: dict[str, Any]) -> bool: + if not hb: + return False + state = str(hb.get("state") or "").strip().lower() + if state not in {"claiming", "starting", "streaming", "source_failover"}: + return False + updated = float(hb.get("updated_at") or 0) + return updated > 0 and (time.time() - updated) <= _CLAIM_GRACE_SECONDS + 90.0 + + +def prune_orphan_relay_recording( + recording: set[str], + recording_time_list: dict[str, Any], + *, + pm2: str, + base_dir: str, +) -> None: + """仅清理孤儿占槽:recording 非空且无有效 relay heartbeat。""" + if not recording: + return + hb = read_relay_heartbeat(pm2, base_dir=base_dir) + if _heartbeat_claim_fresh(hb): + return + global _slot_owner + with _slot_lock: + recording.clear() + recording_time_list.clear() + _slot_owner = None + clear_relay_heartbeat(pm2, base_dir=base_dir) + + +def relay_slot_blocks(record_name: str, recording: set[str]) -> bool: + return any(name for name in recording if name and name != record_name) + + +def claim_relay_slot( + record_name: str, + *, + pm2: str, + base_dir: str, + anchor_name: str = "", +) -> bool: + global _slot_owner + with _slot_lock: + if _slot_owner is not None and _slot_owner != record_name: + return False + _slot_owner = record_name + write_relay_heartbeat( + pm2, + { + "state": "claiming", + "record_name": record_name, + "anchor_name": anchor_name, + "claimed_at": time.time(), + }, + base_dir=base_dir, + ) + return True + + +def release_relay_slot(record_name: str, *, pm2: str = "", base_dir: str = "") -> None: + global _slot_owner + with _slot_lock: + if _slot_owner == record_name: + _slot_owner = None + if pm2 and base_dir: + hb = read_relay_heartbeat(pm2, base_dir=base_dir) + if str(hb.get("record_name") or "") == record_name: + clear_relay_heartbeat(pm2, base_dir=base_dir) + + +def _flv_is_h265(url: str) -> bool: + lower = (url or "").lower() + if "codec=h265" in lower or "codec=hevc" in lower: + return True + try: + from src import utils + + codec = utils.get_query_params(url, "codec") + if codec and str(codec[0]).lower() in {"h265", "hevc"}: + return True + except Exception: + pass + return False + + +def youtube_stream_fallback_urls(port_info: dict[str, Any], primary: str) -> list[str]: + primary_text = (primary or "").strip() + out: list[str] = [] + seen: set[str] = set() + for key in ("flv_url", "m3u8_url"): + url = str(port_info.get(key) or "").strip() + if not url or url == primary_text or url in seen: + continue + if not url.startswith("http"): + continue + if key == "flv_url" and _flv_is_h265(url): + continue + seen.add(url) + out.append(url) + return out diff --git a/web2_youtube_runtime.py b/web2_youtube_runtime.py new file mode 100644 index 0000000..2ebdfe8 --- /dev/null +++ b/web2_youtube_runtime.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +ARM_MACHINES = frozenset({"aarch64", "arm64", "armv7l", "armv8l"}) + + +def resolve_live_video_encoder( + requested_encoder: str, + ffmpeg_encoders_text: str, + machine: str, + *, + has_mpp_service: bool = False, + has_render_node: bool = False, + has_v4l2_device: bool = False, + usable_encoders: set[str] | None = None, +) -> tuple[str, str]: + requested = (requested_encoder or "").strip() + blob = ffmpeg_encoders_text or "" + machine_norm = (machine or "").strip().lower() + is_arm = machine_norm in ARM_MACHINES + + def has_encoder(name: str) -> bool: + return bool(name) and name in blob + + def is_usable(name: str) -> bool: + return usable_encoders is None or name in usable_encoders + + arm_candidates: list[str] = [] + if has_encoder("h264_rkmpp") and has_mpp_service and is_usable("h264_rkmpp"): + arm_candidates.append("h264_rkmpp") + if has_encoder("h264_v4l2m2m") and has_v4l2_device and is_usable("h264_v4l2m2m"): + arm_candidates.append("h264_v4l2m2m") + + if is_arm: + if requested == "h264_vaapi": + if arm_candidates: + return arm_candidates[0], f"LIVE_VIDEO_ENCODER=h264_vaapi is not stable on ARM, fallback to {arm_candidates[0]}" + return "", "LIVE_VIDEO_ENCODER=h264_vaapi is not supported on ARM, fallback to libx264" + if requested == "h264_v4l2m2m" and not has_v4l2_device: + return "", "LIVE_VIDEO_ENCODER=h264_v4l2m2m requires /dev/video* on this ARM host, fallback to libx264" + if requested and has_encoder(requested) and usable_encoders is not None and requested not in usable_encoders: + if arm_candidates: + return arm_candidates[0], f"LIVE_VIDEO_ENCODER={requested} failed encoder smoke test, fallback to {arm_candidates[0]}" + return "", f"LIVE_VIDEO_ENCODER={requested} failed encoder smoke test, fallback to libx264" + if requested: + if requested in arm_candidates: + return requested, "" + if requested.startswith("h264_") and has_encoder(requested) and is_usable(requested): + return requested, "" + if arm_candidates: + return arm_candidates[0], f"LIVE_VIDEO_ENCODER={requested} is unavailable on this ARM host, fallback to {arm_candidates[0]}" + return "", f"LIVE_VIDEO_ENCODER={requested} is unavailable on this ARM host, fallback to libx264" + if arm_candidates: + return arm_candidates[0], "" + return "", "" + + if requested == "h264_qsv": + if has_encoder("h264_vaapi") and has_render_node and is_usable("h264_vaapi"): + return "h264_vaapi", "LIVE_VIDEO_ENCODER=h264_qsv is not enabled in this Linux stack yet, fallback to h264_vaapi" + return "", "LIVE_VIDEO_ENCODER=h264_qsv is not enabled in this Linux stack yet, fallback to libx264" + if requested == "h264_vaapi" and not has_render_node: + return "", "LIVE_VIDEO_ENCODER=h264_vaapi requires /dev/dri/renderD*; fallback to libx264" + if requested and has_encoder(requested) and usable_encoders is not None and requested not in usable_encoders: + return "", f"LIVE_VIDEO_ENCODER={requested} failed encoder smoke test, fallback to libx264" + if requested and requested.startswith("h264_") and has_encoder(requested) and is_usable(requested): + return requested, "" + if requested: + return "", f"LIVE_VIDEO_ENCODER={requested} is unavailable on this host, fallback to libx264" + return "", "" diff --git a/youtube2.py b/youtube2.py index a5dd09b..94eb31c 100644 --- a/youtube2.py +++ b/youtube2.py @@ -37,6 +37,13 @@ from backup.msg_push import ( from ffmpeg_install import ( check_ffmpeg, ffmpeg_path, current_env_path ) +from web2_youtube_relay_slot import ( + claim_relay_slot, + prune_orphan_relay_recording, + relay_slot_blocks, + release_relay_slot, +) +from web2_youtube_relay_heartbeat import clear_relay_heartbeat @@ -70,6 +77,12 @@ start_display_time = datetime.datetime.now() global_proxy = False recording_time_list = {} script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] + + +def _live_pm2_name() -> str: + return (os.environ.get("LIVE_PM2_PROCESS_NAME") or os.environ.get("PM2_SERVICE_NAME") or "").strip() + + config_file = f'{script_path}/config/config.ini' _RELAY_CH = os.environ.get("RELAY_CHANNEL_ID", "").strip() if _RELAY_CH: @@ -92,6 +105,10 @@ os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path def signal_handler(_signal, _frame): + try: + clear_relay_heartbeat(_live_pm2_name() or "youtube", base_dir=script_path) + except Exception: + pass sys.exit(0) @@ -472,6 +489,14 @@ def run_script(command: str) -> None: def clear_record_info(record_name: str, record_url: str) -> None: global monitoring recording.discard(record_name) + try: + release_relay_slot( + record_name, + pm2=_live_pm2_name() or "youtube", + base_dir=script_path, + ) + except Exception: + pass if record_url in url_comments and record_url in running_list: running_list.remove(record_url) monitoring -= 1 @@ -1195,11 +1220,14 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: else: content = f"\r{record_name} 正在直播中..." print(content) - # 检查是否已有直播在录制 - if len(recording) > 0 : - # 如果已有直播在录制,跳过这次录制(主播在线) - # return - continue #如果在循环中 + prune_orphan_relay_recording( + recording, + recording_time_list, + pm2=_live_pm2_name() or "youtube", + base_dir=script_path, + ) + if relay_slot_blocks(record_name, recording): + continue if live_status_push and not start_pushed: if begin_show_push: @@ -1311,7 +1339,15 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: if _ff_proxy: ffmpeg_command.insert(1, "-http_proxy") ffmpeg_command.insert(2, _ff_proxy) - + + if not claim_relay_slot( + record_name, + pm2=_live_pm2_name() or "youtube", + base_dir=script_path, + anchor_name=anchor_name, + ): + continue + recording.add(record_name) start_record_time = datetime.datetime.now() recording_time_list[record_name] = [start_record_time, record_quality_zh] @@ -1635,8 +1671,13 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: import sys sys.path.insert(0, './dist') from douyin_youtube_ffplay import start_douyin_youtube_ffplay + from web2_youtube_relay_slot import youtube_stream_fallback_urls - # 调用(与 douyin_srs_ffplay 调用方式完全一致) + _yt_ini = os.environ.get( + "YOUTUBE_INI_PATH", + f"{script_path}/config/youtube.ini", + ) + _fallback = youtube_stream_fallback_urls(port_info, real_url) start_douyin_youtube_ffplay( title=title_in_name, anchor_name=anchor_name, @@ -1646,9 +1687,12 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: recording=recording, recording_time_list=recording_time_list, running_list=running_list, - monitoring=monitoring, # 假设外部 monitoring 是 list,如 [count] + monitoring=monitoring, clear_record_info=clear_record_info, - color_obj=color_obj + color_obj=color_obj, + record_url=record_url, + config_file=_yt_ini, + fallback_urls=_fallback, ) count_time = time.time()