Finalized Tidal integration

This commit is contained in:
Fabio Manganiello 2022-09-16 21:45:02 +02:00
parent 5ad7d966d7
commit d58cdb8d6c
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
3 changed files with 476 additions and 4 deletions

View File

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

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)