From d58cdb8d6cf9f83f93080cd15fe5a6c4837cae66 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 16 Sep 2022 21:45:02 +0200 Subject: [PATCH] Finalized Tidal integration --- platypush/plugins/music/tidal/__init__.py | 218 +++++++++++++++++++++- platypush/plugins/music/tidal/workers.py | 56 ++++++ platypush/schemas/tidal.py | 206 ++++++++++++++++++++ 3 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 platypush/plugins/music/tidal/workers.py create mode 100644 platypush/schemas/tidal.py diff --git a/platypush/plugins/music/tidal/__init__.py b/platypush/plugins/music/tidal/__init__.py index d26abcb4..1b32eb6c 100644 --- a/platypush/plugins/music/tidal/__init__.py +++ b/platypush/plugins/music/tidal/__init__.py @@ -5,12 +5,20 @@ import requests from datetime import datetime from urllib.parse import urljoin -from typing import Iterable, Optional +from typing import Iterable, Optional, Union from platypush.config import Config from platypush.context import Variable, get_bus from platypush.message.event.music.tidal import TidalPlaylistUpdatedEvent from platypush.plugins import RunnablePlugin, action +from platypush.plugins.music.tidal.workers import get_items +from platypush.schemas.tidal import ( + TidalAlbumSchema, + TidalPlaylistSchema, + TidalArtistSchema, + TidalSearchResultsSchema, + TidalTrackSchema, +) class MusicTidalPlugin(RunnablePlugin): @@ -113,9 +121,18 @@ class MusicTidalPlugin(RunnablePlugin): # Create a new session if we couldn't load an existing one self._oauth_create_new_session() - assert self._session.check_login(), 'Could not connect to TIDAL' + assert ( + self._session.user and self._session.check_login() + ), 'Could not connect to TIDAL' + return self._session + @property + def user(self): + user = self.session.user + assert user, 'Not logged in' + return user + def _api_request(self, url, *args, method='get', **kwargs): method = getattr(requests, method.lower()) url = urljoin(self._base_url, url) @@ -149,9 +166,10 @@ class MusicTidalPlugin(RunnablePlugin): :param name: Playlist name. :param description: Optional playlist description. + :return: .. schema:: tidal.TidalPlaylistSchema """ - return self._api_request( - url=f'users/{self.session.user.id}/playlists', + ret = self._api_request( + url=f'users/{self.user.id}/playlists', method='post', data={ 'title': name, @@ -159,6 +177,8 @@ class MusicTidalPlugin(RunnablePlugin): }, ) + return TidalPlaylistSchema().dump(ret.json()) + @action def delete_playlist(self, playlist_id: str): """ @@ -168,6 +188,124 @@ class MusicTidalPlugin(RunnablePlugin): """ self._api_request(url=f'playlists/{playlist_id}', method='delete') + @action + def edit_playlist(self, playlist_id: str, title=None, description=None): + """ + Edit a playlist's metadata. + + :param name: New name. + :param description: New description. + """ + pl = self.user.playlist(playlist_id) + pl.edit(title=title, description=description) + + @action + def get_playlists(self): + """ + Get the user's playlists (track lists are excluded). + + :return: .. schema:: tidal.TidalPlaylistSchema(many=True) + """ + ret = self.user.playlists() + self.user.favorites.playlists() + + return TidalPlaylistSchema().dump(ret, many=True) + + @action + def get_playlist(self, playlist_id: str): + """ + Get the details of a playlist (including tracks). + + :param playlist_id: Playlist ID. + :return: .. schema:: tidal.TidalPlaylistSchema + """ + pl = self.session.playlist(playlist_id) + pl._tracks = get_items(pl.tracks) + return TidalPlaylistSchema().dump(pl) + + @action + def get_artist(self, artist_id: Union[str, int]): + """ + Get the details of an artist. + + :param artist_id: Artist ID. + :return: .. schema:: tidal.TidalArtistSchema + """ + ret = self.session.artist(artist_id) + ret.albums = get_items(ret.get_albums) + return TidalArtistSchema().dump(ret) + + @action + def get_album(self, album_id: Union[str, int]): + """ + Get the details of an album. + + :param artist_id: Album ID. + :return: .. schema:: tidal.TidalAlbumSchema + """ + ret = self.session.album(album_id) + return TidalAlbumSchema().dump(ret) + + @action + def get_track(self, track_id: Union[str, int]): + """ + Get the details of an track. + + :param artist_id: Track ID. + :return: .. schema:: tidal.TidalTrackSchema + """ + ret = self.session.album(track_id) + return TidalTrackSchema().dump(ret) + + @action + def search( + self, + query: str, + limit: int = 50, + offset: int = 0, + type: Optional[str] = None, + ): + """ + Perform a search. + + :param query: Query string. + :param limit: Maximum results that should be returned (default: 50). + :param offset: Search offset (default: 0). + :param type: Type of results that should be returned. Default: None + (return all the results that match the query). Supported: + ``artist``, ``album``, ``track`` and ``playlist``. + :return: .. schema:: tidal.TidalSearchResultsSchema + """ + from tidalapi.artist import Artist + from tidalapi.album import Album + from tidalapi.media import Track + from tidalapi.playlist import Playlist + + models = None + if type is not None: + if type == 'artist': + models = [Artist] + elif type == 'album': + models = [Album] + elif type == 'track': + models = [Track] + elif type == 'playlist': + models = [Playlist] + else: + raise AssertionError(f'Unsupported search type: {type}') + + ret = self.session.search(query, models=models, limit=limit, offset=offset) + + return TidalSearchResultsSchema().dump(ret) + + @action + def get_download_url(self, track_id: str) -> str: + """ + Get the direct download URL of a track. + + :param artist_id: Track ID. + """ + return self.session.track(track_id).get_url() + @action def add_to_playlist(self, playlist_id: str, track_ids: Iterable[str]): """ @@ -189,6 +327,78 @@ class MusicTidalPlugin(RunnablePlugin): }, ) + @action + def add_track(self, track_id: Union[str, int]): + """ + Add a track to the user's collection. + + :param track_id: Track ID. + """ + self.user.favorites.add_track(track_id) + + @action + def add_album(self, album_id: Union[str, int]): + """ + Add an album to the user's collection. + + :param album_id: Album ID. + """ + self.user.favorites.add_album(album_id) + + @action + def add_artist(self, artist_id: Union[str, int]): + """ + Add an artist to the user's collection. + + :param artist_id: Artist ID. + """ + self.user.favorites.add_artist(artist_id) + + @action + def add_playlist(self, playlist_id: str): + """ + Add a playlist to the user's collection. + + :param playlist_id: Playlist ID. + """ + self.user.favorites.add_playlist(playlist_id) + + @action + def remove_track(self, track_id: Union[str, int]): + """ + Remove a track from the user's collection. + + :param track_id: Track ID. + """ + self.user.favorites.remove_track(track_id) + + @action + def remove_album(self, album_id: Union[str, int]): + """ + Remove an album from the user's collection. + + :param album_id: Album ID. + """ + self.user.favorites.remove_album(album_id) + + @action + def remove_artist(self, artist_id: Union[str, int]): + """ + Remove an artist from the user's collection. + + :param artist_id: Artist ID. + """ + self.user.favorites.remove_artist(artist_id) + + @action + def remove_playlist(self, playlist_id: str): + """ + Remove a playlist from the user's collection. + + :param playlist_id: Playlist ID. + """ + self.user.favorites.remove_playlist(playlist_id) + def main(self): while not self.should_stop(): playlists = self.session.user.playlists() # type: ignore diff --git a/platypush/plugins/music/tidal/workers.py b/platypush/plugins/music/tidal/workers.py new file mode 100644 index 00000000..feea0ea9 --- /dev/null +++ b/platypush/plugins/music/tidal/workers.py @@ -0,0 +1,56 @@ +from concurrent.futures import ThreadPoolExecutor +from typing import Callable + + +def func_wrapper(args): + (f, offset, *args) = args + items = f(*args) + return [(i + offset, item) for i, item in enumerate(items)] + + +def get_items( + func: Callable, + *args, + parse: Callable = lambda _: _, + chunk_size: int = 100, + processes: int = 5, +): + """ + This function performs pagination on a function that supports + `limit`/`offset` parameters and it runs API requests in parallel to speed + things up. + """ + items = [] + offsets = [-chunk_size] + remaining = chunk_size * processes + + with ThreadPoolExecutor( + processes, thread_name_prefix=f'mopidy-tidal-{func.__name__}-' + ) as pool: + while remaining == chunk_size * processes: + offsets = [offsets[-1] + chunk_size * (i + 1) for i in range(processes)] + + pool_results = pool.map( + func_wrapper, + [ + ( + func, + offset, + *args, + chunk_size, # limit + offset, # offset + ) + for offset in offsets + ], + ) + + new_items = [] + for results in pool_results: + new_items.extend(results) + + remaining = len(new_items) + items.extend(new_items) + + items = sorted([_ for _ in items if _], key=lambda item: item[0]) + sorted_items = [item[1] for item in items] + return list(map(parse, sorted_items)) diff --git a/platypush/schemas/tidal.py b/platypush/schemas/tidal.py new file mode 100644 index 00000000..4b17b4b9 --- /dev/null +++ b/platypush/schemas/tidal.py @@ -0,0 +1,206 @@ +from marshmallow import Schema, fields, pre_dump + +from platypush.schemas import DateTime + + +class TidalSchema(Schema): + pass + + +class TidalArtistSchema(TidalSchema): + id = fields.String( + required=True, + dump_only=True, + metadata={ + 'example': '3288612', + 'description': 'Artist ID', + }, + ) + + url = fields.String( + required=True, + dump_only=True, + metadata={ + 'description': 'Artist Tidal URL', + 'example': 'https://tidal.com/artist/3288612', + }, + ) + + name = fields.String(required=True) + albums = fields.Nested("TidalAlbumSchema", many=True) + + @pre_dump + def _prefill_url(self, data, *_, **__): + data.url = f'https://tidal.com/artist/{data.id}' + return data + + +class TidalAlbumSchema(TidalSchema): + id = fields.String( + required=True, + dump_only=True, + attribute='uuid', + metadata={ + 'example': '45288612', + 'description': 'Album ID', + }, + ) + + url = fields.String( + required=True, + dump_only=True, + metadata={ + 'description': 'Album Tidal URL', + 'example': 'https://tidal.com/album/45288612', + }, + ) + + name = fields.String(required=True) + artist = fields.Nested(TidalArtistSchema) + duration = fields.Int(metadata={'description': 'Album duration, in seconds'}) + year = fields.Integer(metadata={'example': 2003}) + num_tracks = fields.Int(metadata={'example': 10}) + + @pre_dump + def _prefill_url(self, data, *_, **__): + data.url = f'https://tidal.com/album/{data.id}' + return data + + +class TidalTrackSchema(TidalSchema): + id = fields.String( + required=True, + dump_only=True, + metadata={ + 'example': '25288614', + 'description': 'Track ID', + }, + ) + + url = fields.String( + required=True, + dump_only=True, + metadata={ + 'description': 'Track Tidal URL', + 'example': 'https://tidal.com/track/25288614', + }, + ) + + artist = fields.Nested(TidalArtistSchema) + album = fields.Nested(TidalAlbumSchema) + name = fields.String(metadata={'description': 'Track title'}) + duration = fields.Int(metadata={'description': 'Track duration, in seconds'}) + track_num = fields.Int( + metadata={'description': 'Index of the track within the album'} + ) + + @pre_dump + def _prefill_url(self, data, *_, **__): + data.url = f'https://tidal.com/track/{data.id}' + return data + + +class TidalPlaylistSchema(TidalSchema): + id = fields.String( + required=True, + dump_only=True, + attribute='uuid', + metadata={ + 'example': '2b288612-34f5-11ed-b42d-001500e8f607', + 'description': 'Playlist ID', + }, + ) + + url = fields.String( + required=True, + dump_only=True, + metadata={ + 'description': 'Playlist Tidal URL', + 'example': 'https://tidal.com/playlist/2b288612-34f5-11ed-b42d-001500e8f607', + }, + ) + + name = fields.String(required=True) + description = fields.String() + duration = fields.Int(metadata={'description': 'Playlist duration, in seconds'}) + public = fields.Boolean(attribute='publicPlaylist') + owner = fields.String( + attribute='creator', + metadata={ + 'description': 'Playlist creator/owner ID', + }, + ) + + num_tracks = fields.Int( + attribute='numberOfTracks', + default=0, + metadata={ + 'example': 42, + 'description': 'Number of tracks in the playlist', + }, + ) + + created_at = DateTime( + attribute='created', + metadata={ + 'description': 'When the playlist was created', + }, + ) + + last_updated_at = DateTime( + attribute='lastUpdated', + metadata={ + 'description': 'When the playlist was last updated', + }, + ) + + tracks = fields.Nested(TidalTrackSchema, many=True) + + def _flatten_object(self, data, *_, **__): + if not isinstance(data, dict): + data = { + 'created': data.created, + 'creator': data.creator.id, + 'description': data.description, + 'duration': data.duration, + 'lastUpdated': data.last_updated, + 'uuid': data.id, + 'name': data.name, + 'numberOfTracks': data.num_tracks, + 'publicPlaylist': data.public, + 'tracks': getattr(data, '_tracks', []), + } + + return data + + def _normalize_owner(self, data, *_, **__): + owner = data.pop('owner', data.pop('creator', None)) + if owner: + if isinstance(owner, dict): + owner = owner['id'] + data['creator'] = owner + + return data + + def _normalize_name(self, data, *_, **__): + if data.get('title'): + data['name'] = data.pop('title') + return data + + @pre_dump + def normalize(self, data, *_, **__): + if not isinstance(data, dict): + data = self._flatten_object(data) + + self._normalize_name(data) + self._normalize_owner(data) + if 'tracks' not in data: + data['tracks'] = [] + return data + + +class TidalSearchResultsSchema(TidalSchema): + artists = fields.Nested(TidalArtistSchema, many=True) + albums = fields.Nested(TidalAlbumSchema, many=True) + tracks = fields.Nested(TidalTrackSchema, many=True) + playlists = fields.Nested(TidalPlaylistSchema, many=True)