Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add support for querying playlists #10

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/api_ref.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
#################

Expand Down
2 changes: 2 additions & 0 deletions pylistenbrainz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 54 additions & 1 deletion pylistenbrainz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -421,4 +424,54 @@ def get_user_listen_count(self, username):
if e.status_code == 204:
return None
else:
raise
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)
281 changes: 281 additions & 0 deletions pylistenbrainz/playlist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
# pylistenbrainz - A simple client library for ListenBrainz
# Copyright (C) 2022 Sam Thursfield <[email protected]>
#
# 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 <https://www.gnu.org/licenses/>.


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('<pylistenbrainz.PlaylistMetadata url="{}">'.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('<pylistenbrainz.PlaylistTrack url="{}">'.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_identifiers', 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 '<pylistenbrainz.Playlist url="{}">'.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 <https://xspf.org/jspf>`_ is a variant of the standard
format `XSPF <https://xspf.org/>`_. ListenBrainz defines some
extensions to the format which are documented
`here <https://musicbrainz.org/doc/jspf>`_.

"""
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
},
'track': [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 {
'playlist': 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']

# This property changed name. Support options for now.
# See https://tickets.metabrainz.org/browse/LB-1058
ext_artist_identifiers = ext.get(
'artist_identifiers',
ext.get(
'artist_identifier',
[]
)
)

artist_identifiers = [
_url_to_mbid(artist, kind='artist')
for artist in ext_artist_identifiers
]
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)