from typing import Literal import msgspec from infrastructure.msgspec_fastapi import AppStruct LASTFM_SECRET_MASK = "••••••••" def _mask_secret(value: str) -> str: if not value: return "" if len(value) <= 4: return LASTFM_SECRET_MASK return LASTFM_SECRET_MASK + value[-4:] class LastFmConnectionSettings(AppStruct): api_key: str = "" shared_secret: str = "" session_key: str = "" username: str = "" enabled: bool = False class LastFmConnectionSettingsResponse(AppStruct): api_key: str = "" shared_secret: str = "" session_key: str = "" username: str = "" enabled: bool = False @classmethod def from_settings(cls, settings: LastFmConnectionSettings) -> "LastFmConnectionSettingsResponse": return cls( api_key=settings.api_key, shared_secret=_mask_secret(settings.shared_secret), session_key=_mask_secret(settings.session_key), username=settings.username, enabled=settings.enabled, ) class LastFmVerifyResponse(AppStruct): valid: bool message: str class LastFmAuthTokenResponse(AppStruct): token: str auth_url: str class LastFmAuthSessionRequest(AppStruct): token: str class LastFmAuthSessionResponse(AppStruct): success: bool message: str username: str = "" class UserPreferences(AppStruct): primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"]) secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"]) release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"]) class LidarrConnectionSettings(AppStruct): lidarr_url: str = "http://lidarr:8686" lidarr_api_key: str = "" quality_profile_id: int = 1 metadata_profile_id: int = 1 root_folder_path: str = "/music" def __post_init__(self) -> None: self.lidarr_url = self.lidarr_url.rstrip("/") if self.quality_profile_id < 1: raise msgspec.ValidationError("quality_profile_id must be >= 1") if self.metadata_profile_id < 1: raise msgspec.ValidationError("metadata_profile_id must be >= 1") class JellyfinConnectionSettings(AppStruct): jellyfin_url: str = "http://jellyfin:8096" api_key: str = "" user_id: str = "" enabled: bool = False def __post_init__(self) -> None: self.jellyfin_url = self.jellyfin_url.rstrip("/") NAVIDROME_PASSWORD_MASK = "********" class NavidromeConnectionSettings(AppStruct): navidrome_url: str = "" username: str = "" password: str = "" enabled: bool = False def __post_init__(self) -> None: self.navidrome_url = self.navidrome_url.rstrip("/") if self.navidrome_url else "" class JellyfinUserInfo(AppStruct): id: str name: str class JellyfinVerifyResponse(AppStruct): success: bool message: str users: list[JellyfinUserInfo] = [] class ListenBrainzConnectionSettings(AppStruct): username: str = "" user_token: str = "" enabled: bool = False class YouTubeConnectionSettings(AppStruct): api_key: str = "" enabled: bool = False api_enabled: bool = False daily_quota_limit: int = 80 def __post_init__(self) -> None: if self.daily_quota_limit < 1 or self.daily_quota_limit > 10000: raise msgspec.ValidationError("daily_quota_limit must be between 1 and 10000") def has_valid_api_key(self) -> bool: return bool(self.api_key and self.api_key.strip()) class HomeSettings(AppStruct): cache_ttl_trending: int = 3600 cache_ttl_personal: int = 300 def __post_init__(self) -> None: if self.cache_ttl_trending < 300 or self.cache_ttl_trending > 86400: raise msgspec.ValidationError("cache_ttl_trending must be between 300 and 86400") if self.cache_ttl_personal < 60 or self.cache_ttl_personal > 3600: raise msgspec.ValidationError("cache_ttl_personal must be between 60 and 3600") class LocalFilesConnectionSettings(AppStruct): enabled: bool = False music_path: str = "/music" lidarr_root_path: str = "/music" class LocalFilesVerifyResponse(AppStruct): success: bool message: str track_count: int = 0 class LidarrSettings(AppStruct): sync_frequency: Literal["manual", "5min", "10min", "30min", "1hr", "6hr", "12hr", "24hr", "3d", "7d"] = "24hr" last_sync: int | None = None last_sync_success: bool = True class LidarrProfileSummary(AppStruct): id: int name: str class LidarrRootFolderSummary(AppStruct): id: str path: str class LidarrVerifyResponse(AppStruct): success: bool message: str quality_profiles: list[LidarrProfileSummary] = [] metadata_profiles: list[LidarrProfileSummary] = [] root_folders: list[LidarrRootFolderSummary] = [] class LidarrMetadataProfileSummary(AppStruct): id: int name: str class ScrobbleSettings(AppStruct): scrobble_to_lastfm: bool = False scrobble_to_listenbrainz: bool = False class PrimaryMusicSourceSettings(AppStruct): source: Literal["listenbrainz", "lastfm"] = "listenbrainz" class LidarrMetadataProfilePreferences(AppStruct): profile_id: int profile_name: str primary_types: list[str] = [] secondary_types: list[str] = [] release_statuses: list[str] = []