diff --git a/docs/api_ref.rst b/docs/api_ref.rst index 4fa84ed..967d0e1 100644 --- a/docs/api_ref.rst +++ b/docs/api_ref.rst @@ -24,6 +24,32 @@ The ``Listen`` class represents a Listen from ListenBrainz. :undoc-members: :show-inheritance: +Playlists module +################ + +The following classes represent ListenBrainz playlists. + +class Playlist +~~~~~~~~~~~~~~ + +.. autoclass:: pylistenbrainz.Playlist + :members: + :undoc-members: + +class PlaylistMetadata +~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: pylistenbrainz.PlaylistMetadata + :members: + :undoc-members: + +class PlaylistTrack +~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: pylistenbrainz.PlaylistTrack + :members: + :undoc-members: + Statistics (beta) ################# diff --git a/pylistenbrainz/__init__.py b/pylistenbrainz/__init__.py index 9c2dad2..72afbc5 100644 --- a/pylistenbrainz/__init__.py +++ b/pylistenbrainz/__init__.py @@ -19,3 +19,5 @@ from pylistenbrainz.client import ListenBrainz from pylistenbrainz.listen import Listen from pylistenbrainz.listen import LISTEN_TYPE_IMPORT, LISTEN_TYPE_PLAYING_NOW, LISTEN_TYPE_SINGLE +from pylistenbrainz.playlist import Playlist, PlaylistMetadata, PlaylistTrack +from pylistenbrainz.playlist import PLAYLIST_QUERY_TYPE_CREATED_BY, PLAYLIST_QUERY_TYPE_COLLABORATOR, PLAYLIST_QUERY_TYPE_CREATED_FOR diff --git a/pylistenbrainz/client.py b/pylistenbrainz/client.py index 3e74ae3..3dfa5a8 100644 --- a/pylistenbrainz/client.py +++ b/pylistenbrainz/client.py @@ -22,6 +22,9 @@ from enum import Enum from pylistenbrainz import errors from pylistenbrainz.listen import LISTEN_TYPE_IMPORT, LISTEN_TYPE_PLAYING_NOW, LISTEN_TYPE_SINGLE +from pylistenbrainz.playlist import PlaylistMetadata +from pylistenbrainz.playlist import _playlist_metadata_from_response, _playlist_from_response +from pylistenbrainz.playlist import PLAYLIST_QUERY_TYPE_CREATED_BY, PLAYLIST_QUERY_TYPE_COLLABORATOR, PLAYLIST_QUERY_TYPE_CREATED_FOR from pylistenbrainz.utils import _validate_submit_listens_payload, _convert_api_payload_to_listen from urllib.parse import urljoin @@ -421,4 +424,54 @@ def get_user_listen_count(self, username): if e.status_code == 204: return None else: - raise \ No newline at end of file + raise + + def get_user_playlists(self, username, query_type=PLAYLIST_QUERY_TYPE_CREATED_BY, count=25, offset=0): + """ List all playlists for user + + By default this returns playlists created by the user. The ``query_type`` + parameter can return several things: + + * ``PLAYLIST_QUERY_TYPE_CREATED_BY``: playlists the user owns (default) + * ``PLAYLIST_QUERY_TYPE_COLLABORATOR``: any playlist where the user contributed + * ``PLAYLIST_QUERY_TYPE_CREATED_FOR``: playlists created for the user (includes generated recommendations) + + This returns the playlist metadata. To retrieve the contents of the + playlist, see: :func:`get_playlist`. + + :param username: the username of the user whose playlists are to be fetched. + :type username: str + + :param query_type: type of playlists to fetch + + :param count: the number of playlists to fetch, defaults to 25, maximum is 100. + :type count: int, optional + + :param offset: the number of playlists to skip from the beginning, for pagination, defaults to 0. + :type offset: int, optional + + :return: a list of :class:`PlaylistMetadata` instances + :rtype: list + """ + if query_type == PLAYLIST_QUERY_TYPE_COLLABORATOR: + endpoint = '/1/user/{}/playlists/collaborator'.format(username) + elif query_type == PLAYLIST_QUERY_TYPE_CREATED_FOR: + endpoint = '/1/user/{}/playlists/createdfor'.format(username) + else: + endpoint = '/1/user/{}/playlists'.format(username) + data = self._get(endpoint) + playlists = data['playlists'] + return [_playlist_metadata_from_response(item) for item in playlists] + + def get_playlist(self, mbid): + """ Fetch a specific playlist by its identifier. + + You will usually call :func:`get_user_playlists` and use + :meth:`.PlaylistMetadata.identifier` to get the MBID of playlists you + are interested in. + + :return: a :class:`.Playlist` instance + :rtype: Playlist + """ + data = self._get('/1/playlist/{mbid}'.format(mbid=mbid)) + return _playlist_from_response(data) diff --git a/pylistenbrainz/playlist.py b/pylistenbrainz/playlist.py new file mode 100644 index 0000000..5c7e3c3 --- /dev/null +++ b/pylistenbrainz/playlist.py @@ -0,0 +1,268 @@ +# pylistenbrainz - A simple client library for ListenBrainz +# Copyright (C) 2022 Sam Thursfield +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import datetime +import pathlib +import urllib.parse + + +PLAYLIST_QUERY_TYPE_CREATED_BY = 'created_by' +PLAYLIST_QUERY_TYPE_COLLABORATOR = 'collaborator' +PLAYLIST_QUERY_TYPE_CREATED_FOR = 'created_for' + +PLAYLIST_QUERY_TYPES = ( + PLAYLIST_QUERY_TYPE_CREATED_BY, + PLAYLIST_QUERY_TYPE_COLLABORATOR, + PLAYLIST_QUERY_TYPE_CREATED_FOR, +) + + +def _url_to_mbid(url, kind): + parsed = urllib.parse.urlsplit(url) + path = pathlib.Path(parsed.path) + if path.parent.name == kind: + return path.name + else: + raise ValueError("Expected {} URL, got: {}".format(kind, url)) + + +def _mbid_to_url(mbid, kind): + if kind == 'playlist': + base = 'https://listenbrainz.org/' + else: + base = 'https://musicbrainz.org/' + path = pathlib.Path(kind).joinpath(mbid) + return urllib.parse.urljoin(base, str(path)) + + +class PlaylistMetadata(): + """Metadata about a ListenBrainz playlist. + + :param identifier: MBID of the playlist + :type identifier: str + :param annotation: A string describing the playlist. + :type annotation: str, optional + :param creator: The username of the creator of the playlist. + :type creator: str, optional + :param title: The title of the playlist. + :type title: str, optional + :param algorithm_metadata: Metadata specific to playlist generator, if any. + :type algorithm_metadata: dict, optional + :param collaborators: List of users who have added tracks to the playlist. + :type collaborators: List[str], optional + :param last_modified_at: The date the playlist was last modified. + :type last_modified_at: datetime.datetime, optional + :param public: Whether the playlist is publicly visible. + :type public: bool + + """ + def __init__(self, + identifier, + annotation=None, + creator=None, + date=None, + title=None, + algorithm_metadata=None, + collaborators=None, + last_modified_at=None, + public=True): + self.identifier = identifier + self.annotation = annotation + self.creator = creator + self.date = date + self.title = title + self.algorithm_metadata = algorithm_metadata + self.collaborators = collaborators or [] + self.last_modified_at = last_modified_at + self.public = public + + self.url = _mbid_to_url(identifier, 'playlist') + + def __repr__(self): + return(''.format(self.url)) + + def identifier(self): + """The MBID of the playlist.""" + return self.identifier + + def url(self): + """A URL where the playlist can be found.""" + return self.url + + +def _maybe_add(data, key, value): + if key: + data[key] = value + + +class PlaylistTrack(): + """A single item in a Listenbrainz playlist. + + :param identifier: the MBID of the recording + :type identifier: str + :param creator: the username of the playlist's creator + :type creator: str + :param title: the title of the playlist + :type title: str + :param added_at: the timestamp when the track was added to the playlist + :type added_at: datetime.datetime, optional + :param added_by: the username of the user who added this track + :type added_by: str, optional + :param artist_identifiers: the MBID of the recording artist(s) + :type artist_identifiers: List[str], optional + """ + def __init__(self, + identifier, + creator, + title, + added_at=None, + added_by=None, + artist_identifiers=None): + self.identifier = identifier + self.creator = creator + self.title = title + self.added_at = added_at + self.added_by = added_by + self.artist_identifiers = artist_identifiers + + self.url = _mbid_to_url(identifier, 'recording') + + def __repr__(self): + return(''.format(self.url)) + + def identifier(self): + """The MBID of the recording.""" + return self.identifier + + def url(self): + """A URL where the recording can be found.""" + return self.url + + def to_jspf(self): + """Return the playlist entry as a fragment of JSPF data.""" + ext = {} + if self.added_at: + ext['added_at'] = self.added_at.isoformat() + _maybe_add(ext, 'added_by', self.added_by) + _maybe_add(ext, 'artist_identifier', self.artist_identifiers) + + data = { + 'identifier': self.identifier, + 'extension': { + 'https://musicbrainz.org/doc/jspf#track': ext + }, + } + + _maybe_add(data, 'creator', self.creator) + _maybe_add(data, 'title', self.title) + + return data + + +class Playlist(): + """ A ListenBrainz playlist. + + :param metadata: a :class:`PlaylistMetadata` instance. + :type metadata: PlaylistMetadata + + :param tracks: a list of :class:`PlaylistTrack` instances. + :type tracks: List[PlaylistTrack] + """ + def __init__(self, metadata, tracks): + self.metadata = metadata + self.tracks = tracks + + self.identifier = metadata.identifier + self.url = metadata.url + + def __repr__(self): + return ''.format(self.url) + + def identifier(self): + """The MBID of the playlist.""" + return self.identifier + + def url(self): + """A URL where the playlist can be found.""" + return self.url + + def to_jspf(self): + """Return the playlist as JSPF data. + + `JSPF `_ is a variant of the standard + format `XSPF `_. ListenBrainz defines some + extensions to the format which are documented + `here `_. + + """ + ext = {} + _maybe_add(ext, 'algorithm_metadata', self.metadata.algorithm_metadata) + _maybe_add(ext, 'collaborators', self.metadata.collaborators) + _maybe_add(ext, 'last_modified_at', self.metadata.last_modified_at.isoformat()) + _maybe_add(ext, 'public', self.metadata.public) + + data = { + 'identifier': self.identifier, + 'extension': { + 'https://musicbrainz.org/doc/jspf#playlist': ext + }, + 'tracks': [track.to_jspf() for track in self.tracks], + } + _maybe_add(data, 'annotation', self.metadata.annotation) + _maybe_add(data, 'creator', self.metadata.creator) + _maybe_add(data, 'date', self.metadata.date.isoformat()) + _maybe_add(data, 'title', self.metadata.title) + + return data + + +def _playlist_metadata_from_response(payload): + data = payload['playlist'] + ext = data['extension']['https://musicbrainz.org/doc/jspf#playlist'] + return PlaylistMetadata( + identifier = _url_to_mbid(data['identifier'], kind='playlist'), + annotation = data['annotation'], + creator = data['creator'], + date = datetime.datetime.fromisoformat(data['date']), + title = data['title'], + algorithm_metadata = ext.get('algorithm_metadata'), + collaborators = ext['collaborators'], + last_modified_at = datetime.datetime.fromisoformat( + ext['last_modified_at'] + ), + public = ext['public'] + ) + + +def _playlist_from_response(payload): + metadata = _playlist_metadata_from_response(payload) + tracks = [] + for track in payload['playlist']['track']: + ext = track['extension']['https://musicbrainz.org/doc/jspf#track'] + artist_identifiers = [ + _url_to_mbid(artist, kind='artist') + for artist in ext['artist_identifier'] + ] + tracks.append(PlaylistTrack( + identifier = _url_to_mbid(track['identifier'], kind='recording'), + creator = track['creator'], + title = track['title'], + added_at = datetime.datetime.fromisoformat(ext['added_at']), + added_by = ext['added_by'], + artist_identifiers = artist_identifiers, + )) + return Playlist(metadata, tracks)