Factor out APIAutotaggerPlugin

This commit is contained in:
Rahul Ahuja 2019-09-04 19:50:04 -07:00
parent 4a552595df
commit bd0cea9f1b
4 changed files with 275 additions and 322 deletions

View file

@ -18,11 +18,21 @@
from __future__ import division, absolute_import, print_function from __future__ import division, absolute_import, print_function
import re
from abc import abstractmethod, abstractproperty
from beets import logging from beets import logging
from beets import config from beets import config
from beets.plugins import BeetsPlugin
# Parts of external interface. # 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 tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa from .match import Recommendation # noqa
@ -32,6 +42,7 @@ log = logging.getLogger('beets')
# Additional utilities for the main interface. # Additional utilities for the main interface.
def apply_item_metadata(item, track_info): def apply_item_metadata(item, track_info):
"""Set an item's metadata from its matched TrackInfo object. """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(): for item, track_info in mapping.items():
# Artist or artist credit. # Artist or artist credit.
if config['artist_credit']: if config['artist_credit']:
item.artist = (track_info.artist_credit or item.artist = (
track_info.artist or track_info.artist_credit
album_info.artist_credit or or track_info.artist
album_info.artist) or album_info.artist_credit
item.albumartist = (album_info.artist_credit or or album_info.artist
album_info.artist) )
item.albumartist = album_info.artist_credit or album_info.artist
else: else:
item.artist = (track_info.artist or album_info.artist) item.artist = track_info.artist or album_info.artist
item.albumartist = album_info.artist item.albumartist = album_info.artist
# Album. # Album.
@ -87,8 +99,9 @@ def apply_metadata(album_info, mapping):
# Artist sort and credit names. # Artist sort and credit names.
item.artist_sort = track_info.artist_sort or album_info.artist_sort item.artist_sort = track_info.artist_sort or album_info.artist_sort
item.artist_credit = (track_info.artist_credit or item.artist_credit = (
album_info.artist_credit) track_info.artist_credit or album_info.artist_credit
)
item.albumartist_sort = album_info.artist_sort item.albumartist_sort = album_info.artist_sort
item.albumartist_credit = album_info.artist_credit item.albumartist_credit = album_info.artist_credit
@ -179,7 +192,7 @@ def apply_metadata(album_info, mapping):
'work', 'work',
'mb_workid', 'mb_workid',
'work_disambig', 'work_disambig',
) ),
} }
# Don't overwrite fields with empty values unless the # 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: if value is None and not clobber:
continue continue
item[field] = value 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,
)

View file

@ -17,7 +17,6 @@
""" """
from __future__ import absolute_import, print_function from __future__ import absolute_import, print_function
import re
import collections import collections
import six import six
@ -25,37 +24,24 @@ import unidecode
import requests import requests
from beets import ui from beets import ui
from beets.plugins import BeetsPlugin from beets.autotag import APIAutotaggerPlugin
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.autotag.hooks import AlbumInfo, TrackInfo
class DeezerPlugin(BeetsPlugin): class DeezerPlugin(APIAutotaggerPlugin):
# Base URLs for the Deezer API # Base URLs for the Deezer API
# Documentation: https://developers.deezer.com/api/ # Documentation: https://developers.deezer.com/api/
search_url = 'https://api.deezer.com/search/' search_url = 'https://api.deezer.com/search/'
album_url = 'https://api.deezer.com/album/' album_url = 'https://api.deezer.com/album/'
track_url = 'https://api.deezer.com/track/' 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): def __init__(self):
super(DeezerPlugin, self).__init__() 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): def album_for_id(self, album_id):
"""Fetch an album by its Deezer ID or URL and return an """Fetch an album by its Deezer ID or URL and return an
@ -66,12 +52,12 @@ class DeezerPlugin(BeetsPlugin):
:return: AlbumInfo object for album. :return: AlbumInfo object for album.
:rtype: beets.autotag.hooks.AlbumInfo or None :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: if deezer_id is None:
return None return None
album_data = requests.get(self.album_url + deezer_id).json() 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'] release_date = album_data['release_date']
date_parts = [int(part) for part in release_date.split('-')] date_parts = [int(part) for part in release_date.split('-')]
@ -89,7 +75,7 @@ class DeezerPlugin(BeetsPlugin):
else: else:
raise ui.UserError( raise ui.UserError(
u"Invalid `release_date` returned " 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( tracks_data = requests.get(
@ -109,7 +95,7 @@ class DeezerPlugin(BeetsPlugin):
album=album_data['title'], album=album_data['title'],
album_id=deezer_id, album_id=deezer_id,
artist=artist, artist=artist,
artist_credit=self._get_artist([album_data['artist']])[0], artist_credit=self.get_artist([album_data['artist']])[0],
artist_id=artist_id, artist_id=artist_id,
tracks=tracks, tracks=tracks,
albumtype=album_data['record_type'], albumtype=album_data['record_type'],
@ -120,7 +106,7 @@ class DeezerPlugin(BeetsPlugin):
day=day, day=day,
label=album_data['label'], label=album_data['label'],
mediums=max(medium_totals.keys()), mediums=max(medium_totals.keys()),
data_source='Deezer', data_source=self.data_source,
data_url=album_data['link'], data_url=album_data['link'],
) )
@ -132,7 +118,7 @@ class DeezerPlugin(BeetsPlugin):
:return: TrackInfo object for track :return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo :rtype: beets.autotag.hooks.TrackInfo
""" """
artist, artist_id = self._get_artist( artist, artist_id = self.get_artist(
track_data.get('contributors', [track_data['artist']]) track_data.get('contributors', [track_data['artist']])
) )
return TrackInfo( return TrackInfo(
@ -144,7 +130,7 @@ class DeezerPlugin(BeetsPlugin):
index=track_data['track_position'], index=track_data['track_position'],
medium=track_data['disk_number'], medium=track_data['disk_number'],
medium_index=track_data['track_position'], medium_index=track_data['track_position'],
data_source='Deezer', data_source=self.data_source,
data_url=track_data['link'], data_url=track_data['link'],
) )
@ -162,7 +148,7 @@ class DeezerPlugin(BeetsPlugin):
:rtype: beets.autotag.hooks.TrackInfo or None :rtype: beets.autotag.hooks.TrackInfo or None
""" """
if track_data is 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: if deezer_id is None:
return None return None
track_data = requests.get(self.track_url + deezer_id).json() track_data = requests.get(self.track_url + deezer_id).json()
@ -176,107 +162,13 @@ class DeezerPlugin(BeetsPlugin):
).json()['data'] ).json()['data']
medium_total = 0 medium_total = 0
for i, track_data in enumerate(album_tracks_data, start=1): 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 medium_total += 1
if track_data['id'] == track.track_id: if track_data['id'] == track.track_id:
track.index = i track.index = i
track.medium_total = medium_total track.medium_total = medium_total
return track 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 @staticmethod
def _construct_search_query(filters=None, keywords=''): def _construct_search_query(filters=None, keywords=''):
"""Construct a query string with the specified filters and keywords to """Construct a query string with the specified filters and keywords to
@ -299,7 +191,7 @@ class DeezerPlugin(BeetsPlugin):
query = query.decode('utf8') query = query.decode('utf8')
return unidecode.unidecode(query) 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 """Query the Deezer Search API for the specified ``keywords``, applying
the provided ``filters``. the provided ``filters``.
@ -320,12 +212,18 @@ class DeezerPlugin(BeetsPlugin):
) )
if not query: if not query:
return None 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( 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

View file

@ -20,7 +20,8 @@ from __future__ import division, absolute_import, print_function
import beets.ui import beets.ui
from beets import config 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 from beets.plugins import BeetsPlugin
import confuse import confuse
from discogs_client import Release, Master, Client from discogs_client import Release, Master, Client
@ -159,10 +160,11 @@ class DiscogsPlugin(BeetsPlugin):
def album_distance(self, items, album_info, mapping): def album_distance(self, items, album_info, mapping):
"""Returns the album distance. """Returns the album distance.
""" """
dist = Distance() return album_distance(
if album_info.data_source == 'Discogs': data_source='Discogs',
dist.add('source', self.config['source_weight'].as_number()) album_info=album_info,
return dist config=self.config
)
def candidates(self, items, artist, album, va_likely): def candidates(self, items, artist, album, va_likely):
"""Returns a list of AlbumInfo objects for discogs search results """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") self._log.warning(u"Release does not contain the required fields")
return None 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 = re.sub(r' +', ' ', result.title)
album_id = result.data['id'] album_id = result.data['id']
# Use `.data` to access the tracklist directly instead of the # Use `.data` to access the tracklist directly instead of the
@ -368,26 +370,6 @@ class DiscogsPlugin(BeetsPlugin):
else: else:
return None 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): def get_tracks(self, tracklist):
"""Returns a list of TrackInfo objects for a discogs tracklist. """Returns a list of TrackInfo objects for a discogs tracklist.
""" """
@ -551,7 +533,7 @@ class DiscogsPlugin(BeetsPlugin):
title = track['title'] title = track['title']
track_id = None track_id = None
medium, medium_index, _ = self.get_track_index(track['position']) 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']) length = self.get_track_length(track['duration'])
return TrackInfo(title, track_id, artist=artist, artist_id=artist_id, return TrackInfo(title, track_id, artist=artist, artist_id=artist_id,
length=length, index=index, length=length, index=index,

View file

@ -27,14 +27,14 @@ import collections
import six import six
import unidecode import unidecode
import requests import requests
import confuse
from beets import ui from beets import ui
from beets.plugins import BeetsPlugin from beets.autotag import APIAutotaggerPlugin
import confuse from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
class SpotifyPlugin(BeetsPlugin): class SpotifyPlugin(APIAutotaggerPlugin):
# Base URLs for the Spotify API # Base URLs for the Spotify API
# Documentation: https://developer.spotify.com/web-api # Documentation: https://developer.spotify.com/web-api
oauth_token_url = 'https://accounts.spotify.com/api/token' oauth_token_url = 'https://accounts.spotify.com/api/token'
@ -43,6 +43,14 @@ class SpotifyPlugin(BeetsPlugin):
album_url = 'https://api.spotify.com/v1/albums/' album_url = 'https://api.spotify.com/v1/albums/'
track_url = 'https://api.spotify.com/v1/tracks/' track_url = 'https://api.spotify.com/v1/tracks/'
playlist_partial = 'spotify:trackset:Playlist:' 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): def __init__(self):
super(SpotifyPlugin, self).__init__() super(SpotifyPlugin, self).__init__()
@ -59,7 +67,6 @@ class SpotifyPlugin(BeetsPlugin):
'client_id': '4e414367a1d14c75a5c5129a627fcab8', 'client_id': '4e414367a1d14c75a5c5129a627fcab8',
'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc', 'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc',
'tokenfile': 'spotify_token.json', 'tokenfile': 'spotify_token.json',
'source_weight': 0.5,
} }
) )
self.config['client_secret'].redact = True self.config['client_secret'].redact = True
@ -140,26 +147,11 @@ class SpotifyPlugin(BeetsPlugin):
self._authenticate() self._authenticate()
return self._handle_response(request_type, url, params=params) return self._handle_response(request_type, url, params=params)
else: 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() 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): def album_for_id(self, album_id):
"""Fetch an album by its Spotify ID or URL and return an """Fetch an album by its Spotify ID or URL and return an
AlbumInfo object or None if the album is not found. AlbumInfo object or None if the album is not found.
@ -169,20 +161,20 @@ class SpotifyPlugin(BeetsPlugin):
:return: AlbumInfo object for album :return: AlbumInfo object for album
:rtype: beets.autotag.hooks.AlbumInfo or None :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: if spotify_id is None:
return None return None
response_data = self._handle_response( album_data = self._handle_response(
requests.get, self.album_url + spotify_id 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 = [ 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': if release_date_precision == 'day':
year, month, day = date_parts year, month, day = date_parts
elif release_date_precision == 'month': elif release_date_precision == 'month':
@ -195,14 +187,14 @@ class SpotifyPlugin(BeetsPlugin):
else: else:
raise ui.UserError( raise ui.UserError(
u"Invalid `release_date_precision` returned " 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 = [] tracks = []
medium_totals = collections.defaultdict(int) medium_totals = collections.defaultdict(int)
for i, track_data in enumerate( for i, track_data in enumerate(album_data['tracks']['items'], start=1):
response_data['tracks']['items'], start=1
):
track = self._get_track(track_data) track = self._get_track(track_data)
track.index = i track.index = i
medium_totals[track.medium] += 1 medium_totals[track.medium] += 1
@ -211,21 +203,21 @@ class SpotifyPlugin(BeetsPlugin):
track.medium_total = medium_totals[track.medium] track.medium_total = medium_totals[track.medium]
return AlbumInfo( return AlbumInfo(
album=response_data['name'], album=album_data['name'],
album_id=spotify_id, album_id=spotify_id,
artist=artist, artist=artist,
artist_id=artist_id, artist_id=artist_id,
tracks=tracks, tracks=tracks,
albumtype=response_data['album_type'], albumtype=album_data['album_type'],
va=len(response_data['artists']) == 1 va=len(album_data['artists']) == 1
and artist.lower() == 'various artists', and artist.lower() == 'various artists',
year=year, year=year,
month=month, month=month,
day=day, day=day,
label=response_data['label'], label=album_data['label'],
mediums=max(medium_totals.keys()), mediums=max(medium_totals.keys()),
data_source='Spotify', data_source=self.data_source,
data_url=response_data['external_urls']['spotify'], data_url=album_data['external_urls']['spotify'],
) )
def _get_track(self, track_data): def _get_track(self, track_data):
@ -237,7 +229,7 @@ class SpotifyPlugin(BeetsPlugin):
:return: TrackInfo object for track :return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo :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( return TrackInfo(
title=track_data['name'], title=track_data['name'],
track_id=track_data['id'], track_id=track_data['id'],
@ -247,7 +239,7 @@ class SpotifyPlugin(BeetsPlugin):
index=track_data['track_number'], index=track_data['track_number'],
medium=track_data['disc_number'], medium=track_data['disc_number'],
medium_index=track_data['track_number'], medium_index=track_data['track_number'],
data_source='Spotify', data_source=self.data_source,
data_url=track_data['external_urls']['spotify'], data_url=track_data['external_urls']['spotify'],
) )
@ -265,7 +257,7 @@ class SpotifyPlugin(BeetsPlugin):
:rtype: beets.autotag.hooks.TrackInfo or None :rtype: beets.autotag.hooks.TrackInfo or None
""" """
if track_data is 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: if spotify_id is None:
return None return None
track_data = self._handle_response( track_data = self._handle_response(
@ -288,99 +280,6 @@ class SpotifyPlugin(BeetsPlugin):
track.medium_total = medium_total track.medium_total = medium_total
return track 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 @staticmethod
def _construct_search_query(filters=None, keywords=''): def _construct_search_query(filters=None, keywords=''):
"""Construct a query string with the specified filters and keywords to """Construct a query string with the specified filters and keywords to
@ -403,14 +302,12 @@ class SpotifyPlugin(BeetsPlugin):
query = query.decode('utf8') query = query.decode('utf8')
return unidecode.unidecode(query) 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 """Query the Spotify Search API for the specified ``keywords``, applying
the provided ``filters``. the provided ``filters``.
:param query_type: A comma-separated list of item types to search :param query_type: Item type to search across. Valid types are: 'album',
across. Valid types are: 'album', 'artist', 'playlist', and 'artist', 'playlist', and 'track'.
'track'. Search results include hits from all the specified item
types.
:type query_type: str :type query_type: str
:param filters: (Optional) Field filters to apply. :param filters: (Optional) Field filters to apply.
:type filters: dict :type filters: dict
@ -425,19 +322,25 @@ class SpotifyPlugin(BeetsPlugin):
) )
if not query: if not query:
return None 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( 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 commands(self):
def queries(lib, opts, args): 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 the Web API for each track, look for the items' JSON data
query_filters = {'artist': artist, 'album': album} query_filters = {'artist': artist, 'album': album}
response_data = self._search_spotify( response_data = self._search_api(
query_type='track', keywords=keywords, filters=query_filters query_type='track', keywords=keywords, filters=query_filters
) )
if response_data is None: if response_data is None: