Feature/tidal-quality-updates (#857)
authorJozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com>
Fri, 27 Oct 2023 12:09:33 +0000 (14:09 +0200)
committerGitHub <noreply@github.com>
Fri, 27 Oct 2023 12:09:33 +0000 (14:09 +0200)
* Update to latest tidalapi

* Re-add limit to similar tracks

* Add quality selector for tidal login

* Add audio parsing for stream details

* Add hi res helper function

* Remove leftovers

* Fix string check that also evaluated to true with substring

music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/tidal/helpers.py
music_assistant/server/providers/tidal/manifest.json
requirements_all.txt

index 177cf087bceb7a8637050b8942c5b4bb4e8e7427..c2f564ac2c43bf658ec523ce12adf1efa5f99ff5 100644 (file)
@@ -17,7 +17,11 @@ from tidalapi import Session as TidalSession
 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 @@ from music_assistant.common.models.media_items import (
     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_AUTH_TOKEN = "auth_token"
 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 @@ class TidalProvider(MusicProvider):
             )
             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 @@ class TidalProvider(MusicProvider):
         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 @@ class TidalProvider(MusicProvider):
             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 @@ class TidalProvider(MusicProvider):
         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 @@ class TidalProvider(MusicProvider):
                     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 @@ class TidalProvider(MusicProvider):
                     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"
index 86d90e22244f76a7429a048c615ea2516677025e..919d8cb113165cd1114449a7a2ab65948a3f324a 100644 (file)
@@ -261,13 +261,15 @@ async def create_playlist(
     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
index 35dd81c4e4973918a7088467466d24de12bb7e3b..5548d67ea647bf8996327b1bc3f2cdfb5b4475bf 100644 (file)
@@ -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
 }
index c45873dd7941641ac921768f02b7c3bf77375ff1..02db5ab63f0a9792b4598455e3d8868992624032 100644 (file)
@@ -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