diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 177cf087b..c2f564ac2 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -17,7 +17,11 @@ from tidalapi import Track as TidalTrack from tidalapi.media import Lyrics as TidalLyrics -from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType +from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueOption, + ConfigValueType, +) from music_assistant.common.models.enums import ( AlbumType, ConfigEntryType, @@ -42,6 +46,7 @@ Track, ) from music_assistant.server.helpers.auth import AuthenticationHelper +from music_assistant.server.helpers.tags import AudioTags, parse_tags from music_assistant.server.models.music_provider import MusicProvider from .helpers import ( @@ -78,6 +83,7 @@ CONF_REFRESH_TOKEN = "refresh_token" CONF_USER_ID = "user_id" CONF_EXPIRY_TIME = "expiry_time" +CONF_QUALITY = "quality" async def setup( @@ -89,11 +95,11 @@ async def setup( return prov -async def tidal_code_login(auth_helper: AuthenticationHelper) -> TidalSession: +async def tidal_code_login(auth_helper: AuthenticationHelper, quality: str) -> TidalSession: """Async wrapper around the tidalapi Session function.""" def inner() -> TidalSession: - config = TidalConfig(quality=TidalQuality.lossless, item_limit=10000, alac=False) + config = TidalConfig(quality=TidalQuality[quality], item_limit=10000, alac=False) session = TidalSession(config=config) login, future = session.login_oauth() auth_helper.send_url(f"https://{login.verification_uri_complete}") @@ -119,7 +125,7 @@ async def get_config_entries( # config flow auth action/step (authenticate button clicked) if action == CONF_ACTION_AUTH: async with AuthenticationHelper(mass, values["session_id"]) as auth_helper: - tidal_session = await tidal_code_login(auth_helper) + tidal_session = await tidal_code_login(auth_helper, values.get(CONF_QUALITY)) if not tidal_session.check_login(): raise LoginFailed("Authentication to Tidal failed") # set the retrieved token on the values object to pass along @@ -128,14 +134,40 @@ async def get_config_entries( values[CONF_EXPIRY_TIME] = tidal_session.expiry_time.isoformat() values[CONF_USER_ID] = str(tidal_session.user.id) + # config flow auth action/step to pick the library to use + # because this call is very slow, we only show/calculate the dropdown if we do + # not yet have this info or we/user invalidated it. + # return the collected config entries return ( + ConfigEntry( + key=CONF_QUALITY, + type=ConfigEntryType.STRING, + label="Quality", + required=True, + description="The Tidal Quality you wish to use", + options=[ + ConfigValueOption( + title=TidalQuality.low_96k.value, value=TidalQuality.low_96k.name + ), + ConfigValueOption( + title=TidalQuality.low_320k.value, value=TidalQuality.low_320k.name + ), + ConfigValueOption( + title=TidalQuality.high_lossless.value, value=TidalQuality.high_lossless.name + ), + ConfigValueOption(title=TidalQuality.hi_res.value, value=TidalQuality.hi_res.name), + ], + default_value=TidalQuality.high_lossless.name, + value=values.get(CONF_QUALITY) if values else None, + ), ConfigEntry( key=CONF_AUTH_TOKEN, type=ConfigEntryType.SECURE_STRING, label="Authentication token for Tidal", description="You need to link Music Assistant to your Tidal account.", action=CONF_ACTION_AUTH, + depends_on=CONF_QUALITY, action_label="Authenticate on Tidal.com", value=values.get(CONF_AUTH_TOKEN) if values else None, ), @@ -310,14 +342,13 @@ async def get_playlist_tracks( ) yield track - async def get_similar_tracks(self, prov_track_id: str, limit=25) -> list[Track]: # noqa: ARG002 + async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]: """Get similar tracks for given track id.""" tidal_session = await self._get_tidal_session() async with self._throttler: return [ await self._parse_track(track_obj=track) - # Re-add limit here after tidalapi supports it, and remove noqa above - for track in await get_similar_tracks(tidal_session, prov_track_id) + for track in await get_similar_tracks(tidal_session, prov_track_id, limit) ] async def library_add(self, prov_item_id: str, media_type: MediaType): @@ -374,15 +405,17 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: tidal_session = await self._get_tidal_session() track = await get_track(tidal_session, item_id) url = await get_track_url(tidal_session, item_id) + media_info = await self._get_media_info(item_id=item_id, url=url) if not track: raise MediaNotFoundError(f"track {item_id} not found") return StreamDetails( item_id=track.id, provider=self.instance_id, audio_format=AudioFormat( - content_type=ContentType.FLAC, - sample_rate=44100, - bit_depth=16, + content_type=ContentType.try_parse(media_info.format), + sample_rate=media_info.sample_rate, + bit_depth=media_info.bits_per_sample, + channels=media_info.channels, ), duration=track.duration, direct=url, @@ -441,6 +474,7 @@ async def _get_tidal_session(self) -> TidalSession: return self._tidal_session self._tidal_session = await self._load_tidal_session( token_type="Bearer", + quality=self.config.get_value(CONF_QUALITY), access_token=self.config.get_value(CONF_AUTH_TOKEN), refresh_token=self.config.get_value(CONF_REFRESH_TOKEN), expiry_time=datetime.fromisoformat(self.config.get_value(CONF_EXPIRY_TIME)), @@ -463,12 +497,12 @@ async def _get_tidal_session(self) -> TidalSession: return self._tidal_session async def _load_tidal_session( - self, token_type, access_token, refresh_token=None, expiry_time=None + self, token_type, quality: TidalQuality, access_token, refresh_token=None, expiry_time=None ) -> TidalSession: """Load the tidalapi Session.""" def inner() -> TidalSession: - config = TidalConfig(quality=TidalQuality.lossless, item_limit=10000, alac=False) + config = TidalConfig(quality=TidalQuality[quality], item_limit=10000, alac=False) session = TidalSession(config=config) session.load_oauth_session(token_type, access_token, refresh_token, expiry_time) return session @@ -592,8 +626,7 @@ async def _parse_track( provider_instance=self.instance_id, audio_format=AudioFormat( content_type=ContentType.FLAC, - sample_rate=44100, - bit_depth=16, + bit_depth=24 if self._is_hi_res(track_obj=track_obj) else 16, ), isrc=track_obj.isrc, url=f"http://www.tidal.com/tracks/{track_id}", @@ -699,3 +732,22 @@ async def _iter_items( yield item if len(chunk) < DEFAULT_LIMIT: break + + async def _get_media_info( + self, item_id: str, url: str, force_refresh: bool = False + ) -> AudioTags: + """Retrieve (cached) mediainfo for track.""" + cache_key = f"{self.instance_id}.media_info.{item_id}" + # do we have some cached info for this url ? + cached_info = await self.mass.cache.get(cache_key) + if cached_info and not force_refresh: + media_info = AudioTags.parse(cached_info) + else: + # parse info with ffprobe (and store in cache) + media_info = await parse_tags(url) + await self.mass.cache.set(cache_key, media_info.raw) + return media_info + + def _is_hi_res(self, track_obj: TidalTrack) -> bool: + """Check if track is hi-res.""" + return track_obj.audio_quality.value == "HI_RES" diff --git a/music_assistant/server/providers/tidal/helpers.py b/music_assistant/server/providers/tidal/helpers.py index 86d90e222..919d8cb11 100644 --- a/music_assistant/server/providers/tidal/helpers.py +++ b/music_assistant/server/providers/tidal/helpers.py @@ -261,13 +261,15 @@ def inner() -> TidalPlaylist: return await asyncio.to_thread(inner) -async def get_similar_tracks(session: TidalSession, prov_track_id) -> list[TidalTrack]: +async def get_similar_tracks( + session: TidalSession, prov_track_id: str, limit: int = 25 +) -> list[TidalTrack]: """Async wrapper around the tidal Track.get_similar_tracks function.""" def inner() -> list[TidalTrack]: try: # Re-add limit here after tidalapi supports it - return TidalTrack(session, prov_track_id).get_track_radio() + return TidalTrack(session, prov_track_id).get_track_radio(limit=limit) except HTTPError as err: if err.response.status_code == 404: raise MediaNotFoundError(f"Track {prov_track_id} not found") from err diff --git a/music_assistant/server/providers/tidal/manifest.json b/music_assistant/server/providers/tidal/manifest.json index 35dd81c4e..5548d67ea 100644 --- a/music_assistant/server/providers/tidal/manifest.json +++ b/music_assistant/server/providers/tidal/manifest.json @@ -4,7 +4,7 @@ "name": "Tidal", "description": "Support for the Tidal streaming provider in Music Assistant.", "codeowners": ["@jozefKruszynski"], - "requirements": ["tidalapi==0.7.2"], + "requirements": ["tidalapi==0.7.3"], "documentation": "https://github.com/orgs/music-assistant/discussions/1201", "multi_instance": true } diff --git a/requirements_all.txt b/requirements_all.txt index c45873dd7..02db5ab63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -27,7 +27,7 @@ pycryptodome==3.18.0 python-slugify==8.0.1 shortuuid==1.0.11 soco==0.29.1 -tidalapi==0.7.2 +tidalapi==0.7.3 unidecode==1.3.6 uvloop==0.17.0 xmltodict==0.13.0