From e1aa214badcee75bdd9ec5bcdbb52a9fac3d8076 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 16 Sep 2022 21:48:09 +0200 Subject: [PATCH] tidal-integration (#223) Reviewed-on: https://git.platypush.tech/platypush/platypush/pulls/223 --- CHANGELOG.md | 2 + platypush/message/event/music/tidal.py | 14 + platypush/plugins/music/spotify/__init__.py | 339 ++++++++++------ platypush/plugins/music/tidal/__init__.py | 417 ++++++++++++++++++++ platypush/plugins/music/tidal/manifest.yaml | 9 + platypush/plugins/music/tidal/workers.py | 56 +++ platypush/schemas/tidal.py | 206 ++++++++++ setup.py | 2 +- 8 files changed, 932 insertions(+), 113 deletions(-) create mode 100644 platypush/message/event/music/tidal.py create mode 100644 platypush/plugins/music/tidal/__init__.py create mode 100644 platypush/plugins/music/tidal/manifest.yaml create mode 100644 platypush/plugins/music/tidal/workers.py create mode 100644 platypush/schemas/tidal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e65edc1c..692dcb0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ reported only starting from v0.20.2. - Added support for web hooks returning their hook method responses back to the HTTP client. +- Added [Tidal integration](https://git.platypush.tech/platypush/platypush/pulls/223) + ## [0.23.4] - 2022-08-28 ### Added diff --git a/platypush/message/event/music/tidal.py b/platypush/message/event/music/tidal.py new file mode 100644 index 000000000..504c82582 --- /dev/null +++ b/platypush/message/event/music/tidal.py @@ -0,0 +1,14 @@ +from platypush.message.event import Event + + +class TidalEvent(Event): + """Base class for Tidal events""" + + +class TidalPlaylistUpdatedEvent(TidalEvent): + """ + Event fired when a Tidal playlist is updated. + """ + + def __init__(self, playlist_id: str, *args, **kwargs): + super().__init__(*args, playlist_id=playlist_id, **kwargs) diff --git a/platypush/plugins/music/spotify/__init__.py b/platypush/plugins/music/spotify/__init__.py index e416c7c70..eb0eb18bb 100644 --- a/platypush/plugins/music/spotify/__init__.py +++ b/platypush/plugins/music/spotify/__init__.py @@ -6,9 +6,17 @@ from platypush.message.response import Response from platypush.plugins import action from platypush.plugins.media import PlayerState from platypush.plugins.music import MusicPlugin -from platypush.schemas.spotify import SpotifyDeviceSchema, SpotifyStatusSchema, SpotifyTrackSchema, \ - SpotifyHistoryItemSchema, SpotifyPlaylistSchema, SpotifyAlbumSchema, SpotifyEpisodeSchema, SpotifyShowSchema, \ - SpotifyArtistSchema +from platypush.schemas.spotify import ( + SpotifyDeviceSchema, + SpotifyStatusSchema, + SpotifyTrackSchema, + SpotifyHistoryItemSchema, + SpotifyPlaylistSchema, + SpotifyAlbumSchema, + SpotifyEpisodeSchema, + SpotifyShowSchema, + SpotifyArtistSchema, +) class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): @@ -45,9 +53,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): be printed on the application logs/stdout. """ - def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None, **kwargs): + def __init__( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + **kwargs, + ): MusicPlugin.__init__(self, **kwargs) - SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret, **kwargs) + SpotifyMixin.__init__( + self, client_id=client_id, client_secret=client_secret, **kwargs + ) self._players_by_id = {} self._players_by_name = {} # Playlist ID -> snapshot ID and tracks cache @@ -63,14 +78,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): return dev @staticmethod - def _parse_datetime(dt: Optional[Union[str, datetime, int, float]]) -> Optional[datetime]: + def _parse_datetime( + dt: Optional[Union[str, datetime, int, float]] + ) -> Optional[datetime]: if isinstance(dt, str): try: dt = float(dt) except (ValueError, TypeError): return datetime.fromisoformat(dt) - if isinstance(dt, int) or isinstance(dt, float): + if isinstance(dt, (int, float)): return datetime.fromtimestamp(dt) return dt @@ -85,18 +102,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): devices = self.spotify_user_call('/v1/me/player/devices').get('devices', []) self._players_by_id = { **self._players_by_id, - **{ - dev['id']: dev - for dev in devices - } + **{dev['id']: dev for dev in devices}, } self._players_by_name = { **self._players_by_name, - **{ - dev['name']: dev - for dev in devices - } + **{dev['name']: dev for dev in devices}, } return SpotifyDeviceSchema().dump(devices, many=True) @@ -118,7 +129,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): params={ 'volume_percent': volume, **({'device_id': device} if device else {}), - } + }, ) def _get_volume(self, device: Optional[str] = None) -> Optional[int]: @@ -138,10 +149,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): if device: device = self._get_device(device)['id'] - self.spotify_user_call('/v1/me/player/volume', params={ - 'volume_percent': min(100, (self._get_volume() or 0) + delta), - **({'device_id': device} if device else {}), - }) + self.spotify_user_call( + '/v1/me/player/volume', + params={ + 'volume_percent': min(100, (self._get_volume() or 0) + delta), + **({'device_id': device} if device else {}), + }, + ) @action def voldown(self, delta: int = 5, device: Optional[str] = None): @@ -154,10 +168,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): if device: device = self._get_device(device)['id'] - self.spotify_user_call('/v1/me/player/volume', params={ - 'volume_percent': max(0, (self._get_volume() or 0) - delta), - **({'device_id': device} if device else {}), - }) + self.spotify_user_call( + '/v1/me/player/volume', + params={ + 'volume_percent': max(0, (self._get_volume() or 0) - delta), + **({'device_id': device} if device else {}), + }, + ) @action def play(self, resource: Optional[str] = None, device: Optional[str] = None): @@ -192,8 +209,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): # noinspection PyUnresolvedReferences status = self.status().output - state = 'play' \ - if status.get('device_id') != device or status.get('state') != PlayerState.PLAY.value else 'pause' + state = ( + 'play' + if status.get('device_id') != device + or status.get('state') != PlayerState.PLAY.value + else 'pause' + ) self.spotify_user_call( f'/v1/me/player/{state}', @@ -212,7 +233,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): status = self.status().output if status.get('state') == PlayerState.PLAY.value: self.spotify_user_call( - f'/v1/me/player/pause', + '/v1/me/player/pause', method='put', ) @@ -230,7 +251,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): status = self.status().output if status.get('state') != PlayerState.PLAY.value: self.spotify_user_call( - f'/v1/me/player/play', + '/v1/me/player/play', method='put', params={ **({'device_id': device} if device else {}), @@ -261,7 +282,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): """ device = self._get_device(device)['id'] self.spotify_user_call( - f'/v1/me/player', + '/v1/me/player', method='put', json={ 'device_ids': [device], @@ -279,7 +300,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): device = self._get_device(device)['id'] self.spotify_user_call( - f'/v1/me/player/next', + '/v1/me/player/next', method='post', params={ **({'device_id': device} if device else {}), @@ -297,7 +318,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): device = self._get_device(device)['id'] self.spotify_user_call( - f'/v1/me/player/previous', + '/v1/me/player/previous', method='post', params={ **({'device_id': device} if device else {}), @@ -316,7 +337,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): device = self._get_device(device)['id'] self.spotify_user_call( - f'/v1/me/player/seek', + '/v1/me/player/seek', method='put', params={ 'position_ms': int(position * 1000), @@ -338,13 +359,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): if value is None: # noinspection PyUnresolvedReferences status = self.status().output - state = 'context' \ - if status.get('device_id') != device or not status.get('repeat') else 'off' + state = ( + 'context' + if status.get('device_id') != device or not status.get('repeat') + else 'off' + ) else: state = value is True self.spotify_user_call( - f'/v1/me/player/repeat', + '/v1/me/player/repeat', method='put', params={ 'state': 'context' if state else 'off', @@ -366,12 +390,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): if value is None: # noinspection PyUnresolvedReferences status = self.status().output - state = True if status.get('device_id') != device or not status.get('random') else False + state = bool(status.get('device_id') != device or not status.get('random')) else: state = value is True self.spotify_user_call( - f'/v1/me/player/shuffle', + '/v1/me/player/shuffle', method='put', params={ 'state': state, @@ -380,8 +404,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): ) @action - def history(self, limit: int = 20, before: Optional[Union[datetime, str, int]] = None, - after: Optional[Union[datetime, str, int]] = None): + def history( + self, + limit: int = 20, + before: Optional[Union[datetime, str, int]] = None, + after: Optional[Union[datetime, str, int]] = None, + ): """ Get a list of recently played track on the account. @@ -396,21 +424,26 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): after = self._parse_datetime(after) assert not (before and after), 'before and after cannot both be set' - results = self._spotify_paginate_results('/v1/me/player/recently-played', - limit=limit, - params={ - 'limit': min(limit, 50), - **({'before': before} if before else {}), - **({'after': after} if after else {}), - }) + results = self._spotify_paginate_results( + '/v1/me/player/recently-played', + limit=limit, + params={ + 'limit': min(limit, 50), + **({'before': before} if before else {}), + **({'after': after} if after else {}), + }, + ) - return SpotifyHistoryItemSchema().dump([ - { - **item.pop('track'), - **item, - } - for item in results - ], many=True) + return SpotifyHistoryItemSchema().dump( + [ + { + **item.pop('track'), + **item, + } + for item in results + ], + many=True, + ) @action def add(self, resource: str, device: Optional[str] = None, **kwargs): @@ -424,7 +457,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): device = self._get_device(device)['id'] self.spotify_user_call( - f'/v1/me/player/queue', + '/v1/me/player/queue', method='post', params={ 'uri': resource, @@ -472,7 +505,9 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): return SpotifyTrackSchema().dump(track) @action - def get_playlists(self, limit: int = 1000, offset: int = 0, user: Optional[str] = None): + def get_playlists( + self, limit: int = 1000, offset: int = 0, user: Optional[str] = None + ): """ Get the user's playlists. @@ -483,7 +518,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): """ playlists = self._spotify_paginate_results( f'/v1/{"users/" + user if user else "me"}/playlists', - limit=limit, offset=offset + limit=limit, + offset=offset, ) return SpotifyPlaylistSchema().dump(playlists, many=True) @@ -491,36 +527,45 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): def _get_playlist(self, playlist: str) -> dict: playlists = self.get_playlists().output playlists = [ - pl for pl in playlists if ( - pl['id'] == playlist or - pl['uri'] == playlist or - pl['name'] == playlist - ) + pl + for pl in playlists + if (pl['id'] == playlist or pl['uri'] == playlist or pl['name'] == playlist) ] assert playlists, f'No such playlist ID, URI or name: {playlist}' return playlists[0] - def _get_playlist_tracks_from_cache(self, id: str, snapshot_id: str, limit: Optional[int] = None, - offset: int = 0) -> Optional[Iterable]: + def _get_playlist_tracks_from_cache( + self, id: str, snapshot_id: str, limit: Optional[int] = None, offset: int = 0 + ) -> Optional[Iterable]: snapshot = self._playlist_snapshots.get(id) if ( - not snapshot or - snapshot['snapshot_id'] != snapshot_id or - (limit is None and snapshot['limit'] is not None) + not snapshot + or snapshot['snapshot_id'] != snapshot_id + or (limit is None and snapshot['limit'] is not None) ): return if limit is not None and snapshot['limit'] is not None: stored_range = (snapshot['limit'], snapshot['limit'] + snapshot['offset']) requested_range = (limit, limit + offset) - if requested_range[0] < stored_range[0] or requested_range[1] > stored_range[1]: + if ( + requested_range[0] < stored_range[0] + or requested_range[1] > stored_range[1] + ): return return snapshot['tracks'] - def _cache_playlist_data(self, id: str, snapshot_id: str, tracks: Iterable[dict], limit: Optional[int] = None, - offset: int = 0, **_): + def _cache_playlist_data( + self, + id: str, + snapshot_id: str, + tracks: Iterable[dict], + limit: Optional[int] = None, + offset: int = 0, + **_, + ): self._playlist_snapshots[id] = { 'id': id, 'tracks': tracks, @@ -530,7 +575,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): } @action - def get_playlist(self, playlist: str, with_tracks: bool = True, limit: Optional[int] = None, offset: int = 0): + def get_playlist( + self, + playlist: str, + with_tracks: bool = True, + limit: Optional[int] = None, + offset: int = 0, + ): """ Get a playlist content. @@ -544,8 +595,10 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): playlist = self._get_playlist(playlist) if with_tracks: playlist['tracks'] = self._get_playlist_tracks_from_cache( - playlist['id'], snapshot_id=playlist['snapshot_id'], - limit=limit, offset=offset + playlist['id'], + snapshot_id=playlist['snapshot_id'], + limit=limit, + offset=offset, ) if playlist['tracks'] is None: @@ -554,13 +607,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): **track, 'track': { **track['track'], - 'position': offset+i+1, - } + 'position': offset + i + 1, + }, } - for i, track in enumerate(self._spotify_paginate_results( - f'/v1/playlists/{playlist["id"]}/tracks', - limit=limit, offset=offset - )) + for i, track in enumerate( + self._spotify_paginate_results( + f'/v1/playlists/{playlist["id"]}/tracks', + limit=limit, + offset=offset, + ) + ) ] self._cache_playlist_data(**playlist, limit=limit, offset=offset) @@ -568,7 +624,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): return SpotifyPlaylistSchema().dump(playlist) @action - def add_to_playlist(self, playlist: str, resources: Union[str, Iterable[str]], position: Optional[int] = None): + def add_to_playlist( + self, + playlist: str, + resources: Union[str, Iterable[str]], + position: Optional[int] = None, + ): """ Add one or more items to a playlist. @@ -585,11 +646,14 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): }, json={ 'uris': [ - uri.strip() for uri in ( - resources.split(',') if isinstance(resources, str) else resources + uri.strip() + for uri in ( + resources.split(',') + if isinstance(resources, str) + else resources ) ] - } + }, ) snapshot_id = response.get('snapshot_id') @@ -611,18 +675,27 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): 'tracks': [ {'uri': uri.strip()} for uri in ( - resources.split(',') if isinstance(resources, str) else resources + resources.split(',') + if isinstance(resources, str) + else resources ) ] - } + }, ) snapshot_id = response.get('snapshot_id') assert snapshot_id is not None, 'Could not save playlist' @action - def playlist_move(self, playlist: str, from_pos: int, to_pos: int, range_length: int = 1, - resources: Optional[Union[str, Iterable[str]]] = None, **_): + def playlist_move( + self, + playlist: str, + from_pos: int, + to_pos: int, + range_length: int = 1, + resources: Optional[Union[str, Iterable[str]]] = None, + **_, + ): """ Move or replace elements in a playlist. @@ -641,12 +714,21 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): 'range_start': int(from_pos) + 1, 'range_length': int(range_length), 'insert_before': int(to_pos) + 1, - **({'uris': [ - uri.strip() for uri in ( - resources.split(',') if isinstance(resources, str) else resources - ) - ]} if resources else {}) - } + **( + { + 'uris': [ + uri.strip() + for uri in ( + resources.split(',') + if isinstance(resources, str) + else resources + ) + ] + } + if resources + else {} + ), + }, ) snapshot_id = response.get('snapshot_id') @@ -673,8 +755,14 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): # noinspection PyShadowingBuiltins @action - def search(self, query: Optional[Union[str, dict]] = None, limit: int = 50, offset: int = 0, type: str = 'track', - **filter) -> Iterable[dict]: + def search( + self, + query: Optional[Union[str, dict]] = None, + limit: int = 50, + offset: int = 0, + type: str = 'track', + **filter, + ) -> Iterable[dict]: """ Search for tracks matching a certain criteria. @@ -714,12 +802,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): }.get('uri', []) uris = uri.split(',') if isinstance(uri, str) else uri - params = { - 'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]), - } if uris else { - 'q': self._make_filter(query, **filter), - 'type': type, - } + params = ( + { + 'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]), + } + if uris + else { + 'q': self._make_filter(query, **filter), + 'type': type, + } + ) response = self._spotify_paginate_results( f'/v1/{type + "s" if uris else "search"}', @@ -739,7 +831,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): track.get('track'), track.get('title'), track.get('popularity'), - ) + ), ) schema_class = None @@ -759,6 +851,31 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): return response + @action + def create_playlist( + self, name: str, description: Optional[str] = None, public: bool = False + ): + """ + Create a playlist. + + :param name: Playlist name. + :param description: Optional playlist description. + :param public: Whether the new playlist should be public + (default: False). + :return: .. schema:: spotify.SpotifyPlaylistSchema + """ + ret = self.spotify_user_call( + '/v1/users/me/playlists', + method='post', + json={ + 'name': name, + 'description': description, + 'public': public, + }, + ) + + return SpotifyPlaylistSchema().dump(ret) + @action def follow_playlist(self, playlist: str, public: bool = True): """ @@ -774,7 +891,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): method='put', json={ 'public': public, - } + }, ) @action @@ -792,10 +909,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): @staticmethod def _uris_to_id(*uris: str) -> Iterable[str]: - return [ - uri.split(':')[-1] - for uri in uris - ] + return [uri.split(':')[-1] for uri in uris] @action def get_albums(self, limit: int = 50, offset: int = 0) -> List[dict]: @@ -811,7 +925,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): '/v1/me/albums', limit=limit, offset=offset, - ), many=True + ), + many=True, ) @action @@ -852,9 +967,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): return [ SpotifyTrackSchema().dump(item['track']) for item in self._spotify_paginate_results( - '/v1/me/tracks', - limit=limit, - offset=offset + '/v1/me/tracks', limit=limit, offset=offset ) ] @@ -898,7 +1011,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): '/v1/me/episodes', limit=limit, offset=offset, - ), many=True + ), + many=True, ) @action @@ -941,7 +1055,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): '/v1/me/shows', limit=limit, offset=offset, - ), many=True + ), + many=True, ) @action diff --git a/platypush/plugins/music/tidal/__init__.py b/platypush/plugins/music/tidal/__init__.py new file mode 100644 index 000000000..1b32eb6c5 --- /dev/null +++ b/platypush/plugins/music/tidal/__init__.py @@ -0,0 +1,417 @@ +import json +import os +import pathlib +import requests + +from datetime import datetime +from urllib.parse import urljoin +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): + """ + Plugin to interact with the user's Tidal account and library. + + Upon the first login, the application will prompt you with a link to + connect to your Tidal account. Once authorized, you should no longer be + required to explicitly login. + + Triggers: + + * :class:`platypush.message.event.music.TidalPlaylistUpdatedEvent`: when a user playlist + is updated. + + Requires: + + * **tidalapi** (``pip install tidalapi``) + + """ + + _base_url = 'https://api.tidalhifi.com/v1/' + _default_credentials_file = os.path.join( + str(Config.get('workdir')), 'tidal', 'credentials.json' + ) + + def __init__( + self, + quality: str = 'high', + credentials_file: str = _default_credentials_file, + **kwargs, + ): + """ + :param quality: Default audio quality. Default: ``high``. + Supported: [``loseless``, ``master``, ``high``, ``low``]. + :param credentials_file: Path to the file where the OAuth session + parameters will be stored (default: + ``/tidal/credentials.json``). + """ + from tidalapi import Quality + + super().__init__(**kwargs) + self._credentials_file = credentials_file + self._user_playlists = {} + + try: + self._quality = getattr(Quality, quality.lower()) + except AttributeError: + raise AssertionError( + f'Invalid quality: {quality}. Supported values: ' + f'{[q.name for q in Quality]}' + ) + + self._session = None + + def _oauth_open_saved_session(self): + if not self._session: + return + + try: + with open(self._credentials_file, 'r') as f: + data = json.load(f) + self._session.load_oauth_session( + data['token_type'], data['access_token'], data['refresh_token'] + ) + except Exception as e: + self.logger.warning('Could not load %s: %s', self._credentials_file, e) + + def _oauth_create_new_session(self): + if not self._session: + return + + self._session.login_oauth_simple(function=self.logger.warning) # type: ignore + if self._session.check_login(): + data = { + 'token_type': self._session.token_type, + 'session_id': self._session.session_id, + 'access_token': self._session.access_token, + 'refresh_token': self._session.refresh_token, + } + + pathlib.Path(os.path.dirname(self._credentials_file)).mkdir( + parents=True, exist_ok=True + ) + + with open(self._credentials_file, 'w') as outfile: + json.dump(data, outfile) + + @property + def session(self): + from tidalapi import Config, Session + + if self._session and self._session.check_login(): + return self._session + + # Attempt to reload the existing session from file + self._session = Session(config=Config(quality=self._quality)) + self._oauth_open_saved_session() + if not self._session.check_login(): + # Create a new session if we couldn't load an existing one + self._oauth_create_new_session() + + 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) + kwargs['headers'] = kwargs.get('headers', {}) + kwargs['params'] = kwargs.get('params', {}) + kwargs['params'].update( + { + 'sessionId': self.session.session_id, + 'countryCode': self.session.country_code, + } + ) + + rs = None + kwargs['headers']['Authorization'] = '{type} {token}'.format( + type=self.session.token_type, token=self.session.access_token + ) + + try: + rs = method(url, *args, **kwargs) + rs.raise_for_status() + return rs + except requests.HTTPError as e: + if rs: + self.logger.error(rs.text) + raise e + + @action + def create_playlist(self, name: str, description: Optional[str] = None): + """ + Create a new playlist. + + :param name: Playlist name. + :param description: Optional playlist description. + :return: .. schema:: tidal.TidalPlaylistSchema + """ + ret = self._api_request( + url=f'users/{self.user.id}/playlists', + method='post', + data={ + 'title': name, + 'description': description, + }, + ) + + return TidalPlaylistSchema().dump(ret.json()) + + @action + def delete_playlist(self, playlist_id: str): + """ + Delete a playlist by ID. + + :param playlist_id: ID of the playlist to delete. + """ + 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]): + """ + Append one or more tracks to a playlist. + + :param playlist_id: Target playlist ID. + :param track_ids: List of track IDs to append. + """ + return self._api_request( + url=f'playlists/{playlist_id}/items', + method='post', + headers={ + 'If-None-Match': None, + }, + data={ + 'onArtifactNotFound': 'SKIP', + 'onDupes': 'SKIP', + 'trackIds': ','.join(map(str, track_ids)), + }, + ) + + @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 + + for pl in playlists: + last_updated_var = Variable(f'TIDAL_PLAYLIST_LAST_UPDATE[{pl.id}]') + prev_last_updated = last_updated_var.get() + if prev_last_updated: + prev_last_updated = datetime.fromisoformat(prev_last_updated) + if pl.last_updated > prev_last_updated: + get_bus().post(TidalPlaylistUpdatedEvent(playlist_id=pl.id)) + + if not prev_last_updated or pl.last_updated > prev_last_updated: + last_updated_var.set(pl.last_updated.isoformat()) + + self.wait_stop(self.poll_interval) diff --git a/platypush/plugins/music/tidal/manifest.yaml b/platypush/plugins/music/tidal/manifest.yaml new file mode 100644 index 000000000..7fde4ffd9 --- /dev/null +++ b/platypush/plugins/music/tidal/manifest.yaml @@ -0,0 +1,9 @@ +manifest: + events: + - platypush.message.event.music.TidalPlaylistUpdatedEvent: when a user playlist + is updated. + install: + pip: + - tidalapi + package: platypush.plugins.music.tidal + type: plugin diff --git a/platypush/plugins/music/tidal/workers.py b/platypush/plugins/music/tidal/workers.py new file mode 100644 index 000000000..feea0ea9a --- /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 000000000..4b17b4b9c --- /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) diff --git a/setup.py b/setup.py index c338b09c1..5db5d5886 100755 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ setup( 'zeroconf>=0.27.0', 'tz', 'python-dateutil', - 'cryptography', + # 'cryptography', 'pyjwt', 'marshmallow', 'frozendict',