""" Pro 多路转播:每频道独立 URL 配置文件、跨频道抖音地址去重、同步到 legacy youtube.ini + URL_config.ini。 """ from __future__ import annotations import json import re import time from pathlib import Path from typing import Any, Callable, Awaitable # 与 web2_client_overview 一致的去重键 def _norm_douyin_url(url: str) -> str: u = (url or "").strip() if not u: return "" u = re.sub(r"#.*$", "", u).strip() return u.rstrip("/").lower() def _norm_stream_key(key: str) -> str: k = (key or "").strip() if not k: return "" if k.startswith("rtmp://") or k.startswith("rtmps://"): parts = k.rstrip("/").split("/") k = parts[-1] if parts else k return k.strip().lower() def relay_pro_dir(config_dir: Path) -> Path: p = config_dir / "relay_pro" p.mkdir(parents=True, exist_ok=True) return p def _safe_channel_file_id(channel_id: str) -> str: s = re.sub(r"[^a-zA-Z0-9_-]", "_", (channel_id or "").strip()) return s or "default" def url_config_path_for_channel(config_dir: Path, channel_id: str) -> Path: return relay_pro_dir(config_dir) / f"url_config.{_safe_channel_file_id(channel_id)}.ini" def channels_json_path(config_dir: Path) -> Path: return config_dir / "youtube_channels.json" def ensure_default_relay_config(config_dir: Path) -> None: """若缺少 youtube_channels.json 或 channels 为空,则写入默认 ch_default 与 url_config(便于独立运行 uvicorn)。""" config_dir = Path(config_dir) config_dir.mkdir(parents=True, exist_ok=True) relay_pro_dir(config_dir) p = channels_json_path(config_dir) need_write = False if not p.exists(): need_write = True else: try: raw = json.loads(p.read_text(encoding="utf-8")) except Exception: need_write = True raw = None else: arr = raw.get("channels") if isinstance(raw, dict) else None if not isinstance(arr, list) or len(arr) == 0: need_write = True if not need_write: return default_doc: dict[str, Any] = { "version": 1, "autoManaged": True, "channels": [ { "id": "ch_default", "name": "默认线路", "douyinUrl": "", "youtubeKey": "", } ], } p.write_text(json.dumps(default_doc, ensure_ascii=False, indent=2), encoding="utf-8") uc = url_config_path_for_channel(config_dir, "ch_default") if not uc.exists(): uc.write_text( "# 软件已自动创建。每行一个抖音直播间地址,例如:\n# https://live.douyin.com/123456789\n\n", encoding="utf-8", ) def load_channels_document(config_dir: Path) -> tuple[dict[str, Any] | None, list[str]]: p = channels_json_path(config_dir) if not p.exists(): return None, ["缺少 config/youtube_channels.json,请先添加(见 youtube_channels.example.json)"] try: raw = json.loads(p.read_text(encoding="utf-8")) except Exception as e: return None, [f"youtube_channels.json 解析失败: {e}"] if not isinstance(raw, dict): return None, ["youtube_channels.json 格式错误"] arr = raw.get("channels") if not isinstance(arr, list): return None, ["youtube_channels.json 缺少 channels 数组"] return raw, [] def ensure_channel_ids(config_dir: Path, raw: dict[str, Any]) -> bool: """为缺 id 的条目补 ch_0, ch_1 并写回文件。返回是否写回。""" arr = raw.get("channels") if not isinstance(arr, list): return False changed = False for i, item in enumerate(arr): if not isinstance(item, dict): continue cid = str(item.get("id") or "").strip() if not cid: item["id"] = f"ch_{i}" changed = True if changed: channels_json_path(config_dir).write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") return changed def list_channels_for_pro(config_dir: Path) -> dict[str, Any]: doc, errs = load_channels_document(config_dir) if doc is None: return {"ok": False, "error": errs[0] if errs else "未知错误", "channels": []} ensure_channel_ids(config_dir, doc) doc, _ = load_channels_document(config_dir) assert doc is not None arr = doc.get("channels") out: list[dict[str, Any]] = [] if isinstance(arr, list): for item in arr: if not isinstance(item, dict): continue cid = str(item.get("id") or "").strip() name = str(item.get("name") or "").strip() or "未命名" durl = str(item.get("douyinUrl") or item.get("douyin_url") or "").strip() key = str(item.get("youtubeKey") or item.get("key") or "").strip() if not cid: continue kp = _mask_key_preview(key) out.append( { "id": cid, "name": name, "douyinUrl": durl, "keyPreview": kp, } ) return {"ok": True, "channels": out} def _mask_key_preview(key: str) -> str: k = (key or "").strip() if not k: return "" if len(k) <= 8: return "****" return f"{k[:4]}…{k[-4:]}" def get_channel_by_id(config_dir: Path, channel_id: str) -> dict[str, Any] | None: doc, _ = load_channels_document(config_dir) if doc is None: return None arr = doc.get("channels") if not isinstance(arr, list): return None for item in arr: if not isinstance(item, dict): continue if str(item.get("id") or "").strip() == channel_id: return item return None def get_channel_detail(config_dir: Path, channel_id: str) -> dict[str, Any] | None: ch = get_channel_by_id(config_dir, channel_id) if not ch: return None return { "id": str(ch.get("id") or "").strip(), "name": str(ch.get("name") or "").strip() or "未命名", "douyinUrl": str(ch.get("douyinUrl") or ch.get("douyin_url") or "").strip(), "youtubeKey": str(ch.get("youtubeKey") or ch.get("key") or "").strip(), } def update_channel_key(config_dir: Path, channel_id: str, new_key: str) -> tuple[bool, str]: doc, errs = load_channels_document(config_dir) if doc is None: return False, errs[0] if errs else "无法读取配置" arr = doc.get("channels") if not isinstance(arr, list): return False, "channels 无效" nk = _norm_stream_key(new_key) if not nk: return False, "密钥不能为空" for item in arr: if not isinstance(item, dict): continue oid = str(item.get("id") or "").strip() if oid == channel_id: continue ok = _norm_stream_key(str(item.get("youtubeKey") or item.get("key") or "")) if ok and ok == nk: return False, "禁止多条线路共用同一 YouTube 串流密钥" for item in arr: if not isinstance(item, dict): continue if str(item.get("id") or "").strip() != channel_id: continue item["youtubeKey"] = new_key.strip() channels_json_path(config_dir).write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") return True, "已保存" return False, "未找到该频道" def extract_url_lines_from_ini(text: str) -> list[str]: lines: list[str] = [] 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) return lines def collect_normalized_urls_from_all_other_channels( config_dir: Path, exclude_channel_id: str ) -> dict[str, set[str]]: """channel_id -> set of normalized douyin urls from their url_config file.""" doc, _ = load_channels_document(config_dir) out: dict[str, set[str]] = {} if doc is None: return out arr = doc.get("channels") if not isinstance(arr, list): return out for item in arr: if not isinstance(item, dict): continue cid = str(item.get("id") or "").strip() if not cid or cid == exclude_channel_id: continue p = url_config_path_for_channel(config_dir, cid) if not p.exists(): out[cid] = set() continue try: text = p.read_text(encoding="utf-8", errors="ignore") except Exception: out[cid] = set() continue s: set[str] = set() for line in extract_url_lines_from_ini(text): nd = _norm_douyin_url(line) if nd: s.add(nd) out[cid] = s return out def validate_url_config_no_cross_duplicate( config_dir: Path, channel_id: str, new_content: str ) -> list[str]: """保存本频道 URL 列表时,与其它频道的 URL 去重(禁止同一抖音直播间出现在多条线路)。""" errors: list[str] = [] mine: set[str] = set() for line in extract_url_lines_from_ini(new_content): nd = _norm_douyin_url(line) if nd: mine.add(nd) others = collect_normalized_urls_from_all_other_channels(config_dir, channel_id) for ocid, oset in others.items(): inter = mine & oset if inter: errors.append( f"与频道「{ocid}」的地址列表冲突:以下抖音链接已出现在其它线路(禁止重复):" + ";".join(list(inter)[:5]) ) return errors def read_url_config_for_channel(config_dir: Path, channel_id: str) -> str: p = url_config_path_for_channel(config_dir, channel_id) if not p.exists(): return "# 每行一个抖音直播间地址\n" try: return p.read_text(encoding="utf-8") except Exception: return "# 读取失败\n" def write_url_config_for_channel(config_dir: Path, channel_id: str, content: str) -> tuple[bool, str]: errs = validate_url_config_no_cross_duplicate(config_dir, channel_id, content) if errs: return False, errs[0] p = url_config_path_for_channel(config_dir, channel_id) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") return True, "保存成功" def write_youtube_ini_single_key(config_dir: Path, key: str) -> None: """写入单路 youtube.ini([youtube] key=)。""" lines = ["[youtube]", f"key = {key.strip()}", ""] (config_dir / "youtube.ini").write_text("\n".join(lines), encoding="utf-8") def sync_channel_to_legacy_files(config_dir: Path, channel_id: str) -> tuple[bool, str]: """将选中频道的密钥与 URL 列表同步到 youtube.ini + URL_config.ini,供当前单进程 youtube 脚本使用。""" d = get_channel_detail(config_dir, channel_id) if not d: return False, "频道不存在" key = d.get("youtubeKey") or "" if not str(key).strip(): return False, "该频道未配置 youtubeKey" url_path = url_config_path_for_channel(config_dir, channel_id) if not url_path.exists(): return False, f"缺少该频道的地址文件:{url_path.name}" try: url_body = url_path.read_text(encoding="utf-8") except Exception as e: return False, f"读取地址文件失败: {e}" write_youtube_ini_single_key(config_dir, str(key)) (config_dir / "URL_config.ini").write_text(url_body, encoding="utf-8") return True, "已同步到 youtube.ini 与 URL_config.ini" def parse_relay_meta_lines(log_text: str) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for line in log_text.splitlines(): if "[RELAY_META]" not in line: continue idx = line.find("{") if idx < 0: continue try: j = json.loads(line[idx:]) if isinstance(j, dict): out.append(j) except Exception: continue return out async def build_relay_live_status( config_dir: Path, base_dir: Path, get_process_info: Callable[[str], Awaitable[dict[str, Any]]], read_log_tail: Callable[[str, int], Awaitable[str]], youtube_pm2_name: str, ) -> dict[str, Any]: """工作台 Pro:解析日志 RELAY_META + 进程状态,匹配 youtube_channels.json 频道名。""" ch_doc = list_channels_for_pro(config_dir) id_to_name = {c["id"]: c["name"] for c in ch_doc.get("channels") or []} try: info = await get_process_info(youtube_pm2_name) except Exception: info = {"process_status": "unknown", "out_path": "", "err_path": ""} st = str(info.get("process_status") or "unknown") online = st == "online" out_path = str(info.get("out_path") or "") err_path = str(info.get("err_path") or "") recent = "" try: if out_path: recent += await read_log_tail(out_path, 400) if err_path: recent += "\n" + await read_log_tail(err_path, 400) except Exception: recent = recent or "" metas = parse_relay_meta_lines(recent) last = metas[-1] if metas else None rows: list[dict[str, Any]] = [] suffix = str(last.get("youtubeKeySuffix") or "").strip().lower() if last else "" matched_id, matched_name = _match_channel_by_key_suffix(config_dir, suffix) if last: ts = last.get("ts") duration_sec: float | None = None if isinstance(ts, (int, float)): duration_sec = max(0.0, time.time() - float(ts)) rows.append( { "channelId": matched_id or None, "channelName": matched_name or id_to_name.get(matched_id, "") or "当前推流", "anchor": last.get("anchor"), "douyinHint": last.get("douyinHint"), "streamTitle": last.get("streamTitle"), "youtubeKeySuffix": last.get("youtubeKeySuffix"), "durationSec": duration_sec, "processOnline": online, } ) return { "youtubeProcess": youtube_pm2_name, "processStatus": st, "processOnline": online, "relayRows": rows, "lastRelayMeta": last, } def _match_channel_by_key_suffix(config_dir: Path, suffix: str) -> tuple[str, str]: """用 RELAY_META 的 youtubeKeySuffix 与 youtube_channels.json 中密钥比对。""" if not suffix: return "", "" doc, _ = load_channels_document(config_dir) if not doc or not isinstance(doc.get("channels"), list): return "", "" for item in doc["channels"]: if not isinstance(item, dict): continue k = str(item.get("youtubeKey") or item.get("key") or "").strip() if not k: continue if k.startswith("rtmp://") or k.startswith("rtmps://"): parts = k.rstrip("/").split("/") k = parts[-1] if parts else k tail = k[-8:].lower() if len(k) >= 8 else k.lower() if suffix == tail or (len(suffix) >= 4 and tail.endswith(suffix)): return str(item.get("id") or ""), str(item.get("name") or "") return "", "" def first_youtube_pm2_name(script_entries: list[dict[str, Any]]) -> str: for e in script_entries: script = str(e.get("script") or "") if script.endswith(".py") and Path(script).stem.startswith("youtube"): return Path(script).stem return "youtube2"