Files
gitlab-instance-0a899031_sh/d2ypp2/src/youtube_pro_runtime.py
root 07aa02bca6 s
2026-05-17 04:31:59 +00:00

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