Musicseerr/backend/core/config.py
2026-04-03 15:53:00 +01:00

251 lines
9.9 KiB
Python

from pathlib import Path
from pydantic import Field, TypeAdapter, ValidationError as PydanticValidationError, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Self
import logging
import msgspec
from core.exceptions import ConfigurationError
from infrastructure.file_utils import atomic_write_json, read_json
logger = logging.getLogger(__name__)
_VALID_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="allow"
)
lidarr_url: str = Field(default="http://lidarr:8686")
lidarr_api_key: str = Field(default="")
jellyfin_url: str = Field(default="http://jellyfin:8096")
contact_email: str = Field(
default="contact@musicseerr.com",
description="Contact email for MusicBrainz API User-Agent. Override with your own if desired."
)
quality_profile_id: int = Field(default=1)
metadata_profile_id: int = Field(default=1)
root_folder_path: str = Field(default="/music")
port: int = Field(default=8688)
debug: bool = Field(default=False)
log_level: str = Field(default="INFO")
cache_ttl_default: int = Field(default=60)
cache_ttl_artist: int = Field(default=3600)
cache_ttl_album: int = Field(default=3600)
cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)")
cache_cleanup_interval: int = Field(default=300)
cache_dir: Path = Field(default=Path("/app/cache"), description="Root directory for all cache files")
library_db_path: Path = Field(default=Path("/app/cache/library.db"), description="SQLite library database path")
cover_cache_max_size_mb: int = Field(default=500, description="Maximum cover cache size in MB")
queue_db_path: Path = Field(default=Path("/app/cache/queue.db"), description="SQLite queue database path")
shutdown_grace_period: float = Field(default=10.0, description="Seconds to wait for tasks on shutdown")
http_timeout: float = Field(default=10.0)
http_connect_timeout: float = Field(default=5.0)
http_max_connections: int = Field(default=200)
http_max_keepalive: int = Field(default=50)
config_file_path: Path = Field(default=Path("/app/config/config.json"))
audiodb_api_key: str = Field(default="123")
audiodb_premium: bool = Field(default=False, description="Set to true if using a premium AudioDB API key")
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
normalised = v.upper()
if normalised not in _VALID_LOG_LEVELS:
raise ValueError(
f"Invalid log_level '{v}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}"
)
return normalised
@field_validator("lidarr_url", "jellyfin_url")
@classmethod
def validate_url(cls, v: str) -> str:
return v.rstrip("/")
@model_validator(mode='after')
def validate_config(self) -> Self:
errors = []
warnings = []
for url_field in ['lidarr_url', 'jellyfin_url']:
url = getattr(self, url_field, '')
if url and not url.startswith(('http://', 'https://')):
errors.append(f"{url_field} must start with http:// or https://")
if self.http_max_connections < self.http_max_keepalive * 2:
warnings.append(
f"http_max_connections ({self.http_max_connections}) should be "
f"at least 2x http_max_keepalive ({self.http_max_keepalive})"
)
if not self.lidarr_api_key:
warnings.append("LIDARR_API_KEY is not set - Lidarr features will not work")
for warning in warnings:
logger.warning(warning)
if errors:
raise ConfigurationError(
f"Critical configuration errors: {'; '.join(errors)}"
)
return self
def get_user_agent(self) -> str:
return f"Musicseerr/1.0 ({self.contact_email}; https://www.musicseerr.com)"
def load_from_file(self) -> None:
if not self.config_file_path.exists():
self._create_default_config()
return
try:
config_data = read_json(self.config_file_path, default={})
if not isinstance(config_data, dict):
raise ValueError("Config file JSON root must be an object")
type_errors: list[str] = []
model_fields = type(self).model_fields
validated_values: dict[str, object] = {}
for key, value in config_data.items():
if key not in model_fields:
logger.warning("Unknown config key '%s' — ignoring", key)
continue
try:
field_info = model_fields[key]
adapter = TypeAdapter(field_info.annotation)
validated_values[key] = adapter.validate_python(value)
except PydanticValidationError as e:
type_errors.append(
f"'{key}': {e.errors()[0].get('msg', str(e))}"
)
except (TypeError, ValueError) as e:
type_errors.append(f"'{key}': {e}")
if type_errors:
raise ConfigurationError(
f"Config file type errors: {'; '.join(type_errors)}"
)
# Run field validators that TypeAdapter doesn't invoke
try:
for url_field in ('lidarr_url', 'jellyfin_url'):
if url_field in validated_values:
validated_values[url_field] = type(self).validate_url(
validated_values[url_field]
)
if 'log_level' in validated_values:
validated_values['log_level'] = type(self).validate_log_level(
validated_values['log_level']
)
except ValueError as e:
raise ConfigurationError(f"Config file validation error: {e}")
# Dry-run cross-field validation on merged candidate state
self._validate_merged(validated_values)
# All validation passed — apply atomically
for key, value in validated_values.items():
setattr(self, key, value)
logger.info(f"Loaded configuration from {self.config_file_path}")
except (ConfigurationError, ValueError):
raise
except msgspec.DecodeError as e:
logger.error(f"Invalid JSON in config file: {e}")
raise ValueError(f"Config file is not valid JSON: {e}")
except Exception as e:
logger.error(f"Failed to load config: {e}")
raise
def _validate_merged(self, overrides: dict[str, object]) -> None:
"""Validate cross-field constraints against candidate merged state without mutating self."""
errors = []
def _get(field: str) -> object:
return overrides.get(field, getattr(self, field))
for url_field in ('lidarr_url', 'jellyfin_url'):
url = _get(url_field)
if url and not str(url).startswith(('http://', 'https://')):
errors.append(f"{url_field} must start with http:// or https://")
if errors:
raise ConfigurationError(
f"Critical configuration errors: {'; '.join(errors)}"
)
def _create_default_config(self) -> None:
self.config_file_path.parent.mkdir(parents=True, exist_ok=True)
config_data = {
"lidarr_url": self.lidarr_url,
"lidarr_api_key": self.lidarr_api_key,
"jellyfin_url": self.jellyfin_url,
"contact_email": self.contact_email,
"quality_profile_id": self.quality_profile_id,
"metadata_profile_id": self.metadata_profile_id,
"root_folder_path": self.root_folder_path,
"port": self.port,
"audiodb_api_key": self.audiodb_api_key,
"audiodb_premium": self.audiodb_premium,
"user_preferences": {
"primary_types": ["album", "ep", "single"],
"secondary_types": ["studio"],
"release_statuses": ["official"],
},
}
atomic_write_json(self.config_file_path, config_data)
logger.info(f"Created default config at {self.config_file_path}")
def save_to_file(self) -> None:
try:
self.config_file_path.parent.mkdir(parents=True, exist_ok=True)
config_data = {}
if self.config_file_path.exists():
loaded = read_json(self.config_file_path, default={})
config_data = loaded if isinstance(loaded, dict) else {}
config_data.update({
"lidarr_url": self.lidarr_url,
"lidarr_api_key": self.lidarr_api_key,
"jellyfin_url": self.jellyfin_url,
"contact_email": self.contact_email,
"quality_profile_id": self.quality_profile_id,
"metadata_profile_id": self.metadata_profile_id,
"root_folder_path": self.root_folder_path,
"port": self.port,
"audiodb_api_key": self.audiodb_api_key,
"audiodb_premium": self.audiodb_premium,
})
atomic_write_json(self.config_file_path, config_data)
logger.info(f"Saved config to {self.config_file_path}")
except Exception as e:
logger.error(f"Failed to save config: {e}")
raise
_settings: Settings | None = None
def get_settings() -> Settings:
global _settings
if _settings is None:
settings = Settings()
settings.load_from_file()
_settings = settings
return _settings