From 45dc0fd3caefb2842f91d2d035a71ffd436f147e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 14 Sep 2022 21:28:33 +0200 Subject: [PATCH] wip --- platypush/message/event/music/tidal.py | 14 +++ platypush/plugins/music/tidal/__init__.py | 128 ++++++++++++++++---- platypush/plugins/music/tidal/manifest.yaml | 4 +- 3 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 platypush/message/event/music/tidal.py diff --git a/platypush/message/event/music/tidal.py b/platypush/message/event/music/tidal.py new file mode 100644 index 00000000..504c8258 --- /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/tidal/__init__.py b/platypush/plugins/music/tidal/__init__.py index e428470a..d26abcb4 100644 --- a/platypush/plugins/music/tidal/__init__.py +++ b/platypush/plugins/music/tidal/__init__.py @@ -1,17 +1,31 @@ import json import os +import pathlib import requests +from datetime import datetime +from urllib.parse import urljoin +from typing import Iterable, Optional + from platypush.config import Config -from platypush.message.response import Response +from platypush.context import Variable, get_bus +from platypush.message.event.music.tidal import TidalPlaylistUpdatedEvent from platypush.plugins import RunnablePlugin, action -from platypush.plugins.media import PlayerState 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``) @@ -20,15 +34,14 @@ class MusicTidalPlugin(RunnablePlugin): _base_url = 'https://api.tidalhifi.com/v1/' _default_credentials_file = os.path.join( - str(Config.get('workdir')), - 'tidal', 'credentials.json' + str(Config.get('workdir')), 'tidal', 'credentials.json' ) def __init__( self, quality: str = 'high', credentials_file: str = _default_credentials_file, - **kwargs + **kwargs, ): """ :param quality: Default audio quality. Default: ``high``. @@ -39,8 +52,9 @@ class MusicTidalPlugin(RunnablePlugin): """ from tidalapi import Quality - super().__init__(self, **kwargs) + super().__init__(**kwargs) self._credentials_file = credentials_file + self._user_playlists = {} try: self._quality = getattr(Quality, quality.lower()) @@ -53,19 +67,23 @@ class MusicTidalPlugin(RunnablePlugin): 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'] + data['token_type'], data['access_token'], data['refresh_token'] ) except Exception as e: - self.logger.warning('Could not load %s: %s', oauth_file, e) + self.logger.warning('Could not load %s: %s', self._credentials_file, e) def _oauth_create_new_session(self): - self._session.login_oauth_simple(function=self.logger.warning) + 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, @@ -74,18 +92,22 @@ class MusicTidalPlugin(RunnablePlugin): 'refresh_token': self._session.refresh_token, } - with open(oauth_file, 'w') as outfile: + 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 - # TODO populate Config object - self._session = Session(config=Config()) + 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 @@ -96,18 +118,19 @@ class MusicTidalPlugin(RunnablePlugin): def _api_request(self, url, *args, method='get', **kwargs): method = getattr(requests, method.lower()) - url = urljoin(base_url, url) + 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, - }) + 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 + type=self.session.token_type, token=self.session.access_token ) try: @@ -119,3 +142,66 @@ class MusicTidalPlugin(RunnablePlugin): 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 self._api_request( + url=f'users/{self.session.user.id}/playlists', + method='post', + data={ + 'title': name, + 'description': description, + }, + ) + + @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 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)), + }, + ) + + 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 index fe554c6c..7fde4ffd 100644 --- a/platypush/plugins/music/tidal/manifest.yaml +++ b/platypush/plugins/music/tidal/manifest.yaml @@ -1,5 +1,7 @@ manifest: - events: {} + events: + - platypush.message.event.music.TidalPlaylistUpdatedEvent: when a user playlist + is updated. install: pip: - tidalapi