forked from platypush/platypush
parent
41acf4b253
commit
e1aa214bad
8 changed files with 932 additions and 113 deletions
|
@ -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
|
||||||
|
|
14
platypush/message/event/music/tidal.py
Normal file
14
platypush/message/event/music/tidal.py
Normal 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)
|
|
@ -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(
|
||||||
'volume_percent': min(100, (self._get_volume() or 0) + delta),
|
'/v1/me/player/volume',
|
||||||
**({'device_id': device} if device else {}),
|
params={
|
||||||
})
|
'volume_percent': min(100, (self._get_volume() or 0) + delta),
|
||||||
|
**({'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(
|
||||||
'volume_percent': max(0, (self._get_volume() or 0) - delta),
|
'/v1/me/player/volume',
|
||||||
**({'device_id': device} if device else {}),
|
params={
|
||||||
})
|
'volume_percent': max(0, (self._get_volume() or 0) - delta),
|
||||||
|
**({'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(
|
||||||
limit=limit,
|
'/v1/me/player/recently-played',
|
||||||
params={
|
limit=limit,
|
||||||
'limit': min(limit, 50),
|
params={
|
||||||
**({'before': before} if before else {}),
|
'limit': min(limit, 50),
|
||||||
**({'after': after} if after else {}),
|
**({'before': before} if before else {}),
|
||||||
})
|
**({'after': after} if after else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return SpotifyHistoryItemSchema().dump([
|
return SpotifyHistoryItemSchema().dump(
|
||||||
{
|
[
|
||||||
**item.pop('track'),
|
{
|
||||||
**item,
|
**item.pop('track'),
|
||||||
}
|
**item,
|
||||||
for item in results
|
}
|
||||||
], many=True)
|
for item in results
|
||||||
|
],
|
||||||
|
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:
|
||||||
|
@ -554,13 +607,16 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
|
||||||
**track,
|
**track,
|
||||||
'track': {
|
'track': {
|
||||||
**track['track'],
|
**track['track'],
|
||||||
'position': offset+i+1,
|
'position': offset + i + 1,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
for i, track in enumerate(self._spotify_paginate_results(
|
for i, track in enumerate(
|
||||||
f'/v1/playlists/{playlist["id"]}/tracks',
|
self._spotify_paginate_results(
|
||||||
limit=limit, offset=offset
|
f'/v1/playlists/{playlist["id"]}/tracks',
|
||||||
))
|
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()
|
||||||
]} if resources else {})
|
for uri in (
|
||||||
}
|
resources.split(',')
|
||||||
|
if isinstance(resources, str)
|
||||||
|
else resources
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
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]),
|
{
|
||||||
} if uris else {
|
'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]),
|
||||||
'q': self._make_filter(query, **filter),
|
}
|
||||||
'type': type,
|
if uris
|
||||||
}
|
else {
|
||||||
|
'q': self._make_filter(query, **filter),
|
||||||
|
'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
|
||||||
|
|
417
platypush/plugins/music/tidal/__init__.py
Normal file
417
platypush/plugins/music/tidal/__init__.py
Normal 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)
|
9
platypush/plugins/music/tidal/manifest.yaml
Normal file
9
platypush/plugins/music/tidal/manifest.yaml
Normal 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
|
56
platypush/plugins/music/tidal/workers.py
Normal file
56
platypush/plugins/music/tidal/workers.py
Normal 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
206
platypush/schemas/tidal.py
Normal 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)
|
2
setup.py
2
setup.py
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue