"""Configuration management for the media server.""" import os import secrets from pathlib import Path from typing import Optional import yaml from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict class MediaFolderConfig(BaseModel): """Configuration for a media folder.""" path: str = Field(..., description="Absolute path to media folder") label: str = Field(..., description="Human-readable display label") enabled: bool = Field(default=True, description="Whether this folder is active") class CallbackConfig(BaseModel): """Configuration for a callback script (no label/description needed).""" command: str = Field(..., description="Command or script to execute") timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) working_dir: Optional[str] = Field(default=None, description="Working directory") shell: bool = Field(default=True, description="Run command in shell") class ScriptConfig(BaseModel): """Configuration for a custom script.""" command: str = Field(..., description="Command or script to execute") label: Optional[str] = Field(default=None, description="User-friendly display label") description: str = Field(default="", description="Script description") icon: Optional[str] = Field(default=None, description="Custom icon (e.g., 'mdi:power')") timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) working_dir: Optional[str] = Field(default=None, description="Working directory") shell: bool = Field(default=True, description="Run command in shell") class LinkConfig(BaseModel): """Configuration for a header quick link.""" url: str = Field(..., description="URL to open") icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')") label: str = Field(default="", description="Tooltip text") description: str = Field(default="", description="Optional description") class Settings(BaseSettings): """Application settings loaded from environment or config file.""" model_config = SettingsConfigDict( env_prefix="MEDIA_SERVER_", env_file=".env", env_file_encoding="utf-8", extra="ignore", ) # Server settings host: str = Field(default="0.0.0.0", description="Server bind address") port: int = Field(default=8765, description="Server port") # Authentication api_tokens: dict[str, str] = Field( default_factory=lambda: {"default": secrets.token_urlsafe(32)}, description="Named API tokens for access control (label: token pairs)", ) # Media controller settings poll_interval: float = Field( default=1.0, description="Media status poll interval in seconds" ) # Audio device settings audio_device: Optional[str] = Field( default=None, description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.", ) # Logging log_level: str = Field(default="INFO", description="Logging level") # Custom scripts (loaded separately from YAML) scripts: dict[str, ScriptConfig] = Field( default_factory=dict, description="Custom scripts that can be executed via API", ) # Callback scripts (executed by integration events, not shown in UI) callbacks: dict[str, CallbackConfig] = Field( default_factory=dict, description="Callback scripts executed by integration events (on_turn_on, on_turn_off, on_toggle)", ) # Media folders for browsing media_folders: dict[str, MediaFolderConfig] = Field( default_factory=dict, description="Media folders available for browsing in the media browser", ) # Thumbnail settings thumbnail_size: str = Field( default="medium", description='Thumbnail size: "small" (150x150), "medium" (300x300), or "both"', ) # Header quick links links: dict[str, LinkConfig] = Field( default_factory=dict, description="Quick links displayed as icons in the header", ) # Audio visualizer visualizer_enabled: bool = Field( default=True, description="Enable audio spectrum visualizer (requires soundcard + numpy)", ) visualizer_fps: int = Field( default=30, description="Visualizer update rate in frames per second", ge=10, le=60, ) visualizer_bins: int = Field( default=32, description="Number of frequency bins for the visualizer", ge=8, le=128, ) visualizer_device: Optional[str] = Field( default=None, description="Loopback audio device name for visualizer (None = auto-detect)", ) @classmethod def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings": """Load settings from a YAML configuration file.""" if path is None: # Look for config in standard locations search_paths = [ Path("config.yaml"), Path("config.yml"), ] # Add platform-specific config directory if os.name == "nt": # Windows appdata = os.environ.get("APPDATA", "") if appdata: search_paths.append(Path(appdata) / "media-server" / "config.yaml") else: # Linux/Unix/macOS search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml") search_paths.append(Path("/etc/media-server/config.yaml")) for search_path in search_paths: if search_path.exists(): path = search_path break if path and path.exists(): with open(path, "r", encoding="utf-8") as f: config_data = yaml.safe_load(f) or {} return cls(**config_data) return cls() def get_config_dir() -> Path: """Get the configuration directory path.""" if os.name == "nt": # Windows config_dir = Path(os.environ.get("APPDATA", "")) / "media-server" else: # Linux/Unix config_dir = Path.home() / ".config" / "media-server" config_dir.mkdir(parents=True, exist_ok=True) return config_dir def generate_default_config(path: Optional[Path] = None) -> Path: """Generate a default configuration file with a new API token.""" if path is None: path = get_config_dir() / "config.yaml" config = { "host": "0.0.0.0", "port": 8765, "api_tokens": { "default": secrets.token_urlsafe(32), }, "poll_interval": 1.0, "log_level": "INFO", # Audio device to control (use GET /api/audio/devices to list available devices) # Set to null or remove to use default device # "audio_device": "Speakers (Realtek", "scripts": { "example_script": { "command": "echo Hello from Media Server!", "description": "Example script - echoes a message", "timeout": 10, "shell": True, }, # Add your custom scripts here: # "shutdown": { # "command": "shutdown /s /t 60", # "description": "Shutdown computer in 60 seconds", # "timeout": 5, # }, # "lock_screen": { # "command": "rundll32.exe user32.dll,LockWorkStation", # "description": "Lock the workstation", # "timeout": 5, # }, }, } path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) return path # Global settings instance settings = Settings.load_from_yaml()