From 6e5e8a9cb01ab12253c2e9c435e255484ea1b8b7 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 17:53:56 -0700 Subject: [PATCH 01/41] Add Deezer plugin --- beetsplug/deezer.py | 341 +++++++++++++++++++++++++++++++++++++++++++ beetsplug/spotify.py | 20 ++- docs/changelog.rst | 4 + 3 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 beetsplug/deezer.py diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py new file mode 100644 index 000000000..b06777f99 --- /dev/null +++ b/beetsplug/deezer.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2019, Rahul Ahuja. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Adds Deezer release and track search support to the autotagger +""" +from __future__ import absolute_import, print_function + +import re +import collections + +import six +import unidecode +import requests + +from beets import ui +from beets.plugins import BeetsPlugin +from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance + + +class DeezerPlugin(BeetsPlugin): + # Base URLs for the Deezer API + # Documentation: https://developers.deezer.com/api/ + search_url = 'https://api.deezer.com/search/' + album_url = 'https://api.deezer.com/album/' + track_url = 'https://api.deezer.com/track/' + + def __init__(self): + super(DeezerPlugin, self).__init__() + self.config.add( + { + 'mode': 'list', + 'tiebreak': 'popularity', + 'show_failures': False, + 'artist_field': 'albumartist', + 'album_field': 'album', + 'track_field': 'title', + 'region_filter': None, + 'regex': [], + 'source_weight': 0.5, + } + ) + + def _get_deezer_id(self, url_type, id_): + """Parse a Deezer ID from its URL if necessary. + + :param url_type: Type of Deezer URL. Either 'album', 'artist', 'playlist', + or 'track'. + :type url_type: str + :param id_: Deezer ID or URL. + :type id_: str + :return: Deezer ID. + :rtype: str + """ + id_regex = r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)' + self._log.debug(u'Searching for {} {}', url_type, id_) + match = re.search(id_regex.format(url_type), str(id_)) + return match.group(3) if match else None + + def album_for_id(self, album_id): + """Fetch an album by its Deezer ID or URL and return an + AlbumInfo object or None if the album is not found. + + :param album_id: Deezer ID or URL for the album. + :type album_id: str + :return: AlbumInfo object for album. + :rtype: beets.autotag.hooks.AlbumInfo or None + """ + deezer_id = self._get_deezer_id('album', album_id) + if deezer_id is None: + return None + + album_data = requests.get(self.album_url + deezer_id).json() + artist, artist_id = self._get_artist(album_data['contributors']) + + release_date = album_data['release_date'] + date_parts = [int(part) for part in release_date.split('-')] + num_date_parts = len(date_parts) + + if num_date_parts == 3: + year, month, day = date_parts + elif num_date_parts == 2: + year, month = date_parts + day = None + elif num_date_parts == 1: + year = date_parts[0] + month = None + day = None + else: + raise ui.UserError( + u"Invalid `release_date` returned " + u"by Deezer API: '{}'".format(release_date) + ) + + tracks_data = requests.get( + self.album_url + deezer_id + '/tracks' + ).json()['data'] + tracks = [] + medium_totals = collections.defaultdict(int) + for i, track_data in enumerate(tracks_data): + track = self._get_track(track_data) + track.index = i + 1 + medium_totals[track.medium] += 1 + tracks.append(track) + for track in tracks: + track.medium_total = medium_totals[track.medium] + + return AlbumInfo( + album=album_data['title'], + album_id=deezer_id, + artist=artist, + artist_credit=self._get_artist([album_data['artist']]), + artist_id=artist_id, + tracks=tracks, + albumtype=album_data['record_type'], + va=len(album_data['contributors']) == 1 + and artist.lower() == 'various artists', + year=year, + month=month, + day=day, + label=album_data['label'], + mediums=max(medium_totals.keys()), + data_source='Deezer', + data_url=album_data['link'], + ) + + def _get_track(self, track_data): + """Convert a Deezer track object dict to a TrackInfo object. + + :param track_data: Deezer Track object dict + :type track_data: dict + :return: TrackInfo object for track + :rtype: beets.autotag.hooks.TrackInfo + """ + artist, artist_id = self._get_artist( + track_data.get('contributors', [track_data['artist']]) + ) + return TrackInfo( + title=track_data['title'], + track_id=track_data['id'], + artist=artist, + artist_id=artist_id, + length=track_data['duration'], + index=track_data['track_position'], + medium=track_data['disk_number'], + medium_index=track_data['track_position'], + data_source='Deezer', + data_url=track_data['link'], + ) + + def track_for_id(self, track_id=None, track_data=None): + """Fetch a track by its Deezer ID or URL and return a + TrackInfo object or None if the track is not found. + + :param track_id: (Optional) Deezer ID or URL for the track. Either + ``track_id`` or ``track_data`` must be provided. + :type track_id: str + :param track_data: (Optional) Simplified track object dict. May be + provided instead of ``track_id`` to avoid unnecessary API calls. + :type track_data: dict + :return: TrackInfo object for track + :rtype: beets.autotag.hooks.TrackInfo or None + """ + if track_data is None: + deezer_id = self._get_deezer_id('track', track_id) + if deezer_id is None: + return None + track_data = requests.get(self.track_url + deezer_id).json() + track = self._get_track(track_data) + + # Get album's tracks to set `track.index` (position on the entire + # release) and `track.medium_total` (total number of tracks on + # the track's disc). + album_tracks_data = requests.get( + self.album_url + str(track_data['album']['id']) + '/tracks' + ).json()['data'] + medium_total = 0 + for i, track_data in enumerate(album_tracks_data, start=1): + if track_data['disc_number'] == track.medium: + medium_total += 1 + if track_data['id'] == track.track_id: + track.index = i + track.medium_total = medium_total + return track + + @staticmethod + def _get_artist(artists): + """Returns an artist string (all artists) and an artist_id (the main + artist) for a list of Deezer artist object dicts. + + :param artists: Iterable of ``contributors`` or ``artist`` returned by the + Deezer Album (https://developers.deezer.com/api/album) or Deezer Track + (https://developers.deezer.com/api/track) APIs. + :type artists: list[dict] + :return: Normalized artist string + :rtype: str + """ + artist_id = None + artist_names = [] + for artist in artists: + if not artist_id: + artist_id = artist['id'] + name = artist['name'] + # Move articles to the front. + name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) + artist_names.append(name) + artist = ', '.join(artist_names).replace(' ,', ',') or None + return artist, artist_id + + def album_distance(self, items, album_info, mapping): + """Returns the Deezer source weight and the maximum source weight + for albums. + """ + dist = Distance() + if album_info.data_source == 'Deezer': + dist.add('source', self.config['source_weight'].as_number()) + return dist + + def track_distance(self, item, track_info): + """Returns the Deezer source weight and the maximum source weight + for individual tracks. + """ + dist = Distance() + if track_info.data_source == 'Deezer': + dist.add('source', self.config['source_weight'].as_number()) + return dist + + def candidates(self, items, artist, album, va_likely): + """Returns a list of AlbumInfo objects for Deezer Search API results + matching an ``album`` and ``artist`` (if not various). + + :param items: List of items comprised by an album to be matched. + :type items: list[beets.library.Item] + :param artist: The artist of the album to be matched. + :type artist: str + :param album: The name of the album to be matched. + :type album: str + :param va_likely: True if the album to be matched likely has + Various Artists. + :type va_likely: bool + :return: Candidate AlbumInfo objects. + :rtype: list[beets.autotag.hooks.AlbumInfo] + """ + query_filters = {'album': album} + if not va_likely: + query_filters['artist'] = artist + response_data = self._search_deezer( + query_type='album', filters=query_filters + ) + if response_data is None: + return [] + return [ + self.album_for_id(album_id=album_data['id']) + for album_data in response_data['data'] + ] + + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for Deezer Search API results + matching ``title`` and ``artist``. + + :param item: Singleton item to be matched. + :type item: beets.library.Item + :param artist: The artist of the track to be matched. + :type artist: str + :param title: The title of the track to be matched. + :type title: str + :return: Candidate TrackInfo objects. + :rtype: list[beets.autotag.hooks.TrackInfo] + """ + response_data = self._search_deezer( + query_type='track', keywords=title, filters={'artist': artist} + ) + if response_data is None: + return [] + return [ + self.track_for_id(track_data=track_data) + for track_data in response_data['data'] + ] + + @staticmethod + def _construct_search_query(filters=None, keywords=''): + """Construct a query string with the specified filters and keywords to + be provided to the Deezer Search API (https://developers.deezer.com/api/search). + + :param filters: (Optional) Field filters to apply. + :type filters: dict + :param keywords: (Optional) Query keywords to use. + :type keywords: str + :return: Query string to be provided to the Search API. + :rtype: str + """ + query_components = [ + keywords, + ' '.join('{}:"{}"'.format(k, v) for k, v in filters.items()), + ] + query = ' '.join([q for q in query_components if q]) + if not isinstance(query, six.text_type): + query = query.decode('utf8') + return unidecode.unidecode(query) + + def _search_deezer(self, query_type, filters=None, keywords=''): + """Query the Deezer Search API for the specified ``keywords``, applying + the provided ``filters``. + + :param query_type: The Deezer Search API method to use. Valid types are: + 'album', 'artist', 'history', 'playlist', 'podcast', 'radio', 'track', + 'user', and 'track'. + :type query_type: str + :param filters: (Optional) Field filters to apply. + :type filters: dict + :param keywords: (Optional) Query keywords to use. + :type keywords: str + :return: JSON data for the class:`Response ` object or None + if no search results are returned. + :rtype: dict or None + """ + query = self._construct_search_query( + keywords=keywords, filters=filters + ) + if not query: + return None + self._log.debug(u"Searching Deezer for '{}'".format(query)) + response_data = requests.get( + self.search_url + query_type, params={'q': query} + ).json() + num_results = len(response_data['data']) + self._log.debug( + u"Found {} results from Deezer for '{}'", num_results, query + ) + return response_data if num_results > 0 else None diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 0af0dc9aa..5cf5b245a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -1,5 +1,21 @@ # -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2019, Rahul Ahuja. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +"""Adds Spotify release and track search support to the autotagger, along with +Spotify playlist construction. +""" from __future__ import division, absolute_import, print_function import re @@ -262,11 +278,11 @@ class SpotifyPlugin(BeetsPlugin): requests.get, self.album_url + track_data['album']['id'] ) medium_total = 0 - for i, track_data in enumerate(album_data['tracks']['items']): + for i, track_data in enumerate(album_data['tracks']['items'], start=1): if track_data['disc_number'] == track.medium: medium_total += 1 if track_data['id'] == track.track_id: - track.index = i + 1 + track.index = i track.medium_total = medium_total return track diff --git a/docs/changelog.rst b/docs/changelog.rst index 690696c1d..012d0a4b8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,6 +66,10 @@ New features: * The 'data_source' field is now also applied as an album-level flexible attribute during imports, allowing for more refined album level searches. :bug:`3350` :bug:`1693` +* :doc:`/plugins/deezer`: + * Added Deezer plugin as an import metadata provider: you can match tracks + and albums using the Deezer database. + Thanks to :user:`rhlahuja`. Fixes: From e8228d0305029347e5e148e6374864c3f3da2f9a Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 18:02:11 -0700 Subject: [PATCH 02/41] Add changelog hyperlink --- docs/changelog.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 012d0a4b8..7544db431 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -67,8 +67,8 @@ New features: attribute during imports, allowing for more refined album level searches. :bug:`3350` :bug:`1693` * :doc:`/plugins/deezer`: - * Added Deezer plugin as an import metadata provider: you can match tracks - and albums using the Deezer database. + * Added Deezer plugin as an import metadata provider: you can now match tracks + and albums using the `Deezer`_ database. Thanks to :user:`rhlahuja`. Fixes: @@ -147,6 +147,7 @@ For packagers: .. _MediaFile: https://github.com/beetbox/mediafile .. _Confuse: https://github.com/beetbox/confuse .. _works: https://musicbrainz.org/doc/Work +.. _Deezer: https://www.deezer.com 1.4.9 (May 30, 2019) From 804397bb124583c9003b6c4be0fb1d02359c1e94 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 18:13:19 -0700 Subject: [PATCH 03/41] Appease flake8 --- beetsplug/deezer.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index b06777f99..fb548756f 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -55,8 +55,8 @@ class DeezerPlugin(BeetsPlugin): def _get_deezer_id(self, url_type, id_): """Parse a Deezer ID from its URL if necessary. - :param url_type: Type of Deezer URL. Either 'album', 'artist', 'playlist', - or 'track'. + :param url_type: Type of Deezer URL. Either 'album', 'artist', + 'playlist', or 'track'. :type url_type: str :param id_: Deezer ID or URL. :type id_: str @@ -199,9 +199,9 @@ class DeezerPlugin(BeetsPlugin): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of Deezer artist object dicts. - :param artists: Iterable of ``contributors`` or ``artist`` returned by the - Deezer Album (https://developers.deezer.com/api/album) or Deezer Track - (https://developers.deezer.com/api/track) APIs. + :param artists: Iterable of ``contributors`` or ``artist`` returned + by the Deezer Album (https://developers.deezer.com/api/album) or + Deezer Track (https://developers.deezer.com/api/track) APIs. :type artists: list[dict] :return: Normalized artist string :rtype: str @@ -291,7 +291,8 @@ class DeezerPlugin(BeetsPlugin): @staticmethod def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to - be provided to the Deezer Search API (https://developers.deezer.com/api/search). + be provided to the Deezer Search API + (https://developers.deezer.com/api/search). :param filters: (Optional) Field filters to apply. :type filters: dict @@ -313,9 +314,9 @@ class DeezerPlugin(BeetsPlugin): """Query the Deezer Search API for the specified ``keywords``, applying the provided ``filters``. - :param query_type: The Deezer Search API method to use. Valid types are: - 'album', 'artist', 'history', 'playlist', 'podcast', 'radio', 'track', - 'user', and 'track'. + :param query_type: The Deezer Search API method to use. Valid types + are: 'album', 'artist', 'history', 'playlist', 'podcast', + 'radio', 'track', 'user', and 'track'. :type query_type: str :param filters: (Optional) Field filters to apply. :type filters: dict From 2cf55ee893b2df1d81846775f9af956d2d1365f1 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 18:33:27 -0700 Subject: [PATCH 04/41] Add deezer.rst doc, remove unused options --- beetsplug/deezer.py | 32 +++++++------------------------- docs/plugins/deezer.rst | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 docs/plugins/deezer.rst diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index fb548756f..9447e9c3c 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -38,19 +38,7 @@ class DeezerPlugin(BeetsPlugin): def __init__(self): super(DeezerPlugin, self).__init__() - self.config.add( - { - 'mode': 'list', - 'tiebreak': 'popularity', - 'show_failures': False, - 'artist_field': 'albumartist', - 'album_field': 'album', - 'track_field': 'title', - 'region_filter': None, - 'regex': [], - 'source_weight': 0.5, - } - ) + self.config.add({'source_weight': 0.5}) def _get_deezer_id(self, url_type, id_): """Parse a Deezer ID from its URL if necessary. @@ -103,9 +91,9 @@ class DeezerPlugin(BeetsPlugin): u"by Deezer API: '{}'".format(release_date) ) - tracks_data = requests.get( - self.album_url + deezer_id + '/tracks' - ).json()['data'] + tracks_data = requests.get(self.album_url + deezer_id + '/tracks').json()[ + 'data' + ] tracks = [] medium_totals = collections.defaultdict(int) for i, track_data in enumerate(tracks_data): @@ -255,9 +243,7 @@ class DeezerPlugin(BeetsPlugin): query_filters = {'album': album} if not va_likely: query_filters['artist'] = artist - response_data = self._search_deezer( - query_type='album', filters=query_filters - ) + response_data = self._search_deezer(query_type='album', filters=query_filters) if response_data is None: return [] return [ @@ -326,9 +312,7 @@ class DeezerPlugin(BeetsPlugin): if no search results are returned. :rtype: dict or None """ - query = self._construct_search_query( - keywords=keywords, filters=filters - ) + query = self._construct_search_query(keywords=keywords, filters=filters) if not query: return None self._log.debug(u"Searching Deezer for '{}'".format(query)) @@ -336,7 +320,5 @@ class DeezerPlugin(BeetsPlugin): self.search_url + query_type, params={'q': query} ).json() num_results = len(response_data['data']) - self._log.debug( - u"Found {} results from Deezer for '{}'", num_results, query - ) + self._log.debug(u"Found {} results from Deezer for '{}'", num_results, query) return response_data if num_results > 0 else None diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst new file mode 100644 index 000000000..94347c99e --- /dev/null +++ b/docs/plugins/deezer.rst @@ -0,0 +1,38 @@ +Spotify Plugin +============== + +The ``deezer`` plugin provides metadata matches for the importer using the +`Deezer_` `Album`_ and `Track`_ APIs. + +.. _Deezer: https://www.deezer.com +.. _Album: https://developers.deezer.com/api/album +.. _Track: https://developers.deezer.com/api/track + +Why Use This Plugin? +-------------------- + +* You're a Beets user. +* You want to autotag music with metadata from the Deezer API. + +Basic Usage +----------- +First, enable the ``deezer`` plugin (see :ref:`using-plugins`). + +You can enter the URL for an album or song on Deezer at the ``enter Id`` +prompt during import:: + + Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i + Enter release ID: https://www.deezer.com/en/album/572261 + +Configuration +------------- +Put these options in config.yaml under the ``deezer:`` section: + +- **source_weight**: Penalty applied to Spotify matches during import. Set to + 0.0 to disable. + Default: ``0.5``. + +Here's an example:: + + deezer: + source_weight: 0.7 From 790ca805d597f9eeda0ba6ef1e9effcc3dfeefbb Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 18:34:06 -0700 Subject: [PATCH 05/41] Formatting --- beetsplug/deezer.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 9447e9c3c..4fad84b9f 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -91,9 +91,9 @@ class DeezerPlugin(BeetsPlugin): u"by Deezer API: '{}'".format(release_date) ) - tracks_data = requests.get(self.album_url + deezer_id + '/tracks').json()[ - 'data' - ] + tracks_data = requests.get( + self.album_url + deezer_id + '/tracks' + ).json()['data'] tracks = [] medium_totals = collections.defaultdict(int) for i, track_data in enumerate(tracks_data): @@ -243,7 +243,9 @@ class DeezerPlugin(BeetsPlugin): query_filters = {'album': album} if not va_likely: query_filters['artist'] = artist - response_data = self._search_deezer(query_type='album', filters=query_filters) + response_data = self._search_deezer( + query_type='album', filters=query_filters + ) if response_data is None: return [] return [ @@ -312,7 +314,9 @@ class DeezerPlugin(BeetsPlugin): if no search results are returned. :rtype: dict or None """ - query = self._construct_search_query(keywords=keywords, filters=filters) + query = self._construct_search_query( + keywords=keywords, filters=filters + ) if not query: return None self._log.debug(u"Searching Deezer for '{}'".format(query)) @@ -320,5 +324,7 @@ class DeezerPlugin(BeetsPlugin): self.search_url + query_type, params={'q': query} ).json() num_results = len(response_data['data']) - self._log.debug(u"Found {} results from Deezer for '{}'", num_results, query) + self._log.debug( + u"Found {} results from Deezer for '{}'", num_results, query + ) return response_data if num_results > 0 else None From ca33f190a5a501bb30e61bde28690af378356dbe Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 18:58:03 -0700 Subject: [PATCH 06/41] Add deezer, spotify docs to autotagger index --- docs/plugins/deezer.rst | 2 +- docs/plugins/index.rst | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 94347c99e..8bddd766f 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -1,4 +1,4 @@ -Spotify Plugin +Deezer Plugin ============== The ``deezer`` plugin provides metadata matches for the importer using the diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 4f3e6fbff..16e564f84 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -105,10 +105,14 @@ Autotagger Extensions * :doc:`chroma`: Use acoustic fingerprinting to identify audio files with missing or incorrect metadata. * :doc:`discogs`: Search for releases in the `Discogs`_ database. +* :doc:`spotify`: Search for releases in the `Spotify`_ database. +* :doc:`deezer`: Search for releases in the `Deezer`_ database. * :doc:`fromfilename`: Guess metadata for untagged tracks from their filenames. .. _Discogs: https://www.discogs.com/ +.. _Spotify: https://www.spotify.com +.. _Deezer: https://www.deezer.com/ Metadata -------- From 8c84daf77af93db75006dc89b3bce253040b0f3d Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 19:12:21 -0700 Subject: [PATCH 07/41] Fix doc links --- docs/plugins/deezer.rst | 2 +- docs/plugins/index.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 8bddd766f..c00d1d68a 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -2,7 +2,7 @@ Deezer Plugin ============== The ``deezer`` plugin provides metadata matches for the importer using the -`Deezer_` `Album`_ and `Track`_ APIs. +`Deezer`_ `Album`_ and `Track`_ APIs. .. _Deezer: https://www.deezer.com .. _Album: https://developers.deezer.com/api/album diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 16e564f84..3f14b3116 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -104,9 +104,9 @@ Autotagger Extensions * :doc:`chroma`: Use acoustic fingerprinting to identify audio files with missing or incorrect metadata. -* :doc:`discogs`: Search for releases in the `Discogs`_ database. -* :doc:`spotify`: Search for releases in the `Spotify`_ database. -* :doc:`deezer`: Search for releases in the `Deezer`_ database. +* :doc:`/plugins/discogs`: Search for releases in the `Discogs`_ database. +* :doc:`/plugins/spotify`: Search for releases in the `Spotify`_ database. +* :doc:`/plugins/deezer`: Search for releases in the `Deezer`_ database. * :doc:`fromfilename`: Guess metadata for untagged tracks from their filenames. From 2177c7695a149ecd5ae7916301300b323a88b9a3 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 19:44:27 -0700 Subject: [PATCH 08/41] Stringify Deezer ID --- beetsplug/deezer.py | 2 +- docs/plugins/index.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 4fad84b9f..164d3efd3 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -54,7 +54,7 @@ class DeezerPlugin(BeetsPlugin): id_regex = r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)' self._log.debug(u'Searching for {} {}', url_type, id_) match = re.search(id_regex.format(url_type), str(id_)) - return match.group(3) if match else None + return str(match.group(3)) if match else None def album_for_id(self, album_id): """Fetch an album by its Deezer ID or URL and return an diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 3f14b3116..16e564f84 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -104,9 +104,9 @@ Autotagger Extensions * :doc:`chroma`: Use acoustic fingerprinting to identify audio files with missing or incorrect metadata. -* :doc:`/plugins/discogs`: Search for releases in the `Discogs`_ database. -* :doc:`/plugins/spotify`: Search for releases in the `Spotify`_ database. -* :doc:`/plugins/deezer`: Search for releases in the `Deezer`_ database. +* :doc:`discogs`: Search for releases in the `Discogs`_ database. +* :doc:`spotify`: Search for releases in the `Spotify`_ database. +* :doc:`deezer`: Search for releases in the `Deezer`_ database. * :doc:`fromfilename`: Guess metadata for untagged tracks from their filenames. From 43f09296c9055f16a64bfccb50b35b0f03129aac Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 19:50:55 -0700 Subject: [PATCH 09/41] Fix AlbumInfo.album_credit assignment --- beetsplug/deezer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 164d3efd3..e2cad928e 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -54,7 +54,7 @@ class DeezerPlugin(BeetsPlugin): id_regex = r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)' self._log.debug(u'Searching for {} {}', url_type, id_) match = re.search(id_regex.format(url_type), str(id_)) - return str(match.group(3)) if match else None + return match.group(3) if match else None def album_for_id(self, album_id): """Fetch an album by its Deezer ID or URL and return an @@ -108,7 +108,7 @@ class DeezerPlugin(BeetsPlugin): album=album_data['title'], album_id=deezer_id, artist=artist, - artist_credit=self._get_artist([album_data['artist']]), + artist_credit=self._get_artist([album_data['artist']])[0], artist_id=artist_id, tracks=tracks, albumtype=album_data['record_type'], From 240097e377f5fb2c112d4baca7516c7c6db01641 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 19:55:26 -0700 Subject: [PATCH 10/41] Include `deezer` in toctree --- docs/plugins/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 16e564f84..b9f512b1f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -47,6 +47,7 @@ like this:: bucket chroma convert + deezer discogs duplicates edit From cd1aa3e8aae83596ea9be70d242bda48873e90f8 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 20:10:34 -0700 Subject: [PATCH 11/41] Avoid empty deezer_id string --- beetsplug/deezer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index e2cad928e..23b52ba07 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -66,7 +66,7 @@ class DeezerPlugin(BeetsPlugin): :rtype: beets.autotag.hooks.AlbumInfo or None """ deezer_id = self._get_deezer_id('album', album_id) - if deezer_id is None: + if not deezer_id: return None album_data = requests.get(self.album_url + deezer_id).json() From 70264ee6ee9b44ea05b7c9e1fbef8fce873070b1 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 21:18:08 -0700 Subject: [PATCH 12/41] Handle empty deezer_id upfront --- beetsplug/deezer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 23b52ba07..12a861465 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -54,7 +54,11 @@ class DeezerPlugin(BeetsPlugin): id_regex = r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)' self._log.debug(u'Searching for {} {}', url_type, id_) match = re.search(id_regex.format(url_type), str(id_)) - return match.group(3) if match else None + if match: + deezer_id = match.group(3) + if deezer_id: + return deezer_id + return None def album_for_id(self, album_id): """Fetch an album by its Deezer ID or URL and return an @@ -66,7 +70,7 @@ class DeezerPlugin(BeetsPlugin): :rtype: beets.autotag.hooks.AlbumInfo or None """ deezer_id = self._get_deezer_id('album', album_id) - if not deezer_id: + if deezer_id is None: return None album_data = requests.get(self.album_url + deezer_id).json() From 2ab883a20e7998d9344fccd2f27a4207d33b7828 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 21:23:16 -0700 Subject: [PATCH 13/41] Fix track.index assignment --- beetsplug/deezer.py | 2 +- beetsplug/spotify.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 12a861465..d5aa8defd 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -102,7 +102,7 @@ class DeezerPlugin(BeetsPlugin): medium_totals = collections.defaultdict(int) for i, track_data in enumerate(tracks_data): track = self._get_track(track_data) - track.index = i + 1 + track.index = i medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 5cf5b245a..3eddb2490 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -202,7 +202,7 @@ class SpotifyPlugin(BeetsPlugin): medium_totals = collections.defaultdict(int) for i, track_data in enumerate(response_data['tracks']['items']): track = self._get_track(track_data) - track.index = i + 1 + track.index = i medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: From 9babce582da764539e9fbf80099f7ffa28096d61 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 1 Sep 2019 21:24:56 -0700 Subject: [PATCH 14/41] Fix track data enumeration idx --- beetsplug/deezer.py | 2 +- beetsplug/spotify.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index d5aa8defd..bd098eb6a 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -100,7 +100,7 @@ class DeezerPlugin(BeetsPlugin): ).json()['data'] tracks = [] medium_totals = collections.defaultdict(int) - for i, track_data in enumerate(tracks_data): + for i, track_data in enumerate(tracks_data, start=1): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 3eddb2490..06bca1190 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -200,7 +200,9 @@ class SpotifyPlugin(BeetsPlugin): tracks = [] medium_totals = collections.defaultdict(int) - for i, track_data in enumerate(response_data['tracks']['items']): + for i, track_data in enumerate( + response_data['tracks']['items'], start=1 + ): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 From 4a552595df562e454eab41517cdf04fbeefb42aa Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 2 Sep 2019 14:27:51 -0700 Subject: [PATCH 15/41] Simplify regex match --- beetsplug/deezer.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index bd098eb6a..654fb59dd 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -54,11 +54,8 @@ class DeezerPlugin(BeetsPlugin): id_regex = r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)' self._log.debug(u'Searching for {} {}', url_type, id_) match = re.search(id_regex.format(url_type), str(id_)) - if match: - deezer_id = match.group(3) - if deezer_id: - return deezer_id - return None + deezer_id = match.group(3) + return deezer_id if deezer_id else None def album_for_id(self, album_id): """Fetch an album by its Deezer ID or URL and return an From bd0cea9f1bfcc2b95206e05ea40423676f37ee6e Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 19:50:04 -0700 Subject: [PATCH 16/41] Factor out APIAutotaggerPlugin --- beets/autotag/__init__.py | 192 +++++++++++++++++++++++++++++++++-- beetsplug/deezer.py | 164 ++++++------------------------ beetsplug/discogs.py | 36 ++----- beetsplug/spotify.py | 205 ++++++++++---------------------------- 4 files changed, 275 insertions(+), 322 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 07d1feffa..0d538a72f 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -18,11 +18,21 @@ from __future__ import division, absolute_import, print_function +import re +from abc import abstractmethod, abstractproperty + from beets import logging from beets import config +from beets.plugins import BeetsPlugin # Parts of external interface. -from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa +from .hooks import ( + AlbumInfo, + TrackInfo, + AlbumMatch, + TrackMatch, + Distance, +) # noqa from .match import tag_item, tag_album, Proposal # noqa from .match import Recommendation # noqa @@ -32,6 +42,7 @@ log = logging.getLogger('beets') # Additional utilities for the main interface. + def apply_item_metadata(item, track_info): """Set an item's metadata from its matched TrackInfo object. """ @@ -72,14 +83,15 @@ def apply_metadata(album_info, mapping): for item, track_info in mapping.items(): # Artist or artist credit. if config['artist_credit']: - item.artist = (track_info.artist_credit or - track_info.artist or - album_info.artist_credit or - album_info.artist) - item.albumartist = (album_info.artist_credit or - album_info.artist) + item.artist = ( + track_info.artist_credit + or track_info.artist + or album_info.artist_credit + or album_info.artist + ) + item.albumartist = album_info.artist_credit or album_info.artist else: - item.artist = (track_info.artist or album_info.artist) + item.artist = track_info.artist or album_info.artist item.albumartist = album_info.artist # Album. @@ -87,8 +99,9 @@ def apply_metadata(album_info, mapping): # Artist sort and credit names. item.artist_sort = track_info.artist_sort or album_info.artist_sort - item.artist_credit = (track_info.artist_credit or - album_info.artist_credit) + item.artist_credit = ( + track_info.artist_credit or album_info.artist_credit + ) item.albumartist_sort = album_info.artist_sort item.albumartist_credit = album_info.artist_credit @@ -179,7 +192,7 @@ def apply_metadata(album_info, mapping): 'work', 'mb_workid', 'work_disambig', - ) + ), } # Don't overwrite fields with empty values unless the @@ -197,3 +210,160 @@ def apply_metadata(album_info, mapping): if value is None and not clobber: continue item[field] = value + + +def album_distance(config, data_source, album_info): + """Returns the ``data_source`` weight and the maximum source weight + for albums. + """ + dist = Distance() + if album_info.data_source == data_source: + dist.add('source', config['source_weight'].as_number()) + return dist + + +def track_distance(config, data_source, track_info): + """Returns the ``data_source`` weight and the maximum source weight + for individual tracks. + """ + dist = Distance() + if track_info.data_source == data_source: + dist.add('source', config['source_weight'].as_number()) + return dist + + +class APIAutotaggerPlugin(BeetsPlugin): + def __init__(self): + super(APIAutotaggerPlugin, self).__init__() + self.config.add({'source_weight': 0.5}) + + @abstractproperty + def id_regex(self): + raise NotImplementedError + + @abstractproperty + def data_source(self): + raise NotImplementedError + + @abstractproperty + def search_url(self): + raise NotImplementedError + + @abstractproperty + def album_url(self): + raise NotImplementedError + + @abstractproperty + def track_url(self): + raise NotImplementedError + + @abstractmethod + def _search_api(self, query_type, filters, keywords=''): + raise NotImplementedError + + @abstractmethod + def album_for_id(self, album_id): + raise NotImplementedError + + @abstractmethod + def track_for_id(self, track_id=None, track_data=None): + raise NotImplementedError + + @staticmethod + def get_artist(artists, id_key='id', name_key='name'): + """Returns an artist string (all artists) and an artist_id (the main + artist) for a list of artist object dicts. + + :param artists: Iterable of artist dicts returned by API. + :type artists: list[dict] + :param id_key: Key corresponding to ``artist_id`` value. + :type id_key: str + :param name_key: Keys corresponding to values to concatenate for ``artist``. + :type name_key: str + :return: Normalized artist string. + :rtype: str + """ + artist_id = None + artist_names = [] + for artist in artists: + if not artist_id: + artist_id = artist[id_key] + name = artist[name_key] + # Move articles to the front. + name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) + artist_names.append(name) + artist = ', '.join(artist_names).replace(' ,', ',') or None + return artist, artist_id + + def _get_id(self, url_type, id_): + """Parse an ID from its URL if necessary. + + :param url_type: Type of URL. Either 'album' or 'track'. + :type url_type: str + :param id_: Album/track ID or URL. + :type id_: str + :return: Album/track ID. + :rtype: str + """ + self._log.debug( + u"Searching {} for {} '{}'", self.data_source, url_type, id_ + ) + match = re.search( + self.id_regex['pattern'].format(url_type=url_type), str(id_) + ) + id_ = match.group(self.id_regex['match_group']) + return id_ if id_ else None + + def candidates(self, items, artist, album, va_likely): + """Returns a list of AlbumInfo objects for Search API results + matching an ``album`` and ``artist`` (if not various). + + :param items: List of items comprised by an album to be matched. + :type items: list[beets.library.Item] + :param artist: The artist of the album to be matched. + :type artist: str + :param album: The name of the album to be matched. + :type album: str + :param va_likely: True if the album to be matched likely has + Various Artists. + :type va_likely: bool + :return: Candidate AlbumInfo objects. + :rtype: list[beets.autotag.hooks.AlbumInfo] + """ + query_filters = {'album': album} + if not va_likely: + query_filters['artist'] = artist + albums = self._search_api(query_type='album', filters=query_filters) + return [self.album_for_id(album_id=album['id']) for album in albums] + + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for Search API results + matching ``title`` and ``artist``. + + :param item: Singleton item to be matched. + :type item: beets.library.Item + :param artist: The artist of the track to be matched. + :type artist: str + :param title: The title of the track to be matched. + :type title: str + :return: Candidate TrackInfo objects. + :rtype: list[beets.autotag.hooks.TrackInfo] + """ + tracks = self._search_api( + query_type='track', keywords=title, filters={'artist': artist} + ) + return [self.track_for_id(track_data=track) for track in tracks] + + def album_distance(self, items, album_info, mapping): + return album_distance( + data_source=self.data_source, + album_info=album_info, + config=self.config, + ) + + def track_distance(self, item, track_info): + return track_distance( + data_source=self.data_source, + track_info=track_info, + config=self.config, + ) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 654fb59dd..215ea3bf1 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -17,7 +17,6 @@ """ from __future__ import absolute_import, print_function -import re import collections import six @@ -25,37 +24,24 @@ import unidecode import requests from beets import ui -from beets.plugins import BeetsPlugin -from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance +from beets.autotag import APIAutotaggerPlugin +from beets.autotag.hooks import AlbumInfo, TrackInfo -class DeezerPlugin(BeetsPlugin): +class DeezerPlugin(APIAutotaggerPlugin): # Base URLs for the Deezer API # Documentation: https://developers.deezer.com/api/ search_url = 'https://api.deezer.com/search/' album_url = 'https://api.deezer.com/album/' track_url = 'https://api.deezer.com/track/' + data_source = 'Deezer' + id_regex = { + 'pattern': r'(^|deezer\.com/([a-z]*/)?{url_type}/)([0-9]*)', + 'match_group': 3, + } def __init__(self): super(DeezerPlugin, self).__init__() - self.config.add({'source_weight': 0.5}) - - def _get_deezer_id(self, url_type, id_): - """Parse a Deezer ID from its URL if necessary. - - :param url_type: Type of Deezer URL. Either 'album', 'artist', - 'playlist', or 'track'. - :type url_type: str - :param id_: Deezer ID or URL. - :type id_: str - :return: Deezer ID. - :rtype: str - """ - id_regex = r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)' - self._log.debug(u'Searching for {} {}', url_type, id_) - match = re.search(id_regex.format(url_type), str(id_)) - deezer_id = match.group(3) - return deezer_id if deezer_id else None def album_for_id(self, album_id): """Fetch an album by its Deezer ID or URL and return an @@ -66,12 +52,12 @@ class DeezerPlugin(BeetsPlugin): :return: AlbumInfo object for album. :rtype: beets.autotag.hooks.AlbumInfo or None """ - deezer_id = self._get_deezer_id('album', album_id) + deezer_id = self._get_id('album', album_id) if deezer_id is None: return None album_data = requests.get(self.album_url + deezer_id).json() - artist, artist_id = self._get_artist(album_data['contributors']) + artist, artist_id = self.get_artist(album_data['contributors']) release_date = album_data['release_date'] date_parts = [int(part) for part in release_date.split('-')] @@ -89,7 +75,7 @@ class DeezerPlugin(BeetsPlugin): else: raise ui.UserError( u"Invalid `release_date` returned " - u"by Deezer API: '{}'".format(release_date) + u"by {} API: '{}'".format(self.data_source, release_date) ) tracks_data = requests.get( @@ -109,7 +95,7 @@ class DeezerPlugin(BeetsPlugin): album=album_data['title'], album_id=deezer_id, artist=artist, - artist_credit=self._get_artist([album_data['artist']])[0], + artist_credit=self.get_artist([album_data['artist']])[0], artist_id=artist_id, tracks=tracks, albumtype=album_data['record_type'], @@ -120,7 +106,7 @@ class DeezerPlugin(BeetsPlugin): day=day, label=album_data['label'], mediums=max(medium_totals.keys()), - data_source='Deezer', + data_source=self.data_source, data_url=album_data['link'], ) @@ -132,7 +118,7 @@ class DeezerPlugin(BeetsPlugin): :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ - artist, artist_id = self._get_artist( + artist, artist_id = self.get_artist( track_data.get('contributors', [track_data['artist']]) ) return TrackInfo( @@ -144,7 +130,7 @@ class DeezerPlugin(BeetsPlugin): index=track_data['track_position'], medium=track_data['disk_number'], medium_index=track_data['track_position'], - data_source='Deezer', + data_source=self.data_source, data_url=track_data['link'], ) @@ -162,7 +148,7 @@ class DeezerPlugin(BeetsPlugin): :rtype: beets.autotag.hooks.TrackInfo or None """ if track_data is None: - deezer_id = self._get_deezer_id('track', track_id) + deezer_id = self._get_id('track', track_id) if deezer_id is None: return None track_data = requests.get(self.track_url + deezer_id).json() @@ -176,107 +162,13 @@ class DeezerPlugin(BeetsPlugin): ).json()['data'] medium_total = 0 for i, track_data in enumerate(album_tracks_data, start=1): - if track_data['disc_number'] == track.medium: + if track_data['disk_number'] == track.medium: medium_total += 1 if track_data['id'] == track.track_id: track.index = i track.medium_total = medium_total return track - @staticmethod - def _get_artist(artists): - """Returns an artist string (all artists) and an artist_id (the main - artist) for a list of Deezer artist object dicts. - - :param artists: Iterable of ``contributors`` or ``artist`` returned - by the Deezer Album (https://developers.deezer.com/api/album) or - Deezer Track (https://developers.deezer.com/api/track) APIs. - :type artists: list[dict] - :return: Normalized artist string - :rtype: str - """ - artist_id = None - artist_names = [] - for artist in artists: - if not artist_id: - artist_id = artist['id'] - name = artist['name'] - # Move articles to the front. - name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) - artist_names.append(name) - artist = ', '.join(artist_names).replace(' ,', ',') or None - return artist, artist_id - - def album_distance(self, items, album_info, mapping): - """Returns the Deezer source weight and the maximum source weight - for albums. - """ - dist = Distance() - if album_info.data_source == 'Deezer': - dist.add('source', self.config['source_weight'].as_number()) - return dist - - def track_distance(self, item, track_info): - """Returns the Deezer source weight and the maximum source weight - for individual tracks. - """ - dist = Distance() - if track_info.data_source == 'Deezer': - dist.add('source', self.config['source_weight'].as_number()) - return dist - - def candidates(self, items, artist, album, va_likely): - """Returns a list of AlbumInfo objects for Deezer Search API results - matching an ``album`` and ``artist`` (if not various). - - :param items: List of items comprised by an album to be matched. - :type items: list[beets.library.Item] - :param artist: The artist of the album to be matched. - :type artist: str - :param album: The name of the album to be matched. - :type album: str - :param va_likely: True if the album to be matched likely has - Various Artists. - :type va_likely: bool - :return: Candidate AlbumInfo objects. - :rtype: list[beets.autotag.hooks.AlbumInfo] - """ - query_filters = {'album': album} - if not va_likely: - query_filters['artist'] = artist - response_data = self._search_deezer( - query_type='album', filters=query_filters - ) - if response_data is None: - return [] - return [ - self.album_for_id(album_id=album_data['id']) - for album_data in response_data['data'] - ] - - def item_candidates(self, item, artist, title): - """Returns a list of TrackInfo objects for Deezer Search API results - matching ``title`` and ``artist``. - - :param item: Singleton item to be matched. - :type item: beets.library.Item - :param artist: The artist of the track to be matched. - :type artist: str - :param title: The title of the track to be matched. - :type title: str - :return: Candidate TrackInfo objects. - :rtype: list[beets.autotag.hooks.TrackInfo] - """ - response_data = self._search_deezer( - query_type='track', keywords=title, filters={'artist': artist} - ) - if response_data is None: - return [] - return [ - self.track_for_id(track_data=track_data) - for track_data in response_data['data'] - ] - @staticmethod def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to @@ -299,7 +191,7 @@ class DeezerPlugin(BeetsPlugin): query = query.decode('utf8') return unidecode.unidecode(query) - def _search_deezer(self, query_type, filters=None, keywords=''): + def _search_api(self, query_type, filters=None, keywords=''): """Query the Deezer Search API for the specified ``keywords``, applying the provided ``filters``. @@ -320,12 +212,18 @@ class DeezerPlugin(BeetsPlugin): ) if not query: return None - self._log.debug(u"Searching Deezer for '{}'".format(query)) - response_data = requests.get( - self.search_url + query_type, params={'q': query} - ).json() - num_results = len(response_data['data']) self._log.debug( - u"Found {} results from Deezer for '{}'", num_results, query + u"Searching {} for '{}'".format(self.data_source, query) ) - return response_data if num_results > 0 else None + response = requests.get( + self.search_url + query_type, params={'q': query} + ) + response.raise_for_status() + response_data = response.json().get('data', []) + self._log.debug( + u"Found {} results from {} for '{}'", + self.data_source, + len(response_data), + query, + ) + return response_data diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 4996c5d7c..0c9d1b397 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,7 +20,8 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance +from beets.autotag import APIAutotaggerPlugin, album_distance +from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.plugins import BeetsPlugin import confuse from discogs_client import Release, Master, Client @@ -159,10 +160,11 @@ class DiscogsPlugin(BeetsPlugin): def album_distance(self, items, album_info, mapping): """Returns the album distance. """ - dist = Distance() - if album_info.data_source == 'Discogs': - dist.add('source', self.config['source_weight'].as_number()) - return dist + return album_distance( + data_source='Discogs', + album_info=album_info, + config=self.config + ) def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for discogs search results @@ -292,7 +294,7 @@ class DiscogsPlugin(BeetsPlugin): self._log.warning(u"Release does not contain the required fields") return None - artist, artist_id = self.get_artist([a.data for a in result.artists]) + artist, artist_id = APIAutotaggerPlugin.get_artist([a.data for a in result.artists]) album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] # Use `.data` to access the tracklist directly instead of the @@ -368,26 +370,6 @@ class DiscogsPlugin(BeetsPlugin): else: return None - def get_artist(self, artists): - """Returns an artist string (all artists) and an artist_id (the main - artist) for a list of discogs album or track artists. - """ - artist_id = None - bits = [] - for i, artist in enumerate(artists): - if not artist_id: - artist_id = artist['id'] - name = artist['name'] - # Strip disambiguation number. - name = re.sub(r' \(\d+\)$', '', name) - # Move articles to the front. - name = re.sub(r'(?i)^(.*?), (a|an|the)$', r'\2 \1', name) - bits.append(name) - if artist['join'] and i < len(artists) - 1: - bits.append(artist['join']) - artist = ' '.join(bits).replace(' ,', ',') or None - return artist, artist_id - def get_tracks(self, tracklist): """Returns a list of TrackInfo objects for a discogs tracklist. """ @@ -551,7 +533,7 @@ class DiscogsPlugin(BeetsPlugin): title = track['title'] track_id = None medium, medium_index, _ = self.get_track_index(track['position']) - artist, artist_id = self.get_artist(track.get('artists', [])) + artist, artist_id = APIAutotaggerPlugin.get_artist(track.get('artists', [])) length = self.get_track_length(track['duration']) return TrackInfo(title, track_id, artist=artist, artist_id=artist_id, length=length, index=index, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 06bca1190..47d10c62a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -27,14 +27,14 @@ import collections import six import unidecode import requests +import confuse from beets import ui -from beets.plugins import BeetsPlugin -import confuse -from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance +from beets.autotag import APIAutotaggerPlugin +from beets.autotag.hooks import AlbumInfo, TrackInfo -class SpotifyPlugin(BeetsPlugin): +class SpotifyPlugin(APIAutotaggerPlugin): # Base URLs for the Spotify API # Documentation: https://developer.spotify.com/web-api oauth_token_url = 'https://accounts.spotify.com/api/token' @@ -43,6 +43,14 @@ class SpotifyPlugin(BeetsPlugin): album_url = 'https://api.spotify.com/v1/albums/' track_url = 'https://api.spotify.com/v1/tracks/' playlist_partial = 'spotify:trackset:Playlist:' + data_source = 'Spotify' + + # Spotify IDs consist of 22 alphanumeric characters + # (zero-left-padded base62 representation of randomly generated UUID4) + id_regex = { + 'pattern': r'(^|open\.spotify\.com/{url_type}/)([0-9A-Za-z]{{22}})', + 'match_group': 2, + } def __init__(self): super(SpotifyPlugin, self).__init__() @@ -59,7 +67,6 @@ class SpotifyPlugin(BeetsPlugin): 'client_id': '4e414367a1d14c75a5c5129a627fcab8', 'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc', 'tokenfile': 'spotify_token.json', - 'source_weight': 0.5, } ) self.config['client_secret'].redact = True @@ -140,26 +147,11 @@ class SpotifyPlugin(BeetsPlugin): self._authenticate() return self._handle_response(request_type, url, params=params) else: - raise ui.UserError(u'Spotify API error:\n{}', response.text) + raise ui.UserError( + u'{} API error:\n{}', self.data_source, response.text + ) return response.json() - def _get_spotify_id(self, url_type, id_): - """Parse a Spotify ID from its URL if necessary. - - :param url_type: Type of Spotify URL, either 'album' or 'track'. - :type url_type: str - :param id_: Spotify ID or URL. - :type id_: str - :return: Spotify ID. - :rtype: str - """ - # Spotify IDs consist of 22 alphanumeric characters - # (zero-left-padded base62 representation of randomly generated UUID4) - id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})' - self._log.debug(u'Searching for {} {}', url_type, id_) - match = re.search(id_regex.format(url_type), id_) - return match.group(2) if match else None - def album_for_id(self, album_id): """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. @@ -169,20 +161,20 @@ class SpotifyPlugin(BeetsPlugin): :return: AlbumInfo object for album :rtype: beets.autotag.hooks.AlbumInfo or None """ - spotify_id = self._get_spotify_id('album', album_id) + spotify_id = self._get_id('album', album_id) if spotify_id is None: return None - response_data = self._handle_response( + album_data = self._handle_response( requests.get, self.album_url + spotify_id ) - artist, artist_id = self._get_artist(response_data['artists']) + artist, artist_id = self.get_artist(album_data['artists']) date_parts = [ - int(part) for part in response_data['release_date'].split('-') + int(part) for part in album_data['release_date'].split('-') ] - release_date_precision = response_data['release_date_precision'] + release_date_precision = album_data['release_date_precision'] if release_date_precision == 'day': year, month, day = date_parts elif release_date_precision == 'month': @@ -195,14 +187,14 @@ class SpotifyPlugin(BeetsPlugin): else: raise ui.UserError( u"Invalid `release_date_precision` returned " - u"by Spotify API: '{}'".format(release_date_precision) + u"by {} API: '{}'".format( + self.data_source, release_date_precision + ) ) tracks = [] medium_totals = collections.defaultdict(int) - for i, track_data in enumerate( - response_data['tracks']['items'], start=1 - ): + for i, track_data in enumerate(album_data['tracks']['items'], start=1): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 @@ -211,21 +203,21 @@ class SpotifyPlugin(BeetsPlugin): track.medium_total = medium_totals[track.medium] return AlbumInfo( - album=response_data['name'], + album=album_data['name'], album_id=spotify_id, artist=artist, artist_id=artist_id, tracks=tracks, - albumtype=response_data['album_type'], - va=len(response_data['artists']) == 1 + albumtype=album_data['album_type'], + va=len(album_data['artists']) == 1 and artist.lower() == 'various artists', year=year, month=month, day=day, - label=response_data['label'], + label=album_data['label'], mediums=max(medium_totals.keys()), - data_source='Spotify', - data_url=response_data['external_urls']['spotify'], + data_source=self.data_source, + data_url=album_data['external_urls']['spotify'], ) def _get_track(self, track_data): @@ -237,7 +229,7 @@ class SpotifyPlugin(BeetsPlugin): :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ - artist, artist_id = self._get_artist(track_data['artists']) + artist, artist_id = self.get_artist(track_data['artists']) return TrackInfo( title=track_data['name'], track_id=track_data['id'], @@ -247,7 +239,7 @@ class SpotifyPlugin(BeetsPlugin): index=track_data['track_number'], medium=track_data['disc_number'], medium_index=track_data['track_number'], - data_source='Spotify', + data_source=self.data_source, data_url=track_data['external_urls']['spotify'], ) @@ -265,7 +257,7 @@ class SpotifyPlugin(BeetsPlugin): :rtype: beets.autotag.hooks.TrackInfo or None """ if track_data is None: - spotify_id = self._get_spotify_id('track', track_id) + spotify_id = self._get_id('track', track_id) if spotify_id is None: return None track_data = self._handle_response( @@ -288,99 +280,6 @@ class SpotifyPlugin(BeetsPlugin): track.medium_total = medium_total return track - @staticmethod - def _get_artist(artists): - """Returns an artist string (all artists) and an artist_id (the main - artist) for a list of Spotify artist object dicts. - - :param artists: Iterable of simplified Spotify artist objects - (https://developer.spotify.com/documentation/web-api/reference/object-model/#artist-object-simplified) - :type artists: list[dict] - :return: Normalized artist string - :rtype: str - """ - artist_id = None - artist_names = [] - for artist in artists: - if not artist_id: - artist_id = artist['id'] - name = artist['name'] - # Move articles to the front. - name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) - artist_names.append(name) - artist = ', '.join(artist_names).replace(' ,', ',') or None - return artist, artist_id - - def album_distance(self, items, album_info, mapping): - """Returns the Spotify source weight and the maximum source weight - for albums. - """ - dist = Distance() - if album_info.data_source == 'Spotify': - dist.add('source', self.config['source_weight'].as_number()) - return dist - - def track_distance(self, item, track_info): - """Returns the Spotify source weight and the maximum source weight - for individual tracks. - """ - dist = Distance() - if track_info.data_source == 'Spotify': - dist.add('source', self.config['source_weight'].as_number()) - return dist - - def candidates(self, items, artist, album, va_likely): - """Returns a list of AlbumInfo objects for Spotify Search API results - matching an ``album`` and ``artist`` (if not various). - - :param items: List of items comprised by an album to be matched. - :type items: list[beets.library.Item] - :param artist: The artist of the album to be matched. - :type artist: str - :param album: The name of the album to be matched. - :type album: str - :param va_likely: True if the album to be matched likely has - Various Artists. - :type va_likely: bool - :return: Candidate AlbumInfo objects. - :rtype: list[beets.autotag.hooks.AlbumInfo] - """ - query_filters = {'album': album} - if not va_likely: - query_filters['artist'] = artist - response_data = self._search_spotify( - query_type='album', filters=query_filters - ) - if response_data is None: - return [] - return [ - self.album_for_id(album_id=album_data['id']) - for album_data in response_data['albums']['items'] - ] - - def item_candidates(self, item, artist, title): - """Returns a list of TrackInfo objects for Spotify Search API results - matching ``title`` and ``artist``. - - :param item: Singleton item to be matched. - :type item: beets.library.Item - :param artist: The artist of the track to be matched. - :type artist: str - :param title: The title of the track to be matched. - :type title: str - :return: Candidate TrackInfo objects. - :rtype: list[beets.autotag.hooks.TrackInfo] - """ - response_data = self._search_spotify( - query_type='track', keywords=title, filters={'artist': artist} - ) - if response_data is None: - return [] - return [ - self.track_for_id(track_data=track_data) - for track_data in response_data['tracks']['items'] - ] - @staticmethod def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to @@ -403,14 +302,12 @@ class SpotifyPlugin(BeetsPlugin): query = query.decode('utf8') return unidecode.unidecode(query) - def _search_spotify(self, query_type, filters=None, keywords=''): + def _search_api(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying the provided ``filters``. - :param query_type: A comma-separated list of item types to search - across. Valid types are: 'album', 'artist', 'playlist', and - 'track'. Search results include hits from all the specified item - types. + :param query_type: Item type to search across. Valid types are: 'album', + 'artist', 'playlist', and 'track'. :type query_type: str :param filters: (Optional) Field filters to apply. :type filters: dict @@ -425,19 +322,25 @@ class SpotifyPlugin(BeetsPlugin): ) if not query: return None - self._log.debug(u"Searching Spotify for '{}'".format(query)) - response_data = self._handle_response( - requests.get, - self.search_url, - params={'q': query, 'type': query_type}, - ) - num_results = 0 - for result_type_data in response_data.values(): - num_results += len(result_type_data['items']) self._log.debug( - u"Found {} results from Spotify for '{}'", num_results, query + u"Searching {} for '{}'".format(self.data_source, query) ) - return response_data if num_results > 0 else None + response_data = ( + self._handle_response( + requests.get, + self.search_url, + params={'q': query, 'type': query_type}, + ) + .get(query_type + 's', {}) + .get('items', []) + ) + self._log.debug( + u"Found {} results from {} for '{}'", + self.data_source, + len(response_data), + query, + ) + return response_data def commands(self): def queries(lib, opts, args): @@ -529,7 +432,7 @@ class SpotifyPlugin(BeetsPlugin): # Query the Web API for each track, look for the items' JSON data query_filters = {'artist': artist, 'album': album} - response_data = self._search_spotify( + response_data = self._search_api( query_type='track', keywords=keywords, filters=query_filters ) if response_data is None: From f64bd65ddb7aab6aa4073a1e4ae46853336696ff Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 20:11:30 -0700 Subject: [PATCH 17/41] Remove unnecessary indexing --- beetsplug/spotify.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 47d10c62a..34ec4b7ee 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -432,16 +432,15 @@ class SpotifyPlugin(APIAutotaggerPlugin): # Query the Web API for each track, look for the items' JSON data query_filters = {'artist': artist, 'album': album} - response_data = self._search_api( + response_data_tracks = self._search_api( query_type='track', keywords=keywords, filters=query_filters ) - if response_data is None: + if not response_data_tracks: query = self._construct_search_query( keywords=keywords, filters=query_filters ) failures.append(query) continue - response_data_tracks = response_data['tracks']['items'] # Apply market filter if requested region_filter = self.config['region_filter'].get() From 1b05912ab9e5679ab7c4d320f76c2a63f38436b0 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 20:39:46 -0700 Subject: [PATCH 18/41] Appease flake8 --- beets/autotag/__init__.py | 3 ++- beetsplug/discogs.py | 8 ++++++-- beetsplug/spotify.py | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 0d538a72f..738ff4813 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -278,7 +278,8 @@ class APIAutotaggerPlugin(BeetsPlugin): :type artists: list[dict] :param id_key: Key corresponding to ``artist_id`` value. :type id_key: str - :param name_key: Keys corresponding to values to concatenate for ``artist``. + :param name_key: Keys corresponding to values to concatenate + for ``artist``. :type name_key: str :return: Normalized artist string. :rtype: str diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 0c9d1b397..47747420a 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -294,7 +294,9 @@ class DiscogsPlugin(BeetsPlugin): self._log.warning(u"Release does not contain the required fields") return None - artist, artist_id = APIAutotaggerPlugin.get_artist([a.data for a in result.artists]) + artist, artist_id = APIAutotaggerPlugin.get_artist( + [a.data for a in result.artists] + ) album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] # Use `.data` to access the tracklist directly instead of the @@ -533,7 +535,9 @@ class DiscogsPlugin(BeetsPlugin): title = track['title'] track_id = None medium, medium_index, _ = self.get_track_index(track['position']) - artist, artist_id = APIAutotaggerPlugin.get_artist(track.get('artists', [])) + artist, artist_id = APIAutotaggerPlugin.get_artist( + track.get('artists', []) + ) length = self.get_track_length(track['duration']) return TrackInfo(title, track_id, artist=artist, artist_id=artist_id, length=length, index=index, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 34ec4b7ee..66ee2a16d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -306,8 +306,8 @@ class SpotifyPlugin(APIAutotaggerPlugin): """Query the Spotify Search API for the specified ``keywords``, applying the provided ``filters``. - :param query_type: Item type to search across. Valid types are: 'album', - 'artist', 'playlist', and 'track'. + :param query_type: Item type to search across. Valid types are: + 'album', 'artist', 'playlist', and 'track'. :type query_type: str :param filters: (Optional) Field filters to apply. :type filters: dict From 8010488f37be8d70c89c96aaa790b9a03f97817d Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 21:03:22 -0700 Subject: [PATCH 19/41] Modularize distance --- beets/autotag/__init__.py | 28 +++++++--------------------- beetsplug/discogs.py | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 738ff4813..9b3970f12 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -212,22 +212,12 @@ def apply_metadata(album_info, mapping): item[field] = value -def album_distance(config, data_source, album_info): +def get_distance(config, data_source, info): """Returns the ``data_source`` weight and the maximum source weight - for albums. + for albums or individual tracks. """ dist = Distance() - if album_info.data_source == data_source: - dist.add('source', config['source_weight'].as_number()) - return dist - - -def track_distance(config, data_source, track_info): - """Returns the ``data_source`` weight and the maximum source weight - for individual tracks. - """ - dist = Distance() - if track_info.data_source == data_source: + if info.data_source == data_source: dist.add('source', config['source_weight'].as_number()) return dist @@ -356,15 +346,11 @@ class APIAutotaggerPlugin(BeetsPlugin): return [self.track_for_id(track_data=track) for track in tracks] def album_distance(self, items, album_info, mapping): - return album_distance( - data_source=self.data_source, - album_info=album_info, - config=self.config, + return get_distance( + data_source=self.data_source, info=album_info, config=self.config ) def track_distance(self, item, track_info): - return track_distance( - data_source=self.data_source, - track_info=track_info, - config=self.config, + return get_distance( + data_source=self.data_source, info=track_info, config=self.config ) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 47747420a..47bee68d0 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,7 +20,7 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag import APIAutotaggerPlugin, album_distance +from beets.autotag import APIAutotaggerPlugin, get_distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.plugins import BeetsPlugin import confuse @@ -160,9 +160,18 @@ class DiscogsPlugin(BeetsPlugin): def album_distance(self, items, album_info, mapping): """Returns the album distance. """ - return album_distance( + return get_distance( data_source='Discogs', - album_info=album_info, + info=album_info, + config=self.config + ) + + def track_distance(self, item, track_info): + """Returns the track distance. + """ + return get_distance( + data_source='Discogs', + info=track_info, config=self.config ) From 30cfd7ff80d5c2079dfe20d8acd170bd8533fa32 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 21:18:07 -0700 Subject: [PATCH 20/41] Use positional str.format arg --- beets/autotag/__init__.py | 4 +--- beetsplug/deezer.py | 2 +- beetsplug/spotify.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 9b3970f12..132c4ce52 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -299,9 +299,7 @@ class APIAutotaggerPlugin(BeetsPlugin): self._log.debug( u"Searching {} for {} '{}'", self.data_source, url_type, id_ ) - match = re.search( - self.id_regex['pattern'].format(url_type=url_type), str(id_) - ) + match = re.search(self.id_regex['pattern'].format(url_type), str(id_)) id_ = match.group(self.id_regex['match_group']) return id_ if id_ else None diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 215ea3bf1..f68609470 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -36,7 +36,7 @@ class DeezerPlugin(APIAutotaggerPlugin): track_url = 'https://api.deezer.com/track/' data_source = 'Deezer' id_regex = { - 'pattern': r'(^|deezer\.com/([a-z]*/)?{url_type}/)([0-9]*)', + 'pattern': r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)', 'match_group': 3, } diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 66ee2a16d..221181fbe 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -48,7 +48,7 @@ class SpotifyPlugin(APIAutotaggerPlugin): # Spotify IDs consist of 22 alphanumeric characters # (zero-left-padded base62 representation of randomly generated UUID4) id_regex = { - 'pattern': r'(^|open\.spotify\.com/{url_type}/)([0-9A-Za-z]{{22}})', + 'pattern': r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})', 'match_group': 2, } From f7c6b5ba7f5b4e640d96b9074f3724ca55acfa9a Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 22:32:55 -0700 Subject: [PATCH 21/41] Fix str format arg order --- beetsplug/deezer.py | 2 +- beetsplug/spotify.py | 42 +++++++++++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index f68609470..bd25f8ecc 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -222,8 +222,8 @@ class DeezerPlugin(APIAutotaggerPlugin): response_data = response.json().get('data', []) self._log.debug( u"Found {} results from {} for '{}'", - self.data_source, len(response_data), + self.data_source, query, ) return response_data diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 221181fbe..3ec576bbc 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -116,7 +116,9 @@ class SpotifyPlugin(APIAutotaggerPlugin): self.access_token = response.json()['access_token'] # Save the token for later use. - self._log.debug(u'Spotify access token: {}', self.access_token) + self._log.debug( + u'{} access token: {}', self.data_source, self.access_token + ) with open(self.tokenfile, 'w') as f: json.dump({'access_token': self.access_token}, f) @@ -142,7 +144,8 @@ class SpotifyPlugin(APIAutotaggerPlugin): if response.status_code != 200: if u'token expired' in response.text: self._log.debug( - 'Spotify access token has expired. Reauthenticating.' + '{} access token has expired. Reauthenticating.', + self.data_source, ) self._authenticate() return self._handle_response(request_type, url, params=params) @@ -336,8 +339,8 @@ class SpotifyPlugin(APIAutotaggerPlugin): ) self._log.debug( u"Found {} results from {} for '{}'", - self.data_source, len(response_data), + self.data_source, query, ) return response_data @@ -350,21 +353,23 @@ class SpotifyPlugin(APIAutotaggerPlugin): self._output_match_results(results) spotify_cmd = ui.Subcommand( - 'spotify', help=u'build a Spotify playlist' + 'spotify', help=u'build a {} playlist'.format(self.data_source) ) spotify_cmd.parser.add_option( u'-m', u'--mode', action='store', - help=u'"open" to open Spotify with playlist, ' - u'"list" to print (default)', + help=u'"open" to open {} with playlist, ' + u'"list" to print (default)'.format(self.data_source), ) spotify_cmd.parser.add_option( u'-f', u'--show-failures', action='store_true', dest='show_failures', - help=u'list tracks that did not match a Spotify ID', + help=u'list tracks that did not match a {} ID'.format( + self.data_source + ), ) spotify_cmd.func = queries return [spotify_cmd] @@ -404,7 +409,8 @@ class SpotifyPlugin(APIAutotaggerPlugin): if not items: self._log.debug( - u'Your beets query returned no items, skipping Spotify.' + u'Your beets query returned no items, skipping {}.', + self.data_source, ) return @@ -456,7 +462,8 @@ class SpotifyPlugin(APIAutotaggerPlugin): or self.config['tiebreak'].get() == 'first' ): self._log.debug( - u'Spotify track(s) found, count: {}', + u'{} track(s) found, count: {}', + self.data_source, len(response_data_tracks), ) chosen_result = response_data_tracks[0] @@ -475,16 +482,19 @@ class SpotifyPlugin(APIAutotaggerPlugin): if failure_count > 0: if self.config['show_failures'].get(): self._log.info( - u'{} track(s) did not match a Spotify ID:', failure_count + u'{} track(s) did not match a {} ID:', + failure_count, + self.data_source, ) for track in failures: self._log.info(u'track: {}', track) self._log.info(u'') else: self._log.warning( - u'{} track(s) did not match a Spotify ID;\n' + u'{} track(s) did not match a {} ID:\n' u'use --show-failures to display', failure_count, + self.data_source, ) return results @@ -500,11 +510,17 @@ class SpotifyPlugin(APIAutotaggerPlugin): if results: spotify_ids = [track_data['id'] for track_data in results] if self.config['mode'].get() == 'open': - self._log.info(u'Attempting to open Spotify with playlist') + self._log.info( + u'Attempting to open {} with playlist'.format( + self.data_source + ) + ) spotify_url = self.playlist_partial + ",".join(spotify_ids) webbrowser.open(spotify_url) else: for spotify_id in spotify_ids: print(self.open_track_url + spotify_id) else: - self._log.warning(u'No Spotify tracks found from beets query') + self._log.warning( + u'No {} tracks found from beets query'.format(self.data_source) + ) From a3fb8ebfff869a9cadcc8ce8278e3ffd50e045a5 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 4 Sep 2019 22:56:09 -0700 Subject: [PATCH 22/41] Formatting --- beetsplug/deezer.py | 3 ++- beetsplug/spotify.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index bd25f8ecc..886978536 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -29,12 +29,13 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo class DeezerPlugin(APIAutotaggerPlugin): + data_source = 'Deezer' + # Base URLs for the Deezer API # Documentation: https://developers.deezer.com/api/ search_url = 'https://api.deezer.com/search/' album_url = 'https://api.deezer.com/album/' track_url = 'https://api.deezer.com/track/' - data_source = 'Deezer' id_regex = { 'pattern': r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)', 'match_group': 3, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 3ec576bbc..35ae7e462 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -35,6 +35,8 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo class SpotifyPlugin(APIAutotaggerPlugin): + data_source = 'Spotify' + # Base URLs for the Spotify API # Documentation: https://developer.spotify.com/web-api oauth_token_url = 'https://accounts.spotify.com/api/token' @@ -42,8 +44,6 @@ class SpotifyPlugin(APIAutotaggerPlugin): search_url = 'https://api.spotify.com/v1/search' album_url = 'https://api.spotify.com/v1/albums/' track_url = 'https://api.spotify.com/v1/tracks/' - playlist_partial = 'spotify:trackset:Playlist:' - data_source = 'Spotify' # Spotify IDs consist of 22 alphanumeric characters # (zero-left-padded base62 representation of randomly generated UUID4) @@ -515,7 +515,9 @@ class SpotifyPlugin(APIAutotaggerPlugin): self.data_source ) ) - spotify_url = self.playlist_partial + ",".join(spotify_ids) + spotify_url = 'spotify:trackset:Playlist:' + ','.join( + spotify_ids + ) webbrowser.open(spotify_url) else: for spotify_id in spotify_ids: From 0d6df42d5fb540e6cbf3a303e1cc7bdfcd5dcc98 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Fri, 6 Sep 2019 12:08:26 -0700 Subject: [PATCH 23/41] Use Abstract Base Class --- beets/autotag/__init__.py | 4 ++-- beetsplug/deezer.py | 4 +++- beetsplug/spotify.py | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 132c4ce52..88621b34c 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -19,7 +19,7 @@ from __future__ import division, absolute_import, print_function import re -from abc import abstractmethod, abstractproperty +from abc import ABC, abstractmethod, abstractproperty from beets import logging from beets import config @@ -222,7 +222,7 @@ def get_distance(config, data_source, info): return dist -class APIAutotaggerPlugin(BeetsPlugin): +class APIAutotaggerPlugin(ABC): def __init__(self): super(APIAutotaggerPlugin, self).__init__() self.config.add({'source_weight': 0.5}) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 886978536..a8b4651e6 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -26,9 +26,10 @@ import requests from beets import ui from beets.autotag import APIAutotaggerPlugin from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.plugins import BeetsPlugin -class DeezerPlugin(APIAutotaggerPlugin): +class DeezerPlugin(APIAutotaggerPlugin, BeetsPlugin): data_source = 'Deezer' # Base URLs for the Deezer API @@ -36,6 +37,7 @@ class DeezerPlugin(APIAutotaggerPlugin): search_url = 'https://api.deezer.com/search/' album_url = 'https://api.deezer.com/album/' track_url = 'https://api.deezer.com/track/' + id_regex = { 'pattern': r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)', 'match_group': 3, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 35ae7e462..43bd8eb76 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -32,9 +32,10 @@ import confuse from beets import ui from beets.autotag import APIAutotaggerPlugin from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.plugins import BeetsPlugin -class SpotifyPlugin(APIAutotaggerPlugin): +class SpotifyPlugin(APIAutotaggerPlugin, BeetsPlugin): data_source = 'Spotify' # Base URLs for the Spotify API From 12a8e0a792163f78f75db45e753beba77bb3bc04 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Fri, 6 Sep 2019 12:41:24 -0700 Subject: [PATCH 24/41] Fix Spotify API error formatting --- beetsplug/spotify.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 43bd8eb76..4ca85454d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -152,7 +152,9 @@ class SpotifyPlugin(APIAutotaggerPlugin, BeetsPlugin): return self._handle_response(request_type, url, params=params) else: raise ui.UserError( - u'{} API error:\n{}', self.data_source, response.text + u'{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format( + self.data_source, response.text, url, params + ) ) return response.json() From 4a6fa5657b1948e9407186b076f465d75d1259c2 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Fri, 6 Sep 2019 13:11:28 -0700 Subject: [PATCH 25/41] Formatting --- beetsplug/deezer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index a8b4651e6..0a1855a81 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -37,7 +37,7 @@ class DeezerPlugin(APIAutotaggerPlugin, BeetsPlugin): search_url = 'https://api.deezer.com/search/' album_url = 'https://api.deezer.com/album/' track_url = 'https://api.deezer.com/track/' - + id_regex = { 'pattern': r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)', 'match_group': 3, From 46065c3c8ecaee641ce2b189c59b78b4ec0a8628 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Fri, 6 Sep 2019 15:20:05 -0700 Subject: [PATCH 26/41] Use `six.with_metaclass` --- beets/autotag/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 88621b34c..17f731c28 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -19,11 +19,12 @@ from __future__ import division, absolute_import, print_function import re -from abc import ABC, abstractmethod, abstractproperty +from abc import ABCMeta, abstractmethod, abstractproperty + +import six from beets import logging from beets import config -from beets.plugins import BeetsPlugin # Parts of external interface. from .hooks import ( @@ -222,7 +223,8 @@ def get_distance(config, data_source, info): return dist -class APIAutotaggerPlugin(ABC): +@six.with_metaclass(ABCMeta) +class APIAutotaggerPlugin(object): def __init__(self): super(APIAutotaggerPlugin, self).__init__() self.config.add({'source_weight': 0.5}) From 867242da656863ae74bc1227361b8c0433b46aea Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Fri, 6 Sep 2019 15:25:06 -0700 Subject: [PATCH 27/41] `with_metaclass` --> `add_metaclass` --- beets/autotag/__init__.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 17f731c28..cec63ade1 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -19,7 +19,7 @@ from __future__ import division, absolute_import, print_function import re -from abc import ABCMeta, abstractmethod, abstractproperty +import abc import six @@ -223,41 +223,41 @@ def get_distance(config, data_source, info): return dist -@six.with_metaclass(ABCMeta) +@six.add_metaclass(abc.ABCMeta) class APIAutotaggerPlugin(object): def __init__(self): super(APIAutotaggerPlugin, self).__init__() self.config.add({'source_weight': 0.5}) - @abstractproperty + @abc.abstractproperty def id_regex(self): raise NotImplementedError - @abstractproperty + @abc.abstractproperty def data_source(self): raise NotImplementedError - @abstractproperty + @abc.abstractproperty def search_url(self): raise NotImplementedError - @abstractproperty + @abc.abstractproperty def album_url(self): raise NotImplementedError - @abstractproperty + @abc.abstractproperty def track_url(self): raise NotImplementedError - @abstractmethod + @abc.abstractmethod def _search_api(self, query_type, filters, keywords=''): raise NotImplementedError - @abstractmethod + @abc.abstractmethod def album_for_id(self, album_id): raise NotImplementedError - @abstractmethod + @abc.abstractmethod def track_for_id(self, track_id=None, track_data=None): raise NotImplementedError From 112941b944b5f588ad671d59cc4f73e9b5ef2d10 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Fri, 6 Sep 2019 17:24:26 -0700 Subject: [PATCH 28/41] Guard against None match --- beets/autotag/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index cec63ade1..8c0e62067 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -302,8 +302,11 @@ class APIAutotaggerPlugin(object): u"Searching {} for {} '{}'", self.data_source, url_type, id_ ) match = re.search(self.id_regex['pattern'].format(url_type), str(id_)) - id_ = match.group(self.id_regex['match_group']) - return id_ if id_ else None + if match: + id_ = match.group(self.id_regex['match_group']) + if id_: + return id_ + return None def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for Search API results From bdb756550097c80dd8b4de7e502e4f54ef75d14d Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 7 Sep 2019 00:48:19 -0700 Subject: [PATCH 29/41] Avoid nested capturing groups --- beetsplug/deezer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 0a1855a81..787139e7c 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -39,8 +39,8 @@ class DeezerPlugin(APIAutotaggerPlugin, BeetsPlugin): track_url = 'https://api.deezer.com/track/' id_regex = { - 'pattern': r'(^|deezer\.com/([a-z]*/)?{}/)([0-9]*)', - 'match_group': 3, + 'pattern': r'(^|deezer\.com/)([a-z]*/)?({}/)([0-9]*)', + 'match_group': 4, } def __init__(self): From 0a700c75a22cd9afb48ef90e15abd2cfd8d7e301 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 7 Sep 2019 01:07:44 -0700 Subject: [PATCH 30/41] Optional capturing groups --- beetsplug/deezer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 787139e7c..cab3b9a5f 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -39,7 +39,7 @@ class DeezerPlugin(APIAutotaggerPlugin, BeetsPlugin): track_url = 'https://api.deezer.com/track/' id_regex = { - 'pattern': r'(^|deezer\.com/)([a-z]*/)?({}/)([0-9]*)', + 'pattern': r'(^|deezer\.com/)?([a-z]*/)?({}/)?([0-9]*)', 'match_group': 4, } From 732e372ed2687853b524f66786eecb3e07152429 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 9 Sep 2019 17:31:42 -0700 Subject: [PATCH 31/41] Rename/move to `plugins.MetadataSourcePlugin`, fix formatting --- beets/autotag/__init__.py | 182 +++----------------------------------- beets/plugins.py | 148 +++++++++++++++++++++++++++++++ beetsplug/deezer.py | 8 +- beetsplug/spotify.py | 7 +- docs/changelog.rst | 11 ++- docs/plugins/deezer.rst | 8 +- 6 files changed, 173 insertions(+), 191 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 8c0e62067..07d1feffa 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -18,22 +18,11 @@ from __future__ import division, absolute_import, print_function -import re -import abc - -import six - from beets import logging from beets import config # Parts of external interface. -from .hooks import ( - AlbumInfo, - TrackInfo, - AlbumMatch, - TrackMatch, - Distance, -) # noqa +from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa from .match import tag_item, tag_album, Proposal # noqa from .match import Recommendation # noqa @@ -43,7 +32,6 @@ log = logging.getLogger('beets') # Additional utilities for the main interface. - def apply_item_metadata(item, track_info): """Set an item's metadata from its matched TrackInfo object. """ @@ -84,15 +72,14 @@ def apply_metadata(album_info, mapping): for item, track_info in mapping.items(): # Artist or artist credit. if config['artist_credit']: - item.artist = ( - track_info.artist_credit - or track_info.artist - or album_info.artist_credit - or album_info.artist - ) - item.albumartist = album_info.artist_credit or album_info.artist + item.artist = (track_info.artist_credit or + track_info.artist or + album_info.artist_credit or + album_info.artist) + item.albumartist = (album_info.artist_credit or + album_info.artist) else: - item.artist = track_info.artist or album_info.artist + item.artist = (track_info.artist or album_info.artist) item.albumartist = album_info.artist # Album. @@ -100,9 +87,8 @@ def apply_metadata(album_info, mapping): # Artist sort and credit names. item.artist_sort = track_info.artist_sort or album_info.artist_sort - item.artist_credit = ( - track_info.artist_credit or album_info.artist_credit - ) + item.artist_credit = (track_info.artist_credit or + album_info.artist_credit) item.albumartist_sort = album_info.artist_sort item.albumartist_credit = album_info.artist_credit @@ -193,7 +179,7 @@ def apply_metadata(album_info, mapping): 'work', 'mb_workid', 'work_disambig', - ), + ) } # Don't overwrite fields with empty values unless the @@ -211,149 +197,3 @@ def apply_metadata(album_info, mapping): if value is None and not clobber: continue item[field] = value - - -def get_distance(config, data_source, info): - """Returns the ``data_source`` weight and the maximum source weight - for albums or individual tracks. - """ - dist = Distance() - if info.data_source == data_source: - dist.add('source', config['source_weight'].as_number()) - return dist - - -@six.add_metaclass(abc.ABCMeta) -class APIAutotaggerPlugin(object): - def __init__(self): - super(APIAutotaggerPlugin, self).__init__() - self.config.add({'source_weight': 0.5}) - - @abc.abstractproperty - def id_regex(self): - raise NotImplementedError - - @abc.abstractproperty - def data_source(self): - raise NotImplementedError - - @abc.abstractproperty - def search_url(self): - raise NotImplementedError - - @abc.abstractproperty - def album_url(self): - raise NotImplementedError - - @abc.abstractproperty - def track_url(self): - raise NotImplementedError - - @abc.abstractmethod - def _search_api(self, query_type, filters, keywords=''): - raise NotImplementedError - - @abc.abstractmethod - def album_for_id(self, album_id): - raise NotImplementedError - - @abc.abstractmethod - def track_for_id(self, track_id=None, track_data=None): - raise NotImplementedError - - @staticmethod - def get_artist(artists, id_key='id', name_key='name'): - """Returns an artist string (all artists) and an artist_id (the main - artist) for a list of artist object dicts. - - :param artists: Iterable of artist dicts returned by API. - :type artists: list[dict] - :param id_key: Key corresponding to ``artist_id`` value. - :type id_key: str - :param name_key: Keys corresponding to values to concatenate - for ``artist``. - :type name_key: str - :return: Normalized artist string. - :rtype: str - """ - artist_id = None - artist_names = [] - for artist in artists: - if not artist_id: - artist_id = artist[id_key] - name = artist[name_key] - # Move articles to the front. - name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) - artist_names.append(name) - artist = ', '.join(artist_names).replace(' ,', ',') or None - return artist, artist_id - - def _get_id(self, url_type, id_): - """Parse an ID from its URL if necessary. - - :param url_type: Type of URL. Either 'album' or 'track'. - :type url_type: str - :param id_: Album/track ID or URL. - :type id_: str - :return: Album/track ID. - :rtype: str - """ - self._log.debug( - u"Searching {} for {} '{}'", self.data_source, url_type, id_ - ) - match = re.search(self.id_regex['pattern'].format(url_type), str(id_)) - if match: - id_ = match.group(self.id_regex['match_group']) - if id_: - return id_ - return None - - def candidates(self, items, artist, album, va_likely): - """Returns a list of AlbumInfo objects for Search API results - matching an ``album`` and ``artist`` (if not various). - - :param items: List of items comprised by an album to be matched. - :type items: list[beets.library.Item] - :param artist: The artist of the album to be matched. - :type artist: str - :param album: The name of the album to be matched. - :type album: str - :param va_likely: True if the album to be matched likely has - Various Artists. - :type va_likely: bool - :return: Candidate AlbumInfo objects. - :rtype: list[beets.autotag.hooks.AlbumInfo] - """ - query_filters = {'album': album} - if not va_likely: - query_filters['artist'] = artist - albums = self._search_api(query_type='album', filters=query_filters) - return [self.album_for_id(album_id=album['id']) for album in albums] - - def item_candidates(self, item, artist, title): - """Returns a list of TrackInfo objects for Search API results - matching ``title`` and ``artist``. - - :param item: Singleton item to be matched. - :type item: beets.library.Item - :param artist: The artist of the track to be matched. - :type artist: str - :param title: The title of the track to be matched. - :type title: str - :return: Candidate TrackInfo objects. - :rtype: list[beets.autotag.hooks.TrackInfo] - """ - tracks = self._search_api( - query_type='track', keywords=title, filters={'artist': artist} - ) - return [self.track_for_id(track_data=track) for track in tracks] - - def album_distance(self, items, album_info, mapping): - return get_distance( - data_source=self.data_source, info=album_info, config=self.config - ) - - def track_distance(self, item, track_info): - return get_distance( - data_source=self.data_source, info=track_info, config=self.config - ) diff --git a/beets/plugins.py b/beets/plugins.py index 7c98225ca..c5db5f4bd 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,12 +20,14 @@ from __future__ import division, absolute_import, print_function import traceback import re import inspect +import abc from collections import defaultdict from functools import wraps import beets from beets import logging +from beets.autotag.hooks import Distance import mediafile import six @@ -576,3 +578,149 @@ def notify_info_yielded(event): yield v return decorated return decorator + + +def get_distance(config, data_source, info): + """Returns the ``data_source`` weight and the maximum source weight + for albums or individual tracks. + """ + dist = Distance() + if info.data_source == data_source: + dist.add('source', config['source_weight'].as_number()) + return dist + + +@six.add_metaclass(abc.ABCMeta) +class MetadataSourcePlugin(object): + def __init__(self): + super(MetadataSourcePlugin, self).__init__() + self.config.add({'source_weight': 0.5}) + + @abc.abstractproperty + def id_regex(self): + raise NotImplementedError + + @abc.abstractproperty + def data_source(self): + raise NotImplementedError + + @abc.abstractproperty + def search_url(self): + raise NotImplementedError + + @abc.abstractproperty + def album_url(self): + raise NotImplementedError + + @abc.abstractproperty + def track_url(self): + raise NotImplementedError + + @abc.abstractmethod + def _search_api(self, query_type, filters, keywords=''): + raise NotImplementedError + + @abc.abstractmethod + def album_for_id(self, album_id): + raise NotImplementedError + + @abc.abstractmethod + def track_for_id(self, track_id=None, track_data=None): + raise NotImplementedError + + @staticmethod + def get_artist(artists, id_key='id', name_key='name'): + """Returns an artist string (all artists) and an artist_id (the main + artist) for a list of artist object dicts. + + :param artists: Iterable of artist dicts returned by API. + :type artists: list[dict] + :param id_key: Key corresponding to ``artist_id`` value. + :type id_key: str + :param name_key: Keys corresponding to values to concatenate + for ``artist``. + :type name_key: str + :return: Normalized artist string. + :rtype: str + """ + artist_id = None + artist_names = [] + for artist in artists: + if not artist_id: + artist_id = artist[id_key] + name = artist[name_key] + # Move articles to the front. + name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) + artist_names.append(name) + artist = ', '.join(artist_names).replace(' ,', ',') or None + return artist, artist_id + + def _get_id(self, url_type, id_): + """Parse an ID from its URL if necessary. + + :param url_type: Type of URL. Either 'album' or 'track'. + :type url_type: str + :param id_: Album/track ID or URL. + :type id_: str + :return: Album/track ID. + :rtype: str + """ + self._log.debug( + u"Searching {} for {} '{}'", self.data_source, url_type, id_ + ) + match = re.search(self.id_regex['pattern'].format(url_type), str(id_)) + if match: + id_ = match.group(self.id_regex['match_group']) + if id_: + return id_ + return None + + def candidates(self, items, artist, album, va_likely): + """Returns a list of AlbumInfo objects for Search API results + matching an ``album`` and ``artist`` (if not various). + + :param items: List of items comprised by an album to be matched. + :type items: list[beets.library.Item] + :param artist: The artist of the album to be matched. + :type artist: str + :param album: The name of the album to be matched. + :type album: str + :param va_likely: True if the album to be matched likely has + Various Artists. + :type va_likely: bool + :return: Candidate AlbumInfo objects. + :rtype: list[beets.autotag.hooks.AlbumInfo] + """ + query_filters = {'album': album} + if not va_likely: + query_filters['artist'] = artist + albums = self._search_api(query_type='album', filters=query_filters) + return [self.album_for_id(album_id=album['id']) for album in albums] + + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for Search API results + matching ``title`` and ``artist``. + + :param item: Singleton item to be matched. + :type item: beets.library.Item + :param artist: The artist of the track to be matched. + :type artist: str + :param title: The title of the track to be matched. + :type title: str + :return: Candidate TrackInfo objects. + :rtype: list[beets.autotag.hooks.TrackInfo] + """ + tracks = self._search_api( + query_type='track', keywords=title, filters={'artist': artist} + ) + return [self.track_for_id(track_data=track) for track in tracks] + + def album_distance(self, items, album_info, mapping): + return get_distance( + data_source=self.data_source, info=album_info, config=self.config + ) + + def track_distance(self, item, track_info): + return get_distance( + data_source=self.data_source, info=track_info, config=self.config + ) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index cab3b9a5f..f84a6d30e 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -24,12 +24,10 @@ import unidecode import requests from beets import ui -from beets.autotag import APIAutotaggerPlugin -from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.plugins import BeetsPlugin +from beets.plugins import MetadataSourcePlugin, BeetsPlugin -class DeezerPlugin(APIAutotaggerPlugin, BeetsPlugin): +class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin): data_source = 'Deezer' # Base URLs for the Deezer API @@ -224,7 +222,7 @@ class DeezerPlugin(APIAutotaggerPlugin, BeetsPlugin): response.raise_for_status() response_data = response.json().get('data', []) self._log.debug( - u"Found {} results from {} for '{}'", + u"Found {} result(s) from {} for '{}'", len(response_data), self.data_source, query, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 4ca85454d..8fe0d394c 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -30,12 +30,11 @@ import requests import confuse from beets import ui -from beets.autotag import APIAutotaggerPlugin from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.plugins import BeetsPlugin +from beets.plugins import MetadataSourcePlugin, BeetsPlugin -class SpotifyPlugin(APIAutotaggerPlugin, BeetsPlugin): +class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): data_source = 'Spotify' # Base URLs for the Spotify API @@ -341,7 +340,7 @@ class SpotifyPlugin(APIAutotaggerPlugin, BeetsPlugin): .get('items', []) ) self._log.debug( - u"Found {} results from {} for '{}'", + u"Found {} result(s) from {} for '{}'", len(response_data), self.data_source, query, diff --git a/docs/changelog.rst b/docs/changelog.rst index 7544db431..c9cd03dab 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,10 +66,9 @@ New features: * The 'data_source' field is now also applied as an album-level flexible attribute during imports, allowing for more refined album level searches. :bug:`3350` :bug:`1693` -* :doc:`/plugins/deezer`: - * Added Deezer plugin as an import metadata provider: you can now match tracks - and albums using the `Deezer`_ database. - Thanks to :user:`rhlahuja`. +* :doc:`/plugins/deezer`: Added Deezer plugin as an import metadata provider: + you can now match tracks and albums using the `Deezer`_ database. + Thanks to :user:`rhlahuja`. Fixes: @@ -128,6 +127,10 @@ For plugin developers: longer separate R128 backend instances. Instead the targetlevel is passed to ``compute_album_gain`` and ``compute_track_gain``. :bug:`3065` +* The ``beets.plugins.MetadataSourcePlugin`` base class has been added to + simplify development of plugins which query album, track, and search + APIs to provide metadata matches for the importer. Refer to the Spotify and + Deezer plugins for examples of using this template class. For packagers: diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index c00d1d68a..cb964c612 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -8,12 +8,6 @@ The ``deezer`` plugin provides metadata matches for the importer using the .. _Album: https://developers.deezer.com/api/album .. _Track: https://developers.deezer.com/api/track -Why Use This Plugin? --------------------- - -* You're a Beets user. -* You want to autotag music with metadata from the Deezer API. - Basic Usage ----------- First, enable the ``deezer`` plugin (see :ref:`using-plugins`). @@ -28,7 +22,7 @@ Configuration ------------- Put these options in config.yaml under the ``deezer:`` section: -- **source_weight**: Penalty applied to Spotify matches during import. Set to +- **source_weight**: Penalty applied to Deezer matches during import. Set to 0.0 to disable. Default: ``0.5``. From 68e91b18b0ff1801e7d663442b1aa9cce3005ca2 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 9 Sep 2019 17:33:42 -0700 Subject: [PATCH 32/41] Fix discogs.py `MetadataSourcePlugin` refs --- beetsplug/discogs.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 47bee68d0..bccf1f7e2 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,9 +20,8 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag import APIAutotaggerPlugin, get_distance from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.plugins import BeetsPlugin +from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance import confuse from discogs_client import Release, Master, Client from discogs_client.exceptions import DiscogsAPIError @@ -303,7 +302,7 @@ class DiscogsPlugin(BeetsPlugin): self._log.warning(u"Release does not contain the required fields") return None - artist, artist_id = APIAutotaggerPlugin.get_artist( + artist, artist_id = MetadataSourcePlugin.get_artist( [a.data for a in result.artists] ) album = re.sub(r' +', ' ', result.title) @@ -544,7 +543,7 @@ class DiscogsPlugin(BeetsPlugin): title = track['title'] track_id = None medium, medium_index, _ = self.get_track_index(track['position']) - artist, artist_id = APIAutotaggerPlugin.get_artist( + artist, artist_id = MetadataSourcePlugin.get_artist( track.get('artists', []) ) length = self.get_track_length(track['duration']) From c531b1628e88460374511449f94c7c9260b01146 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 9 Sep 2019 18:28:20 -0700 Subject: [PATCH 33/41] Avoid circular import --- beets/autotag/hooks.py | 10 ++++++++++ beets/plugins.py | 14 +++----------- beetsplug/discogs.py | 4 ++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 686423360..9cb6866ab 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -652,3 +652,13 @@ def item_candidates(item, artist, title): # Plugin candidates. for candidate in plugins.item_candidates(item, artist, title): yield candidate + + +def get_distance(config, data_source, info): + """Returns the ``data_source`` weight and the maximum source weight + for albums or individual tracks. + """ + dist = Distance() + if info.data_source == data_source: + dist.add('source', config['source_weight'].as_number()) + return dist diff --git a/beets/plugins.py b/beets/plugins.py index c5db5f4bd..19374868f 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -27,10 +27,12 @@ from functools import wraps import beets from beets import logging -from beets.autotag.hooks import Distance import mediafile import six +from .autotag.hooks import get_distance + + PLUGIN_NAMESPACE = 'beetsplug' # Plugins using the Last.fm API can share the same API key. @@ -580,16 +582,6 @@ def notify_info_yielded(event): return decorator -def get_distance(config, data_source, info): - """Returns the ``data_source`` weight and the maximum source weight - for albums or individual tracks. - """ - dist = Distance() - if info.data_source == data_source: - dist.add('source', config['source_weight'].as_number()) - return dist - - @six.add_metaclass(abc.ABCMeta) class MetadataSourcePlugin(object): def __init__(self): diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index bccf1f7e2..7addbc2ca 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,8 +20,8 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance +from beets.autotag.hooks import AlbumInfo, TrackInfo, get_distance +from beets.plugins import MetadataSourcePlugin, BeetsPlugin import confuse from discogs_client import Release, Master, Client from discogs_client.exceptions import DiscogsAPIError From 1de0af669df6fd0fb2bf52f2d1e319298256595e Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 9 Sep 2019 18:45:12 -0700 Subject: [PATCH 34/41] Try absolute import --- beets/plugins.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 19374868f..e76c99dfd 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -27,11 +27,10 @@ from functools import wraps import beets from beets import logging +from beets.autotag.hooks import get_distance import mediafile import six -from .autotag.hooks import get_distance - PLUGIN_NAMESPACE = 'beetsplug' From 84b13475e03eda560570c95a1f8ac9bc1ff936a3 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 9 Sep 2019 19:13:24 -0700 Subject: [PATCH 35/41] Move `get_distance` to `beets.autotag` --- beets/autotag/__init__.py | 18 +++++++++++++++++- beets/autotag/hooks.py | 10 ---------- beets/plugins.py | 2 +- beetsplug/discogs.py | 2 +- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 07d1feffa..2c9c03ffb 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -22,7 +22,13 @@ from beets import logging from beets import config # Parts of external interface. -from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa +from .hooks import ( + AlbumInfo, + TrackInfo, + AlbumMatch, + TrackMatch, + Distance, +) # noqa from .match import tag_item, tag_album, Proposal # noqa from .match import Recommendation # noqa @@ -197,3 +203,13 @@ def apply_metadata(album_info, mapping): if value is None and not clobber: continue item[field] = value + + +def get_distance(config, data_source, info): + """Returns the ``data_source`` weight and the maximum source weight + for albums or individual tracks. + """ + dist = Distance() + if info.data_source == data_source: + dist.add('source', config['source_weight'].as_number()) + return dist diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 9cb6866ab..686423360 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -652,13 +652,3 @@ def item_candidates(item, artist, title): # Plugin candidates. for candidate in plugins.item_candidates(item, artist, title): yield candidate - - -def get_distance(config, data_source, info): - """Returns the ``data_source`` weight and the maximum source weight - for albums or individual tracks. - """ - dist = Distance() - if info.data_source == data_source: - dist.add('source', config['source_weight'].as_number()) - return dist diff --git a/beets/plugins.py b/beets/plugins.py index e76c99dfd..cfeb5aa0b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -27,7 +27,7 @@ from functools import wraps import beets from beets import logging -from beets.autotag.hooks import get_distance +from beets.autotag import get_distance import mediafile import six diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 7addbc2ca..e7ac73d64 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,7 +20,7 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag.hooks import AlbumInfo, TrackInfo, get_distance +from beets.autotag import AlbumInfo, TrackInfo, get_distance from beets.plugins import MetadataSourcePlugin, BeetsPlugin import confuse from discogs_client import Release, Master, Client From 2b0cf3e0021e9a95f48eea876bb2ef3abd139f8c Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 10 Sep 2019 22:39:06 -0700 Subject: [PATCH 36/41] Try absolute import --- beets/plugins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index cfeb5aa0b..b429adfe7 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -27,7 +27,7 @@ from functools import wraps import beets from beets import logging -from beets.autotag import get_distance +import beets.autotag import mediafile import six @@ -707,11 +707,11 @@ class MetadataSourcePlugin(object): return [self.track_for_id(track_data=track) for track in tracks] def album_distance(self, items, album_info, mapping): - return get_distance( + return beets.autotag.get_distance( data_source=self.data_source, info=album_info, config=self.config ) def track_distance(self, item, track_info): - return get_distance( + return beets.autotag.get_distance( data_source=self.data_source, info=track_info, config=self.config ) From dfdf8ded336b79acb979648685014ea957007341 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 10 Sep 2019 22:55:41 -0700 Subject: [PATCH 37/41] Add missing import --- beetsplug/deezer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index f84a6d30e..a4dfb2bed 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -24,6 +24,7 @@ import unidecode import requests from beets import ui +from beets.autotag import AlbumInfo, TrackInfo from beets.plugins import MetadataSourcePlugin, BeetsPlugin From 876c0f733feb0fa0483055a411157d45dde07142 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 10 Sep 2019 23:52:35 -0700 Subject: [PATCH 38/41] Appease flake8 --- beets/autotag/__init__.py | 4 ++-- beets/plugins.py | 2 +- beetsplug/deezer.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 2c9c03ffb..ccb23bf9e 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -22,13 +22,13 @@ from beets import logging from beets import config # Parts of external interface. -from .hooks import ( +from .hooks import ( # noqa AlbumInfo, TrackInfo, AlbumMatch, TrackMatch, Distance, -) # noqa +) from .match import tag_item, tag_album, Proposal # noqa from .match import Recommendation # noqa diff --git a/beets/plugins.py b/beets/plugins.py index b429adfe7..fc71e8401 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -686,7 +686,7 @@ class MetadataSourcePlugin(object): if not va_likely: query_filters['artist'] = artist albums = self._search_api(query_type='album', filters=query_filters) - return [self.album_for_id(album_id=album['id']) for album in albums] + return [self.album_for_id(album_id=a['id']) for a in albums] def item_candidates(self, item, artist, title): """Returns a list of TrackInfo objects for Search API results diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index a4dfb2bed..a9a8e1b5b 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -15,7 +15,7 @@ """Adds Deezer release and track search support to the autotagger """ -from __future__ import absolute_import, print_function +from __future__ import absolute_import, print_function, division import collections From ed80e915fe44a9fdf57256bc7424396bd277f3cc Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 11 Sep 2019 00:07:43 -0700 Subject: [PATCH 39/41] Move `get_distance` --> `beets.plugins` --- beets/autotag/__init__.py | 10 ---------- beets/plugins.py | 15 ++++++++++++--- beetsplug/discogs.py | 4 ++-- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index ccb23bf9e..b8bdea479 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -203,13 +203,3 @@ def apply_metadata(album_info, mapping): if value is None and not clobber: continue item[field] = value - - -def get_distance(config, data_source, info): - """Returns the ``data_source`` weight and the maximum source weight - for albums or individual tracks. - """ - dist = Distance() - if info.data_source == data_source: - dist.add('source', config['source_weight'].as_number()) - return dist diff --git a/beets/plugins.py b/beets/plugins.py index fc71e8401..b0752203f 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -27,7 +27,6 @@ from functools import wraps import beets from beets import logging -import beets.autotag import mediafile import six @@ -581,6 +580,16 @@ def notify_info_yielded(event): return decorator +def get_distance(config, data_source, info): + """Returns the ``data_source`` weight and the maximum source weight + for albums or individual tracks. + """ + dist = beets.autotag.Distance() + if info.data_source == data_source: + dist.add('source', config['source_weight'].as_number()) + return dist + + @six.add_metaclass(abc.ABCMeta) class MetadataSourcePlugin(object): def __init__(self): @@ -707,11 +716,11 @@ class MetadataSourcePlugin(object): return [self.track_for_id(track_data=track) for track in tracks] def album_distance(self, items, album_info, mapping): - return beets.autotag.get_distance( + return get_distance( data_source=self.data_source, info=album_info, config=self.config ) def track_distance(self, item, track_info): - return beets.autotag.get_distance( + return get_distance( data_source=self.data_source, info=track_info, config=self.config ) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index e7ac73d64..c6aab991d 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,8 +20,8 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag import AlbumInfo, TrackInfo, get_distance -from beets.plugins import MetadataSourcePlugin, BeetsPlugin +from beets.autotag import AlbumInfo, TrackInfo +from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance import confuse from discogs_client import Release, Master, Client from discogs_client.exceptions import DiscogsAPIError From 6cfe7adb6cb14c70e51fa01da51f3d846a470d34 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 11 Sep 2019 00:26:48 -0700 Subject: [PATCH 40/41] Use qualified import --- beetsplug/discogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index c6aab991d..bccf1f7e2 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,7 +20,7 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag import AlbumInfo, TrackInfo +from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance import confuse from discogs_client import Release, Master, Client From 0b2837dd4f2433fffbb14428756831e622447345 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 11 Sep 2019 00:37:23 -0700 Subject: [PATCH 41/41] Revert Spotify, Discogs changes --- beetsplug/discogs.py | 50 ++++---- beetsplug/spotify.py | 276 ++++++++++++++++++++++++++----------------- 2 files changed, 196 insertions(+), 130 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index bccf1f7e2..4996c5d7c 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -20,8 +20,8 @@ from __future__ import division, absolute_import, print_function import beets.ui from beets import config -from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance +from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance +from beets.plugins import BeetsPlugin import confuse from discogs_client import Release, Master, Client from discogs_client.exceptions import DiscogsAPIError @@ -159,20 +159,10 @@ class DiscogsPlugin(BeetsPlugin): def album_distance(self, items, album_info, mapping): """Returns the album distance. """ - return get_distance( - data_source='Discogs', - info=album_info, - config=self.config - ) - - def track_distance(self, item, track_info): - """Returns the track distance. - """ - return get_distance( - data_source='Discogs', - info=track_info, - config=self.config - ) + dist = Distance() + if album_info.data_source == 'Discogs': + dist.add('source', self.config['source_weight'].as_number()) + return dist def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for discogs search results @@ -302,9 +292,7 @@ class DiscogsPlugin(BeetsPlugin): self._log.warning(u"Release does not contain the required fields") return None - artist, artist_id = MetadataSourcePlugin.get_artist( - [a.data for a in result.artists] - ) + artist, artist_id = self.get_artist([a.data for a in result.artists]) album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] # Use `.data` to access the tracklist directly instead of the @@ -380,6 +368,26 @@ class DiscogsPlugin(BeetsPlugin): else: return None + def get_artist(self, artists): + """Returns an artist string (all artists) and an artist_id (the main + artist) for a list of discogs album or track artists. + """ + artist_id = None + bits = [] + for i, artist in enumerate(artists): + if not artist_id: + artist_id = artist['id'] + name = artist['name'] + # Strip disambiguation number. + name = re.sub(r' \(\d+\)$', '', name) + # Move articles to the front. + name = re.sub(r'(?i)^(.*?), (a|an|the)$', r'\2 \1', name) + bits.append(name) + if artist['join'] and i < len(artists) - 1: + bits.append(artist['join']) + artist = ' '.join(bits).replace(' ,', ',') or None + return artist, artist_id + def get_tracks(self, tracklist): """Returns a list of TrackInfo objects for a discogs tracklist. """ @@ -543,9 +551,7 @@ class DiscogsPlugin(BeetsPlugin): title = track['title'] track_id = None medium, medium_index, _ = self.get_track_index(track['position']) - artist, artist_id = MetadataSourcePlugin.get_artist( - track.get('artists', []) - ) + artist, artist_id = self.get_artist(track.get('artists', [])) length = self.get_track_length(track['duration']) return TrackInfo(title, track_id, artist=artist, artist_id=artist_id, length=length, index=index, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 8fe0d394c..0af0dc9aa 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -1,21 +1,5 @@ # -*- coding: utf-8 -*- -# This file is part of beets. -# Copyright 2019, Rahul Ahuja. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -"""Adds Spotify release and track search support to the autotagger, along with -Spotify playlist construction. -""" from __future__ import division, absolute_import, print_function import re @@ -27,16 +11,14 @@ import collections import six import unidecode import requests -import confuse from beets import ui -from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.plugins import MetadataSourcePlugin, BeetsPlugin +from beets.plugins import BeetsPlugin +import confuse +from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance -class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): - data_source = 'Spotify' - +class SpotifyPlugin(BeetsPlugin): # Base URLs for the Spotify API # Documentation: https://developer.spotify.com/web-api oauth_token_url = 'https://accounts.spotify.com/api/token' @@ -44,13 +26,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): search_url = 'https://api.spotify.com/v1/search' album_url = 'https://api.spotify.com/v1/albums/' track_url = 'https://api.spotify.com/v1/tracks/' - - # Spotify IDs consist of 22 alphanumeric characters - # (zero-left-padded base62 representation of randomly generated UUID4) - id_regex = { - 'pattern': r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})', - 'match_group': 2, - } + playlist_partial = 'spotify:trackset:Playlist:' def __init__(self): super(SpotifyPlugin, self).__init__() @@ -67,6 +43,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): 'client_id': '4e414367a1d14c75a5c5129a627fcab8', 'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc', 'tokenfile': 'spotify_token.json', + 'source_weight': 0.5, } ) self.config['client_secret'].redact = True @@ -116,9 +93,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): self.access_token = response.json()['access_token'] # Save the token for later use. - self._log.debug( - u'{} access token: {}', self.data_source, self.access_token - ) + self._log.debug(u'Spotify access token: {}', self.access_token) with open(self.tokenfile, 'w') as f: json.dump({'access_token': self.access_token}, f) @@ -144,19 +119,31 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): if response.status_code != 200: if u'token expired' in response.text: self._log.debug( - '{} access token has expired. Reauthenticating.', - self.data_source, + 'Spotify access token has expired. Reauthenticating.' ) self._authenticate() return self._handle_response(request_type, url, params=params) else: - raise ui.UserError( - u'{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format( - self.data_source, response.text, url, params - ) - ) + raise ui.UserError(u'Spotify API error:\n{}', response.text) return response.json() + def _get_spotify_id(self, url_type, id_): + """Parse a Spotify ID from its URL if necessary. + + :param url_type: Type of Spotify URL, either 'album' or 'track'. + :type url_type: str + :param id_: Spotify ID or URL. + :type id_: str + :return: Spotify ID. + :rtype: str + """ + # Spotify IDs consist of 22 alphanumeric characters + # (zero-left-padded base62 representation of randomly generated UUID4) + id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})' + self._log.debug(u'Searching for {} {}', url_type, id_) + match = re.search(id_regex.format(url_type), id_) + return match.group(2) if match else None + def album_for_id(self, album_id): """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. @@ -166,20 +153,20 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): :return: AlbumInfo object for album :rtype: beets.autotag.hooks.AlbumInfo or None """ - spotify_id = self._get_id('album', album_id) + spotify_id = self._get_spotify_id('album', album_id) if spotify_id is None: return None - album_data = self._handle_response( + response_data = self._handle_response( requests.get, self.album_url + spotify_id ) - artist, artist_id = self.get_artist(album_data['artists']) + artist, artist_id = self._get_artist(response_data['artists']) date_parts = [ - int(part) for part in album_data['release_date'].split('-') + int(part) for part in response_data['release_date'].split('-') ] - release_date_precision = album_data['release_date_precision'] + release_date_precision = response_data['release_date_precision'] if release_date_precision == 'day': year, month, day = date_parts elif release_date_precision == 'month': @@ -192,37 +179,35 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): else: raise ui.UserError( u"Invalid `release_date_precision` returned " - u"by {} API: '{}'".format( - self.data_source, release_date_precision - ) + u"by Spotify API: '{}'".format(release_date_precision) ) tracks = [] medium_totals = collections.defaultdict(int) - for i, track_data in enumerate(album_data['tracks']['items'], start=1): + for i, track_data in enumerate(response_data['tracks']['items']): track = self._get_track(track_data) - track.index = i + track.index = i + 1 medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: track.medium_total = medium_totals[track.medium] return AlbumInfo( - album=album_data['name'], + album=response_data['name'], album_id=spotify_id, artist=artist, artist_id=artist_id, tracks=tracks, - albumtype=album_data['album_type'], - va=len(album_data['artists']) == 1 + albumtype=response_data['album_type'], + va=len(response_data['artists']) == 1 and artist.lower() == 'various artists', year=year, month=month, day=day, - label=album_data['label'], + label=response_data['label'], mediums=max(medium_totals.keys()), - data_source=self.data_source, - data_url=album_data['external_urls']['spotify'], + data_source='Spotify', + data_url=response_data['external_urls']['spotify'], ) def _get_track(self, track_data): @@ -234,7 +219,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ - artist, artist_id = self.get_artist(track_data['artists']) + artist, artist_id = self._get_artist(track_data['artists']) return TrackInfo( title=track_data['name'], track_id=track_data['id'], @@ -244,7 +229,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): index=track_data['track_number'], medium=track_data['disc_number'], medium_index=track_data['track_number'], - data_source=self.data_source, + data_source='Spotify', data_url=track_data['external_urls']['spotify'], ) @@ -262,7 +247,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): :rtype: beets.autotag.hooks.TrackInfo or None """ if track_data is None: - spotify_id = self._get_id('track', track_id) + spotify_id = self._get_spotify_id('track', track_id) if spotify_id is None: return None track_data = self._handle_response( @@ -277,14 +262,107 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): requests.get, self.album_url + track_data['album']['id'] ) medium_total = 0 - for i, track_data in enumerate(album_data['tracks']['items'], start=1): + for i, track_data in enumerate(album_data['tracks']['items']): if track_data['disc_number'] == track.medium: medium_total += 1 if track_data['id'] == track.track_id: - track.index = i + track.index = i + 1 track.medium_total = medium_total return track + @staticmethod + def _get_artist(artists): + """Returns an artist string (all artists) and an artist_id (the main + artist) for a list of Spotify artist object dicts. + + :param artists: Iterable of simplified Spotify artist objects + (https://developer.spotify.com/documentation/web-api/reference/object-model/#artist-object-simplified) + :type artists: list[dict] + :return: Normalized artist string + :rtype: str + """ + artist_id = None + artist_names = [] + for artist in artists: + if not artist_id: + artist_id = artist['id'] + name = artist['name'] + # Move articles to the front. + name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) + artist_names.append(name) + artist = ', '.join(artist_names).replace(' ,', ',') or None + return artist, artist_id + + def album_distance(self, items, album_info, mapping): + """Returns the Spotify source weight and the maximum source weight + for albums. + """ + dist = Distance() + if album_info.data_source == 'Spotify': + dist.add('source', self.config['source_weight'].as_number()) + return dist + + def track_distance(self, item, track_info): + """Returns the Spotify source weight and the maximum source weight + for individual tracks. + """ + dist = Distance() + if track_info.data_source == 'Spotify': + dist.add('source', self.config['source_weight'].as_number()) + return dist + + def candidates(self, items, artist, album, va_likely): + """Returns a list of AlbumInfo objects for Spotify Search API results + matching an ``album`` and ``artist`` (if not various). + + :param items: List of items comprised by an album to be matched. + :type items: list[beets.library.Item] + :param artist: The artist of the album to be matched. + :type artist: str + :param album: The name of the album to be matched. + :type album: str + :param va_likely: True if the album to be matched likely has + Various Artists. + :type va_likely: bool + :return: Candidate AlbumInfo objects. + :rtype: list[beets.autotag.hooks.AlbumInfo] + """ + query_filters = {'album': album} + if not va_likely: + query_filters['artist'] = artist + response_data = self._search_spotify( + query_type='album', filters=query_filters + ) + if response_data is None: + return [] + return [ + self.album_for_id(album_id=album_data['id']) + for album_data in response_data['albums']['items'] + ] + + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for Spotify Search API results + matching ``title`` and ``artist``. + + :param item: Singleton item to be matched. + :type item: beets.library.Item + :param artist: The artist of the track to be matched. + :type artist: str + :param title: The title of the track to be matched. + :type title: str + :return: Candidate TrackInfo objects. + :rtype: list[beets.autotag.hooks.TrackInfo] + """ + response_data = self._search_spotify( + query_type='track', keywords=title, filters={'artist': artist} + ) + if response_data is None: + return [] + return [ + self.track_for_id(track_data=track_data) + for track_data in response_data['tracks']['items'] + ] + @staticmethod def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to @@ -307,12 +385,14 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): query = query.decode('utf8') return unidecode.unidecode(query) - def _search_api(self, query_type, filters=None, keywords=''): + def _search_spotify(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying the provided ``filters``. - :param query_type: Item type to search across. Valid types are: - 'album', 'artist', 'playlist', and 'track'. + :param query_type: A comma-separated list of item types to search + across. Valid types are: 'album', 'artist', 'playlist', and + 'track'. Search results include hits from all the specified item + types. :type query_type: str :param filters: (Optional) Field filters to apply. :type filters: dict @@ -327,25 +407,19 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): ) if not query: return None + self._log.debug(u"Searching Spotify for '{}'".format(query)) + response_data = self._handle_response( + requests.get, + self.search_url, + params={'q': query, 'type': query_type}, + ) + num_results = 0 + for result_type_data in response_data.values(): + num_results += len(result_type_data['items']) self._log.debug( - u"Searching {} for '{}'".format(self.data_source, query) + u"Found {} results from Spotify for '{}'", num_results, query ) - response_data = ( - self._handle_response( - requests.get, - self.search_url, - params={'q': query, 'type': query_type}, - ) - .get(query_type + 's', {}) - .get('items', []) - ) - self._log.debug( - u"Found {} result(s) from {} for '{}'", - len(response_data), - self.data_source, - query, - ) - return response_data + return response_data if num_results > 0 else None def commands(self): def queries(lib, opts, args): @@ -355,23 +429,21 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): self._output_match_results(results) spotify_cmd = ui.Subcommand( - 'spotify', help=u'build a {} playlist'.format(self.data_source) + 'spotify', help=u'build a Spotify playlist' ) spotify_cmd.parser.add_option( u'-m', u'--mode', action='store', - help=u'"open" to open {} with playlist, ' - u'"list" to print (default)'.format(self.data_source), + help=u'"open" to open Spotify with playlist, ' + u'"list" to print (default)', ) spotify_cmd.parser.add_option( u'-f', u'--show-failures', action='store_true', dest='show_failures', - help=u'list tracks that did not match a {} ID'.format( - self.data_source - ), + help=u'list tracks that did not match a Spotify ID', ) spotify_cmd.func = queries return [spotify_cmd] @@ -411,8 +483,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): if not items: self._log.debug( - u'Your beets query returned no items, skipping {}.', - self.data_source, + u'Your beets query returned no items, skipping Spotify.' ) return @@ -440,15 +511,16 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): # Query the Web API for each track, look for the items' JSON data query_filters = {'artist': artist, 'album': album} - response_data_tracks = self._search_api( + response_data = self._search_spotify( query_type='track', keywords=keywords, filters=query_filters ) - if not response_data_tracks: + if response_data is None: query = self._construct_search_query( keywords=keywords, filters=query_filters ) failures.append(query) continue + response_data_tracks = response_data['tracks']['items'] # Apply market filter if requested region_filter = self.config['region_filter'].get() @@ -464,8 +536,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): or self.config['tiebreak'].get() == 'first' ): self._log.debug( - u'{} track(s) found, count: {}', - self.data_source, + u'Spotify track(s) found, count: {}', len(response_data_tracks), ) chosen_result = response_data_tracks[0] @@ -484,19 +555,16 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): if failure_count > 0: if self.config['show_failures'].get(): self._log.info( - u'{} track(s) did not match a {} ID:', - failure_count, - self.data_source, + u'{} track(s) did not match a Spotify ID:', failure_count ) for track in failures: self._log.info(u'track: {}', track) self._log.info(u'') else: self._log.warning( - u'{} track(s) did not match a {} ID:\n' + u'{} track(s) did not match a Spotify ID;\n' u'use --show-failures to display', failure_count, - self.data_source, ) return results @@ -512,19 +580,11 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): if results: spotify_ids = [track_data['id'] for track_data in results] if self.config['mode'].get() == 'open': - self._log.info( - u'Attempting to open {} with playlist'.format( - self.data_source - ) - ) - spotify_url = 'spotify:trackset:Playlist:' + ','.join( - spotify_ids - ) + self._log.info(u'Attempting to open Spotify with playlist') + spotify_url = self.playlist_partial + ",".join(spotify_ids) webbrowser.open(spotify_url) else: for spotify_id in spotify_ids: print(self.open_track_url + spotify_id) else: - self._log.warning( - u'No {} tracks found from beets query'.format(self.data_source) - ) + self._log.warning(u'No Spotify tracks found from beets query')