tidal-integration (#223)

Reviewed-on: platypush/platypush#223
This commit is contained in:
Fabio Manganiello 2022-09-16 21:48:09 +02:00
parent 41acf4b253
commit e1aa214bad
8 changed files with 932 additions and 113 deletions

View file

@ -11,6 +11,8 @@ reported only starting from v0.20.2.
- Added support for web hooks returning their hook method responses back to the - Added support for web hooks returning their hook method responses back to the
HTTP client. HTTP client.
- Added [Tidal integration](https://git.platypush.tech/platypush/platypush/pulls/223)
## [0.23.4] - 2022-08-28 ## [0.23.4] - 2022-08-28
### Added ### Added

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

@ -6,9 +6,17 @@ from platypush.message.response import Response
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.media import PlayerState from platypush.plugins.media import PlayerState
from platypush.plugins.music import MusicPlugin from platypush.plugins.music import MusicPlugin
from platypush.schemas.spotify import SpotifyDeviceSchema, SpotifyStatusSchema, SpotifyTrackSchema, \ from platypush.schemas.spotify import (
SpotifyHistoryItemSchema, SpotifyPlaylistSchema, SpotifyAlbumSchema, SpotifyEpisodeSchema, SpotifyShowSchema, \ SpotifyDeviceSchema,
SpotifyArtistSchema SpotifyStatusSchema,
SpotifyTrackSchema,
SpotifyHistoryItemSchema,
SpotifyPlaylistSchema,
SpotifyAlbumSchema,
SpotifyEpisodeSchema,
SpotifyShowSchema,
SpotifyArtistSchema,
)
class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
@ -45,9 +53,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
be printed on the application logs/stdout. 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) 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_id = {}
self._players_by_name = {} self._players_by_name = {}
# Playlist ID -> snapshot ID and tracks cache # Playlist ID -> snapshot ID and tracks cache
@ -63,14 +78,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return dev return dev
@staticmethod @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): if isinstance(dt, str):
try: try:
dt = float(dt) dt = float(dt)
except (ValueError, TypeError): except (ValueError, TypeError):
return datetime.fromisoformat(dt) return datetime.fromisoformat(dt)
if isinstance(dt, int) or isinstance(dt, float): if isinstance(dt, (int, float)):
return datetime.fromtimestamp(dt) return datetime.fromtimestamp(dt)
return dt return dt
@ -85,18 +102,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
devices = self.spotify_user_call('/v1/me/player/devices').get('devices', []) devices = self.spotify_user_call('/v1/me/player/devices').get('devices', [])
self._players_by_id = { self._players_by_id = {
**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 = {
**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) return SpotifyDeviceSchema().dump(devices, many=True)
@ -118,7 +129,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
params={ params={
'volume_percent': volume, 'volume_percent': volume,
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
} },
) )
def _get_volume(self, device: Optional[str] = None) -> Optional[int]: def _get_volume(self, device: Optional[str] = None) -> Optional[int]:
@ -138,10 +149,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if device: if device:
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call('/v1/me/player/volume', params={ self.spotify_user_call(
'/v1/me/player/volume',
params={
'volume_percent': min(100, (self._get_volume() or 0) + delta), 'volume_percent': min(100, (self._get_volume() or 0) + delta),
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
}) },
)
@action @action
def voldown(self, delta: int = 5, device: Optional[str] = None): def voldown(self, delta: int = 5, device: Optional[str] = None):
@ -154,10 +168,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if device: if device:
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call('/v1/me/player/volume', params={ self.spotify_user_call(
'/v1/me/player/volume',
params={
'volume_percent': max(0, (self._get_volume() or 0) - delta), 'volume_percent': max(0, (self._get_volume() or 0) - delta),
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
}) },
)
@action @action
def play(self, resource: Optional[str] = None, device: Optional[str] = None): def play(self, resource: Optional[str] = None, device: Optional[str] = None):
@ -192,8 +209,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
status = self.status().output status = self.status().output
state = 'play' \ state = (
if status.get('device_id') != device or status.get('state') != PlayerState.PLAY.value else 'pause' 'play'
if status.get('device_id') != device
or status.get('state') != PlayerState.PLAY.value
else 'pause'
)
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/{state}', f'/v1/me/player/{state}',
@ -212,7 +233,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
status = self.status().output status = self.status().output
if status.get('state') == PlayerState.PLAY.value: if status.get('state') == PlayerState.PLAY.value:
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/pause', '/v1/me/player/pause',
method='put', method='put',
) )
@ -230,7 +251,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
status = self.status().output status = self.status().output
if status.get('state') != PlayerState.PLAY.value: if status.get('state') != PlayerState.PLAY.value:
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/play', '/v1/me/player/play',
method='put', method='put',
params={ params={
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
@ -261,7 +282,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
""" """
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player', '/v1/me/player',
method='put', method='put',
json={ json={
'device_ids': [device], 'device_ids': [device],
@ -279,7 +300,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/next', '/v1/me/player/next',
method='post', method='post',
params={ params={
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
@ -297,7 +318,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/previous', '/v1/me/player/previous',
method='post', method='post',
params={ params={
**({'device_id': device} if device else {}), **({'device_id': device} if device else {}),
@ -316,7 +337,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/seek', '/v1/me/player/seek',
method='put', method='put',
params={ params={
'position_ms': int(position * 1000), 'position_ms': int(position * 1000),
@ -338,13 +359,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if value is None: if value is None:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
status = self.status().output status = self.status().output
state = 'context' \ state = (
if status.get('device_id') != device or not status.get('repeat') else 'off' 'context'
if status.get('device_id') != device or not status.get('repeat')
else 'off'
)
else: else:
state = value is True state = value is True
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/repeat', '/v1/me/player/repeat',
method='put', method='put',
params={ params={
'state': 'context' if state else 'off', 'state': 'context' if state else 'off',
@ -366,12 +390,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
if value is None: if value is None:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
status = self.status().output 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: else:
state = value is True state = value is True
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/shuffle', '/v1/me/player/shuffle',
method='put', method='put',
params={ params={
'state': state, 'state': state,
@ -380,8 +404,12 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
) )
@action @action
def history(self, limit: int = 20, before: Optional[Union[datetime, str, int]] = None, def history(
after: Optional[Union[datetime, str, int]] = None): 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. Get a list of recently played track on the account.
@ -396,21 +424,26 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
after = self._parse_datetime(after) after = self._parse_datetime(after)
assert not (before and after), 'before and after cannot both be set' assert not (before and after), 'before and after cannot both be set'
results = self._spotify_paginate_results('/v1/me/player/recently-played', results = self._spotify_paginate_results(
'/v1/me/player/recently-played',
limit=limit, limit=limit,
params={ params={
'limit': min(limit, 50), 'limit': min(limit, 50),
**({'before': before} if before else {}), **({'before': before} if before else {}),
**({'after': after} if after else {}), **({'after': after} if after else {}),
}) },
)
return SpotifyHistoryItemSchema().dump([ return SpotifyHistoryItemSchema().dump(
[
{ {
**item.pop('track'), **item.pop('track'),
**item, **item,
} }
for item in results for item in results
], many=True) ],
many=True,
)
@action @action
def add(self, resource: str, device: Optional[str] = None, **kwargs): def add(self, resource: str, device: Optional[str] = None, **kwargs):
@ -424,7 +457,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
device = self._get_device(device)['id'] device = self._get_device(device)['id']
self.spotify_user_call( self.spotify_user_call(
f'/v1/me/player/queue', '/v1/me/player/queue',
method='post', method='post',
params={ params={
'uri': resource, 'uri': resource,
@ -472,7 +505,9 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return SpotifyTrackSchema().dump(track) return SpotifyTrackSchema().dump(track)
@action @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. Get the user's playlists.
@ -483,7 +518,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
""" """
playlists = self._spotify_paginate_results( playlists = self._spotify_paginate_results(
f'/v1/{"users/" + user if user else "me"}/playlists', f'/v1/{"users/" + user if user else "me"}/playlists',
limit=limit, offset=offset limit=limit,
offset=offset,
) )
return SpotifyPlaylistSchema().dump(playlists, many=True) return SpotifyPlaylistSchema().dump(playlists, many=True)
@ -491,36 +527,45 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
def _get_playlist(self, playlist: str) -> dict: def _get_playlist(self, playlist: str) -> dict:
playlists = self.get_playlists().output playlists = self.get_playlists().output
playlists = [ playlists = [
pl for pl in playlists if ( pl
pl['id'] == playlist or for pl in playlists
pl['uri'] == playlist or if (pl['id'] == playlist or pl['uri'] == playlist or pl['name'] == playlist)
pl['name'] == playlist
)
] ]
assert playlists, f'No such playlist ID, URI or name: {playlist}' assert playlists, f'No such playlist ID, URI or name: {playlist}'
return playlists[0] return playlists[0]
def _get_playlist_tracks_from_cache(self, id: str, snapshot_id: str, limit: Optional[int] = None, def _get_playlist_tracks_from_cache(
offset: int = 0) -> Optional[Iterable]: self, id: str, snapshot_id: str, limit: Optional[int] = None, offset: int = 0
) -> Optional[Iterable]:
snapshot = self._playlist_snapshots.get(id) snapshot = self._playlist_snapshots.get(id)
if ( if (
not snapshot or not snapshot
snapshot['snapshot_id'] != snapshot_id or or snapshot['snapshot_id'] != snapshot_id
(limit is None and snapshot['limit'] is not None) or (limit is None and snapshot['limit'] is not None)
): ):
return return
if limit is not None and snapshot['limit'] is not None: if limit is not None and snapshot['limit'] is not None:
stored_range = (snapshot['limit'], snapshot['limit'] + snapshot['offset']) stored_range = (snapshot['limit'], snapshot['limit'] + snapshot['offset'])
requested_range = (limit, limit + 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
return snapshot['tracks'] return snapshot['tracks']
def _cache_playlist_data(self, id: str, snapshot_id: str, tracks: Iterable[dict], limit: Optional[int] = None, def _cache_playlist_data(
offset: int = 0, **_): self,
id: str,
snapshot_id: str,
tracks: Iterable[dict],
limit: Optional[int] = None,
offset: int = 0,
**_,
):
self._playlist_snapshots[id] = { self._playlist_snapshots[id] = {
'id': id, 'id': id,
'tracks': tracks, 'tracks': tracks,
@ -530,7 +575,13 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
} }
@action @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. Get a playlist content.
@ -544,8 +595,10 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
playlist = self._get_playlist(playlist) playlist = self._get_playlist(playlist)
if with_tracks: if with_tracks:
playlist['tracks'] = self._get_playlist_tracks_from_cache( playlist['tracks'] = self._get_playlist_tracks_from_cache(
playlist['id'], snapshot_id=playlist['snapshot_id'], playlist['id'],
limit=limit, offset=offset snapshot_id=playlist['snapshot_id'],
limit=limit,
offset=offset,
) )
if playlist['tracks'] is None: if playlist['tracks'] is None:
@ -555,12 +608,15 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'track': { 'track': {
**track['track'], **track['track'],
'position': offset + i + 1, 'position': offset + i + 1,
},
} }
} for i, track in enumerate(
for i, track in enumerate(self._spotify_paginate_results( self._spotify_paginate_results(
f'/v1/playlists/{playlist["id"]}/tracks', f'/v1/playlists/{playlist["id"]}/tracks',
limit=limit, offset=offset limit=limit,
)) offset=offset,
)
)
] ]
self._cache_playlist_data(**playlist, 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) return SpotifyPlaylistSchema().dump(playlist)
@action @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. Add one or more items to a playlist.
@ -585,11 +646,14 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
}, },
json={ json={
'uris': [ 'uris': [
uri.strip() for uri in ( uri.strip()
resources.split(',') if isinstance(resources, str) else resources for uri in (
resources.split(',')
if isinstance(resources, str)
else resources
) )
] ]
} },
) )
snapshot_id = response.get('snapshot_id') snapshot_id = response.get('snapshot_id')
@ -611,18 +675,27 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'tracks': [ 'tracks': [
{'uri': uri.strip()} {'uri': uri.strip()}
for uri in ( 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') snapshot_id = response.get('snapshot_id')
assert snapshot_id is not None, 'Could not save playlist' assert snapshot_id is not None, 'Could not save playlist'
@action @action
def playlist_move(self, playlist: str, from_pos: int, to_pos: int, range_length: int = 1, def playlist_move(
resources: Optional[Union[str, Iterable[str]]] = None, **_): 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. Move or replace elements in a playlist.
@ -641,12 +714,21 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'range_start': int(from_pos) + 1, 'range_start': int(from_pos) + 1,
'range_length': int(range_length), 'range_length': int(range_length),
'insert_before': int(to_pos) + 1, 'insert_before': int(to_pos) + 1,
**({'uris': [ **(
uri.strip() for uri in ( {
resources.split(',') if isinstance(resources, str) else resources 'uris': [
uri.strip()
for uri in (
resources.split(',')
if isinstance(resources, str)
else resources
) )
]} if resources else {}) ]
} }
if resources
else {}
),
},
) )
snapshot_id = response.get('snapshot_id') snapshot_id = response.get('snapshot_id')
@ -673,8 +755,14 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
# noinspection PyShadowingBuiltins # noinspection PyShadowingBuiltins
@action @action
def search(self, query: Optional[Union[str, dict]] = None, limit: int = 50, offset: int = 0, type: str = 'track', def search(
**filter) -> Iterable[dict]: 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. Search for tracks matching a certain criteria.
@ -714,12 +802,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
}.get('uri', []) }.get('uri', [])
uris = uri.split(',') if isinstance(uri, str) else uri uris = uri.split(',') if isinstance(uri, str) else uri
params = { params = (
{
'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]), 'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]),
} if uris else { }
if uris
else {
'q': self._make_filter(query, **filter), 'q': self._make_filter(query, **filter),
'type': type, 'type': type,
} }
)
response = self._spotify_paginate_results( response = self._spotify_paginate_results(
f'/v1/{type + "s" if uris else "search"}', f'/v1/{type + "s" if uris else "search"}',
@ -739,7 +831,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
track.get('track'), track.get('track'),
track.get('title'), track.get('title'),
track.get('popularity'), track.get('popularity'),
) ),
) )
schema_class = None schema_class = None
@ -759,6 +851,31 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return response 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 @action
def follow_playlist(self, playlist: str, public: bool = True): def follow_playlist(self, playlist: str, public: bool = True):
""" """
@ -774,7 +891,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
method='put', method='put',
json={ json={
'public': public, 'public': public,
} },
) )
@action @action
@ -792,10 +909,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
@staticmethod @staticmethod
def _uris_to_id(*uris: str) -> Iterable[str]: def _uris_to_id(*uris: str) -> Iterable[str]:
return [ return [uri.split(':')[-1] for uri in uris]
uri.split(':')[-1]
for uri in uris
]
@action @action
def get_albums(self, limit: int = 50, offset: int = 0) -> List[dict]: def get_albums(self, limit: int = 50, offset: int = 0) -> List[dict]:
@ -811,7 +925,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'/v1/me/albums', '/v1/me/albums',
limit=limit, limit=limit,
offset=offset, offset=offset,
), many=True ),
many=True,
) )
@action @action
@ -852,9 +967,7 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
return [ return [
SpotifyTrackSchema().dump(item['track']) SpotifyTrackSchema().dump(item['track'])
for item in self._spotify_paginate_results( for item in self._spotify_paginate_results(
'/v1/me/tracks', '/v1/me/tracks', limit=limit, offset=offset
limit=limit,
offset=offset
) )
] ]
@ -898,7 +1011,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'/v1/me/episodes', '/v1/me/episodes',
limit=limit, limit=limit,
offset=offset, offset=offset,
), many=True ),
many=True,
) )
@action @action
@ -941,7 +1055,8 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
'/v1/me/shows', '/v1/me/shows',
limit=limit, limit=limit,
offset=offset, offset=offset,
), many=True ),
many=True,
) )
@action @action

View file

@ -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:
``<WORKDIR>/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)

View file

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

View file

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

206
platypush/schemas/tidal.py Normal file
View file

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

View file

@ -64,7 +64,7 @@ setup(
'zeroconf>=0.27.0', 'zeroconf>=0.27.0',
'tz', 'tz',
'python-dateutil', 'python-dateutil',
'cryptography', # 'cryptography',
'pyjwt', 'pyjwt',
'marshmallow', 'marshmallow',
'frozendict', 'frozendict',