From 0309775a91d89bdec629c4be5e35235478e145ba Mon Sep 17 00:00:00 2001 From: Simon Luijk Date: Wed, 29 May 2013 21:25:23 +0200 Subject: [PATCH 01/64] Initial support for ALAC encoded audio files Both AAC and ALAC are normally embedded in an .m4a container. For this reason I have split the m4a type into aac and alac types. To distinguish between the two encodings I have taken advantage of the fact that, in my collection, ALAC don't have a sample_rate set. I could not find verification if this is always the case or not. Would bitrate be a more reliable determining factor? e.g. if bitrate > 320000: type = 'alac' Signed-off-by: Simon Luijk --- beets/mediafile.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index e6648d757..b177e13b4 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -73,7 +73,8 @@ class FileTypeError(UnreadableFileError): # Human-readable type names. TYPES = { 'mp3': 'MP3', - 'mp4': 'AAC', + 'aac': 'AAC', + 'alac': 'ALAC', 'ogg': 'OGG', 'flac': 'FLAC', 'ape': 'APE', @@ -532,8 +533,10 @@ class MediaField(object): obj.mgfile[style.key] = out def _styles(self, obj): - if obj.type in ('mp3', 'mp4', 'asf'): + if obj.type in ('mp3', 'asf'): styles = self.styles[obj.type] + elif obj.type in ('aac', 'alac'): + styles = self.styles['mp4'] else: styles = self.styles['etc'] # Sane styles. @@ -568,7 +571,7 @@ class MediaField(object): out = out[:-len(style.suffix)] # MPEG-4 freeform frames are (should be?) encoded as UTF-8. - if obj.type == 'mp4' and style.key.startswith('----:') and \ + if obj.type in ('aac', 'alac') and style.key.startswith('----:') and \ isinstance(out, str): out = out.decode('utf8') @@ -636,7 +639,7 @@ class MediaField(object): # MPEG-4 "freeform" (----) frames must be encoded as UTF-8 # byte strings. - if obj.type == 'mp4' and style.key.startswith('----:') and \ + if obj.type in ('aac', 'alac') and style.key.startswith('----:') and \ isinstance(out, unicode): out = out.encode('utf8') @@ -723,7 +726,7 @@ class ImageField(object): return picframe.data - elif obj.type == 'mp4': + elif obj.type in ('aac', 'alac'): if 'covr' in obj.mgfile: covers = obj.mgfile['covr'] if covers: @@ -795,7 +798,7 @@ class ImageField(object): ) obj.mgfile['APIC'] = picframe - elif obj.type == 'mp4': + elif obj.type in ('aac', 'alac'): if val is None: if 'covr' in obj.mgfile: del obj.mgfile['covr'] @@ -880,7 +883,11 @@ class MediaFile(object): raise FileTypeError('file type unsupported by Mutagen') elif type(self.mgfile).__name__ == 'M4A' or \ type(self.mgfile).__name__ == 'MP4': - self.type = 'mp4' + if hasattr(self.mgfile.info, 'sample_rate') and \ + self.mgfile.info.sample_rate > 0: + self.type = 'aac' + else: + self.type = 'alac' elif type(self.mgfile).__name__ == 'ID3' or \ type(self.mgfile).__name__ == 'MP3': self.type = 'mp3' From ffe65648d2157ad6a1d57ee91265503240c0ac17 Mon Sep 17 00:00:00 2001 From: Simon Luijk Date: Thu, 30 May 2013 08:22:39 +0200 Subject: [PATCH 02/64] Style change Signed-off-by: Simon Luijk --- beets/mediafile.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index b177e13b4..985f7fa72 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -83,6 +83,8 @@ TYPES = { 'asf': 'Windows Media', } +MP4_TYPES = ('aac', 'alac') + # Utility. @@ -535,7 +537,7 @@ class MediaField(object): def _styles(self, obj): if obj.type in ('mp3', 'asf'): styles = self.styles[obj.type] - elif obj.type in ('aac', 'alac'): + elif obj.type in MP4_TYPES: styles = self.styles['mp4'] else: styles = self.styles['etc'] # Sane styles. @@ -571,7 +573,7 @@ class MediaField(object): out = out[:-len(style.suffix)] # MPEG-4 freeform frames are (should be?) encoded as UTF-8. - if obj.type in ('aac', 'alac') and style.key.startswith('----:') and \ + if obj.type in MP4_TYPES and style.key.startswith('----:') and \ isinstance(out, str): out = out.decode('utf8') @@ -639,7 +641,7 @@ class MediaField(object): # MPEG-4 "freeform" (----) frames must be encoded as UTF-8 # byte strings. - if obj.type in ('aac', 'alac') and style.key.startswith('----:') and \ + if obj.type in MP4_TYPES and style.key.startswith('----:') and \ isinstance(out, unicode): out = out.encode('utf8') @@ -726,7 +728,7 @@ class ImageField(object): return picframe.data - elif obj.type in ('aac', 'alac'): + elif obj.type in MP4_TYPES: if 'covr' in obj.mgfile: covers = obj.mgfile['covr'] if covers: @@ -798,7 +800,7 @@ class ImageField(object): ) obj.mgfile['APIC'] = picframe - elif obj.type in ('aac', 'alac'): + elif obj.type in MP4_TYPES: if val is None: if 'covr' in obj.mgfile: del obj.mgfile['covr'] From 79f56adb124bc956cbbf762016f0f36bba7b70cf Mon Sep 17 00:00:00 2001 From: Simon Luijk Date: Thu, 30 May 2013 09:26:57 +0200 Subject: [PATCH 03/64] Add note about current approach of detecting aac vs alac Signed-off-by: Simon Luijk --- beets/mediafile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beets/mediafile.py b/beets/mediafile.py index 985f7fa72..06b327e0d 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -885,6 +885,8 @@ class MediaFile(object): raise FileTypeError('file type unsupported by Mutagen') elif type(self.mgfile).__name__ == 'M4A' or \ type(self.mgfile).__name__ == 'MP4': + # This hack differentiates aac and alac until we find a more + # deterministic approach. if hasattr(self.mgfile.info, 'sample_rate') and \ self.mgfile.info.sample_rate > 0: self.type = 'aac' From 1a8f54fd519c8b92af768b451448c881225fbebe Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Wed, 29 May 2013 18:12:21 +0200 Subject: [PATCH 04/64] Lay out beatport plugin based on discogs plugin --- beetsplug/beatport.py | 147 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 beetsplug/beatport.py diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py new file mode 100644 index 000000000..74359bb9c --- /dev/null +++ b/beetsplug/beatport.py @@ -0,0 +1,147 @@ +# This file is part of beets. +# Copyright 2013, Adrian Sampson. +# +# 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 Beatport release and track search support to the autotagger +""" +from beets import config +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.autotag.match import current_metadata, VA_ARTISTS +from beets.plugins import BeetsPlugin + +import beets +import discogs_client +import logging +import re +import time + +log = logging.getLogger('beets') + +# Distance parameters. +DISCOGS_SOURCE_WEIGHT = config['beatport']['source_weight'].as_number() +SOURCE_WEIGHT = config['match']['weight']['source'].as_number() + +class BeatportAPIError(Exception): + pass + +class BeatportRelease(object): + pass + +class BeatportTrack(object): + pass + +class BeatportPlugin(BeetsPlugin): + def album_distance(self, items, album_info, mapping): + """Returns the beatport source weight and the maximum source weight + for albums. + """ + return DISCOGS_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT + + def track_distance(self, item, info): + """Returns the beatport source weight and the maximum source weight + for individual tracks. + """ + return DISCOGS_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT + + def candidates(self, items, artist, release, va_likely): + """Returns a list of AlbumInfo objects for beatport search results + matching release and artist (if not various). + """ + if va_likely: + query = album + else: + query = '%s %s' % (artist, album) + try: + return self._get_releases(query) + except BeatportAPIError as e: + log.debug('Beatport API Error: %s (query: %s)' % (e, query)) + return [] + + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for beatport search results + matching title and artist. + """ + query = '%s %s' % (artist, title) + try: + return self._get_tracks(query) + except BeatportAPIError as e: + log.debug('Beatport API Error: %s (query: %s)' % (e, query)) + return [] + + def album_for_id(self, release_id): + """Fetches a release by its Beatport ID and returns an AlbumInfo object + or None if the release is not found. + """ + log.debug('Searching Beatport for release %s' % str(album_id)) + # TODO: Verify that release_id is a valid Beatport release ID + # TODO: Obtain release from Beatport + # TODO: Return an AlbumInfo object generated from the Beatport release + raise NotImplementedError + + def _get_releases(self, query): + """Returns a list of AlbumInfo objects for a beatport search query. + """ + # Strip non-word characters from query. Things like "!" and "-" can + # cause a query to return no results, even if they match the artist or + # album title. Use `re.UNICODE` flag to avoid stripping non-english + # word characters. + query = re.sub(r'\W+', ' ', query, re.UNICODE) + # Strip medium information from query, Things like "CD1" and "disk 1" + # can also negate an otherwise positive result. + query = re.sub(r'\b(CD|disc)\s*\d+', '', query, re.I) + albums = [] + # TODO: Obtain search results from Beatport (count=5) + # TODO: Generate AlbumInfo object for each item in the results and + # return them in a list + raise NotImplementedError + + def _get_album_info(self, result): + """Returns an AlbumInfo object for a Beatport Release object. + """ + raise NotImplementedError + + def _get_track_info(self, result): + """Returns a TrackInfo object for a Beatport Track object. + """ + raise NotImplementedError + + def _get_artist(self, artists): + """Returns an artist string (all artists) and an artist_id (the main + artist) for a list of Beatport release or track artists. + """ + artist_id = None + bits = [] + for artist in 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'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) + bits.append(name) + if artist['join']: + 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 list of Beatport Track + objects. + """ + tracks = [] + for track in tracklist: + # TODO: Generate TrackInfo object from Beatport Track object and + # add it to the list of tracks + pass + raise NotImplementedError From af81b67c0c3ff594494dcc931d2b406885829623 Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Sat, 1 Jun 2013 15:17:01 +0200 Subject: [PATCH 05/64] Implement Beatport API wrapper --- beetsplug/beatport.py | 159 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 19 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 74359bb9c..9fd3c1603 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -14,53 +14,164 @@ """Adds Beatport release and track search support to the autotagger """ -from beets import config -from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.autotag.match import current_metadata, VA_ARTISTS -from beets.plugins import BeetsPlugin - -import beets -import discogs_client import logging import re -import time +from datetime import datetime, timedelta + +import requests + +from beets import config +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.autotag.match import current_metadata +from beets.plugins import BeetsPlugin log = logging.getLogger('beets') # Distance parameters. -DISCOGS_SOURCE_WEIGHT = config['beatport']['source_weight'].as_number() +BEATPORT_SOURCE_WEIGHT = config['beatport']['source_weight'].as_number() SOURCE_WEIGHT = config['match']['weight']['source'].as_number() + class BeatportAPIError(Exception): pass -class BeatportRelease(object): - pass -class BeatportTrack(object): - pass +class BeatportObject(object): + beatport_id = None + name = None + release_date = None + artists = [] + genres = [] + + def __init__(self, data): + self.beatport_id = data['id'] + self.name = data['name'] + if 'releaseDate' in data: + self.release_date = datetime.strptime(data['releaseDate'], + '%Y-%m-%d') + if 'artists' in data: + self.artists = [x['name'] for x in data['artists']] + if 'genres' in data: + self.genres = [x['name'] for x in data['genres']] + + +class BeatportAPI(object): + API_BASE = 'http://api.beatport.com/' + + @classmethod + def get(cls, endpoint, **kwargs): + response = requests.get(cls.API_BASE + endpoint, params=kwargs) + if not response: + raise BeatportAPIError( + "Error {.status_code} for '{.request.path_url}" + .format(response)) + return response.json()['results'] + + +class BeatportSearch(object): + query = None + release_type = None + results = [] + + def __unicode__(self): + return u"".format( + self.release_type, self.query, len(self.results)) + + def __init__(self, query, release_type='release', details=True): + self.query = query + self.release_type = release_type + results = BeatportAPI.get('catalog/3/search', query=query, + facets=['fieldType:{}'.format(release_type)], + perPage=5) + for item in results: + if release_type == 'release': + self.results.append(BeatportRelease(item)) + elif release_type == 'track': + self.results.append(BeatportTrack(item)) + if details: + self.results[-1].get_tracks() + + +class BeatportRelease(BeatportObject): + API_ENDPOINT = 'catalog/3/beatport/release' + catalog_number = None + label_name = None + tracks = [] + + def __unicode__(self): + if len(self.artists) < 4: + artist_str = ", ".join(self.artists) + else: + artist_str = "Various Artists" + return u"".format(artist_str, self.name, + self.catalog_number) + + def __init__(self, data): + BeatportObject.__init__(self, data) + if 'catalogNumber' in data: + self.catalog_number = data['catalogNumber'] + if 'label' in data: + self.label_name = data['label']['name'] + + @classmethod + def from_id(cls, beatport_id): + response = BeatportAPI.get(cls.API_ENDPOINT, id=beatport_id) + release = BeatportRelease(response['release']) + release.tracks = [BeatportTrack(x) for x in response['tracks']] + return release + + def get_tracks(self): + response = BeatportAPI.get(self.API_ENDPOINT, id=self.beatport_id) + self.tracks = [BeatportTrack(x) for x in response['tracks']] + + +class BeatportTrack(BeatportObject): + API_ENDPOINT = 'catalog/3/beatport/release' + title = None + mix_name = None + length = None + + def __unicode__(self): + artist_str = ", ".join(self.artists) + return u"".format(artist_str, self.name, + self.mix_name) + + def __init__(self, data): + BeatportObject.__init__(self, data) + if 'title' in data: + self.title = data['title'] + if 'mixName' in data: + self.mix_name = data['mixName'] + if 'length' in data: + self.length = timedelta(milliseconds=data['lengthMs']) + + @classmethod + def from_id(cls, beatport_id): + response = BeatportAPI.get(cls.API_ENDPOINT, id=beatport_id) + return BeatportTrack(response['track']) + class BeatportPlugin(BeetsPlugin): def album_distance(self, items, album_info, mapping): """Returns the beatport source weight and the maximum source weight for albums. """ - return DISCOGS_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT + return BEATPORT_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT def track_distance(self, item, info): """Returns the beatport source weight and the maximum source weight for individual tracks. """ - return DISCOGS_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT + return BEATPORT_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT def candidates(self, items, artist, release, va_likely): """Returns a list of AlbumInfo objects for beatport search results matching release and artist (if not various). """ if va_likely: - query = album + query = release else: - query = '%s %s' % (artist, album) + query = '%s %s' % (artist, release) try: return self._get_releases(query) except BeatportAPIError as e: @@ -82,10 +193,20 @@ class BeatportPlugin(BeetsPlugin): """Fetches a release by its Beatport ID and returns an AlbumInfo object or None if the release is not found. """ - log.debug('Searching Beatport for release %s' % str(album_id)) + log.debug('Searching Beatport for release %s' % str(release_id)) # TODO: Verify that release_id is a valid Beatport release ID # TODO: Obtain release from Beatport - # TODO: Return an AlbumInfo object generated from the Beatport release + # TODO: Return an AlbumInfo object generated from the BeatporRelease + raise NotImplementedError + + def track_for_id(self, track_id): + """Fetches a track by its Beatport ID and returns a TrackInfo object + or None if the track is not found. + """ + log.debug('Searching Beatport for track %s' % str(track_id)) + # TODO: Verify that release_id is a valid Beatport track ID + # TODO: Obtain track from Beatport + # TODO: Return a TrackInfo object generated from the BeatportTrack raise NotImplementedError def _get_releases(self, query): From bbd50c65cdabe9c82f36165fdb4211aa8632e563 Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Sat, 1 Jun 2013 15:50:59 +0200 Subject: [PATCH 06/64] Fix Beatport source weight --- beetsplug/beatport.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 9fd3c1603..6668e0e50 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -27,10 +27,6 @@ from beets.plugins import BeetsPlugin log = logging.getLogger('beets') -# Distance parameters. -BEATPORT_SOURCE_WEIGHT = config['beatport']['source_weight'].as_number() -SOURCE_WEIGHT = config['match']['weight']['source'].as_number() - class BeatportAPIError(Exception): pass @@ -152,17 +148,33 @@ class BeatportTrack(BeatportObject): class BeatportPlugin(BeetsPlugin): + def __init__(self): + super(BeatportPlugin, self).__init__() + self.config.add({ + 'source_weight': 0.5, + }) + def album_distance(self, items, album_info, mapping): """Returns the beatport source weight and the maximum source weight for albums. """ - return BEATPORT_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT + if album_info.data_source == 'Beatport': + return self.config['source_weight'].as_number() * \ + config['match']['weight']['source'].as_number(), \ + config['match']['weight']['source'].as_number() + else: + return 0.0, 0.0 def track_distance(self, item, info): """Returns the beatport source weight and the maximum source weight for individual tracks. """ - return BEATPORT_SOURCE_WEIGHT * SOURCE_WEIGHT, SOURCE_WEIGHT + if info.data_source == 'Beatport': + return self.config['source_weight'].as_number() * \ + config['match']['weight']['source'].as_number(), \ + config['match']['weight']['source'].as_number() + else: + return 0.0, 0.0 def candidates(self, items, artist, release, va_likely): """Returns a list of AlbumInfo objects for beatport search results From eb20d77c6926175530255c6318d3d116b3672009 Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Sat, 1 Jun 2013 18:45:54 +0200 Subject: [PATCH 07/64] Implement BeetsPlugin API for Beatport plugin --- beetsplug/beatport.py | 146 +++++++++++++++++++++++++----------------- 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 6668e0e50..ca61e0624 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -22,7 +22,6 @@ import requests from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.autotag.match import current_metadata from beets.plugins import BeetsPlugin log = logging.getLogger('beets') @@ -38,17 +37,20 @@ class BeatportObject(object): release_date = None artists = [] genres = [] + url = None def __init__(self, data): self.beatport_id = data['id'] - self.name = data['name'] + self.name = unicode(data['name']) if 'releaseDate' in data: self.release_date = datetime.strptime(data['releaseDate'], '%Y-%m-%d') if 'artists' in data: - self.artists = [x['name'] for x in data['artists']] + self.artists = [(x['id'], unicode(x['name'])) + for x in data['artists']] if 'genres' in data: - self.genres = [x['name'] for x in data['genres']] + self.genres = [unicode(x['name']) + for x in data['genres']] class BeatportAPI(object): @@ -56,10 +58,14 @@ class BeatportAPI(object): @classmethod def get(cls, endpoint, **kwargs): - response = requests.get(cls.API_BASE + endpoint, params=kwargs) + try: + response = requests.get(cls.API_BASE + endpoint, params=kwargs) + except Exception as e: + raise BeatportAPIError("Error connection to Beatport API: {}" + .format(e.message)) if not response: raise BeatportAPIError( - "Error {.status_code} for '{.request.path_url}" + "Error {0.status_code} for '{0.request.path_url}" .format(response)) return response.json()['results'] @@ -67,36 +73,38 @@ class BeatportAPI(object): class BeatportSearch(object): query = None release_type = None - results = [] def __unicode__(self): return u"".format( self.release_type, self.query, len(self.results)) def __init__(self, query, release_type='release', details=True): + self.results = [] self.query = query self.release_type = release_type - results = BeatportAPI.get('catalog/3/search', query=query, - facets=['fieldType:{}'.format(release_type)], - perPage=5) - for item in results: + response = BeatportAPI.get('catalog/3/search', query=query, + facets=['fieldType:{}' + .format(release_type)], + perPage=5) + for item in response: if release_type == 'release': - self.results.append(BeatportRelease(item)) + release = BeatportRelease(item) + if details: + release.get_tracks() + self.results.append(release) elif release_type == 'track': self.results.append(BeatportTrack(item)) - if details: - self.results[-1].get_tracks() class BeatportRelease(BeatportObject): API_ENDPOINT = 'catalog/3/beatport/release' catalog_number = None label_name = None - tracks = [] + category = None def __unicode__(self): if len(self.artists) < 4: - artist_str = ", ".join(self.artists) + artist_str = ", ".join(x[1] for x in self.artists) else: artist_str = "Various Artists" return u"".format(artist_str, self.name, @@ -108,6 +116,11 @@ class BeatportRelease(BeatportObject): self.catalog_number = data['catalogNumber'] if 'label' in data: self.label_name = data['label']['name'] + if 'category' in data: + self.category = data['category'] + if 'slug' in data: + self.url = "http://beatport.com/release/{}/{}".format( + data['slug'], data['id']) @classmethod def from_id(cls, beatport_id): @@ -128,18 +141,21 @@ class BeatportTrack(BeatportObject): length = None def __unicode__(self): - artist_str = ", ".join(self.artists) + artist_str = ", ".join(x[1] for x in self.artists) return u"".format(artist_str, self.name, self.mix_name) def __init__(self, data): BeatportObject.__init__(self, data) if 'title' in data: - self.title = data['title'] + self.title = unicode(data['title']) if 'mixName' in data: self.mix_name = data['mixName'] if 'length' in data: self.length = timedelta(milliseconds=data['lengthMs']) + if 'slug' in data: + self.url = "http://beatport.com/track/{}/{}".format( + data['slug'], data['id']) @classmethod def from_id(cls, beatport_id): @@ -160,8 +176,8 @@ class BeatportPlugin(BeetsPlugin): """ if album_info.data_source == 'Beatport': return self.config['source_weight'].as_number() * \ - config['match']['weight']['source'].as_number(), \ - config['match']['weight']['source'].as_number() + config['match']['weight']['source'].as_number(), \ + config['match']['weight']['source'].as_number() else: return 0.0, 0.0 @@ -169,12 +185,9 @@ class BeatportPlugin(BeetsPlugin): """Returns the beatport source weight and the maximum source weight for individual tracks. """ - if info.data_source == 'Beatport': - return self.config['source_weight'].as_number() * \ - config['match']['weight']['source'].as_number(), \ - config['match']['weight']['source'].as_number() - else: - return 0.0, 0.0 + return self.config['source_weight'].as_number() * \ + config['match']['weight']['source'].as_number(), \ + config['match']['weight']['source'].as_number() def candidates(self, items, artist, release, va_likely): """Returns a list of AlbumInfo objects for beatport search results @@ -206,20 +219,24 @@ class BeatportPlugin(BeetsPlugin): or None if the release is not found. """ log.debug('Searching Beatport for release %s' % str(release_id)) - # TODO: Verify that release_id is a valid Beatport release ID - # TODO: Obtain release from Beatport - # TODO: Return an AlbumInfo object generated from the BeatporRelease - raise NotImplementedError + match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id) + if not match: + return None + release = BeatportRelease.from_id(match.group(2)) + album = self._get_album_info(release) + return album def track_for_id(self, track_id): """Fetches a track by its Beatport ID and returns a TrackInfo object or None if the track is not found. """ log.debug('Searching Beatport for track %s' % str(track_id)) - # TODO: Verify that release_id is a valid Beatport track ID - # TODO: Obtain track from Beatport - # TODO: Return a TrackInfo object generated from the BeatportTrack - raise NotImplementedError + match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id) + if not match: + return None + bp_track = BeatportTrack.from_id(match.group(2)) + track = self._get_track_info(bp_track) + return track def _get_releases(self, query): """Returns a list of AlbumInfo objects for a beatport search query. @@ -232,21 +249,42 @@ class BeatportPlugin(BeetsPlugin): # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(r'\b(CD|disc)\s*\d+', '', query, re.I) - albums = [] - # TODO: Obtain search results from Beatport (count=5) - # TODO: Generate AlbumInfo object for each item in the results and - # return them in a list - raise NotImplementedError + albums = [self._get_album_info(x) + for x in BeatportSearch(query).results] + return albums - def _get_album_info(self, result): + def _get_album_info(self, release): """Returns an AlbumInfo object for a Beatport Release object. """ - raise NotImplementedError + va = len(release.artists) > 3 + artist, artist_id = self._get_artist(release.artists) + if va: + artist = "Various Artists" + tracks = [self._get_track_info(x, index=idx) + for idx, x in enumerate(release.tracks, 1)] - def _get_track_info(self, result): + return AlbumInfo(album=release.name, album_id=release.beatport_id, + artist=artist, artist_id=artist_id, tracks=tracks, + albumtype=release.category, va=va, + year=release.release_date.year, + month=release.release_date.month, + day=release.release_date.day, + label=release.label_name, + catalognum=release.catalog_number, media='Digital', + data_source='Beatport', data_url=release.url) + + def _get_track_info(self, track, index=None): """Returns a TrackInfo object for a Beatport Track object. """ - raise NotImplementedError + title = track.name + if track.mix_name != "Original Mix": + title += " ({})".format(track.mix_name) + artist, artist_id = self._get_artist(track.artists) + length = track.length.total_seconds() + + return TrackInfo(title=title, track_id=track.beatport_id, + artist=artist, artist_id=artist_id, + length=length, index=index) def _get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main @@ -256,25 +294,19 @@ class BeatportPlugin(BeetsPlugin): bits = [] for artist in artists: if not artist_id: - artist_id = artist['id'] - name = artist['name'] + artist_id = artist[0] + name = artist[1] # Strip disambiguation number. name = re.sub(r' \(\d+\)$', '', name) # Move articles to the front. name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) bits.append(name) - if artist['join']: - bits.append(artist['join']) - artist = ' '.join(bits).replace(' ,', ',') or None + artist = ', '.join(bits).replace(' ,', ',') or None return artist, artist_id - def _get_tracks(self, tracklist): - """Returns a list of TrackInfo objects for a list of Beatport Track - objects. + def _get_tracks(self, query): + """Returns a list of TrackInfo objects for a Beatport query. """ - tracks = [] - for track in tracklist: - # TODO: Generate TrackInfo object from Beatport Track object and - # add it to the list of tracks - pass - raise NotImplementedError + bp_tracks = BeatportSearch(query, release_type='track').results + tracks = [self._get_track_info(x) for x in bp_tracks] + return tracks From 4624f65ce3c77e4ec8472c2878c9eceedb9e348b Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 1 Jun 2013 17:22:39 -0700 Subject: [PATCH 08/64] fix interface to ID matching As outlined in #299, we broke many places in the code that were expecting _album_for_id and _track_for_id to return a single item rather than a list. I took this opportunity to divide up the interface: there's now one function for MBIDs (returning a single object or None) and one for generic IDs (returning a list). --- beets/autotag/hooks.py | 44 +++++++++++++++++++++--------------------- beets/autotag/match.py | 14 ++++++-------- beetsplug/chroma.py | 8 ++++---- beetsplug/mbsync.py | 7 +++---- beetsplug/missing.py | 4 +--- 5 files changed, 36 insertions(+), 41 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index cedaa3d90..96de6b674 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -166,37 +166,37 @@ TrackMatch = namedtuple('TrackMatch', ['distance', 'info']) # Aggregation of sources. -def _album_for_id(album_id): - """Get a list of albums corresponding to a release ID.""" - candidates = [] - - # Candidates from MusicBrainz. +def album_for_mbid(release_id): + """Get an AlbumInfo object for a MusicBrainz release ID. Return None + if the ID is not found. + """ try: - candidates.append(mb.album_for_id(album_id)) + return mb.album_for_id(release_id) except mb.MusicBrainzAPIError as exc: exc.log(log) - # From plugins. +def track_for_mbid(recording_id): + """Get a TrackInfo object for a MusicBrainz recording ID. Return None + if the ID is not found. + """ + try: + return mb.track_for_id(recording_id) + except mb.MusicBrainzAPIError as exc: + exc.log(log) + +def albums_for_id(album_id): + """Get a list of albums for an ID.""" + candidates = [album_for_mbid(album_id)] candidates.extend(plugins.album_for_id(album_id)) - return filter(None, candidates) -def _track_for_id(track_id): - """Get an item for a recording ID.""" - candidates = [] - - # From MusicBrainz. - try: - candidates.append(mb.track_for_id(track_id)) - except mb.MusicBrainzAPIError as exc: - exc.log(log) - - # From plugins. +def tracks_for_id(track_id): + """Get a list of tracks for an ID.""" + candidates = [track_for_mbid(track_id)] candidates.extend(plugins.track_for_id(track_id)) - return filter(None, candidates) -def _album_candidates(items, artist, album, va_likely): +def album_candidates(items, artist, album, va_likely): """Search for album matches. ``items`` is a list of Item objects that make up the album. ``artist`` and ``album`` are the respective names (strings), which may be derived from the item list or may be @@ -224,7 +224,7 @@ def _album_candidates(items, artist, album, va_likely): return out -def _item_candidates(item, artist, title): +def item_candidates(item, artist, title): """Search for item matches. ``item`` is the Item to be matched. ``artist`` and ``title`` are strings and either reflect the item or are specified by the user. diff --git a/beets/autotag/match.py b/beets/autotag/match.py index bcd3d040d..8935165f3 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -361,9 +361,7 @@ def match_by_id(items): if bool(reduce(lambda x,y: x if x==y else (), albumids)): albumid = albumids[0] log.debug('Searching for discovered album ID: ' + albumid) - matches = hooks._album_for_id(albumid) - if matches: - return matches[0] + return hooks.album_for_mbid(albumid) else: log.debug('No album ID consensus.') @@ -485,7 +483,7 @@ def tag_album(items, search_artist=None, search_album=None, # Search by explicit ID. if search_id is not None: log.debug('Searching for album ID: ' + search_id) - search_cands = hooks._album_for_id(search_id) + search_cands = hooks.albums_for_id(search_id) # Use existing metadata or text search. else: @@ -516,8 +514,8 @@ def tag_album(items, search_artist=None, search_album=None, log.debug(u'Album might be VA: %s' % str(va_likely)) # Get the results from the data sources. - search_cands = hooks._album_candidates(items, search_artist, - search_album, va_likely) + search_cands = hooks.album_candidates(items, search_artist, + search_album, va_likely) log.debug(u'Evaluating %i candidates.' % len(search_cands)) for info in search_cands: @@ -544,7 +542,7 @@ def tag_item(item, search_artist=None, search_title=None, trackid = search_id or item.mb_trackid if trackid: log.debug('Searching for track ID: ' + trackid) - for track_info in hooks._track_for_id(trackid): + for track_info in hooks.tracks_for_id(trackid): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = \ hooks.TrackMatch(dist, track_info) @@ -567,7 +565,7 @@ def tag_item(item, search_artist=None, search_title=None, log.debug(u'Item search terms: %s - %s' % (search_artist, search_title)) # Get and evaluate candidate metadata. - for track_info in hooks._item_candidates(item, search_artist, search_title): + for track_info in hooks.item_candidates(item, search_artist, search_title): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 163d7b1ee..08a78e3af 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -127,9 +127,9 @@ class AcoustidPlugin(plugins.BeetsPlugin): def candidates(self, items, artist, album, va_likely): albums = [] for relid in _all_releases(items): - matches = hooks._album_for_id(relid) - if matches: - albums.extend(matches) + album = hooks.album_for_mbid(relid) + if album: + albums.append(album) log.debug('acoustid album candidates: %i' % len(albums)) return albums @@ -141,7 +141,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): recording_ids, _ = _matches[item.path] tracks = [] for recording_id in recording_ids: - track = hooks._track_for_id(recording_id) + track = hooks.track_for_mbid(recording_id) if track: tracks.append(track) log.debug('acoustid item candidates: {0}'.format(len(tracks))) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 103de81b5..81e802a33 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -72,7 +72,7 @@ def mbsync_singletons(lib, query, move, pretend, write): s.old_data = dict(s.record) # Get the MusicBrainz recording info. - track_info = hooks._track_for_id(s.mb_trackid) + track_info = hooks.track_for_mbid(s.mb_trackid) if not track_info: log.info(u'Recording ID not found: {0}'.format(s.mb_trackid)) continue @@ -97,11 +97,10 @@ def mbsync_albums(lib, query, move, pretend, write): item.old_data = dict(item.record) # Get the MusicBrainz album information. - matches = hooks._album_for_id(a.mb_albumid) - if not matches: + album_info = hooks.album_for_mbid(a.mb_albumid) + if not album_info: log.info(u'Release ID not found: {0}'.format(a.mb_albumid)) continue - album_info = matches[0] # Construct a track mapping according to MBIDs. This should work # for albums that have missing or extra tracks. diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 0aa1427e2..8e4c4010f 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -39,9 +39,7 @@ def _missing(album): if len([i for i in album.items()]) < album.tracktotal: # fetch missing items # TODO: Implement caching that without breaking other stuff - matches = hooks._album_for_id(album.mb_albumid) - if matches: - album_info = matches[0] + album_info = hooks.album_for_mbid(album.mb_albumid) for track_info in getattr(album_info, 'tracks', []): if track_info.track_id not in item_mbids: item = _item(track_info, album_info, album.id) From 9a6b6240d0befaa2efd5578e9d3a3ce5a539109d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 1 Jun 2013 17:28:59 -0700 Subject: [PATCH 09/64] zero: fix nulling fields containing None --- beetsplug/zero.py | 3 ++- docs/changelog.rst | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index ef0b8b28d..1ea6d0e50 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -89,6 +89,7 @@ class ZeroPlugin(BeetsPlugin): continue self._log.debug(u'[zero] \"{0}\" ({1}) match: {2}' .format(fval, fn, ' '.join(patterns))) - setattr(item, fn, type(fval)()) + new_val = None if fval is None else type(fval)() + setattr(item, fn, new_val) self._log.debug(u'[zero] {0}={1}' .format(fn, getattr(item, fn))) diff --git a/docs/changelog.rst b/docs/changelog.rst index eeeb78d86..f602ca744 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -47,6 +47,8 @@ Changelog that go missing. * The :ref:`modify-cmd` command can now change albums' album art paths (i.e., ``beet modify artpath=...`` works). Thanks to Lucas Duailibe. +* :doc:`/plugins/zero`: Fix a crash when nulling out a field that contains + None. * Various UI enhancements to the importer due to Tai Lee: * More consistent format and colorization of album and track metadata. From 3a715a6703e50c2cafac4b4b9560f78762e90d81 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 1 Jun 2013 17:33:06 -0700 Subject: [PATCH 10/64] changelog/thanks for ALAC (#295) --- beets/mediafile.py | 6 ++++-- docs/changelog.rst | 2 ++ docs/guides/tagger.rst | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index 06b327e0d..b9b35f3ef 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -885,8 +885,10 @@ class MediaFile(object): raise FileTypeError('file type unsupported by Mutagen') elif type(self.mgfile).__name__ == 'M4A' or \ type(self.mgfile).__name__ == 'MP4': - # This hack differentiates aac and alac until we find a more - # deterministic approach. + # This hack differentiates AAC and ALAC until we find a more + # deterministic approach. Mutagen only sets the sample rate + # for AAC files. See: + # https://github.com/sampsyo/beets/pull/295 if hasattr(self.mgfile.info, 'sample_rate') and \ self.mgfile.info.sample_rate > 0: self.type = 'aac' diff --git a/docs/changelog.rst b/docs/changelog.rst index f602ca744..0f8b08b51 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,8 @@ Changelog **numeric ranges**. For example, you can get a list of albums from the '90s by typing ``beet ls year:1990..1999`` or find high-bitrate music with ``bitrate:128000..``. See :ref:`numericquery`. Thanks to Michael Schuerig. +* **ALAC files** are now marked as ALAC instead of being conflated with AAC + audio. Thanks to Simon Luijk. * :doc:`/plugins/random`: A new ``-e`` option gives an equal chance to each artist in your collection to avoid biasing random samples to prolific artists. Thanks to Georges Dubus. diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index 68bf3ac1f..8bf9af621 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -60,9 +60,9 @@ all of these limitations. plugin if you're willing to spend a little more CPU power to get tags for unidentified albums. -* Currently, MP3, AAC, FLAC, Ogg Vorbis, Monkey's Audio, WavPack, Musepack, and - Windows Media files are supported. (Do you use some other format? `Let me - know!`_) +* Currently, MP3, AAC, FLAC, ALAC, Ogg Vorbis, Monkey's Audio, WavPack, + Musepack, and Windows Media files are supported. (Do you use some other + format? `Let me know!`_) .. _Let me know!: mailto:adrian@radbox.org From cff06431cccb504e25867332809d3af3887e57b4 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 1 Jun 2013 17:44:21 -0700 Subject: [PATCH 11/64] add tests for ALAC (#295) --- test/rsrc/full.alac.m4a | Bin 0 -> 6884 bytes test/test_mediafile_basic.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 test/rsrc/full.alac.m4a diff --git a/test/rsrc/full.alac.m4a b/test/rsrc/full.alac.m4a new file mode 100644 index 0000000000000000000000000000000000000000..8ec7d377c37176e1b7e98f469eb2801f5e375323 GIT binary patch literal 6884 zcmeHMYgAKL7C!eTfe}g7+#XpIwS!Rt&cQCmrJX1^UzRUAqj}KnuNjt z$~b7Xs~vR;mUf_{)wZ@;Uj)Htt;J3&uBo+YZPl^1rPgVqhTPfb3e~FZxaQ|9&+_K% zv-dvx@tu8kE)gL_Ew(SS%t%*L2nL=@Ea|C*wz3kuMkwS$Nm*GX5XB{xW+Uc&@ZlGy z@q^IT9QGj)f`7NiuNJ_*YpuGic|VGT4d2Cwz7)SL3(f|R3(L_i(w_QmE5v4Ac;5L z;>Q@RCM?GT#~4y!Ev9bK1lMNQ7Xv-lX1B5E-r}P?x|lWqjmPc02qeOcf!yP7>i}Dn zC=e2e0rjm2&CVUrqHK1%4ex;g$RYtKVj66QU<4Dv@1P$n$G&(2GiPANJQe6Ihk0-W zgX&0F?1EVd^XKp!gy$kW$AAYh5K{+aI^f0F?1nO!@c_FxhVY#TMXiI7YATF&8s=w9 zOn7F;uTY1RveFg{#_5;Ai!Lm-*H&ORcPxyEP1DC6+Rq37@>Qoi5hH9C^16uq&`@Y5NirE{l55@`3SRiByf^9-WkXIPzaO;Azg{8|WHCq(aRD2K9?+iE7uvl)Ql}4Fjf?g>Dkx{0nl_ptgT1EyvAzh(V znsD7#J%D5Cevb34w4vxeeieF!A~hp3Lnc?y8M5^Bj8qw&NoUFodcA^9$w*6Cti&+? z&wkaoe(&Q~X;NgSDKeBY1x=^R(leEEnO>>LkY&)yj5L$Nw0J_Q0{qT=fPBS+`A(zn|f$jmUMGRWAROpKfZD#g*{-(tx4`4BR(BkA$LmBkzC6t!7(j~ZMCJvMU zkO)ifu#e0o>7y3?;wS zj7m(UY$K0vUt-1gI_7R$ORQxT7Dxd4ciLjBiM@X~yoci<0uK>*h`>Vxeg^^a>cf`# zJMcUd{of%VxKaXd5Nuju{2+(-2vdVd#(DLy_%k_jxMfGm|8!1-h$9O+5;u>14eMoj z_^<3njB{*mOqOR@*KVXZIC1lS_)9L-OE%KIZ~O>v9^WB!wNc@4$D9{Fa;hdLWFm6Z zADq}6@a=ahgzSwY_aIXKP46-%LN{{7XTN>tLkbLYk1X(@%qm3m2UdO$My9~$kWY^% zXc4D^vmO+p_aWlKqabp)MN=%qjl<%zXqj;DN-z`K$K`izhy7>;xGb*|me(!xWR|HA z`fNY!bhyQ^fkDqSDs=LCS4}0mDbPors5pm^zw3=Y6zJm~kgxG@{6wh4!Z=%nm|P&4 zQvQq+tn)w(Mi`&ZTz`h?L#Zr8z32A53S2O}pi!~1yk;qACU^FhojQJtJGF7--la|i zhBy+||9%Wg+<9T7aIYJ51)aTBSlW)It#p?kIk0al8-^N_Ni5kf!B`N>A$9O&Y#g~8 z<#pBFJUn9gw>Q93pzAE!?}pzK&f#3g-9jkxyfC&el>O5Thh2VS0(uGw@-BroU7Uo_ z1?7h-l(kcTNg#AGoXt*7EA4Q{PML@}hm)O*Q@+Nz9vI>yViSFx*_m+ zjpMz%V*Mp3lo0(l08EI`wxsx&3f`mrM1o|`XZrxF{wb=;m@6oDpZ@Df@O#rAeyC;8 zt`WnVQPqgjb1Iy_dIWq-Xrg`VI?SXuO52AGwtU z0P`j`rn!egy_~8`+?Z-JoZ8b+0fK9gvhaL3ANEXu>T}LtPO0jAykT6vF>xL% znOB>*sE?DFC>rnOo(RwKgoqMIxEAW!Qipq34zxfGbe#Gym%}YBn8{;MbakDND2Cj( zgIZj@!=2l`stx)d1(3yqYYoJw6i3ht+0R3#YE7VIM7mpNhgZqOZ zeqPmZYSESK7Kk~BT4Z?@oIOT?Z(=>w4Xt~}cyC}he<&34Vlc-k2es%LNX??9TE}~X zu0-g-Q;ix&?T_D5=i|)ddY~*iL66^E6ZDHUV?34D(l3BL;-i441@g*?0H8D7F6_B` z6&C=E-87I0x6WrIt}{O|<|ZtbdYV-op75UWXX>C*vv0b{*goPC#NEg3goD492msbY zb`kClPY6EYFgGBr$iB)u8*I_~Ym&HG9k2ZePZ{6`Q&umpYulD_hPlYRT5>v|ekW(Hj66JVh}ewMr*|!Vo&?bgwaw^s z^u8DA($*dnC2f2>m)9Ju=^1CpNiWKl%zdZ9oKa7h19r?q1V2Se^1c$NHbtJu){E5B zn)E}c%dS+>)gcM-E%j-8NBpyAUe69Rw;S=(H+`-h4dq633S60;p%Ag-%|~m(+QQ6G zMN3HO&tydhbrOTs9kf-`tPA_;DZ$|m?f%}&1zfqwA*zjjw81@aR^*ZXt;7#Z^GT^MJfg$H?P=QUN1u|V zUsS^tl%_cJdLW2XDQdGu;F&LCWPFD^m{)eDR`nj)&tG}SI96MC6OKl_>YS!KK0b!O zLbcCjmufHbTFi4TbwgAedtO{0BkE{@!aXZc?Ft-HuNJt>b8d(}9yh0_Wll>Vl$z&> zrJVs|MWb7WxHlzhkADt*D$E$v;o*E4dua~C&C-t7Y;m{)CsUIzliR(7WDgXy;*VK`|m~k#?4s7#9Y22CRHX1kLa#G0w;ik9ZWc66wo* z#E%;o1PukjR~#N;e4^h^5`M&GoKrsTU(`o@b7PD;HK*bOcyWMjpX41E_Gfu`qf$3r zfIfP34SY4MZ<+3J7oMEf;rWAT#D-bk;OWQC9%E2qf{#dl)v?#B+xWPt;`8&z#K(ge zG`r)^UgFvM3%Z@Urncm_L}ymj@TH_6M>nlj)1uqJ8?*nIlz(|`xVSfA^t8mG)$_W8 z8pNvBac%i&>sy<4M>M^}7v;=Jg0t+sp0-ZS7j?I-dNQGSZPJqtci1OnR8#HzxWtI) zu}#&=p=}zic2r87R82-hsmxruZlr5W13zvmpVGaQtd(|#J{gy^-rDS%8ur$ki5f5v zEUbg$5yT(yA=g#zh|4*DuN||;;SQMwvIxPG3#=pI>&%+4%ZJ{r`U-D|0LWw6{~M6h zv*7*PF97fa{Z?sqhbQzO20!Y_pZNSpcnf^wwOL-liI2*>yXneCPmEz1H#{1RB`y literal 0 HcmV?d00001 diff --git a/test/test_mediafile_basic.py b/test/test_mediafile_basic.py index decdfb9a2..91f663556 100644 --- a/test/test_mediafile_basic.py +++ b/test/test_mediafile_basic.py @@ -208,6 +208,15 @@ READ_ONLY_CORRECT_DICTS = { 'bitdepth': 0, 'channels': 1, }, + + 'full.alac.m4a': { + 'length': 1.0, + 'bitrate': 55072, + 'format': 'ALAC', + 'samplerate': 0, + 'bitdepth': 0, + 'channels': 0, + }, } TEST_FILES = { @@ -267,6 +276,9 @@ class AllFilesMixin(object): def test_wma(self): self._run('full', 'wma') + def test_alac(self): + self._run('full', 'alac.m4a') + # Special test for advanced release date. def test_date_mp3(self): self._run('date', 'mp3') @@ -429,6 +441,9 @@ class ReadOnlyTest(unittest.TestCase): def test_wma(self): self._run('full.wma') + def test_alac(self): + self._run('full.alac.m4a') + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From e6ac8e16461270d869e5472c5bdb3d1ccec489be Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sun, 2 Jun 2013 16:33:07 +1000 Subject: [PATCH 12/64] Use a Distance object instead of floats for distance calculations. The new Distance object knows how to perform various types of distance calculations (expression, equality, number, priority, string). It will keep track of each individual penalty that has been applied so that we can utilise that information in the UI and when making decisions about the recommendation level. We now display the top 3 penalties (sorted by weight) on the release list (and "..." if there are more than 3), and we display all penalties on the album info line and track change line. The implementation of the `max_rec` setting has been simplified by removing duplicate validation and instead looking at the penalties that have been applied to a distance. As a result, we can now configure a maximum recommendation for any penalty that might be applied. We have a few new checks when calculating album distance: `match: preferred: countries` and `match: preferred: media` can each be set to a list of countries and media in order of your preference. These are empty by default. A value that matches the first item will have no penalty, and a value that doesn't match any item will have an unweighted penalty of 1.0. If `match: preferred: original_year` is set to "yes", beets will apply an unweighted penalty of 1.0 for each year of difference between the release year and the original year. We now configure individual weights for `mediums` (disctotal), `label`, `catalognum`, `country` and `albumdisambig` instead of a single generic `minor` weight. This gives more control, but more importantly separates and names the applied penalties so that the UI can convey exactly which fields have contributed to the overall distance penalty. Likewise, `missing tracks` and `unmatched tracks` are penalised and displayed in the UI separately, instead of a combined `partial` penalty. Display non-MusicBrainz source in the disambiguation string, and "source" in the list of penalties if a release is penalised for being a non-MusicBrainz. --- beets/autotag/match.py | 408 ++++++++++++++++++++++++-------------- beets/config_default.yaml | 46 ++++- beets/plugins.py | 32 ++- beets/ui/commands.py | 76 ++++--- beetsplug/chroma.py | 11 +- beetsplug/discogs.py | 12 +- docs/changelog.rst | 34 ++-- docs/reference/config.rst | 75 ++++--- test/test_autotag.py | 122 ++++++++++++ test/test_ui.py | 17 +- 10 files changed, 553 insertions(+), 280 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8935165f3..7f2f01c56 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -30,7 +30,7 @@ from beets.util.enumeration import enum from beets.autotag import hooks # A configuration view for the distance weights. -weights = config['match']['weight'] +weights = config['match']['distance_weights'] # Parameters for string distance function. # Words that can be moved to the end of a string using a comma. @@ -187,62 +187,202 @@ def track_index_changed(item, track_info): """ return item.track not in (track_info.medium_index, track_info.index) +class Distance(object): + """Keeps track of multiple distance penalties. Provides a single weighted + distance for all penalties as well as a weighted distance for each + individual penalty. + """ + def __cmp__(self, other): + return cmp(self.distance, other) + + def __float__(self): + return self.distance + + def __getitem__(self, key): + """Returns the weighted distance for a named penalty. + """ + dist = sum(self.penalties[key]) * weights[key].as_number() + dist_max = self.max_distance + if dist_max: + return dist / dist_max + return 0.0 + + def __init__(self): + self.penalties = {} + + def __sub__(self, other): + return self.distance - other + + def __rsub__(self, other): + return other - self.distance + + def _eq(self, value1, value2): + """Returns True if `value1` is equal to `value2`. `value1` may be a + compiled regular expression, in which case it will be matched against + `value2`. + """ + if isinstance(value1, re._pattern_type): + return bool(value1.match(value2)) + return value1 == value2 + + def add(self, key, dist): + """Adds a distance penalty. `key` must correspond with a configured + weight setting. `dist` must be a float between 0.0 and 1.0, and will be + added to any existing distance penalties for the same key. + """ + if not 0.0 <= dist <= 1.0: + raise ValueError( + '`dist` must be between 0.0 and 1.0. It is: %r' % dist) + self.penalties.setdefault(key, []).append(dist) + + def add_equality(self, key, value, options): + """Adds a distance penalty of 1.0 if `value` doesn't match any of the + values in `options`. If an option is a compiled regular expression, it + will be considered equal if it matches against `value`. + """ + if not isinstance(options, (list, tuple)): + options = [options] + for opt in options: + if self._eq(opt, value): + dist = 0.0 + break + else: + dist = 1.0 + self.add(key, dist) + + def add_expr(self, key, expr): + """Adds a distance penalty of 1.0 if `expr` evaluates to True, or 0.0. + """ + if expr: + self.add(key, 1.0) + else: + self.add(key, 0.0) + + def add_number(self, key, number1, number2): + """Adds a distance penalty of 1.0 for each number of difference between + `number1` and `number2`, or 0.0 when there is no difference. Use this + when there is no upper limit on the difference between the two numbers. + """ + diff = abs(number1 - number2) + if diff: + for i in range(diff): + self.add(key, 1.0) + else: + self.add(key, 0.0) + + def add_priority(self, key, value, options): + """Adds a distance penalty that corresponds to the position at which + `value` appears in `options`. A distance penalty of 0.0 for the first + option, or 1.0 if there is no matching option. If an option is a + compiled regular expression, it will be considered equal if it matches + against `value`. + """ + if not isinstance(options, (list, tuple)): + options = [options] + unit = 1.0 / (len(options) + 1) + for i, opt in enumerate(options): + if self._eq(opt, value): + dist = i * unit + break + else: + dist = 1.0 + self.add(key, dist) + + def add_ratio(self, key, number1, number2): + """Adds a distance penalty for `number1` as a ratio of `number2`. + `number1` is bound at 0 and `number2`. + """ + number = float(max(min(number1, number2), 0)) + if number2: + dist = number / number2 + else: + dist = 0.0 + self.add(key, dist) + + def add_string(self, key, str1, str2): + """Adds a distance penalty based on the edit distance between `str1` + and `str2`. + """ + dist = string_dist(str1, str2) + self.add(key, dist) + + @property + def distance(self): + """Returns an overall weighted distance across all penalties. + """ + dist = 0.0 + for key, penalty in self.penalties.iteritems(): + dist += sum(penalty) * weights[key].as_number() + dist_max = self.max_distance + if dist_max: + return dist / dist_max + return 0.0 + + @property + def max_distance(self): + """Returns the maximum distance penalty. + """ + dist_max = 0.0 + for key, penalty in self.penalties.iteritems(): + dist_max += len(penalty) * weights[key].as_number() + return dist_max + + @property + def sorted(self): + """Returns a list of (dist, key) pairs, with `dist` being the weighted + distance, sorted from highest to lowest. + """ + list_ = [(self[key], key) for key in self.penalties] + return sorted(list_, key=lambda (dist, key): (0-dist, key)) + + def update(self, dist): + """Adds all the distance penalties from `dist`. + """ + if not isinstance(dist, Distance): + raise ValueError( + '`dist` must be a Distance object. It is: %r' % dist) + for key, penalties in dist.penalties.iteritems(): + self.penalties.setdefault(key, []).extend(penalties) + def track_distance(item, track_info, incl_artist=False): """Determines the significance of a track metadata change. Returns a - float in [0.0,1.0]. `incl_artist` indicates that a distance - component should be included for the track artist (i.e., for - various-artist releases). + Distance object. `incl_artist` indicates that a distance component should + be included for the track artist (i.e., for various-artist releases). """ - # Distance and normalization accumulators. - dist, dist_max = 0.0, 0.0 + dist = Distance() - # Check track length. - # If there's no length to check, apply no penalty. + # Length. if track_info.length: diff = abs(item.length - track_info.length) diff = max(diff - weights['track_length_grace'].as_number(), 0.0) diff = min(diff, weights['track_length_max'].as_number()) - dist += (diff / weights['track_length_max'].as_number()) * \ - weights['track_length'].as_number() - dist_max += weights['track_length'].as_number() + dist.add_ratio('track_length', diff, + weights['track_length_max'].as_number()) - # Track title. - dist += string_dist(item.title, track_info.title) * \ - weights['track_title'].as_number() - dist_max += weights['track_title'].as_number() + # Title. + dist.add_string('track_title', item.title, track_info.title) - # Track artist, if included. - # Attention: MB DB does not have artist info for all compilations, - # so only check artist distance if there is actually an artist in - # the MB track data. + # Artist. Only check if there is actually an artist in the track data. if incl_artist and track_info.artist and \ item.artist.lower() not in VA_ARTISTS: - dist += string_dist(item.artist, track_info.artist) * \ - weights['track_artist'].as_number() - dist_max += weights['track_artist'].as_number() + dist.add_string('track_artist', item.artist, track_info.artist) # Track index. if track_info.index and item.track: - if track_index_changed(item, track_info): - dist += weights['track_index'].as_number() - dist_max += weights['track_index'].as_number() + dist.add_expr('track_index', track_index_changed(item, track_info)) - # MusicBrainz track ID. + # Track ID. if item.mb_trackid: - if item.mb_trackid != track_info.track_id: - dist += weights['track_id'].as_number() - dist_max += weights['track_id'].as_number() + dist.add_expr('track_id', item.mb_trackid != track_info.track_id) - # Plugin distances. - plugin_d, plugin_dm = plugins.track_distance(item, track_info) - dist += plugin_d - dist_max += plugin_dm + # Plugins. + dist.update(plugins.track_distance(item, track_info)) - return dist / dist_max + return dist def distance(items, album_info, mapping): """Determines how "significant" an album metadata change would be. - Returns a float in [0.0,1.0]. `album_info` is an AlbumInfo object + Returns a Distance object. `album_info` is an AlbumInfo object reflecting the album to be compared. `items` is a sequence of all Item objects that will be matched (order is not important). `mapping` is a dictionary mapping Items to TrackInfo objects; the @@ -251,100 +391,89 @@ def distance(items, album_info, mapping): """ likelies, _ = current_metadata(items) - # These accumulate the possible distance components. The final - # distance will be dist/dist_max. - dist = 0.0 - dist_max = 0.0 + dist = Distance() - # Artist/album metadata. + # Artist, if not various. if not album_info.va: - dist += string_dist(likelies['artist'], album_info.artist) * \ - weights['artist'].as_number() - dist_max += weights['artist'].as_number() - dist += string_dist(likelies['album'], album_info.album) * \ - weights['album'].as_number() - dist_max += weights['album'].as_number() + dist.add_string('artist', likelies['artist'], album_info.artist) - # Year. No penalty for matching release or original year. - if likelies['year'] and album_info.year: - if likelies['year'] not in (album_info.year, album_info.original_year): - diff = abs(album_info.year - likelies['year']) - if diff: - dist += (1.0 - 1.0 / diff) * weights['year'].as_number() - dist_max += weights['year'].as_number() + # Album. + dist.add_string('album', likelies['album'], album_info.album) - # Actual or preferred media. - preferred_media = config['match']['preferred_media'].get() + # Media. if likelies['media'] and album_info.media: - dist += string_dist(likelies['media'], album_info.media) * \ - weights['media'].as_number() - dist_max += weights['media'].as_number() - elif album_info.media and preferred_media: - dist += string_dist(album_info.media, preferred_media) * \ - weights['media'].as_number() - dist_max += weights['media'].as_number() + dist.add_string('media', likelies['media'], album_info.media) - # MusicBrainz album ID. - if likelies['mb_albumid']: - if likelies['mb_albumid'] != album_info.album_id: - dist += weights['album_id'].as_number() - dist_max += weights['album_id'].as_number() + # Preferred media. + preferred_media = [re.compile(r'(\d+x)?%s' % pattern, re.I) for pattern + in config['match']['preferred']['media'].get()] + if album_info.media and preferred_media: + dist.add_priority('media', album_info.media, preferred_media) - # Apply a small penalty for differences across many minor metadata. This - # helps prioritise releases that are nearly identical. + # Number of discs. + if likelies['disctotal'] and album_info.mediums: + dist.add_number('mediums', likelies['disctotal'], album_info.mediums) - if likelies['disctotal']: - if likelies['disctotal'] != album_info.mediums: - dist += weights['minor'].as_number() - dist_max += weights['minor'].as_number() + # Year. + if likelies['year'] and album_info.year: + # No penalty for matching release or original year. + if likelies['year'] in (album_info.year, album_info.original_year): + dist.add('year', 0.0) + else: + dist.add_number('year', likelies['year'], album_info.year) - if likelies['label'] and album_info.label: - dist += string_dist(likelies['label'], album_info.label) * \ - weights['minor'].as_number() - dist_max += weights['minor'].as_number() - - if likelies['catalognum'] and album_info.catalognum: - dist += string_dist(likelies['catalognum'], - album_info.catalognum) * \ - weights['minor'].as_number() - dist_max += weights['minor'].as_number() + # Prefer earlier releases. + if album_info.year and album_info.original_year and \ + config['match']['preferred']['original_year'].get(): + dist.add_number('year', album_info.year, album_info.original_year) + # Country. if likelies['country'] and album_info.country: - dist += string_dist(likelies['country'], - album_info.country) * \ - weights['minor'].as_number() - dist_max += weights['minor'].as_number() + dist.add_string('country', likelies['country'], album_info.country) + # Preferred countries. + preferred_countries = [re.compile(pattern, re.I) for pattern + in config['match']['preferred']['countries'].get()] + if album_info.country and preferred_countries: + dist.add_priority('country', album_info.country, preferred_countries) + + # Label. + if likelies['label'] and album_info.label: + dist.add_string('label', likelies['label'], album_info.label) + + # Catalog number. + if likelies['catalognum'] and album_info.catalognum: + dist.add_string('catalognum', likelies['catalognum'], + album_info.catalognum) + + # Disambiguation. if likelies['albumdisambig'] and album_info.albumdisambig: - dist += string_dist(likelies['albumdisambig'], - album_info.albumdisambig) * \ - weights['minor'].as_number() - dist_max += weights['minor'].as_number() + dist.add_string('albumdisambig', likelies['albumdisambig'], + album_info.albumdisambig) - # Matched track distances. + # Album ID. + if likelies['mb_albumid']: + dist.add_equality('album_id', likelies['mb_albumid'], + album_info.album_id) + + # Tracks. + dist.tracks = {} for item, track in mapping.iteritems(): - dist += track_distance(item, track, album_info.va) * \ - weights['track'].as_number() - dist_max += weights['track'].as_number() + dist.tracks[track] = track_distance(item, track, album_info.va) + dist.add('tracks', dist.tracks[track].distance) - # Extra and unmatched tracks. - for track in set(album_info.tracks) - set(mapping.values()): - dist += weights['missing'].as_number() - dist_max += weights['missing'].as_number() - for item in set(items) - set(mapping.keys()): - dist += weights['unmatched'].as_number() - dist_max += weights['unmatched'].as_number() + # Missing tracks. + for i in range(len(album_info.tracks) - len(mapping)): + dist.add('missing_tracks', 1.0) - # Plugin distances. - plugin_d, plugin_dm = plugins.album_distance(items, album_info, mapping) - dist += plugin_d - dist_max += plugin_dm + # Unmatched tracks. + for i in range(len(items) - len(mapping)): + dist.add('unmatched_tracks', 1.0) - # Normalize distance, avoiding divide-by-zero. - if dist_max == 0.0: - return 0.0 - else: - return dist / dist_max + # Plugins. + dist.update(plugins.album_distance(items, album_info, mapping)) + + return dist def match_by_id(items): """If the items are tagged with a MusicBrainz album ID, returns an @@ -370,8 +499,8 @@ def _recommendation(results): recommendation based on the results' distances. If the recommendation is higher than the configured maximum for - certain situations, the recommendation will be downgraded to the - configured maximum. + an applied penalty, the recommendation will be downgraded to the + configured maximum for that penalty. """ if not results: # No candidates: no recommendation. @@ -393,45 +522,20 @@ def _recommendation(results): # Gap between first two candidates is large. rec = recommendation.low else: - # No conclusion. - rec = recommendation.none + # No conclusion. Return immediately. Can't be downgraded any further. + return recommendation.none - # "Downgrades" in certain configured situations. - if isinstance(results[0], hooks.AlbumMatch): - # Load the configured recommendation maxima. - max_rec = {} - for trigger in 'non_mb_source', 'partial', 'tracklength', 'tracknumber': - max_rec[trigger] = \ - config['match']['max_rec'][trigger].as_choice({ - 'strong': recommendation.strong, - 'medium': recommendation.medium, - 'low': recommendation.low, - 'none': recommendation.none, - }) - - # Non-MusicBrainz source. - if rec > max_rec['non_mb_source'] and \ - results[0].info.data_source != 'MusicBrainz': - rec = max_rec['non_mb_source'] - - # Partial match. - if rec > max_rec['partial'] and \ - (results[0].extra_items or results[0].extra_tracks): - rec = max_rec['partial'] - - # Check track number and duration for each item. - for item, track_info in results[0].mapping.items(): - # Track length differs. - if rec > max_rec['tracklength'] and \ - item.length and track_info.length and \ - abs(item.length - track_info.length) > \ - weights['track_length_grace'].as_number(): - rec = max_rec['tracklength'] - - # Track number differs. - if rec > max_rec['tracknumber'] and \ - track_index_changed(item, track_info): - rec = max_rec['tracknumber'] + # Downgrade to the max rec if it is lower than the current rec for an + # applied penalty. + for dist, key in results[0].distance.sorted: + if dist: + max_rec = config['match']['max_rec'][key].as_choice({ + 'strong': recommendation.strong, + 'medium': recommendation.medium, + 'low': recommendation.low, + 'none': recommendation.none, + }) + rec = min(rec, max_rec) return rec @@ -465,7 +569,7 @@ def tag_album(items, search_artist=None, search_album=None, - The current artist. - The current album. - A list of AlbumMatch objects. The candidates are sorted by - distance (i.e., best match first). + distance (i.e., best match first). - A recommendation. If search_artist and search_album or search_id are provided, then they are used as search terms in place of the current metadata. diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 7bbb16a6b..7b9867813 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -68,22 +68,42 @@ match: medium_rec_thresh: 0.25 rec_gap_thresh: 0.25 max_rec: - non_mb_source: strong - partial: medium - tracklength: strong - tracknumber: strong - preferred_media: CD - weight: + source: strong + artist: strong + album: strong + media: strong + mediums: strong + year: strong + country: strong + label: strong + catalognum: strong + albumdisambig: strong + album_id: strong + tracks: strong + missing_tracks: medium + unmatched_tracks: medium + track_title: strong + track_artist: strong + track_index: strong + track_length_grace: strong + track_length_max: strong + track_length: strong + track_id: strong + distance_weights: source: 2.0 artist: 3.0 album: 3.0 - year: 1.0 media: 1.0 + mediums: 1.0 + year: 1.0 + country: 0.5 + label: 0.5 + catalognum: 0.5 + albumdisambig: 0.5 album_id: 5.0 - minor: 0.5 - track: 1.0 - missing: 0.9 - unmatched: 0.6 + tracks: 2.0 + missing_tracks: 0.9 + unmatched_tracks: 0.6 track_title: 3.0 track_artist: 2.0 track_index: 1.0 @@ -91,3 +111,7 @@ match: track_length_max: 30 track_length: 2.0 track_id: 5.0 + preferred: + countries: [] + media: [] + original_year: no diff --git a/beets/plugins.py b/beets/plugins.py index 7d49ad3aa..d0c0a9654 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -64,16 +64,16 @@ class BeetsPlugin(object): return {} def track_distance(self, item, info): - """Should return a (distance, distance_max) pair to be added - to the distance value for every track comparison. + """Should return a Distance object to be added to the + distance for every track comparison. """ - return 0.0, 0.0 + return beets.autotag.match.Distance() def album_distance(self, items, album_info, mapping): - """Should return a (distance, distance_max) pair to be added - to the distance value for every album-level comparison. + """Should return a Distance object to be added to the + distance for every album-level comparison. """ - return 0.0, 0.0 + return beets.autotag.match.Distance() def candidates(self, items, artist, album, va_likely): """Should return a sequence of AlbumInfo objects that match the @@ -242,25 +242,19 @@ def queries(): def track_distance(item, info): """Gets the track distance calculated by all loaded plugins. - Returns a (distance, distance_max) pair. + Returns a Distance object. """ - dist = 0.0 - dist_max = 0.0 + dist = beets.autotag.match.Distance() for plugin in find_plugins(): - d, dm = plugin.track_distance(item, info) - dist += d - dist_max += dm - return dist, dist_max + dist.update(plugin.track_distance(item, info)) + return dist def album_distance(items, album_info, mapping): """Returns the album distance calculated by plugins.""" - dist = 0.0 - dist_max = 0.0 + dist = beets.autotag.match.Distance() for plugin in find_plugins(): - d, dm = plugin.album_distance(items, album_info, mapping) - dist += d - dist_max += dm - return dist, dist_max + dist.update(plugin.album_distance(items, album_info, mapping)) + return dist def candidates(items, artist, album, va_likely): """Gets MusicBrainz candidates for an album from each plugin. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 9e42751ab..e306256d4 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -125,14 +125,14 @@ default_commands.append(fields_cmd) VARIOUS_ARTISTS = u'Various Artists' -PARTIAL_MATCH_MESSAGE = u'(partial match!)' - # Importer utilities and support. def disambig_string(info): - """Returns label, year and media disambiguation, if available. + """Returns source, media, year, country, and album disambiguation. """ disambig = [] + if info.data_source != 'MusicBrainz': + disambig.append(info.data_source) if info.media: if info.mediums > 1: disambig.append(u'{0}x{1}'.format( @@ -163,26 +163,35 @@ def dist_string(dist): out = ui.colorize('red', out) return out +def penalty_string(distance, limit=None): + """Returns a colorized string that indicates all the penalties applied to + a distance object. + """ + penalties = [] + for dist, key in distance.sorted: + if dist: + key = key.replace('album_', '') + key = key.replace('track_', '') + key = key.replace('_', ' ') + penalties.append(key) + if penalties: + if limit and len(penalties) > limit: + penalties = penalties[:limit] + ['...'] + return ui.colorize('yellow', '(%s)' % ', '.join(penalties)) + def show_change(cur_artist, cur_album, match): """Print out a representation of the changes that will be made if an album's tags are changed according to `match`, which must be an AlbumMatch object. """ - def show_album(artist, album, partial=False): + def show_album(artist, album): if artist: album_description = u' %s - %s' % (artist, album) elif album: album_description = u' %s' % album else: album_description = u' (unknown album)' - - out = album_description - - # Add a suffix if this is a partial match. - if partial: - out += u' %s' % ui.colorize('yellow', PARTIAL_MATCH_MESSAGE) - - print_(out) + print_(album_description) def format_index(track_info): """Return a string representing the track index of the given @@ -223,11 +232,7 @@ def show_change(cur_artist, cur_album, match): print_("To:") show_album(artist_r, album_r) else: - message = u"Tagging:\n %s - %s" % (match.info.artist, - match.info.album) - if match.extra_items or match.extra_tracks: - message += u' %s' % ui.colorize('yellow', PARTIAL_MATCH_MESSAGE) - print_(message) + print_(u"Tagging:\n %s - %s" % (match.info.artist, match.info.album)) # Data URL. if match.info.data_url: @@ -235,9 +240,13 @@ def show_change(cur_artist, cur_album, match): # Info line. info = [] + # Similarity. info.append('(Similarity: %s)' % dist_string(match.distance)) - if match.info.data_source != 'MusicBrainz': - info.append(ui.colorize('turquoise', '(%s)' % match.info.data_source)) + # Penalties. + penalties = penalty_string(match.distance) + if penalties: + info.append(penalties) + # Disambiguation. disambig = disambig_string(match.info) if disambig: info.append(ui.colorize('lightgray', '(%s)' % disambig)) @@ -315,18 +324,10 @@ def show_change(cur_artist, cur_album, match): rhs += templ.format(rhs_length) lhs_width += len(cur_length) + 3 - # Hidden penalties. No LHS/RHS diff is displayed, but we still want to - # indicate that a penalty has been applied to explain the similarity - # score. - penalties = [] - if match.info.va and track_info.artist and \ - item.artist.lower() not in VA_ARTISTS: - penalties.append('artist') - if item.mb_trackid and item.mb_trackid != track_info.track_id: - penalties.append('ID') + # Penalties. + penalties = penalty_string(match.distance.tracks[track_info]) if penalties: - rhs += ' %s' % ui.colorize('red', - '(%s)' % ', '.join(penalties)) + rhs += ' %s' % penalties if lhs != rhs: lines.append((' * %s' % lhs, rhs, lhs_width)) @@ -489,20 +490,17 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, (cur_artist, cur_album)) print_('Candidates:') for i, match in enumerate(candidates): + # Artist, album and distance. line = ['%i. %s - %s (%s)' % (i + 1, match.info.artist, match.info.album, dist_string(match.distance))] - # Point out the partial matches. - if match.extra_items or match.extra_tracks: - line.append(ui.colorize('yellow', - PARTIAL_MATCH_MESSAGE)) - - # Sources other than MusicBrainz. - source = match.info.data_source - if source != 'MusicBrainz': - line.append(ui.colorize('turquoise', '(%s)' % source)) + # Penalties. + penalties = penalty_string(match.distance, 3) + if penalties: + line.append(penalties) + # Disambiguation disambig = disambig_string(match.info) if disambig: line.append(ui.colorize('lightgray', '(%s)' % disambig)) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 08a78e3af..006f85db0 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -21,6 +21,7 @@ from beets import util from beets import config from beets.util import confit from beets.autotag import hooks +from beets.autotag.match import Distance import acoustid import logging from collections import defaultdict @@ -113,16 +114,14 @@ def _all_releases(items): class AcoustidPlugin(plugins.BeetsPlugin): def track_distance(self, item, info): + dist = Distance() if item.path not in _matches or not info.track_id: # Match failed or no track ID. - return 0.0, 0.0 + return dist recording_ids, _ = _matches[item.path] - if info.track_id in recording_ids: - dist = 0.0 - else: - dist = TRACK_ID_WEIGHT - return dist, TRACK_ID_WEIGHT + dist.add_expr('track_id', info.track_id not in recording_ids) + return dist def candidates(self, items, artist, album, va_likely): albums = [] diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index bb8d37146..822ed59e3 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -17,7 +17,7 @@ discogs-client library. """ from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.autotag.match import current_metadata, VA_ARTISTS +from beets.autotag.match import current_metadata, Distance, VA_ARTISTS from beets.plugins import BeetsPlugin from discogs_client import Artist, DiscogsAPIError, Release, Search import beets @@ -44,14 +44,12 @@ class DiscogsPlugin(BeetsPlugin): }) def album_distance(self, items, album_info, mapping): - """Returns the discogs source weight and the maximum source weight. + """Returns the album distance. """ + dist = Distance() if album_info.data_source == 'Discogs': - return self.config['source_weight'].as_number() * \ - config['match']['weight']['source'].as_number(), \ - config['match']['weight']['source'].as_number() - else: - return 0.0, 0.0 + 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 diff --git a/docs/changelog.rst b/docs/changelog.rst index 0f8b08b51..527982190 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,30 +53,36 @@ Changelog None. * Various UI enhancements to the importer due to Tai Lee: - * More consistent format and colorization of album and track metadata. - * Display data source URL for :doc:`/plugins/discogs` matches. This should - make it easier for people who would rather import and correct data from - Discogs into MusicBrainz. + * Display data source URL and source name in album disambiguation for + non-MusicBrainz matches. This should make it easier for people who want to + import and correct data from other sources into MusicBrainz. + * The top 3 distance penalties are now displayed on the release listing, + and all album and track penalties are now displayed on the track changes + list. This should make it clear exactly which metadata is contributing to a + low similarity score. * Display album disambiguation and disc titles in the track listing, when available. - * Track changes highlighted in yellow indicate a change in format to or from - :ref:`per_disc_numbering`. No penalty is applied because the track number - is still "correct", just in a different format. + * More consistent format and colorization of album and track metadata. + * Track changes highlighted in turquoise indicate a change in format to or + from :ref:`per_disc_numbering`. No penalty is applied because the track + number is still "correct", just in a different format. * Sort missing and unmatched tracks by index and title and group them together for better readability. - * Indicate MusicBrainz ID mismatches. -* Improve calculation of similarity score: +* Improve calculation of similarity score and recommendation: + * It is now possible to configure a :ref:`max_rec` for any field that is used + to calculate the similarity score. The recommendation will be downgraded if + a penalty is being applied to the specified field. * Strongly prefer releases with a matching MusicBrainz album ID. This helps beets re-identify the same release when re-importing existing files. * Prefer releases that are closest to the tagged ``year``. Tolerate files tagged with release or original year. - * Prefer CD releases by default, when there is no ``media`` tagged in the - files being imported. This can be changed with the :ref:`preferred_media` - setting. - * Apply minor penalties across a range of fields to differentiate between - nearly identical releases: ``disctotal``, ``label``, ``catalognum``, + * Add a :ref:`preferred` collection of settings, which allow the user to + specify a sorted list of preferred countries and media types, or prefer + releases closest to the original year for an album. + * Apply minor distance penalties across a range of fields to differentiate + between nearly identical releases: ``mediums``, ``label``, ``catalognum``, ``country`` and ``albumdisambig``. .. _Discogs: http://discogs.com/ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index d23db6b02..ec194afde 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -394,40 +394,65 @@ max_rec As mentioned above, autotagger matches have *recommendations* that control how the UI behaves for a certain quality of match. The recommendation for a certain -match is usually based on the distance calculation. But you can also control -the recommendation for certain specific situations by defining *maximum* -recommendations when: +match is based on the overall distance calculation. But you can also control +the recommendation when a distance penalty is being applied for a specific +field by defining *maximum* recommendations for each field: -* a match came from a source other than MusicBrainz (e.g., the - :doc:`Discogs ` plugin); -* a match has missing or extra tracks; -* the length (duration) of at least one track differs; or -* at least one track number differs. - -To define maxima, use keys under ``max_rec:`` in the ``match`` section:: +To define maxima, use keys under ``max_rec:`` in the ``match`` section. Here +are the defaults:: match: max_rec: - non_mb_source: strong - partial: medium - tracklength: strong - tracknumber: strong + source: strong + artist: strong + album: strong + media: strong + mediums: strong + year: strong + country: strong + label: strong + catalognum: strong + albumdisambig: strong + album_id: strong + tracks: strong + missing_tracks: medium + unmatched_tracks: medium + track_title: strong + track_artist: strong + track_index: strong + track_length_grace: strong + track_length_max: strong + track_length: strong + track_id: strong -If a recommendation is higher than the configured maximum and the condition is -met, the recommendation will be downgraded. The maximum for each condition can -be one of ``none``, ``low``, ``medium`` or ``strong``. When the maximum -recommendation is ``strong``, no "downgrading" occurs for that situation. +If a recommendation is higher than the configured maximum and a penalty is +being applied, the recommendation will be downgraded. The maximum for each +field can be one of ``none``, ``low``, ``medium`` or ``strong``. When the +maximum recommendation is ``strong``, no "downgrading" occurs. -The above example shows the default ``max_rec`` settings. +.. _preferred: -.. _preferred_media: +preferred +~~~~~~~~~ -preferred_media -~~~~~~~~~~~~~~~ +In addition to comparing the tagged metadata with the match metadata for +similarity, you can also specify an ordered list of preferred countries and +media types. A distance penalty will be applied if the country or media type +from the match metadata doesn't match. The order is important, the first item +will be most preferred. -When comparing files that have no ``media`` tagged, prefer releases that more -closely resemble this media (using a string distance). When files are already -tagged with media, this setting is ignored. Default: ``CD``. +You can also tell the autotagger to prefer matches that have a release year +closest to the original year for an album. + +Here's an example:: + + match: + preferred: + countries: ['US', 'GB', 'UK'] + media: ['CD', 'Digital Media'] + original_year: yes + +By default, none of these options are enabled. .. _path-format-config: diff --git a/test/test_autotag.py b/test/test_autotag.py index 1a6188e7c..92088a7b8 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -23,6 +23,7 @@ import _common from _common import unittest from beets import autotag from beets.autotag import match +from beets.autotag.match import Distance from beets.library import Item from beets.util import plurality from beets.autotag import AlbumInfo, TrackInfo @@ -105,6 +106,127 @@ def _make_trackinfo(): TrackInfo(u'three', None, u'some artist', length=1, index=3), ] +class DistanceTest(unittest.TestCase): + def setUp(self): + self.dist = Distance() + + def test_add(self): + self.dist.add('add', 1.0) + self.assertEqual(self.dist.penalties, {'add': [1.0]}) + + def test_add_equality(self): + self.dist.add_equality('equality', 'ghi', ['abc', 'def', 'ghi']) + self.assertEqual(self.dist.penalties['equality'], [0.0]) + + self.dist.add_equality('equality', 'xyz', ['abc', 'def', 'ghi']) + self.assertEqual(self.dist.penalties['equality'], [0.0, 1.0]) + + self.dist.add_equality('equality', 'abc', re.compile(r'ABC', re.I)) + self.assertEqual(self.dist.penalties['equality'], [0.0, 1.0, 0.0]) + + def test_add_expr(self): + self.dist.add_expr('expr', True) + self.assertEqual(self.dist.penalties['expr'], [1.0]) + + self.dist.add_expr('expr', False) + self.assertEqual(self.dist.penalties['expr'], [1.0, 0.0]) + + def test_add_number(self): + # Add a full penalty for each number of difference between two numbers. + + self.dist.add_number('number', 1, 1) + self.assertEqual(self.dist.penalties['number'], [0.0]) + + self.dist.add_number('number', 1, 2) + self.assertEqual(self.dist.penalties['number'], [0.0, 1.0]) + + self.dist.add_number('number', 2, 1) + self.assertEqual(self.dist.penalties['number'], [0.0, 1.0, 1.0]) + + self.dist.add_number('number', -1, 2) + self.assertEqual(self.dist.penalties['number'], [0.0, 1.0, 1.0, 1.0, + 1.0, 1.0]) + + def test_add_priority(self): + self.dist.add_priority('priority', 'abc', 'abc') + self.assertEqual(self.dist.penalties['priority'], [0.0]) + + self.dist.add_priority('priority', 'def', ['abc', 'def', 'ghi']) + self.assertEqual(self.dist.penalties['priority'], [0.0, 0.25]) + + self.dist.add_priority('priority', 'ghi', ['abc', 'def', + re.compile('GHI', re.I)]) + self.assertEqual(self.dist.penalties['priority'], [0.0, 0.25, 0.5]) + + self.dist.add_priority('priority', 'xyz', ['abc', 'def']) + self.assertEqual(self.dist.penalties['priority'], [0.0, 0.25, 0.5, 1.0]) + + def test_add_ratio(self): + self.dist.add_ratio('ratio', 25, 100) + self.assertEqual(self.dist.penalties['ratio'], [0.25]) + + self.dist.add_ratio('ratio', 10, 5) + self.assertEqual(self.dist.penalties['ratio'], [0.25, 1.0]) + + self.dist.add_ratio('ratio', -5, 5) + self.assertEqual(self.dist.penalties['ratio'], [0.25, 1.0, 0.0]) + + self.dist.add_ratio('ratio', 5, 0) + self.assertEqual(self.dist.penalties['ratio'], [0.25, 1.0, 0.0, 0.0]) + + def test_add_string(self): + dist = match.string_dist(u'abc', u'bcd') + self.dist.add_string('string', u'abc', u'bcd') + self.assertEqual(self.dist.penalties['string'], [dist]) + + def test_distance(self): + config['match']['distance_weights']['album'] = 2.0 + config['match']['distance_weights']['medium'] = 1.0 + self.dist.add('album', 0.5) + self.dist.add('media', 0.25) + self.dist.add('media', 0.75) + self.assertEqual(self.dist.distance, 0.5) + + # __getitem__() + self.assertEqual(self.dist['album'], 0.25) + self.assertEqual(self.dist['media'], 0.25) + + def test_max_distance(self): + config['match']['distance_weights']['album'] = 3.0 + config['match']['distance_weights']['medium'] = 1.0 + self.dist.add('album', 0.5) + self.dist.add('medium', 0.0) + self.dist.add('medium', 0.0) + self.assertEqual(self.dist.max_distance, 5.0) + + def test_sorted(self): + config['match']['distance_weights']['album'] = 4.0 + config['match']['distance_weights']['medium'] = 2.0 + + self.dist.add('album', 0.1875) + self.dist.add('medium', 0.75) + self.assertEqual(self.dist.sorted, [(0.25, 'medium'), (0.125, 'album')]) + + # Sort by key if distance is equal. + dist = Distance() + dist.add('album', 0.375) + dist.add('medium', 0.75) + self.assertEqual(dist.sorted, [(0.25, 'album'), (0.25, 'medium')]) + + def test_update(self): + self.dist.add('album', 0.5) + self.dist.add('media', 1.0) + + dist = Distance() + dist.add('album', 0.75) + dist.add('album', 0.25) + self.dist.add('media', 0.05) + + self.dist.update(dist) + + self.assertEqual(self.dist.penalties, {'album': [0.5, 0.75, 0.25], + 'media': [1.0, 0.05]}) + class TrackDistanceTest(unittest.TestCase): def test_identical_tracks(self): item = _make_item(u'one', 1) diff --git a/test/test_ui.py b/test/test_ui.py index b679021f7..bfdd53ddd 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -27,6 +27,7 @@ from beets import library from beets import ui from beets.ui import commands from beets import autotag +from beets.autotag.match import distance from beets import importer from beets.mediafile import MediaFile from beets import config @@ -594,21 +595,23 @@ class ShowChangeTest(_common.TestCase): self.items[0].track = 1 self.items[0].path = '/path/to/file.mp3' self.info = autotag.AlbumInfo( - 'the album', 'album id', 'the artist', 'artist id', [ - autotag.TrackInfo('the title', 'track id', index=1) + u'the album', u'album id', u'the artist', u'artist id', [ + autotag.TrackInfo(u'the title', u'track id', index=1) ]) def _show_change(self, items=None, info=None, - cur_artist='the artist', cur_album='the album', + cur_artist=u'the artist', cur_album=u'the album', dist=0.1): items = items or self.items info = info or self.info mapping = dict(zip(items, info.tracks)) config['color'] = False + album_dist = distance(items, info, mapping) + album_dist.penalties = {'album': [dist]} commands.show_change( cur_artist, cur_album, - autotag.AlbumMatch(0.1, info, mapping, set(), set()), + autotag.AlbumMatch(album_dist, info, mapping, set(), set()), ) return self.io.getoutput().lower() @@ -623,7 +626,7 @@ class ShowChangeTest(_common.TestCase): self.assertTrue('correcting tags from:' in msg) def test_item_data_change(self): - self.items[0].title = 'different' + self.items[0].title = u'different' msg = self._show_change() self.assertTrue('different -> the title' in msg) @@ -638,12 +641,12 @@ class ShowChangeTest(_common.TestCase): self.assertTrue('correcting tags from:' in msg) def test_item_data_change_title_missing(self): - self.items[0].title = '' + self.items[0].title = u'' msg = re.sub(r' +', ' ', self._show_change()) self.assertTrue('file.mp3 -> the title' in msg) def test_item_data_change_title_missing_with_unicode_filename(self): - self.items[0].title = '' + self.items[0].title = u'' self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8') msg = re.sub(r' +', ' ', self._show_change().decode('utf8')) self.assertTrue(u'caf\xe9.mp3 -> the title' in msg From f2a924fb56f4fc90edc145eb16ab378ec63275e0 Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Sun, 2 Jun 2013 11:55:44 +0200 Subject: [PATCH 13/64] Bugfixes in Beatport plugin --- beetsplug/beatport.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index ca61e0624..73514f298 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -32,13 +32,6 @@ class BeatportAPIError(Exception): class BeatportObject(object): - beatport_id = None - name = None - release_date = None - artists = [] - genres = [] - url = None - def __init__(self, data): self.beatport_id = data['id'] self.name = unicode(data['name']) @@ -98,9 +91,6 @@ class BeatportSearch(object): class BeatportRelease(BeatportObject): API_ENDPOINT = 'catalog/3/beatport/release' - catalog_number = None - label_name = None - category = None def __unicode__(self): if len(self.artists) < 4: @@ -135,10 +125,7 @@ class BeatportRelease(BeatportObject): class BeatportTrack(BeatportObject): - API_ENDPOINT = 'catalog/3/beatport/release' - title = None - mix_name = None - length = None + API_ENDPOINT = 'catalog/3/beatport/track' def __unicode__(self): artist_str = ", ".join(x[1] for x in self.artists) From 6c3e38863be5ed5c062447f8c38674ecd8da4fa8 Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Sun, 2 Jun 2013 11:55:57 +0200 Subject: [PATCH 14/64] Add documentation for Beatport plugin --- docs/plugins/beatport.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/plugins/beatport.rst diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst new file mode 100644 index 000000000..d74c6f8d6 --- /dev/null +++ b/docs/plugins/beatport.rst @@ -0,0 +1,26 @@ +Beatport Plugin +============== + +The ``beatport`` plugin adds support for querying the `Beatport`_ catalogue +during the autotagging process. This can potentially be helpful for users +whose collection includes a lot of diverse electronic music releases, for which +both MusicBrainz and (to a lesser degree) Discogs show no matches. + +.. _Beatport: http://beatport.com + +Installation +------------ + +To see matches from the ``beatport`` plugin, you first have to enable it in +your configuration (see :doc:`/plugins/index`). Then, install the `requests`_ +library (which we need for querying the Beatport API) by typing:: + + pip install requests + +And you're done. Matches from Beatport should now show up alongside matches +from MusicBrainz and other sources. + +If you have a Beatport ID or a URL for a release or track you want to tag, you +can just enter one of the two at the "enter Id" prompt in the importer. + +.. _requests: http://docs.python-requests.org/en/latest/ From 4de5d36b71bc8355476091d08611a148d6c44fbb Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sun, 2 Jun 2013 22:29:48 +1000 Subject: [PATCH 15/64] Use `add_ratio()` for year penalties, with the difference between now and the original year as the max. --- beets/autotag/match.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 7f2f01c56..bbfae4134 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -420,12 +420,17 @@ def distance(items, album_info, mapping): if likelies['year'] in (album_info.year, album_info.original_year): dist.add('year', 0.0) else: - dist.add_number('year', likelies['year'], album_info.year) + diff = abs(likelies['year'] - album_info.year) + diff_max = abs(datetime.date.today().year - + album_info.original_year) + dist.add_ratio('year', diff, diff_max) # Prefer earlier releases. if album_info.year and album_info.original_year and \ config['match']['preferred']['original_year'].get(): - dist.add_number('year', album_info.year, album_info.original_year) + diff = abs(album_info.year - album_info.original_year) + diff_max = abs(datetime.date.today().year - album_info.original_year) + dist.add_ratio('year', diff, diff_max) # Country. if likelies['country'] and album_info.country: From 083575314d45e18978ea40c8626aa6c8ef68b91e Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sun, 2 Jun 2013 22:31:28 +1000 Subject: [PATCH 16/64] Remove redundant max/min calculations for track length distance. `add_ratio()` already does this. --- beets/autotag/match.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index bbfae4134..4f59680e8 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -353,9 +353,8 @@ def track_distance(item, track_info, incl_artist=False): # Length. if track_info.length: - diff = abs(item.length - track_info.length) - diff = max(diff - weights['track_length_grace'].as_number(), 0.0) - diff = min(diff, weights['track_length_max'].as_number()) + diff = abs(item.length - track_info.length) - \ + weights['track_length_grace'].as_number() dist.add_ratio('track_length', diff, weights['track_length_max'].as_number()) From 3254f2f3b0b63e594cabb6409507e214955e15b9 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sun, 2 Jun 2013 22:53:53 +1000 Subject: [PATCH 17/64] Don't assume all releases know the original year. Use `add_ratio()` if they do, otherwise apply full penalty with `add()`. --- beets/autotag/match.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 4f59680e8..8762d3bf9 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -415,14 +415,18 @@ def distance(items, album_info, mapping): # Year. if likelies['year'] and album_info.year: - # No penalty for matching release or original year. if likelies['year'] in (album_info.year, album_info.original_year): + # No penalty for matching release or original year. dist.add('year', 0.0) - else: + elif album_info.original_year: + # Prefer matchest closest to the release year. diff = abs(likelies['year'] - album_info.year) diff_max = abs(datetime.date.today().year - album_info.original_year) dist.add_ratio('year', diff, diff_max) + else: + # Full penalty when there is no original year. + dist.add('year', 1.0) # Prefer earlier releases. if album_info.year and album_info.original_year and \ From 1b5d3c057f0100049327b581897869f078857eab Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sun, 2 Jun 2013 22:54:48 +1000 Subject: [PATCH 18/64] Code style. --- beets/autotag/match.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8762d3bf9..0d270d238 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -409,7 +409,7 @@ def distance(items, album_info, mapping): if album_info.media and preferred_media: dist.add_priority('media', album_info.media, preferred_media) - # Number of discs. + # Mediums. if likelies['disctotal'] and album_info.mediums: dist.add_number('mediums', likelies['disctotal'], album_info.mediums) @@ -430,7 +430,7 @@ def distance(items, album_info, mapping): # Prefer earlier releases. if album_info.year and album_info.original_year and \ - config['match']['preferred']['original_year'].get(): + config['match']['preferred']['original_year']: diff = abs(album_info.year - album_info.original_year) diff_max = abs(datetime.date.today().year - album_info.original_year) dist.add_ratio('year', diff, diff_max) From f6492e68eee8fbdaa67a02345291a5edfab33500 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sun, 2 Jun 2013 23:16:28 +1000 Subject: [PATCH 19/64] Doc string update. --- beets/ui/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index e306256d4..18b539083 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -128,7 +128,7 @@ VARIOUS_ARTISTS = u'Various Artists' # Importer utilities and support. def disambig_string(info): - """Returns source, media, year, country, and album disambiguation. + """Returns source, media, year, country, label and album disambiguation. """ disambig = [] if info.data_source != 'MusicBrainz': From 51f40d26dc2812d4834799ae44e500195e3bbd3b Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:04:45 +1000 Subject: [PATCH 20/64] Rename `Distance.penalties` to `Distance._penalties`. It should be private. --- beets/autotag/match.py | 16 +++++++------- test/test_autotag.py | 47 +++++++++++++++++++++--------------------- test/test_ui.py | 2 +- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 0d270d238..d6eab98d5 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -201,14 +201,14 @@ class Distance(object): def __getitem__(self, key): """Returns the weighted distance for a named penalty. """ - dist = sum(self.penalties[key]) * weights[key].as_number() + dist = sum(self._penalties[key]) * weights[key].as_number() dist_max = self.max_distance if dist_max: return dist / dist_max return 0.0 def __init__(self): - self.penalties = {} + self._penalties = {} def __sub__(self, other): return self.distance - other @@ -233,7 +233,7 @@ class Distance(object): if not 0.0 <= dist <= 1.0: raise ValueError( '`dist` must be between 0.0 and 1.0. It is: %r' % dist) - self.penalties.setdefault(key, []).append(dist) + self._penalties.setdefault(key, []).append(dist) def add_equality(self, key, value, options): """Adds a distance penalty of 1.0 if `value` doesn't match any of the @@ -311,7 +311,7 @@ class Distance(object): """Returns an overall weighted distance across all penalties. """ dist = 0.0 - for key, penalty in self.penalties.iteritems(): + for key, penalty in self._penalties.iteritems(): dist += sum(penalty) * weights[key].as_number() dist_max = self.max_distance if dist_max: @@ -323,7 +323,7 @@ class Distance(object): """Returns the maximum distance penalty. """ dist_max = 0.0 - for key, penalty in self.penalties.iteritems(): + for key, penalty in self._penalties.iteritems(): dist_max += len(penalty) * weights[key].as_number() return dist_max @@ -332,7 +332,7 @@ class Distance(object): """Returns a list of (dist, key) pairs, with `dist` being the weighted distance, sorted from highest to lowest. """ - list_ = [(self[key], key) for key in self.penalties] + list_ = [(self[key], key) for key in self._penalties] return sorted(list_, key=lambda (dist, key): (0-dist, key)) def update(self, dist): @@ -341,8 +341,8 @@ class Distance(object): if not isinstance(dist, Distance): raise ValueError( '`dist` must be a Distance object. It is: %r' % dist) - for key, penalties in dist.penalties.iteritems(): - self.penalties.setdefault(key, []).extend(penalties) + for key, penalties in dist._penalties.iteritems(): + self._penalties.setdefault(key, []).extend(penalties) def track_distance(item, track_info, incl_artist=False): """Determines the significance of a track metadata change. Returns a diff --git a/test/test_autotag.py b/test/test_autotag.py index 92088a7b8..b257f62c9 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -112,72 +112,73 @@ class DistanceTest(unittest.TestCase): def test_add(self): self.dist.add('add', 1.0) - self.assertEqual(self.dist.penalties, {'add': [1.0]}) + self.assertEqual(self.dist._penalties, {'add': [1.0]}) def test_add_equality(self): self.dist.add_equality('equality', 'ghi', ['abc', 'def', 'ghi']) - self.assertEqual(self.dist.penalties['equality'], [0.0]) + self.assertEqual(self.dist._penalties['equality'], [0.0]) self.dist.add_equality('equality', 'xyz', ['abc', 'def', 'ghi']) - self.assertEqual(self.dist.penalties['equality'], [0.0, 1.0]) + self.assertEqual(self.dist._penalties['equality'], [0.0, 1.0]) self.dist.add_equality('equality', 'abc', re.compile(r'ABC', re.I)) - self.assertEqual(self.dist.penalties['equality'], [0.0, 1.0, 0.0]) + self.assertEqual(self.dist._penalties['equality'], [0.0, 1.0, 0.0]) def test_add_expr(self): self.dist.add_expr('expr', True) - self.assertEqual(self.dist.penalties['expr'], [1.0]) + self.assertEqual(self.dist._penalties['expr'], [1.0]) self.dist.add_expr('expr', False) - self.assertEqual(self.dist.penalties['expr'], [1.0, 0.0]) + self.assertEqual(self.dist._penalties['expr'], [1.0, 0.0]) def test_add_number(self): # Add a full penalty for each number of difference between two numbers. self.dist.add_number('number', 1, 1) - self.assertEqual(self.dist.penalties['number'], [0.0]) + self.assertEqual(self.dist._penalties['number'], [0.0]) self.dist.add_number('number', 1, 2) - self.assertEqual(self.dist.penalties['number'], [0.0, 1.0]) + self.assertEqual(self.dist._penalties['number'], [0.0, 1.0]) self.dist.add_number('number', 2, 1) - self.assertEqual(self.dist.penalties['number'], [0.0, 1.0, 1.0]) + self.assertEqual(self.dist._penalties['number'], [0.0, 1.0, 1.0]) self.dist.add_number('number', -1, 2) - self.assertEqual(self.dist.penalties['number'], [0.0, 1.0, 1.0, 1.0, - 1.0, 1.0]) + self.assertEqual(self.dist._penalties['number'], [0.0, 1.0, 1.0, 1.0, + 1.0, 1.0]) def test_add_priority(self): self.dist.add_priority('priority', 'abc', 'abc') - self.assertEqual(self.dist.penalties['priority'], [0.0]) + self.assertEqual(self.dist._penalties['priority'], [0.0]) self.dist.add_priority('priority', 'def', ['abc', 'def', 'ghi']) - self.assertEqual(self.dist.penalties['priority'], [0.0, 0.25]) + self.assertEqual(self.dist._penalties['priority'], [0.0, 0.25]) self.dist.add_priority('priority', 'ghi', ['abc', 'def', - re.compile('GHI', re.I)]) - self.assertEqual(self.dist.penalties['priority'], [0.0, 0.25, 0.5]) + re.compile('GHI', re.I)]) + self.assertEqual(self.dist._penalties['priority'], [0.0, 0.25, 0.5]) self.dist.add_priority('priority', 'xyz', ['abc', 'def']) - self.assertEqual(self.dist.penalties['priority'], [0.0, 0.25, 0.5, 1.0]) + self.assertEqual(self.dist._penalties['priority'], [0.0, 0.25, 0.5, + 1.0]) def test_add_ratio(self): self.dist.add_ratio('ratio', 25, 100) - self.assertEqual(self.dist.penalties['ratio'], [0.25]) + self.assertEqual(self.dist._penalties['ratio'], [0.25]) self.dist.add_ratio('ratio', 10, 5) - self.assertEqual(self.dist.penalties['ratio'], [0.25, 1.0]) + self.assertEqual(self.dist._penalties['ratio'], [0.25, 1.0]) self.dist.add_ratio('ratio', -5, 5) - self.assertEqual(self.dist.penalties['ratio'], [0.25, 1.0, 0.0]) + self.assertEqual(self.dist._penalties['ratio'], [0.25, 1.0, 0.0]) self.dist.add_ratio('ratio', 5, 0) - self.assertEqual(self.dist.penalties['ratio'], [0.25, 1.0, 0.0, 0.0]) + self.assertEqual(self.dist._penalties['ratio'], [0.25, 1.0, 0.0, 0.0]) def test_add_string(self): dist = match.string_dist(u'abc', u'bcd') self.dist.add_string('string', u'abc', u'bcd') - self.assertEqual(self.dist.penalties['string'], [dist]) + self.assertEqual(self.dist._penalties['string'], [dist]) def test_distance(self): config['match']['distance_weights']['album'] = 2.0 @@ -224,8 +225,8 @@ class DistanceTest(unittest.TestCase): self.dist.update(dist) - self.assertEqual(self.dist.penalties, {'album': [0.5, 0.75, 0.25], - 'media': [1.0, 0.05]}) + self.assertEqual(self.dist._penalties, {'album': [0.5, 0.75, 0.25], + 'media': [1.0, 0.05]}) class TrackDistanceTest(unittest.TestCase): def test_identical_tracks(self): diff --git a/test/test_ui.py b/test/test_ui.py index bfdd53ddd..6cb09dcf1 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -607,7 +607,7 @@ class ShowChangeTest(_common.TestCase): mapping = dict(zip(items, info.tracks)) config['color'] = False album_dist = distance(items, info, mapping) - album_dist.penalties = {'album': [dist]} + album_dist._penalties = {'album': [dist]} commands.show_change( cur_artist, cur_album, From ac4e86981fe20e1c9ab3f20128d35180de1353f7 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:07:20 +1000 Subject: [PATCH 21/64] Add `Distance.raw_distance`, to compliment `max_distance`. --- beets/autotag/match.py | 16 +++++++++++----- test/test_autotag.py | 8 ++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index d6eab98d5..a5d5fce14 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -308,14 +308,11 @@ class Distance(object): @property def distance(self): - """Returns an overall weighted distance across all penalties. + """Returns a weighted and normalised distance across all penalties. """ - dist = 0.0 - for key, penalty in self._penalties.iteritems(): - dist += sum(penalty) * weights[key].as_number() dist_max = self.max_distance if dist_max: - return dist / dist_max + return self.raw_distance / self.max_distance return 0.0 @property @@ -327,6 +324,15 @@ class Distance(object): dist_max += len(penalty) * weights[key].as_number() return dist_max + @property + def raw_distance(self): + """Returns the raw (denormalised) distance. + """ + dist_raw = 0.0 + for key, penalty in self._penalties.iteritems(): + dist_raw += sum(penalty) * weights[key].as_number() + return dist_raw + @property def sorted(self): """Returns a list of (dist, key) pairs, with `dist` being the weighted diff --git a/test/test_autotag.py b/test/test_autotag.py index b257f62c9..f2dcbbc28 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -200,6 +200,14 @@ class DistanceTest(unittest.TestCase): self.dist.add('medium', 0.0) self.assertEqual(self.dist.max_distance, 5.0) + def test_raw_distance(self): + config['match']['distance_weights']['album'] = 3.0 + config['match']['distance_weights']['medium'] = 1.0 + self.dist.add('album', 0.5) + self.dist.add('medium', 0.25) + self.dist.add('medium', 0.5) + self.assertEqual(self.dist.raw_distance, 2.25) + def test_sorted(self): config['match']['distance_weights']['album'] = 4.0 config['match']['distance_weights']['medium'] = 2.0 From 809ea8c7f9f9d523078ad04da145e5076b28345f Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:20:19 +1000 Subject: [PATCH 22/64] Exclude zero value penalties from `Distance.sorted`. --- beets/autotag/match.py | 24 ++++++++++++++---------- beets/ui/commands.py | 9 ++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index a5d5fce14..2f226cef4 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -336,9 +336,14 @@ class Distance(object): @property def sorted(self): """Returns a list of (dist, key) pairs, with `dist` being the weighted - distance, sorted from highest to lowest. + distance, sorted from highest to lowest. Does not include penalties + with a zero value. """ - list_ = [(self[key], key) for key in self._penalties] + list_ = [] + for key in self._penalties: + dist = self[key] + if dist: + list_.append((dist, key)) return sorted(list_, key=lambda (dist, key): (0-dist, key)) def update(self, dist): @@ -542,14 +547,13 @@ def _recommendation(results): # Downgrade to the max rec if it is lower than the current rec for an # applied penalty. for dist, key in results[0].distance.sorted: - if dist: - max_rec = config['match']['max_rec'][key].as_choice({ - 'strong': recommendation.strong, - 'medium': recommendation.medium, - 'low': recommendation.low, - 'none': recommendation.none, - }) - rec = min(rec, max_rec) + max_rec = config['match']['max_rec'][key].as_choice({ + 'strong': recommendation.strong, + 'medium': recommendation.medium, + 'low': recommendation.low, + 'none': recommendation.none, + }) + rec = min(rec, max_rec) return rec diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 18b539083..a3d4d8cdd 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -169,11 +169,10 @@ def penalty_string(distance, limit=None): """ penalties = [] for dist, key in distance.sorted: - if dist: - key = key.replace('album_', '') - key = key.replace('track_', '') - key = key.replace('_', ' ') - penalties.append(key) + key = key.replace('album_', '') + key = key.replace('track_', '') + key = key.replace('_', ' ') + penalties.append(key) if penalties: if limit and len(penalties) > limit: penalties = penalties[:limit] + ['...'] From 45dc99f1a9dd9ff8cf65bcd04135832ccd97a7ac Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:25:31 +1000 Subject: [PATCH 23/64] Group preferred media patterns, in case they contain "|" to keep them separate from the number of media. --- beets/autotag/match.py | 2 +- docs/reference/config.rst | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 2f226cef4..a44784c07 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -415,7 +415,7 @@ def distance(items, album_info, mapping): dist.add_string('media', likelies['media'], album_info.media) # Preferred media. - preferred_media = [re.compile(r'(\d+x)?%s' % pattern, re.I) for pattern + preferred_media = [re.compile(r'(\d+x)?(%s)' % pattern, re.I) for pattern in config['match']['preferred']['media'].get()] if album_info.media and preferred_media: dist.add_priority('media', album_info.media, preferred_media) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index ec194afde..c9f36c5e1 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -437,9 +437,13 @@ preferred In addition to comparing the tagged metadata with the match metadata for similarity, you can also specify an ordered list of preferred countries and -media types. A distance penalty will be applied if the country or media type -from the match metadata doesn't match. The order is important, the first item -will be most preferred. +media types. + +A distance penalty will be applied if the country or media type from the match +metadata doesn't match. The order is important, the first item will be most +preferred. Each item may be a regular expression, and will be matched case +insensitively. The number of media will be stripped when matching preferred +media (e.g. "2x" in "2xCD"). You can also tell the autotagger to prefer matches that have a release year closest to the original year for an album. @@ -448,8 +452,8 @@ Here's an example:: match: preferred: - countries: ['US', 'GB', 'UK'] - media: ['CD', 'Digital Media'] + countries: ['US', 'GB|UK'] + media: ['CD', 'Digital Media|File'] original_year: yes By default, none of these options are enabled. From f3545860da0118c91c0c802b8b905f5554589a75 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:35:32 +1000 Subject: [PATCH 24/64] Add `ignored` setting. Don't show matches with specified penalties applied, e.g. missing tracks or unmatched tracks. If you know you never want these, they can clutter up the interface especially now that we have multiple data sources. --- beets/autotag/match.py | 9 ++++++++- beets/config_default.yaml | 1 + docs/changelog.rst | 2 ++ docs/reference/config.rst | 11 +++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index a44784c07..c2a2372ca 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -575,8 +575,15 @@ def _add_candidate(items, results, info): # Get the change distance. dist = distance(items, info, mapping) - log.debug('Success. Distance: %f' % dist) + # Skip matches with ignored penalties. + penalties = [key for _, key in dist.sorted] + for penalty in config['match']['ignored'].as_str_seq(): + if penalty in penalties: + log.debug('Ignored. Penalty: %s' % penalty) + return + + log.debug('Success. Distance: %f' % dist) results[info.album_id] = hooks.AlbumMatch(dist, info, mapping, extra_items, extra_tracks) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 7b9867813..44cb51051 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -115,3 +115,4 @@ match: countries: [] media: [] original_year: no + ignored: [] diff --git a/docs/changelog.rst b/docs/changelog.rst index 527982190..4076248d0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -68,6 +68,8 @@ Changelog number is still "correct", just in a different format. * Sort missing and unmatched tracks by index and title and group them together for better readability. + * Don't show potential matches that have specific penalties applied, as + configured by the :ref:`ignored` setting. * Improve calculation of similarity score and recommendation: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index c9f36c5e1..d320cd655 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -458,6 +458,17 @@ Here's an example:: By default, none of these options are enabled. +.. _ignored: + +ignored +~~~~~~~ + +You can completely avoid matches that have certain penalties applied by adding +the penalty name to the ``ignored`` setting:: + + match: + ignored: missing_tracks unmatched_tracks + .. _path-format-config: Path Format Configuration From ad52ede73674489c421053076597da603b186581 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:36:01 +1000 Subject: [PATCH 25/64] Code style. Use "_" when expanding variables we don't need. --- beets/autotag/match.py | 2 +- beets/ui/commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index c2a2372ca..5c6e4ffed 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -546,7 +546,7 @@ def _recommendation(results): # Downgrade to the max rec if it is lower than the current rec for an # applied penalty. - for dist, key in results[0].distance.sorted: + for _, key in results[0].distance.sorted: max_rec = config['match']['max_rec'][key].as_choice({ 'strong': recommendation.strong, 'medium': recommendation.medium, diff --git a/beets/ui/commands.py b/beets/ui/commands.py index a3d4d8cdd..6bf1db53c 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -168,7 +168,7 @@ def penalty_string(distance, limit=None): a distance object. """ penalties = [] - for dist, key in distance.sorted: + for _, key in distance.sorted: key = key.replace('album_', '') key = key.replace('track_', '') key = key.replace('_', ' ') From 461c3c047c64d60981eea26f274f05f948c5be7d Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 00:46:40 +1000 Subject: [PATCH 26/64] Colour benign track index changes in light gray, consistent with non-penalty supplementary information. --- beets/ui/commands.py | 2 +- docs/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 6bf1db53c..63d1df00d 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -293,7 +293,7 @@ def show_change(cur_artist, cur_album, match): cur_track, new_track = format_index(item), format_index(track_info) if cur_track != new_track: if item.track in (track_info.index, track_info.medium_index): - color = 'yellow' + color = 'lightgray' else: color = 'red' if (cur_track + new_track).count('-') == 1: diff --git a/docs/changelog.rst b/docs/changelog.rst index 4076248d0..9d9cb8403 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -63,7 +63,7 @@ Changelog * Display album disambiguation and disc titles in the track listing, when available. * More consistent format and colorization of album and track metadata. - * Track changes highlighted in turquoise indicate a change in format to or + * Track changes highlighted in light gray indicate a change in format to or from :ref:`per_disc_numbering`. No penalty is applied because the track number is still "correct", just in a different format. * Sort missing and unmatched tracks by index and title and group them From 2c175faa4677da1f4fea87aa024c582a8b3635cb Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 01:08:35 +1000 Subject: [PATCH 27/64] Colorise no-penalty text differences in a secondary colour, light grey. --- beets/ui/__init__.py | 14 ++++++++++---- docs/changelog.rst | 4 +++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 6789045f1..21d56ad0d 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -37,6 +37,7 @@ from beets.util.functemplate import Template from beets import config from beets.util import confit from beets.autotag import mb +from beets.autotag.match import string_dist # On Windows platforms, use colorama to support "ANSI" terminal colors. @@ -366,7 +367,7 @@ def colorize(color, text): else: return text -def _colordiff(a, b, highlight='red'): +def _colordiff(a, b, highlight='red', second_highlight='lightgray'): """Given two values, return the same pair of strings except with their differences highlighted in the specified color. Strings are highlighted intelligently to show differences; other values are @@ -402,9 +403,14 @@ def _colordiff(a, b, highlight='red'): # Left only. a_out.append(colorize(highlight, a[a_start:a_end])) elif op == 'replace': - # Right and left differ. - a_out.append(colorize(highlight, a[a_start:a_end])) - b_out.append(colorize(highlight, b[b_start:b_end])) + # Right and left differ. Colorise with second highlight if + # there's no distance penalty. + if string_dist(a[a_start:a_end], b[b_start:b_end]): + color = highlight + else: + color = second_highlight + a_out.append(colorize(color, a[a_start:a_end])) + b_out.append(colorize(color, b[b_start:b_end])) else: assert(False) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9d9cb8403..1daa09ef3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -62,7 +62,9 @@ Changelog low similarity score. * Display album disambiguation and disc titles in the track listing, when available. - * More consistent format and colorization of album and track metadata. + * More consistent format and colorization of album and track metadata. Red + for actual differences, yellow to indicate that a penalty is being applied, + and light gray for no-penalty supplementary data. * Track changes highlighted in light gray indicate a change in format to or from :ref:`per_disc_numbering`. No penalty is applied because the track number is still "correct", just in a different format. From b02974f68f8e71d526aec16da5c1f74eaa48c7e9 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 01:20:32 +1000 Subject: [PATCH 28/64] Don't bypass candidate selection in timid mode. Always show all candidates. Saves paranoid and interested users from having to either force all max recommendations to none or constantly go back to candidate selection from a recommendation to see if there is another slightly less similar but more preferred (by the user) candidate. --- beets/ui/commands.py | 2 +- docs/changelog.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 63d1df00d..e55068f80 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -466,7 +466,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, # Is the change good enough? bypass_candidates = False - if rec != recommendation.none: + if rec != recommendation.none and not config['import']['timid']: match = candidates[0] bypass_candidates = True diff --git a/docs/changelog.rst b/docs/changelog.rst index 1daa09ef3..362bbd5b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -72,6 +72,7 @@ Changelog together for better readability. * Don't show potential matches that have specific penalties applied, as configured by the :ref:`ignored` setting. + * Don't bypass candidate selection in timid mode. Always show all candidates. * Improve calculation of similarity score and recommendation: From 5904959b8a5e9e9649d237b960ea1e7b7e46763d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 2 Jun 2013 16:49:10 -0700 Subject: [PATCH 29/64] item templates now expand all fields A user noticed that $id wasn't being expanded. There's no good reason for that. --- beets/library.py | 8 +++++--- docs/changelog.rst | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/beets/library.py b/beets/library.py index d1b0b6d09..f8aaaaad2 100644 --- a/beets/library.py +++ b/beets/library.py @@ -399,7 +399,7 @@ class Item(object): # Build the mapping for substitution in the template, # beginning with the values from the database. mapping = {} - for key in ITEM_KEYS_META: + for key in ITEM_KEYS: # Get the values from either the item or its album. if key in ALBUM_KEYS_ITEM and album is not None: # From album. @@ -411,8 +411,10 @@ class Item(object): value = format_for_path(value, key, pathmod) mapping[key] = value - # Additional fields in non-sanitized case. - if not sanitize: + # Include the path if we're not sanitizing to construct a path. + if sanitize: + del mapping['path'] + else: mapping['path'] = displayable_path(self.path) # Use the album artist if the track artist is not set and diff --git a/docs/changelog.rst b/docs/changelog.rst index 0f8b08b51..9d6984912 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -51,6 +51,8 @@ Changelog ``beet modify artpath=...`` works). Thanks to Lucas Duailibe. * :doc:`/plugins/zero`: Fix a crash when nulling out a field that contains None. +* Templates can now refer to non-tag item fields (e.g., ``$id`` and + ``$album_id``). * Various UI enhancements to the importer due to Tai Lee: * More consistent format and colorization of album and track metadata. From c12abb74abb7ffb7c7fcfd79a5d641f8d59cd324 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 12:49:55 +1000 Subject: [PATCH 30/64] Look at track penalties as well when downgrading recommendations for albums. --- beets/autotag/match.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 5c6e4ffed..b1332ddf4 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -546,7 +546,11 @@ def _recommendation(results): # Downgrade to the max rec if it is lower than the current rec for an # applied penalty. - for _, key in results[0].distance.sorted: + keys = set(key for _, key in min_dist.sorted) + if isinstance(results[0], hooks.AlbumMatch): + for track_dist in min_dist.tracks.values(): + keys.update(key for _, key in track_dist.sorted) + for key in keys: max_rec = config['match']['max_rec'][key].as_choice({ 'strong': recommendation.strong, 'medium': recommendation.medium, From 0c27d275f33880ebfcc4387c09810b2f2d67fb20 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 14:31:53 +1000 Subject: [PATCH 31/64] Improve preferred media/country and original year distance calculation. Check only preferred media/country, if specified. Don't apply penalty for preferred AND tagged mismatch. Assume original year is 1889 (first gramophone discs) when we don't know the original year. Allow single values to be specified in configuration, instead of requiring a list (e.g. use `as_str_seq()`). --- beets/autotag/match.py | 47 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index b1332ddf4..813105910 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -410,22 +410,29 @@ def distance(items, album_info, mapping): # Album. dist.add_string('album', likelies['album'], album_info.album) - # Media. - if likelies['media'] and album_info.media: - dist.add_string('media', likelies['media'], album_info.media) - # Preferred media. - preferred_media = [re.compile(r'(\d+x)?(%s)' % pattern, re.I) for pattern - in config['match']['preferred']['media'].get()] - if album_info.media and preferred_media: - dist.add_priority('media', album_info.media, preferred_media) + patterns = config['match']['preferred']['media'].as_str_seq() + options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns] + if album_info.media and options: + dist.add_priority('media', album_info.media, options) + # Media. + elif likelies['media'] and album_info.media: + dist.add_string('media', likelies['media'], album_info.media) # Mediums. if likelies['disctotal'] and album_info.mediums: dist.add_number('mediums', likelies['disctotal'], album_info.mediums) + # Prefer earliest release. + if album_info.year and config['match']['preferred']['original_year']: + # Assume 1889 (earliest first gramophone discs) if we don't know the + # original year. + original = album_info.original_year or 1889 + diff = abs(album_info.year - original) + diff_max = abs(datetime.date.today().year - original) + dist.add_ratio('year', diff, diff_max) # Year. - if likelies['year'] and album_info.year: + elif likelies['year'] and album_info.year: if likelies['year'] in (album_info.year, album_info.original_year): # No penalty for matching release or original year. dist.add('year', 0.0) @@ -439,22 +446,14 @@ def distance(items, album_info, mapping): # Full penalty when there is no original year. dist.add('year', 1.0) - # Prefer earlier releases. - if album_info.year and album_info.original_year and \ - config['match']['preferred']['original_year']: - diff = abs(album_info.year - album_info.original_year) - diff_max = abs(datetime.date.today().year - album_info.original_year) - dist.add_ratio('year', diff, diff_max) - - # Country. - if likelies['country'] and album_info.country: - dist.add_string('country', likelies['country'], album_info.country) - # Preferred countries. - preferred_countries = [re.compile(pattern, re.I) for pattern - in config['match']['preferred']['countries'].get()] - if album_info.country and preferred_countries: - dist.add_priority('country', album_info.country, preferred_countries) + patterns = config['match']['preferred']['countries'].as_str_seq() + options = [re.compile(pat, re.I) for pat in patterns] + if album_info.country and options: + dist.add_priority('country', album_info.country, options) + # Country. + elif likelies['country'] and album_info.country: + dist.add_string('country', likelies['country'], album_info.country) # Label. if likelies['label'] and album_info.label: From e92b8bb8fbcca499ca4566c87e524d9e83fcc3a8 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Mon, 3 Jun 2013 14:49:39 +1000 Subject: [PATCH 32/64] Fix `add_priority()` calculation. We were incorrectly adding 1 to the length of options to avoid a divide by zero, when we should instead default the length to 1. Otherwise we skew the penalty towards zero. --- beets/autotag/match.py | 2 +- test/test_autotag.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 813105910..ebf781421 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -279,7 +279,7 @@ class Distance(object): """ if not isinstance(options, (list, tuple)): options = [options] - unit = 1.0 / (len(options) + 1) + unit = 1.0 / (len(options) or 1) for i, opt in enumerate(options): if self._eq(opt, value): dist = i * unit diff --git a/test/test_autotag.py b/test/test_autotag.py index f2dcbbc28..c513dc530 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -151,15 +151,15 @@ class DistanceTest(unittest.TestCase): self.dist.add_priority('priority', 'abc', 'abc') self.assertEqual(self.dist._penalties['priority'], [0.0]) - self.dist.add_priority('priority', 'def', ['abc', 'def', 'ghi']) - self.assertEqual(self.dist._penalties['priority'], [0.0, 0.25]) + self.dist.add_priority('priority', 'def', ['abc', 'def']) + self.assertEqual(self.dist._penalties['priority'], [0.0, 0.5]) - self.dist.add_priority('priority', 'ghi', ['abc', 'def', - re.compile('GHI', re.I)]) - self.assertEqual(self.dist._penalties['priority'], [0.0, 0.25, 0.5]) + self.dist.add_priority('priority', 'gh', ['ab', 'cd', 'ef', + re.compile('GH', re.I)]) + self.assertEqual(self.dist._penalties['priority'], [0.0, 0.5, 0.75]) self.dist.add_priority('priority', 'xyz', ['abc', 'def']) - self.assertEqual(self.dist._penalties['priority'], [0.0, 0.25, 0.5, + self.assertEqual(self.dist._penalties['priority'], [0.0, 0.5, 0.75, 1.0]) def test_add_ratio(self): From edd0efcb87757a0082e9bbec0c61522e3e26a93d Mon Sep 17 00:00:00 2001 From: Johannes Baiter Date: Mon, 3 Jun 2013 17:22:29 +0200 Subject: [PATCH 33/64] Fix unicode bug in Beatport plugin --- beetsplug/beatport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 73514f298..3987891c1 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -137,7 +137,7 @@ class BeatportTrack(BeatportObject): if 'title' in data: self.title = unicode(data['title']) if 'mixName' in data: - self.mix_name = data['mixName'] + self.mix_name = unicode(data['mixName']) if 'length' in data: self.length = timedelta(milliseconds=data['lengthMs']) if 'slug' in data: @@ -265,7 +265,7 @@ class BeatportPlugin(BeetsPlugin): """ title = track.name if track.mix_name != "Original Mix": - title += " ({})".format(track.mix_name) + title += u" ({})".format(track.mix_name) artist, artist_id = self._get_artist(track.artists) length = track.length.total_seconds() From 7c430a791cf84f20763abb378cb04453ac0578b8 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 3 Jun 2013 10:06:54 -0700 Subject: [PATCH 34/64] possibly fix error for some Mutagen exceptions --- beets/library.py | 2 +- beets/mediafile.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index f8aaaaad2..7696541fa 100644 --- a/beets/library.py +++ b/beets/library.py @@ -321,7 +321,7 @@ class Item(object): try: f = MediaFile(syspath(read_path)) except (OSError, IOError) as exc: - raise util.FilesystemError(exc, 'read', (self.path,), + raise util.FilesystemError(exc, 'read', (read_path,), traceback.format_exc()) for key in ITEM_KEYS_META: diff --git a/beets/mediafile.py b/beets/mediafile.py index b9b35f3ef..7b60a57c2 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -61,7 +61,11 @@ class UnreadableFileError(Exception): class FileIOError(UnreadableFileError, IOError): def __init__(self, exc): - IOError.__init__(self, exc.errno, exc.strerror, exc.filename) + if exc.errno is not None: + # A valid underlying IOError. + IOError.__init__(self, exc.errno, exc.strerror, exc.filename) + else: + UnreadableFileError.__init__(self, unicode(exc)) # Raised for files that don't seem to have a type MediaFile supports. class FileTypeError(UnreadableFileError): From e093d76e21f4ea6827121a617dddbe768edf4b0c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 3 Jun 2013 12:01:06 -0700 Subject: [PATCH 35/64] handle FLACVorbisError exceptions from Mutagen --- beets/mediafile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beets/mediafile.py b/beets/mediafile.py index 7b60a57c2..375a1d49a 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -867,6 +867,7 @@ class MediaFile(object): unreadable_exc = ( mutagen.mp3.HeaderNotFoundError, mutagen.flac.FLACNoHeaderError, + mutagen.flac.FLACVorbisError, mutagen.monkeysaudio.MonkeysAudioHeaderError, mutagen.mp4.MP4StreamInfoError, mutagen.oggvorbis.OggVorbisHeaderError, From c276cfecc915cc284f54a601349860e51086a50d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 3 Jun 2013 12:11:28 -0700 Subject: [PATCH 36/64] mediafile: pass through base IOErrors This also tries to be a bit more thorough about capturing all the Mutagen-originated exceptions. --- beets/mediafile.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index 375a1d49a..9b6234192 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -59,14 +59,6 @@ log = logging.getLogger('beets') class UnreadableFileError(Exception): pass -class FileIOError(UnreadableFileError, IOError): - def __init__(self, exc): - if exc.errno is not None: - # A valid underlying IOError. - IOError.__init__(self, exc.errno, exc.strerror, exc.filename) - else: - UnreadableFileError.__init__(self, unicode(exc)) - # Raised for files that don't seem to have a type MediaFile supports. class FileTypeError(UnreadableFileError): pass @@ -865,13 +857,15 @@ class MediaFile(object): self.path = path unreadable_exc = ( - mutagen.mp3.HeaderNotFoundError, - mutagen.flac.FLACNoHeaderError, - mutagen.flac.FLACVorbisError, + mutagen.mp3.error, + mutagen.id3.error, + mutagen.flac.error, mutagen.monkeysaudio.MonkeysAudioHeaderError, - mutagen.mp4.MP4StreamInfoError, - mutagen.oggvorbis.OggVorbisHeaderError, - mutagen.asf.ASFHeaderError, + mutagen.mp4.error, + mutagen.oggvorbis.error, + mutagen.ogg.error, + mutagen.asf.error, + mutagen.apev2.error, ) try: self.mgfile = mutagen.File(path) @@ -879,7 +873,13 @@ class MediaFile(object): log.debug(u'header parsing failed: {0}'.format(unicode(exc))) raise UnreadableFileError('Mutagen could not read file') except IOError as exc: - raise FileIOError(exc) + if type(exc) == IOError: + # This is a base IOError, not a subclass from Mutagen or + # anywhere else. + raise + else: + log.debug(traceback.format_exc()) + raise UnreadableFileError('Mutagen raised an exception') except Exception as exc: # Hide bugs in Mutagen. log.debug(traceback.format_exc()) From 4e016f1913af6f48b186d207197db9077950ae91 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 3 Jun 2013 13:39:52 -0700 Subject: [PATCH 37/64] fix MediaFile exception test --- test/test_mediafile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 5c237c248..71ebba3c4 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -151,7 +151,7 @@ class SafetyTest(unittest.TestCase): fn = os.path.join(_common.RSRC, 'brokenlink') os.symlink('does_not_exist', fn) try: - self.assertRaises(beets.mediafile.UnreadableFileError, + self.assertRaises(IOError, beets.mediafile.MediaFile, fn) finally: os.unlink(fn) From 975f5bd818771e5615a4581620e9a568c5ad33f6 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 3 Jun 2013 14:01:16 -0700 Subject: [PATCH 38/64] changelog/doc links for Beatport plugin (#301) --- docs/changelog.rst | 4 ++++ docs/plugins/index.rst | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9d6984912..0c4cdbadf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ Changelog tracks**. Thanks once more to Pedro Silva. * New :doc:`/plugins/discogs`: Extends the autotagger to include matches from the `Discogs`_ database. Thanks to Artem Ponomarenko and Tai Lee. +* New :doc:`/plugins/beatport`: Get matches from the `Beatport`_ database. + Thanks to Johannes Baiter. * Your library now keeps track of **when music was added** to it. The new ``added`` field is a timestamp reflecting when each item and album was imported and the new ``%time{}`` template function lets you format this @@ -82,6 +84,8 @@ Changelog ``country`` and ``albumdisambig``. .. _Discogs: http://discogs.com/ +.. _Beatport: http://www.beatport.com/ + 1.1.0 (April 29, 203) --------------------- diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 5db83e8e1..d56471d89 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -66,13 +66,18 @@ disabled by default, but you can turn them on as described above. missing duplicates discogs + beatport 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:`discogs`: Search for releases in the `Discogs`_ database. +* :doc:`beatport`: Search for tracks and releases in the `Beatport`_ database. + +.. _Beatport: http://www.beatport.com/ +.. _Discogs: http://www.discogs.com/ Metadata '''''''' From dfda5a311da3e1dda54114ecfd1d2dfd1a927e9a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 3 Jun 2013 14:06:51 -0700 Subject: [PATCH 39/64] beatport (#301): more Unicode literals Avoids warnings in the unidecode module. --- beetsplug/beatport.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 3987891c1..5cabfef87 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -246,7 +246,7 @@ class BeatportPlugin(BeetsPlugin): va = len(release.artists) > 3 artist, artist_id = self._get_artist(release.artists) if va: - artist = "Various Artists" + artist = u"Various Artists" tracks = [self._get_track_info(x, index=idx) for idx, x in enumerate(release.tracks, 1)] @@ -257,14 +257,14 @@ class BeatportPlugin(BeetsPlugin): month=release.release_date.month, day=release.release_date.day, label=release.label_name, - catalognum=release.catalog_number, media='Digital', - data_source='Beatport', data_url=release.url) + catalognum=release.catalog_number, media=u'Digital', + data_source=u'Beatport', data_url=release.url) def _get_track_info(self, track, index=None): """Returns a TrackInfo object for a Beatport Track object. """ title = track.name - if track.mix_name != "Original Mix": + if track.mix_name != u"Original Mix": title += u" ({})".format(track.mix_name) artist, artist_id = self._get_artist(track.artists) length = track.length.total_seconds() From 1bf8ae0a01ba39691c25a13dd56028dc2ea588d5 Mon Sep 17 00:00:00 2001 From: John Hawthorn Date: Wed, 5 Jun 2013 12:17:49 -0700 Subject: [PATCH 40/64] mpdupdate: Allow UNIX domain socket for MPD server If the host configuration begins with a '/', it is assumed to reference a UNIX domain socket. This is similar to libmpdclient's behaviour. --- beetsplug/mpdupdate.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 0360a45df..6138efca2 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -35,14 +35,16 @@ database_changed = False # easier. class BufferedSocket(object): """Socket abstraction that allows reading by line.""" - def __init__(self, sep='\n'): - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + def __init__(self, host, port, sep='\n'): + if host[0] == '/': + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(host) + else: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((host, port)) self.buf = '' self.sep = sep - def connect(self, host, port): - self.sock.connect((host, port)) - def readline(self): while self.sep not in self.buf: data = self.sock.recv(1024) @@ -67,8 +69,7 @@ def update_mpd(host='localhost', port=6600, password=None): """ print('Updating MPD database...') - s = BufferedSocket() - s.connect(host, port) + s = BufferedSocket(host, port) resp = s.readline() if 'OK MPD' not in resp: print('MPD connection failed:', repr(resp)) From 1364e6ba3731ff5367288ad5f3ceb5d26760879f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 15:20:36 -0700 Subject: [PATCH 41/64] organize 1.1.1 changelog into sections --- docs/changelog.rst | 83 +++++++++++++++++++++++---------------- docs/plugins/beatport.rst | 2 +- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0c4cdbadf..e8e4b1cbf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,14 +4,24 @@ Changelog 1.1.1 (in development) ---------------------- -* New :doc:`/plugins/duplicates`: Find tracks or albums in your - library that are **duplicated**. Thanks to Pedro Silva. -* New :doc:`/plugins/missing`: Find albums in your library that are **missing - tracks**. Thanks once more to Pedro Silva. +Two new plugins that add new **data sources** to beets that augment +MusicBrainz in the importer: + * New :doc:`/plugins/discogs`: Extends the autotagger to include matches from the `Discogs`_ database. Thanks to Artem Ponomarenko and Tai Lee. * New :doc:`/plugins/beatport`: Get matches from the `Beatport`_ database. Thanks to Johannes Baiter. + +Two new plugins that can check your library for common problems, both by Pedro +Silva: + +* New :doc:`/plugins/duplicates`: Find tracks or albums in your + library that are **duplicated**. +* New :doc:`/plugins/missing`: Find albums in your library that are **missing + tracks**. + +A few more big features: + * Your library now keeps track of **when music was added** to it. The new ``added`` field is a timestamp reflecting when each item and album was imported and the new ``%time{}`` template function lets you format this @@ -22,6 +32,37 @@ Changelog ``bitrate:128000..``. See :ref:`numericquery`. Thanks to Michael Schuerig. * **ALAC files** are now marked as ALAC instead of being conflated with AAC audio. Thanks to Simon Luijk. + +Various UI enhancements to the importer due to Tai Lee: + +* More consistent format and colorization of album and track metadata. +* Display data source URL for :doc:`/plugins/discogs` matches. This should + make it easier for people who would rather import and correct data from + Discogs into MusicBrainz. +* Display album disambiguation and disc titles in the track listing, when + available. +* Track changes highlighted in yellow indicate a change in format to or from + :ref:`per_disc_numbering`. No penalty is applied because the track number + is still "correct", just in a different format. +* Sort missing and unmatched tracks by index and title and group them + together for better readability. +* Indicate MusicBrainz ID mismatches. + +Improve calculation of similarity score, also thanks to Tai Lee: + +* Strongly prefer releases with a matching MusicBrainz album ID. This helps + beets re-identify the same release when re-importing existing files. +* Prefer releases that are closest to the tagged ``year``. Tolerate files + tagged with release or original year. +* Prefer CD releases by default, when there is no ``media`` tagged in the + files being imported. This can be changed with the :ref:`preferred_media` + setting. +* Apply minor penalties across a range of fields to differentiate between + nearly identical releases: ``disctotal``, ``label``, ``catalognum``, + ``country`` and ``albumdisambig``. + +Lots of little enhancements: + * :doc:`/plugins/random`: A new ``-e`` option gives an equal chance to each artist in your collection to avoid biasing random samples to prolific artists. Thanks to Georges Dubus. @@ -33,8 +74,6 @@ Changelog Duailibe. * The importer output now shows the number of audio files in each album. Thanks to jayme on GitHub. -* :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due - to some fixes in dealing with special characters. * Plugins can now provide fields for both Album and Item templates, thanks to Pedro Silva. Accordingly, the :doc:`/plugins/inline` can also now define album fields. For consistency, the ``pathfields`` configuration section has @@ -46,6 +85,9 @@ Changelog Johannes Baiter. * The :ref:`fields-cmd` command shows template fields provided by plugins. Thanks again to Pedro Silva. + +And a batch of fixes: + * Album art filenames now respect the :ref:`replace` configuration. * Friendly error messages are now printed when trying to read or write files that go missing. @@ -55,33 +97,8 @@ Changelog None. * Templates can now refer to non-tag item fields (e.g., ``$id`` and ``$album_id``). -* Various UI enhancements to the importer due to Tai Lee: - - * More consistent format and colorization of album and track metadata. - * Display data source URL for :doc:`/plugins/discogs` matches. This should - make it easier for people who would rather import and correct data from - Discogs into MusicBrainz. - * Display album disambiguation and disc titles in the track listing, when - available. - * Track changes highlighted in yellow indicate a change in format to or from - :ref:`per_disc_numbering`. No penalty is applied because the track number - is still "correct", just in a different format. - * Sort missing and unmatched tracks by index and title and group them - together for better readability. - * Indicate MusicBrainz ID mismatches. - -* Improve calculation of similarity score: - - * Strongly prefer releases with a matching MusicBrainz album ID. This helps - beets re-identify the same release when re-importing existing files. - * Prefer releases that are closest to the tagged ``year``. Tolerate files - tagged with release or original year. - * Prefer CD releases by default, when there is no ``media`` tagged in the - files being imported. This can be changed with the :ref:`preferred_media` - setting. - * Apply minor penalties across a range of fields to differentiate between - nearly identical releases: ``disctotal``, ``label``, ``catalognum``, - ``country`` and ``albumdisambig``. +* :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due + to some fixes in dealing with special characters. .. _Discogs: http://discogs.com/ .. _Beatport: http://www.beatport.com/ diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst index d74c6f8d6..751f458be 100644 --- a/docs/plugins/beatport.rst +++ b/docs/plugins/beatport.rst @@ -1,5 +1,5 @@ Beatport Plugin -============== +=============== The ``beatport`` plugin adds support for querying the `Beatport`_ catalogue during the autotagging process. This can potentially be helpful for users From 7fca25fba644f1db84aaa955b8f9798755c449d1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 15:21:43 -0700 Subject: [PATCH 42/64] 1.1.1 -> 1.2.0 --- beets/__init__.py | 2 +- docs/changelog.rst | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 151b46994..02e7ad204 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -__version__ = '1.1.1' +__version__ = '1.2.0' __author__ = 'Adrian Sampson ' import beets.library diff --git a/docs/changelog.rst b/docs/changelog.rst index e8e4b1cbf..da4368edf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,7 +1,7 @@ Changelog ========= -1.1.1 (in development) +1.2.0 (in development) ---------------------- Two new plugins that add new **data sources** to beets that augment diff --git a/docs/conf.py b/docs/conf.py index f5fd07017..0136ac23e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,8 +12,8 @@ master_doc = 'index' project = u'beets' copyright = u'2012, Adrian Sampson' -version = '1.1' -release = '1.1.1' +version = '1.2' +release = '1.2.0' pygments_style = 'sphinx' diff --git a/setup.py b/setup.py index d9ec76e69..350bc6cd1 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ if 'sdist' in sys.argv: shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', - version='1.1.1', + version='1.2.0', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', From 9542a292ed73ff84ed1a82007c5fa3598f51531d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 15:51:19 -0700 Subject: [PATCH 43/64] write more connective text in changelog --- docs/changelog.rst | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index da4368edf..9aa62d548 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,23 +4,31 @@ Changelog 1.2.0 (in development) ---------------------- -Two new plugins that add new **data sources** to beets that augment -MusicBrainz in the importer: +There's a *lot* of new stuff in this release: new data sources for the +autotagger, new plugins to look for problems in your library, tracking the +date that you acquired new music, an awesome new syntax for doing queries over +numeric fields, support for ALAC files, and major enhancements to the +importer's UI and distance calculations. A special thanks goes out to all the +contributors who helped make this release awesome. -* New :doc:`/plugins/discogs`: Extends the autotagger to include matches from - the `Discogs`_ database. Thanks to Artem Ponomarenko and Tai Lee. +For the first time, beets can now tag your music using additional **data +sources** to augment the matches from MusicBrainz. When you enable either of +these plugins, the importer will start showing you new kinds of matches: + +* New :doc:`/plugins/discogs`: Get matches from the `Discogs`_ database. + Thanks to Artem Ponomarenko and Tai Lee. * New :doc:`/plugins/beatport`: Get matches from the `Beatport`_ database. Thanks to Johannes Baiter. -Two new plugins that can check your library for common problems, both by Pedro -Silva: +We also have two other new plugins that can scan your library to check for +common problems, both by Pedro Silva: * New :doc:`/plugins/duplicates`: Find tracks or albums in your library that are **duplicated**. * New :doc:`/plugins/missing`: Find albums in your library that are **missing tracks**. -A few more big features: +There are also three more big features added to beets core: * Your library now keeps track of **when music was added** to it. The new ``added`` field is a timestamp reflecting when each item and album was @@ -33,22 +41,26 @@ A few more big features: * **ALAC files** are now marked as ALAC instead of being conflated with AAC audio. Thanks to Simon Luijk. -Various UI enhancements to the importer due to Tai Lee: +In addition, the importer saw various UI enhancements, thanks to Tai Lee: * More consistent format and colorization of album and track metadata. -* Display data source URL for :doc:`/plugins/discogs` matches. This should - make it easier for people who would rather import and correct data from - Discogs into MusicBrainz. +* Display data source URL for matches from the new data source plugins. This + should make it easier to migrate data from Discogs or Beatport into + MusicBrainz. * Display album disambiguation and disc titles in the track listing, when available. -* Track changes highlighted in yellow indicate a change in format to or from - :ref:`per_disc_numbering`. No penalty is applied because the track number - is still "correct", just in a different format. +* Track changes are highlighted in yellow when they indicate a change in + format to or from the style of :ref:`per_disc_numbering`. (As before, no + penalty is applied because the track number is still "correct", just in a + different format.) * Sort missing and unmatched tracks by index and title and group them together for better readability. * Indicate MusicBrainz ID mismatches. -Improve calculation of similarity score, also thanks to Tai Lee: +The calculation of the similarity score for autotagger matches was also +approved, again thanks to Tai Lee. These changes, in general, help deal with +the new metadata sources and help disambiguate between similar releases in the +same MusicBrainz release group: * Strongly prefer releases with a matching MusicBrainz album ID. This helps beets re-identify the same release when re-importing existing files. @@ -61,7 +73,7 @@ Improve calculation of similarity score, also thanks to Tai Lee: nearly identical releases: ``disctotal``, ``label``, ``catalognum``, ``country`` and ``albumdisambig``. -Lots of little enhancements: +As usual, there were also lots of other great littler enhancements: * :doc:`/plugins/random`: A new ``-e`` option gives an equal chance to each artist in your collection to avoid biasing random samples to prolific From ea1becfea16289f961faa5e99ab6fe7409d2fdf0 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Thu, 6 Jun 2013 09:51:17 +1000 Subject: [PATCH 44/64] Add `Distance.__iter__()` and `Distance.__len__()`, for convenience. --- beets/autotag/match.py | 15 ++++++++++++--- beets/ui/commands.py | 2 +- test/test_autotag.py | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index ebf781421..59f0d00f4 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -210,6 +210,12 @@ class Distance(object): def __init__(self): self._penalties = {} + def __iter__(self): + return iter(self.sorted) + + def __len__(self): + return len(self.sorted) + def __sub__(self, other): return self.distance - other @@ -344,6 +350,9 @@ class Distance(object): dist = self[key] if dist: list_.append((dist, key)) + # Convert distance into a negative float we can sort items in ascending + # order (for keys, when the penalty is equal) and still get the items + # with the biggest distance first. return sorted(list_, key=lambda (dist, key): (0-dist, key)) def update(self, dist): @@ -545,10 +554,10 @@ def _recommendation(results): # Downgrade to the max rec if it is lower than the current rec for an # applied penalty. - keys = set(key for _, key in min_dist.sorted) + keys = set(key for _, key in min_dist) if isinstance(results[0], hooks.AlbumMatch): for track_dist in min_dist.tracks.values(): - keys.update(key for _, key in track_dist.sorted) + keys.update(key for _, key in track_dist) for key in keys: max_rec = config['match']['max_rec'][key].as_choice({ 'strong': recommendation.strong, @@ -580,7 +589,7 @@ def _add_candidate(items, results, info): dist = distance(items, info, mapping) # Skip matches with ignored penalties. - penalties = [key for _, key in dist.sorted] + penalties = [key for _, key in dist] for penalty in config['match']['ignored'].as_str_seq(): if penalty in penalties: log.debug('Ignored. Penalty: %s' % penalty) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index e55068f80..96e67cde2 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -168,7 +168,7 @@ def penalty_string(distance, limit=None): a distance object. """ penalties = [] - for _, key in distance.sorted: + for _, key in distance: key = key.replace('album_', '') key = key.replace('track_', '') key = key.replace('_', ' ') diff --git a/test/test_autotag.py b/test/test_autotag.py index c513dc530..dc75ee0ab 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -200,6 +200,23 @@ class DistanceTest(unittest.TestCase): self.dist.add('medium', 0.0) self.assertEqual(self.dist.max_distance, 5.0) + def test_operators(self): + config['match']['distance_weights']['source'] = 1.0 + config['match']['distance_weights']['album'] = 2.0 + config['match']['distance_weights']['medium'] = 1.0 + self.dist.add('source', 0.0) + self.dist.add('album', 0.5) + self.dist.add('medium', 0.25) + self.dist.add('medium', 0.75) + self.assertEqual(len(self.dist), 2) + self.assertEqual(list(self.dist), [(0.2, 'album'), (0.2, 'medium')]) + self.assertTrue(self.dist == 0.4) + self.assertTrue(self.dist < 1.0) + self.assertTrue(self.dist > 0.0) + self.assertEqual(self.dist - 0.4, 0.0) + self.assertEqual(0.4 - self.dist, 0.0) + self.assertEqual(float(self.dist), 0.4) + def test_raw_distance(self): config['match']['distance_weights']['album'] = 3.0 config['match']['distance_weights']['medium'] = 1.0 From 5ce996df0db8e2dc2fc4b4a85abbab0cd0d5257d Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Thu, 6 Jun 2013 10:18:01 +1000 Subject: [PATCH 45/64] Revert "Don't bypass candidate selection in timid mode. Always show all candidates." This reverts commit b02974f68f8e71d526aec16da5c1f74eaa48c7e9. --- beets/ui/commands.py | 2 +- docs/changelog.rst | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 96e67cde2..dfe3585c1 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -466,7 +466,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, # Is the change good enough? bypass_candidates = False - if rec != recommendation.none and not config['import']['timid']: + if rec != recommendation.none: match = candidates[0] bypass_candidates = True diff --git a/docs/changelog.rst b/docs/changelog.rst index 362bbd5b3..1daa09ef3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -72,7 +72,6 @@ Changelog together for better readability. * Don't show potential matches that have specific penalties applied, as configured by the :ref:`ignored` setting. - * Don't bypass candidate selection in timid mode. Always show all candidates. * Improve calculation of similarity score and recommendation: From c1ebae83bc44fb35e599813a30868e88c76a2d16 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Thu, 6 Jun 2013 10:44:24 +1000 Subject: [PATCH 46/64] Decouple `color_diff()` UI function from `string_dist()` matcher function. These are separate issues. We're still colorising case changes in light gray because these characters are effectively equivalent, but symbol and transliteration edits will continue to be colorised in red. --- beets/ui/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 21d56ad0d..460320a34 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -37,7 +37,6 @@ from beets.util.functemplate import Template from beets import config from beets.util import confit from beets.autotag import mb -from beets.autotag.match import string_dist # On Windows platforms, use colorama to support "ANSI" terminal colors. @@ -404,8 +403,8 @@ def _colordiff(a, b, highlight='red', second_highlight='lightgray'): a_out.append(colorize(highlight, a[a_start:a_end])) elif op == 'replace': # Right and left differ. Colorise with second highlight if - # there's no distance penalty. - if string_dist(a[a_start:a_end], b[b_start:b_end]): + # it's just a case change. + if a[a_start:a_end].lower() != b[b_start:b_end].lower(): color = highlight else: color = second_highlight From 11e8c3e784c405c4cad9edd9ce2b2a781469360c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 18:43:47 -0700 Subject: [PATCH 47/64] mpdupdate domain sockets (#313): changelog/docs --- docs/changelog.rst | 2 ++ docs/plugins/mpdupdate.rst | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9aa62d548..3c979c070 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -97,6 +97,8 @@ As usual, there were also lots of other great littler enhancements: Johannes Baiter. * The :ref:`fields-cmd` command shows template fields provided by plugins. Thanks again to Pedro Silva. +* :doc:`/plugins/mpdupdate`: You can now communicate with MPD over a Unix + domain socket. Thanks to John Hawthorn. And a batch of fixes: diff --git a/docs/plugins/mpdupdate.rst b/docs/plugins/mpdupdate.rst index 492660106..dca41fd22 100644 --- a/docs/plugins/mpdupdate.rst +++ b/docs/plugins/mpdupdate.rst @@ -17,3 +17,8 @@ MPD server. You can do that using an ``mpdupdate:`` section in your password: seekrit With that all in place, you'll see beets send the "update" command to your MPD server every time you change your beets library. + +If you want to communicate with MPD over a Unix domain socket instead over +TCP, just give the path to the socket in the filesystem for the ``host`` +setting. (Any ``host`` value starting with a slash is interpreted as a domain +socket.) From 78187cfcbaa34e835d861a9d3fa220845745c065 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 20:00:17 -0700 Subject: [PATCH 48/64] preferred_media: null default, expand docs Setting the default preferred_media to null is more like previous versions. This way, as digital becomes more popular, we aren't stuck with a default configuration that prefers an outdated format. --- beets/autotag/match.py | 19 ++++++++++--------- beets/config_default.yaml | 2 +- docs/changelog.rst | 5 ++--- docs/reference/config.rst | 9 ++++++--- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8935165f3..0e3d2ad13 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -274,15 +274,16 @@ def distance(items, album_info, mapping): dist_max += weights['year'].as_number() # Actual or preferred media. - preferred_media = config['match']['preferred_media'].get() - if likelies['media'] and album_info.media: - dist += string_dist(likelies['media'], album_info.media) * \ - weights['media'].as_number() - dist_max += weights['media'].as_number() - elif album_info.media and preferred_media: - dist += string_dist(album_info.media, preferred_media) * \ - weights['media'].as_number() - dist_max += weights['media'].as_number() + if album_info.media: + preferred_media = config['match']['preferred_media'].get() + if likelies['media']: + dist += string_dist(likelies['media'], album_info.media) * \ + weights['media'].as_number() + dist_max += weights['media'].as_number() + elif preferred_media: + dist += string_dist(album_info.media, preferred_media) * \ + weights['media'].as_number() + dist_max += weights['media'].as_number() # MusicBrainz album ID. if likelies['mb_albumid']: diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 7bbb16a6b..30b7bdac5 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -72,7 +72,7 @@ match: partial: medium tracklength: strong tracknumber: strong - preferred_media: CD + preferred_media: null weight: source: 2.0 artist: 3.0 diff --git a/docs/changelog.rst b/docs/changelog.rst index 3c979c070..364c7f029 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,9 +66,8 @@ same MusicBrainz release group: beets re-identify the same release when re-importing existing files. * Prefer releases that are closest to the tagged ``year``. Tolerate files tagged with release or original year. -* Prefer CD releases by default, when there is no ``media`` tagged in the - files being imported. This can be changed with the :ref:`preferred_media` - setting. +* The new :ref:`preferred_media` config option lets you prefer a certain media + type when the ``media`` field is unset on an album. * Apply minor penalties across a range of fields to differentiate between nearly identical releases: ``disctotal``, ``label``, ``catalognum``, ``country`` and ``albumdisambig``. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index d23db6b02..05ef16b4f 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -425,9 +425,12 @@ The above example shows the default ``max_rec`` settings. preferred_media ~~~~~~~~~~~~~~~ -When comparing files that have no ``media`` tagged, prefer releases that more -closely resemble this media (using a string distance). When files are already -tagged with media, this setting is ignored. Default: ``CD``. +When an album has its ``media`` field set, it is compared against matches to +prefer releases of the same media type. But this option lets you control what +happens when an album *doesn't* have ``media`` set (which is the case for most +albums that haven't already been run through a MusicBrainz tagger). Set this +option to ``CD``, for example, to prefer CD releases. Defaults to ``null``, +indicating no preference. .. _path-format-config: From e04cb6f5482dcecec4d51b029e52a8f4f39ce9bd Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 20:06:49 -0700 Subject: [PATCH 49/64] media field distance: binary, not string diff --- beets/autotag/match.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 0e3d2ad13..707fa2f67 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -275,14 +275,10 @@ def distance(items, album_info, mapping): # Actual or preferred media. if album_info.media: - preferred_media = config['match']['preferred_media'].get() - if likelies['media']: - dist += string_dist(likelies['media'], album_info.media) * \ - weights['media'].as_number() - dist_max += weights['media'].as_number() - elif preferred_media: - dist += string_dist(album_info.media, preferred_media) * \ - weights['media'].as_number() + compare_media = likelies['media'] or \ + config['match']['preferred_media'].get() + if compare_media and compare_media != album_info.media: + dist += weights['media'].as_number() dist_max += weights['media'].as_number() # MusicBrainz album ID. From 90873af0bea40f653e8bec7700613f0abd558a3e Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 20:57:27 -0700 Subject: [PATCH 50/64] media comparison: lower-case both sides --- beets/autotag/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 707fa2f67..bb00ee862 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -277,7 +277,7 @@ def distance(items, album_info, mapping): if album_info.media: compare_media = likelies['media'] or \ config['match']['preferred_media'].get() - if compare_media and compare_media != album_info.media: + if compare_media and compare_media.lower() != album_info.media.lower(): dist += weights['media'].as_number() dist_max += weights['media'].as_number() From d21a6aad83c31d2cfe5f413d7303ae244fe09f7d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 21:52:26 -0700 Subject: [PATCH 51/64] Added tag v1.2.0 for changeset b3f7b5267a2f --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 7fa4eb224..e73394121 100644 --- a/.hgtags +++ b/.hgtags @@ -19,3 +19,4 @@ f3cd4c138c6f40dc324a23bf01c4c7d97766477e 1.0rc2 f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2 8f070ce28a7b33d8509b29a8dbe937109bbdbd21 v1.1.0-beta.3 97f04ce252332dbda013cbc478d702d54a8fc1bd v1.1.0 +b3f7b5267a2f7b46b826d087421d7f4569211240 v1.2.0 From c5e8e7b52dc231632078d8f85a9a3f03606d39c1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 21:58:04 -0700 Subject: [PATCH 52/64] oops! forgot release date --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 364c7f029..b6ddf0270 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,8 +1,8 @@ Changelog ========= -1.2.0 (in development) ----------------------- +1.2.0 (June 5, 2013) +-------------------- There's a *lot* of new stuff in this release: new data sources for the autotagger, new plugins to look for problems in your library, tracking the From 31d1d90e4ceaac34b8b82fb087573e4daa732db5 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Jun 2013 21:58:08 -0700 Subject: [PATCH 53/64] Added tag v1.2.0 for changeset ecff182221ec --- .hgtags | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.hgtags b/.hgtags index e73394121..2d04bb17f 100644 --- a/.hgtags +++ b/.hgtags @@ -20,3 +20,5 @@ f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2 8f070ce28a7b33d8509b29a8dbe937109bbdbd21 v1.1.0-beta.3 97f04ce252332dbda013cbc478d702d54a8fc1bd v1.1.0 b3f7b5267a2f7b46b826d087421d7f4569211240 v1.2.0 +b3f7b5267a2f7b46b826d087421d7f4569211240 v1.2.0 +ecff182221ec32a9f6549ad3ce8d2ab4c3e5568a v1.2.0 From daec2e68067e5508fbc5efde1a9c89fe7aa89d26 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 6 Jun 2013 10:17:46 -0700 Subject: [PATCH 54/64] version bump: 1.2.1 --- beets/__init__.py | 2 +- docs/changelog.rst | 6 ++++++ docs/conf.py | 2 +- setup.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 02e7ad204..7a1cc1b92 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -__version__ = '1.2.0' +__version__ = '1.2.1' __author__ = 'Adrian Sampson ' import beets.library diff --git a/docs/changelog.rst b/docs/changelog.rst index b6ddf0270..71d2d0a08 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,12 @@ Changelog ========= +1.2.1 (in development) +---------------------- + +Coming soon. + + 1.2.0 (June 5, 2013) -------------------- diff --git a/docs/conf.py b/docs/conf.py index 0136ac23e..81ae4e9e6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,7 +13,7 @@ project = u'beets' copyright = u'2012, Adrian Sampson' version = '1.2' -release = '1.2.0' +release = '1.2.1' pygments_style = 'sphinx' diff --git a/setup.py b/setup.py index 350bc6cd1..86fd375bb 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ if 'sdist' in sys.argv: shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', - version='1.2.0', + version='1.2.1', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', From e3472a51505e51a4791181edcf1498d792104945 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 6 Jun 2013 10:23:18 -0700 Subject: [PATCH 55/64] move changelog for #302 to 1.2.1 section --- docs/changelog.rst | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9c8a733aa..8b46a7aa1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,29 @@ Changelog 1.2.1 (in development) ---------------------- -Coming soon. +This release introduces a major internal change in the way that similarity +scores are handled. The changes you'll notice while using the autotagger +are: + +* The top 3 distance penalties are now displayed on the release listing, + and all album and track penalties are now displayed on the track changes + list. This should make it clear exactly which metadata is contributing to a + low similarity score. +* Even more consistent format and colorization of album and track metadata. Red + for an actual difference, yellow to indicate that a distance penalty is being + applied, and light gray for no-penalty or disambiguation data. + +There are also three new configuration options that let you customize the way +that matches are selected: + +* Don't show potential matches that have specific penalties applied, as + configured by the :ref:`ignored` setting. +* Add a :ref:`preferred` collection of settings, which allow the user to + specify a sorted list of preferred countries and media types, or prefer + releases closest to the original year for an album. +* It is now possible to configure a :ref:`max_rec` for any field that is used + to calculate the similarity score. The recommendation will be downgraded if + a penalty is being applied to the specified field. 1.2.0 (June 5, 2013) @@ -49,26 +71,19 @@ There are also three more big features added to beets core: In addition, the importer saw various UI enhancements, thanks to Tai Lee: +* More consistent format and colorization of album and track metadata. * Display data source URL for matches from the new data source plugins. This should make it easier to migrate data from Discogs or Beatport into MusicBrainz. -* The top 3 distance penalties are now displayed on the release listing, - and all album and track penalties are now displayed on the track changes - list. This should make it clear exactly which metadata is contributing to a - low similarity score. * Display album disambiguation and disc titles in the track listing, when available. -* More consistent format and colorization of album and track metadata. Red - for an actual difference, yellow to indicate that a distance penalty is being - applied, and light gray for no-penalty or disambiguation data. * Track changes are highlighted in yellow when they indicate a change in format to or from the style of :ref:`per_disc_numbering`. (As before, no penalty is applied because the track number is still "correct", just in a different format.) * Sort missing and unmatched tracks by index and title and group them together for better readability. -* Don't show potential matches that have specific penalties applied, as - configured by the :ref:`ignored` setting. +* Indicate MusicBrainz ID mismatches. The calculation of the similarity score for autotagger matches was also improved, again thanks to Tai Lee. These changes, in general, help deal with @@ -79,12 +94,8 @@ same MusicBrainz release group: beets re-identify the same release when re-importing existing files. * Prefer releases that are closest to the tagged ``year``. Tolerate files tagged with release or original year. -* Add a :ref:`preferred` collection of settings, which allow the user to - specify a sorted list of preferred countries and media types, or prefer - releases closest to the original year for an album. -* It is now possible to configure a :ref:`max_rec` for any field that is used - to calculate the similarity score. The recommendation will be downgraded if - a penalty is being applied to the specified field. +* The new :ref:`preferred_media` config option lets you prefer a certain media + type when the ``media`` field is unset on an album. * Apply minor penalties across a range of fields to differentiate between nearly identical releases: ``disctotal``, ``label``, ``catalognum``, ``country`` and ``albumdisambig``. From fa40fd910836845f6f329a867c91d4aaf25b460a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 6 Jun 2013 10:34:20 -0700 Subject: [PATCH 56/64] beatport: use new Distance objects (#302) This also brought to light the fact that the distance calculations for tracks was incorrect because there's no source field on TrackInfo objects yet. This needs to be fixed. --- beetsplug/beatport.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 5cabfef87..908398d99 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -20,8 +20,8 @@ from datetime import datetime, timedelta import requests -from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.autotag.match import Distance from beets.plugins import BeetsPlugin log = logging.getLogger('beets') @@ -161,20 +161,16 @@ class BeatportPlugin(BeetsPlugin): """Returns the beatport source weight and the maximum source weight for albums. """ + dist = Distance() if album_info.data_source == 'Beatport': - return self.config['source_weight'].as_number() * \ - config['match']['weight']['source'].as_number(), \ - config['match']['weight']['source'].as_number() - else: - return 0.0, 0.0 + dist.add('source', self.config['source_weight'].as_number()) + return dist def track_distance(self, item, info): """Returns the beatport source weight and the maximum source weight for individual tracks. """ - return self.config['source_weight'].as_number() * \ - config['match']['weight']['source'].as_number(), \ - config['match']['weight']['source'].as_number() + return Distance() # FIXME: Need source information for tracks. def candidates(self, items, artist, release, va_likely): """Returns a list of AlbumInfo objects for beatport search results From 1eb588f743084be92c9c63898c7ba2fcf20f1236 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 6 Jun 2013 10:35:29 -0700 Subject: [PATCH 57/64] use equality for media comparisons again --- beets/autotag/match.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 59f0d00f4..cc32d6e8f 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -419,14 +419,16 @@ def distance(items, album_info, mapping): # Album. dist.add_string('album', likelies['album'], album_info.album) - # Preferred media. - patterns = config['match']['preferred']['media'].as_str_seq() - options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns] - if album_info.media and options: - dist.add_priority('media', album_info.media, options) - # Media. - elif likelies['media'] and album_info.media: - dist.add_string('media', likelies['media'], album_info.media) + # Current or preferred media. + if album_info.media: + # Preferred media options. + patterns = config['match']['preferred']['media'].as_str_seq() + options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns] + if options: + dist.add_priority('media', album_info.media, options) + # Current media. + elif likelies['media']: + dist.add_equality('media', album_info.media, likelies['media']) # Mediums. if likelies['disctotal'] and album_info.mediums: From 884c596f46f7c53b625274b83885d1e83142aa1c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 6 Jun 2013 10:44:30 -0700 Subject: [PATCH 58/64] clarify changelog for distance refactor (#302) --- docs/changelog.rst | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8b46a7aa1..d18cca9a7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,28 +5,29 @@ Changelog ---------------------- This release introduces a major internal change in the way that similarity -scores are handled. The changes you'll notice while using the autotagger -are: +scores are handled, thanks to the continued efforts of Tai Lee. The changes +you'll notice while using the autotagger are: * The top 3 distance penalties are now displayed on the release listing, and all album and track penalties are now displayed on the track changes list. This should make it clear exactly which metadata is contributing to a low similarity score. -* Even more consistent format and colorization of album and track metadata. Red - for an actual difference, yellow to indicate that a distance penalty is being - applied, and light gray for no-penalty or disambiguation data. +* When displaying differences, the colorization has been made more consistent + and helpful: red for an actual difference, yellow to indicate that a + distance penalty is being applied, and light gray for no penalty (e.g., case + changes) or disambiguation data. -There are also three new configuration options that let you customize the way -that matches are selected: +There are also three new (or overhauled) configuration options that let you +customize the way that matches are selected: -* Don't show potential matches that have specific penalties applied, as - configured by the :ref:`ignored` setting. -* Add a :ref:`preferred` collection of settings, which allow the user to - specify a sorted list of preferred countries and media types, or prefer - releases closest to the original year for an album. -* It is now possible to configure a :ref:`max_rec` for any field that is used - to calculate the similarity score. The recommendation will be downgraded if - a penalty is being applied to the specified field. +* The :ref:`ignored` setting lets you instruct the importer not to show you + matches that have a certain penalty applied. +* The :ref:`preferred` collection of settings specifies a sorted list of + preferred countries and media types, or prefer releases closest to the + original year for an album. +* The :ref:`max_rec` settings can now be used for any distance penalty + component. The recommendation will be downgraded if a penalty is being + applied to the specified field. 1.2.0 (June 5, 2013) From 0009c51577f064bef00c7fa87799cf52b7b98f71 Mon Sep 17 00:00:00 2001 From: Wessie Date: Fri, 7 Jun 2013 15:58:36 +0200 Subject: [PATCH 59/64] Made formatting strings compatible with python 2.6 Also fixed styling according to pep8 --- beetsplug/beatport.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 908398d99..8a80fb5ac 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -68,7 +68,7 @@ class BeatportSearch(object): release_type = None def __unicode__(self): - return u"".format( + return u"".format( self.release_type, self.query, len(self.results)) def __init__(self, query, release_type='release', details=True): @@ -76,7 +76,7 @@ class BeatportSearch(object): self.query = query self.release_type = release_type response = BeatportAPI.get('catalog/3/search', query=query, - facets=['fieldType:{}' + facets=['fieldType:{0}' .format(release_type)], perPage=5) for item in response: @@ -97,8 +97,10 @@ class BeatportRelease(BeatportObject): artist_str = ", ".join(x[1] for x in self.artists) else: artist_str = "Various Artists" - return u"".format(artist_str, self.name, - self.catalog_number) + return u"".format( + artist_str, self.name, + self.catalog_number + ) def __init__(self, data): BeatportObject.__init__(self, data) @@ -109,7 +111,7 @@ class BeatportRelease(BeatportObject): if 'category' in data: self.category = data['category'] if 'slug' in data: - self.url = "http://beatport.com/release/{}/{}".format( + self.url = "http://beatport.com/release/{0}/{1}".format( data['slug'], data['id']) @classmethod @@ -129,8 +131,8 @@ class BeatportTrack(BeatportObject): def __unicode__(self): artist_str = ", ".join(x[1] for x in self.artists) - return u"".format(artist_str, self.name, - self.mix_name) + return u"".format( + artist_str, self.name, self.mix_name) def __init__(self, data): BeatportObject.__init__(self, data) @@ -141,7 +143,7 @@ class BeatportTrack(BeatportObject): if 'length' in data: self.length = timedelta(milliseconds=data['lengthMs']) if 'slug' in data: - self.url = "http://beatport.com/track/{}/{}".format( + self.url = "http://beatport.com/track/{0}/{1}".format( data['slug'], data['id']) @classmethod @@ -202,7 +204,7 @@ class BeatportPlugin(BeetsPlugin): or None if the release is not found. """ log.debug('Searching Beatport for release %s' % str(release_id)) - match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id) + match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id) if not match: return None release = BeatportRelease.from_id(match.group(2)) @@ -214,7 +216,7 @@ class BeatportPlugin(BeetsPlugin): or None if the track is not found. """ log.debug('Searching Beatport for track %s' % str(track_id)) - match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id) + match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id) if not match: return None bp_track = BeatportTrack.from_id(match.group(2)) @@ -261,7 +263,7 @@ class BeatportPlugin(BeetsPlugin): """ title = track.name if track.mix_name != u"Original Mix": - title += u" ({})".format(track.mix_name) + title += u" ({0})".format(track.mix_name) artist, artist_id = self._get_artist(track.artists) length = track.length.total_seconds() From 42efd2a7618f8ce3dc5056e906164d334d713615 Mon Sep 17 00:00:00 2001 From: Tai Lee Date: Sat, 8 Jun 2013 00:32:40 +1000 Subject: [PATCH 60/64] Change log for #316 and code style tweaks. --- beetsplug/beatport.py | 17 ++++++++--------- docs/changelog.rst | 4 ++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 8a80fb5ac..158cec09c 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -68,7 +68,7 @@ class BeatportSearch(object): release_type = None def __unicode__(self): - return u"".format( + return u''.format( self.release_type, self.query, len(self.results)) def __init__(self, query, release_type='release', details=True): @@ -97,10 +97,9 @@ class BeatportRelease(BeatportObject): artist_str = ", ".join(x[1] for x in self.artists) else: artist_str = "Various Artists" - return u"".format( - artist_str, self.name, - self.catalog_number - ) + return u"".format(artist_str, + self.name, + self.catalog_number) def __init__(self, data): BeatportObject.__init__(self, data) @@ -131,8 +130,8 @@ class BeatportTrack(BeatportObject): def __unicode__(self): artist_str = ", ".join(x[1] for x in self.artists) - return u"".format( - artist_str, self.name, self.mix_name) + return u"".format(artist_str, self.name, + self.mix_name) def __init__(self, data): BeatportObject.__init__(self, data) @@ -143,8 +142,8 @@ class BeatportTrack(BeatportObject): if 'length' in data: self.length = timedelta(milliseconds=data['lengthMs']) if 'slug' in data: - self.url = "http://beatport.com/track/{0}/{1}".format( - data['slug'], data['id']) + self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'], + data['id']) @classmethod def from_id(cls, beatport_id): diff --git a/docs/changelog.rst b/docs/changelog.rst index d18cca9a7..e7b361a07 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,10 @@ customize the way that matches are selected: component. The recommendation will be downgraded if a penalty is being applied to the specified field. +And some bug fixes: + +* Python 2.6 compatibility for :doc:`/plugins/beatport`. Thanks Wesley Bitter. + 1.2.0 (June 5, 2013) -------------------- From 5015f6a21d7c4ab7430f532ad12a16a6c4b56b49 Mon Sep 17 00:00:00 2001 From: Theofilos Intzoglou Date: Fri, 7 Jun 2013 19:09:33 +0300 Subject: [PATCH 61/64] Don't rename the config file if there is nothing to migrate from --- beets/ui/migrate.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/beets/ui/migrate.py b/beets/ui/migrate.py index fea9c13b1..784d7c827 100644 --- a/beets/ui/migrate.py +++ b/beets/ui/migrate.py @@ -251,6 +251,15 @@ def migrate_config(replace=False): config.yaml will be moved aside. Otherwise, the process is aborted when the file exists. """ + + # Load legacy configuration data, if any. + config, configpath = get_config() + if not config: + log.debug(u'no config file found at {0}'.format( + util.displayable_path(configpath) + )) + return + # Get the new configuration file path and possibly move it out of # the way. destfn = os.path.join(beets.config.config_dir(), confit.CONFIG_FILENAME) @@ -264,13 +273,6 @@ def migrate_config(replace=False): # File exists and we won't replace it. We're done. return - # Load legacy configuration data, if any. - config, configpath = get_config() - if not config: - log.debug(u'no config file found at {0}'.format( - util.displayable_path(configpath) - )) - return log.debug(u'migrating config file {0}'.format( util.displayable_path(configpath) )) From 6baaa7e06ec67fbdf2cbd876c3cc184a348bffe6 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 7 Jun 2013 11:59:08 -0600 Subject: [PATCH 62/64] Changelog for migration fix (#317) Eventually, we should just remove the migration code. Not sure when, however. --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e7b361a07..37eae87e7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,8 @@ customize the way that matches are selected: And some bug fixes: * Python 2.6 compatibility for :doc:`/plugins/beatport`. Thanks Wesley Bitter. +* Don't move the config file during a null migration. Thanks to Theofilos + Intzoglou. 1.2.0 (June 5, 2013) From 847edcd6ccf4897facd0fa87eedb552409d9e40f Mon Sep 17 00:00:00 2001 From: Timothy Appnel Date: Fri, 7 Jun 2013 23:43:21 -0400 Subject: [PATCH 63/64] Fix bug where a null value for lengthMS would crash an import. Added a fallback to parse the standard min:sec length field. --- beetsplug/beatport.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 158cec09c..1e26baac2 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -139,8 +139,10 @@ class BeatportTrack(BeatportObject): self.title = unicode(data['title']) if 'mixName' in data: self.mix_name = unicode(data['mixName']) - if 'length' in data: - self.length = timedelta(milliseconds=data['lengthMs']) + self.length = timedelta(milliseconds=data.get('lengthMs',0) or 0) + if self.length == 0: + (min, sec) = data.get('length','0:0').split(':') + self.length = timedelta(minutes=min, seconds=sec) if 'slug' in data: self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'], data['id']) From d1ebe423c9693ba78f9330f60c11885d460f67c8 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 7 Jun 2013 20:53:53 -0700 Subject: [PATCH 64/64] changelog/thanks/style for #319 --- beetsplug/beatport.py | 11 +++++++---- docs/changelog.rst | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 1e26baac2..c68901cc1 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -139,10 +139,13 @@ class BeatportTrack(BeatportObject): self.title = unicode(data['title']) if 'mixName' in data: self.mix_name = unicode(data['mixName']) - self.length = timedelta(milliseconds=data.get('lengthMs',0) or 0) - if self.length == 0: - (min, sec) = data.get('length','0:0').split(':') - self.length = timedelta(minutes=min, seconds=sec) + self.length = timedelta(milliseconds=data.get('lengthMs', 0) or 0) + if not self.length: + try: + min, sec = data.get('length', '0:0').split(':') + self.length = timedelta(minutes=int(min), seconds=int(sec)) + except ValueError: + pass if 'slug' in data: self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'], data['id']) diff --git a/docs/changelog.rst b/docs/changelog.rst index 37eae87e7..784d2eb88 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,8 @@ And some bug fixes: * Python 2.6 compatibility for :doc:`/plugins/beatport`. Thanks Wesley Bitter. * Don't move the config file during a null migration. Thanks to Theofilos Intzoglou. +* Fix an occasional crash in the :doc:`/plugins/beatport` when a length + field was missing from the API response. Thanks to Timothy Appnel. 1.2.0 (June 5, 2013)