Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
440 lines
15 KiB
Python
440 lines
15 KiB
Python
"""
|
||
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"
|