486 lines
21 KiB
Python
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())
|