Finalized Tidal integration
This commit is contained in:
parent
5ad7d966d7
commit
d58cdb8d6c
|
@ -5,12 +5,20 @@ import requests
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from typing import Iterable, Optional
|
from typing import Iterable, Optional, Union
|
||||||
|
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.context import Variable, get_bus
|
from platypush.context import Variable, get_bus
|
||||||
from platypush.message.event.music.tidal import TidalPlaylistUpdatedEvent
|
from platypush.message.event.music.tidal import TidalPlaylistUpdatedEvent
|
||||||
from platypush.plugins import RunnablePlugin, action
|
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):
|
class MusicTidalPlugin(RunnablePlugin):
|
||||||
|
@ -113,9 +121,18 @@ class MusicTidalPlugin(RunnablePlugin):
|
||||||
# Create a new session if we couldn't load an existing one
|
# Create a new session if we couldn't load an existing one
|
||||||
self._oauth_create_new_session()
|
self._oauth_create_new_session()
|
||||||
|
|
||||||
assert self._session.check_login(), 'Could not connect to TIDAL'
|
assert (
|
||||||
|
self._session.user and self._session.check_login()
|
||||||
|
), 'Could not connect to TIDAL'
|
||||||
|
|
||||||
return self._session
|
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):
|
def _api_request(self, url, *args, method='get', **kwargs):
|
||||||
method = getattr(requests, method.lower())
|
method = getattr(requests, method.lower())
|
||||||
url = urljoin(self._base_url, url)
|
url = urljoin(self._base_url, url)
|
||||||
|
@ -149,9 +166,10 @@ class MusicTidalPlugin(RunnablePlugin):
|
||||||
|
|
||||||
:param name: Playlist name.
|
:param name: Playlist name.
|
||||||
:param description: Optional playlist description.
|
:param description: Optional playlist description.
|
||||||
|
:return: .. schema:: tidal.TidalPlaylistSchema
|
||||||
"""
|
"""
|
||||||
return self._api_request(
|
ret = self._api_request(
|
||||||
url=f'users/{self.session.user.id}/playlists',
|
url=f'users/{self.user.id}/playlists',
|
||||||
method='post',
|
method='post',
|
||||||
data={
|
data={
|
||||||
'title': name,
|
'title': name,
|
||||||
|
@ -159,6 +177,8 @@ class MusicTidalPlugin(RunnablePlugin):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return TidalPlaylistSchema().dump(ret.json())
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def delete_playlist(self, playlist_id: str):
|
def delete_playlist(self, playlist_id: str):
|
||||||
"""
|
"""
|
||||||
|
@ -168,6 +188,124 @@ class MusicTidalPlugin(RunnablePlugin):
|
||||||
"""
|
"""
|
||||||
self._api_request(url=f'playlists/{playlist_id}', method='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
|
@action
|
||||||
def add_to_playlist(self, playlist_id: str, track_ids: Iterable[str]):
|
def add_to_playlist(self, playlist_id: str, track_ids: Iterable[str]):
|
||||||
"""
|
"""
|
||||||
|
@ -189,6 +327,78 @@ class MusicTidalPlugin(RunnablePlugin):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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):
|
def main(self):
|
||||||
while not self.should_stop():
|
while not self.should_stop():
|
||||||
playlists = self.session.user.playlists() # type: ignore
|
playlists = self.session.user.playlists() # type: ignore
|
||||||
|
|
|
@ -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))
|
|
@ -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)
|
Loading…
Reference in New Issue