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