From db1519bcea861576accfce5ddb770158c4012834 Mon Sep 17 00:00:00 2001 From: rembo10 Date: Tue, 19 May 2015 20:26:50 -0700 Subject: [PATCH] Updated musicbrainz library --- lib/musicbrainzngs/__init__.py | 1 + lib/musicbrainzngs/caa.py | 177 +++++++++++++++++++++++++++ lib/musicbrainzngs/mbxml.py | 115 ++++++++++++------ lib/musicbrainzngs/musicbrainz.py | 191 +++++++++++++++++++++--------- 4 files changed, 396 insertions(+), 88 deletions(-) create mode 100644 lib/musicbrainzngs/caa.py diff --git a/lib/musicbrainzngs/__init__.py b/lib/musicbrainzngs/__init__.py index 36962ef5..22fed80d 100644 --- a/lib/musicbrainzngs/__init__.py +++ b/lib/musicbrainzngs/__init__.py @@ -1 +1,2 @@ from musicbrainzngs.musicbrainz import * +from musicbrainzngs.caa import * diff --git a/lib/musicbrainzngs/caa.py b/lib/musicbrainzngs/caa.py new file mode 100644 index 00000000..c43a5bea --- /dev/null +++ b/lib/musicbrainzngs/caa.py @@ -0,0 +1,177 @@ +# This file is part of the musicbrainzngs library +# Copyright (C) Alastair Porter, Wieland Hoffmann, and others +# This file is distributed under a BSD-2-Clause type license. +# See the COPYING file for more information. + +__all__ = [ + 'set_caa_hostname', 'get_image_list', 'get_release_group_image_list', + 'get_release_group_image_front', 'get_image_front', 'get_image_back', + 'get_image' + ] + +import json + +from musicbrainzngs import compat +from musicbrainzngs import musicbrainz + +hostname = "coverartarchive.org" + + +def set_caa_hostname(new_hostname): + """Set the base hostname for Cover Art Archive requests. + Defaults to 'coverartarchive.org'.""" + global hostname + hostname = new_hostname + + +def _caa_request(mbid, imageid=None, size=None, entitytype="release"): + """ Make a CAA request. + + :param imageid: ``front``, ``back`` or a number from the listing obtained + with :meth:`get_image_list`. + :type imageid: str + + :param size: 250, 500 + :type size: str or None + + :param entitytype: ``release`` or ``release-group`` + :type entitytype: str + """ + # Construct the full URL for the request, including hostname and + # query string. + path = [entitytype, mbid] + if imageid and size: + path.append("%s-%s" % (imageid, size)) + elif imageid: + path.append(imageid) + url = compat.urlunparse(( + 'http', + hostname, + '/%s' % '/'.join(path), + '', + '', + '' + )) + musicbrainz._log.debug("GET request for %s" % (url, )) + + # Set up HTTP request handler and URL opener. + httpHandler = compat.HTTPHandler(debuglevel=0) + handlers = [httpHandler] + + opener = compat.build_opener(*handlers) + + # Make request. + req = musicbrainz._MusicbrainzHttpRequest("GET", url, None) + # Useragent isn't needed for CAA, but we'll add it if it exists + if musicbrainz._useragent != "": + req.add_header('User-Agent', musicbrainz._useragent) + musicbrainz._log.debug("requesting with UA %s" % musicbrainz._useragent) + + resp = musicbrainz._safe_read(opener, req, None) + + # TODO: The content type declared by the CAA for JSON files is + # 'applicaiton/octet-stream'. This is not useful to detect whether the + # content is JSON, so default to decoding JSON if no imageid was supplied. + # http://tickets.musicbrainz.org/browse/CAA-75 + if imageid: + # If we asked for an image, return the image + return resp + else: + # Otherwise it's json + return json.loads(resp) + + +def get_image_list(releaseid): + """Get the list of cover art associated with a release. + + The return value is the deserialized response of the `JSON listing + `_ + returned by the Cover Art Archive API. + + If an error occurs then a musicbrainz.ResponseError will + be raised with one of the following HTTP codes: + + * 400: `Releaseid` is not a valid UUID + * 404: No release exists with an MBID of `releaseid` + * 503: Ratelimit exceeded + """ + return _caa_request(releaseid) + + +def get_release_group_image_list(releasegroupid): + """Get the list of cover art associated with a release group. + + The return value is the deserialized response of the `JSON listing + `_ + returned by the Cover Art Archive API. + + If an error occurs then a musicbrainz.ResponseError will + be raised with one of the following HTTP codes: + + * 400: `Releaseid` is not a valid UUID + * 404: No release exists with an MBID of `releaseid` + * 503: Ratelimit exceeded + """ + return _caa_request(releasegroupid, entitytype="release-group") + + +def get_release_group_image_front(releasegroupid, size=None): + """Download the front cover art for a release group. + The `size` argument and the possible error conditions are the same as for + :meth:`get_image`. + """ + return get_image(releasegroupid, "front", size=size, + entitytype="release-group") + + +def get_image_front(releaseid, size=None): + """Download the front cover art for a release. + The `size` argument and the possible error conditions are the same as for + :meth:`get_image`. + """ + return get_image(releaseid, "front", size=size) + + +def get_image_back(releaseid, size=None): + """Download the back cover art for a release. + The `size` argument and the possible error conditions are the same as for + :meth:`get_image`. + """ + return get_image(releaseid, "back", size=size) + + +def get_image(mbid, coverid, size=None, entitytype="release"): + """Download cover art for a release. The coverart file to download + is specified by the `coverid` argument. + + If `size` is not specified, download the largest copy present, which can be + very large. + + If an error occurs then a musicbrainz.ResponseError will be raised with one + of the following HTTP codes: + + * 400: `Releaseid` is not a valid UUID or `coverid` is invalid + * 404: No release exists with an MBID of `releaseid` + * 503: Ratelimit exceeded + + :param coverid: ``front``, ``back`` or a number from the listing obtained with + :meth:`get_image_list` + :type coverid: int or str + + :param size: 250, 500 or None. If it is None, the largest available picture + will be downloaded. If the image originally uploaded to the + Cover Art Archive was smaller than the requested size, only + the original image will be returned. + :type size: str or None + + :param entitytype: The type of entity for which to download the cover art. + This is either ``release`` or ``release-group``. + :type entitytype: str + :return: The binary image data + :type: str + """ + if isinstance(coverid, int): + coverid = "%d" % (coverid, ) + if isinstance(size, int): + size = "%d" % (size, ) + return _caa_request(mbid, coverid, size=size, entitytype=entitytype) diff --git a/lib/musicbrainzngs/mbxml.py b/lib/musicbrainzngs/mbxml.py index 03998143..49a4a02e 100644 --- a/lib/musicbrainzngs/mbxml.py +++ b/lib/musicbrainzngs/mbxml.py @@ -36,6 +36,22 @@ NS_MAP = {"http://musicbrainz.org/ns/mmd-2.0#": "ws2", "http://musicbrainz.org/ns/ext#-2.0": "ext"} _log = logging.getLogger("musicbrainzngs") +def get_error_message(error): + """ Given an error XML message from the webservice containing + xy, return a list + of [x, y]""" + try: + tree = util.bytes_to_elementtree(error) + root = tree.getroot() + errors = [] + if root.tag == "error": + for ch in root: + if ch.tag == "text": + errors.append(ch.text) + return errors + except ET.ParseError: + return None + def make_artist_credit(artists): names = [] for artist in artists: @@ -123,6 +139,7 @@ def parse_message(message): "place": parse_place, "release": parse_release, "release-group": parse_release_group, + "series": parse_series, "recording": parse_recording, "work": parse_work, "url": parse_url, @@ -138,6 +155,7 @@ def parse_message(message): "place-list": parse_place_list, "release-list": parse_release_list, "release-group-list": parse_release_group_list, + "series-list": parse_series_list, "recording-list": parse_recording_list, "work-list": parse_work_list, "url-list": parse_url_list, @@ -297,7 +315,7 @@ def parse_relation_list(rl): def parse_relation(relation): result = {} attribs = ["type", "type-id"] - elements = ["target", "direction", "begin", "end", "ended"] + elements = ["target", "direction", "begin", "end", "ended", "ordering-key"] inner_els = {"area": parse_area, "artist": parse_artist, "label": parse_label, @@ -305,6 +323,7 @@ def parse_relation(relation): "recording": parse_recording, "release": parse_release, "release-group": parse_release_group, + "series": parse_series, "attribute-list": parse_element_list, "work": parse_work, "target": parse_relation_target @@ -324,6 +343,8 @@ def parse_release(release): "label-info-list": parse_label_info_list, "medium-list": parse_medium_list, "release-group": parse_release_group, + "tag-list": parse_tag_list, + "user-tag-list": parse_tag_list, "relation-list": parse_relation_list, "annotation": parse_annotation, "cover-art-archive": parse_caa, @@ -408,6 +429,22 @@ def parse_recording(recording): return result +def parse_series_list(sl): + return [parse_series(s) for s in sl] + +def parse_series(series): + result = {} + attribs = ["id", "type", "ext:score"] + elements = ["name", "disambiguation"] + inner_els = {"alias-list": parse_alias_list, + "relation-list": parse_relation_list, + "annotation": parse_annotation} + + result.update(parse_attributes(attribs, series)) + result.update(parse_elements(elements, inner_els, series)) + + return result + def parse_external_id_list(pl): return [parse_attributes(["id"], p)["id"] for p in pl] @@ -427,13 +464,28 @@ def parse_work(work): "alias-list": parse_alias_list, "iswc-list": parse_element_list, "relation-list": parse_relation_list, - "annotation": parse_response_message} + "annotation": parse_response_message, + "attribute-list": parse_work_attribute_list + } result.update(parse_attributes(attribs, work)) result.update(parse_elements(elements, inner_els, work)) return result +def parse_work_attribute_list(wal): + return [parse_work_attribute(wa) for wa in wal] + +def parse_work_attribute(wa): + result = {} + attribs = ["type"] + + result.update(parse_attributes(attribs, wa)) + result["attribute"] = wa.text + + return result + + def parse_url_list(ul): return [parse_url(u) for u in ul] @@ -617,45 +669,40 @@ def make_barcode_request(release2barcode): return ET.tostring(root, "utf-8") -def make_tag_request(artist2tags, recording2tags): +def make_tag_request(**kwargs): NS = "http://musicbrainz.org/ns/mmd-2.0#" root = ET.Element("{%s}metadata" % NS) - rec_list = ET.SubElement(root, "{%s}recording-list" % NS) - for rec, tags in recording2tags.items(): - rec_xml = ET.SubElement(rec_list, "{%s}recording" % NS) - rec_xml.set("{%s}id" % NS, rec) - taglist = ET.SubElement(rec_xml, "{%s}user-tag-list" % NS) - for tag in tags: - usertag_xml = ET.SubElement(taglist, "{%s}user-tag" % NS) - name_xml = ET.SubElement(usertag_xml, "{%s}name" % NS) - name_xml.text = tag - art_list = ET.SubElement(root, "{%s}artist-list" % NS) - for art, tags in artist2tags.items(): - art_xml = ET.SubElement(art_list, "{%s}artist" % NS) - art_xml.set("{%s}id" % NS, art) - taglist = ET.SubElement(art_xml, "{%s}user-tag-list" % NS) - for tag in tags: - usertag_xml = ET.SubElement(taglist, "{%s}user-tag" % NS) - name_xml = ET.SubElement(usertag_xml, "{%s}name" % NS) - name_xml.text = tag + for entity_type in ['artist', 'label', 'place', 'recording', 'release', 'release_group', 'work']: + entity_tags = kwargs.pop(entity_type + '_tags', None) + if entity_tags is not None: + e_list = ET.SubElement(root, "{%s}%s-list" % (NS, entity_type.replace('_', '-'))) + for e, tags in entity_tags.items(): + e_xml = ET.SubElement(e_list, "{%s}%s" % (NS, entity_type.replace('_', '-'))) + e_xml.set("{%s}id" % NS, e) + taglist = ET.SubElement(e_xml, "{%s}user-tag-list" % NS) + for tag in tags: + usertag_xml = ET.SubElement(taglist, "{%s}user-tag" % NS) + name_xml = ET.SubElement(usertag_xml, "{%s}name" % NS) + name_xml.text = tag + if kwargs.keys(): + raise TypeError("make_tag_request() got an unexpected keyword argument '%s'" % kwargs.popitem()[0]) return ET.tostring(root, "utf-8") -def make_rating_request(artist2rating, recording2rating): +def make_rating_request(**kwargs): NS = "http://musicbrainz.org/ns/mmd-2.0#" root = ET.Element("{%s}metadata" % NS) - rec_list = ET.SubElement(root, "{%s}recording-list" % NS) - for rec, rating in recording2rating.items(): - rec_xml = ET.SubElement(rec_list, "{%s}recording" % NS) - rec_xml.set("{%s}id" % NS, rec) - rating_xml = ET.SubElement(rec_xml, "{%s}user-rating" % NS) - rating_xml.text = str(rating) - art_list = ET.SubElement(root, "{%s}artist-list" % NS) - for art, rating in artist2rating.items(): - art_xml = ET.SubElement(art_list, "{%s}artist" % NS) - art_xml.set("{%s}id" % NS, art) - rating_xml = ET.SubElement(art_xml, "{%s}user-rating" % NS) - rating_xml.text = str(rating) + for entity_type in ['artist', 'label', 'recording', 'release_group', 'work']: + entity_ratings = kwargs.pop(entity_type + '_ratings', None) + if entity_ratings is not None: + e_list = ET.SubElement(root, "{%s}%s-list" % (NS, entity_type.replace('_', '-'))) + for e, rating in entity_ratings.items(): + e_xml = ET.SubElement(e_list, "{%s}%s" % (NS, entity_type.replace('_', '-'))) + e_xml.set("{%s}id" % NS, e) + rating_xml = ET.SubElement(e_xml, "{%s}user-rating" % NS) + rating_xml.text = str(rating) + if kwargs.keys(): + raise TypeError("make_rating_request() got an unexpected keyword argument '%s'" % kwargs.popitem()[0]) return ET.tostring(root, "utf-8") diff --git a/lib/musicbrainzngs/musicbrainz.py b/lib/musicbrainzngs/musicbrainz.py index d7e5e74f..57450270 100644 --- a/lib/musicbrainzngs/musicbrainz.py +++ b/lib/musicbrainzngs/musicbrainz.py @@ -11,25 +11,25 @@ import socket import hashlib import locale import sys -import base64 +import json import xml.etree.ElementTree as etree from xml.parsers import expat -from warnings import warn, simplefilter +from warnings import warn from musicbrainzngs import mbxml from musicbrainzngs import util from musicbrainzngs import compat +import base64 + _version = "0.6devMODIFIED" _log = logging.getLogger("musicbrainzngs") -# turn on DeprecationWarnings below -simplefilter(action="once", category=DeprecationWarning) - +LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' # Constants for validation. -RELATABLE_TYPES = ['area', 'artist', 'label', 'place', 'recording', 'release', 'release-group', 'url', 'work'] +RELATABLE_TYPES = ['area', 'artist', 'label', 'place', 'recording', 'release', 'release-group', 'series', 'url', 'work'] RELATION_INCLUDES = [entity + '-rels' for entity in RELATABLE_TYPES] TAG_INCLUDES = ["tags", "user-tags"] RATING_INCLUDES = ["ratings", "user-ratings"] @@ -43,6 +43,9 @@ VALID_INCLUDES = { ] + RELATION_INCLUDES + TAG_INCLUDES + RATING_INCLUDES, 'annotation': [ + ], + 'instrument': [ + ], 'label': [ "releases", # Subqueries @@ -59,20 +62,23 @@ VALID_INCLUDES = { "artists", "labels", "recordings", "release-groups", "media", "artist-credits", "discids", "puids", "isrcs", "recording-level-rels", "work-level-rels", "annotation", "aliases" - ] + RELATION_INCLUDES, + ] + TAG_INCLUDES + RELATION_INCLUDES, 'release-group': [ "artists", "releases", "discids", "media", "artist-credits", "annotation", "aliases" ] + TAG_INCLUDES + RATING_INCLUDES + RELATION_INCLUDES, + 'series': [ + "annotation", "aliases" + ] + RELATION_INCLUDES, 'work': [ "artists", # Subqueries "aliases", "annotation" ] + TAG_INCLUDES + RATING_INCLUDES + RELATION_INCLUDES, 'url': RELATION_INCLUDES, - 'discid': [ + 'discid': [ # Discid should be the same as release "artists", "labels", "recordings", "release-groups", "media", "artist-credits", "discids", "puids", "isrcs", - "recording-level-rels", "work-level-rels" + "recording-level-rels", "work-level-rels", "annotation", "aliases" ] + RELATION_INCLUDES, 'isrc': ["artists", "releases", "puids", "isrcs"], 'iswc': ["artists"], @@ -93,7 +99,7 @@ VALID_RELEASE_TYPES = [ "nat", "album", "single", "ep", "broadcast", "other", # primary types "compilation", "soundtrack", "spokenword", "interview", "audiobook", - "live", "remix", "dj-mix", "mixtape/street", "demo", # secondary types + "live", "remix", "dj-mix", "mixtape/street", # secondary types ] #: These can be used to filter whenever releases or release-groups are involved VALID_RELEASE_STATUSES = ["official", "promotion", "bootleg", "pseudo-release"] @@ -101,6 +107,10 @@ VALID_SEARCH_FIELDS = { 'annotation': [ 'entity', 'name', 'text', 'type' ], + 'area': [ + 'aid', 'area', 'alias', 'begin', 'comment', 'end', 'ended', + 'iso', 'iso1', 'iso2', 'iso3', 'type' + ], 'artist': [ 'arid', 'artist', 'artistaccent', 'alias', 'begin', 'comment', 'country', 'end', 'ended', 'gender', 'ipi', 'sortname', 'tag', 'type', @@ -133,12 +143,20 @@ VALID_SEARCH_FIELDS = { 'rgid', 'script', 'secondarytype', 'status', 'tag', 'tracks', 'tracksmedium', 'type' ], + 'series': [ + 'alias', 'comment', 'sid', 'series', 'type' + ], 'work': [ 'alias', 'arid', 'artist', 'comment', 'iswc', 'lang', 'tag', 'type', 'wid', 'work', 'workaccent' ], } +# Constants +class AUTH_YES: pass +class AUTH_NO: pass +class AUTH_IFSET: pass + # Exceptions. @@ -280,7 +298,7 @@ def auth(u, p): global user, password user = u password = p - + def hpauth(u, p): """Set the username and password to be used in subsequent queries to the MusicBrainz XML API that require authentication. @@ -559,28 +577,33 @@ def set_format(fmt="xml"): """Sets the format that should be returned by the Web Service. The server currently supports `xml` and `json`. - When you set the format to anything different from the default, - you need to provide your own parser with :func:`set_parser`. + This method will set a default parser for the specified format, + but you can modify it with :func:`set_parser`. .. warning:: The json format used by the server is different from the json format returned by the `musicbrainzngs` internal parser - when using the `xml` format! + when using the `xml` format! This format may change at any time. """ global ws_format - if fmt not in ["xml", "json"]: - raise ValueError("invalid format: %s" % fmt) - else: + if fmt == "xml": ws_format = fmt + set_parser() # set to default + elif fmt == "json": + ws_format = fmt + warn("The json format is non-official and may change at any time") + set_parser(json.loads) + else: + raise ValueError("invalid format: %s" % fmt) @_rate_limit -def _mb_request(path, method='GET', auth_required=False, client_required=False, - args=None, data=None, body=None): +def _mb_request(path, method='GET', auth_required=AUTH_NO, + client_required=False, args=None, data=None, body=None): """Makes a request for the specified `path` (endpoint) on /ws/2 on the globally-specified hostname. Parses the responses and returns the resulting object. `auth_required` and `client_required` control - whether exceptions should be raised if the client and - username/password are left unspecified, respectively. + whether exceptions should be raised if the username/password and + client are left unspecified, respectively. """ global parser_fun @@ -626,11 +649,19 @@ def _mb_request(path, method='GET', auth_required=False, client_required=False, handlers = [httpHandler] # Add credentials if required. - if auth_required: + add_auth = False + if auth_required == AUTH_YES: _log.debug("Auth required for %s" % url) if not user: raise UsageError("authorization required; " "use auth(user, pass) first") + add_auth = True + + if auth_required == AUTH_IFSET and user: + _log.debug("Using auth for %s because user and pass is set" % url) + add_auth = True + + if add_auth: passwordMgr = _RedirectPasswordMgr() authHandler = _DigestAuthHandler(passwordMgr) authHandler.add_password("musicbrainz.org", (), user, password) @@ -641,6 +672,7 @@ def _mb_request(path, method='GET', auth_required=False, client_required=False, # Make request. req = _MusicbrainzHttpRequest(method, url, data) req.add_header('User-Agent', _useragent) + # Add headphones credentials if mb_auth: @@ -658,16 +690,19 @@ def _mb_request(path, method='GET', auth_required=False, client_required=False, return parser_fun(resp) -def _is_auth_required(entity, includes): - """ Some calls require authentication. This returns - True if a call does, False otherwise - """ - if "user-tags" in includes or "user-ratings" in includes: - return True - elif entity.startswith("collection"): - return True - else: - return False +def _get_auth_type(entity, id, includes): + """ Some calls require authentication. This returns + True if a call does, False otherwise + """ + if "user-tags" in includes or "user-ratings" in includes: + return AUTH_YES + elif entity.startswith("collection"): + if not id: + return AUTH_YES + else: + return AUTH_IFSET + else: + return AUTH_NO def _do_mb_query(entity, id, includes=[], params={}): """Make a single GET call to the MusicBrainz XML API. `entity` is a @@ -681,7 +716,7 @@ def _do_mb_query(entity, id, includes=[], params={}): if not isinstance(includes, list): includes = [includes] _check_includes(entity, includes) - auth_required = _is_auth_required(entity, includes) + auth_required = _get_auth_type(entity, id, includes) args = dict(params) if len(includes) > 0: inc = " ".join(includes) @@ -704,8 +739,8 @@ def _do_mb_search(entity, query='', fields={}, if query: clean_query = util._unicode(query) if fields: - clean_query = re.sub(r'([+\-&|!(){}\[\]\^"~*?:\\])', - r'\\\1', clean_query) + clean_query = re.sub(LUCENE_SPECIAL, r'\\\1', + clean_query) if strict: query_parts.append('"%s"' % clean_query) else: @@ -721,11 +756,11 @@ def _do_mb_search(entity, query='', fields={}, elif key == "puid": warn("PUID support was removed from server\n" "the 'puid' field is ignored", - DeprecationWarning, stacklevel=2) + Warning, stacklevel=2) # Escape Lucene's special characters. value = util._unicode(value) - value = re.sub(r'([+\-&|!(){}\[\]\^"~*?:\\\/])', r'\\\1', value) + value = re.sub(LUCENE_SPECIAL, r'\\\1', value) if value: if strict: query_parts.append('%s:"%s"' % (key, value)) @@ -752,18 +787,18 @@ def _do_mb_search(entity, query='', fields={}, def _do_mb_delete(path): """Send a DELETE request for the specified object. """ - return _mb_request(path, 'DELETE', True, True) + return _mb_request(path, 'DELETE', AUTH_YES, True) def _do_mb_put(path): """Send a PUT request for the specified object. """ - return _mb_request(path, 'PUT', True, True) + return _mb_request(path, 'PUT', AUTH_YES, True) def _do_mb_post(path, body): """Perform a single POST call for an endpoint with a specified request body. """ - return _mb_request(path, 'POST', True, True, body=body) + return _mb_request(path, 'POST', AUTH_YES, True, body=body) # The main interface! @@ -788,6 +823,15 @@ def get_artist_by_id(id, includes=[], release_status=[], release_type=[]): release_status, release_type) return _do_mb_query("artist", id, includes, params) +@_docstring('instrument') +def get_instrument_by_id(id, includes=[], release_status=[], release_type=[]): + """Get the instrument with the MusicBrainz `id` as a dict with an 'artist' key. + + *Available includes*: {includes}""" + params = _check_filter_and_make_params("instrument", includes, + release_status, release_type) + return _do_mb_query("instrument", id, includes, params) + @_docstring('label') def get_label_by_id(id, includes=[], release_status=[], release_type=[]): """Get the label with the MusicBrainz `id` as a dict with a 'label' key. @@ -836,6 +880,13 @@ def get_release_group_by_id(id, includes=[], release_status, release_type) return _do_mb_query("release-group", id, includes, params) +@_docstring('series') +def get_series_by_id(id, includes=[]): + """Get the series with the MusicBrainz `id` as a dict with a 'series' key. + + *Available includes*: {includes}""" + return _do_mb_query("series", id, includes) + @_docstring('work') def get_work_by_id(id, includes=[]): """Get the work with the MusicBrainz `id` as a dict with a 'work' key. @@ -860,6 +911,13 @@ def search_annotations(query='', limit=None, offset=None, strict=False, **fields *Available search fields*: {fields}""" return _do_mb_search('annotation', query, fields, limit, offset, strict) +@_docstring('area') +def search_areas(query='', limit=None, offset=None, strict=False, **fields): + """Search for areas and return a dict with an 'area-list' key. + + *Available search fields*: {fields}""" + return _do_mb_search('area', query, fields, limit, offset, strict) + @_docstring('artist') def search_artists(query='', limit=None, offset=None, strict=False, **fields): """Search for artists and return a dict with an 'artist-list' key. @@ -898,6 +956,13 @@ def search_release_groups(query='', limit=None, offset=None, *Available search fields*: {fields}""" return _do_mb_search('release-group', query, fields, limit, offset, strict) +@_docstring('series') +def search_series(query='', limit=None, offset=None, strict=False, **fields): + """Search for series and return a dict with a 'series-list' key. + + *Available search fields*: {fields}""" + return _do_mb_search('series', query, fields, limit, offset, strict) + @_docstring('work') def search_works(query='', limit=None, offset=None, strict=False, **fields): """Search for works and return a dict with a 'work-list' key. @@ -907,18 +972,25 @@ def search_works(query='', limit=None, offset=None, strict=False, **fields): # Lists of entities -@_docstring('release') -def get_releases_by_discid(id, includes=[], toc=None, cdstubs=True): - """Search for releases with a :musicbrainz:`Disc ID`. +@_docstring('discid') +def get_releases_by_discid(id, includes=[], toc=None, cdstubs=True, media_format=None): + """Search for releases with a :musicbrainz:`Disc ID` or table of contents. When a `toc` is provided and no release with the disc ID is found, a fuzzy search by the toc is done. The `toc` should have to same format as :attr:`discid.Disc.toc_string`. + When a `toc` is provided, the format of the discid itself is not + checked server-side, so any value may be passed if searching by only + `toc` is desired. If no toc matches in musicbrainz but a :musicbrainz:`CD Stub` does, the CD Stub will be returned. Prevent this from happening by passing `cdstubs=False`. + By default only results that match a format that allows discids + (e.g. CD) are included. To include all media formats, pass + `media_format='all'`. + The result is a dict with either a 'disc' , a 'cdstub' key or a 'release-list' (fuzzy match with TOC). A 'disc' has a 'release-list' and a 'cdstub' key has direct 'artist' @@ -931,6 +1003,8 @@ def get_releases_by_discid(id, includes=[], toc=None, cdstubs=True): params["toc"] = toc if not cdstubs: params["cdstubs"] = "no" + if media_format: + params["media-format"] = media_format return _do_mb_query("discid", id, includes, params) @_docstring('recording') @@ -940,7 +1014,7 @@ def get_recordings_by_echoprint(echoprint, includes=[], release_status=[], (not available on server)""" warn("Echoprints were never introduced\n" "and will not be found (404)", - DeprecationWarning, stacklevel=2) + Warning, stacklevel=2) raise ResponseError(cause=compat.HTTPError( None, 404, "Not Found", None, None)) @@ -951,7 +1025,7 @@ def get_recordings_by_puid(puid, includes=[], release_status=[], (not available on server)""" warn("PUID support was removed from the server\n" "and no PUIDs will be found (404)", - DeprecationWarning, stacklevel=2) + Warning, stacklevel=2) raise ResponseError(cause=compat.HTTPError( None, 404, "Not Found", None, None)) @@ -1116,7 +1190,7 @@ def submit_puids(recording_puids): """ warn("PUID support was dropped at the server\n" "nothing will be submitted", - DeprecationWarning, stacklevel=2) + Warning, stacklevel=2) return {'message': {'text': 'OK'}} def submit_echoprints(recording_echoprints): @@ -1125,7 +1199,7 @@ def submit_echoprints(recording_echoprints): """ warn("Echoprints were never introduced\n" "nothing will be submitted", - DeprecationWarning, stacklevel=2) + Warning, stacklevel=2) return {'message': {'text': 'OK'}} def submit_isrcs(recording_isrcs): @@ -1139,20 +1213,29 @@ def submit_isrcs(recording_isrcs): query = mbxml.make_isrc_request(rec2isrcs) return _do_mb_post("recording", query) -def submit_tags(artist_tags={}, recording_tags={}): +def submit_tags(**kwargs): """Submit user tags. - Artist or recording parameters are of the form: + Takes parameters named e.g. 'artist_tags', 'recording_tags', etc., + and of the form: {entity_id1: [tag1, ...], ...} + + The user's tags for each entity will be set to that list, adding or + removing tags as necessary. Submitting an empty list for an entity + will remove all tags for that entity by the user. """ - query = mbxml.make_tag_request(artist_tags, recording_tags) + query = mbxml.make_tag_request(**kwargs) return _do_mb_post("tag", query) -def submit_ratings(artist_ratings={}, recording_ratings={}): - """ Submit user ratings. - Artist or recording parameters are of the form: +def submit_ratings(**kwargs): + """Submit user ratings. + Takes parameters named e.g. 'artist_ratings', 'recording_ratings', etc., + and of the form: {entity_id1: rating, ...} + + Ratings are numbers from 0-100, at intervals of 20 (20 per 'star'). + Submitting a rating of 0 will remove the user's rating. """ - query = mbxml.make_rating_request(artist_ratings, recording_ratings) + query = mbxml.make_rating_request(**kwargs) return _do_mb_post("rating", query) def add_releases_to_collection(collection, releases=[]):