Files
musicseerr/backend/services/preferences_service.py
T
2026-04-18 00:58:37 +01:00

486 lines
21 KiB
Python

import logging
import threading
from typing import Optional, TypeVar, Type
from typing import Any
import msgspec
from api.v1.schemas.settings import (
UserPreferences,
LidarrSettings,
LidarrConnectionSettings,
JellyfinConnectionSettings,
ListenBrainzConnectionSettings,
YouTubeConnectionSettings,
HomeSettings,
LocalFilesConnectionSettings,
LastFmConnectionSettings,
ScrobbleSettings,
PrimaryMusicSourceSettings,
LASTFM_SECRET_MASK,
NavidromeConnectionSettings,
NAVIDROME_PASSWORD_MASK,
PlexConnectionSettings,
PLEX_TOKEN_MASK,
MusicBrainzConnectionSettings,
)
from api.v1.schemas.profile import ProfileSettings
from api.v1.schemas.advanced_settings import AdvancedSettings
from core.config import Settings
from core.exceptions import ConfigurationError
from infrastructure.file_utils import atomic_write_json, read_json
from infrastructure.serialization import to_jsonable
logger = logging.getLogger(__name__)
T = TypeVar('T', bound=msgspec.Struct)
class PreferencesService:
def __init__(self, settings: Settings):
self._settings = settings
self._config_path = settings.config_file_path
self._config_cache: Optional[dict] = None
self._cache_lock = threading.Lock()
self._migrate_musicbrainz_settings()
self._ensure_instance_id()
def _ensure_instance_id(self) -> None:
"""Generate a stable instance ID on first run."""
config = self._load_config()
if config.get("instance_id"):
return
import uuid
instance_id = str(uuid.uuid4())
config = self._load_config().copy()
config["instance_id"] = instance_id
self._save_config(config)
logger.info("Generated new instance ID: %s", instance_id)
def get_instance_id(self) -> str:
config = self._load_config()
return config.get("instance_id", "unknown")
def _load_config(self) -> dict:
with self._cache_lock:
if self._config_cache is not None:
return self._config_cache
if not self._config_path.exists():
self._config_cache = {}
return self._config_cache
try:
loaded = read_json(self._config_path, default={})
self._config_cache = loaded if isinstance(loaded, dict) else {}
except Exception as e: # noqa: BLE001
logger.error(f"Failed to load config: {e}")
self._config_cache = {}
return self._config_cache
def _save_config(self, config: dict) -> None:
with self._cache_lock:
self._config_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(self._config_path, config)
self._config_cache = config
def _get_section(self, key: str, model: Type[T], default_factory: Optional[callable] = None) -> T:
config = self._load_config()
data = config.get(key, {})
try:
if not (isinstance(model, type) and issubclass(model, msgspec.Struct)):
raise TypeError(f"Preferences section model must be msgspec.Struct, got {model!r}")
if data:
return msgspec.convert(data, type=model)
return default_factory() if default_factory else model()
except Exception as e: # noqa: BLE001
logger.error(f"Failed to parse {key}: {e}")
return default_factory() if default_factory else model()
def _save_section(self, key: str, value: Any) -> None:
config = self._load_config().copy()
config[key] = to_jsonable(value)
self._save_config(config)
def get_preferences(self) -> UserPreferences:
return self._get_section("user_preferences", UserPreferences)
def save_preferences(self, preferences: UserPreferences) -> None:
try:
self._save_section("user_preferences", preferences)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save preferences: {e}")
raise ConfigurationError(f"Failed to save preferences: {e}")
def get_lidarr_settings(self) -> LidarrSettings:
return self._get_section("lidarr_settings", LidarrSettings)
def save_lidarr_settings(self, lidarr_settings: LidarrSettings) -> None:
try:
self._save_section("lidarr_settings", lidarr_settings)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save Lidarr settings: {e}")
raise ConfigurationError(f"Failed to save Lidarr settings: {e}")
def get_advanced_settings(self) -> AdvancedSettings:
return self._get_section("advanced_settings", AdvancedSettings)
def save_advanced_settings(self, advanced_settings: AdvancedSettings) -> None:
try:
self._save_section("advanced_settings", advanced_settings)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save advanced settings: {e}")
raise ConfigurationError(f"Failed to save advanced settings: {e}")
def get_lidarr_connection(self) -> LidarrConnectionSettings:
config = self._load_config()
return LidarrConnectionSettings(
lidarr_url=config.get("lidarr_url", self._settings.lidarr_url),
lidarr_api_key=config.get("lidarr_api_key", self._settings.lidarr_api_key),
quality_profile_id=config.get("quality_profile_id", self._settings.quality_profile_id),
metadata_profile_id=config.get("metadata_profile_id", self._settings.metadata_profile_id),
root_folder_path=config.get("root_folder_path", self._settings.root_folder_path),
)
def save_lidarr_connection(self, settings: LidarrConnectionSettings) -> None:
try:
config = self._load_config().copy()
config.update({
"lidarr_url": settings.lidarr_url,
"lidarr_api_key": settings.lidarr_api_key,
"quality_profile_id": settings.quality_profile_id,
"metadata_profile_id": settings.metadata_profile_id,
"root_folder_path": settings.root_folder_path,
})
self._save_config(config)
self._settings.lidarr_url = settings.lidarr_url
self._settings.lidarr_api_key = settings.lidarr_api_key
self._settings.quality_profile_id = settings.quality_profile_id
self._settings.metadata_profile_id = settings.metadata_profile_id
self._settings.root_folder_path = settings.root_folder_path
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save Lidarr connection settings: {e}")
raise ConfigurationError(f"Failed to save Lidarr connection settings: {e}")
def get_jellyfin_connection(self) -> JellyfinConnectionSettings:
config = self._load_config()
jellyfin_data = config.get("jellyfin_settings", {})
return JellyfinConnectionSettings(
jellyfin_url=jellyfin_data.get("jellyfin_url", config.get("jellyfin_url", self._settings.jellyfin_url)),
api_key=jellyfin_data.get("api_key", ""),
user_id=jellyfin_data.get("user_id", ""),
enabled=jellyfin_data.get("enabled", False),
)
def save_jellyfin_connection(self, settings: JellyfinConnectionSettings) -> None:
try:
config = self._load_config().copy()
config["jellyfin_url"] = settings.jellyfin_url
config["jellyfin_settings"] = {
"jellyfin_url": settings.jellyfin_url,
"api_key": settings.api_key,
"user_id": settings.user_id,
"enabled": settings.enabled,
}
self._save_config(config)
self._settings.jellyfin_url = settings.jellyfin_url
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save Jellyfin connection settings: {e}")
raise ConfigurationError(f"Failed to save Jellyfin connection settings: {e}")
def get_navidrome_connection(self) -> NavidromeConnectionSettings:
config = self._load_config()
nd_data = config.get("navidrome_settings", {})
password = nd_data.get("password", "")
return NavidromeConnectionSettings(
navidrome_url=nd_data.get("navidrome_url", ""),
username=nd_data.get("username", ""),
password=NAVIDROME_PASSWORD_MASK if password else "",
enabled=nd_data.get("enabled", False),
)
def get_navidrome_connection_raw(self) -> NavidromeConnectionSettings:
config = self._load_config()
nd_data = config.get("navidrome_settings", {})
return NavidromeConnectionSettings(
navidrome_url=nd_data.get("navidrome_url", ""),
username=nd_data.get("username", ""),
password=nd_data.get("password", ""),
enabled=nd_data.get("enabled", False),
)
def save_navidrome_connection(self, settings: NavidromeConnectionSettings) -> None:
try:
config = self._load_config().copy()
current_data = config.get("navidrome_settings", {})
password = settings.password
if password == NAVIDROME_PASSWORD_MASK:
password = current_data.get("password", "")
config["navidrome_settings"] = {
"navidrome_url": settings.navidrome_url,
"username": settings.username,
"password": password,
"enabled": settings.enabled,
}
self._save_config(config)
except Exception as e: # noqa: BLE001
logger.error("Failed to save Navidrome connection settings: %s", e)
raise ConfigurationError(f"Failed to save Navidrome connection settings: {e}")
def get_plex_connection(self) -> PlexConnectionSettings:
config = self._load_config()
plex_data = config.get("plex_settings", {})
settings = PlexConnectionSettings(
plex_url=plex_data.get("plex_url", ""),
plex_token=plex_data.get("plex_token", ""),
enabled=plex_data.get("enabled", False),
music_library_ids=plex_data.get("music_library_ids", []),
scrobble_to_plex=plex_data.get("scrobble_to_plex", True),
)
if settings.plex_token:
settings.plex_token = PLEX_TOKEN_MASK
return settings
def get_plex_connection_raw(self) -> PlexConnectionSettings:
config = self._load_config()
plex_data = config.get("plex_settings", {})
return PlexConnectionSettings(
plex_url=plex_data.get("plex_url", ""),
plex_token=plex_data.get("plex_token", ""),
enabled=plex_data.get("enabled", False),
music_library_ids=plex_data.get("music_library_ids", []),
scrobble_to_plex=plex_data.get("scrobble_to_plex", True),
)
def save_plex_connection(self, settings: PlexConnectionSettings) -> None:
try:
config = self._load_config().copy()
current_data = config.get("plex_settings", {})
token = settings.plex_token
if token == PLEX_TOKEN_MASK:
token = current_data.get("plex_token", "")
config["plex_settings"] = {
"plex_url": settings.plex_url,
"plex_token": token,
"enabled": settings.enabled,
"music_library_ids": settings.music_library_ids,
"scrobble_to_plex": settings.scrobble_to_plex,
}
self._save_config(config)
logger.info("Saved Plex connection settings to %s", self._config_path)
except Exception as e: # noqa: BLE001
logger.error("Failed to save Plex connection settings: %s", e)
raise ConfigurationError(f"Failed to save Plex connection settings: {e}")
def get_listenbrainz_connection(self) -> ListenBrainzConnectionSettings:
config = self._load_config()
lb_data = config.get("listenbrainz_settings", {})
return ListenBrainzConnectionSettings(
username=lb_data.get("username", ""),
user_token=lb_data.get("user_token", ""),
enabled=lb_data.get("enabled", False),
)
def save_listenbrainz_connection(self, settings: ListenBrainzConnectionSettings) -> None:
try:
config = self._load_config().copy()
config["listenbrainz_settings"] = {
"username": settings.username,
"user_token": settings.user_token,
"enabled": settings.enabled,
}
self._save_config(config)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save ListenBrainz connection settings: {e}")
raise ConfigurationError(f"Failed to save ListenBrainz connection settings: {e}")
def get_youtube_connection(self) -> YouTubeConnectionSettings:
config = self._load_config()
yt_data = config.get("youtube_settings", {})
api_key = str(yt_data.get("api_key") or "")
enabled = yt_data.get("enabled", False)
# Auto-migrate: existing setups with enabled+api_key get api_enabled=True
if "api_enabled" not in yt_data and enabled and api_key.strip():
api_enabled = True
else:
api_enabled = yt_data.get("api_enabled", False)
return YouTubeConnectionSettings(
api_key=api_key,
enabled=enabled,
api_enabled=api_enabled,
daily_quota_limit=yt_data.get("daily_quota_limit", 80),
)
def save_youtube_connection(self, settings: YouTubeConnectionSettings) -> None:
try:
config = self._load_config().copy()
config["youtube_settings"] = {
"api_key": settings.api_key.strip(),
"enabled": settings.enabled,
"api_enabled": settings.api_enabled,
"daily_quota_limit": settings.daily_quota_limit,
}
self._save_config(config)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save YouTube connection settings: {e}")
raise ConfigurationError(f"Failed to save YouTube connection settings: {e}")
def get_home_settings(self) -> HomeSettings:
return self._get_section("home_settings", HomeSettings)
def save_home_settings(self, settings: HomeSettings) -> None:
try:
self._save_section("home_settings", settings)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save home settings: {e}")
raise ConfigurationError(f"Failed to save home settings: {e}")
def get_local_files_connection(self) -> LocalFilesConnectionSettings:
return self._get_section("local_files_settings", LocalFilesConnectionSettings)
def save_local_files_connection(self, settings: LocalFilesConnectionSettings) -> None:
try:
self._save_section("local_files_settings", settings)
except Exception as e: # noqa: BLE001
logger.error("Failed to save local files settings: %s", e)
raise ConfigurationError(f"Failed to save local files settings: {e}")
def get_lastfm_connection(self) -> LastFmConnectionSettings:
return self._get_section("lastfm_settings", LastFmConnectionSettings)
def save_lastfm_connection(self, settings: LastFmConnectionSettings) -> None:
try:
current = self.get_lastfm_connection()
api_key = settings.api_key.strip()
shared_secret = settings.shared_secret
if shared_secret.startswith(LASTFM_SECRET_MASK):
shared_secret = current.shared_secret
else:
shared_secret = shared_secret.strip()
session_key = settings.session_key
if session_key.startswith(LASTFM_SECRET_MASK):
session_key = current.session_key
else:
session_key = session_key.strip()
username = settings.username.strip()
enabled = settings.enabled
if not api_key or not shared_secret:
enabled = False
session_key = ""
username = ""
resolved = LastFmConnectionSettings(
api_key=api_key,
shared_secret=shared_secret,
session_key=session_key,
username=username,
enabled=enabled,
)
self._save_section("lastfm_settings", resolved)
except Exception as e: # noqa: BLE001
logger.error("Failed to save Last.fm connection settings: %s", e)
raise ConfigurationError(f"Failed to save Last.fm connection settings: {e}")
def is_lastfm_enabled(self) -> bool:
settings = self.get_lastfm_connection()
return settings.enabled and bool(settings.api_key) and bool(settings.shared_secret)
def get_scrobble_settings(self) -> ScrobbleSettings:
return self._get_section("scrobble_settings", ScrobbleSettings)
def save_scrobble_settings(self, settings: ScrobbleSettings) -> None:
try:
self._save_section("scrobble_settings", settings)
except Exception as e: # noqa: BLE001
logger.error("Failed to save scrobble settings: %s", e)
raise ConfigurationError(f"Failed to save scrobble settings: {e}")
def get_primary_music_source(self) -> PrimaryMusicSourceSettings:
return self._get_section("primary_music_source", PrimaryMusicSourceSettings)
def save_primary_music_source(self, settings: PrimaryMusicSourceSettings) -> None:
try:
self._save_section("primary_music_source", settings)
except Exception as e: # noqa: BLE001
logger.error("Failed to save primary music source: %s", e)
raise ConfigurationError(f"Failed to save primary music source: {e}")
def get_profile_settings(self) -> ProfileSettings:
return self._get_section("profile_settings", ProfileSettings)
def save_profile_settings(self, settings: ProfileSettings) -> None:
try:
self._save_section("profile_settings", settings)
except Exception as e: # noqa: BLE001
logger.error("Failed to save profile settings: %s", e)
raise ConfigurationError(f"Failed to save profile settings: {e}")
def get_setting(self, key: str) -> Any:
config = self._load_config()
internal = config.get("_internal", {})
return internal.get(key)
def save_setting(self, key: str, value: Any) -> None:
config = self._load_config().copy()
internal = config.get("_internal", {}).copy()
if value is None:
internal.pop(key, None)
else:
internal[key] = value
config["_internal"] = internal
self._save_config(config)
def get_or_create_setting(self, key: str, factory: Any) -> Any:
"""Atomically get or create an internal setting under the cache lock."""
with self._cache_lock:
config = self._load_config()
internal = config.get("_internal", {})
value = internal.get(key)
if value:
return value
value = factory() if callable(factory) else factory
config = config.copy()
internal = internal.copy()
internal[key] = value
config["_internal"] = internal
self._save_config(config)
return value
def get_musicbrainz_connection(self) -> MusicBrainzConnectionSettings:
return self._get_section("musicbrainz_settings", MusicBrainzConnectionSettings)
def save_musicbrainz_connection(self, settings: MusicBrainzConnectionSettings) -> None:
try:
settings.api_url = settings.api_url.rstrip("/")
self._save_section("musicbrainz_settings", settings)
except Exception as e: # noqa: BLE001
logger.error(f"Failed to save MusicBrainz settings: {e}")
raise ConfigurationError(f"Failed to save MusicBrainz settings: {e}")
def _migrate_musicbrainz_settings(self) -> None:
"""One-time migration of musicbrainz_concurrent_searches from advanced_settings."""
try:
config = self._load_config()
if config.get("musicbrainz_settings") is not None:
return
advanced_data = config.get("advanced_settings", {})
old_value = advanced_data.get("musicbrainz_concurrent_searches")
if old_value is not None:
settings = MusicBrainzConnectionSettings(concurrent_searches=int(old_value))
self._save_section("musicbrainz_settings", settings)
logger.info(f"Migrated musicbrainz_concurrent_searches={old_value} to musicbrainz_settings")
except Exception: # noqa: BLE001
logger.warning("Failed to migrate musicbrainz_concurrent_searches, using defaults")
self._save_section("musicbrainz_settings", MusicBrainzConnectionSettings())