"""Validated configuration for Controller v2.""" from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from typing import Any, Mapping import yaml from .contracts import SLOT_IDS, Topics BASE_DIR = Path(__file__).resolve().parent.parent DEFAULT_CONFIG_PATH = BASE_DIR / "config.yaml" class ConfigError(ValueError): """Raised when Controller configuration is invalid.""" @dataclass(frozen=True) class MqttConfig: host: str = "10.40.0.2" port: int = 1883 username: str | None = None password: str | None = None client_id: str = "yt-controller" keepalive_seconds: int = 30 reconnect_min_seconds: int = 1 reconnect_max_seconds: int = 30 command_queue_size: int = 512 publish_ack_timeout_seconds: float = 2.0 @dataclass(frozen=True) class StorageConfig: media_base: Path = Path("/mnt/Media/YTPlayer") data_base: Path = Path("/controller-data") database_name: str = "controller.sqlite3" legacy_yaml_name: str = "playlists.yaml" retention_seconds: int = 48 * 3600 expiry_interval_seconds: float = 60.0 uncertain_review_after_seconds: float = 15 * 60.0 @property def database_path(self) -> Path: return self.data_base / self.database_name @property def legacy_yaml_path(self) -> Path: return self.data_base / self.legacy_yaml_name @dataclass(frozen=True) class DownloadConfig: yt_dlp_binary: str = "yt-dlp" cookie_path: Path | None = Path("/run/secrets/yt-dlp-cookies.txt") cookie_required: bool = False max_workers: int = 2 process_timeout_seconds: float = 2 * 3600.0 max_height: int = 1080 @dataclass(frozen=True) class RouterConfig: required_mode: str = "video" confirmation_timeout_seconds: float = 3.0 when_state_unknown: str = "attempt" continue_on_confirmation_failure: bool = True fallback_restore_mode: str | None = None @dataclass(frozen=True) class HomeAssistantConfig: enabled: bool = True discovery_prefix: str = "homeassistant" @dataclass(frozen=True) class WebConfig: host: str = "0.0.0.0" port: int = 7531 @dataclass(frozen=True) class ControllerConfig: mqtt: MqttConfig = field(default_factory=MqttConfig) topics: Topics = field(default_factory=Topics) storage: StorageConfig = field(default_factory=StorageConfig) downloads: DownloadConfig = field(default_factory=DownloadConfig) router: RouterConfig = field(default_factory=RouterConfig) homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig) web: WebConfig = field(default_factory=WebConfig) @property def slot_ids(self) -> tuple[int, ...]: return SLOT_IDS def _mapping(value: Any, name: str) -> Mapping[str, Any]: if value is None: return {} if not isinstance(value, Mapping): raise ConfigError(f"{name} must be a mapping") return value def _text(value: Any, name: str, *, allow_none: bool = False) -> str | None: if value is None and allow_none: return None if not isinstance(value, str) or not value.strip(): raise ConfigError(f"{name} must be a non-empty string") return value.strip() def _integer(value: Any, name: str, *, minimum: int = 1, maximum: int | None = None) -> int: if isinstance(value, bool): raise ConfigError(f"{name} must be an integer") try: result = int(value) except (TypeError, ValueError, OverflowError) as exc: raise ConfigError(f"{name} must be an integer") from exc if result < minimum or (maximum is not None and result > maximum): upper = f" and <= {maximum}" if maximum is not None else "" raise ConfigError(f"{name} must be >= {minimum}{upper}") return result def _number(value: Any, name: str, *, minimum: float = 0.0) -> float: if isinstance(value, bool): raise ConfigError(f"{name} must be a number") try: result = float(value) except (TypeError, ValueError, OverflowError) as exc: raise ConfigError(f"{name} must be a number") from exc if result < minimum: raise ConfigError(f"{name} must be >= {minimum}") return result def _boolean(value: Any, name: str) -> bool: if isinstance(value, bool): return value if isinstance(value, str): normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False raise ConfigError(f"{name} must be a boolean") def _optional_path(value: Any, name: str) -> Path | None: if value in (None, ""): return None text = _text(value, name) assert text is not None return Path(text) def _env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any: return env[key] if key in env else fallback def load_config( path: str | Path | None = None, *, environ: Mapping[str, str] | None = None, ) -> ControllerConfig: """Load YAML relative to the repository and apply documented env overrides.""" config_path = Path(path) if path is not None else DEFAULT_CONFIG_PATH raw: Mapping[str, Any] = {} if config_path.exists(): loaded = yaml.safe_load(config_path.read_text(encoding="utf-8")) raw = _mapping(loaded, "configuration") env = os.environ if environ is None else environ mqtt_raw = _mapping(raw.get("mqtt"), "mqtt") topics_raw = _mapping(raw.get("topics"), "topics") storage_raw = _mapping(raw.get("storage"), "storage") downloads_raw = _mapping(raw.get("downloads"), "downloads") router_raw = _mapping(raw.get("router"), "router") ha_raw = _mapping(raw.get("homeassistant"), "homeassistant") web_raw = _mapping(raw.get("web"), "web") mqtt = MqttConfig( host=_text(_env_value(env, "MQTT_HOST", mqtt_raw.get("host", "10.40.0.2")), "mqtt.host"), port=_integer(_env_value(env, "MQTT_PORT", mqtt_raw.get("port", 1883)), "mqtt.port", maximum=65535), username=_text(_env_value(env, "MQTT_USERNAME", mqtt_raw.get("username")), "mqtt.username", allow_none=True), password=_text(_env_value(env, "MQTT_PASSWORD", mqtt_raw.get("password")), "mqtt.password", allow_none=True), client_id=_text(_env_value(env, "MQTT_CLIENT_ID", mqtt_raw.get("client_id", "yt-controller")), "mqtt.client_id"), keepalive_seconds=_integer(mqtt_raw.get("keepalive_seconds", 30), "mqtt.keepalive_seconds"), reconnect_min_seconds=_integer(mqtt_raw.get("reconnect_min_seconds", 1), "mqtt.reconnect_min_seconds"), reconnect_max_seconds=_integer(mqtt_raw.get("reconnect_max_seconds", 30), "mqtt.reconnect_max_seconds"), command_queue_size=_integer(mqtt_raw.get("command_queue_size", 512), "mqtt.command_queue_size"), publish_ack_timeout_seconds=_number( mqtt_raw.get("publish_ack_timeout_seconds", 2.0), "mqtt.publish_ack_timeout_seconds", minimum=0.1, ), ) if mqtt.reconnect_max_seconds < mqtt.reconnect_min_seconds: raise ConfigError("mqtt.reconnect_max_seconds must be >= reconnect_min_seconds") if (mqtt.username is None) != (mqtt.password is None): raise ConfigError("mqtt.username and mqtt.password must either both be set or both be null") topics = Topics( player_command=_text(topics_raw.get("player_command", "youtube/player/cmd"), "topics.player_command"), player_status=_text(topics_raw.get("player_status", "youtube/player/status"), "topics.player_status"), player_state=_text(topics_raw.get("player_state", "youtube/player/state"), "topics.player_state"), player_telemetry=_text(topics_raw.get("player_telemetry", "youtube/player/telemetry"), "topics.player_telemetry"), controller_command=_text(topics_raw.get("controller_command", "youtube/controller/cmd"), "topics.controller_command"), ingest_progress=_text(topics_raw.get("ingest_progress", "youtube/ingest/progress"), "topics.ingest_progress"), playback_base=_text(topics_raw.get("playback_base", "youtube/media/playback"), "topics.playback_base"), router_mode_set=_text(topics_raw.get("router_mode_set", "audio/player/mode/set"), "topics.router_mode_set"), router_mode_state=_text(topics_raw.get("router_mode_state", "audio/player/mode/state"), "topics.router_mode_state"), controller_result=_text( topics_raw.get("controller_result", "youtube/controller/v2/events"), "topics.controller_result", allow_none=True, ), ) configured_slots = _integer(storage_raw.get("slot_count", 10), "storage.slot_count") if configured_slots != len(SLOT_IDS): raise ConfigError("storage.slot_count is fixed at 10 for stable slot identities") storage = StorageConfig( media_base=Path(_text(_env_value(env, "YTPLAYER_MEDIA_BASE", storage_raw.get("media_base", "/mnt/Media/YTPlayer")), "storage.media_base")), data_base=Path(_text(_env_value(env, "YTPLAYER_DATA_BASE", storage_raw.get("data_base", "/controller-data")), "storage.data_base")), database_name=_text(storage_raw.get("database_name", "controller.sqlite3"), "storage.database_name"), legacy_yaml_name=_text(storage_raw.get("legacy_yaml_name", "playlists.yaml"), "storage.legacy_yaml_name"), retention_seconds=_integer(_env_value(env, "YTPLAYER_RETENTION_SECONDS", storage_raw.get("retention_seconds", 48 * 3600)), "storage.retention_seconds"), expiry_interval_seconds=_number(storage_raw.get("expiry_interval_seconds", 60), "storage.expiry_interval_seconds", minimum=0.1), uncertain_review_after_seconds=_number(storage_raw.get("uncertain_review_after_seconds", 15 * 60), "storage.uncertain_review_after_seconds", minimum=0.0), ) if Path(storage.database_name).name != storage.database_name: raise ConfigError("storage.database_name must be a filename") if Path(storage.legacy_yaml_name).name != storage.legacy_yaml_name: raise ConfigError("storage.legacy_yaml_name must be a filename") cookie_raw = _env_value(env, "YTPLAYER_COOKIE_PATH", downloads_raw.get("cookie_path", "/run/secrets/yt-dlp-cookies.txt")) downloads = DownloadConfig( yt_dlp_binary=_text(downloads_raw.get("yt_dlp_binary", "yt-dlp"), "downloads.yt_dlp_binary"), cookie_path=_optional_path(cookie_raw, "downloads.cookie_path"), cookie_required=_boolean(downloads_raw.get("cookie_required", False), "downloads.cookie_required"), max_workers=_integer(downloads_raw.get("max_workers", 2), "downloads.max_workers", maximum=10), process_timeout_seconds=_number(downloads_raw.get("process_timeout_seconds", 2 * 3600), "downloads.process_timeout_seconds", minimum=1.0), max_height=_integer(downloads_raw.get("max_height", 1080), "downloads.max_height"), ) if downloads.cookie_required and downloads.cookie_path is None: raise ConfigError("downloads.cookie_path is required when cookie_required is true") unknown_behavior = _text(router_raw.get("when_state_unknown", "attempt"), "router.when_state_unknown") if unknown_behavior not in {"attempt", "continue", "reject"}: raise ConfigError("router.when_state_unknown must be attempt, continue, or reject") fallback = _text(router_raw.get("fallback_restore_mode"), "router.fallback_restore_mode", allow_none=True) router = RouterConfig( required_mode=_text(router_raw.get("required_mode", "video"), "router.required_mode"), confirmation_timeout_seconds=_number(router_raw.get("confirmation_timeout_seconds", 3.0), "router.confirmation_timeout_seconds", minimum=0.1), when_state_unknown=unknown_behavior, continue_on_confirmation_failure=_boolean(router_raw.get("continue_on_confirmation_failure", True), "router.continue_on_confirmation_failure"), fallback_restore_mode=fallback, ) homeassistant = HomeAssistantConfig( enabled=_boolean(ha_raw.get("enabled", True), "homeassistant.enabled"), discovery_prefix=_text(ha_raw.get("discovery_prefix", "homeassistant"), "homeassistant.discovery_prefix"), ) web = WebConfig( host=_text(web_raw.get("host", "0.0.0.0"), "web.host"), port=_integer(_env_value(env, "PORT", web_raw.get("port", 7531)), "web.port", maximum=65535), ) return ControllerConfig( mqtt=mqtt, topics=topics, storage=storage, downloads=downloads, router=router, homeassistant=homeassistant, web=web, )