Files
gitlab-instance-0a899031_d2…/web2_relay_pro.py
eric b77a8cadb2
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
'init'
2026-03-24 14:46:41 -05:00

440 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"