import os import json import logging from pathlib import Path from typing import Dict, Any, Optional from dotenv import load_dotenv logger = logging.getLogger(__name__) class ConfigService: """Manages application configuration with support for environment variables and config files.""" def __init__(self, config_path: str = None): """Initialize the configuration service. Args: config_path: Optional path to a JSON configuration file """ # Load environment variables from .env file load_dotenv() # Default configuration self._config = { # Spotify API settings 'spotify_client_id': os.getenv('SPOTIFY_CLIENT_ID', os.getenv('SPOTIPY_CLIENT_ID', '')), 'spotify_client_secret': os.getenv('SPOTIFY_CLIENT_SECRET', os.getenv('SPOTIPY_CLIENT_SECRET', '')), 'spotify_redirect_uri': os.getenv('SPOTIFY_REDIRECT_URI', 'http://localhost:6544/callback'), # Download settings 'download_dir': os.path.expanduser(os.getenv('DOWNLOAD_DIR', '~/Music/SpotSpot/Downloads')), 'temp_dir': os.path.expanduser(os.getenv('TEMP_DIR', '~/Music/SpotSpot/temp')), 'max_concurrent_downloads': int(os.getenv('MAX_CONCURRENT_DOWNLOADS', '3')), 'download_format': os.getenv('DOWNLOAD_FORMAT', 'mp3'), 'bitrate': os.getenv('BITRATE', '320k'), 'overwrite_existing': os.getenv('OVERWRITE_EXISTING', 'false').lower() == 'true', 'create_m3u_playlist': os.getenv('CREATE_M3U_PLAYLIST', 'true').lower() == 'true', # Path settings 'track_output': os.path.expanduser(os.getenv('TRACK_OUTPUT', '~/Music/SpotSpot/Downloads/Tracks/{artist} - {title}.{ext}')), 'album_output': os.path.expanduser(os.getenv('ALBUM_OUTPUT', '~/Music/SpotSpot/Downloads/Albums/{album_artist}/{album}/{track:02d} - {title}.{ext}')), 'playlist_output': os.path.expanduser(os.getenv('PLAYLIST_OUTPUT', '~/Music/SpotSpot/Downloads/Playlists/{playlist}/{track:02d} - {artist} - {title}.{ext}')), 'artist_output': os.path.expanduser(os.getenv('ARTIST_OUTPUT', '~/Music/SpotSpot/Downloads/Artists/{artist}/{album}/{track:02d} - {title}.{ext}')), # Jellyfin/Emby settings 'jellyfin_address': os.getenv('JELLYFIN_ADDRESS', ''), 'jellyfin_api_key': os.getenv('JELLYFIN_API_KEY', ''), 'jellyfin_library_path': os.getenv('JELLYFIN_LIBRARY_PATH', ''), 'jellyfin_scan_after_download': os.getenv('JELLYFIN_SCAN_AFTER_DOWNLOAD', 'true').lower() == 'true', # Playlist settings 'playlists_dir': os.path.expanduser(os.getenv('PLAYLISTS_DIR', '~/Music/SpotSpot/Playlists')), 'auto_add_to_playlist': os.getenv('AUTO_ADD_TO_PLAYLIST', ''), # Application settings 'host': os.getenv('HOST', '0.0.0.0'), 'port': int(os.getenv('PORT', '6544')), 'debug': os.getenv('DEBUG', 'false').lower() == 'true', 'secret_key': os.getenv('SECRET_KEY', 'dev-secret-key-change-me'), 'log_level': os.getenv('LOG_LEVEL', 'INFO').upper(), 'extra_logging': os.getenv('EXTRA_LOGGING', 'True'), # Search settings 'search_limit': int(os.getenv('SEARCH_LIMIT', '50')), 'search_timeout': int(os.getenv('SEARCH_TIMEOUT', '10')), # SpotifyDL settings 'spotdl_path': os.getenv('SPOTDL_PATH', 'spotdl'), 'youtube_dl_path': os.getenv('YOUTUBE_DL_PATH', 'youtube-dl'), 'ffmpeg_path': os.getenv('FFMPEG_PATH', 'ffmpeg'), 'use_youtube_dl': os.getenv('USE_YOUTUBE_DL', 'false').lower() == 'true', 'cookie_file': os.path.expanduser(os.getenv('COOKIE_FILE', '')), 'audio_providers': os.getenv('AUDIO_PROVIDERS', 'youtube,ytmusic').split(','), 'lyrics_providers': os.getenv('LYRICS_PROVIDERS', 'genius,musixmatch,azlyrics').split(','), } # Load additional config from file if provided if config_path and os.path.exists(config_path): self._load_config_file(config_path) # Create necessary directories self._create_directories() # Configure logging self._configure_logging() def _load_config_file(self, config_path: str) -> None: """Load configuration from a JSON file. Args: config_path: Path to the JSON configuration file """ try: with open(config_path, 'r') as f: file_config = json.load(f) # Update only the keys that exist in our default config for key, value in file_config.items(): if key in self._config: # Handle path expansion for directory paths if isinstance(value, str) and ('_dir' in key or '_path' in key or '_output' in key): value = os.path.expanduser(value) self._config[key] = value logger.info(f"Loaded configuration from {config_path}") except Exception as e: logger.error(f"Error loading config file {config_path}: {str(e)}") def _create_directories(self) -> None: """Create all necessary directories.""" dirs_to_create = [ self.download_dir, self.temp_dir, self.playlists_dir, os.path.dirname(self.track_output), os.path.dirname(self.album_output), os.path.dirname(self.playlist_output), os.path.dirname(self.artist_output) ] for directory in set(dirs_to_create): # Remove duplicates try: if directory: # Skip empty strings os.makedirs(directory, exist_ok=True) except Exception as e: logger.error(f"Failed to create directory {directory}: {str(e)}") def _configure_logging(self) -> None: """Configure logging based on configuration.""" log_level = getattr(logging, self.log_level, logging.INFO) # Force reconfiguration of logging logging.basicConfig( level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(), logging.FileHandler(os.path.join(self.download_dir, 'spotspot.log')) ], force=True ) def __getattr__(self, name: str) -> Any: """Allow direct attribute access to config values.""" if name in self._config: return self._config[name] raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") def get(self, key: str, default: Any = None) -> Any: """Get a configuration value by key. Args: key: Configuration key default: Default value if key doesn't exist Returns: The configuration value or default """ return self._config.get(key, default) def to_dict(self) -> Dict[str, Any]: """Convert the configuration to a dictionary. Returns: Dictionary containing all configuration values """ return self._config.copy() def save(self, path: str = None) -> None: """Save the current configuration to a file. Args: path: Path to save the configuration file (default: ~/.config/spotspot/config.json) """ if path is None: config_dir = os.path.expanduser('~/.config/spotspot') os.makedirs(config_dir, exist_ok=True) path = os.path.join(config_dir, 'config.json') try: with open(path, 'w') as f: json.dump(self._config, f, indent=2) logger.info(f"Configuration saved to {path}") except Exception as e: logger.error(f"Failed to save configuration to {path}: {str(e)}")