255 lines
8.4 KiB
Python
255 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Optional
|
|
|
|
from src.youtube_config_paths import resolve_youtube_style_config_paths
|
|
|
|
_SAFE_PM2_SEGMENT = re.compile(r"[^a-zA-Z0-9_.-]+")
|
|
_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)
|
|
_RESERVED_PM2_NAMES = frozenset({"tiktok", "obs", "web"})
|
|
_DEFAULT_YOUTUBE_TEMPLATE = "[youtube]\nkey = \nrtmps = 是\nfast_audio = 0\n"
|
|
|
|
|
|
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"youtube2__{safe or 'channel'}"
|
|
|
|
|
|
def channel_pm2_name(channel: dict[str, Any]) -> str:
|
|
raw = sanitize_pm2_segment(str(channel.get("pm2Name", "")).strip())
|
|
if raw:
|
|
return raw
|
|
return default_youtube_pro_pm2_name(str(channel.get("id", "")).strip())
|
|
|
|
|
|
def managed_url_config_relpath_for_pm2(pm2: str) -> str:
|
|
return f"URL_config.{sanitize_pm2_segment(pm2)}.ini"
|
|
|
|
|
|
def managed_youtube_ini_relpath_for_pm2(pm2: str) -> str:
|
|
return f"youtube.{sanitize_pm2_segment(pm2)}.ini"
|
|
|
|
|
|
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 item in 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"] = str(row.get("name", "")).strip() or cid
|
|
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 _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 _rtmps_value_for_key(stream_key: str) -> str:
|
|
return "否" if (stream_key or "").strip().lower().startswith("rtmp://") else "是"
|
|
|
|
|
|
def _upsert_youtube_key(text: str, stream_key: str) -> str:
|
|
source = (text or "").lstrip("\ufeff")
|
|
lines = source.splitlines()
|
|
if not lines:
|
|
lines = _DEFAULT_YOUTUBE_TEMPLATE.splitlines()
|
|
out: list[str] = []
|
|
in_youtube = False
|
|
inserted_key = False
|
|
saw_youtube_header = False
|
|
saw_rtmps = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if _YOUTUBE_HEADER.match(stripped):
|
|
saw_youtube_header = True
|
|
in_youtube = True
|
|
out.append(line)
|
|
continue
|
|
if in_youtube and _ANY_HEADER.match(stripped):
|
|
if not inserted_key:
|
|
out.append(f"key = {stream_key}")
|
|
inserted_key = True
|
|
if not saw_rtmps:
|
|
out.append(f"rtmps = {_rtmps_value_for_key(stream_key)}")
|
|
saw_rtmps = True
|
|
in_youtube = False
|
|
if in_youtube and stripped.lower().startswith("rtmps"):
|
|
saw_rtmps = True
|
|
if in_youtube and _KEY_LINE.match(stripped):
|
|
if not inserted_key:
|
|
out.append(f"key = {stream_key}")
|
|
inserted_key = True
|
|
continue
|
|
out.append(line)
|
|
if saw_youtube_header:
|
|
if not inserted_key:
|
|
out.append(f"key = {stream_key}")
|
|
if not saw_rtmps:
|
|
out.append(f"rtmps = {_rtmps_value_for_key(stream_key)}")
|
|
body = "\n".join(out).strip("\n")
|
|
return f"{body}\n"
|
|
prefix = [
|
|
"[youtube]",
|
|
f"key = {stream_key}",
|
|
f"rtmps = {_rtmps_value_for_key(stream_key)}",
|
|
"fast_audio = 0",
|
|
"",
|
|
]
|
|
tail = source.strip("\n")
|
|
if tail:
|
|
return "\n".join(prefix + [tail]) + "\n"
|
|
return "\n".join(prefix) + "\n"
|
|
|
|
|
|
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, []
|
|
|
|
pm2 = channel_pm2_name(channel)
|
|
youtube_path = config_dir / managed_youtube_ini_relpath_for_pm2(pm2)
|
|
url_path = config_dir / managed_url_config_relpath_for_pm2(pm2)
|
|
legacy_youtube = config_dir / "youtube.ini"
|
|
legacy_url = config_dir / "URL_config.ini"
|
|
|
|
seed_youtube = (
|
|
_read_text_if_file(youtube_path)
|
|
or _read_text_if_file(legacy_youtube)
|
|
or _DEFAULT_YOUTUBE_TEMPLATE
|
|
)
|
|
seed_url = _read_text_if_file(url_path) or _read_text_if_file(legacy_url) or ""
|
|
|
|
effective_youtube = seed_youtube
|
|
stream_key = str(channel.get("streamKey", "") or "").strip()
|
|
if stream_key:
|
|
effective_youtube = _upsert_youtube_key(seed_youtube, stream_key)
|
|
|
|
effective_url = _normalize_url_lines(str(channel.get("urlLines", "") or "")) or _normalize_url_lines(seed_url)
|
|
|
|
errors: list[str] = []
|
|
if not _extract_stream_key(effective_youtube):
|
|
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
|
|
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
youtube_path.write_text(effective_youtube, encoding="utf-8")
|
|
url_path.write_text(effective_url, encoding="utf-8")
|
|
except OSError as exc:
|
|
return True, [f"{pm2}: failed to write config ({exc})"]
|
|
return True, []
|
|
|
|
|
|
def validate_youtube_runtime_files(
|
|
base_dir: Path,
|
|
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_raw, url_path_raw = resolve_youtube_style_config_paths(base_dir, process)
|
|
youtube_text = _read_text_if_file(Path(youtube_path_raw)) or ""
|
|
url_text = _read_text_if_file(Path(url_path_raw)) 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
|