""" Configuration management utilities for the karaoke downloader. Provides centralized configuration loading, validation, and management. """ import json import platform import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional, Union # Default configuration values DEFAULT_CONFIG = { "download_settings": { "format": "best[height<=720][ext=mp4]/best[height<=720]/best[ext=mp4]/best", "preferred_resolution": "720p", "audio_format": "mp3", "audio_quality": "0", "subtitle_language": "en", "subtitle_format": "srt", "write_metadata": False, "write_thumbnail": False, "write_description": False, "write_annotations": False, "write_comments": False, "write_subtitles": False, "embed_metadata": False, "add_metadata": False, "continue_downloads": True, "no_overwrites": True, "ignore_errors": True, "no_warnings": False, }, "folder_structure": { "downloads_dir": "downloads", "logs_dir": "logs", "data_dir": "data", "tracking_file": "data/karaoke_tracking.json", }, "logging": { "level": "INFO", "format": "%(asctime)s - %(levelname)s - %(message)s", "include_console": True, "include_file": True, }, "platform_settings": { "auto_detect_platform": True, "yt_dlp_paths": { "windows": "downloader/yt-dlp.exe", "macos": "downloader/yt-dlp_macos" } }, "yt_dlp_path": "downloader/yt-dlp.exe", } # Resolution mapping for CLI arguments RESOLUTION_MAP = { "480p": "480", "720p": "720", "1080p": "1080", "1440p": "1440", "2160p": "2160", } def detect_platform() -> str: """Detect the current platform and return platform name.""" system = platform.system().lower() if system == "windows": return "windows" elif system == "darwin": return "macos" else: return "windows" # Default to Windows for other platforms def get_platform_yt_dlp_path(platform_paths: Dict[str, str]) -> str: """Get the appropriate yt-dlp path for the current platform.""" platform_name = detect_platform() return platform_paths.get(platform_name, platform_paths.get("windows", "downloader/yt-dlp.exe")) @dataclass class DownloadSettings: """Configuration for download settings.""" format: str = "best[height<=720][ext=mp4]/best[height<=720]/best[ext=mp4]/best" outtmpl: str = "%(title)s_720p.%(ext)s" merge_output_format: str = "mp4" noplaylist: bool = True postprocessors: list = None preferred_resolution: str = "720p" audio_format: str = "mp3" audio_quality: str = "0" subtitle_language: str = "en" subtitle_format: str = "srt" write_metadata: bool = False write_thumbnail: bool = False write_description: bool = False writedescription: bool = False write_annotations: bool = False writeannotations: bool = False write_comments: bool = False writecomments: bool = False write_subtitles: bool = False writesubtitles: bool = False writeinfojson: bool = False writethumbnail: bool = False embed_metadata: bool = False add_metadata: bool = False continue_downloads: bool = True continuedl: bool = True no_overwrites: bool = True nooverwrites: bool = True ignore_errors: bool = True ignoreerrors: bool = True no_warnings: bool = False def __post_init__(self): """Initialize default values for complex fields.""" if self.postprocessors is None: self.postprocessors = [ { "key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "0", } ] @dataclass class FolderStructure: """Configuration for folder structure.""" downloads_dir: str = "downloads" logs_dir: str = "logs" data_dir: str = "data" tracking_file: str = "data/karaoke_tracking.json" @dataclass class LoggingConfig: """Configuration for logging.""" level: str = "INFO" format: str = "%(asctime)s - %(levelname)s - %(message)s" include_console: bool = True include_file: bool = True @dataclass class AppConfig: """Main application configuration.""" download_settings: DownloadSettings = field(default_factory=DownloadSettings) folder_structure: FolderStructure = field(default_factory=FolderStructure) logging: LoggingConfig = field(default_factory=LoggingConfig) yt_dlp_path: str = "downloader/yt-dlp.exe" _config_file: Optional[Path] = None _last_modified: Optional[datetime] = None class ConfigManager: """ Manages application configuration with loading, validation, and caching. """ def __init__(self, config_file: Union[str, Path] = "config/config.json", data_dir: Optional[str] = None): """ Initialize the configuration manager. Args: config_file: Path to the configuration file data_dir: Optional custom data directory path """ # If config_file is relative and data_dir is provided, make it relative to data_dir if data_dir and not Path(config_file).is_absolute(): self.config_file = Path(data_dir) / config_file else: self.config_file = Path(config_file) self._data_dir = data_dir self._config: Optional[AppConfig] = None self._last_modified: Optional[datetime] = None def load_config(self, force_reload: bool = False) -> AppConfig: """ Load configuration from file with caching. Args: force_reload: Force reload even if file hasn't changed Returns: AppConfig instance """ # Check if we need to reload if not force_reload and self._config is not None: if self.config_file.exists(): current_mtime = datetime.fromtimestamp(self.config_file.stat().st_mtime) if self._last_modified and current_mtime <= self._last_modified: return self._config # Load configuration config_data = self._load_config_file() self._config = self._create_config_from_dict(config_data) self._last_modified = datetime.now() return self._config def _load_config_file(self) -> Dict[str, Any]: """ Load configuration from file with fallback to defaults. Returns: Configuration dictionary """ if self.config_file.exists(): try: with open(self.config_file, "r", encoding="utf-8") as f: file_config = json.load(f) # Merge with defaults return self._merge_configs(DEFAULT_CONFIG, file_config) except (json.JSONDecodeError, FileNotFoundError) as e: print(f"Warning: Could not load config.json: {e}") print("Using default configuration.") return DEFAULT_CONFIG.copy() def _merge_configs( self, default: Dict[str, Any], user: Dict[str, Any] ) -> Dict[str, Any]: """ Merge user configuration with defaults. Args: default: Default configuration user: User configuration Returns: Merged configuration """ merged = default.copy() for key, value in user.items(): if ( key in merged and isinstance(merged[key], dict) and isinstance(value, dict) ): merged[key] = self._merge_configs(merged[key], value) else: merged[key] = value return merged def _create_config_from_dict(self, config_data: Dict[str, Any]) -> AppConfig: """ Create AppConfig from dictionary. Args: config_data: Configuration dictionary Returns: AppConfig instance """ download_settings = DownloadSettings(**config_data.get("download_settings", {})) folder_structure = FolderStructure(**config_data.get("folder_structure", {})) logging_config = LoggingConfig(**config_data.get("logging", {})) # Handle platform-specific yt-dlp path yt_dlp_path = config_data.get("yt_dlp_path", "downloader/yt-dlp.exe") # Check if platform auto-detection is enabled platform_settings = config_data.get("platform_settings", {}) if platform_settings.get("auto_detect_platform", True): platform_paths = platform_settings.get("yt_dlp_paths", {}) if platform_paths: yt_dlp_path = get_platform_yt_dlp_path(platform_paths) return AppConfig( download_settings=download_settings, folder_structure=folder_structure, logging=logging_config, yt_dlp_path=yt_dlp_path, _config_file=self.config_file, ) def update_resolution(self, resolution: str) -> None: """ Update the download format based on resolution. Args: resolution: Resolution string (e.g., "720p", "1080p") """ if self._config is None: self.load_config() if resolution in RESOLUTION_MAP: height = RESOLUTION_MAP[resolution] format_str = f"best[height<={height}][ext=mp4]/best[height<={height}]/best[ext=mp4]/best" self._config.download_settings.format = format_str self._config.download_settings.preferred_resolution = resolution print(f"🎬 Using resolution: {resolution}") def get_config(self) -> AppConfig: """ Get the current configuration. Returns: AppConfig instance """ if self._config is None: return self.load_config() return self._config def save_config(self) -> None: """ Save current configuration to file. """ if self._config is None: return config_dict = { "download_settings": self._config.download_settings.__dict__, "folder_structure": self._config.folder_structure.__dict__, "logging": self._config.logging.__dict__, "yt_dlp_path": self._config.yt_dlp_path, } # Ensure directory exists self.config_file.parent.mkdir(parents=True, exist_ok=True) with open(self.config_file, "w", encoding="utf-8") as f: json.dump(config_dict, f, indent=2, ensure_ascii=False) print(f"Configuration saved to {self.config_file}") # Global configuration manager instance _config_manager: Optional[ConfigManager] = None def get_config_manager(config_file: Optional[Union[str, Path]] = None, data_dir: Optional[str] = None) -> ConfigManager: """ Get the global configuration manager instance. Args: config_file: Optional path to config file (default: "config.json" in root) data_dir: Optional custom data directory path Returns: ConfigManager instance """ global _config_manager if _config_manager is None or config_file is not None or data_dir is not None: if config_file is None: config_file = "config/config.json" _config_manager = ConfigManager(config_file, data_dir) return _config_manager def load_config(force_reload: bool = False, config_file: Optional[Union[str, Path]] = None, data_dir: Optional[str] = None) -> AppConfig: """ Load configuration using the global manager. Args: force_reload: Force reload even if file hasn't changed config_file: Optional path to config file (default: "config.json" in root) data_dir: Optional custom data directory path Returns: AppConfig instance """ return get_config_manager(config_file, data_dir).load_config(force_reload)