309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""Validated configuration for Controller v2."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
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 _optional_text(value: Any, name: str) -> str | None:
|
|
"""Normalize unset/blank optional environment values to ``None``."""
|
|
|
|
if value is None or (isinstance(value, str) and not value.strip()):
|
|
return None
|
|
return _text(value, name)
|
|
|
|
|
|
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 not math.isfinite(result):
|
|
raise ConfigError(f"{name} must be finite")
|
|
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 _optional_env_value(env: Mapping[str, str], key: str, fallback: Any) -> Any:
|
|
value = env.get(key)
|
|
if value is None or not value.strip():
|
|
return fallback
|
|
return value
|
|
|
|
|
|
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=_optional_text(_optional_env_value(env, "MQTT_USERNAME", mqtt_raw.get("username")), "mqtt.username"),
|
|
password=_optional_text(_optional_env_value(env, "MQTT_PASSWORD", mqtt_raw.get("password")), "mqtt.password"),
|
|
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")
|
|
if not storage.media_base.is_absolute():
|
|
raise ConfigError("storage.media_base must be an absolute path")
|
|
if not storage.data_base.is_absolute():
|
|
raise ConfigError("storage.data_base must be an absolute path")
|
|
|
|
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")
|
|
if downloads.cookie_path is not None and not downloads.cookie_path.is_absolute():
|
|
raise ConfigError("downloads.cookie_path must be an absolute path")
|
|
|
|
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,
|
|
)
|