KaraokeVideoDownloader/karaoke_downloader/config_manager.py
Matt Bruce eb3642d652 mac support
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-08-05 16:11:29 -05:00

360 lines
11 KiB
Python

"""
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",
"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"
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] = "data/config.json"):
"""
Initialize the configuration manager.
Args:
config_file: Path to the configuration file
"""
self.config_file = Path(config_file)
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() -> ConfigManager:
"""
Get the global configuration manager instance.
Returns:
ConfigManager instance
"""
global _config_manager
if _config_manager is None:
_config_manager = ConfigManager()
return _config_manager
def load_config(force_reload: bool = False) -> AppConfig:
"""
Load configuration using the global manager.
Args:
force_reload: Force reload even if file hasn't changed
Returns:
AppConfig instance
"""
return get_config_manager().load_config(force_reload)