ProviderConfig,
)
from music_assistant.common.models.enums import EventType, PlayerState, ProviderType
-from music_assistant.common.models.errors import InvalidDataError, PlayerUnavailableError
+from music_assistant.common.models.errors import (
+ InvalidDataError,
+ PlayerUnavailableError,
+ ProviderUnavailableError,
+)
from music_assistant.constants import (
CONF_CORE,
CONF_PLAYERS,
return
self.remove(conf_key)
- async def set_provider_config_value(
- self, instance_id: str, key: str, value: ConfigValueType
- ) -> None:
- """Set single ProviderConfig value."""
- config = await self.get_provider_config(instance_id)
- config.update({key: value})
- config.validate()
- conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
- self.set(conf_key, config.to_raw())
-
@api_command("config/players")
async def get_player_configs(
self, provider: str | None = None, include_values: bool = False
)
def set_raw_provider_config_value(
- self, provider_instance: str, key: str, value: ConfigValueType
+ self, provider_instance: str, key: str, value: ConfigValueType, encrypted: bool = False
) -> None:
"""
Set (raw) single config(entry) value for a provider.
# only allow setting raw values if main entry exists
msg = f"Invalid provider_instance: {provider_instance}"
raise KeyError(msg)
+ if encrypted:
+ value = self.encrypt_string(value)
+ # also update the cached value in the provider itself
+ if not (prov := self.mass.get_provider(provider_instance, return_unavailable=True)):
+ raise ProviderUnavailableError(provider_instance)
+ prov.config.values[key].value = value
self.set(f"{CONF_PROVIDERS}/{provider_instance}/values/{key}", value)
def set_raw_core_config_value(self, core_module: str, key: str, value: ConfigValueType) -> None:
# should not be overridden in normal circumstances
return self.instance_id if self.manifest.multi_instance else self.domain
+ async def handle_async_init(self) -> None:
+ """Handle async initialization of the provider."""
+
async def loaded_in_mass(self) -> None:
"""Call after the provider has been loaded."""
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = AirplayProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return AirplayProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = AppleMusicProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return AppleMusicProvider(mass, manifest, config)
async def get_config_entries(
if media_type == MediaType.PLAYLIST and prov_item_id in BUILTIN_PLAYLISTS:
# user wants to disable/remove one of our builtin playlists
# to prevent it comes back, we mark it as disabled in config
- await self.mass.config.set_provider_config_value(self.instance_id, prov_item_id, False)
+ self.mass.config.set_raw_provider_config_value(self.instance_id, prov_item_id, False)
return True
if media_type == MediaType.TRACK:
# regular manual track URL/path
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = DeezerProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return DeezerProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = DLNAPlayerProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return DLNAPlayerProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = FanartTvMetadataProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return FanartTvMetadataProvider(mass, manifest, config)
async def get_config_entries(
if not share or "/" in share or "\\" in share:
msg = "Invalid share name"
raise LoginFailed(msg)
- prov = SMBFileSystemProvider(mass, manifest, config)
- await prov.handle_async_init()
- await prov.check_write_access()
- return prov
+ return SMBFileSystemProvider(mass, manifest, config)
async def get_config_entries(
msg = f"Connection failed for the given details: {err}"
raise LoginFailed(msg) from err
+ await self.check_write_access()
+
async def unload(self) -> None:
"""
Handle unload/close of the provider.
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = FullyKioskProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return FullyKioskProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = HomeAssistant(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return HomeAssistant(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = JellyfinProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return JellyfinProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = MusicbrainzProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return MusicbrainzProvider(mass, manifest, config)
async def get_config_entries(
msg = "Invalid login credentials"
raise LoginFailed(msg)
- prov = PlexProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return PlexProvider(mass, manifest, config)
async def get_config_entries( # noqa: PLR0915
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = QobuzProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return QobuzProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = RadioBrowserProvider(mass, manifest, config)
-
- await prov.handle_async_init()
- return prov
+ return RadioBrowserProvider(mass, manifest, config)
async def get_config_entries(
return False
self.logger.debug("Adding radio %s to stored radios", item.item_id)
stored_radios = [*stored_radios, item.item_id]
- await self.mass.config.set_provider_config_value(
+ self.mass.config.set_raw_provider_config_value(
self.instance_id, CONF_STORED_RADIOS, stored_radios
)
return True
return False
self.logger.debug("Removing radio %s from stored radios", prov_item_id)
stored_radios = [x for x in stored_radios if x != prov_item_id]
- await self.mass.config.set_provider_config_value(
+ self.mass.config.set_raw_provider_config_value(
self.instance_id, CONF_STORED_RADIOS, stored_radios
)
return True
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = SlimprotoProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return SlimprotoProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = SnapCastProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return SnapCastProvider(mass, manifest, config)
async def get_config_entries(
logging.getLogger("soco").setLevel(logging.DEBUG)
else:
logging.getLogger("soco").setLevel(prov.logger.level + 10)
- await prov.handle_async_init()
return prov
if not config.get_value(CONF_CLIENT_ID) or not config.get_value(CONF_AUTHORIZATION):
msg = "Invalid login credentials"
raise LoginFailed(msg)
- prov = SoundcloudMusicProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return SoundcloudMusicProvider(mass, manifest, config)
async def get_config_entries(
LoginFailed,
MediaNotFoundError,
ResourceTemporarilyUnavailable,
+ SetupFailedError,
)
from music_assistant.common.models.media_items import (
Album,
CONF_CLIENT_ID = "client_id"
CONF_ACTION_AUTH = "auth"
-CONF_ACCESS_TOKEN = "access_token"
CONF_REFRESH_TOKEN = "refresh_token"
-CONF_AUTH_EXPIRES_AT = "expires_at"
CONF_ACTION_CLEAR_AUTH = "clear_auth"
SCOPE = [
"playlist-read",
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = SpotifyProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ if not config.get_value(CONF_REFRESH_TOKEN):
+ msg = "Re-Authentication required"
+ raise SetupFailedError(msg)
+ return SpotifyProvider(mass, manifest, config)
async def get_config_entries(
"https://accounts.spotify.com/api/token", data=params
) as response:
result = await response.json()
- values[CONF_ACCESS_TOKEN] = result["access_token"]
values[CONF_REFRESH_TOKEN] = result["refresh_token"]
- values[CONF_AUTH_EXPIRES_AT] = int(time.time() + result["expires_in"])
- auth_required = values.get(CONF_REFRESH_TOKEN) is None
+ auth_required = values.get(CONF_REFRESH_TOKEN) in (None, "")
if auth_required:
values[CONF_CLIENT_ID] = None
- values[CONF_ACCESS_TOKEN] = None
label_text = (
"You need to authenticate to Spotify. Click the authenticate button below "
"to start the authentication process which will open in a new (popup) window, "
type=ConfigEntryType.LABEL,
label=label_text,
),
- ConfigEntry(
- key=CONF_ACCESS_TOKEN,
- type=ConfigEntryType.SECURE_STRING,
- label=CONF_ACCESS_TOKEN,
- hidden=True,
- value=values.get(CONF_ACCESS_TOKEN) if values else None,
- ),
ConfigEntry(
key=CONF_REFRESH_TOKEN,
type=ConfigEntryType.SECURE_STRING,
required=True,
value=values.get(CONF_REFRESH_TOKEN) if values else None,
),
- ConfigEntry(
- key=CONF_AUTH_EXPIRES_AT,
- type=ConfigEntryType.INTEGER,
- label=CONF_AUTH_EXPIRES_AT,
- hidden=True,
- default_value=0,
- value=values.get(CONF_AUTH_EXPIRES_AT) if values else None,
- ),
ConfigEntry(
key=CONF_CLIENT_ID,
type=ConfigEntryType.SECURE_STRING,
# try login which will raise if it fails
await self.login()
+ async def loaded_in_mass(self) -> None:
+ """Call after the provider has been loaded."""
+
@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
"""Return the features supported by this Provider."""
async def get_stream_details(self, item_id: str) -> StreamDetails:
"""Return the content details for the given track when it will be streamed."""
+ # make sure that the token is still valid by just requesting it
+ await self.login()
return StreamDetails(
item_id=item_id,
provider=self.instance_id,
self, streamdetails: StreamDetails, seek_position: int = 0
) -> AsyncGenerator[bytes, None]:
"""Return the audio stream for the provider item."""
- # make sure that the token is still valid by just requesting it
auth_info = await self.login()
librespot = await self.get_librespot_binary()
args = [
if self._auth_info and (self._auth_info["expires_at"] > (time.time() - 120)):
return self._auth_info
+ # request new access token using the refresh token
if not (refresh_token := self.config.get_value(CONF_REFRESH_TOKEN)):
raise LoginFailed("Authentication required")
- expires_at = self.config.get_value(CONF_AUTH_EXPIRES_AT) or 0
- access_token = self.config.get_value(CONF_ACCESS_TOKEN)
-
- if expires_at < (time.time() - 300):
- # refresh token
- client_id = self.config.get_value(CONF_CLIENT_ID) or app_var(2)
- params = {
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "client_id": client_id,
- }
- async with self.mass.http_session.post(
- "https://accounts.spotify.com/api/token", data=params
- ) as response:
- if response.status != 200:
- err = await response.text()
- if "revoked" in err:
- # clear refresh token if it's invalid
- self.mass.config.set_raw_provider_config_value(
- self.instance_id, CONF_REFRESH_TOKEN, None
- )
- raise LoginFailed(f"Failed to refresh access token: {err}")
- data = await response.json()
- access_token = data.get("access_token") or access_token
- refresh_token = data.get("refresh_token") or refresh_token
- expires_at = int(data["expires_in"] + time.time())
- self.logger.debug("Successfully refreshed access token")
-
- self._auth_info = auth_info = {
- "access_token": access_token,
+ client_id = self.config.get_value(CONF_CLIENT_ID) or app_var(2)
+ params = {
+ "grant_type": "refresh_token",
"refresh_token": refresh_token,
- "expires_at": expires_at,
+ "client_id": client_id,
}
-
- # make sure that our updated creds get stored in config
- await self.mass.config.set_provider_config_value(
- self.instance_id, CONF_REFRESH_TOKEN, refresh_token
- )
- await self.mass.config.set_provider_config_value(
- self.instance_id, CONF_ACCESS_TOKEN, access_token
- )
- await self.mass.config.set_provider_config_value(
- self.instance_id, CONF_AUTH_EXPIRES_AT, expires_at
+ async with self.mass.http_session.post(
+ "https://accounts.spotify.com/api/token", data=params
+ ) as response:
+ if response.status != 200:
+ err = await response.text()
+ if "revoked" in err:
+ # clear refresh token if it's invalid
+ self.mass.config.set_raw_provider_config_value(
+ self.instance_id, CONF_REFRESH_TOKEN, ""
+ )
+ raise LoginFailed(f"Failed to refresh access token: {err}")
+ auth_info = await response.json()
+ auth_info["expires_at"] = int(auth_info["expires_in"] + time.time())
+ self.logger.debug("Successfully refreshed access token")
+
+ # make sure that our updated creds get stored in memory + config config
+ self._auth_info = auth_info
+ self.mass.config.set_raw_provider_config_value(
+ self.instance_id, CONF_REFRESH_TOKEN, auth_info["refresh_token"], encrypted=True
)
-
# get logged-in user info
if not self._sp_user:
self._sp_user = userinfo = await self._get_data("me", auth_info=auth_info)
if response.status in (502, 503):
raise ResourceTemporarilyUnavailable(backoff_time=30)
+ # handle token expired, raise ResourceTemporarilyUnavailable
+ # so it will be retried (and the token refreshed)
+ if response.status == 401:
+ raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
+
# handle 404 not found, convert to MediaNotFoundError
if response.status == 404:
raise MediaNotFoundError(f"{endpoint} not found")
raise ResourceTemporarilyUnavailable(
"Spotify Rate Limiter", backoff_time=backoff_time
)
+ # handle token expired, raise ResourceTemporarilyUnavailable
+ # so it will be retried (and the token refreshed)
+ if response.status == 401:
+ raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
+
# handle temporary server error
if response.status in (502, 503):
raise ResourceTemporarilyUnavailable(backoff_time=30)
raise ResourceTemporarilyUnavailable(
"Spotify Rate Limiter", backoff_time=backoff_time
)
+ # handle token expired, raise ResourceTemporarilyUnavailable
+ # so it will be retried (and the token refreshed)
+ if response.status == 401:
+ raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
# handle temporary server error
if response.status in (502, 503):
raise ResourceTemporarilyUnavailable(backoff_time=30)
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = AudioDbMetadataProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return AudioDbMetadataProvider(mass, manifest, config)
async def get_config_entries(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = TidalProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return TidalProvider(mass, manifest, config)
async def tidal_auth_url(auth_helper: AuthenticationHelper, quality: str) -> str:
self.mass.config.set_raw_provider_config_value(
self.instance_id, CONF_REFRESH_TOKEN, None
)
- raise LoginFailed("Credentials, expired, you need to re-setup")
+ raise LoginFailed("Credentials expired, you need to re-setup")
raise
@property
refresh_token=str(self.config.get_value(CONF_REFRESH_TOKEN)),
expiry_time=datetime.fromisoformat(str(self.config.get_value(CONF_EXPIRY_TIME))),
)
- await self.mass.config.set_provider_config_value(
+ self.mass.config.set_raw_provider_config_value(
self.config.instance_id,
CONF_AUTH_TOKEN,
self._tidal_session.access_token,
+ encrypted=True,
)
- await self.mass.config.set_provider_config_value(
+ self.mass.config.set_raw_provider_config_value(
self.config.instance_id,
CONF_REFRESH_TOKEN,
self._tidal_session.refresh_token,
+ encrypted=True,
)
- await self.mass.config.set_provider_config_value(
+ self.mass.config.set_raw_provider_config_value(
self.config.instance_id,
CONF_EXPIRY_TIME,
self._tidal_session.expiry_time.isoformat(),
msg = "Username is invalid"
raise LoginFailed(msg)
- prov = TuneInProvider(mass, manifest, config)
- if "@" in config.get_value(CONF_USERNAME):
- prov.logger.warning(
- "Email address detected instead of username, "
- "it is advised to use the tunein username instead of email."
- )
- await prov.handle_async_init()
- return prov
+ return TuneInProvider(mass, manifest, config)
async def get_config_entries(
async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
self._throttler = Throttler(rate_limit=1, period=2)
+ if "@" in self.config.get_value(CONF_USERNAME):
+ self.logger.warning(
+ "Email address detected instead of username, "
+ "it is advised to use the tunein username instead of email."
+ )
async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
"""Retrieve library/subscribed radio stations from the provider."""
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
- prov = YoutubeMusicProvider(mass, manifest, config)
- await prov.handle_async_init()
- return prov
+ return YoutubeMusicProvider(mass, manifest, config)
async def get_config_entries(
except TimeoutError as err:
msg = f"Provider {domain} did not load within 30 seconds"
raise SetupFailedError(msg) from err
+
+ self._providers[provider.instance_id] = provider
+ # run async setup
+ await provider.handle_async_init()
+
# if we reach this point, the provider loaded successfully
LOGGER.info(
"Loaded %s provider %s",
conf.name or conf.domain,
)
provider.available = True
- self._providers[provider.instance_id] = provider
+
self.create_task(provider.loaded_in_mass())
self.config.set(f"{CONF_PROVIDERS}/{conf.instance_id}/last_error", None)
self.signal_event(EventType.PROVIDERS_UPDATED, data=self.get_providers())