Add Tidal Music provider (#626)
authorJozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com>
Tue, 18 Apr 2023 09:54:41 +0000 (11:54 +0200)
committerGitHub <noreply@github.com>
Tue, 18 Apr 2023 09:54:41 +0000 (11:54 +0200)
Initial implementation of a music provider for Tidal.

---------

Co-authored-by: Jozef Kruszynski <j.kruszynski@rewe-group.at>
Co-authored-by: jkruszynski <jozef@krush.at>
.gitignore
music_assistant/server/providers/tidal/__init__.py [new file with mode: 0644]
music_assistant/server/providers/tidal/helpers.py [new file with mode: 0644]
music_assistant/server/providers/tidal/icon.png [new file with mode: 0644]
music_assistant/server/providers/tidal/manifest.json [new file with mode: 0644]
requirements_all.txt

index 8e3aed5b27af57b616c848873adb367cbf347c80..9356c840e9951c18fb6a48316de4e970296b0c67 100644 (file)
@@ -14,3 +14,5 @@ venv/
 .tox/
 *.egg-info/
 *.spec
+.history
+.idea
diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py
new file mode 100644 (file)
index 0000000..5fbf459
--- /dev/null
@@ -0,0 +1,373 @@
+"""Tidal music provider support for MusicAssistant."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from typing import TYPE_CHECKING
+
+from tidalapi import Session as TidalSession
+
+from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant.common.models.enums import ConfigEntryType, MediaType, ProviderFeature
+from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
+from music_assistant.common.models.media_items import (
+    Album,
+    Artist,
+    ContentType,
+    Playlist,
+    SearchResults,
+    StreamDetails,
+    Track,
+)
+from music_assistant.server.helpers.auth import AuthenticationHelper
+from music_assistant.server.models.music_provider import MusicProvider
+
+from .helpers import (
+    add_remove_playlist_tracks,
+    create_playlist,
+    get_album,
+    get_album_tracks,
+    get_artist,
+    get_artist_albums,
+    get_artist_toptracks,
+    get_library_albums,
+    get_library_artists,
+    get_library_playlists,
+    get_library_tracks,
+    get_playlist,
+    get_playlist_tracks,
+    get_similar_tracks,
+    get_tidal_session,
+    get_track,
+    get_track_url,
+    library_items_add_remove,
+    parse_album,
+    parse_artist,
+    parse_playlist,
+    parse_track,
+    search,
+    tidal_code_login,
+)
+
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+TOKEN_TYPE = "Bearer"
+CONF_ACTION_AUTH = "auth"
+CONF_AUTH_TOKEN = "auth_token"
+CONF_REFRESH_TOKEN = "refresh_token"
+CONF_USER_ID = "user_id"
+CONF_EXPIRY_TIME = "expiry_time"
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = TidalProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,  # noqa: ARG001
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    # 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)
+            if not tidal_session.check_login():
+                raise LoginFailed("Authentication to Tidal failed")
+            # set the retrieved token on the values object to pass along
+            values[CONF_AUTH_TOKEN] = tidal_session.access_token
+            values[CONF_REFRESH_TOKEN] = tidal_session.refresh_token
+            values[CONF_EXPIRY_TIME] = tidal_session.expiry_time.isoformat()
+            values[CONF_USER_ID] = str(tidal_session.user.id)
+
+    # return the collected config entries
+    return (
+        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,
+            action_label="Authenticate on Tidal.com",
+            value=values.get(CONF_AUTH_TOKEN) if values else None,
+        ),
+        ConfigEntry(
+            key=CONF_REFRESH_TOKEN,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Refresh token for Tidal",
+            description="You need to link Music Assistant to your Tidal account.",
+            hidden=True,
+            value=values.get(CONF_REFRESH_TOKEN) if values else None,
+        ),
+        ConfigEntry(
+            key=CONF_EXPIRY_TIME,
+            type=ConfigEntryType.STRING,
+            label="Expiry time of auth token for Tidal",
+            hidden=True,
+            value=values.get(CONF_EXPIRY_TIME) if values else None,
+        ),
+        ConfigEntry(
+            key=CONF_USER_ID,
+            type=ConfigEntryType.STRING,
+            label="Your Tidal User ID",
+            description="This is your unique Tidal user ID.",
+            hidden=True,
+            value=values.get(CONF_USER_ID) if values else None,
+        ),
+    )
+
+
+class TidalProvider(MusicProvider):
+    """Implementation of a Tidal MusicProvider."""
+
+    _tidal_session: TidalSession | None = None
+    _tidal_user_id: str | None = None
+
+    async def handle_setup(self) -> None:
+        """Handle async initialization of the provider."""
+        self._tidal_user_id = self.config.get_value(CONF_USER_ID)
+        self._tidal_session = await get_tidal_session(self)
+
+    @property
+    def supported_features(self) -> tuple[ProviderFeature, ...]:
+        """Return the features supported by this Provider."""
+        return (
+            ProviderFeature.LIBRARY_ARTISTS,
+            ProviderFeature.LIBRARY_ALBUMS,
+            ProviderFeature.LIBRARY_TRACKS,
+            ProviderFeature.LIBRARY_PLAYLISTS,
+            ProviderFeature.ARTIST_ALBUMS,
+            ProviderFeature.ARTIST_TOPTRACKS,
+            ProviderFeature.SEARCH,
+            ProviderFeature.LIBRARY_ARTISTS_EDIT,
+            ProviderFeature.LIBRARY_ALBUMS_EDIT,
+            ProviderFeature.LIBRARY_TRACKS_EDIT,
+            ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+            ProviderFeature.PLAYLIST_CREATE,
+            ProviderFeature.SIMILAR_TRACKS,
+            ProviderFeature.BROWSE,
+            ProviderFeature.PLAYLIST_TRACKS_EDIT,
+        )
+
+    async def search(
+        self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
+    ) -> SearchResults:
+        """Perform search on musicprovider.
+
+        :param search_query: Search query.
+        :param media_types: A list of media_types to include. All types if None.
+        :param limit: Number of items to return in the search (per type).
+        """
+        search_query = search_query.replace("'", "")
+        results = await search(self, search_query, media_types, limit)
+        parsed_results = SearchResults()
+        if results["artists"]:
+            for artist in results["artists"]:
+                parsed_results.artists.append(await parse_artist(artist))
+        if results["albums"]:
+            for album in results["albums"]:
+                parsed_results.albums.append(await parse_album(album))
+        if results["playlists"]:
+            for playlist in results["playlists"]:
+                parsed_results.playlists.append(await parse_playlist(playlist))
+        if results["tracks"]:
+            for track in results["tracks"]:
+                parsed_results.tracks.append(await parse_track(track))
+        return parsed_results
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve all library artists from Tidal."""
+        artists_obj = await get_library_artists(self, self._tidal_user_id)
+        for artist in artists_obj:
+            yield parse_artist(tidal_provider=self, artist_obj=artist)
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve all library albums from Tidal."""
+        albums_obj = await get_library_albums(self, self._tidal_user_id)
+        for album in albums_obj:
+            yield parse_album(tidal_provider=self, album_obj=album)
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from Tidal."""
+        tracks_obj = await get_library_tracks(self, self._tidal_user_id)
+        for track in tracks_obj:
+            if track.available:
+                yield parse_track(tidal_provider=self, track_obj=track)
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve all library playlists from the provider."""
+        playlists_obj = await get_library_playlists(self, self._tidal_user_id)
+        for playlist in playlists_obj:
+            yield parse_playlist(tidal_provider=self, playlist_obj=playlist)
+
+    async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
+        """Get album tracks for given album id."""
+        result = []
+        tracks = await get_album_tracks(self, prov_album_id)
+        for index, track_obj in enumerate(tracks, 1):
+            if track_obj.available:
+                track = parse_track(tidal_provider=self, track_obj=track_obj)
+                track.position = index
+                result.append(track)
+        return result
+
+    async def get_artist_albums(self, prov_artist_id) -> list[Album]:
+        """Get a list of all albums for the given artist."""
+        result = []
+        albums = await get_artist_albums(self, prov_artist_id)
+        for album_obj in albums:
+            album = parse_album(tidal_provider=self, album_obj=album_obj)
+            result.append(album)
+        return result
+
+    async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
+        """Get a list of 10 most popular tracks for the given artist."""
+        result = []
+        tracks = await get_artist_toptracks(self, prov_artist_id)
+        for index, track_obj in enumerate(tracks, 1):
+            if track_obj.available:
+                track = parse_track(tidal_provider=self, track_obj=track_obj)
+                track.position = index
+                result.append(track)
+        return result
+
+    async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
+        """Get all playlist tracks for given playlist id."""
+        tracks = await get_playlist_tracks(self, prov_playlist_id=prov_playlist_id)
+        for index, track_obj in enumerate(tracks):
+            if track_obj.available:
+                track = parse_track(tidal_provider=self, track_obj=track_obj)
+                track.position = index + 1
+                yield track
+
+    async def get_similar_tracks(self, prov_track_id, limit=25) -> list[Track]:
+        """Get similar tracks for given track id."""
+        similar_tracks_obj = await get_similar_tracks(self, prov_track_id, limit)
+        tracks = []
+        for track_obj in similar_tracks_obj:
+            if track_obj.available:
+                track = parse_track(tidal_provider=self, track_obj=track_obj)
+                tracks.append(track)
+        return tracks
+
+    async def library_add(self, prov_item_id, media_type: MediaType):
+        """Add item to library."""
+        return await library_items_add_remove(
+            self,
+            self._tidal_user_id,
+            prov_item_id,
+            media_type,
+            add=True,
+        )
+
+    async def library_remove(self, prov_item_id, media_type: MediaType):
+        """Remove item from library."""
+        return await library_items_add_remove(
+            self,
+            self._tidal_user_id,
+            prov_item_id,
+            media_type,
+            add=False,
+        )
+
+    async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]):
+        """Add track(s) to playlist."""
+        return await add_remove_playlist_tracks(
+            self._tidal_session, prov_playlist_id, prov_track_ids, add=True
+        )
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        prov_track_ids = []
+        async for track in self.get_playlist_tracks(prov_playlist_id):
+            if track.position in positions_to_remove:
+                prov_track_ids.append(track.item_id)
+            if len(prov_track_ids) == len(positions_to_remove):
+                break
+        return await add_remove_playlist_tracks(self, prov_playlist_id, prov_track_ids, add=False)
+
+    async def create_playlist(self, name: str) -> Playlist:
+        """Create a new playlist on provider with given name."""
+        playlist_obj = await create_playlist(self, self._tidal_user_id, name)
+        playlist = parse_playlist(tidal_provider=self, playlist_obj=playlist_obj)
+        return await self.mass.music.playlists.add_db_item(playlist)
+
+    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 a valid track is requested.
+        track = await get_track(self, item_id)
+        url = await get_track_url(self, item_id)
+        if not track:
+            raise MediaNotFoundError(f"track {item_id} not found")
+        # make sure that the token is still valid by just requesting it
+        await self.get_tidal_session()
+        return StreamDetails(
+            item_id=track.id,
+            provider=self.instance_id,
+            content_type=ContentType.FLAC,
+            duration=track.duration,
+            direct=url,
+        )
+
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get artist details for given artist id."""
+        try:
+            artist = parse_artist(
+                tidal_provider=self,
+                artist_obj=await get_artist(self, prov_artist_id),
+            )
+        except MediaNotFoundError as err:
+            raise MediaNotFoundError(f"Artist {prov_artist_id} not found") from err
+        return artist
+
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get album details for given album id."""
+        try:
+            album = parse_album(
+                tidal_provider=self,
+                album_obj=await get_album(self, prov_album_id),
+            )
+        except MediaNotFoundError as err:
+            raise MediaNotFoundError(f"Album {prov_album_id} not found") from err
+        return album
+
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get track details for given track id."""
+        try:
+            track = parse_track(
+                tidal_provider=self,
+                track_obj=await get_track(self, prov_track_id),
+            )
+        except MediaNotFoundError as err:
+            raise MediaNotFoundError(f"Track {prov_track_id} not found") from err
+        return track
+
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get playlist details for given playlist id."""
+        try:
+            playlist = parse_playlist(
+                tidal_provider=self,
+                playlist_obj=await get_playlist(self, prov_playlist_id),
+            )
+        except MediaNotFoundError as err:
+            raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from err
+        return playlist
diff --git a/music_assistant/server/providers/tidal/helpers.py b/music_assistant/server/providers/tidal/helpers.py
new file mode 100644 (file)
index 0000000..57fb0d0
--- /dev/null
@@ -0,0 +1,516 @@
+"""Helper module for parsing the Tidal API.
+
+This helpers file is an async wrapper around the excellent tidalapi package.
+While the tidalapi package does an excellent job at parsing the Tidal results,
+it is unfortunately not async, which is required for Music Assistant to run smoothly.
+This also nicely separates the parsing logic from the Tidal provider logic.
+
+CREDITS:
+tidalapi: https://github.com/tamland/python-tidal
+"""
+
+import asyncio
+from datetime import datetime, timedelta
+from functools import partial, wraps
+
+from requests import HTTPError
+from tidalapi import Album as TidalAlbum
+from tidalapi import Artist as TidalArtist
+from tidalapi import Config as TidalConfig
+from tidalapi import Favorites as TidalFavorites
+from tidalapi import LoggedInUser
+from tidalapi import Playlist as TidalPlaylist
+from tidalapi import Quality as TidalQuality
+from tidalapi import Session as TidalSession
+from tidalapi import Track as TidalTrack
+from tidalapi import UserPlaylist as TidalUserPlaylist
+
+from music_assistant.common.helpers.uri import create_uri
+from music_assistant.common.helpers.util import create_sort_name
+from music_assistant.common.models.enums import AlbumType, ContentType, ImageType, MediaType
+from music_assistant.common.models.errors import MediaNotFoundError
+from music_assistant.common.models.media_items import (
+    Album,
+    Artist,
+    ItemMapping,
+    MediaItemImage,
+    MediaItemMetadata,
+    Playlist,
+    ProviderMapping,
+    Track,
+)
+from music_assistant.server.helpers.auth import AuthenticationHelper
+
+CONF_AUTH_TOKEN = "auth_token"
+CONF_REFRESH_TOKEN = "refresh_token"
+CONF_USER_ID = "user_id"
+CONF_EXPIRY_TIME = "expiry_time"
+
+
+# Parsers
+
+
+def parse_artist(tidal_provider, artist_obj: TidalArtist) -> Artist:
+    """Parse tidal artist object to generic layout."""
+    artist_id = artist_obj.id
+    artist = Artist(item_id=artist_id, provider=tidal_provider.instance_id, name=artist_obj.name)
+    artist.add_provider_mapping(
+        ProviderMapping(
+            item_id=str(artist_id),
+            provider_domain=tidal_provider.domain,
+            provider_instance=tidal_provider.instance_id,
+            url=f"http://www.tidal.com/artist/{artist_id}",
+        )
+    )
+    artist.metadata = parse_artist_metadata(tidal_provider, artist_obj)
+    return artist
+
+
+def parse_artist_metadata(tidal_provider, artist_obj: TidalArtist) -> MediaItemMetadata:
+    """Parse tidal artist object to MA metadata."""
+    metadata = MediaItemMetadata()
+    image_url = None
+    if artist_obj.name != "Various Artists":
+        try:
+            image_url = artist_obj.image(750)
+        except Exception:
+            tidal_provider.logger.info(f"Artist {artist_obj.id} has no available picture")
+    metadata.images = [
+        MediaItemImage(
+            ImageType.THUMB,
+            image_url,
+        )
+    ]
+    return metadata
+
+
+def parse_album(tidal_provider, album_obj: TidalAlbum) -> Album:
+    """Parse tidal album object to generic layout."""
+    name = album_obj.name
+    version = album_obj.version if album_obj.version is not None else None
+    album_id = album_obj.id
+    album = Album(item_id=album_id, provider=tidal_provider.instance_id, name=name, version=version)
+    for artist_obj in album_obj.artists:
+        album.artists.append(parse_artist(tidal_provider=tidal_provider, artist_obj=artist_obj))
+    if album_obj.type == "ALBUM":
+        album.album_type = AlbumType.ALBUM
+    elif album_obj.type == "COMPILATION":
+        album.album_type = AlbumType.COMPILATION
+    elif album_obj.type == "EP":
+        album.album_type = AlbumType.EP
+    elif album_obj.type == "SINGLE":
+        album.album_type = AlbumType.SINGLE
+
+    album.upc = album_obj.universal_product_number
+    album.year = int(album_obj.year)
+    album.add_provider_mapping(
+        ProviderMapping(
+            item_id=album_id,
+            provider_domain=tidal_provider.domain,
+            provider_instance=tidal_provider.instance_id,
+            content_type=ContentType.FLAC,
+            url=f"http://www.tidal.com/album/{album_id}",
+        )
+    )
+    album.metadata = parse_album_metadata(tidal_provider, album_obj)
+    return album
+
+
+def parse_album_metadata(tidal_provider, album_obj: TidalAlbum) -> MediaItemMetadata:
+    """Parse tidal album object to MA metadata."""
+    metadata = MediaItemMetadata()
+    image_url = None
+    try:
+        image_url = album_obj.image(1280)
+    except Exception:
+        tidal_provider.logger.info(f"Album {album_obj.id} has no available picture")
+    metadata.images = [
+        MediaItemImage(
+            ImageType.THUMB,
+            image_url,
+        )
+    ]
+    metadata.copyright = album_obj.copyright
+    metadata.explicit = album_obj.explicit
+    metadata.popularity = album_obj.popularity
+    return metadata
+
+
+def parse_track(tidal_provider, track_obj: TidalTrack) -> Track:
+    """Parse tidal track object to generic layout."""
+    version = track_obj.version if track_obj.version is not None else None
+    track_id = str(track_obj.id)
+    track = Track(
+        item_id=track_id,
+        provider=tidal_provider.instance_id,
+        name=track_obj.name,
+        version=version,
+        duration=track_obj.duration,
+        disc_number=track_obj.volume_num,
+        track_number=track_obj.track_num,
+    )
+    track.isrc.add(track_obj.isrc)
+    track.album = get_item_mapping(
+        tidal_provider=tidal_provider,
+        media_type=MediaType.ALBUM,
+        key=track_obj.album.id,
+        name=track_obj.album.name,
+    )
+    track.artists = []
+    for track_artist in track_obj.artists:
+        artist = parse_artist(tidal_provider=tidal_provider, artist_obj=track_artist)
+        track.artists.append(artist)
+    available = track_obj.available
+    track.add_provider_mapping(
+        ProviderMapping(
+            item_id=track_id,
+            provider_domain=tidal_provider.domain,
+            provider_instance=tidal_provider.instance_id,
+            content_type=ContentType.FLAC,
+            bit_rate=1411,
+            url=f"http://www.tidal.com/tracks/{track_id}",
+            available=available,
+        )
+    )
+    track.metadata = parse_track_metadata(tidal_provider, track_obj)
+    return track
+
+
+def parse_track_metadata(tidal_provider, track_obj: TidalTrack) -> MediaItemMetadata:
+    """Parse tidal track object to MA metadata."""
+    metadata = MediaItemMetadata()
+    try:
+        metadata.lyrics = track_obj.lyrics().text
+    except Exception:
+        tidal_provider.logger.info(f"Track {track_obj.id} has no available lyrics")
+    metadata.explicit = track_obj.explicit
+    metadata.popularity = track_obj.popularity
+    metadata.copyright = track_obj.copyright
+    return metadata
+
+
+def parse_playlist(tidal_provider, playlist_obj: TidalPlaylist) -> Playlist:
+    """Parse tidal playlist object to generic layout."""
+    playlist_id = playlist_obj.id
+    creator_id = playlist_obj.creator.id if playlist_obj.creator else None
+    creator_name = playlist_obj.creator.name if playlist_obj.creator else "Tidal"
+    playlist = Playlist(
+        item_id=playlist_id,
+        provider=tidal_provider.instance_id,
+        name=playlist_obj.name,
+        owner=creator_name,
+    )
+    playlist.add_provider_mapping(
+        ProviderMapping(
+            item_id=playlist_id,
+            provider_domain=tidal_provider.domain,
+            provider_instance=tidal_provider.instance_id,
+            url=f"http://www.tidal.com/playlists/{playlist_id}",
+        )
+    )
+    is_editable = bool(creator_id and creator_id == tidal_provider._tidal_user_id)
+    playlist.is_editable = is_editable
+    playlist.metadata = parse_playlist_metadata(tidal_provider, playlist_obj)
+    return playlist
+
+
+def parse_playlist_metadata(tidal_provider, playlist_obj: TidalPlaylist) -> MediaItemMetadata:
+    """Parse tidal playlist object to MA metadata."""
+    metadata = MediaItemMetadata()
+    image_url = None
+    try:
+        image_url = playlist_obj.image(1080)
+    except Exception:
+        tidal_provider.logger.info(f"Playlist {playlist_obj.id} has no available picture")
+    metadata.images = [
+        MediaItemImage(
+            ImageType.THUMB,
+            image_url,
+        )
+    ]
+    metadata.checksum = str(playlist_obj.last_updated)
+    metadata.popularity = playlist_obj.popularity
+    return metadata
+
+
+# Helper functions
+
+
+def async_wrap(func):
+    """Async decorator for all tidalapi functions."""
+
+    @wraps(func)
+    async def run(*args, loop=None, executor=None, **kwargs):
+        if loop is None:
+            loop = asyncio.get_event_loop()
+        pfunc = partial(func, *args, **kwargs)
+        return await loop.run_in_executor(executor, pfunc)
+
+    return run
+
+
+# Login and session management
+def get_session(func):
+    """Async decorator to get a tidal session."""
+
+    @wraps(func)
+    async def wrapper(tidal_provider, *args, **kwargs):
+        return await func(await get_tidal_session(tidal_provider), *args, **kwargs)
+
+    return wrapper
+
+
+async def get_tidal_session(tidal_provider) -> TidalSession:
+    """Ensure the current token is valid and return a tidal session."""
+    if (
+        tidal_provider._tidal_session
+        and tidal_provider._tidal_session.access_token
+        and datetime.fromisoformat(tidal_provider.config.get_value(CONF_EXPIRY_TIME))
+        > (datetime.now() + timedelta(days=1))
+    ):
+        return tidal_provider._tidal_session
+    tidal_provider._tidal_session = await load_tidal_session(
+        token_type="Bearer",
+        access_token=tidal_provider.config.get_value(CONF_AUTH_TOKEN),
+        refresh_token=tidal_provider.config.get_value(CONF_REFRESH_TOKEN),
+        expiry_time=datetime.fromisoformat(tidal_provider.config.get_value(CONF_EXPIRY_TIME)),
+    )
+    await tidal_provider.mass.config.set_provider_config_value(
+        tidal_provider.config.instance_id,
+        CONF_AUTH_TOKEN,
+        tidal_provider._tidal_session.access_token,
+    )
+    await tidal_provider.mass.config.set_provider_config_value(
+        tidal_provider.config.instance_id,
+        CONF_REFRESH_TOKEN,
+        tidal_provider._tidal_session.refresh_token,
+    )
+    await tidal_provider.mass.config.set_provider_config_value(
+        tidal_provider.config.instance_id,
+        CONF_EXPIRY_TIME,
+        tidal_provider._tidal_session.expiry_time.isoformat(),
+    )
+    return tidal_provider._tidal_session
+
+
+@async_wrap
+def tidal_code_login(auth_helper: AuthenticationHelper) -> TidalSession:
+    """Async wrapper around the tidalapi Session function."""
+    config = TidalConfig(quality=TidalQuality.lossless, item_limit=10000, alac=False)
+    session = TidalSession(config=config)
+    login, future = session.login_oauth()
+    auth_helper.send_url(f"https://{login.verification_uri_complete}")
+    future.result()
+    return session
+
+
+@async_wrap
+def load_tidal_session(
+    token_type, access_token, refresh_token=None, expiry_time=None
+) -> TidalSession:
+    """Async wrapper around the tidalapi Session function."""
+    config = TidalConfig(quality=TidalQuality.lossless, item_limit=10000, alac=False)
+    session = TidalSession(config=config)
+    session.load_oauth_session(token_type, access_token, refresh_token, expiry_time)
+    return session
+
+
+@get_session
+@async_wrap
+def get_library_artists(session: TidalSession, user_id: str) -> dict[str, str]:
+    """Async wrapper around the tidalapi Favorites.artists function."""
+    return TidalFavorites(session, user_id).artists(limit=9999)
+
+
+@get_session
+@async_wrap
+def library_items_add_remove(
+    session: TidalSession, user_id: str, item_id: str, media_type: MediaType, add: bool = True
+) -> None:
+    """Async wrapper around the tidalapi Favorites.items add/remove function."""
+    match media_type:
+        case MediaType.ARTIST:
+            TidalFavorites(session, user_id).add_artist(item_id) if add else TidalFavorites(
+                session, user_id
+            ).remove_artist(item_id)
+        case MediaType.ALBUM:
+            TidalFavorites(session, user_id).add_album(item_id) if add else TidalFavorites(
+                session, user_id
+            ).remove_album(item_id)
+        case MediaType.TRACK:
+            TidalFavorites(session, user_id).add_track(item_id) if add else TidalFavorites(
+                session, user_id
+            ).remove_track(item_id)
+        case MediaType.PLAYLIST:
+            TidalFavorites(session, user_id).add_playlist(item_id) if add else TidalFavorites(
+                session, user_id
+            ).remove_playlist(item_id)
+        case MediaType.UNKNOWN:
+            return
+
+
+@get_session
+@async_wrap
+def get_artist(session: TidalSession, prov_artist_id: str) -> TidalArtist:
+    """Async wrapper around the tidalapi Artist function."""
+    try:
+        artist_obj = TidalArtist(session, prov_artist_id)
+    except HTTPError as err:
+        raise MediaNotFoundError(f"Artist {prov_artist_id} not found") from err
+    return artist_obj
+
+
+@get_session
+@async_wrap
+def get_artist_albums(session: TidalSession, prov_artist_id: str) -> list[TidalAlbum]:
+    """Async wrapper around 3 tidalapi album functions."""
+    all_albums = []
+    albums = TidalArtist(session, prov_artist_id).get_albums(limit=9999)
+    eps_singles = TidalArtist(session, prov_artist_id).get_albums_ep_singles(limit=9999)
+    compilations = TidalArtist(session, prov_artist_id).get_albums_other(limit=9999)
+    all_albums.extend(albums)
+    all_albums.extend(eps_singles)
+    all_albums.extend(compilations)
+    return all_albums
+
+
+@get_session
+@async_wrap
+def get_artist_toptracks(session: TidalSession, prov_artist_id: str) -> list[TidalTrack]:
+    """Async wrapper around the tidalapi Artist.get_top_tracks function."""
+    return TidalArtist(session, prov_artist_id).get_top_tracks(limit=10)
+
+
+@get_session
+@async_wrap
+def get_library_albums(session: TidalSession, user_id: str) -> list[TidalAlbum]:
+    """Async wrapper around the tidalapi Favorites.albums function."""
+    return TidalFavorites(session, user_id).albums(limit=9999)
+
+
+@get_session
+@async_wrap
+def get_album(session: TidalSession, prov_album_id: str) -> TidalAlbum:
+    """Async wrapper around the tidalapi Album function."""
+    try:
+        album_obj = TidalAlbum(session, prov_album_id)
+    except HTTPError as err:
+        raise MediaNotFoundError(f"Album {prov_album_id} not found") from err
+    return album_obj
+
+
+@get_session
+@async_wrap
+def get_track(session: TidalSession, prov_track_id: str) -> TidalTrack:
+    """Async wrapper around the tidalapi Track function."""
+    try:
+        track_obj = TidalTrack(session, prov_track_id)
+    except HTTPError as err:
+        raise MediaNotFoundError(f"Track {prov_track_id} not found") from err
+    return track_obj
+
+
+@get_session
+@async_wrap
+def get_track_url(session: TidalSession, prov_track_id: str) -> dict[str, str]:
+    """Async wrapper around the tidalapi Track.get_url function."""
+    return TidalTrack(session, prov_track_id).get_url()
+
+
+@get_session
+@async_wrap
+def get_album_tracks(session: TidalSession, prov_album_id: str) -> list[TidalTrack]:
+    """Async wrapper around the tidalapi Album.tracks function."""
+    return TidalAlbum(session, prov_album_id).tracks()
+
+
+@get_session
+@async_wrap
+def get_library_tracks(session: TidalSession, user_id: str) -> list[TidalTrack]:
+    """Async wrapper around the tidalapi Favorites.tracks function."""
+    return TidalFavorites(session, user_id).tracks(limit=9999)
+
+
+@get_session
+@async_wrap
+def get_library_playlists(session: TidalSession, user_id: str) -> list[TidalPlaylist]:
+    """Async wrapper around the tidalapi LoggedInUser.playlist_and_favorite_playlists function."""
+    return LoggedInUser(session, user_id).playlist_and_favorite_playlists()
+
+
+@get_session
+@async_wrap
+def get_playlist(session: TidalSession, prov_playlist_id: str) -> TidalPlaylist:
+    """Async wrapper around the tidal Playlist function."""
+    try:
+        playlist_obj = TidalPlaylist(session, prov_playlist_id)
+    except HTTPError as err:
+        raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from err
+    return playlist_obj
+
+
+@get_session
+@async_wrap
+def get_playlist_tracks(session: TidalSession, prov_playlist_id: str) -> list[TidalTrack]:
+    """Async wrapper around the tidal Playlist.tracks function."""
+    return TidalPlaylist(session, prov_playlist_id).tracks(limit=9999)
+
+
+@get_session
+@async_wrap
+def add_remove_playlist_tracks(
+    session: TidalSession, prov_playlist_id: str, track_ids: list[str], add: bool = True
+) -> None:
+    """Async wrapper around the tidal Playlist.add and Playlist.remove function."""
+    if add:
+        return TidalUserPlaylist(session, prov_playlist_id).add(track_ids)
+    for item in track_ids:
+        TidalUserPlaylist(session, prov_playlist_id).remove_by_id(int(item))
+
+
+@get_session
+@async_wrap
+def create_playlist(
+    session: TidalSession, user_id: str, title: str, description: str = None
+) -> TidalPlaylist:
+    """Async wrapper around the tidal LoggedInUser.create_playlist function."""
+    return LoggedInUser(session, user_id).create_playlist(title, description)
+
+
+@get_session
+@async_wrap
+def get_similar_tracks(session: TidalSession, prov_track_id, limit: int) -> list[TidalTrack]:
+    """Async wrapper around the tidal Track.get_similar_tracks function."""
+    return TidalTrack(session, media_id=prov_track_id).get_track_radio(limit)
+
+
+@get_session
+@async_wrap
+def search(
+    session: TidalSession, query: str, media_types=None, limit=50, offset=0
+) -> dict[str, str]:
+    """Async wrapper around the tidalapi Search function."""
+    search_types = []
+    if MediaType.ARTIST in media_types:
+        search_types.append(TidalArtist)
+    if MediaType.ALBUM in media_types:
+        search_types.append(TidalAlbum)
+    if MediaType.TRACK in media_types:
+        search_types.append(TidalTrack)
+    if MediaType.PLAYLIST in media_types:
+        search_types.append(TidalPlaylist)
+
+    models = search_types if search_types else None
+    return session.search(query, models, limit, offset)
+
+
+def get_item_mapping(tidal_provider, media_type: MediaType, key: str, name: str) -> ItemMapping:
+    """Create a generic item mapping."""
+    return ItemMapping(
+        media_type,
+        key,
+        tidal_provider.instance_id,
+        name,
+        create_uri(media_type, tidal_provider.instance_id, key),
+        create_sort_name(tidal_provider.name),
+    )
diff --git a/music_assistant/server/providers/tidal/icon.png b/music_assistant/server/providers/tidal/icon.png
new file mode 100644 (file)
index 0000000..917bb96
Binary files /dev/null and b/music_assistant/server/providers/tidal/icon.png differ
diff --git a/music_assistant/server/providers/tidal/manifest.json b/music_assistant/server/providers/tidal/manifest.json
new file mode 100644 (file)
index 0000000..03ba1f2
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "music",
+  "domain": "tidal",
+  "name": "Tidal",
+  "description": "Support for the Tidal streaming provider in Music Assistant.",
+  "codeowners": ["@jozefKruszynski"],
+  "requirements": ["git+https://github.com/jozefKruszynski/python-tidal.git@v0.7.1"],
+  "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/432",
+  "multi_instance": true
+}
index dac5137c6ec59a6856e7dd46ea65191fed004cae..307a7bcc53a69f67094507511ce8652e533e2df5 100644 (file)
@@ -10,6 +10,7 @@ asyncio-throttle==1.0.2
 coloredlogs==15.0.1
 cryptography==40.0.2
 databases==0.7.0
+git+https://github.com/jozefKruszynski/python-tidal.git@v0.7.1
 git+https://github.com/pytube/pytube.git@refs/pull/1501/head
 mashumaro==3.7
 memory-tempfile==2.2.3