This commit is contained in:
Fabio Manganiello 2022-09-14 21:28:33 +02:00
parent 0bcd321086
commit 45dc0fd3ca
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
3 changed files with 124 additions and 22 deletions

View File

@ -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)

View File

@ -1,17 +1,31 @@
import json import json
import os import os
import pathlib
import requests import requests
from datetime import datetime
from urllib.parse import urljoin
from typing import Iterable, Optional
from platypush.config import Config 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 import RunnablePlugin, action
from platypush.plugins.media import PlayerState
class MusicTidalPlugin(RunnablePlugin): class MusicTidalPlugin(RunnablePlugin):
""" """
Plugin to interact with the user's Tidal account and library. 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: Requires:
* **tidalapi** (``pip install tidalapi``) * **tidalapi** (``pip install tidalapi``)
@ -20,15 +34,14 @@ class MusicTidalPlugin(RunnablePlugin):
_base_url = 'https://api.tidalhifi.com/v1/' _base_url = 'https://api.tidalhifi.com/v1/'
_default_credentials_file = os.path.join( _default_credentials_file = os.path.join(
str(Config.get('workdir')), str(Config.get('workdir')), 'tidal', 'credentials.json'
'tidal', 'credentials.json'
) )
def __init__( def __init__(
self, self,
quality: str = 'high', quality: str = 'high',
credentials_file: str = _default_credentials_file, credentials_file: str = _default_credentials_file,
**kwargs **kwargs,
): ):
""" """
:param quality: Default audio quality. Default: ``high``. :param quality: Default audio quality. Default: ``high``.
@ -39,8 +52,9 @@ class MusicTidalPlugin(RunnablePlugin):
""" """
from tidalapi import Quality from tidalapi import Quality
super().__init__(self, **kwargs) super().__init__(**kwargs)
self._credentials_file = credentials_file self._credentials_file = credentials_file
self._user_playlists = {}
try: try:
self._quality = getattr(Quality, quality.lower()) self._quality = getattr(Quality, quality.lower())
@ -53,19 +67,23 @@ class MusicTidalPlugin(RunnablePlugin):
self._session = None self._session = None
def _oauth_open_saved_session(self): def _oauth_open_saved_session(self):
if not self._session:
return
try: try:
with open(self._credentials_file, 'r') as f: with open(self._credentials_file, 'r') as f:
data = json.load(f) data = json.load(f)
self._session.load_oauth_session( self._session.load_oauth_session(
data['token_type'], data['token_type'], data['access_token'], data['refresh_token']
data['access_token'],
data['refresh_token']
) )
except Exception as e: 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): 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(): if self._session.check_login():
data = { data = {
'token_type': self._session.token_type, 'token_type': self._session.token_type,
@ -74,18 +92,22 @@ class MusicTidalPlugin(RunnablePlugin):
'refresh_token': self._session.refresh_token, '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) json.dump(data, outfile)
@property @property
def session(self): def session(self):
from tidalapi import Config, Session from tidalapi import Config, Session
if self._session and self._session.check_login(): if self._session and self._session.check_login():
return self._session return self._session
# Attempt to reload the existing session from file # Attempt to reload the existing session from file
# TODO populate Config object self._session = Session(config=Config(quality=self._quality))
self._session = Session(config=Config())
self._oauth_open_saved_session() self._oauth_open_saved_session()
if not self._session.check_login(): if not self._session.check_login():
# Create a new session if we couldn't load an existing one # 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): def _api_request(self, url, *args, method='get', **kwargs):
method = getattr(requests, method.lower()) method = getattr(requests, method.lower())
url = urljoin(base_url, url) url = urljoin(self._base_url, url)
kwargs['headers'] = kwargs.get('headers', {}) kwargs['headers'] = kwargs.get('headers', {})
kwargs['params'] = kwargs.get('params', {}) kwargs['params'] = kwargs.get('params', {})
kwargs['params'].update({ kwargs['params'].update(
'sessionId': self.session.session_id, {
'countryCode': self.session.country_code, 'sessionId': self.session.session_id,
}) 'countryCode': self.session.country_code,
}
)
rs = None rs = None
kwargs['headers']['Authorization'] = '{type} {token}'.format( kwargs['headers']['Authorization'] = '{type} {token}'.format(
type=self.session.token_type, type=self.session.token_type, token=self.session.access_token
token=self.session.access_token
) )
try: try:
@ -119,3 +142,66 @@ class MusicTidalPlugin(RunnablePlugin):
self.logger.error(rs.text) self.logger.error(rs.text)
raise e 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)

View File

@ -1,5 +1,7 @@
manifest: manifest:
events: {} events:
- platypush.message.event.music.TidalPlaylistUpdatedEvent: when a user playlist
is updated.
install: install:
pip: pip:
- tidalapi - tidalapi