diff -Nru beets-1.3.19/beets/art.py beets-1.4.6/beets/art.py --- beets-1.3.19/beets/art.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/art.py 2016-12-17 03:01:22.000000000 +0000 @@ -22,7 +22,6 @@ import subprocess import platform from tempfile import NamedTemporaryFile -import imghdr import os from beets.util import displayable_path, syspath, bytestring_path @@ -128,10 +127,10 @@ # to grayscale and then pipe them into the `compare` command. # On Windows, ImageMagick doesn't support the magic \\?\ prefix # on paths, so we pass `prefix=False` to `syspath`. - convert_cmd = [b'convert', syspath(imagepath, prefix=False), + convert_cmd = ['convert', syspath(imagepath, prefix=False), syspath(art, prefix=False), - b'-colorspace', b'gray', b'MIFF:-'] - compare_cmd = [b'compare', b'-metric', b'PHASH', b'-', b'null:'] + '-colorspace', 'gray', 'MIFF:-'] + compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:'] log.debug(u'comparing images with pipeline {} | {}', convert_cmd, compare_cmd) convert_proc = subprocess.Popen( @@ -180,7 +179,7 @@ log.debug(u'IM output is not a number: {0!r}', out_str) return - log.debug(u'ImageMagick copmare score: {0}', phash_diff) + log.debug(u'ImageMagick compare score: {0}', phash_diff) return phash_diff <= compare_threshold return True @@ -194,7 +193,7 @@ return # Add an extension to the filename. - ext = imghdr.what(None, h=art) + ext = mediafile.image_extension(art) if not ext: log.warning(u'Unknown image type in {0}.', displayable_path(item.path)) diff -Nru beets-1.3.19/beets/autotag/hooks.py beets-1.4.6/beets/autotag/hooks.py --- beets-1.3.19/beets/autotag/hooks.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beets/autotag/hooks.py 2017-06-20 19:15:08.000000000 +0000 @@ -27,6 +27,7 @@ from beets.autotag import mb from jellyfish import levenshtein_distance from unidecode import unidecode +import six log = logging.getLogger('beets') @@ -106,7 +107,7 @@ # Work around a bug in python-musicbrainz-ngs that causes some # strings to be bytes rather than Unicode. # https://github.com/alastair/python-musicbrainz-ngs/issues/85 - def decode(self, codec='utf8'): + def decode(self, codec='utf-8'): """Ensure that all string attributes on this object, and the constituent `TrackInfo` objects, are decoded to Unicode. """ @@ -141,6 +142,11 @@ - ``artist_credit``: Recording-specific artist name - ``data_source``: The original data source (MusicBrainz, Discogs, etc.) - ``data_url``: The data source release URL. + - ``lyricist``: individual track lyricist name + - ``composer``: individual track composer name + - ``composer_sort``: individual track composer sort name + - ``arranger`: individual track arranger name + - ``track_alt``: alternative track number (tape, vinyl, etc.) Only ``title`` and ``track_id`` are required. The rest of the fields may be None. The indices ``index``, ``medium``, and ``medium_index`` @@ -150,7 +156,8 @@ length=None, index=None, medium=None, medium_index=None, medium_total=None, artist_sort=None, disctitle=None, artist_credit=None, data_source=None, data_url=None, - media=None): + media=None, lyricist=None, composer=None, composer_sort=None, + arranger=None, track_alt=None): self.title = title self.track_id = track_id self.artist = artist @@ -166,9 +173,14 @@ self.artist_credit = artist_credit self.data_source = data_source self.data_url = data_url + self.lyricist = lyricist + self.composer = composer + self.composer_sort = composer_sort + self.arranger = arranger + self.track_alt = track_alt # As above, work around a bug in python-musicbrainz-ngs. - def decode(self, codec='utf8'): + def decode(self, codec='utf-8'): """Ensure that all string attributes on this object are decoded to Unicode. """ @@ -205,8 +217,8 @@ transliteration/lowering to ASCII characters. Normalized by string length. """ - assert isinstance(str1, unicode) - assert isinstance(str2, unicode) + assert isinstance(str1, six.text_type) + assert isinstance(str2, six.text_type) str1 = as_string(unidecode(str1)) str2 = as_string(unidecode(str2)) str1 = re.sub(r'[^a-z0-9]', '', str1.lower()) @@ -291,6 +303,7 @@ @total_ordering +@six.python_2_unicode_compatible class Distance(object): """Keeps track of multiple distance penalties. Provides a single weighted distance for all penalties as well as a weighted distance @@ -326,7 +339,7 @@ """Return the maximum distance penalty (normalization factor). """ dist_max = 0.0 - for key, penalty in self._penalties.iteritems(): + for key, penalty in self._penalties.items(): dist_max += len(penalty) * self._weights[key] return dist_max @@ -335,7 +348,7 @@ """Return the raw (denormalized) distance. """ dist_raw = 0.0 - for key, penalty in self._penalties.iteritems(): + for key, penalty in self._penalties.items(): dist_raw += sum(penalty) * self._weights[key] return dist_raw @@ -377,7 +390,7 @@ def __rsub__(self, other): return other - self.distance - def __unicode__(self): + def __str__(self): return "{0:.2f}".format(self.distance) # Behave like a dict. @@ -407,7 +420,7 @@ raise ValueError( u'`dist` must be a Distance object, not {0}'.format(type(dist)) ) - for key, penalties in dist._penalties.iteritems(): + for key, penalties in dist._penalties.items(): self._penalties.setdefault(key, []).extend(penalties) # Adding components. @@ -523,10 +536,7 @@ if the ID is not found. """ try: - album = mb.album_for_id(release_id) - if album: - plugins.send(u'albuminfo_received', info=album) - return album + return mb.album_for_id(release_id) except mb.MusicBrainzAPIError as exc: exc.log(log) @@ -536,34 +546,34 @@ if the ID is not found. """ try: - track = mb.track_for_id(recording_id) - if track: - plugins.send(u'trackinfo_received', info=track) - return track + return mb.track_for_id(recording_id) except mb.MusicBrainzAPIError as exc: exc.log(log) +@plugins.notify_info_yielded(u'albuminfo_received') def albums_for_id(album_id): """Get a list of albums for an ID.""" - candidates = [album_for_mbid(album_id)] - plugin_albums = plugins.album_for_id(album_id) - for a in plugin_albums: - plugins.send(u'albuminfo_received', info=a) - candidates.extend(plugin_albums) - return [a for a in candidates if a] + a = album_for_mbid(album_id) + if a: + yield a + for a in plugins.album_for_id(album_id): + if a: + yield a +@plugins.notify_info_yielded(u'trackinfo_received') def tracks_for_id(track_id): """Get a list of tracks for an ID.""" - candidates = [track_for_mbid(track_id)] - plugin_tracks = plugins.track_for_id(track_id) - for t in plugin_tracks: - plugins.send(u'trackinfo_received', info=t) - candidates.extend(plugin_tracks) - return [t for t in candidates if t] + t = track_for_mbid(track_id) + if t: + yield t + for t in plugins.track_for_id(track_id): + if t: + yield t +@plugins.notify_info_yielded(u'albuminfo_received') 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 @@ -571,51 +581,42 @@ entered by the user. ``va_likely`` is a boolean indicating whether the album is likely to be a "various artists" release. """ - out = [] - # Base candidates if we have album and artist to match. if artist and album: try: - out.extend(mb.match_album(artist, album, len(items))) + for candidate in mb.match_album(artist, album, len(items)): + yield candidate except mb.MusicBrainzAPIError as exc: exc.log(log) # Also add VA matches from MusicBrainz where appropriate. if va_likely and album: try: - out.extend(mb.match_album(None, album, len(items))) + for candidate in mb.match_album(None, album, len(items)): + yield candidate except mb.MusicBrainzAPIError as exc: exc.log(log) # Candidates from plugins. - out.extend(plugins.candidates(items, artist, album, va_likely)) - - # Notify subscribed plugins about fetched album info - for a in out: - plugins.send(u'albuminfo_received', info=a) - - return out + for candidate in plugins.candidates(items, artist, album, va_likely): + yield candidate +@plugins.notify_info_yielded(u'trackinfo_received') 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. """ - out = [] # MusicBrainz candidates. if artist and title: try: - out.extend(mb.match_track(artist, title)) + for candidate in mb.match_track(artist, title): + yield candidate except mb.MusicBrainzAPIError as exc: exc.log(log) # Plugin candidates. - out.extend(plugins.item_candidates(item, artist, title)) - - # Notify subscribed plugins about fetched track info - for i in out: - plugins.send(u'trackinfo_received', info=i) - - return out + for candidate in plugins.item_candidates(item, artist, title): + yield candidate diff -Nru beets-1.3.19/beets/autotag/__init__.py beets-1.4.6/beets/autotag/__init__.py --- beets-1.3.19/beets/autotag/__init__.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/autotag/__init__.py 2017-06-20 19:15:08.000000000 +0000 @@ -23,7 +23,7 @@ # Parts of external interface. from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa -from .match import tag_item, tag_album # noqa +from .match import tag_item, tag_album, Proposal # noqa from .match import Recommendation # noqa # Global logger. @@ -44,6 +44,16 @@ item.mb_artistid = track_info.artist_id if track_info.data_source: item.data_source = track_info.data_source + + if track_info.lyricist is not None: + item.lyricist = track_info.lyricist + if track_info.composer is not None: + item.composer = track_info.composer + if track_info.composer_sort is not None: + item.composer_sort = track_info.composer_sort + if track_info.arranger is not None: + item.arranger = track_info.arranger + # At the moment, the other metadata is left intact (including album # and track number). Perhaps these should be emptied? @@ -52,7 +62,7 @@ """Set the items' metadata to match an AlbumInfo object using a mapping from Items to TrackInfo objects. """ - for item, track_info in mapping.iteritems(): + for item, track_info in mapping.items(): # Album, artist, track count. if track_info.artist: item.artist = track_info.artist @@ -97,8 +107,9 @@ if config['per_disc_numbering']: # We want to let the track number be zero, but if the medium index # is not provided we need to fall back to the overall index. - item.track = track_info.medium_index - if item.track is None: + if track_info.medium_index is not None: + item.track = track_info.medium_index + else: item.track = track_info.index item.tracktotal = track_info.medium_total or len(album_info.tracks) else: @@ -141,3 +152,14 @@ if track_info.media is not None: item.media = track_info.media + + if track_info.lyricist is not None: + item.lyricist = track_info.lyricist + if track_info.composer is not None: + item.composer = track_info.composer + if track_info.composer_sort is not None: + item.composer_sort = track_info.composer_sort + if track_info.arranger is not None: + item.arranger = track_info.arranger + + item.track_alt = track_info.track_alt diff -Nru beets-1.3.19/beets/autotag/match.py beets-1.4.6/beets/autotag/match.py --- beets-1.3.19/beets/autotag/match.py 2016-06-20 17:08:57.000000000 +0000 +++ beets-1.4.6/beets/autotag/match.py 2017-06-14 23:13:48.000000000 +0000 @@ -22,6 +22,7 @@ import datetime import re from munkres import Munkres +from collections import namedtuple from beets import logging from beets import plugins @@ -29,7 +30,6 @@ from beets.util import plurality from beets.autotag import hooks from beets.util.enumeration import OrderedEnum -from functools import reduce # Artist signals that indicate "various artists". These are used at the # album level to determine whether a given release is likely a VA @@ -53,6 +53,13 @@ strong = 3 +# A structure for holding a set of possible matches to choose between. This +# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo +# objects) and a recommendation value. + +Proposal = namedtuple('Proposal', ('candidates', 'recommendation')) + + # Primary matching functionality. def current_metadata(items): @@ -96,7 +103,9 @@ costs.append(row) # Find a minimum-cost bipartite matching. + log.debug('Computing track assignment...') matching = Munkres().compute(costs) + log.debug('...done.') # Produce the output matching. mapping = dict((items[i], tracks[j]) for (i, j) in matching) @@ -238,7 +247,7 @@ # Tracks. dist.tracks = {} - for item, track in mapping.iteritems(): + for item, track in mapping.items(): dist.tracks[track] = track_distance(item, track, album_info.va) dist.add('tracks', dist.tracks[track].distance) @@ -261,19 +270,23 @@ AlbumInfo object for the corresponding album. Otherwise, returns None. """ - # Is there a consensus on the MB album ID? - albumids = [item.mb_albumid for item in items if item.mb_albumid] - if not albumids: - log.debug(u'No album IDs found.') + albumids = (item.mb_albumid for item in items if item.mb_albumid) + + # Did any of the items have an MB album ID? + try: + first = next(albumids) + except StopIteration: + log.debug(u'No album ID found.') return None + # Is there a consensus on the MB album ID? + for other in albumids: + if other != first: + log.debug(u'No album ID consensus.') + return None # If all album IDs are equal, look up the album. - if bool(reduce(lambda x, y: x if x == y else (), albumids)): - albumid = albumids[0] - log.debug(u'Searching for discovered album ID: {0}', albumid) - return hooks.album_for_mbid(albumid) - else: - log.debug(u'No album ID consensus.') + log.debug(u'Searching for discovered album ID: {0}', first) + return hooks.album_for_mbid(first) def _recommendation(results): @@ -312,10 +325,10 @@ keys = set(min_dist.keys()) if isinstance(results[0], hooks.AlbumMatch): for track_dist in min_dist.tracks.values(): - keys.update(track_dist.keys()) + keys.update(list(track_dist.keys())) max_rec_view = config['match']['max_rec'] for key in keys: - if key in max_rec_view.keys(): + if key in list(max_rec_view.keys()): max_rec = max_rec_view[key].as_choice({ 'strong': Recommendation.strong, 'medium': Recommendation.medium, @@ -327,13 +340,19 @@ return rec +def _sort_candidates(candidates): + """Sort candidates by distance.""" + return sorted(candidates, key=lambda match: match.distance) + + def _add_candidate(items, results, info): """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for duplicates, and calculating the distance. """ - log.debug(u'Candidate: {0} - {1}', info.artist, info.album) + log.debug(u'Candidate: {0} - {1} ({2})', + info.artist, info.album, info.album_id) # Discard albums with zero tracks. if not info.tracks: @@ -371,9 +390,8 @@ def tag_album(items, search_artist=None, search_album=None, search_ids=[]): - """Return a tuple of a artist name, an album name, a list of - `AlbumMatch` candidates from the metadata backend, and a - `Recommendation`. + """Return a tuple of the current artist name, the current album + name, and a `Proposal` containing `AlbumMatch` candidates. The artist and album are the most common values of these fields among `items`. @@ -401,10 +419,10 @@ # Search by explicit ID. if search_ids: - search_cands = [] for search_id in search_ids: log.debug(u'Searching for album ID: {0}', search_id) - search_cands.extend(hooks.albums_for_id(search_id)) + for id_candidate in hooks.albums_for_id(search_id): + _add_candidate(items, candidates, id_candidate) # Use existing metadata or text search. else: @@ -420,7 +438,8 @@ # matches. if rec == Recommendation.strong: log.debug(u'ID match.') - return cur_artist, cur_album, candidates.values(), rec + return cur_artist, cur_album, \ + Proposal(list(candidates.values()), rec) # Search terms. if not (search_artist and search_album): @@ -435,24 +454,25 @@ log.debug(u'Album might be VA: {0}', va_likely) # Get the results from the data sources. - search_cands = hooks.album_candidates(items, search_artist, - search_album, va_likely) - - log.debug(u'Evaluating {0} candidates.', len(search_cands)) - for info in search_cands: - _add_candidate(items, candidates, info) + for matched_candidate in hooks.album_candidates(items, + search_artist, + search_album, + va_likely): + _add_candidate(items, candidates, matched_candidate) + log.debug(u'Evaluating {0} candidates.', len(candidates)) # Sort and get the recommendation. - candidates = sorted(candidates.itervalues()) + candidates = _sort_candidates(candidates.values()) rec = _recommendation(candidates) - return cur_artist, cur_album, candidates, rec + return cur_artist, cur_album, Proposal(candidates, rec) def tag_item(item, search_artist=None, search_title=None, search_ids=[]): - """Attempts to find metadata for a single track. Returns a - `(candidates, recommendation)` pair where `candidates` is a list of - TrackMatch objects. `search_artist` and `search_title` may be used + """Find metadata for a single track. Return a `Proposal` consisting + of `TrackMatch` objects. + + `search_artist` and `search_title` may be used to override the current metadata for the purposes of the MusicBrainz title. `search_ids` may be used for restricting the search to a list of metadata backend IDs. @@ -471,18 +491,18 @@ candidates[track_info.track_id] = \ hooks.TrackMatch(dist, track_info) # If this is a good match, then don't keep searching. - rec = _recommendation(sorted(candidates.itervalues())) + rec = _recommendation(_sort_candidates(candidates.values())) if rec == Recommendation.strong and \ not config['import']['timid']: log.debug(u'Track ID match.') - return sorted(candidates.itervalues()), rec + return Proposal(_sort_candidates(candidates.values()), rec) # If we're searching by ID, don't proceed. if search_ids: if candidates: - return sorted(candidates.itervalues()), rec + return Proposal(_sort_candidates(candidates.values()), rec) else: - return [], Recommendation.none + return Proposal([], Recommendation.none) # Search terms. if not (search_artist and search_title): @@ -496,6 +516,6 @@ # Sort by distance and return with recommendation. log.debug(u'Found {0} candidates.', len(candidates)) - candidates = sorted(candidates.itervalues()) + candidates = _sort_candidates(candidates.values()) rec = _recommendation(candidates) - return candidates, rec + return Proposal(candidates, rec) diff -Nru beets-1.3.19/beets/autotag/mb.py beets-1.4.6/beets/autotag/mb.py --- beets-1.3.19/beets/autotag/mb.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beets/autotag/mb.py 2017-08-16 18:25:59.000000000 +0000 @@ -20,16 +20,21 @@ import musicbrainzngs import re import traceback -from urlparse import urljoin +from six.moves.urllib.parse import urljoin from beets import logging import beets.autotag.hooks import beets from beets import util from beets import config +import six VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377' -BASE_URL = 'http://musicbrainz.org/' + +if util.SNI_SUPPORTED: + BASE_URL = 'https://musicbrainz.org/' +else: + BASE_URL = 'http://musicbrainz.org/' musicbrainzngs.set_useragent('beets', beets.__version__, 'http://beets.io/') @@ -53,8 +58,12 @@ log = logging.getLogger('beets') RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', - 'labels', 'artist-credits', 'aliases'] + 'labels', 'artist-credits', 'aliases', + 'recording-level-rels', 'work-rels', + 'work-level-rels', 'artist-rels'] TRACK_INCLUDES = ['artists', 'aliases'] +if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']: + TRACK_INCLUDES += ['work-level-rels', 'artist-rels'] def track_url(trackid): @@ -69,7 +78,8 @@ """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. """ - musicbrainzngs.set_hostname(config['musicbrainz']['host'].get(unicode)) + hostname = config['musicbrainz']['host'].as_str() + musicbrainzngs.set_hostname(hostname) musicbrainzngs.set_rate_limit( config['musicbrainz']['ratelimit_interval'].as_number(), config['musicbrainz']['ratelimit'].get(int), @@ -99,6 +109,24 @@ return matches[0] +def _preferred_release_event(release): + """Given a release, select and return the user's preferred release + event as a tuple of (country, release_date). Fall back to the + default release event if a preferred event is not found. + """ + countries = config['match']['preferred']['countries'].as_str_seq() + + for event in release.get('release-event-list', {}): + for country in countries: + try: + if country in event['area']['iso-3166-1-code-list']: + return country, event['date'] + except KeyError: + pass + + return release.get('country'), release.get('date') + + def _flatten_artist_credit(credit): """Given a list representing an ``artist-credit`` block, flatten the data into a triple of joined artist name strings: canonical, sort, and @@ -108,7 +136,7 @@ artist_sort_parts = [] artist_credit_parts = [] for el in credit: - if isinstance(el, basestring): + if isinstance(el, six.string_types): # Join phrase. artist_parts.append(el) artist_credit_parts.append(el) @@ -177,6 +205,37 @@ if recording.get('length'): info.length = int(recording['length']) / (1000.0) + lyricist = [] + composer = [] + composer_sort = [] + for work_relation in recording.get('work-relation-list', ()): + if work_relation['type'] != 'performance': + continue + for artist_relation in work_relation['work'].get( + 'artist-relation-list', ()): + if 'type' in artist_relation: + type = artist_relation['type'] + if type == 'lyricist': + lyricist.append(artist_relation['artist']['name']) + elif type == 'composer': + composer.append(artist_relation['artist']['name']) + composer_sort.append( + artist_relation['artist']['sort-name']) + if lyricist: + info.lyricist = u', '.join(lyricist) + if composer: + info.composer = u', '.join(composer) + info.composer_sort = u', '.join(composer_sort) + + arranger = [] + for artist_relation in recording.get('artist-relation-list', ()): + if 'type' in artist_relation: + type = artist_relation['type'] + if type == 'arranger': + arranger.append(artist_relation['artist']['name']) + if arranger: + info.arranger = u', '.join(arranger) + info.decode() return info @@ -232,6 +291,7 @@ ) ti.disctitle = disctitle ti.media = format + ti.track_alt = track['number'] # Prefer track data, where present, over recording data. if track.get('title'): @@ -260,10 +320,9 @@ ) info.va = info.artist_id == VARIOUS_ARTISTS_ID if info.va: - info.artist = config['va_name'].get(unicode) + info.artist = config['va_name'].as_str() info.asin = release.get('asin') info.releasegroup_id = release['release-group']['id'] - info.country = release.get('country') info.albumstatus = release.get('status') # Build up the disambiguation string from the release group and release. @@ -274,14 +333,28 @@ disambig.append(release.get('disambiguation')) info.albumdisambig = u', '.join(disambig) - # Release type not always populated. + # Get the "classic" Release type. This data comes from a legacy API + # feature before MusicBrainz supported multiple release types. if 'type' in release['release-group']: reltype = release['release-group']['type'] if reltype: info.albumtype = reltype.lower() - # Release dates. - release_date = release.get('date') + # Log the new-style "primary" and "secondary" release types. + # Eventually, we'd like to actually store this data, but we just log + # it for now to help understand the differences. + if 'primary-type' in release['release-group']: + rel_primarytype = release['release-group']['primary-type'] + if rel_primarytype: + log.debug('primary MB release type: ' + rel_primarytype.lower()) + if 'secondary-type-list' in release['release-group']: + if release['release-group']['secondary-type-list']: + log.debug('secondary MB release type(s): ' + ', '.join( + [secondarytype.lower() for secondarytype in + release['release-group']['secondary-type-list']])) + + # Release events. + info.country, release_date = _preferred_release_event(release) release_group_date = release['release-group'].get('first-release-date') if not release_date: # Fall back if release-specific date is not available. @@ -329,13 +402,14 @@ # Various Artists search. criteria['arid'] = VARIOUS_ARTISTS_ID if tracks is not None: - criteria['tracks'] = unicode(tracks) + criteria['tracks'] = six.text_type(tracks) # Abort if we have no search terms. - if not any(criteria.itervalues()): + if not any(criteria.values()): return try: + log.debug(u'Searching for MusicBrainz releases with: {!r}', criteria) res = musicbrainzngs.search_releases( limit=config['musicbrainz']['searchlimit'].get(int), **criteria) except musicbrainzngs.MusicBrainzError as exc: @@ -358,7 +432,7 @@ 'recording': title.lower().strip(), } - if not any(criteria.itervalues()): + if not any(criteria.values()): return try: @@ -386,6 +460,7 @@ object or None if the album is not found. May raise a MusicBrainzAPIError. """ + log.debug(u'Requesting MusicBrainz release {}', releaseid) albumid = _parse_id(releaseid) if not albumid: log.debug(u'Invalid MBID ({0}).', releaseid) diff -Nru beets-1.3.19/beets/config_default.yaml beets-1.4.6/beets/config_default.yaml --- beets-1.3.19/beets/config_default.yaml 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/config_default.yaml 2017-12-16 20:00:33.000000000 +0000 @@ -6,9 +6,11 @@ copy: yes move: no link: no + hardlink: no delete: no resume: ask incremental: no + from_scratch: no quiet_fallback: skip none_rec_action: ask timid: no @@ -24,6 +26,8 @@ pretend: false search_ids: [] duplicate_action: ask + bell: no + set_fields: {} clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information", "lost+found"] @@ -37,6 +41,7 @@ '\.$': _ '\s+$': '' '^\s+': '' + '^-': _ path_sep_replace: _ asciify_paths: false art_filename: cover diff -Nru beets-1.3.19/beets/dbcore/db.py beets-1.4.6/beets/dbcore/db.py --- beets-1.3.19/beets/dbcore/db.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/dbcore/db.py 2017-08-27 14:19:06.000000000 +0000 @@ -27,8 +27,19 @@ import beets from beets.util.functemplate import Template +from beets.util import py3_path from beets.dbcore import types from .query import MatchQuery, NullSort, TrueQuery +import six + + +class DBAccessError(Exception): + """The SQLite database became inaccessible. + + This can happen when trying to read or write the database when, for + example, the database file is deleted or otherwise disappears. There + is probably no way to recover from this error. + """ class FormattedMapping(collections.Mapping): @@ -66,10 +77,10 @@ def _get_formatted(self, model, key): value = model._type(key).format(model.get(key)) if isinstance(value, bytes): - value = value.decode('utf8', 'ignore') + value = value.decode('utf-8', 'ignore') if self.for_path: - sep_repl = beets.config['path_sep_replace'].get(unicode) + sep_repl = beets.config['path_sep_replace'].as_str() for sep in (os.path.sep, os.path.altsep): if sep: value = value.replace(sep, sep_repl) @@ -176,9 +187,9 @@ ordinary construction are bypassed. """ obj = cls(db) - for key, value in fixed_values.iteritems(): + for key, value in fixed_values.items(): obj._values_fixed[key] = cls._type(key).from_sql(value) - for key, value in flex_values.iteritems(): + for key, value in flex_values.items(): obj._values_flex[key] = cls._type(key).from_sql(value) return obj @@ -206,6 +217,21 @@ if need_id and not self.id: raise ValueError(u'{0} has no id'.format(type(self).__name__)) + def copy(self): + """Create a copy of the model object. + + The field values and other state is duplicated, but the new copy + remains associated with the same database as the old object. + (A simple `copy.deepcopy` will not work because it would try to + duplicate the SQLite connection.) + """ + new = self.__class__() + new._db = self._db + new._values_fixed = self._values_fixed.copy() + new._values_flex = self._values_flex.copy() + new._dirty = self._dirty.copy() + return new + # Essential field accessors. @classmethod @@ -225,14 +251,15 @@ if key in getters: # Computed. return getters[key](self) elif key in self._fields: # Fixed. - return self._values_fixed.get(key) + return self._values_fixed.get(key, self._type(key).null) elif key in self._values_flex: # Flexible. return self._values_flex[key] else: raise KeyError(key) - def __setitem__(self, key, value): - """Assign the value for a field. + def _setitem(self, key, value): + """Assign the value for a field, return whether new and old value + differ. """ # Choose where to place the value. if key in self._fields: @@ -246,9 +273,17 @@ # Assign value and possibly mark as dirty. old_value = source.get(key) source[key] = value - if self._always_dirty or old_value != value: + changed = old_value != value + if self._always_dirty or changed: self._dirty.add(key) + return changed + + def __setitem__(self, key, value): + """Assign the value for a field. + """ + self._setitem(key, value) + def __delitem__(self, key): """Remove a flexible attribute from the model. """ @@ -340,15 +375,19 @@ # Database interaction (CRUD methods). - def store(self): + def store(self, fields=None): """Save the object's metadata into the library database. + :param fields: the fields to be stored. If not specified, all fields + will be. """ + if fields is None: + fields = self._fields self._check_db() # Build assignments for query. assignments = [] subvars = [] - for key in self._fields: + for key in fields: if key != 'id' and key in self._dirty: self._dirty.remove(key) assignments.append(key + '=?') @@ -452,7 +491,7 @@ separators will be added to the template. """ # Perform substitution. - if isinstance(template, basestring): + if isinstance(template, six.string_types): template = Template(template) return template.substitute(self.formatted(for_path), self._template_funcs()) @@ -463,7 +502,7 @@ def _parse(cls, key, string): """Parse a string as a value for the given key. """ - if not isinstance(string, basestring): + if not isinstance(string, six.string_types): raise TypeError(u"_parse() argument must be a string") return cls._type(key).parse(string) @@ -674,8 +713,18 @@ """Execute an SQL statement with substitution values and return the row ID of the last affected row. """ - cursor = self.db._connection().execute(statement, subvals) - return cursor.lastrowid + try: + cursor = self.db._connection().execute(statement, subvals) + return cursor.lastrowid + except sqlite3.OperationalError as e: + # In two specific cases, SQLite reports an error while accessing + # the underlying database file. We surface these exceptions as + # DBAccessError so the application can abort. + if e.args[0] in ("attempt to write a readonly database", + "unable to open database file"): + raise DBAccessError(e.args[0]) + else: + raise def script(self, statements): """Execute a string containing multiple SQL statements.""" @@ -690,8 +739,9 @@ """The Model subclasses representing tables in this database. """ - def __init__(self, path): + def __init__(self, path, timeout=5.0): self.path = path + self.timeout = timeout self._connections = {} self._tx_stacks = defaultdict(list) @@ -726,18 +776,28 @@ if thread_id in self._connections: return self._connections[thread_id] else: - # Make a new connection. - conn = sqlite3.connect( - self.path, - timeout=beets.config['timeout'].as_number(), - ) - - # Access SELECT results like dictionaries. - conn.row_factory = sqlite3.Row - + conn = self._create_connection() self._connections[thread_id] = conn return conn + def _create_connection(self): + """Create a SQLite connection to the underlying database. + + Makes a new connection every time. If you need to configure the + connection settings (e.g., add custom functions), override this + method. + """ + # Make a new connection. The `sqlite3` module can't use + # bytestring paths here on Python 3, so we need to + # provide a `str` using `py3_path`. + conn = sqlite3.connect( + py3_path(self.path), timeout=self.timeout + ) + + # Access SELECT results like dictionaries. + conn.row_factory = sqlite3.Row + return conn + def _close(self): """Close the all connections to the underlying SQLite database from all threads. This does not render the database object diff -Nru beets-1.3.19/beets/dbcore/query.py beets-1.4.6/beets/dbcore/query.py --- beets-1.3.19/beets/dbcore/query.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/dbcore/query.py 2017-06-20 19:15:08.000000000 +0000 @@ -23,6 +23,10 @@ from datetime import datetime, timedelta import unicodedata from functools import reduce +import six + +if not six.PY2: + buffer = memoryview # sqlite won't accept memoryview in python 2 class ParsingError(ValueError): @@ -36,6 +40,7 @@ The query should be a unicode string or a list, which will be space-joined. """ + def __init__(self, query, explanation): if isinstance(query, list): query = " ".join(query) @@ -43,22 +48,24 @@ super(InvalidQueryError, self).__init__(message) -class InvalidQueryArgumentTypeError(ParsingError): +class InvalidQueryArgumentValueError(ParsingError): """Represent a query argument that could not be converted as expected. It exists to be caught in upper stack levels so a meaningful (i.e. with the query) InvalidQueryError can be raised. """ + def __init__(self, what, expected, detail=None): message = u"'{0}' is not {1}".format(what, expected) if detail: message = u"{0}: {1}".format(message, detail) - super(InvalidQueryArgumentTypeError, self).__init__(message) + super(InvalidQueryArgumentValueError, self).__init__(message) class Query(object): """An abstract class representing a query into the item database. """ + def clause(self): """Generate an SQLite expression implementing the query. @@ -91,6 +98,7 @@ string. Subclasses may also provide `col_clause` to implement the same matching functionality in SQLite. """ + def __init__(self, field, pattern, fast=True): self.field = field self.pattern = pattern @@ -130,6 +138,7 @@ class MatchQuery(FieldQuery): """A query that looks for exact matches in an item field.""" + def col_clause(self): return self.field + " = ?", [self.pattern] @@ -139,6 +148,7 @@ class NoneQuery(FieldQuery): + """A query that checks whether a field is null.""" def __init__(self, field, fast=True): super(NoneQuery, self).__init__(field, None, fast) @@ -161,6 +171,7 @@ """A FieldQuery that converts values to strings before matching them. """ + @classmethod def value_match(cls, pattern, value): """Determine whether the value matches the pattern. The value @@ -178,11 +189,12 @@ class SubstringQuery(StringFieldQuery): """A query that matches a substring in a specific item field.""" + def col_clause(self): pattern = (self.pattern - .replace('\\', '\\\\') - .replace('%', '\\%') - .replace('_', '\\_')) + .replace('\\', '\\\\') + .replace('%', '\\%') + .replace('_', '\\_')) search = '%' + pattern + '%' clause = self.field + " like ? escape '\\'" subvals = [search] @@ -200,6 +212,7 @@ Raises InvalidQueryError when the pattern is not a valid regular expression. """ + def __init__(self, field, pattern, fast=True): super(RegexpQuery, self).__init__(field, pattern, fast) pattern = self._normalize(pattern) @@ -207,9 +220,9 @@ self.pattern = re.compile(self.pattern) except re.error as exc: # Invalid regular expression. - raise InvalidQueryArgumentTypeError(pattern, - u"a regular expression", - format(exc)) + raise InvalidQueryArgumentValueError(pattern, + u"a regular expression", + format(exc)) @staticmethod def _normalize(s): @@ -227,9 +240,10 @@ """Matches a boolean field. Pattern should either be a boolean or a string reflecting a boolean. """ + def __init__(self, field, pattern, fast=True): super(BooleanQuery, self).__init__(field, pattern, fast) - if isinstance(pattern, basestring): + if isinstance(pattern, six.string_types): self.pattern = util.str2bool(pattern) self.pattern = int(self.pattern) @@ -240,15 +254,16 @@ `unicode` equivalently in Python 2. Always use this query instead of `MatchQuery` when matching on BLOB values. """ + def __init__(self, field, pattern): super(BytesQuery, self).__init__(field, pattern) - # Use a buffer representation of the pattern for SQLite + # Use a buffer/memoryview representation of the pattern for SQLite # matching. This instructs SQLite to treat the blob as binary # rather than encoded Unicode. - if isinstance(self.pattern, (unicode, bytes)): - if isinstance(self.pattern, unicode): - self.pattern = self.pattern.encode('utf8') + if isinstance(self.pattern, (six.text_type, bytes)): + if isinstance(self.pattern, six.text_type): + self.pattern = self.pattern.encode('utf-8') self.buf_pattern = buffer(self.pattern) elif isinstance(self.pattern, buffer): self.buf_pattern = self.pattern @@ -266,6 +281,7 @@ Raises InvalidQueryError when the pattern does not represent an int or a float. """ + def _convert(self, s): """Convert a string to a numeric type (float or int). @@ -281,7 +297,7 @@ try: return float(s) except ValueError: - raise InvalidQueryArgumentTypeError(s, u"an int or a float") + raise InvalidQueryArgumentValueError(s, u"an int or a float") def __init__(self, field, pattern, fast=True): super(NumericQuery, self).__init__(field, pattern, fast) @@ -302,7 +318,7 @@ if self.field not in item: return False value = item[self.field] - if isinstance(value, basestring): + if isinstance(value, six.string_types): value = self._convert(value) if self.point is not None: @@ -333,6 +349,7 @@ """An abstract query class that aggregates other queries. Can be indexed like a list to access the sub-queries. """ + def __init__(self, subqueries=()): self.subqueries = subqueries @@ -385,6 +402,7 @@ any field. The individual field query class is provided to the constructor. """ + def __init__(self, pattern, fields, cls): self.pattern = pattern self.fields = fields @@ -420,6 +438,7 @@ """A collection query whose subqueries may be modified after the query is initialized. """ + def __setitem__(self, key, value): self.subqueries[key] = value @@ -429,6 +448,7 @@ class AndQuery(MutableCollectionQuery): """A conjunction of a list of other queries.""" + def clause(self): return self.clause_with_joiner('and') @@ -438,6 +458,7 @@ class OrQuery(MutableCollectionQuery): """A conjunction of a list of other queries.""" + def clause(self): return self.clause_with_joiner('or') @@ -449,6 +470,7 @@ """A query that matches the negation of its `subquery`, as a shorcut for performing `not(subquery)` without using regular expressions. """ + def __init__(self, subquery): self.subquery = subquery @@ -477,6 +499,7 @@ class TrueQuery(Query): """A query that always matches.""" + def clause(self): return '1', () @@ -486,6 +509,7 @@ class FalseQuery(Query): """A query that never matches.""" + def clause(self): return '0', () @@ -499,9 +523,13 @@ """Convert a `datetime` object to an integer number of seconds since the (local) Unix epoch. """ - epoch = datetime.fromtimestamp(0) - delta = date - epoch - return int(delta.total_seconds()) + if hasattr(date, 'timestamp'): + # The `timestamp` method exists on Python 3.3+. + return int(date.timestamp()) + else: + epoch = datetime.fromtimestamp(0) + delta = date - epoch + return int(delta.total_seconds()) def _parse_periods(pattern): @@ -525,12 +553,23 @@ instants of time during January 2014. """ - precisions = ('year', 'month', 'day') - date_formats = ('%Y', '%Y-%m', '%Y-%m-%d') + precisions = ('year', 'month', 'day', 'hour', 'minute', 'second') + date_formats = ( + ('%Y',), # year + ('%Y-%m',), # month + ('%Y-%m-%d',), # day + ('%Y-%m-%dT%H', '%Y-%m-%d %H'), # hour + ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute + ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second + ) + relative_units = {'y': 365, 'm': 30, 'w': 7, 'd': 1} + relative_re = '(?P[+|-]?)(?P[0-9]+)' + \ + '(?P[y|m|w|d])' def __init__(self, date, precision): """Create a period with the given date (a `datetime` object) and - precision (a string, one of "year", "month", or "day"). + precision (a string, one of "year", "month", "day", "hour", "minute", + or "second"). """ if precision not in Period.precisions: raise ValueError(u'Invalid precision {0}'.format(precision)) @@ -540,20 +579,55 @@ @classmethod def parse(cls, string): """Parse a date and return a `Period` object or `None` if the - string is empty. - """ + string is empty, or raise an InvalidQueryArgumentValueError if + the string cannot be parsed to a date. + + The date may be absolute or relative. Absolute dates look like + `YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative + dates have three parts: + + - Optionally, a ``+`` or ``-`` sign indicating the future or the + past. The default is the future. + - A number: how much to add or subtract. + - A letter indicating the unit: days, weeks, months or years + (``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days + and a "year" is exactly 365 days. + """ + + def find_date_and_format(string): + for ord, format in enumerate(cls.date_formats): + for format_option in format: + try: + date = datetime.strptime(string, format_option) + return date, ord + except ValueError: + # Parsing failed. + pass + return (None, None) + if not string: return None - ordinal = string.count('-') - if ordinal >= len(cls.date_formats): - # Too many components. - return None - date_format = cls.date_formats[ordinal] - try: - date = datetime.strptime(string, date_format) - except ValueError: - # Parsing failed. - return None + + # Check for a relative date. + match_dq = re.match(cls.relative_re, string) + if match_dq: + sign = match_dq.group('sign') + quantity = match_dq.group('quantity') + timespan = match_dq.group('timespan') + + # Add or subtract the given amount of time from the current + # date. + multiplier = -1 if sign == '-' else 1 + days = cls.relative_units[timespan] + date = datetime.now() + \ + timedelta(days=int(quantity) * days) * multiplier + return cls(date, cls.precisions[5]) + + # Check for an absolute date. + date, ordinal = find_date_and_format(string) + if date is None: + raise InvalidQueryArgumentValueError(string, + 'a valid date/time string') precision = cls.precisions[ordinal] return cls(date, precision) @@ -572,6 +646,12 @@ return date.replace(year=date.year + 1, month=1) elif 'day' == precision: return date + timedelta(days=1) + elif 'hour' == precision: + return date + timedelta(hours=1) + elif 'minute' == precision: + return date + timedelta(minutes=1) + elif 'second' == precision: + return date + timedelta(seconds=1) else: raise ValueError(u'unhandled precision {0}'.format(precision)) @@ -618,12 +698,15 @@ The value of a date field can be matched against a date interval by using an ellipsis interval syntax similar to that of NumericQuery. """ + def __init__(self, field, pattern, fast=True): super(DateQuery, self).__init__(field, pattern, fast) start, end = _parse_periods(pattern) self.interval = DateInterval.from_periods(start, end) def match(self, item): + if self.field not in item: + return False timestamp = float(item[self.field]) date = datetime.utcfromtimestamp(timestamp) return self.interval.contains(date) @@ -659,6 +742,7 @@ Raises InvalidQueryError when the pattern does not represent an int, float or M:SS time interval. """ + def _convert(self, s): """Convert a M:SS or numeric string to a float. @@ -673,7 +757,7 @@ try: return float(s) except ValueError: - raise InvalidQueryArgumentTypeError( + raise InvalidQueryArgumentValueError( s, u"a M:SS string or a float") @@ -781,6 +865,7 @@ """An abstract sort criterion that orders by a specific field (of any kind). """ + def __init__(self, field, ascending=True, case_insensitive=True): self.field = field self.ascending = ascending @@ -793,7 +878,7 @@ def key(item): field_val = item.get(self.field, '') - if self.case_insensitive and isinstance(field_val, unicode): + if self.case_insensitive and isinstance(field_val, six.text_type): field_val = field_val.lower() return field_val @@ -818,6 +903,7 @@ class FixedFieldSort(FieldSort): """Sort object to sort on a fixed field. """ + def order_clause(self): order = "ASC" if self.ascending else "DESC" if self.case_insensitive: @@ -834,12 +920,14 @@ """A sort criterion by some model field other than a fixed field: i.e., a computed or flexible field. """ + def is_slow(self): return True class NullSort(Sort): """No sorting. Leave results unsorted.""" + def sort(self, items): return items diff -Nru beets-1.3.19/beets/dbcore/types.py beets-1.4.6/beets/dbcore/types.py --- beets-1.3.19/beets/dbcore/types.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/dbcore/types.py 2016-12-17 03:01:22.000000000 +0000 @@ -19,6 +19,10 @@ from . import query from beets.util import str2bool +import six + +if not six.PY2: + buffer = memoryview # sqlite won't accept memoryview in python 2 # Abstract base. @@ -37,7 +41,7 @@ """The `Query` subclass to be used when querying the field. """ - model_type = unicode + model_type = six.text_type """The Python type that is used to represent the value in the model. The model is guaranteed to return a value of this type if the field @@ -61,9 +65,9 @@ if value is None: value = u'' if isinstance(value, bytes): - value = value.decode('utf8', 'ignore') + value = value.decode('utf-8', 'ignore') - return unicode(value) + return six.text_type(value) def parse(self, string): """Parse a (possibly human-written) string and return the @@ -97,12 +101,12 @@ https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types Flexible fields have the type affinity `TEXT`. This means the - `sql_value` is either a `buffer` or a `unicode` object` and the - method must handle these in addition. + `sql_value` is either a `buffer`/`memoryview` or a `unicode` object` + and the method must handle these in addition. """ if isinstance(sql_value, buffer): - sql_value = bytes(sql_value).decode('utf8', 'ignore') - if isinstance(sql_value, unicode): + sql_value = bytes(sql_value).decode('utf-8', 'ignore') + if isinstance(sql_value, six.text_type): return self.parse(sql_value) else: return self.normalize(sql_value) @@ -194,7 +198,7 @@ model_type = bool def format(self, value): - return unicode(bool(value)) + return six.text_type(bool(value)) def parse(self, string): return str2bool(string) diff -Nru beets-1.3.19/beets/importer.py beets-1.4.6/beets/importer.py --- beets-1.3.19/beets/importer.py 2016-06-20 17:08:57.000000000 +0000 +++ beets-1.4.6/beets/importer.py 2017-12-16 20:00:33.000000000 +0000 @@ -37,14 +37,13 @@ from beets import plugins from beets import util from beets import config -from beets.util import pipeline, sorted_walk, ancestry +from beets.util import pipeline, sorted_walk, ancestry, MoveOperation from beets.util import syspath, normpath, displayable_path from enum import Enum from beets import mediafile action = Enum('action', - ['SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID', - 'ALBUMS', 'RETAG']) + ['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG']) # The RETAG action represents "don't apply any match, but do record # new metadata". It's not reachable via the standard command prompt but # can be used by plugins. @@ -189,6 +188,8 @@ self.paths = paths self.query = query self._is_resuming = dict() + self._merged_items = set() + self._merged_dirs = set() # Normalize the paths. if self.paths: @@ -221,13 +222,19 @@ iconfig['resume'] = False iconfig['incremental'] = False - # Copy, move, and link are mutually exclusive. + # Copy, move, link, and hardlink are mutually exclusive. if iconfig['move']: iconfig['copy'] = False iconfig['link'] = False + iconfig['hardlink'] = False elif iconfig['link']: iconfig['copy'] = False iconfig['move'] = False + iconfig['hardlink'] = False + elif iconfig['hardlink']: + iconfig['copy'] = False + iconfig['move'] = False + iconfig['link'] = False # Only delete when copying. if not iconfig['copy']: @@ -345,6 +352,24 @@ self._history_dirs = history_get() return self._history_dirs + def already_merged(self, paths): + """Returns true if all the paths being imported were part of a merge + during previous tasks. + """ + for path in paths: + if path not in self._merged_items \ + and path not in self._merged_dirs: + return False + return True + + def mark_merged(self, paths): + """Mark paths and directories as merged for future reimport tasks. + """ + self._merged_items.update(paths) + dirs = set([os.path.dirname(path) if os.path.isfile(path) else path + for path in paths]) + self._merged_dirs.update(dirs) + def is_resuming(self, toppath): """Return `True` if user wants to resume import of this path. @@ -362,8 +387,8 @@ # Either accept immediately or prompt for input to decide. if self.want_resume is True or \ self.should_resume(toppath): - log.warn(u'Resuming interrupted import of {0}', - util.displayable_path(toppath)) + log.warning(u'Resuming interrupted import of {0}', + util.displayable_path(toppath)) self._is_resuming[toppath] = True else: # Clear progress; we're starting from the top. @@ -414,7 +439,7 @@ from the `candidates` list. * `find_duplicates()` Returns a list of albums from `lib` with the - same artist and album name as the task. + same artist and album name as the task. * `apply_metadata()` Sets the attributes of the items from the task's `match` attribute. @@ -424,6 +449,9 @@ * `manipulate_files()` Copy, move, and write files depending on the session configuration. + * `set_fields()` Sets the fields given at CLI or configuration to + the specified values. + * `finalize()` Update the import progress and cleanup the file system. """ @@ -435,6 +463,7 @@ self.candidates = [] self.rec = None self.should_remove_duplicates = False + self.should_merge_duplicates = False self.is_album = True self.search_ids = [] # user-supplied candidate IDs. @@ -443,7 +472,6 @@ indicates that an action has been selected for this task. """ # Not part of the task structure: - assert choice not in (action.MANUAL, action.MANUAL_ID) assert choice != action.APPLY # Only used internally. if choice in (action.SKIP, action.ASIS, action.TRACKS, action.ALBUMS, action.RETAG): @@ -506,6 +534,10 @@ def apply_metadata(self): """Copy metadata from match info to the items. """ + if config['import']['from_scratch']: + for item in self.match.mapping: + item.clear() + autotag.apply_metadata(self.match.info, self.match.mapping) def duplicate_items(self, lib): @@ -526,6 +558,19 @@ util.prune_dirs(os.path.dirname(item.path), lib.directory) + def set_fields(self): + """Sets the fields given at CLI or configuration to the specified + values. + """ + for field, view in config['import']['set_fields'].items(): + value = view.get() + log.debug(u'Set field {1}={2} for {0}', + displayable_path(self.paths), + field, + value) + self.album[field] = value + self.album.store() + def finalize(self, session): """Save progress, clean up files, and emit plugin event. """ @@ -587,12 +632,12 @@ candidate IDs are stored in self.search_ids: if present, the initial lookup is restricted to only those IDs. """ - artist, album, candidates, recommendation = \ + artist, album, prop = \ autotag.tag_album(self.items, search_ids=self.search_ids) self.cur_artist = artist self.cur_album = album - self.candidates = candidates - self.rec = recommendation + self.candidates = prop.candidates + self.rec = prop.recommendation def find_duplicates(self, lib): """Return a list of albums from `lib` with the same artist and @@ -612,10 +657,11 @@ )) for album in lib.albums(duplicate_query): - # Check whether the album is identical in contents, in which - # case it is not a duplicate (will be replaced). + # Check whether the album paths are all present in the task + # i.e. album is being completely re-imported by the task, + # in which case it is not a duplicate (will be replaced). album_paths = set(i.path for i in album.items()) - if album_paths != task_paths: + if not (album_paths <= task_paths): duplicates.append(album) return duplicates @@ -640,7 +686,7 @@ changes['comp'] = False else: # VA. - changes['albumartist'] = config['va_name'].get(unicode) + changes['albumartist'] = config['va_name'].as_str() changes['comp'] = True elif self.choice_flag in (action.APPLY, action.RETAG): @@ -655,20 +701,28 @@ for item in self.items: item.update(changes) - def manipulate_files(self, move=False, copy=False, write=False, - link=False, session=None): + def manipulate_files(self, operation=None, write=False, session=None): + """ Copy, move, link or hardlink (depending on `operation`) the files + as well as write metadata. + + `operation` should be an instance of `util.MoveOperation`. + + If `write` is `True` metadata is written to the files. + """ + items = self.imported_items() # Save the original paths of all items for deletion and pruning # in the next step (finalization). self.old_paths = [item.path for item in items] for item in items: - if move or copy or link: + if operation is not None: # In copy and link modes, treat re-imports specially: # move in-library files. (Out-of-library files are # copied/moved as usual). old_path = item.path - if (copy or link) and self.replaced_items[item] and \ - session.lib.directory in util.ancestry(old_path): + if (operation != MoveOperation.MOVE + and self.replaced_items[item] + and session.lib.directory in util.ancestry(old_path)): item.move() # We moved the item, so remove the # now-nonexistent file from old_paths. @@ -676,7 +730,7 @@ else: # A normal import. Just copy files and keep track of # old paths. - item.move(copy, link) + item.move(operation) if write and (self.apply or self.choice_flag == action.RETAG): item.try_write() @@ -830,10 +884,9 @@ plugins.send('item_imported', lib=lib, item=item) def lookup_candidates(self): - candidates, recommendation = autotag.tag_item( - self.item, search_ids=self.search_ids) - self.candidates = candidates - self.rec = recommendation + prop = autotag.tag_item(self.item, search_ids=self.search_ids) + self.candidates = prop.candidates + self.rec = prop.recommendation def find_duplicates(self, lib): """Return a list of items from `lib` that have the same artist @@ -874,6 +927,19 @@ def reload(self): self.item.load() + def set_fields(self): + """Sets the fields given at CLI or configuration to the specified + values. + """ + for field, view in config['import']['set_fields'].items(): + value = view.get() + log.debug(u'Set field {1}={2} for {0}', + displayable_path(self.paths), + field, + value) + self.item[field] = value + self.item.store() + # FIXME The inheritance relationships are inverted. This is why there # are so many methods which pass. More responsibility should be delegated to @@ -944,7 +1010,7 @@ return False for path_test, _ in cls.handlers(): - if path_test(path): + if path_test(util.py3_path(path)): return True return False @@ -985,12 +1051,12 @@ `toppath` to that directory. """ for path_test, handler_class in self.handlers(): - if path_test(self.toppath): + if path_test(util.py3_path(self.toppath)): break try: extract_to = mkdtemp() - archive = handler_class(self.toppath, mode='r') + archive = handler_class(util.py3_path(self.toppath), mode='r') archive.extractall(extract_to) finally: archive.close() @@ -1148,8 +1214,8 @@ if not (self.session.config['move'] or self.session.config['copy']): - log.warn(u"Archive importing requires either " - u"'copy' or 'move' to be enabled.") + log.warning(u"Archive importing requires either " + u"'copy' or 'move' to be enabled.") return log.debug(u'Extracting archive: {0}', @@ -1179,12 +1245,33 @@ # Silently ignore non-music files. pass elif isinstance(exc.reason, mediafile.UnreadableFileError): - log.warn(u'unreadable file: {0}', displayable_path(path)) + log.warning(u'unreadable file: {0}', displayable_path(path)) else: log.error(u'error reading {0}: {1}', displayable_path(path), exc) +# Pipeline utilities + +def _freshen_items(items): + # Clear IDs from re-tagged items so they appear "fresh" when + # we add them back to the library. + for item in items: + item.id = None + item.album_id = None + + +def _extend_pipeline(tasks, *stages): + # Return pipeline extension for stages with list of tasks + if type(tasks) == list: + task_iter = iter(tasks) + else: + task_iter = tasks + + ipl = pipeline.Pipeline([task_iter] + list(stages)) + return pipeline.multiple(ipl.pull()) + + # Full-album pipeline stages. def read_tasks(session): @@ -1204,8 +1291,8 @@ skipped += task_factory.skipped if not task_factory.imported: - log.warn(u'No files imported from {0}', - displayable_path(toppath)) + log.warning(u'No files imported from {0}', + displayable_path(toppath)) # Show skipped directories (due to incremental/resume). if skipped: @@ -1230,12 +1317,7 @@ log.debug(u'yielding album {0}: {1} - {2}', album.id, album.albumartist, album.album) items = list(album.items()) - - # Clear IDs from re-tagged items so they appear "fresh" when - # we add them back to the library. - for item in items: - item.id = None - item.album_id = None + _freshen_items(items) task = ImportTask(None, [album.item_dir()], items) for task in task.handle_created(session): @@ -1281,6 +1363,9 @@ if task.skip: return task + if session.already_merged(task.paths): + return pipeline.BUBBLE + # Ask the user for a choice. task.choose_match(session) plugins.send('import_task_choice', session=session, task=task) @@ -1295,24 +1380,38 @@ yield new_task yield SentinelImportTask(task.toppath, task.paths) - ipl = pipeline.Pipeline([ - emitter(task), - lookup_candidates(session), - user_query(session), - ]) - return pipeline.multiple(ipl.pull()) + return _extend_pipeline(emitter(task), + lookup_candidates(session), + user_query(session)) # As albums: group items by albums and create task for each album if task.choice_flag is action.ALBUMS: - ipl = pipeline.Pipeline([ - iter([task]), - group_albums(session), - lookup_candidates(session), - user_query(session) - ]) - return pipeline.multiple(ipl.pull()) + return _extend_pipeline([task], + group_albums(session), + lookup_candidates(session), + user_query(session)) resolve_duplicates(session, task) + + if task.should_merge_duplicates: + # Create a new task for tagging the current items + # and duplicates together + duplicate_items = task.duplicate_items(session.lib) + + # Duplicates would be reimported so make them look "fresh" + _freshen_items(duplicate_items) + duplicate_paths = [item.path for item in duplicate_items] + + # Record merged paths in the session so they are not reimported + session.mark_merged(duplicate_paths) + + merged_task = ImportTask(None, task.paths + duplicate_paths, + task.items + duplicate_items) + + return _extend_pipeline([merged_task], + lookup_candidates(session), + user_query(session)) + apply_choice(session, task) return task @@ -1333,6 +1432,7 @@ u'skip': u's', u'keep': u'k', u'remove': u'r', + u'merge': u'm', u'ask': u'a', }) log.debug(u'default action for duplicates: {0}', duplicate_action) @@ -1346,6 +1446,9 @@ elif duplicate_action == u'r': # Remove old. task.should_remove_duplicates = True + elif duplicate_action == u'm': + # Merge duplicates together + task.should_merge_duplicates = True else: # No default action set; ask the session. session.resolve_duplicate(task, found_duplicates) @@ -1382,6 +1485,14 @@ task.add(session.lib) + # If ``set_fields`` is set, set those fields to the + # configured values. + # NOTE: This cannot be done before the ``task.add()`` call above, + # because then the ``ImportTask`` won't have an `album` for which + # it can set the fields. + if config['import']['set_fields']: + task.set_fields() + @pipeline.mutator_stage def plugin_stage(session, func, task): @@ -1410,11 +1521,20 @@ if task.should_remove_duplicates: task.remove_duplicates(session.lib) + if session.config['move']: + operation = MoveOperation.MOVE + elif session.config['copy']: + operation = MoveOperation.COPY + elif session.config['link']: + operation = MoveOperation.LINK + elif session.config['hardlink']: + operation = MoveOperation.HARDLINK + else: + operation = None + task.manipulate_files( - move=session.config['move'], - copy=session.config['copy'], + operation, write=session.config['write'], - link=session.config['link'], session=session, ) @@ -1465,6 +1585,14 @@ MULTIDISC_PAT_FMT = br'^(.*%s[\W_]*)\d' +def is_subdir_of_any_in_list(path, dirs): + """Returns True if path os a subdirectory of any directory in dirs + (a list). In other case, returns False. + """ + ancestors = ancestry(path) + return any(d in ancestors for d in dirs) + + def albums_in_dir(path): """Recursively searches the given directory and returns an iterable of (paths, items) where paths is a list of directories and items is @@ -1484,7 +1612,7 @@ # and add the current directory. If so, just add the directory # and move on to the next directory. If not, stop collapsing. if collapse_paths: - if (not collapse_pat and collapse_paths[0] in ancestry(root)) or \ + if (is_subdir_of_any_in_list(root, collapse_paths)) or \ (collapse_pat and collapse_pat.match(os.path.basename(root))): # Still collapsing. diff -Nru beets-1.3.19/beets/__init__.py beets-1.4.6/beets/__init__.py --- beets-1.3.19/beets/__init__.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/__init__.py 2017-06-20 19:15:08.000000000 +0000 @@ -19,7 +19,7 @@ from beets.util import confit -__version__ = u'1.3.18' +__version__ = u'1.4.6' __author__ = u'Adrian Sampson ' diff -Nru beets-1.3.19/beets/library.py beets-1.4.6/beets/library.py --- beets-1.3.19/beets/library.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beets/library.py 2017-12-16 20:00:33.000000000 +0000 @@ -22,18 +22,27 @@ import unicodedata import time import re -from unidecode import unidecode +import six from beets import logging -from beets.mediafile import MediaFile, MutagenError, UnreadableFileError +from beets.mediafile import MediaFile, UnreadableFileError from beets import plugins from beets import util -from beets.util import bytestring_path, syspath, normpath, samefile +from beets.util import bytestring_path, syspath, normpath, samefile, \ + MoveOperation from beets.util.functemplate import Template from beets import dbcore from beets.dbcore import types import beets +# To use the SQLite "blob" type, it doesn't suffice to provide a byte +# string; SQLite treats that as encoded text. Wrapping it in a `buffer` or a +# `memoryview`, depending on the Python version, tells it that we +# actually mean non-text data. +if six.PY2: + BLOB_TYPE = buffer # noqa: F821 +else: + BLOB_TYPE = memoryview log = logging.getLogger('beets') @@ -48,9 +57,6 @@ and case-sensitive otherwise. """ - escape_re = re.compile(br'[\\_%]') - escape_char = b'\\' - def __init__(self, field, pattern, fast=True, case_sensitive=None): """Create a path query. `pattern` must be a path, either to a file or a directory. @@ -99,20 +105,17 @@ return (path == self.file_path) or path.startswith(self.dir_path) def col_clause(self): + file_blob = BLOB_TYPE(self.file_path) + dir_blob = BLOB_TYPE(self.dir_path) + if self.case_sensitive: - file_blob = buffer(self.file_path) - dir_blob = buffer(self.dir_path) - return '({0} = ?) || (substr({0}, 1, ?) = ?)'.format(self.field), \ - (file_blob, len(dir_blob), dir_blob) - - escape = lambda m: self.escape_char + m.group(0) - dir_pattern = self.escape_re.sub(escape, self.dir_path) - dir_blob = buffer(dir_pattern + b'%') - file_pattern = self.escape_re.sub(escape, self.file_path) - file_blob = buffer(file_pattern) - return '({0} LIKE ? ESCAPE ?) || ({0} LIKE ? ESCAPE ?)'.format( - self.field), (file_blob, self.escape_char, dir_blob, - self.escape_char) + query_part = '({0} = ?) || (substr({0}, 1, ?) = ?)' + else: + query_part = '(BYTELOWER({0}) = BYTELOWER(?)) || \ + (substr(BYTELOWER({0}), 1, ?) = BYTELOWER(?))' + + return query_part.format(self.field), \ + (file_blob, len(dir_blob), dir_blob) # Library-specific field types. @@ -123,14 +126,15 @@ query = dbcore.query.DateQuery def format(self, value): - return time.strftime(beets.config['time_format'].get(unicode), + return time.strftime(beets.config['time_format'].as_str(), time.localtime(value or 0)) def parse(self, string): try: # Try a formatted date string. return time.mktime( - time.strptime(string, beets.config['time_format'].get(unicode)) + time.strptime(string, + beets.config['time_format'].as_str()) ) except ValueError: # Fall back to a plain timestamp number. @@ -141,10 +145,27 @@ class PathType(types.Type): + """A dbcore type for filesystem paths. These are represented as + `bytes` objects, in keeping with the Unix filesystem abstraction. + """ + sql = u'BLOB' query = PathQuery model_type = bytes + def __init__(self, nullable=False): + """Create a path type object. `nullable` controls whether the + type may be missing, i.e., None. + """ + self.nullable = nullable + + @property + def null(self): + if self.nullable: + return None + else: + return b'' + def format(self, value): return util.displayable_path(value) @@ -152,12 +173,11 @@ return normpath(bytestring_path(string)) def normalize(self, value): - if isinstance(value, unicode): + if isinstance(value, six.text_type): # Paths stored internally as encoded bytes. return bytestring_path(value) - elif isinstance(value, buffer): - # SQLite must store bytestings as buffers to avoid decoding. + elif isinstance(value, BLOB_TYPE): # We unwrap buffers to bytes. return bytes(value) @@ -169,7 +189,7 @@ def to_sql(self, value): if isinstance(value, bytes): - value = buffer(value) + value = BLOB_TYPE(value) return value @@ -186,6 +206,8 @@ r'bb': 'a#', } + null = None + def parse(self, key): key = key.lower() for flat, sharp in self.ENHARMONIC.items(): @@ -260,7 +282,7 @@ # Exceptions. - +@six.python_2_unicode_compatible class FileOperationError(Exception): """Indicates an error when interacting with a file on disk. Possibilities include an unsupported media type, a permissions @@ -274,35 +296,39 @@ self.path = path self.reason = reason - def __unicode__(self): + def text(self): """Get a string representing the error. Describes both the underlying reason and the file path in question. """ return u'{0}: {1}'.format( util.displayable_path(self.path), - unicode(self.reason) + six.text_type(self.reason) ) - def __str__(self): - return unicode(self).encode('utf8') + # define __str__ as text to avoid infinite loop on super() calls + # with @six.python_2_unicode_compatible + __str__ = text +@six.python_2_unicode_compatible class ReadError(FileOperationError): """An error while reading a file (i.e. in `Item.read`). """ - def __unicode__(self): - return u'error reading ' + super(ReadError, self).__unicode__() + def __str__(self): + return u'error reading ' + super(ReadError, self).text() +@six.python_2_unicode_compatible class WriteError(FileOperationError): """An error while writing a file (i.e. in `Item.write`). """ - def __unicode__(self): - return u'error writing ' + super(WriteError, self).__unicode__() + def __str__(self): + return u'error writing ' + super(WriteError, self).text() # Item and Album model classes. +@six.python_2_unicode_compatible class LibModel(dbcore.Model): """Shared concrete functionality for Items and Albums. """ @@ -316,8 +342,8 @@ funcs.update(plugins.template_funcs()) return funcs - def store(self): - super(LibModel, self).store() + def store(self, fields=None): + super(LibModel, self).store(fields) plugins.send('database_change', lib=self._db, model=self) def remove(self): @@ -330,20 +356,16 @@ def __format__(self, spec): if not spec: - spec = beets.config[self._format_config_key].get(unicode) - result = self.evaluate_template(spec) - if isinstance(spec, bytes): - # if spec is a byte string then we must return a one as well - return result.encode('utf8') - else: - return result + spec = beets.config[self._format_config_key].as_str() + assert isinstance(spec, six.text_type) + return self.evaluate_template(spec) def __str__(self): - return format(self).encode('utf8') - - def __unicode__(self): return format(self) + def __bytes__(self): + return self.__str__().encode('utf-8') + class FormattedItemMapping(dbcore.db.FormattedMapping): """Add lookup for album-level fields. @@ -413,7 +435,10 @@ 'albumartist_sort': types.STRING, 'albumartist_credit': types.STRING, 'genre': types.STRING, + 'lyricist': types.STRING, 'composer': types.STRING, + 'composer_sort': types.STRING, + 'arranger': types.STRING, 'grouping': types.STRING, 'year': types.PaddedInt(4), 'month': types.PaddedInt(2), @@ -449,6 +474,8 @@ 'rg_track_peak': types.NULL_FLOAT, 'rg_album_gain': types.NULL_FLOAT, 'rg_album_peak': types.NULL_FLOAT, + 'r128_track_gain': types.PaddedInt(6), + 'r128_album_gain': types.PaddedInt(6), 'original_year': types.PaddedInt(4), 'original_month': types.PaddedInt(2), 'original_day': types.PaddedInt(2), @@ -516,15 +543,15 @@ """ # Encode unicode paths and read buffers. if key == 'path': - if isinstance(value, unicode): + if isinstance(value, six.text_type): value = bytestring_path(value) - elif isinstance(value, buffer): + elif isinstance(value, BLOB_TYPE): value = bytes(value) - if key in MediaFile.fields(): - self.mtime = 0 # Reset mtime on dirty. + changed = super(Item, self)._setitem(key, value) - super(Item, self).__setitem__(key, value) + if changed and key in MediaFile.fields(): + self.mtime = 0 # Reset mtime on dirty. def update(self, values): """Set all key/value pairs in the mapping. If mtime is @@ -534,6 +561,11 @@ if self.mtime == 0 and 'mtime' in values: self.mtime = values['mtime'] + def clear(self): + """Set all key/value pairs to None.""" + for key in self._media_fields: + setattr(self, key, None) + def get_album(self): """Get the Album object that this item belongs to, if any, or None if the item is a singleton or is not associated with a @@ -560,12 +592,12 @@ read_path = normpath(read_path) try: mediafile = MediaFile(syspath(read_path)) - except (OSError, IOError, UnreadableFileError) as exc: + except UnreadableFileError as exc: raise ReadError(read_path, exc) for key in self._media_fields: value = getattr(mediafile, key) - if isinstance(value, (int, long)): + if isinstance(value, six.integer_types): if value.bit_length() > 63: value = 0 self[key] = value @@ -607,14 +639,14 @@ try: mediafile = MediaFile(syspath(path), id3v23=beets.config['id3v23'].get(bool)) - except (OSError, IOError, UnreadableFileError) as exc: - raise ReadError(self.path, exc) + except UnreadableFileError as exc: + raise ReadError(path, exc) # Write the tags to the file. mediafile.update(item_tags) try: mediafile.save() - except (OSError, IOError, MutagenError) as exc: + except UnreadableFileError as exc: raise WriteError(self.path, exc) # The file has a new mtime. @@ -659,26 +691,33 @@ # Files themselves. - def move_file(self, dest, copy=False, link=False): - """Moves or copies the item's file, updating the path value if - the move succeeds. If a file exists at ``dest``, then it is - slightly modified to be unique. + def move_file(self, dest, operation=MoveOperation.MOVE): + """Move, copy, link or hardlink the item's depending on `operation`, + updating the path value if the move succeeds. + + If a file exists at `dest`, then it is slightly modified to be unique. + + `operation` should be an instance of `util.MoveOperation`. """ if not util.samefile(self.path, dest): dest = util.unique_path(dest) - if copy: + if operation == MoveOperation.MOVE: + plugins.send("before_item_moved", item=self, source=self.path, + destination=dest) + util.move(self.path, dest) + plugins.send("item_moved", item=self, source=self.path, + destination=dest) + elif operation == MoveOperation.COPY: util.copy(self.path, dest) plugins.send("item_copied", item=self, source=self.path, destination=dest) - elif link: + elif operation == MoveOperation.LINK: util.link(self.path, dest) plugins.send("item_linked", item=self, source=self.path, destination=dest) - else: - plugins.send("before_item_moved", item=self, source=self.path, - destination=dest) - util.move(self.path, dest) - plugins.send("item_moved", item=self, source=self.path, + elif operation == MoveOperation.HARDLINK: + util.hardlink(self.path, dest) + plugins.send("item_hardlinked", item=self, source=self.path, destination=dest) # Either copying or moving succeeded, so update the stored path. @@ -726,26 +765,27 @@ self._db._memotable = {} - def move(self, copy=False, link=False, basedir=None, with_album=True): + def move(self, operation=MoveOperation.MOVE, basedir=None, + with_album=True, store=True): """Move the item to its designated location within the library directory (provided by destination()). Subdirectories are created as needed. If the operation succeeds, the item's path field is updated to reflect the new location. - If `copy` is true, moving the file is copied rather than moved. - Similarly, `link` creates a symlink instead. - - basedir overrides the library base directory for the - destination. - - If the item is in an album, the album is given an opportunity to - move its art. (This can be disabled by passing - with_album=False.) - - The item is stored to the database if it is in the database, so - any dirty fields prior to the move() call will be written as a - side effect. You probably want to call save() to commit the DB - transaction. + Instead of moving the item it can also be copied, linked or hardlinked + depending on `operation` which should be an instance of + `util.MoveOperation`. + + `basedir` overrides the library base directory for the destination. + + If the item is in an album and `with_album` is `True`, the album is + given an opportunity to move its art. + + By default, the item is stored to the database if it is in the + database, so any dirty fields prior to the move() call will be written + as a side effect. + If `store` is `False` however, the item won't be stored and you'll + have to manually store it after invoking this method. """ self._check_db() dest = self.destination(basedir=basedir) @@ -755,18 +795,20 @@ # Perform the move and store the change. old_path = self.path - self.move_file(dest, copy, link) - self.store() + self.move_file(dest, operation) + if store: + self.store() # If this item is in an album, move its art. if with_album: album = self.get_album() if album: - album.move_art(copy) - album.store() + album.move_art(operation) + if store: + album.store() # Prune vacated directory. - if not copy: + if operation == MoveOperation.MOVE: util.prune_dirs(os.path.dirname(old_path), self._db.directory) # Templating. @@ -817,7 +859,10 @@ subpath = unicodedata.normalize('NFC', subpath) if beets.config['asciify_paths']: - subpath = unidecode(subpath) + subpath = util.asciify_path( + subpath, + beets.config['path_sep_replace'].as_str() + ) maxlen = beets.config['max_filename_length'].get(int) if not maxlen: @@ -854,7 +899,7 @@ _always_dirty = True _fields = { 'id': types.PRIMARY_ID, - 'artpath': PathType(), + 'artpath': PathType(True), 'added': DateType(), 'albumartist': types.STRING, @@ -881,6 +926,7 @@ 'albumdisambig': types.STRING, 'rg_album_gain': types.NULL_FLOAT, 'rg_album_peak': types.NULL_FLOAT, + 'r128_album_gain': types.PaddedInt(6), 'original_year': types.PaddedInt(4), 'original_month': types.PaddedInt(2), 'original_day': types.PaddedInt(2), @@ -924,6 +970,7 @@ 'albumdisambig', 'rg_album_gain', 'rg_album_peak', + 'r128_album_gain', 'original_year', 'original_month', 'original_day', @@ -968,9 +1015,12 @@ for item in self.items(): item.remove(delete, False) - def move_art(self, copy=False, link=False): - """Move or copy any existing album art so that it remains in the - same directory as the items. + def move_art(self, operation=MoveOperation.MOVE): + """Move, copy, link or hardlink (depending on `operation`) any + existing album art so that it remains in the same directory as + the items. + + `operation` should be an instance of `util.MoveOperation`. """ old_art = self.artpath if not old_art: @@ -984,39 +1034,47 @@ log.debug(u'moving album art {0} to {1}', util.displayable_path(old_art), util.displayable_path(new_art)) - if copy: + if operation == MoveOperation.MOVE: + util.move(old_art, new_art) + util.prune_dirs(os.path.dirname(old_art), self._db.directory) + elif operation == MoveOperation.COPY: util.copy(old_art, new_art) - elif link: + elif operation == MoveOperation.LINK: util.link(old_art, new_art) - else: - util.move(old_art, new_art) + elif operation == MoveOperation.HARDLINK: + util.hardlink(old_art, new_art) self.artpath = new_art - # Prune old path when moving. - if not copy: - util.prune_dirs(os.path.dirname(old_art), - self._db.directory) - - def move(self, copy=False, link=False, basedir=None): - """Moves (or copies) all items to their destination. Any album - art moves along with them. basedir overrides the library base - directory for the destination. The album is stored to the - database, persisting any modifications to its metadata. + def move(self, operation=MoveOperation.MOVE, basedir=None, store=True): + """Move, copy, link or hardlink (depending on `operation`) + all items to their destination. Any album art moves along with them. + + `basedir` overrides the library base directory for the destination. + + `operation` should be an instance of `util.MoveOperation`. + + By default, the album is stored to the database, persisting any + modifications to its metadata. If `store` is `False` however, + the album is not stored automatically, and you'll have to manually + store it after invoking this method. """ basedir = basedir or self._db.directory # Ensure new metadata is available to items for destination # computation. - self.store() + if store: + self.store() # Move items. items = list(self.items()) for item in items: - item.move(copy, link, basedir=basedir, with_album=False) + item.move(operation, basedir=basedir, with_album=False, + store=store) # Move art. - self.move_art(copy, link) - self.store() + self.move_art(operation) + if store: + self.store() def item_dir(self): """Returns the directory containing the album's first item, @@ -1060,10 +1118,14 @@ image = bytestring_path(image) item_dir = item_dir or self.item_dir() - filename_tmpl = Template(beets.config['art_filename'].get(unicode)) + filename_tmpl = Template( + beets.config['art_filename'].as_str()) subpath = self.evaluate_template(filename_tmpl, True) if beets.config['asciify_paths']: - subpath = unidecode(subpath) + subpath = util.asciify_path( + subpath, + beets.config['path_sep_replace'].as_str() + ) subpath = util.sanitize_path(subpath, replacements=self._db.replacements) subpath = bytestring_path(subpath) @@ -1104,9 +1166,11 @@ plugins.send('art_set', album=self) - def store(self): + def store(self, fields=None): """Update the database with the album information. The album's tracks are also updated. + :param fields: The fields to be stored. If not specified, all fields + will be. """ # Get modified track fields. track_updates = {} @@ -1115,7 +1179,7 @@ track_updates[key] = self[key] with self._db.transaction(): - super(Album, self).store() + super(Album, self).store(fields) if track_updates: for item in self.items(): for key, value in track_updates.items(): @@ -1178,7 +1242,8 @@ The string is split into components using shell-like syntax. """ - assert isinstance(s, unicode), u"Query is not unicode: {0!r}".format(s) + message = u"Query is not unicode: {0!r}".format(s) + assert isinstance(s, six.text_type), message try: parts = util.shlex_split(s) except ValueError as exc: @@ -1186,6 +1251,19 @@ return parse_query_parts(parts, model_cls) +def _sqlite_bytelower(bytestring): + """ A custom ``bytelower`` sqlite function so we can compare + bytestrings in a semi case insensitive fashion. This is to work + around sqlite builds are that compiled with + ``-DSQLITE_LIKE_DOESNT_MATCH_BLOBS``. See + ``https://github.com/beetbox/beets/issues/2172`` for details. + """ + if not six.PY2: + return bytestring.lower() + + return buffer(bytes(bytestring).lower()) # noqa: F821 + + # The Library: interface to the database. class Library(dbcore.Database): @@ -1198,9 +1276,8 @@ path_formats=((PF_KEY_DEFAULT, '$artist/$album/$track $title'),), replacements=None): - if path != ':memory:': - self.path = bytestring_path(normpath(path)) - super(Library, self).__init__(path) + timeout = beets.config['timeout'].as_number() + super(Library, self).__init__(path, timeout=timeout) self.directory = bytestring_path(normpath(directory)) self.path_formats = path_formats @@ -1208,6 +1285,11 @@ self._memotable = {} # Used for template substitution performance. + def _create_connection(self): + conn = super(Library, self)._create_connection() + conn.create_function('bytelower', 1, _sqlite_bytelower) + return conn + # Adding objects to the database. def add(self, obj): @@ -1254,11 +1336,11 @@ # Parse the query, if necessary. try: parsed_sort = None - if isinstance(query, basestring): + if isinstance(query, six.string_types): query, parsed_sort = parse_query_string(query, model_cls) elif isinstance(query, (list, tuple)): query, parsed_sort = parse_query_parts(query, model_cls) - except dbcore.query.InvalidQueryArgumentTypeError as exc: + except dbcore.query.InvalidQueryArgumentValueError as exc: raise dbcore.InvalidQueryError(query, exc) # Any non-null sort specified by the parsed query overrides the @@ -1398,22 +1480,24 @@ def tmpl_asciify(s): """Translate non-ASCII characters to their ASCII equivalents. """ - return unidecode(s) + return util.asciify_path(s, beets.config['path_sep_replace'].as_str()) @staticmethod def tmpl_time(s, fmt): """Format a time value using `strftime`. """ - cur_fmt = beets.config['time_format'].get(unicode) + cur_fmt = beets.config['time_format'].as_str() return time.strftime(fmt, time.strptime(s, cur_fmt)) - def tmpl_aunique(self, keys=None, disam=None): + def tmpl_aunique(self, keys=None, disam=None, bracket=None): """Generate a string that is guaranteed to be unique among all albums in the library who share the same set of keys. A fields from "disam" is used in the string if one is sufficient to disambiguate the albums. Otherwise, a fallback opaque value is used. Both "keys" and "disam" should be given as - whitespace-separated lists of field names. + whitespace-separated lists of field names, while "bracket" is a + pair of characters to be used as brackets surrounding the + disambiguator or empty to have no brackets. """ # Fast paths: no album, no item or library, or memoized value. if not self.item or not self.lib: @@ -1427,9 +1511,19 @@ keys = keys or 'albumartist album' disam = disam or 'albumtype year label catalognum albumdisambig' + if bracket is None: + bracket = '[]' keys = keys.split() disam = disam.split() + # Assign a left and right bracket or leave blank if argument is empty. + if len(bracket) == 2: + bracket_l = bracket[0] + bracket_r = bracket[1] + else: + bracket_l = u'' + bracket_r = u'' + album = self.lib.get_album(self.item) if not album: # Do nothing for singletons. @@ -1462,13 +1556,19 @@ else: # No disambiguator distinguished all fields. - res = u' {0}'.format(album.id) + res = u' {1}{0}{2}'.format(album.id, bracket_l, bracket_r) self.lib._memotable[memokey] = res return res # Flatten disambiguation value into a string. disam_value = album.formatted(True).get(disambiguator) - res = u' [{0}]'.format(disam_value) + + # Return empty string if disambiguator is empty. + if disam_value: + res = u' {1}{0}{2}'.format(disam_value, bracket_l, bracket_r) + else: + res = u'' + self.lib._memotable[memokey] = res return res diff -Nru beets-1.3.19/beets/logging.py beets-1.4.6/beets/logging.py --- beets-1.3.19/beets/logging.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/logging.py 2016-12-17 03:01:22.000000000 +0000 @@ -27,6 +27,7 @@ from logging import * # noqa import subprocess import threading +import six def logsafe(val): @@ -42,7 +43,7 @@ example. """ # Already Unicode. - if isinstance(val, unicode): + if isinstance(val, six.text_type): return val # Bytestring: needs decoding. @@ -51,16 +52,16 @@ # (a) only do this for paths, if they can be given a distinct # type, and (b) warn the developer if they do this for other # bytestrings. - return val.decode('utf8', 'replace') + return val.decode('utf-8', 'replace') # A "problem" object: needs a workaround. elif isinstance(val, subprocess.CalledProcessError): try: - return unicode(val) + return six.text_type(val) except UnicodeDecodeError: # An object with a broken __unicode__ formatter. Use __str__ # instead. - return str(val).decode('utf8', 'replace') + return str(val).decode('utf-8', 'replace') # Other objects are used as-is so field access, etc., still works in # the format string. diff -Nru beets-1.3.19/beets/__main__.py beets-1.4.6/beets/__main__.py --- beets-1.3.19/beets/__main__.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/beets/__main__.py 2017-06-14 23:13:48.000000000 +0000 @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2017, 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. + +"""The __main__ module lets you run the beets CLI interface by typing +`python -m beets`. +""" + +from __future__ import division, absolute_import, print_function + +import sys +from .ui import main + +if __name__ == "__main__": + main(sys.argv[1:]) diff -Nru beets-1.3.19/beets/mediafile.py beets-1.4.6/beets/mediafile.py --- beets-1.3.19/beets/mediafile.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beets/mediafile.py 2017-11-25 22:56:53.000000000 +0000 @@ -36,15 +36,11 @@ from __future__ import division, absolute_import, print_function import mutagen -import mutagen.mp3 import mutagen.id3 -import mutagen.oggopus -import mutagen.oggvorbis import mutagen.mp4 import mutagen.flac -import mutagen.monkeysaudio import mutagen.asf -import mutagen.aiff + import codecs import datetime import re @@ -56,14 +52,13 @@ import os import traceback import enum - -from beets import logging -from beets.util import displayable_path, syspath, as_string +import logging +import six __all__ = ['UnreadableFileError', 'FileTypeError', 'MediaFile'] -log = logging.getLogger('beets') +log = logging.getLogger(__name__) # Human-readable type names. TYPES = { @@ -78,16 +73,19 @@ 'mpc': 'Musepack', 'asf': 'Windows Media', 'aiff': 'AIFF', + 'dsf': 'DSD Stream File', } +PREFERRED_IMAGE_EXTENSIONS = {'jpeg': 'jpg'} + # Exceptions. class UnreadableFileError(Exception): """Mutagen is not able to extract information from the file. """ - def __init__(self, path): - Exception.__init__(self, displayable_path(path)) + def __init__(self, path, msg): + Exception.__init__(self, msg if msg else repr(path)) class FileTypeError(UnreadableFileError): @@ -97,11 +95,10 @@ mutagen type is not supported by `Mediafile`. """ def __init__(self, path, mutagen_type=None): - path = displayable_path(path) if mutagen_type is None: - msg = path + msg = u'{0!r}: not in a recognized format'.format(path) else: - msg = u'{0}: of mutagen type {1}'.format(path, mutagen_type) + msg = u'{0}: of mutagen type {1}'.format(repr(path), mutagen_type) Exception.__init__(self, msg) @@ -109,10 +106,37 @@ """Raised when Mutagen fails unexpectedly---probably due to a bug. """ def __init__(self, path, mutagen_exc): - msg = u'{0}: {1}'.format(displayable_path(path), mutagen_exc) + msg = u'{0}: {1}'.format(repr(path), mutagen_exc) Exception.__init__(self, msg) +# Interacting with Mutagen. + +def mutagen_call(action, path, func, *args, **kwargs): + """Call a Mutagen function with appropriate error handling. + + `action` is a string describing what the function is trying to do, + and `path` is the relevant filename. The rest of the arguments + describe the callable to invoke. + + We require at least Mutagen 1.33, where `IOError` is *never* used, + neither for internal parsing errors *nor* for ordinary IO error + conditions such as a bad filename. Mutagen-specific parsing errors and IO + errors are reraised as `UnreadableFileError`. Other exceptions + raised inside Mutagen---i.e., bugs---are reraised as `MutagenError`. + """ + try: + return func(*args, **kwargs) + except mutagen.MutagenError as exc: + log.debug(u'%s failed: %s', action, six.text_type(exc)) + raise UnreadableFileError(path, six.text_type(exc)) + except Exception as exc: + # Isolate bugs in Mutagen. + log.debug(u'%s', traceback.format_exc()) + log.error(u'uncaught Mutagen exception in %s: %s', action, exc) + raise MutagenError(path, exc) + + # Utility. def _safe_cast(out_type, val): @@ -130,14 +154,13 @@ return int(val) else: # Process any other type as a string. - if not isinstance(val, basestring): - val = unicode(val) + if isinstance(val, bytes): + val = val.decode('utf-8', 'ignore') + elif not isinstance(val, six.string_types): + val = six.text_type(val) # Get a number from the front of the string. - val = re.match(r'[0-9]*', val.strip()).group(0) - if not val: - return 0 - else: - return int(val) + match = re.match(r'[\+-]?[0-9]+', val.strip()) + return int(match.group(0)) if match else 0 elif out_type == bool: try: @@ -146,22 +169,22 @@ except ValueError: return False - elif out_type == unicode: + elif out_type == six.text_type: if isinstance(val, bytes): - return val.decode('utf8', 'ignore') - elif isinstance(val, unicode): + return val.decode('utf-8', 'ignore') + elif isinstance(val, six.text_type): return val else: - return unicode(val) + return six.text_type(val) elif out_type == float: if isinstance(val, int) or isinstance(val, float): return float(val) else: if isinstance(val, bytes): - val = val.decode('utf8', 'ignore') + val = val.decode('utf-8', 'ignore') else: - val = unicode(val) + val = six.text_type(val) match = re.match(r'[\+-]?([0-9]+\.?[0-9]*|[0-9]*\.[0-9]+)', val.strip()) if match: @@ -220,8 +243,8 @@ """ # We decode binary data. If one of the formats gives us a text # string, interpret it as UTF-8. - if isinstance(soundcheck, unicode): - soundcheck = soundcheck.encode('utf8') + if isinstance(soundcheck, six.text_type): + soundcheck = soundcheck.encode('utf-8') # SoundCheck tags consist of 10 numbers, each represented by 8 # characters of ASCII hex preceded by a space. @@ -268,18 +291,29 @@ # from the gain ratio using a reference value of 1000 units. We also # enforce the maximum value here, which is equivalent to about # -18.2dB. - g1 = min(round((10 ** (gain / -10)) * 1000), 65534) + g1 = int(min(round((10 ** (gain / -10)) * 1000), 65534)) # Same as above, except our reference level is 2500 units. - g2 = min(round((10 ** (gain / -10)) * 2500), 65534) + g2 = int(min(round((10 ** (gain / -10)) * 2500), 65534)) # The purpose of these values are unknown, but they also seem to be # unused so we just use zero. uk = 0 - values = (g1, g1, g2, g2, uk, uk, peak, peak, uk, uk) + values = (g1, g1, g2, g2, uk, uk, int(peak), int(peak), uk, uk) return (u' %08X' * 10) % values # Cover art and other images. +def _imghdr_what_wrapper(data): + """A wrapper around imghdr.what to account for jpeg files that can only be + identified as such using their magic bytes + See #1545 + See https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12 + """ + # imghdr.what returns none for jpegs with only the magic bytes, so + # _wider_test_jpeg is run in that case. It still returns None if it didn't + # match such a jpeg file. + return imghdr.what(None, h=data) or _wider_test_jpeg(data) + def _wider_test_jpeg(data): """Test for a jpeg file following the UNIX file implementation which @@ -290,14 +324,14 @@ return 'jpeg' -def _image_mime_type(data): +def image_mime_type(data): """Return the MIME type of the image data (a bytestring). """ # This checks for a jpeg file with only the magic bytes (unrecognized by # imghdr.what). imghdr.what returns none for that type of file, so # _wider_test_jpeg is run in that case. It still returns None if it didn't # match such a jpeg file. - kind = imghdr.what(None, h=data) or _wider_test_jpeg(data) + kind = _imghdr_what_wrapper(data) if kind in ['gif', 'jpeg', 'png', 'tiff', 'bmp']: return 'image/{0}'.format(kind) elif kind == 'pgm': @@ -312,6 +346,11 @@ return 'image/x-{0}'.format(kind) +def image_extension(data): + ext = _imghdr_what_wrapper(data) + return PREFERRED_IMAGE_EXTENSIONS.get(ext, ext) + + class ImageType(enum.Enum): """Indicates the kind of an `Image` stored in a file's tag. """ @@ -350,20 +389,23 @@ the binary data """ def __init__(self, data, desc=None, type=None): + assert isinstance(data, bytes) + if desc is not None: + assert isinstance(desc, six.text_type) self.data = data self.desc = desc if isinstance(type, int): try: type = list(ImageType)[type] except IndexError: - log.debug(u"ignoring unknown image type index {0}", type) + log.debug(u"ignoring unknown image type index %s", type) type = ImageType.other self.type = type @property def mime_type(self): if self.data: - return _image_mime_type(self.data) + return image_mime_type(self.data) @property def type_index(self): @@ -407,7 +449,8 @@ """List of mutagen classes the StorageStyle can handle. """ - def __init__(self, key, as_type=unicode, suffix=None, float_places=2): + def __init__(self, key, as_type=six.text_type, suffix=None, + float_places=2): """Create a basic storage strategy. Parameters: - `key`: The key on the Mutagen file object used to access the @@ -426,9 +469,9 @@ self.float_places = float_places # Convert suffix to correct string type. - if self.suffix and self.as_type is unicode \ - and not isinstance(self.suffix, unicode): - self.suffix = self.suffix.decode('utf8') + if self.suffix and self.as_type is six.text_type \ + and not isinstance(self.suffix, six.text_type): + self.suffix = self.suffix.decode('utf-8') # Getter. @@ -450,7 +493,7 @@ """Given a raw value stored on a Mutagen object, decode and return the represented value. """ - if self.suffix and isinstance(mutagen_value, unicode) \ + if self.suffix and isinstance(mutagen_value, six.text_type) \ and mutagen_value.endswith(self.suffix): return mutagen_value[:-len(self.suffix)] else: @@ -472,17 +515,17 @@ """Convert the external Python value to a type that is suitable for storing in a Mutagen file object. """ - if isinstance(value, float) and self.as_type is unicode: + if isinstance(value, float) and self.as_type is six.text_type: value = u'{0:.{1}f}'.format(value, self.float_places) value = self.as_type(value) - elif self.as_type is unicode: + elif self.as_type is six.text_type: if isinstance(value, bool): # Store bools as 1/0 instead of True/False. - value = unicode(int(bool(value))) + value = six.text_type(int(bool(value))) elif isinstance(value, bytes): - value = value.decode('utf8', 'ignore') + value = value.decode('utf-8', 'ignore') else: - value = unicode(value) + value = six.text_type(value) else: value = self.as_type(value) @@ -592,8 +635,8 @@ def serialize(self, value): value = super(MP4StorageStyle, self).serialize(value) - if self.key.startswith('----:') and isinstance(value, unicode): - value = value.encode('utf8') + if self.key.startswith('----:') and isinstance(value, six.text_type): + value = value.encode('utf-8') return value @@ -685,7 +728,7 @@ class MP3StorageStyle(StorageStyle): """Store data in ID3 frames. """ - formats = ['MP3', 'AIFF'] + formats = ['MP3', 'AIFF', 'DSF'] def __init__(self, key, id3_lang=None, **kwargs): """Create a new ID3 storage style. `id3_lang` is the value for @@ -705,6 +748,43 @@ mutagen_file.tags.setall(self.key, [frame]) +class MP3PeopleStorageStyle(MP3StorageStyle): + """Store list of people in ID3 frames. + """ + def __init__(self, key, involvement='', **kwargs): + self.involvement = involvement + super(MP3PeopleStorageStyle, self).__init__(key, **kwargs) + + def store(self, mutagen_file, value): + frames = mutagen_file.tags.getall(self.key) + + # Try modifying in place. + found = False + for frame in frames: + if frame.encoding == mutagen.id3.Encoding.UTF8: + for pair in frame.people: + if pair[0].lower() == self.involvement.lower(): + pair[1] = value + found = True + + # Try creating a new frame. + if not found: + frame = mutagen.id3.Frames[self.key]( + encoding=mutagen.id3.Encoding.UTF8, + people=[[self.involvement, value]] + ) + mutagen_file.tags.add(frame) + + def fetch(self, mutagen_file): + for frame in mutagen_file.tags.getall(self.key): + for pair in frame.people: + if pair[0].lower() == self.involvement.lower(): + try: + return pair[1] + except IndexError: + return None + + class MP3ListStorageStyle(ListStorageStyle, MP3StorageStyle): """Store lists of data in multiple ID3 frames. """ @@ -720,7 +800,7 @@ class MP3UFIDStorageStyle(MP3StorageStyle): - """Store data in a UFID ID3 frame with a particular owner. + """Store string data in a UFID ID3 frame with a particular owner. """ def __init__(self, owner, **kwargs): self.owner = owner @@ -733,6 +813,10 @@ return None def store(self, mutagen_file, value): + # This field type stores text data as encoded data. + assert isinstance(value, six.text_type) + value = value.encode('utf-8') + frames = mutagen_file.tags.getall(self.key) for frame in frames: # Replace existing frame data. @@ -749,6 +833,7 @@ selected based its ``desc`` field. """ def __init__(self, desc=u'', key='TXXX', **kwargs): + assert isinstance(desc, six.text_type) self.description = desc super(MP3DescStorageStyle, self).__init__(key=key, **kwargs) @@ -768,7 +853,7 @@ # Try creating a new frame. if not found: frame = mutagen.id3.Frames[self.key]( - desc=bytes(self.description), + desc=self.description, text=value, encoding=mutagen.id3.Encoding.UTF8, ) @@ -807,7 +892,7 @@ def _fetch_unpacked(self, mutagen_file): data = self.fetch(mutagen_file) if data: - items = unicode(data).split('/') + items = six.text_type(data).split('/') else: items = [] packing_length = 2 @@ -823,7 +908,7 @@ items[0] = '' if items[1] is None: items.pop() # Do not store last value - self.store(mutagen_file, '/'.join(map(unicode, items))) + self.store(mutagen_file, '/'.join(map(six.text_type, items))) def delete(self, mutagen_file): if self.pack_pos == 0: @@ -864,8 +949,17 @@ frame = mutagen.id3.Frames[self.key]() frame.data = image.data frame.mime = image.mime_type - frame.desc = (image.desc or u'').encode('utf8') - frame.encoding = 3 # UTF-8 encoding of desc + frame.desc = image.desc or u'' + + # For compatibility with OS X/iTunes prefer latin-1 if possible. + # See issue #899 + try: + frame.desc.encode("latin-1") + except UnicodeEncodeError: + frame.encoding = mutagen.id3.Encoding.UTF16 + else: + frame.encoding = mutagen.id3.Encoding.LATIN1 + frame.type = image.type_index return frame @@ -945,7 +1039,11 @@ pic.type = image.type_index pic.mime = image.mime_type pic.desc = image.desc or u'' - return base64.b64encode(pic.write()) + + # Encoding with base64 returns bytes on both Python 2 and 3. + # Mutagen requires the data to be a Unicode string, so we decode + # it before passing it along. + return base64.b64encode(pic.write()).decode('ascii') class FlacImageStorageStyle(ListStorageStyle): @@ -1024,8 +1122,11 @@ try: frame = mutagen_file[cover_tag] text_delimiter_index = frame.value.find(b'\x00') - comment = frame.value[0:text_delimiter_index] \ - if text_delimiter_index > 0 else None + if text_delimiter_index > 0: + comment = frame.value[0:text_delimiter_index] + comment = comment.decode('utf-8', 'replace') + else: + comment = None image_data = frame.value[text_delimiter_index + 1:] images.append(Image(data=image_data, type=cover_type, desc=comment)) @@ -1040,7 +1141,7 @@ for image in values: image_type = image.type or ImageType.other comment = image.desc or '' - image_data = comment.encode('utf8') + b'\x00' + image.data + image_data = comment.encode('utf-8') + b'\x00' + image.data cover_tag = self.TAG_NAMES[image_type] mutagen_file[cover_tag] = image_data @@ -1074,7 +1175,7 @@ getting this property. """ - self.out_type = kwargs.get('out_type', unicode) + self.out_type = kwargs.get('out_type', six.text_type) self._styles = styles def styles(self, mutagen_file): @@ -1113,7 +1214,7 @@ return 0.0 elif self.out_type == bool: return False - elif self.out_type == unicode: + elif self.out_type == six.text_type: return u'' @@ -1194,9 +1295,9 @@ """ # Get the underlying data and split on hyphens and slashes. datestring = super(DateField, self).__get__(mediafile, None) - if isinstance(datestring, basestring): - datestring = re.sub(r'[Tt ].*$', '', unicode(datestring)) - items = re.split('[-/]', unicode(datestring)) + if isinstance(datestring, six.string_types): + datestring = re.sub(r'[Tt ].*$', '', six.text_type(datestring)) + items = re.split('[-/]', six.text_type(datestring)) else: items = [] @@ -1215,7 +1316,7 @@ for item in items: try: items_.append(int(item)) - except: + except (TypeError, ValueError): items_.append(None) return items_ @@ -1233,7 +1334,7 @@ date.append(u'{0:02d}'.format(int(month))) if month and day: date.append(u'{0:02d}'.format(int(day))) - date = map(unicode, date) + date = map(six.text_type, date) super(DateField, self).__set__(mediafile, u'-'.join(date)) if hasattr(self, '_year_field'): @@ -1341,40 +1442,9 @@ By default, MP3 files are saved with ID3v2.4 tags. You can use the older ID3v2.3 standard by specifying the `id3v23` option. """ - path = syspath(path) self.path = path - unreadable_exc = ( - mutagen.mp3.error, - mutagen.id3.error, - mutagen.flac.error, - mutagen.monkeysaudio.MonkeysAudioHeaderError, - mutagen.mp4.error, - mutagen.oggopus.error, - mutagen.oggvorbis.error, - mutagen.ogg.error, - mutagen.asf.error, - mutagen.apev2.error, - mutagen.aiff.error, - ) - try: - self.mgfile = mutagen.File(path) - except unreadable_exc as exc: - log.debug(u'header parsing failed: {0}', unicode(exc)) - raise UnreadableFileError(path) - except IOError as exc: - if type(exc) == IOError: - # This is a base IOError, not a subclass from Mutagen or - # anywhere else. - raise - else: - log.debug(u'{}', traceback.format_exc()) - raise MutagenError(path, exc) - except Exception as exc: - # Isolate bugs in Mutagen. - log.debug(u'{}', traceback.format_exc()) - log.error(u'uncaught Mutagen exception in open: {0}', exc) - raise MutagenError(path, exc) + self.mgfile = mutagen_call('open', path, mutagen.File, path) if self.mgfile is None: # Mutagen couldn't guess the type @@ -1382,20 +1452,10 @@ elif (type(self.mgfile).__name__ == 'M4A' or type(self.mgfile).__name__ == 'MP4'): info = self.mgfile.info - if hasattr(info, 'codec'): - if info.codec and info.codec.startswith('alac'): - self.type = 'alac' - else: - self.type = 'aac' + if info.codec and info.codec.startswith('alac'): + self.type = 'alac' else: - # This hack differentiates AAC and ALAC on versions of - # Mutagen < 1.26. Once Mutagen > 1.26 is out and - # required by beets, we can remove this. - if hasattr(self.mgfile.info, 'bitrate') and \ - self.mgfile.info.bitrate > 0: - self.type = 'aac' - else: - self.type = 'alac' + self.type = 'aac' elif (type(self.mgfile).__name__ == 'ID3' or type(self.mgfile).__name__ == 'MP3'): self.type = 'mp3' @@ -1415,6 +1475,8 @@ self.type = 'asf' elif type(self.mgfile).__name__ == 'AIFF': self.type = 'aiff' + elif type(self.mgfile).__name__ == 'DSF': + self.type = 'dsf' else: raise FileTypeError(path, type(self.mgfile).__name__) @@ -1426,7 +1488,8 @@ self.id3v23 = id3v23 and self.type == 'mp3' def save(self): - """Write the object's tags back to the file. + """Write the object's tags back to the file. May + throw `UnreadableFileError`. """ # Possibly save the tags to ID3v2.3. kwargs = {} @@ -1438,27 +1501,13 @@ id3.update_to_v23() kwargs['v2_version'] = 3 - # Isolate bugs in Mutagen. - try: - self.mgfile.save(**kwargs) - except (IOError, OSError): - # Propagate these through: they don't represent Mutagen bugs. - raise - except Exception as exc: - log.debug(u'{}', traceback.format_exc()) - log.error(u'uncaught Mutagen exception in save: {0}', exc) - raise MutagenError(self.path, exc) + mutagen_call('save', self.path, self.mgfile.save, **kwargs) def delete(self): - """Remove the current metadata tag from the file. + """Remove the current metadata tag from the file. May + throw `UnreadableFileError`. """ - try: - self.mgfile.delete() - except NotImplementedError: - # For Mutagen types that don't support deletion (notably, - # ASF), just delete each tag individually. - for tag in self.mgfile.keys(): - del self.mgfile[tag] + mutagen_call('delete', self.path, self.mgfile.delete) # Convenient access to the set of available fields. @@ -1470,7 +1519,12 @@ """ for property, descriptor in cls.__dict__.items(): if isinstance(descriptor, MediaField): - yield as_string(property) + if isinstance(property, bytes): + # On Python 2, class field names are bytes. This method + # produces text strings. + yield property.decode('utf8', 'ignore') + else: + yield property @classmethod def _field_sort_name(cls, name): @@ -1571,12 +1625,31 @@ ) genre = genres.single_field() + lyricist = MediaField( + MP3StorageStyle('TEXT'), + MP4StorageStyle('----:com.apple.iTunes:LYRICIST'), + StorageStyle('LYRICIST'), + ASFStorageStyle('WM/Writer'), + ) composer = MediaField( MP3StorageStyle('TCOM'), MP4StorageStyle('\xa9wrt'), StorageStyle('COMPOSER'), ASFStorageStyle('WM/Composer'), ) + composer_sort = MediaField( + MP3StorageStyle('TSOC'), + MP4StorageStyle('soco'), + StorageStyle('COMPOSERSORT'), + ASFStorageStyle('WM/Composersortorder'), + ) + arranger = MediaField( + MP3PeopleStorageStyle('TIPL', involvement='arranger'), + MP4StorageStyle('----:com.apple.iTunes:Arranger'), + StorageStyle('ARRANGER'), + ASFStorageStyle('beets/Arranger'), + ) + grouping = MediaField( MP3StorageStyle('TIT1'), MP4StorageStyle('\xa9grp'), @@ -1716,7 +1789,7 @@ ASFStorageStyle('WM/Language'), ) country = MediaField( - MP3DescStorageStyle('MusicBrainz Album Release Country'), + MP3DescStorageStyle(u'MusicBrainz Album Release Country'), MP4StorageStyle('----:com.apple.iTunes:MusicBrainz ' 'Album Release Country'), StorageStyle('RELEASECOUNTRY'), @@ -1873,9 +1946,9 @@ u'replaygain_album_gain', float_places=2, suffix=u' dB' ), - MP4SoundCheckStorageStyle( - '----:com.apple.iTunes:iTunNORM', - index=1 + MP4StorageStyle( + '----:com.apple.iTunes:replaygain_album_gain', + float_places=2, suffix=' dB' ), StorageStyle( u'REPLAYGAIN_ALBUM_GAIN', @@ -1931,6 +2004,38 @@ out_type=float, ) + # EBU R128 fields. + r128_track_gain = MediaField( + MP3DescStorageStyle( + u'R128_TRACK_GAIN' + ), + MP4StorageStyle( + '----:com.apple.iTunes:R128_TRACK_GAIN' + ), + StorageStyle( + u'R128_TRACK_GAIN' + ), + ASFStorageStyle( + u'R128_TRACK_GAIN' + ), + out_type=int, + ) + r128_album_gain = MediaField( + MP3DescStorageStyle( + u'R128_ALBUM_GAIN' + ), + MP4StorageStyle( + '----:com.apple.iTunes:R128_ALBUM_GAIN' + ), + StorageStyle( + u'R128_ALBUM_GAIN' + ), + ASFStorageStyle( + u'R128_ALBUM_GAIN' + ), + out_type=int, + ) + initial_key = MediaField( MP3StorageStyle('TKEY'), MP4StorageStyle('----:com.apple.iTunes:initialkey'), @@ -1966,13 +2071,6 @@ @property def channels(self): """The number of channels in the audio (an int).""" - if isinstance(self.mgfile.info, mutagen.mp3.MPEGInfo): - return { - mutagen.mp3.STEREO: 2, - mutagen.mp3.JOINTSTEREO: 2, - mutagen.mp3.DUALCHANNEL: 2, - mutagen.mp3.MONO: 1, - }[self.mgfile.info.mode] if hasattr(self.mgfile.info, 'channels'): return self.mgfile.info.channels return 0 diff -Nru beets-1.3.19/beets/plugins.py beets-1.4.6/beets/plugins.py --- beets-1.3.19/beets/plugins.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/plugins.py 2017-06-14 23:13:48.000000000 +0000 @@ -27,6 +27,7 @@ import beets from beets import logging from beets import mediafile +import six PLUGIN_NAMESPACE = 'beetsplug' @@ -54,10 +55,10 @@ def filter(self, record): if hasattr(record.msg, 'msg') and isinstance(record.msg.msg, - basestring): + six.string_types): # A _LogMessage from our hacked-up Logging replacement. record.msg.msg = self.prefix + record.msg.msg - elif isinstance(record.msg, basestring): + elif isinstance(record.msg, six.string_types): record.msg = self.prefix + record.msg return True @@ -254,7 +255,7 @@ except ImportError as exc: # Again, this is hacky: if exc.args[0].endswith(' ' + name): - log.warn(u'** plugin {0} not found', name) + log.warning(u'** plugin {0} not found', name) else: raise else: @@ -263,8 +264,8 @@ and obj != BeetsPlugin and obj not in _classes: _classes.add(obj) - except: - log.warn( + except Exception: + log.warning( u'** error loading plugin {}:\n{}', name, traceback.format_exc(), @@ -350,41 +351,35 @@ def candidates(items, artist, album, va_likely): """Gets MusicBrainz candidates for an album from each plugin. """ - out = [] for plugin in find_plugins(): - out.extend(plugin.candidates(items, artist, album, va_likely)) - return out + for candidate in plugin.candidates(items, artist, album, va_likely): + yield candidate def item_candidates(item, artist, title): """Gets MusicBrainz candidates for an item from the plugins. """ - out = [] for plugin in find_plugins(): - out.extend(plugin.item_candidates(item, artist, title)) - return out + for item_candidate in plugin.item_candidates(item, artist, title): + yield item_candidate def album_for_id(album_id): """Get AlbumInfo objects for a given ID string. """ - out = [] for plugin in find_plugins(): - res = plugin.album_for_id(album_id) - if res: - out.append(res) - return out + album = plugin.album_for_id(album_id) + if album: + yield album def track_for_id(track_id): """Get TrackInfo objects for a given ID string. """ - out = [] for plugin in find_plugins(): - res = plugin.track_for_id(track_id) - if res: - out.append(res) - return out + track = plugin.track_for_id(track_id) + if track: + yield track def template_funcs(): @@ -487,3 +482,19 @@ if not (s in seen or seen.add(s)): res.extend(list(others) if s == '*' else [s]) return res + + +def notify_info_yielded(event): + """Makes a generator send the event 'event' every time it yields. + This decorator is supposed to decorate a generator, but any function + returning an iterable should work. + Each yielded value is passed to plugins using the 'info' parameter of + 'send'. + """ + def decorator(generator): + def decorated(*args, **kwargs): + for v in generator(*args, **kwargs): + send(event, info=v) + yield v + return decorated + return decorator diff -Nru beets-1.3.19/beets/ui/commands.py beets-1.4.6/beets/ui/commands.py --- beets-1.3.19/beets/ui/commands.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/ui/commands.py 2017-12-16 20:00:33.000000000 +0000 @@ -21,6 +21,7 @@ import os import re +from platform import python_version from collections import namedtuple, Counter from itertools import chain @@ -33,14 +34,17 @@ from beets import plugins from beets import importer from beets import util -from beets.util import syspath, normpath, ancestry, displayable_path +from beets.util import syspath, normpath, ancestry, displayable_path, \ + MoveOperation from beets import library from beets import config from beets import logging from beets.util.confit import _package_path +import six +from . import _store_dict VARIOUS_ARTISTS = u'Various Artists' -PromptChoice = namedtuple('ExtraChoice', ['short', 'long', 'callback']) +PromptChoice = namedtuple('PromptChoice', ['short', 'long', 'callback']) # Global logger. log = logging.getLogger('beets') @@ -82,16 +86,16 @@ def _print_keys(query): """Given a SQLite query result, print the `key` field of each - returned row, with identation of 2 spaces. + returned row, with indentation of 2 spaces. """ for row in query: - print_(' ' * 2 + row['key']) + print_(u' ' * 2 + row['key']) def fields_func(lib, opts, args): def _print_rows(names): names.sort() - print_(" " + "\n ".join(names)) + print_(u' ' + u'\n '.join(names)) print_(u"Item fields:") _print_rows(library.Item.all_keys()) @@ -156,14 +160,14 @@ if isinstance(info, hooks.AlbumInfo): if info.media: - if info.mediums > 1: + if info.mediums and info.mediums > 1: disambig.append(u'{0}x{1}'.format( info.mediums, info.media )) else: disambig.append(info.media) if info.year: - disambig.append(unicode(info.year)) + disambig.append(six.text_type(info.year)) if info.country: disambig.append(info.country) if info.label: @@ -236,9 +240,9 @@ if mediums > 1: return u'{0}-{1}'.format(medium, medium_index) else: - return unicode(medium_index) + return six.text_type(medium_index or index) else: - return unicode(index) + return six.text_type(index) # Identify the album in question. if cur_artist != match.info.artist or \ @@ -493,7 +497,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, cur_album=None, item=None, itemcount=None, - extra_choices=[]): + choices=[]): """Given a sorted list of candidates, ask the user for a selection of which candidate to use. Applies to both full albums and singletons (tracks). Candidates are either AlbumMatch or TrackMatch @@ -501,16 +505,12 @@ `cur_album`, and `itemcount` must be provided. For singletons, `item` must be provided. - `extra_choices` is a list of `PromptChoice`s, containg the choices - appended by the plugins after receiving the `before_choose_candidate` - event. If not empty, the choices are appended to the prompt presented - to the user. + `choices` is a list of `PromptChoice`s to be used in each prompt. Returns one of the following: - * the result of the choice, which may be SKIP, ASIS, TRACKS, or MANUAL + * the result of the choice, which may be SKIP or ASIS * a candidate (an AlbumMatch/TrackMatch object) - * the short letter of a `PromptChoice` (if the user selected one of - the `extra_choices`). + * a chosen `PromptChoice` from `choices` """ # Sanity check. if singleton: @@ -519,41 +519,22 @@ assert cur_artist is not None assert cur_album is not None - # Build helper variables for extra choices. - extra_opts = tuple(c.long for c in extra_choices) - extra_actions = tuple(c.short for c in extra_choices) + # Build helper variables for the prompt choices. + choice_opts = tuple(c.long for c in choices) + choice_actions = {c.short: c for c in choices} # Zero candidates. if not candidates: if singleton: print_(u"No matching recordings found.") - opts = (u'Use as-is', u'Skip', u'Enter search', u'enter Id', - u'aBort') else: print_(u"No matching release found for {0} tracks." .format(itemcount)) print_(u'For help, see: ' u'http://beets.readthedocs.org/en/latest/faq.html#nomatch') - opts = (u'Use as-is', u'as Tracks', u'Group albums', u'Skip', - u'Enter search', u'enter Id', u'aBort') - sel = ui.input_options(opts + extra_opts) - if sel == u'u': - return importer.action.ASIS - elif sel == u't': - assert not singleton - return importer.action.TRACKS - elif sel == u'e': - return importer.action.MANUAL - elif sel == u's': - return importer.action.SKIP - elif sel == u'b': - raise importer.ImportAbort() - elif sel == u'i': - return importer.action.MANUAL_ID - elif sel == u'g': - return importer.action.ALBUMS - elif sel in extra_actions: - return sel + sel = ui.input_options(choice_opts) + if sel in choice_actions: + return choice_actions[sel] else: assert False @@ -601,33 +582,12 @@ print_(u' '.join(line)) # Ask the user for a choice. - if singleton: - opts = (u'Skip', u'Use as-is', u'Enter search', u'enter Id', - u'aBort') - else: - opts = (u'Skip', u'Use as-is', u'as Tracks', u'Group albums', - u'Enter search', u'enter Id', u'aBort') - sel = ui.input_options(opts + extra_opts, + sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) - if sel == u's': - return importer.action.SKIP - elif sel == u'u': - return importer.action.ASIS - elif sel == u'm': + if sel == u'm': pass - elif sel == u'e': - return importer.action.MANUAL - elif sel == u't': - assert not singleton - return importer.action.TRACKS - elif sel == u'b': - raise importer.ImportAbort() - elif sel == u'i': - return importer.action.MANUAL_ID - elif sel == u'g': - return importer.action.ALBUMS - elif sel in extra_actions: - return sel + elif sel in choice_actions: + return choice_actions[sel] else: # Numerical selection. match = candidates[sel - 1] if sel != 1: @@ -647,13 +607,6 @@ return match # Ask for confirmation. - if singleton: - opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', - u'Enter search', u'enter Id', u'aBort') - else: - opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', - u'as Tracks', u'Group albums', u'Enter search', - u'enter Id', u'aBort') default = config['import']['default_action'].as_choice({ u'apply': u'a', u'skip': u's', @@ -662,43 +615,57 @@ }) if default is None: require = True - sel = ui.input_options(opts + extra_opts, require=require, - default=default) + # Bell ring when user interaction is needed. + if config['import']['bell']: + ui.print_(u'\a', end=u'') + sel = ui.input_options((u'Apply', u'More candidates') + choice_opts, + require=require, default=default) if sel == u'a': return match - elif sel == u'g': - return importer.action.ALBUMS - elif sel == u's': - return importer.action.SKIP - elif sel == u'u': - return importer.action.ASIS - elif sel == u't': - assert not singleton - return importer.action.TRACKS - elif sel == u'e': - return importer.action.MANUAL - elif sel == u'b': - raise importer.ImportAbort() - elif sel == u'i': - return importer.action.MANUAL_ID - elif sel in extra_actions: - return sel + elif sel in choice_actions: + return choice_actions[sel] -def manual_search(singleton): - """Input either an artist and album (for full albums) or artist and +def manual_search(session, task): + """Get a new `Proposal` using manual search criteria. + + Input either an artist and album (for full albums) or artist and track name (for singletons) for manual search. """ - artist = input_(u'Artist:') - name = input_(u'Track:' if singleton else u'Album:') - return artist.strip(), name.strip() + artist = input_(u'Artist:').strip() + name = input_(u'Album:' if task.is_album else u'Track:').strip() + + if task.is_album: + _, _, prop = autotag.tag_album( + task.items, artist, name + ) + return prop + else: + return autotag.tag_item(task.item, artist, name) + + +def manual_id(session, task): + """Get a new `Proposal` using a manually-entered ID. + + Input an ID, either for an album ("release") or a track ("recording"). + """ + prompt = u'Enter {0} ID:'.format(u'release' if task.is_album + else u'recording') + search_id = input_(prompt).strip() + if task.is_album: + _, _, prop = autotag.tag_album( + task.items, search_ids=search_id.split() + ) + return prop + else: + return autotag.tag_item(task.item, search_ids=search_id.split()) -def manual_id(singleton): - """Input an ID, either for an album ("release") or a track ("recording"). + +def abort_action(session, task): + """A prompt choice callback that aborts the importer. """ - prompt = u'Enter {0} ID:'.format(u'recording' if singleton else u'release') - return input_(prompt).strip() + raise importer.ImportAbort() class TerminalImportSession(importer.ImportSession): @@ -726,40 +693,33 @@ # Loop until we have a choice. candidates, rec = task.candidates, task.rec while True: - # Gather extra choices from plugins. - extra_choices = self._get_plugin_choices(task) - extra_ops = {c.short: c.callback for c in extra_choices} - - # Ask for a choice from the user. + # Ask for a choice from the user. The result of + # `choose_candidate` may be an `importer.action`, an + # `AlbumMatch` object for a specific selection, or a + # `PromptChoice`. + choices = self._get_choices(task) choice = choose_candidate( candidates, False, rec, task.cur_artist, task.cur_album, - itemcount=len(task.items), extra_choices=extra_choices + itemcount=len(task.items), choices=choices ) - # Choose which tags to use. - if choice in (importer.action.SKIP, importer.action.ASIS, - importer.action.TRACKS, importer.action.ALBUMS): + # Basic choices that require no more action here. + if choice in (importer.action.SKIP, importer.action.ASIS): # Pass selection to main control flow. return choice - elif choice is importer.action.MANUAL: - # Try again with manual search terms. - search_artist, search_album = manual_search(False) - _, _, candidates, rec = autotag.tag_album( - task.items, search_artist, search_album - ) - elif choice is importer.action.MANUAL_ID: - # Try a manually-entered ID. - search_id = manual_id(False) - if search_id: - _, _, candidates, rec = autotag.tag_album( - task.items, search_ids=search_id.split() - ) - elif choice in list(extra_ops.keys()): - # Allow extra ops to automatically set the post-choice. - post_choice = extra_ops[choice](self, task) + + # Plugin-provided choices. We invoke the associated callback + # function. + elif choice in choices: + post_choice = choice.callback(self, task) if isinstance(post_choice, importer.action): - # MANUAL and MANUAL_ID have no effect, even if returned. return post_choice + elif isinstance(post_choice, autotag.Proposal): + # Use the new candidates and continue around the loop. + candidates = post_choice.candidates + rec = post_choice.recommendation + + # Otherwise, we have a specific match selection. else: # We have a candidate! Finish tagging. Here, choice is an # AlbumMatch object. @@ -771,7 +731,7 @@ either an action constant or a TrackMatch object. """ print_() - print_(task.item.path) + print_(displayable_path(task.item.path)) candidates, rec = task.candidates, task.rec # Take immediate action if appropriate. @@ -784,34 +744,22 @@ return action while True: - extra_choices = self._get_plugin_choices(task) - extra_ops = {c.short: c.callback for c in extra_choices} - # Ask for a choice. + choices = self._get_choices(task) choice = choose_candidate(candidates, True, rec, item=task.item, - extra_choices=extra_choices) + choices=choices) if choice in (importer.action.SKIP, importer.action.ASIS): return choice - elif choice == importer.action.TRACKS: - assert False # TRACKS is only legal for albums. - elif choice == importer.action.MANUAL: - # Continue in the loop with a new set of candidates. - search_artist, search_title = manual_search(True) - candidates, rec = autotag.tag_item(task.item, search_artist, - search_title) - elif choice == importer.action.MANUAL_ID: - # Ask for a track ID. - search_id = manual_id(True) - if search_id: - candidates, rec = autotag.tag_item( - task.item, search_ids=search_id.split()) - elif choice in extra_ops.keys(): - # Allow extra ops to automatically set the post-choice. - post_choice = extra_ops[choice](self, task) + + elif choice in choices: + post_choice = choice.callback(self, task) if isinstance(post_choice, importer.action): - # MANUAL and MANUAL_ID have no effect, even if returned. return post_choice + elif isinstance(post_choice, autotag.Proposal): + candidates = post_choice.candidates + rec = post_choice.recommendation + else: # Chose a candidate. assert isinstance(choice, autotag.TrackMatch) @@ -821,8 +769,8 @@ """Decide what to do when a new album or item seems similar to one that's already in the library. """ - log.warn(u"This {0} is already in the library!", - (u"album" if task.is_album else u"item")) + log.warning(u"This {0} is already in the library!", + (u"album" if task.is_album else u"item")) if config['import']['quiet']: # In quiet mode, don't prompt -- just skip. @@ -843,7 +791,7 @@ )) sel = ui.input_options( - (u'Skip new', u'Keep both', u'Remove old') + (u'Skip new', u'Keep both', u'Remove old', u'Merge all') ) if sel == u's': @@ -855,6 +803,8 @@ elif sel == u'r': # Remove old. task.should_remove_duplicates = True + elif sel == u'm': + task.should_merge_duplicates = True else: assert False @@ -863,8 +813,10 @@ u"was interrupted. Resume (Y/n)?" .format(displayable_path(path))) - def _get_plugin_choices(self, task): - """Get the extra choices appended to the plugins to the ui prompt. + def _get_choices(self, task): + """Get the list of prompt choices that should be presented to the + user. This consists of both built-in choices and ones provided by + plugins. The `before_choose_candidate` event is sent to the plugins, with session and task as its parameters. Plugins are responsible for @@ -877,20 +829,37 @@ Returns a list of `PromptChoice`s. """ + # Standard, built-in choices. + choices = [ + PromptChoice(u's', u'Skip', + lambda s, t: importer.action.SKIP), + PromptChoice(u'u', u'Use as-is', + lambda s, t: importer.action.ASIS) + ] + if task.is_album: + choices += [ + PromptChoice(u't', u'as Tracks', + lambda s, t: importer.action.TRACKS), + PromptChoice(u'g', u'Group albums', + lambda s, t: importer.action.ALBUMS), + ] + choices += [ + PromptChoice(u'e', u'Enter search', manual_search), + PromptChoice(u'i', u'enter Id', manual_id), + PromptChoice(u'b', u'aBort', abort_action), + ] + # Send the before_choose_candidate event and flatten list. extra_choices = list(chain(*plugins.send('before_choose_candidate', session=self, task=task))) - # Take into account default options, for duplicate checking. - all_choices = [PromptChoice(u'a', u'Apply', None), - PromptChoice(u's', u'Skip', None), - PromptChoice(u'u', u'Use as-is', None), - PromptChoice(u't', u'as Tracks', None), - PromptChoice(u'g', u'Group albums', None), - PromptChoice(u'e', u'Enter search', None), - PromptChoice(u'i', u'enter Id', None), - PromptChoice(u'b', u'aBort', None)] +\ - extra_choices + # Add a "dummy" choice for the other baked-in option, for + # duplicate checking. + all_choices = [ + PromptChoice(u'a', u'Apply', None), + ] + choices + extra_choices + + # Check for conflicts. short_letters = [c.short for c in all_choices] if len(short_letters) != len(set(short_letters)): # Duplicate short letter has been found. @@ -900,11 +869,12 @@ # Keep the first of the choices, removing the rest. dup_choices = [c for c in all_choices if c.short == short] for c in dup_choices[1:]: - log.warn(u"Prompt choice '{0}' removed due to conflict " - u"with '{1}' (short letter: '{2}')", - c.long, dup_choices[0].long, c.short) + log.warning(u"Prompt choice '{0}' removed due to conflict " + u"with '{1}' (short letter: '{2}')", + c.long, dup_choices[0].long, c.short) extra_choices.remove(c) - return extra_choices + + return choices + extra_choices # The import command. @@ -979,6 +949,10 @@ help=u"don't copy tracks (opposite of -c)" ) import_cmd.parser.add_option( + u'-m', u'--move', action='store_true', dest='move', + help=u"move tracks into the library (overrides -c)" +) +import_cmd.parser.add_option( u'-w', u'--write', action='store_true', default=None, help=u"write new metadata to files' tags (default)" ) @@ -1031,6 +1005,10 @@ help=u'do not skip already-imported directories' ) import_cmd.parser.add_option( + u'--from-scratch', dest='from_scratch', action='store_true', + help=u'erase existing metadata before applying new metadata' +) +import_cmd.parser.add_option( u'--flat', dest='flat', action='store_true', help=u'import an entire tree as a single album' ) @@ -1044,16 +1022,22 @@ ) import_cmd.parser.add_option( u'-S', u'--search-id', dest='search_ids', action='append', - metavar='BACKEND_ID', + metavar='ID', help=u'restrict matching to a specific metadata backend ID' ) +import_cmd.parser.add_option( + u'--set', dest='set_fields', action='callback', + callback=_store_dict, + metavar='FIELD=VALUE', + help=u'set the given fields to the supplied values' +) import_cmd.func = import_func default_commands.append(import_cmd) # list: Query and show library contents. -def list_items(lib, query, album, fmt=''): +def list_items(lib, query, album, fmt=u''): """Print out items in lib matching query. If album, then search for albums instead of single items. """ @@ -1079,11 +1063,18 @@ # update: Update library contents according to on-disk tags. -def update_items(lib, query, album, move, pretend): +def update_items(lib, query, album, move, pretend, fields): """For all the items matched by the query, update the library to reflect the item's embedded tags. + :param fields: The fields to be stored. If not specified, all fields will + be. """ with lib.transaction(): + if move and fields is not None and 'path' not in fields: + # Special case: if an item needs to be moved, the path field has to + # updated; otherwise the new path will not be reflected in the + # database. + fields.append('path') items, _ = _do_query(lib, query, album) # Walk through the items and pick up their changes. @@ -1122,24 +1113,25 @@ item._dirty.discard(u'albumartist') # Check for and display changes. - changed = ui.show_model_changes(item, - fields=library.Item._media_fields) + changed = ui.show_model_changes( + item, + fields=fields or library.Item._media_fields) # Save changes. if not pretend: if changed: # Move the item if it's in the library. if move and lib.directory in ancestry(item.path): - item.move() + item.move(store=False) - item.store() + item.store(fields=fields) affected_albums.add(item.album_id) else: # The file's mtime was different, but there were no # changes to the metadata. Store the new mtime, # which is set in the call to read(), so we don't # check this again in the future. - item.store() + item.store(fields=fields) # Skip album changes while pretending. if pretend: @@ -1158,17 +1150,24 @@ # Update album structure to reflect an item in it. for key in library.Album.item_keys: album[key] = first_item[key] - album.store() + album.store(fields=fields) # Move album art (and any inconsistent items). if move and lib.directory in ancestry(first_item.path): log.debug(u'moving album {0}', album_id) - album.move() + + # Manually moving and storing the album. + items = list(album.items()) + for item in items: + item.move(store=False) + item.store(fields=fields) + album.move(store=False) + album.store(fields=fields) def update_func(lib, opts, args): update_items(lib, decargs(args), opts.album, ui.should_move(opts.move), - opts.pretend) + opts.pretend, opts.fields) update_cmd = ui.Subcommand( @@ -1188,6 +1187,10 @@ u'-p', u'--pretend', action='store_true', help=u"show all changes but do nothing" ) +update_cmd.parser.add_option( + u'-F', u'--field', default=None, action='append', dest='fields', + help=u'list of fields to update' +) update_cmd.func = update_func default_commands.append(update_cmd) @@ -1210,7 +1213,7 @@ prompt = u'Really DELETE %i file%s (y/n)?' % \ (len(items), 's' if len(items) > 1 else '') else: - fmt = '' + fmt = u'' prompt = u'Really remove %i item%s from the library (y/n)?' % \ (len(items), 's' if len(items) > 1 else '') @@ -1316,6 +1319,7 @@ def show_version(lib, opts, args): print_(u'beets version %s' % beets.__version__) + print_(u'Python version {}'.format(python_version())) # Show plugins. names = sorted(p.name for p in plugins.find_plugins()) if names: @@ -1460,7 +1464,8 @@ # move: Move/copy files to the library or a new base directory. -def move_items(lib, dest, query, copy, album, pretend, confirm=False): +def move_items(lib, dest, query, copy, album, pretend, confirm=False, + export=False): """Moves or copies items to a new base directory, given by dest. If dest is None, then the library's base directory is used, making the command "consolidate" files. @@ -1473,6 +1478,7 @@ isalbummoved = lambda album: any(isitemmoved(i) for i in album.items()) objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] + copy = copy or export # Exporting always copies. action = u'Copying' if copy else u'Moving' act = u'copy' if copy else u'move' entity = u'album' if album else u'item' @@ -1498,8 +1504,16 @@ for obj in objs: log.debug(u'moving: {0}', util.displayable_path(obj.path)) - obj.move(copy, basedir=dest) - obj.store() + if export: + # Copy without affecting the database. + obj.move(operation=MoveOperation.COPY, basedir=dest, + store=False) + else: + # Ordinary move/copy: store the new path. + if copy: + obj.move(operation=MoveOperation.COPY, basedir=dest) + else: + obj.move(operation=MoveOperation.MOVE, basedir=dest) def move_func(lib, opts, args): @@ -1510,7 +1524,7 @@ raise ui.UserError(u'no such directory: %s' % dest) move_items(lib, dest, decargs(args), opts.copy, opts.album, opts.pretend, - opts.timid) + opts.timid, opts.export) move_cmd = ui.Subcommand( @@ -1532,6 +1546,10 @@ u'-t', u'--timid', dest='timid', action='store_true', help=u'always confirm all actions' ) +move_cmd.parser.add_option( + u'-e', u'--export', default=False, action='store_true', + help=u'copy without changing the database path' +) move_cmd.parser.add_album_option() move_cmd.func = move_func default_commands.append(move_cmd) @@ -1607,7 +1625,7 @@ filenames.insert(0, user_path) for filename in filenames: - print_(filename) + print_(displayable_path(filename)) # Open in editor. elif opts.edit: @@ -1615,7 +1633,8 @@ # Dump configuration. else: - print_(config.dump(full=opts.defaults, redact=opts.redact)) + config_out = config.dump(full=opts.defaults, redact=opts.redact) + print_(util.text_string(config_out)) def config_edit(): @@ -1661,10 +1680,10 @@ def print_completion(*args): for line in completion_script(default_commands + plugins.commands()): - print_(line, end='') + print_(line, end=u'') if not any(map(os.path.isfile, BASH_COMPLETION_PATHS)): - log.warn(u'Warning: Unable to find the bash-completion package. ' - u'Command line completion might not work.') + log.warning(u'Warning: Unable to find the bash-completion package. ' + u'Command line completion might not work.') BASH_COMPLETION_PATHS = map(syspath, [ u'/etc/bash_completion', @@ -1685,7 +1704,7 @@ """ base_script = os.path.join(_package_path('beets.ui'), 'completion_base.sh') with open(base_script, 'r') as base_script: - yield base_script.read() + yield util.text_string(base_script.read()) options = {} aliases = {} @@ -1700,12 +1719,12 @@ if re.match(r'^\w+$', alias): aliases[alias] = name - options[name] = {'flags': [], 'opts': []} + options[name] = {u'flags': [], u'opts': []} for opts in cmd.parser._get_all_options()[1:]: if opts.action in ('store_true', 'store_false'): - option_type = 'flags' + option_type = u'flags' else: - option_type = 'opts' + option_type = u'opts' options[name][option_type].extend( opts._short_opts + opts._long_opts @@ -1713,14 +1732,14 @@ # Add global options options['_global'] = { - 'flags': [u'-v', u'--verbose'], - 'opts': u'-l --library -c --config -d --directory -h --help'.split( - u' ') + u'flags': [u'-v', u'--verbose'], + u'opts': + u'-l --library -c --config -d --directory -h --help'.split(u' ') } # Add flags common to all commands options['_common'] = { - 'flags': [u'-h', u'--help'] + u'flags': [u'-h', u'--help'] } # Start generating the script @@ -1748,7 +1767,7 @@ for cmd, opts in options.items(): for option_type, option_list in opts.items(): if option_list: - option_list = ' '.join(option_list) + option_list = u' '.join(option_list) yield u" local %s__%s='%s'\n" % ( option_type, cmd, option_list) diff -Nru beets-1.3.19/beets/ui/__init__.py beets-1.4.6/beets/ui/__init__.py --- beets-1.3.19/beets/ui/__init__.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beets/ui/__init__.py 2017-08-11 18:33:37.000000000 +0000 @@ -20,7 +20,6 @@ from __future__ import division, absolute_import, print_function -import locale import optparse import textwrap import sys @@ -31,6 +30,7 @@ import struct import traceback import os.path +from six.moves import input from beets import logging from beets import library @@ -41,6 +41,8 @@ from beets.util import confit, as_string from beets.autotag import mb from beets.dbcore import query as db_query +from beets.dbcore import db +import six # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == 'win32': @@ -85,7 +87,7 @@ return _stream_encoding(sys.stdout) -def _stream_encoding(stream, default='utf8'): +def _stream_encoding(stream, default='utf-8'): """A helper for `_in_encoding` and `_out_encoding`: get the stream's preferred encoding, using a configured override or a default fallback if neither is not specified. @@ -106,23 +108,14 @@ return stream.encoding or default -def _arg_encoding(): - """Get the encoding for command-line arguments (and other OS - locale-sensitive strings). - """ - try: - return locale.getdefaultlocale()[1] or 'utf8' - except ValueError: - # Invalid locale environment variable setting. To avoid - # failing entirely for no good reason, assume UTF-8. - return 'utf8' - - def decargs(arglist): """Given a list of command-line argument bytestrings, attempts to - decode them to Unicode strings. + decode them to Unicode strings when running under Python 2. """ - return [s.decode(_arg_encoding()) for s in arglist] + if six.PY2: + return [s.decode(util.arg_encoding()) for s in arglist] + else: + return arglist def print_(*strings, **kwargs): @@ -130,27 +123,37 @@ is not in the terminal's encoding's character set, just silently replaces it. - If the arguments are strings then they're expected to share the same - type: either bytes or unicode. + The arguments must be Unicode strings: `unicode` on Python 2; `str` on + Python 3. The `end` keyword argument behaves similarly to the built-in `print` - (it defaults to a newline). The value should have the same string - type as the arguments. + (it defaults to a newline). """ - end = kwargs.get('end') - - if not strings or isinstance(strings[0], unicode): - txt = u' '.join(strings) - txt += u'\n' if end is None else end + if not strings: + strings = [u''] + assert isinstance(strings[0], six.text_type) + + txt = u' '.join(strings) + txt += kwargs.get('end', u'\n') + + # Encode the string and write it to stdout. + if six.PY2: + # On Python 2, sys.stdout expects bytes. + out = txt.encode(_out_encoding(), 'replace') + sys.stdout.write(out) else: - txt = b' '.join(strings) - txt += b'\n' if end is None else end - - # Always send bytes to the stdout stream. - if isinstance(txt, unicode): - txt = txt.encode(_out_encoding(), 'replace') - - sys.stdout.write(txt) + # On Python 3, sys.stdout expects text strings and uses the + # exception-throwing encoding error policy. To avoid throwing + # errors and use our configurable encoding override, we use the + # underlying bytes buffer instead. + if hasattr(sys.stdout, 'buffer'): + out = txt.encode(_out_encoding(), 'replace') + sys.stdout.buffer.write(out) + sys.stdout.buffer.flush() + else: + # In our test harnesses (e.g., DummyOut), sys.stdout.buffer + # does not exist. We instead just record the text string. + sys.stdout.write(txt) # Configuration wrappers. @@ -193,7 +196,7 @@ # Input prompts. def input_(prompt=None): - """Like `raw_input`, but decodes the result to a Unicode string. + """Like `input`, but decodes the result to a Unicode string. Raises a UserError if stdin is not available. The prompt is sent to stdout rather than stderr. A printed between the prompt and the input cursor. @@ -202,14 +205,17 @@ # use print_() explicitly to display prompts. # http://bugs.python.org/issue1927 if prompt: - print_(prompt, end=' ') + print_(prompt, end=u' ') try: - resp = raw_input() + resp = input() except EOFError: raise UserError(u'stdin stream ended while input required') - return resp.decode(_in_encoding(), 'ignore') + if six.PY2: + return resp.decode(_in_encoding(), 'ignore') + else: + return resp def input_options(options, require=False, prompt=None, fallback_prompt=None, @@ -261,7 +267,7 @@ # Mark the option's shortcut letter for display. if not require and ( (default is None and not numrange and first) or - (isinstance(default, basestring) and + (isinstance(default, six.string_types) and found_letter.lower() == default.lower())): # The first option is the default; mark it. show_letter = '[%s]' % found_letter.upper() @@ -297,11 +303,11 @@ prompt_part_lengths = [] if numrange: if isinstance(default, int): - default_name = unicode(default) + default_name = six.text_type(default) default_name = colorize('action_default', default_name) tmpl = '# selection (default %s)' prompt_parts.append(tmpl % default_name) - prompt_part_lengths.append(len(tmpl % unicode(default))) + prompt_part_lengths.append(len(tmpl % six.text_type(default))) else: prompt_parts.append('# selection') prompt_part_lengths.append(len(prompt_parts[-1])) @@ -521,7 +527,8 @@ if config['ui']['color']: global COLORS if not COLORS: - COLORS = dict((name, config['ui']['colors'][name].get(unicode)) + COLORS = dict((name, + config['ui']['colors'][name].as_str()) for name in COLOR_NAMES) # In case a 3rd party plugin is still passing the actual color ('red') # instead of the abstract color name ('text_error') @@ -541,10 +548,11 @@ highlighted intelligently to show differences; other values are stringified and highlighted in their entirety. """ - if not isinstance(a, basestring) or not isinstance(b, basestring): + if not isinstance(a, six.string_types) \ + or not isinstance(b, six.string_types): # Non-strings: use ordinary equality. - a = unicode(a) - b = unicode(b) + a = six.text_type(a) + b = six.text_type(b) if a == b: return a, b else: @@ -592,7 +600,7 @@ if config['ui']['color']: return _colordiff(a, b, highlight) else: - return unicode(a), unicode(b) + return six.text_type(a), six.text_type(b) def get_path_formats(subview=None): @@ -603,7 +611,7 @@ subview = subview or config['paths'] for query, view in subview.items(): query = PF_KEY_QUERIES.get(query, query) # Expand common queries. - path_formats.append((query, Template(view.get(unicode)))) + path_formats.append((query, Template(view.as_str()))) return path_formats @@ -671,7 +679,7 @@ # For strings, highlight changes. For others, colorize the whole # thing. - if isinstance(oldval, basestring): + if isinstance(oldval, six.string_types): oldstr, newstr = colordiff(oldval, newstr) else: oldstr = colorize('text_error', oldstr) @@ -762,6 +770,34 @@ log.info(u'{0} {1} -> {2}', source, ' ' * pad, dest) +# Helper functions for option parsing. + +def _store_dict(option, opt_str, value, parser): + """Custom action callback to parse options which have ``key=value`` + pairs as values. All such pairs passed for this option are + aggregated into a dictionary. + """ + dest = option.dest + option_values = getattr(parser.values, dest, None) + + if option_values is None: + # This is the first supplied ``key=value`` pair of option. + # Initialize empty dictionary and get a reference to it. + setattr(parser.values, dest, dict()) + option_values = getattr(parser.values, dest) + + try: + key, value = map(lambda s: util.text_string(s), value.split('=')) + if not (key and value): + raise ValueError + except ValueError: + raise UserError( + "supplied argument `{0}' is not of the form `key=value'" + .format(value)) + + option_values[key] = value + + class CommonOptionsParser(optparse.OptionParser, object): """Offers a simple way to add common formatting options. @@ -842,7 +878,7 @@ """ path = optparse.Option(*flags, nargs=0, action='callback', callback=self._set_format, - callback_kwargs={'fmt': '$path', + callback_kwargs={'fmt': u'$path', 'store_true': True}, help=u'print paths for matched items or albums') self.add_option(path) @@ -864,7 +900,7 @@ """ kwargs = {} if target: - if isinstance(target, basestring): + if isinstance(target, six.string_types): target = {'item': library.Item, 'album': library.Album}[target] kwargs['target'] = target @@ -1056,54 +1092,24 @@ optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',) -def vararg_callback(option, opt_str, value, parser): - """Callback for an option with variable arguments. - Manually collect arguments right of a callback-action - option (ie. with action="callback"), and add the resulting - list to the destination var. - - Usage: - parser.add_option("-c", "--callback", dest="vararg_attr", - action="callback", callback=vararg_callback) - - Details: - http://docs.python.org/2/library/optparse.html#callback-example-6-variable - -arguments - """ - value = [value] - - def floatable(str): - try: - float(str) - return True - except ValueError: - return False - - for arg in parser.rargs: - # stop on --foo like options - if arg[:2] == "--" and len(arg) > 2: - break - # stop on -a, but not on -3 or -3.0 - if arg[:1] == "-" and len(arg) > 1 and not floatable(arg): - break - value.append(arg) - - del parser.rargs[:len(value) - 1] - setattr(parser.values, option.dest, value) - - # The main entry point and bootstrapping. def _load_plugins(config): """Load the plugins specified in the configuration. """ - paths = config['pluginpath'].get(confit.StrSeq(split=False)) - paths = list(map(util.normpath, paths)) + paths = config['pluginpath'].as_str_seq(split=False) + paths = [util.normpath(p) for p in paths] log.debug(u'plugin paths: {0}', util.displayable_path(paths)) + # On Python 3, the search paths need to be unicode. + paths = [util.py3_path(p) for p in paths] + + # Extend the `beetsplug` package to include the plugin paths. import beetsplug beetsplug.__path__ = paths + beetsplug.__path__ - # For backwards compatibility. + + # For backwards compatibility, also support plugin paths that + # *contain* a `beetsplug` package. sys.path += paths plugins.load_plugins(config['plugins'].as_str_seq()) @@ -1145,9 +1151,11 @@ # special handling lets specified plugins get loaded before we # finish parsing the command line. if getattr(options, 'config', None) is not None: - config_path = options.config + overlay_path = options.config del options.config - config.set_file(config_path) + config.set_file(overlay_path) + else: + overlay_path = None config.set_args(options) # Configure the logger. @@ -1156,27 +1164,9 @@ else: log.set_global_level(logging.INFO) - # Ensure compatibility with old (top-level) color configuration. - # Deprecation msg to motivate user to switch to config['ui']['color]. - if config['color'].exists(): - log.warning(u'Warning: top-level configuration of `color` ' - u'is deprecated. Configure color use under `ui`. ' - u'See documentation for more info.') - config['ui']['color'].set(config['color'].get(bool)) - - # Compatibility from list_format_{item,album} to format_{item,album} - for elem in ('item', 'album'): - old_key = 'list_format_{0}'.format(elem) - if config[old_key].exists(): - new_key = 'format_{0}'.format(elem) - log.warning( - u'Warning: configuration uses "{0}" which is deprecated' - u' in favor of "{1}" now that it affects all commands. ' - u'See changelog & documentation.', - old_key, - new_key, - ) - config[new_key].set(config[old_key]) + if overlay_path: + log.debug(u'overlaying configuration: {0}', + util.displayable_path(overlay_path)) config_path = config.user_config_path() if os.path.isfile(config_path): @@ -1194,7 +1184,7 @@ def _open_library(config): """Create a new library instance from the configuration. """ - dbpath = config['library'].as_filename() + dbpath = util.bytestring_path(config['library'].as_filename()) try: lib = library.Library( dbpath, @@ -1286,9 +1276,16 @@ except IOError as exc: if exc.errno == errno.EPIPE: # "Broken pipe". End silently. - pass + sys.stderr.close() else: raise except KeyboardInterrupt: # Silently ignore ^C except in verbose mode. log.debug(u'{}', traceback.format_exc()) + except db.DBAccessError as exc: + log.error( + u'database access error: {0}\n' + u'the library file might have a permissions problem', + exc + ) + sys.exit(1) diff -Nru beets-1.3.19/beets/util/artresizer.py beets-1.4.6/beets/util/artresizer.py --- beets-1.3.19/beets/util/artresizer.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/util/artresizer.py 2017-12-19 15:42:45.000000000 +0000 @@ -18,21 +18,24 @@ """ from __future__ import division, absolute_import, print_function -import urllib import subprocess import os import re from tempfile import NamedTemporaryFile - +from six.moves.urllib.parse import urlencode from beets import logging from beets import util +import six # Resizing methods PIL = 1 IMAGEMAGICK = 2 WEBPROXY = 3 -PROXY_URL = 'http://images.weserv.nl/' +if util.SNI_SUPPORTED: + PROXY_URL = 'https://images.weserv.nl/' +else: + PROXY_URL = 'http://images.weserv.nl/' log = logging.getLogger('beets') @@ -41,9 +44,9 @@ """Return a proxied image URL that resizes the original image to maxwidth (preserving aspect ratio). """ - return '{0}?{1}'.format(PROXY_URL, urllib.urlencode({ + return '{0}?{1}'.format(PROXY_URL, urlencode({ 'url': url.replace('http://', ''), - 'w': bytes(maxwidth), + 'w': maxwidth, })) @@ -52,8 +55,8 @@ specified path. """ ext = os.path.splitext(path)[1] - with NamedTemporaryFile(suffix=ext, delete=False) as f: - return f.name + with NamedTemporaryFile(suffix=util.py3_path(ext), delete=False) as f: + return util.bytestring_path(f.name) def pil_resize(maxwidth, path_in, path_out=None): @@ -85,19 +88,18 @@ log.debug(u'artresizer: ImageMagick resizing {0} to {1}', util.displayable_path(path_in), util.displayable_path(path_out)) - # "-resize widthxheight>" shrinks images with dimension(s) larger - # than the corresponding width and/or height dimension(s). The > - # "only shrink" flag is prefixed by ^ escape char for Windows - # compatibility. + # "-resize WIDTHx>" shrinks images with the width larger + # than the given width while maintaining the aspect ratio + # with regards to the height. try: util.command_output([ - b'convert', util.syspath(path_in, prefix=False), - b'-resize', b'{0}x^>'.format(maxwidth), + 'convert', util.syspath(path_in, prefix=False), + '-resize', '{0}x>'.format(maxwidth), util.syspath(path_out, prefix=False), ]) except subprocess.CalledProcessError: - log.warn(u'artresizer: IM convert failed for {0}', - util.displayable_path(path_in)) + log.warning(u'artresizer: IM convert failed for {0}', + util.displayable_path(path_in)) return path_in return path_out @@ -119,12 +121,12 @@ def im_getsize(path_in): - cmd = [b'identify', b'-format', b'%w %h', + cmd = ['identify', '-format', '%w %h', util.syspath(path_in, prefix=False)] try: out = util.command_output(cmd) except subprocess.CalledProcessError as exc: - log.warn(u'ImageMagick size query failed') + log.warning(u'ImageMagick size query failed') log.debug( u'`convert` exited with (status {}) when ' u'getting size with command {}:\n{}', @@ -134,7 +136,7 @@ try: return tuple(map(int, out.split(b' '))) except IndexError: - log.warn(u'Could not understand IM output: {0!r}', out) + log.warning(u'Could not understand IM output: {0!r}', out) BACKEND_GET_SIZE = { @@ -160,10 +162,9 @@ return self._instance -class ArtResizer(object): +class ArtResizer(six.with_metaclass(Shareable, object)): """A singleton class that performs image resizes. """ - __metaclass__ = Shareable def __init__(self): """Create a resizer object with an inferred method. @@ -234,7 +235,7 @@ Try invoking ImageMagick's "convert". """ try: - out = util.command_output([b'convert', b'--version']) + out = util.command_output(['convert', '--version']) if b'imagemagick' in out.lower(): pattern = br".+ (\d+)\.(\d+)\.(\d+).*" diff -Nru beets-1.3.19/beets/util/bluelet.py beets-1.4.6/beets/util/bluelet.py --- beets-1.3.19/beets/util/bluelet.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/util/bluelet.py 2017-06-14 23:13:48.000000000 +0000 @@ -9,6 +9,7 @@ """ from __future__ import division, absolute_import, print_function +import six import socket import select import sys @@ -19,20 +20,6 @@ import collections -# A little bit of "six" (Python 2/3 compatibility): cope with PEP 3109 syntax -# changes. - -PY3 = sys.version_info[0] == 3 -if PY3: - def _reraise(typ, exc, tb): - raise exc.with_traceback(tb) -else: - exec(""" -def _reraise(typ, exc, tb): - raise typ, exc, tb -""") - - # Basic events used for thread scheduling. class Event(object): @@ -214,7 +201,7 @@ self.exc_info = exc_info def reraise(self): - _reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2]) + six.reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2]) SUSPENDED = Event() # Special sentinel placeholder for suspended threads. @@ -282,7 +269,7 @@ except StopIteration: # Thread is done. complete_thread(coro, None) - except: + except BaseException: # Thread raised some other exception. del threads[coro] raise ThreadException(coro, sys.exc_info()) @@ -379,7 +366,7 @@ exit_te = te break - except: + except BaseException: # For instance, KeyboardInterrupt during select(). Raise # into root thread and terminate others. threads = {root_coro: ExceptionEvent(sys.exc_info())} diff -Nru beets-1.3.19/beets/util/confit.py beets-1.4.6/beets/util/confit.py --- beets-1.3.19/beets/util/confit.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beets/util/confit.py 2017-06-20 19:15:08.000000000 +0000 @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# This file is part of Confit. +# This file is part of Confuse. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining @@ -44,9 +44,9 @@ # Utilities. PY3 = sys.version_info[0] == 3 -STRING = str if PY3 else unicode # noqa -BASESTRING = str if PY3 else basestring # noqa -NUMERIC_TYPES = (int, float) if PY3 else (int, float, long) # noqa +STRING = str if PY3 else unicode # noqa: F821 +BASESTRING = str if PY3 else basestring # noqa: F821 +NUMERIC_TYPES = (int, float) if PY3 else (int, float, long) # noqa: F821 def iter_first(sequence): @@ -245,10 +245,15 @@ def set_args(self, namespace): """Overlay parsed command-line arguments, generated by a library - like argparse or optparse, onto this view's value. + like argparse or optparse, onto this view's value. ``namespace`` + can be a ``dict`` or namespace object. """ args = {} - for key, value in namespace.__dict__.items(): + if isinstance(namespace, dict): + items = namespace.items() + else: + items = namespace.__dict__.items() + for key, value in items: if value is not None: # Avoid unset options. args[key] = value self.set(args) @@ -383,19 +388,36 @@ """ return as_template(template).value(self, template) - # Shortcuts + # Shortcuts for common templates. def as_filename(self): + """Get the value as a path. Equivalent to `get(Filename())`. + """ return self.get(Filename()) def as_choice(self, choices): + """Get the value from a list of choices. Equivalent to + `get(Choice(choices))`. + """ return self.get(Choice(choices)) def as_number(self): + """Get the value as any number type: int or float. Equivalent to + `get(Number())`. + """ return self.get(Number()) - def as_str_seq(self): - return self.get(StrSeq()) + def as_str_seq(self, split=True): + """Get the value as a sequence of strings. Equivalent to + `get(StrSeq())`. + """ + return self.get(StrSeq(split=split)) + + def as_str(self): + """Get the value as a (Unicode) string. Equivalent to + `get(unicode)` on Python 2 and `get(str)` on Python 3. + """ + return self.get(String()) # Redaction. @@ -481,11 +503,10 @@ self.name += '.' if isinstance(self.key, int): self.name += u'#{0}'.format(self.key) - elif isinstance(self.key, (bytes, BASESTRING)): - if isinstance(self.key, STRING): - self.name += self.key - else: - self.name += self.key.decode('utf8') + elif isinstance(self.key, bytes): + self.name += self.key.decode('utf-8') + elif isinstance(self.key, STRING): + self.name += self.key else: self.name += repr(self.key) @@ -647,7 +668,7 @@ parsed, a ConfigReadError is raised. """ try: - with open(filename, 'r') as f: + with open(filename, 'rb') as f: return yaml.load(f, Loader=Loader) except (IOError, yaml.error.YAMLError) as exc: raise ConfigReadError(filename, exc) @@ -887,9 +908,10 @@ default_source = source break if default_source and default_source.filename: - with open(default_source.filename, 'r') as fp: + with open(default_source.filename, 'rb') as fp: default_data = fp.read() - yaml_out = restore_yaml_comments(yaml_out, default_data) + yaml_out = restore_yaml_comments(yaml_out, + default_data.decode('utf8')) return yaml_out @@ -950,7 +972,7 @@ class Template(object): """A value template for configuration fields. - The template works like a type and instructs Confit about how to + The template works like a type and instructs Confuse about how to interpret a deserialized YAML value. This includes type conversions, providing a default value, and validating for errors. For example, a filepath type might expand tildes and check that the file exists. @@ -1222,7 +1244,7 @@ def convert(self, value, view): if isinstance(value, bytes): - value = value.decode('utf8', 'ignore') + value = value.decode('utf-8', 'ignore') if isinstance(value, STRING): if self.split: @@ -1240,7 +1262,7 @@ if isinstance(x, STRING): return x elif isinstance(x, bytes): - return x.decode('utf8', 'ignore') + return x.decode('utf-8', 'ignore') else: self.fail(u'must be a list of strings', view, True) return list(map(convert, value)) @@ -1341,7 +1363,7 @@ def value(self, view, template=None): path, source = view.first() - if not isinstance(path, (bytes, BASESTRING)): + if not isinstance(path, BASESTRING): self.fail( u'must be a filename, not {0}'.format(type(path).__name__), view, diff -Nru beets-1.3.19/beets/util/functemplate.py beets-1.4.6/beets/util/functemplate.py --- beets-1.3.19/beets/util/functemplate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/util/functemplate.py 2017-06-20 19:15:08.000000000 +0000 @@ -33,8 +33,8 @@ import ast import dis import types - -from .confit import NUMERIC_TYPES +import sys +import six SYMBOL_DELIM = u'$' FUNC_DELIM = u'%' @@ -74,11 +74,11 @@ """ if val is None: return ast.Name('None', ast.Load()) - elif isinstance(val, NUMERIC_TYPES): + elif isinstance(val, six.integer_types): return ast.Num(val) elif isinstance(val, bool): return ast.Name(bytes(val), ast.Load()) - elif isinstance(val, basestring): + elif isinstance(val, six.string_types): return ast.Str(val) raise TypeError(u'no literal for {0}'.format(type(val))) @@ -97,7 +97,7 @@ function may be an expression or the name of a function. Each argument may be an expression or a value to be used as a literal. """ - if isinstance(func, basestring): + if isinstance(func, six.string_types): func = ex_rvalue(func) args = list(args) @@ -105,7 +105,10 @@ if not isinstance(args[i], ast.expr): args[i] = ex_literal(args[i]) - return ast.Call(func, args, [], None, None) + if sys.version_info[:2] < (3, 5): + return ast.Call(func, args, [], None, None) + else: + return ast.Call(func, args, []) def compile_func(arg_names, statements, name='_the_func', debug=False): @@ -113,16 +116,31 @@ the resulting Python function. If `debug`, then print out the bytecode of the compiled function. """ - func_def = ast.FunctionDef( - name.encode('utf8'), - ast.arguments( - [ast.Name(n, ast.Param()) for n in arg_names], - None, None, - [ex_literal(None) for _ in arg_names], - ), - statements, - [], - ) + if six.PY2: + func_def = ast.FunctionDef( + name=name.encode('utf-8'), + args=ast.arguments( + args=[ast.Name(n, ast.Param()) for n in arg_names], + vararg=None, + kwarg=None, + defaults=[ex_literal(None) for _ in arg_names], + ), + body=statements, + decorator_list=[], + ) + else: + func_def = ast.FunctionDef( + name=name, + args=ast.arguments( + args=[ast.arg(arg=n, annotation=None) for n in arg_names], + kwonlyargs=[], + kw_defaults=[], + defaults=[ex_literal(None) for _ in arg_names], + ), + body=statements, + decorator_list=[], + ) + mod = ast.Module([func_def]) ast.fix_missing_locations(mod) @@ -164,8 +182,12 @@ def translate(self): """Compile the variable lookup.""" - expr = ex_rvalue(VARIABLE_PREFIX + self.ident.encode('utf8')) - return [expr], set([self.ident.encode('utf8')]), set() + if six.PY2: + ident = self.ident.encode('utf-8') + else: + ident = self.ident + expr = ex_rvalue(VARIABLE_PREFIX + ident) + return [expr], set([ident]), set() class Call(object): @@ -190,15 +212,19 @@ except Exception as exc: # Function raised exception! Maybe inlining the name of # the exception will help debug. - return u'<%s>' % unicode(exc) - return unicode(out) + return u'<%s>' % six.text_type(exc) + return six.text_type(out) else: return self.original def translate(self): """Compile the function call.""" varnames = set() - funcnames = set([self.ident.encode('utf8')]) + if six.PY2: + ident = self.ident.encode('utf-8') + else: + ident = self.ident + funcnames = set([ident]) arg_exprs = [] for arg in self.args: @@ -213,14 +239,14 @@ [ex_call( 'map', [ - ex_rvalue('unicode'), + ex_rvalue(six.text_type.__name__), ast.List(subexprs, ast.Load()), ] )], )) subexpr_call = ex_call( - FUNCTION_PREFIX + self.ident.encode('utf8'), + FUNCTION_PREFIX + ident, arg_exprs ) return [subexpr_call], varnames, funcnames @@ -242,11 +268,11 @@ """ out = [] for part in self.parts: - if isinstance(part, basestring): + if isinstance(part, six.string_types): out.append(part) else: out.append(part.evaluate(env)) - return u''.join(map(unicode, out)) + return u''.join(map(six.text_type, out)) def translate(self): """Compile the expression to a list of Python AST expressions, a @@ -256,7 +282,7 @@ varnames = set() funcnames = set() for part in self.parts: - if isinstance(part, basestring): + if isinstance(part, six.string_types): expressions.append(ex_literal(part)) else: e, v, f = part.translate() @@ -285,16 +311,24 @@ replaced with a real, accepted parsing technique (PEG, parser generator, etc.). """ - def __init__(self, string): + def __init__(self, string, in_argument=False): + """ Create a new parser. + :param in_arguments: boolean that indicates the parser is to be + used for parsing function arguments, ie. considering commas + (`ARG_SEP`) a special character + """ self.string = string + self.in_argument = in_argument self.pos = 0 self.parts = [] # Common parsing resources. special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE, - ARG_SEP, ESCAPE_CHAR) - special_char_re = re.compile(r'[%s]|$' % + ESCAPE_CHAR) + special_char_re = re.compile(r'[%s]|\Z' % u''.join(re.escape(c) for c in special_chars)) + escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP) + terminator_chars = (GROUP_CLOSE,) def parse_expression(self): """Parse a template expression starting at ``pos``. Resulting @@ -302,16 +336,29 @@ the ``parts`` field, a list. The ``pos`` field is updated to be the next character after the expression. """ + # Append comma (ARG_SEP) to the list of special characters only when + # parsing function arguments. + extra_special_chars = () + special_char_re = self.special_char_re + if self.in_argument: + extra_special_chars = (ARG_SEP,) + special_char_re = re.compile( + r'[%s]|\Z' % u''.join( + re.escape(c) for c in + self.special_chars + extra_special_chars + ) + ) + text_parts = [] while self.pos < len(self.string): char = self.string[self.pos] - if char not in self.special_chars: + if char not in self.special_chars + extra_special_chars: # A non-special character. Skip to the next special # character, treating the interstice as literal text. next_pos = ( - self.special_char_re.search( + special_char_re.search( self.string[self.pos:]).start() + self.pos ) text_parts.append(self.string[self.pos:next_pos]) @@ -322,14 +369,14 @@ # The last character can never begin a structure, so we # just interpret it as a literal character (unless it # terminates the expression, as with , and }). - if char not in (GROUP_CLOSE, ARG_SEP): + if char not in self.terminator_chars + extra_special_chars: text_parts.append(char) self.pos += 1 break next_char = self.string[self.pos + 1] - if char == ESCAPE_CHAR and next_char in \ - (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP): + if char == ESCAPE_CHAR and next_char in (self.escapable_chars + + extra_special_chars): # An escaped special character ($$, $}, etc.). Note that # ${ is not an escape sequence: this is ambiguous with # the start of a symbol and it's not necessary (just @@ -349,7 +396,7 @@ elif char == FUNC_DELIM: # Parse a function call. self.parse_call() - elif char in (GROUP_CLOSE, ARG_SEP): + elif char in self.terminator_chars + extra_special_chars: # Template terminated. break elif char == GROUP_OPEN: @@ -457,7 +504,7 @@ expressions = [] while self.pos < len(self.string): - subparser = Parser(self.string[self.pos:]) + subparser = Parser(self.string[self.pos:], in_argument=True) subparser.parse_expression() # Extract and advance past the parsed expression. @@ -526,8 +573,9 @@ """ try: res = self.compiled(values, functions) - except: # Handle any exceptions thrown by compiled version. + except Exception: # Handle any exceptions thrown by compiled version. res = self.interpret(values, functions) + return res def translate(self): @@ -563,7 +611,7 @@ import timeit _tmpl = Template(u'foo $bar %baz{foozle $bar barzle} $bar') _vars = {'bar': 'qux'} - _funcs = {'baz': unicode.upper} + _funcs = {'baz': six.text_type.upper} interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)', 'from __main__ import _tmpl, _vars, _funcs', number=10000) diff -Nru beets-1.3.19/beets/util/hidden.py beets-1.4.6/beets/util/hidden.py --- beets-1.3.19/beets/util/hidden.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/util/hidden.py 2016-12-17 03:01:22.000000000 +0000 @@ -20,6 +20,7 @@ import stat import ctypes import sys +import beets.util def _is_hidden_osx(path): @@ -27,7 +28,7 @@ This uses os.lstat to work out if a file has the "hidden" flag. """ - file_stat = os.lstat(path) + file_stat = os.lstat(beets.util.syspath(path)) if hasattr(file_stat, 'st_flags') and hasattr(stat, 'UF_HIDDEN'): return bool(file_stat.st_flags & stat.UF_HIDDEN) @@ -45,7 +46,7 @@ hidden_mask = 2 # Retrieve the attributes for the file. - attrs = ctypes.windll.kernel32.GetFileAttributesW(path) + attrs = ctypes.windll.kernel32.GetFileAttributesW(beets.util.syspath(path)) # Ensure we have valid attribues and compare them against the mask. return attrs >= 0 and attrs & hidden_mask @@ -56,11 +57,12 @@ Files starting with a dot are seen as "hidden" files on Unix-based OSes. """ - return os.path.basename(path).startswith('.') + return os.path.basename(path).startswith(b'.') def is_hidden(path): - """Return whether or not a file is hidden. + """Return whether or not a file is hidden. `path` should be a + bytestring filename. This method works differently depending on the platform it is called on. @@ -73,10 +75,6 @@ On any other operating systems (i.e. Linux), it uses `is_hidden_dot` to work out if a file is hidden. """ - # Convert the path to unicode if it is not already. - if not isinstance(path, unicode): - path = path.decode('utf-8') - # Run platform specific functions depending on the platform if sys.platform == 'darwin': return _is_hidden_osx(path) or _is_hidden_dot(path) diff -Nru beets-1.3.19/beets/util/__init__.py beets-1.4.6/beets/util/__init__.py --- beets-1.3.19/beets/util/__init__.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/util/__init__.py 2017-10-29 20:27:34.000000000 +0000 @@ -18,6 +18,8 @@ from __future__ import division, absolute_import, print_function import os import sys +import errno +import locale import re import shutil import fnmatch @@ -27,10 +29,14 @@ import platform import shlex from beets.util import hidden +import six +from unidecode import unidecode +from enum import Enum MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = u'\\\\?\\' +SNI_SUPPORTED = sys.version_info >= (2, 7, 9) class HumanReadableException(Exception): @@ -65,14 +71,14 @@ def _reasonstr(self): """Get the reason as a string.""" - if isinstance(self.reason, unicode): + if isinstance(self.reason, six.text_type): return self.reason elif isinstance(self.reason, bytes): - return self.reason.decode('utf8', 'ignore') + return self.reason.decode('utf-8', 'ignore') elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError return self.reason.strerror else: - return u'"{0}"'.format(unicode(self.reason)) + return u'"{0}"'.format(six.text_type(self.reason)) def get_message(self): """Create the human-readable description of the error, sans @@ -119,6 +125,15 @@ return u'{0} {1}'.format(self._reasonstr(), clause) +class MoveOperation(Enum): + """The file operations that e.g. various move functions can carry out. + """ + MOVE = 0 + COPY = 1 + LINK = 2 + HARDLINK = 3 + + def normpath(path): """Provide the canonical form of the path suitable for storing in the database. @@ -167,7 +182,7 @@ contents = os.listdir(syspath(path)) except OSError as exc: if logger: - logger.warn(u'could not list directory {0}: {1}'.format( + logger.warning(u'could not list directory {0}: {1}'.format( displayable_path(path), exc.strerror )) return @@ -266,7 +281,8 @@ # Directory gone already. continue clutter = [bytestring_path(c) for c in clutter] - if fnmatch_all(os.listdir(directory), clutter): + match_paths = [bytestring_path(d) for d in os.listdir(directory)] + if fnmatch_all(match_paths, clutter): # Directory contains only clutter (or nothing). try: shutil.rmtree(directory) @@ -300,6 +316,18 @@ return comps +def arg_encoding(): + """Get the encoding for command-line arguments (and other OS + locale-sensitive strings). + """ + try: + return locale.getdefaultlocale()[1] or 'utf-8' + except ValueError: + # Invalid locale environment variable setting. To avoid + # failing entirely for no good reason, assume UTF-8. + return 'utf-8' + + def _fsencoding(): """Get the system's filesystem encoding. On Windows, this is always UTF-8 (not MBCS). @@ -311,7 +339,7 @@ # for Windows paths, so the encoding is actually immaterial so # we can avoid dealing with this nastiness. We arbitrarily # choose UTF-8. - encoding = 'utf8' + encoding = 'utf-8' return encoding @@ -329,11 +357,11 @@ if os.path.__name__ == 'ntpath' and path.startswith(WINDOWS_MAGIC_PREFIX): path = path[len(WINDOWS_MAGIC_PREFIX):] - # Try to encode with default encodings, but fall back to UTF8. + # Try to encode with default encodings, but fall back to utf-8. try: return path.encode(_fsencoding()) except (UnicodeError, LookupError): - return path.encode('utf8') + return path.encode('utf-8') PATH_SEP = bytestring_path(os.sep) @@ -346,16 +374,16 @@ """ if isinstance(path, (list, tuple)): return separator.join(displayable_path(p) for p in path) - elif isinstance(path, unicode): + elif isinstance(path, six.text_type): return path elif not isinstance(path, bytes): # A non-string object: just get its unicode representation. - return unicode(path) + return six.text_type(path) try: return path.decode(_fsencoding(), 'ignore') except (UnicodeError, LookupError): - return path.decode('utf8', 'ignore') + return path.decode('utf-8', 'ignore') def syspath(path, prefix=True): @@ -369,12 +397,12 @@ if os.path.__name__ != 'ntpath': return path - if not isinstance(path, unicode): + if not isinstance(path, six.text_type): # Beets currently represents Windows paths internally with UTF-8 # arbitrarily. But earlier versions used MBCS because it is # reported as the FS encoding by Windows. Try both. try: - path = path.decode('utf8') + path = path.decode('utf-8') except UnicodeError: # The encoding should always be MBCS, Windows' broken # Unicode representation. @@ -442,8 +470,7 @@ path = syspath(path) dest = syspath(dest) if os.path.exists(dest) and not replace: - raise FilesystemError(u'file exists', 'rename', (path, dest), - traceback.format_exc()) + raise FilesystemError(u'file exists', 'rename', (path, dest)) # First, try renaming the file. try: @@ -461,23 +488,52 @@ def link(path, dest, replace=False): """Create a symbolic link from path to `dest`. Raises an OSError if `dest` already exists, unless `replace` is True. Does nothing if - `path` == `dest`.""" - if (samefile(path, dest)): + `path` == `dest`. + """ + if samefile(path, dest): return - path = syspath(path) - dest = syspath(dest) - if os.path.exists(dest) and not replace: - raise FilesystemError(u'file exists', 'rename', (path, dest), - traceback.format_exc()) + if os.path.exists(syspath(dest)) and not replace: + raise FilesystemError(u'file exists', 'rename', (path, dest)) try: - os.symlink(path, dest) - except OSError: - raise FilesystemError(u'Operating system does not support symbolic ' - u'links.', 'link', (path, dest), + os.symlink(syspath(path), syspath(dest)) + except NotImplementedError: + # raised on python >= 3.2 and Windows versions before Vista + raise FilesystemError(u'OS does not support symbolic links.' + 'link', (path, dest), traceback.format_exc()) + except OSError as exc: + # TODO: Windows version checks can be removed for python 3 + if hasattr('sys', 'getwindowsversion'): + if sys.getwindowsversion()[0] < 6: # is before Vista + exc = u'OS does not support symbolic links.' + raise FilesystemError(exc, 'link', (path, dest), traceback.format_exc()) +def hardlink(path, dest, replace=False): + """Create a hard link from path to `dest`. Raises an OSError if + `dest` already exists, unless `replace` is True. Does nothing if + `path` == `dest`. + """ + if samefile(path, dest): + return + + if os.path.exists(syspath(dest)) and not replace: + raise FilesystemError(u'file exists', 'rename', (path, dest)) + try: + os.link(syspath(path), syspath(dest)) + except NotImplementedError: + raise FilesystemError(u'OS does not support hard links.' + 'link', (path, dest), traceback.format_exc()) + except OSError as exc: + if exc.errno == errno.EXDEV: + raise FilesystemError(u'Cannot hard link across devices.' + 'link', (path, dest), traceback.format_exc()) + else: + raise FilesystemError(exc, 'link', (path, dest), + traceback.format_exc()) + + def unique_path(path): """Returns a version of ``path`` that does not exist on the filesystem. Specifically, if ``path` itself already exists, then @@ -495,7 +551,8 @@ num = 0 while True: num += 1 - new_path = b'%s.%i%s' % (base, num, ext) + suffix = u'.{}'.format(num).encode() + ext + new_path = base + suffix if not os.path.exists(new_path): return new_path @@ -599,7 +656,7 @@ if fragment: # Outputting Unicode. - extension = extension.decode('utf8', 'ignore') + extension = extension.decode('utf-8', 'ignore') first_stage_path, _ = _legalize_stage( path, replacements, length, extension, fragment @@ -623,6 +680,24 @@ return second_stage_path, retruncated +def py3_path(path): + """Convert a bytestring path to Unicode on Python 3 only. On Python + 2, return the bytestring path unchanged. + + This helps deal with APIs on Python 3 that *only* accept Unicode + (i.e., `str` objects). I philosophically disagree with this + decision, because paths are sadly bytes on Unix, but that's the way + it is. So this function helps us "smuggle" the true bytes data + through APIs that took Python 3's Unicode mandate too seriously. + """ + if isinstance(path, six.text_type): + return path + assert isinstance(path, bytes) + if six.PY2: + return path + return os.fsdecode(path) + + def str2bool(value): """Returns a boolean reflecting a human-entered string.""" return value.lower() in (u'yes', u'1', u'true', u't', u'y') @@ -632,14 +707,32 @@ """Convert a value to a Unicode object for matching with a query. None becomes the empty string. Bytestrings are silently decoded. """ + if six.PY2: + buffer_types = buffer, memoryview # noqa: F821 + else: + buffer_types = memoryview + if value is None: return u'' - elif isinstance(value, buffer): - return bytes(value).decode('utf8', 'ignore') + elif isinstance(value, buffer_types): + return bytes(value).decode('utf-8', 'ignore') elif isinstance(value, bytes): - return value.decode('utf8', 'ignore') + return value.decode('utf-8', 'ignore') else: - return unicode(value) + return six.text_type(value) + + +def text_string(value, encoding='utf-8'): + """Convert a string, which can either be bytes or unicode, to + unicode. + + Text (unicode) is left untouched; bytes are decoded. This is useful + to convert from a "native string" (bytes on Python 2, str on Python + 3) to a consistently unicode value. + """ + if isinstance(value, bytes): + return value.decode(encoding) + return value def plurality(objs): @@ -666,7 +759,7 @@ num = 0 elif sys.platform == 'darwin': try: - num = int(command_output([b'/usr/sbin/sysctl', b'-n', b'hw.ncpu'])) + num = int(command_output(['/usr/sbin/sysctl', '-n', 'hw.ncpu'])) except (ValueError, OSError, subprocess.CalledProcessError): num = 0 else: @@ -680,10 +773,28 @@ return 1 +def convert_command_args(args): + """Convert command arguments to bytestrings on Python 2 and + surrogate-escaped strings on Python 3.""" + assert isinstance(args, list) + + def convert(arg): + if six.PY2: + if isinstance(arg, six.text_type): + arg = arg.encode(arg_encoding()) + else: + if isinstance(arg, bytes): + arg = arg.decode(arg_encoding(), 'surrogateescape') + return arg + + return [convert(a) for a in args] + + def command_output(cmd, shell=False): """Runs the command and returns its output after it has exited. - ``cmd`` is a list of byte string arguments starting with the command names. + ``cmd`` is a list of arguments starting with the command names. The + arguments are bytes on Unix and strings on Windows. If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a shell to execute. @@ -694,10 +805,18 @@ This replaces `subprocess.check_output` which can have problems if lots of output is sent to stderr. """ + cmd = convert_command_args(cmd) + + try: # python >= 3.3 + devnull = subprocess.DEVNULL + except AttributeError: + devnull = open(os.devnull, 'r+b') + proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + stdin=devnull, close_fds=platform.system() != 'Windows', shell=shell ) @@ -705,7 +824,7 @@ if proc.returncode: raise subprocess.CalledProcessError( returncode=proc.returncode, - cmd=b' '.join(cmd), + cmd=' '.join(cmd), output=stdout + stderr, ) return stdout @@ -761,15 +880,14 @@ Raise `ValueError` if the string is not a well-formed shell string. This is a workaround for a bug in some versions of Python. """ - if isinstance(s, bytes): - # Shlex works fine. + if not six.PY2 or isinstance(s, bytes): # Shlex works fine. return shlex.split(s) - elif isinstance(s, unicode): + elif isinstance(s, six.text_type): # Work around a Python bug. # http://bugs.python.org/issue6988 - bs = s.encode('utf8') - return [c.decode('utf8') for c in shlex.split(bs)] + bs = s.encode('utf-8') + return [c.decode('utf-8') for c in shlex.split(bs)] else: raise TypeError(u'shlex_split called with non-string') @@ -801,7 +919,7 @@ """Use Windows' `GetLongPathNameW` via ctypes to get the canonical, long path given a short filename. """ - if not isinstance(short_path, unicode): + if not isinstance(short_path, six.text_type): short_path = short_path.decode(_fsencoding()) import ctypes @@ -865,3 +983,27 @@ raise ValueError(u'String not in M:SS format') minutes, seconds = map(int, match.groups()) return float(minutes * 60 + seconds) + + +def asciify_path(path, sep_replace): + """Decodes all unicode characters in a path into ASCII equivalents. + + Substitutions are provided by the unidecode module. Path separators in the + input are preserved. + + Keyword arguments: + path -- The path to be asciified. + sep_replace -- the string to be used to replace extraneous path separators. + """ + # if this platform has an os.altsep, change it to os.sep. + if os.altsep: + path = path.replace(os.altsep, os.sep) + path_components = path.split(os.sep) + for index, item in enumerate(path_components): + path_components[index] = unidecode(item).replace(os.sep, sep_replace) + if os.altsep: + path_components[index] = unidecode(item).replace( + os.altsep, + sep_replace + ) + return os.sep.join(path_components) diff -Nru beets-1.3.19/beets/util/pipeline.py beets-1.4.6/beets/util/pipeline.py --- beets-1.3.19/beets/util/pipeline.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beets/util/pipeline.py 2017-06-14 23:13:48.000000000 +0000 @@ -34,9 +34,10 @@ from __future__ import division, absolute_import, print_function -import Queue +from six.moves import queue from threading import Thread, Lock import sys +import six BUBBLE = '__PIPELINE_BUBBLE__' POISON = '__PIPELINE_POISON__' @@ -63,7 +64,17 @@ q.mutex.acquire() try: - q.maxsize = 0 + # Originally, we set `maxsize` to 0 here, which is supposed to mean + # an unlimited queue size. However, there is a race condition since + # Python 3.2 when this attribute is changed while another thread is + # waiting in put()/get() due to a full/empty queue. + # Setting it to 2 is still hacky because Python does not give any + # guarantee what happens if Queue methods/attributes are overwritten + # when it is already in use. However, because of our dummy _put() + # and _get() methods, it provides a workaround to let the queue appear + # to be never empty or full. + # See issue https://github.com/beetbox/beets/issues/2078 + q.maxsize = 2 q._qsize = _qsize q._put = _put q._get = _get @@ -75,13 +86,13 @@ q.mutex.release() -class CountedQueue(Queue.Queue): +class CountedQueue(queue.Queue): """A queue that keeps track of the number of threads that are still feeding into it. The queue is poisoned when all threads are finished with the queue. """ def __init__(self, maxsize=0): - Queue.Queue.__init__(self, maxsize) + queue.Queue.__init__(self, maxsize) self.nthreads = 0 self.poisoned = False @@ -259,7 +270,7 @@ return self.out_queue.put(msg) - except: + except BaseException: self.abort_all(sys.exc_info()) return @@ -307,7 +318,7 @@ return self.out_queue.put(msg) - except: + except BaseException: self.abort_all(sys.exc_info()) return @@ -346,7 +357,7 @@ # Send to consumer. self.coro.send(msg) - except: + except BaseException: self.abort_all(sys.exc_info()) return @@ -411,10 +422,10 @@ try: # Using a timeout allows us to receive KeyboardInterrupt # exceptions during the join(). - while threads[-1].isAlive(): + while threads[-1].is_alive(): threads[-1].join(1) - except: + except BaseException: # Stop all the threads immediately. for thread in threads: thread.abort() @@ -431,7 +442,7 @@ exc_info = thread.exc_info if exc_info: # Make the exception appear as it was raised originally. - raise exc_info[0], exc_info[1], exc_info[2] + six.reraise(exc_info[0], exc_info[1], exc_info[2]) def pull(self): """Yield elements from the end of the pipeline. Runs the stages diff -Nru beets-1.3.19/beets.egg-info/pbr.json beets-1.4.6/beets.egg-info/pbr.json --- beets-1.3.19/beets.egg-info/pbr.json 2016-06-26 00:52:50.000000000 +0000 +++ beets-1.4.6/beets.egg-info/pbr.json 2017-06-20 20:32:24.000000000 +0000 @@ -1 +1 @@ -{"is_release": false, "git_version": "9d66e2c"} \ No newline at end of file +{"is_release": false, "git_version": "336fdba9"} \ No newline at end of file diff -Nru beets-1.3.19/beets.egg-info/PKG-INFO beets-1.4.6/beets.egg-info/PKG-INFO --- beets-1.3.19/beets.egg-info/PKG-INFO 2016-06-26 00:52:50.000000000 +0000 +++ beets-1.4.6/beets.egg-info/PKG-INFO 2017-12-21 18:12:27.000000000 +0000 @@ -1,17 +1,15 @@ Metadata-Version: 1.1 Name: beets -Version: 1.3.19 +Version: 1.4.6 Summary: music tagger and library organizer Home-page: http://beets.io/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT +Description-Content-Type: UNKNOWN Description: .. image:: http://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets - .. image:: https://img.shields.io/pypi/dw/beets.svg - :target: https://pypi.python.org/pypi/beets#downloads - .. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets @@ -111,5 +109,11 @@ Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console Classifier: Environment :: Web Environment +Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: Implementation :: CPython diff -Nru beets-1.3.19/beets.egg-info/requires.txt beets-1.4.6/beets.egg-info/requires.txt --- beets-1.3.19/beets.egg-info/requires.txt 2016-06-26 00:52:50.000000000 +0000 +++ beets-1.4.6/beets.egg-info/requires.txt 2017-12-21 18:12:27.000000000 +0000 @@ -1,10 +1,14 @@ -enum34>=1.0.4 -mutagen>=1.27 +six>=1.9 +mutagen>=1.33 munkres unidecode musicbrainzngs>=0.4 pyyaml jellyfish +enum34>=1.0.4 + +[absubmit] +requests [beatport] requests-oauthlib>=0.6.1 @@ -13,7 +17,7 @@ pyacoustid [discogs] -discogs-client>=2.1.0 +discogs-client>=2.2.1 [fetchart] requests @@ -28,11 +32,11 @@ dbus-python [mpdstats] -python-mpd2 +python-mpd2>=0.4.2 [thumbnails] -pathlib pyxdg +pathlib [web] flask diff -Nru beets-1.3.19/beets.egg-info/SOURCES.txt beets-1.4.6/beets.egg-info/SOURCES.txt --- beets-1.3.19/beets.egg-info/SOURCES.txt 2016-06-26 00:52:53.000000000 +0000 +++ beets-1.4.6/beets.egg-info/SOURCES.txt 2017-12-21 18:12:27.000000000 +0000 @@ -4,6 +4,7 @@ setup.cfg setup.py beets/__init__.py +beets/__main__.py beets/art.py beets/config_default.yaml beets/importer.py @@ -40,6 +41,7 @@ beets/util/hidden.py beets/util/pipeline.py beetsplug/__init__.py +beetsplug/absubmit.py beetsplug/acousticbrainz.py beetsplug/badfiles.py beetsplug/beatport.py @@ -61,6 +63,7 @@ beetsplug/fromfilename.py beetsplug/ftintitle.py beetsplug/fuzzy.py +beetsplug/gmusic.py beetsplug/hook.py beetsplug/ihate.py beetsplug/importadded.py @@ -69,6 +72,7 @@ beetsplug/inline.py beetsplug/ipfs.py beetsplug/keyfinder.py +beetsplug/kodiupdate.py beetsplug/lastimport.py beetsplug/lyrics.py beetsplug/mbcollection.py @@ -110,7 +114,6 @@ docs/conf.py docs/faq.rst docs/index.rst -docs/serve.py docs/dev/api.rst docs/dev/index.rst docs/dev/media_file.rst @@ -119,6 +122,7 @@ docs/guides/index.rst docs/guides/main.rst docs/guides/tagger.rst +docs/plugins/absubmit.rst docs/plugins/acousticbrainz.rst docs/plugins/badfiles.rst docs/plugins/beatport.rst @@ -140,6 +144,7 @@ docs/plugins/fromfilename.rst docs/plugins/ftintitle.rst docs/plugins/fuzzy.rst +docs/plugins/gmusic.rst docs/plugins/hook.rst docs/plugins/ihate.rst docs/plugins/importadded.rst @@ -149,6 +154,7 @@ docs/plugins/inline.rst docs/plugins/ipfs.rst docs/plugins/keyfinder.rst +docs/plugins/kodiupdate.rst docs/plugins/lastgenre.rst docs/plugins/lastimport.rst docs/plugins/lyrics.rst @@ -178,12 +184,17 @@ docs/reference/index.rst docs/reference/pathformat.rst docs/reference/query.rst +extra/_beet +extra/ascii_logo.txt +extra/beets.reg +extra/release.py man/beet.1 man/beetsconfig.5 test/__init__.py test/_common.py test/helper.py test/lyrics_download_samples.py +test/test_acousticbrainz.py test/test_art.py test/test_autotag.py test/test_bucket.py @@ -191,6 +202,7 @@ test/test_convert.py test/test_datequery.py test/test_dbcore.py +test/test_discogs.py test/test_edit.py test/test_embedart.py test/test_embyupdate.py @@ -223,6 +235,7 @@ test/test_play.py test/test_player.py test/test_plexupdate.py +test/test_plugin_mediafield.py test/test_plugins.py test/test_query.py test/test_replaygain.py @@ -255,6 +268,7 @@ test/rsrc/empty.aiff test/rsrc/empty.alac.m4a test/rsrc/empty.ape +test/rsrc/empty.dsf test/rsrc/empty.flac test/rsrc/empty.m4a test/rsrc/empty.mp3 @@ -267,6 +281,7 @@ test/rsrc/full.aiff test/rsrc/full.alac.m4a test/rsrc/full.ape +test/rsrc/full.dsf test/rsrc/full.flac test/rsrc/full.m4a test/rsrc/full.mp3 @@ -278,6 +293,7 @@ test/rsrc/image-2x3.jpg test/rsrc/image-2x3.png test/rsrc/image-2x3.tiff +test/rsrc/image-jpeg.mp3 test/rsrc/image.ape test/rsrc/image.flac test/rsrc/image.m4a @@ -305,6 +321,7 @@ test/rsrc/unparseable.aiff test/rsrc/unparseable.alac.m4a test/rsrc/unparseable.ape +test/rsrc/unparseable.dsf test/rsrc/unparseable.flac test/rsrc/unparseable.m4a test/rsrc/unparseable.mp3 @@ -314,7 +331,9 @@ test/rsrc/unparseable.wma test/rsrc/unparseable.wv test/rsrc/year.ogg +test/rsrc/acousticbrainz/data.json test/rsrc/beetsplug/test.py +test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt test/rsrc/lyrics/examplecom/beetssong.txt test/rsrc/spotify/missing_request.json test/rsrc/spotify/track_request.json \ No newline at end of file diff -Nru beets-1.3.19/beetsplug/absubmit.py beets-1.4.6/beetsplug/absubmit.py --- beets-1.3.19/beetsplug/absubmit.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/beetsplug/absubmit.py 2017-08-27 14:19:13.000000000 +0000 @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, Pieter Mulder. +# +# 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. + +"""Calculate acoustic information and submit to AcousticBrainz. +""" + +from __future__ import division, absolute_import, print_function + +import errno +import hashlib +import json +import os +import subprocess +import tempfile + +from distutils.spawn import find_executable +import requests + +from beets import plugins +from beets import util +from beets import ui + + +class ABSubmitError(Exception): + """Raised when failing to analyse file with extractor.""" + + +def call(args): + """Execute the command and return its output. + + Raise a AnalysisABSubmitError on failure. + """ + try: + return util.command_output(args) + except subprocess.CalledProcessError as e: + raise ABSubmitError( + u'{0} exited with status {1}'.format(args[0], e.returncode) + ) + + +class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): + + def __init__(self): + super(AcousticBrainzSubmitPlugin, self).__init__() + + self.config.add({'extractor': u''}) + + self.extractor = self.config['extractor'].as_str() + if self.extractor: + self.extractor = util.normpath(self.extractor) + # Expicit path to extractor + if not os.path.isfile(self.extractor): + raise ui.UserError( + u'Extractor command does not exist: {0}.'. + format(self.extractor) + ) + else: + # Implicit path to extractor, search for it in path + self.extractor = 'streaming_extractor_music' + try: + call([self.extractor]) + except OSError: + raise ui.UserError( + u'No extractor command found: please install the ' + u'extractor binary from http://acousticbrainz.org/download' + ) + except ABSubmitError: + # Extractor found, will exit with an error if not called with + # the correct amount of arguments. + pass + + # Get the executable location on the system, which we need + # to calculate the SHA-1 hash. + self.extractor = find_executable(self.extractor) + + # Calculate extractor hash. + self.extractor_sha = hashlib.sha1() + with open(self.extractor, 'rb') as extractor: + self.extractor_sha.update(extractor.read()) + self.extractor_sha = self.extractor_sha.hexdigest() + + base_url = 'https://acousticbrainz.org/api/v1/{mbid}/low-level' + + def commands(self): + cmd = ui.Subcommand( + 'absubmit', + help=u'calculate and submit AcousticBrainz analysis' + ) + cmd.func = self.command + return [cmd] + + def command(self, lib, opts, args): + # Get items from arguments + items = lib.items(ui.decargs(args)) + for item in items: + analysis = self._get_analysis(item) + if analysis: + self._submit_data(item, analysis) + + def _get_analysis(self, item): + mbid = item['mb_trackid'] + # If file has no mbid skip it. + if not mbid: + self._log.info(u'Not analysing {}, missing ' + u'musicbrainz track id.', item) + return None + + # Temporary file to save extractor output to, extractor only works + # if an output file is given. Here we use a temporary file to copy + # the data into a python object and then remove the file from the + # system. + tmp_file, filename = tempfile.mkstemp(suffix='.json') + try: + # Close the file, so the extractor can overwrite it. + os.close(tmp_file) + try: + call([self.extractor, util.syspath(item.path), filename]) + except ABSubmitError as e: + self._log.warning( + u'Failed to analyse {item} for AcousticBrainz: {error}', + item=item, error=e + ) + return None + with open(filename, 'rb') as tmp_file: + analysis = json.load(tmp_file) + # Add the hash to the output. + analysis['metadata']['version']['essentia_build_sha'] = \ + self.extractor_sha + return analysis + finally: + try: + os.remove(filename) + except OSError as e: + # ENOENT means file does not exist, just ignore this error. + if e.errno != errno.ENOENT: + raise + + def _submit_data(self, item, data): + mbid = item['mb_trackid'] + headers = {'Content-Type': 'application/json'} + response = requests.post(self.base_url.format(mbid=mbid), + json=data, headers=headers) + # Test that request was successful and raise an error on failure. + if response.status_code != 200: + try: + message = response.json()['message'] + except (ValueError, KeyError) as e: + message = u'unable to get error message: {}'.format(e) + self._log.error( + u'Failed to submit AcousticBrainz analysis of {item}: ' + u'{message}).', item=item, message=message + ) + else: + self._log.debug(u'Successfully submitted AcousticBrainz analysis ' + u'for {}.', item) diff -Nru beets-1.3.19/beetsplug/acousticbrainz.py beets-1.4.6/beetsplug/acousticbrainz.py --- beets-1.3.19/beetsplug/acousticbrainz.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/acousticbrainz.py 2017-12-12 16:57:06.000000000 +0000 @@ -18,20 +18,101 @@ from __future__ import division, absolute_import, print_function import requests -import operator +from collections import defaultdict from beets import plugins, ui -from functools import reduce ACOUSTIC_BASE = "https://acousticbrainz.org/" LEVELS = ["/low-level", "/high-level"] +ABSCHEME = { + 'highlevel': { + 'danceability': { + 'all': { + 'danceable': 'danceable' + } + }, + 'gender': { + 'value': 'gender' + }, + 'genre_rosamerica': { + 'value': 'genre_rosamerica' + }, + 'mood_acoustic': { + 'all': { + 'acoustic': 'mood_acoustic' + } + }, + 'mood_aggressive': { + 'all': { + 'aggressive': 'mood_aggressive' + } + }, + 'mood_electronic': { + 'all': { + 'electronic': 'mood_electronic' + } + }, + 'mood_happy': { + 'all': { + 'happy': 'mood_happy' + } + }, + 'mood_party': { + 'all': { + 'party': 'mood_party' + } + }, + 'mood_relaxed': { + 'all': { + 'relaxed': 'mood_relaxed' + } + }, + 'mood_sad': { + 'all': { + 'sad': 'mood_sad' + } + }, + 'ismir04_rhythm': { + 'value': 'rhythm' + }, + 'tonal_atonal': { + 'all': { + 'tonal': 'tonal' + } + }, + 'voice_instrumental': { + 'value': 'voice_instrumental' + }, + }, + 'lowlevel': { + 'average_loudness': 'average_loudness' + }, + 'rhythm': { + 'bpm': 'bpm' + }, + 'tonal': { + 'chords_changes_rate': 'chords_changes_rate', + 'chords_key': 'chords_key', + 'chords_number_rate': 'chords_number_rate', + 'chords_scale': 'chords_scale', + 'key_key': ('initial_key', 0), + 'key_scale': ('initial_key', 1), + 'key_strength': 'key_strength' + + } +} class AcousticPlugin(plugins.BeetsPlugin): def __init__(self): super(AcousticPlugin, self).__init__() - self.config.add({'auto': True}) + self.config.add({ + 'auto': True, + 'force': False, + 'tags': [] + }) + if self.config['auto']: self.register_listener('import_task_files', self.import_task_files) @@ -39,10 +120,16 @@ def commands(self): cmd = ui.Subcommand('acousticbrainz', help=u"fetch metadata from AcousticBrainz") + cmd.parser.add_option( + u'-f', u'--force', dest='force_refetch', + action='store_true', default=False, + help=u're-download data when already present' + ) def func(lib, opts, args): items = lib.items(ui.decargs(args)) - fetch_info(self._log, items, ui.should_write()) + self._fetch_info(items, ui.should_write(), + opts.force_refetch or self.config['force']) cmd.func = func return [cmd] @@ -50,116 +137,169 @@ def import_task_files(self, session, task): """Function is called upon beet import. """ + self._fetch_info(task.imported_items(), False, True) - items = task.imported_items() - fetch_info(self._log, items, False) + def _get_data(self, mbid): + data = {} + for url in _generate_urls(mbid): + self._log.debug(u'fetching URL: {}', url) - -def fetch_info(log, items, write): - """Get data from AcousticBrainz for the items. - """ - - def get_value(*map_path): - try: - return reduce(operator.getitem, map_path, data) - except KeyError: - log.debug(u'Invalid Path: {}', map_path) - - for item in items: - if item.mb_trackid: - log.info(u'getting data for: {}', item) - - # Fetch the data from the AB API. - urls = [generate_url(item.mb_trackid, path) for path in LEVELS] - log.debug(u'fetching URLs: {}', ' '.join(urls)) try: - res = [requests.get(url) for url in urls] + res = requests.get(url) except requests.RequestException as exc: - log.info(u'request error: {}', exc) - continue + self._log.info(u'request error: {}', exc) + return {} - # Check for missing tracks. - if any(r.status_code == 404 for r in res): - log.info(u'recording ID {} not found', item.mb_trackid) - continue + if res.status_code == 404: + self._log.info(u'recording ID {} not found', mbid) + return {} - # Parse the JSON response. try: - data = res[0].json() - data.update(res[1].json()) + data.update(res.json()) except ValueError: - log.debug(u'Invalid Response: {} & {}', [r.text for r in res]) + self._log.debug(u'Invalid Response: {}', res.text) + return {} - # Get each field and assign it on the item. - item.danceable = get_value( - "highlevel", "danceability", "all", "danceable", - ) - item.gender = get_value( - "highlevel", "gender", "value", - ) - item.genre_rosamerica = get_value( - "highlevel", "genre_rosamerica", "value" - ) - item.mood_acoustic = get_value( - "highlevel", "mood_acoustic", "all", "acoustic" - ) - item.mood_aggressive = get_value( - "highlevel", "mood_aggressive", "all", "aggressive" - ) - item.mood_electronic = get_value( - "highlevel", "mood_electronic", "all", "electronic" - ) - item.mood_happy = get_value( - "highlevel", "mood_happy", "all", "happy" - ) - item.mood_party = get_value( - "highlevel", "mood_party", "all", "party" - ) - item.mood_relaxed = get_value( - "highlevel", "mood_relaxed", "all", "relaxed" - ) - item.mood_sad = get_value( - "highlevel", "mood_sad", "all", "sad" - ) - item.rhythm = get_value( - "highlevel", "ismir04_rhythm", "value" - ) - item.tonal = get_value( - "highlevel", "tonal_atonal", "all", "tonal" - ) - item.voice_instrumental = get_value( - "highlevel", "voice_instrumental", "value" - ) - item.average_loudness = get_value( - "lowlevel", "average_loudness" - ) - item.chords_changes_rate = get_value( - "tonal", "chords_changes_rate" - ) - item.chords_key = get_value( - "tonal", "chords_key" - ) - item.chords_number_rate = get_value( - "tonal", "chords_number_rate" - ) - item.chords_scale = get_value( - "tonal", "chords_scale" - ) - item.initial_key = '{} {}'.format( - get_value("tonal", "key_key"), - get_value("tonal", "key_scale") - ) - item.key_strength = get_value( - "tonal", "key_strength" - ) - - # Store the data. - item.store() - if write: - item.try_write() + return data + + def _fetch_info(self, items, write, force): + """Fetch additional information from AcousticBrainz for the `item`s. + """ + tags = self.config['tags'].as_str_seq() + for item in items: + # If we're not forcing re-downloading for all tracks, check + # whether the data is already present. We use one + # representative field name to check for previously fetched + # data. + if not force: + mood_str = item.get('mood_acoustic', u'') + if mood_str: + self._log.info(u'data already present for: {}', item) + continue + + # We can only fetch data for tracks with MBIDs. + if not item.mb_trackid: + continue + + self._log.info(u'getting data for: {}', item) + data = self._get_data(item.mb_trackid) + if data: + for attr, val in self._map_data_to_scheme(data, ABSCHEME): + if not tags or attr in tags: + self._log.debug(u'attribute {} of {} set to {}', + attr, + item, + val) + setattr(item, attr, val) + else: + self._log.debug(u'skipping attribute {} of {}' + u' (value {}) due to config', + attr, + item, + val) + item.store() + if write: + item.try_write() + + def _map_data_to_scheme(self, data, scheme): + """Given `data` as a structure of nested dictionaries, and `scheme` as a + structure of nested dictionaries , `yield` tuples `(attr, val)` where + `attr` and `val` are corresponding leaf nodes in `scheme` and `data`. + + As its name indicates, `scheme` defines how the data is structured, + so this function tries to find leaf nodes in `data` that correspond + to the leafs nodes of `scheme`, and not the other way around. + Leaf nodes of `data` that do not exist in the `scheme` do not matter. + If a leaf node of `scheme` is not present in `data`, + no value is yielded for that attribute and a simple warning is issued. + + Finally, to account for attributes of which the value is split between + several leaf nodes in `data`, leaf nodes of `scheme` can be tuples + `(attr, order)` where `attr` is the attribute to which the leaf node + belongs, and `order` is the place at which it should appear in the + value. The different `value`s belonging to the same `attr` are simply + joined with `' '`. This is hardcoded and not very flexible, but it gets + the job done. + + For example: + + >>> scheme = { + 'key1': 'attribute', + 'key group': { + 'subkey1': 'subattribute', + 'subkey2': ('composite attribute', 0) + }, + 'key2': ('composite attribute', 1) + } + >>> data = { + 'key1': 'value', + 'key group': { + 'subkey1': 'subvalue', + 'subkey2': 'part 1 of composite attr' + }, + 'key2': 'part 2' + } + >>> print(list(_map_data_to_scheme(data, scheme))) + [('subattribute', 'subvalue'), + ('attribute', 'value'), + ('composite attribute', 'part 1 of composite attr part 2')] + """ + # First, we traverse `scheme` and `data`, `yield`ing all the non + # composites attributes straight away and populating the dictionary + # `composites` with the composite attributes. + + # When we are finished traversing `scheme`, `composites` should + # map each composite attribute to an ordered list of the values + # belonging to the attribute, for example: + # `composites = {'initial_key': ['B', 'minor']}`. + + # The recursive traversal. + composites = defaultdict(list) + for attr, val in self._data_to_scheme_child(data, + scheme, + composites): + yield attr, val + + # When composites has been populated, yield the composite attributes + # by joining their parts. + for composite_attr, value_parts in composites.items(): + yield composite_attr, ' '.join(value_parts) + + def _data_to_scheme_child(self, subdata, subscheme, composites): + """The recursive business logic of :meth:`_map_data_to_scheme`: + Traverse two structures of nested dictionaries in parallel and `yield` + tuples of corresponding leaf nodes. + + If a leaf node belongs to a composite attribute (is a `tuple`), + populate `composites` rather than yielding straight away. + All the child functions for a single traversal share the same + `composites` instance, which is passed along. + """ + for k, v in subscheme.items(): + if k in subdata: + if type(v) == dict: + for attr, val in self._data_to_scheme_child(subdata[k], + v, + composites): + yield attr, val + elif type(v) == tuple: + composite_attribute, part_number = v + attribute_parts = composites[composite_attribute] + # Parts are not guaranteed to be inserted in order + while len(attribute_parts) <= part_number: + attribute_parts.append('') + attribute_parts[part_number] = subdata[k] + else: + yield v, subdata[k] + else: + self._log.warning(u'Acousticbrainz did not provide info' + u'about {}', k) + self._log.debug(u'Data {} could not be mapped to scheme {} ' + u'because key {} was not found', subdata, v, k) -def generate_url(mbid, level): - """Generates AcousticBrainz end point url for given MBID. +def _generate_urls(mbid): + """Generates AcousticBrainz end point urls for given `mbid`. """ - return ACOUSTIC_BASE + mbid + level + for level in LEVELS: + yield ACOUSTIC_BASE + mbid + level diff -Nru beets-1.3.19/beetsplug/badfiles.py beets-1.4.6/beetsplug/badfiles.py --- beets-1.3.19/beetsplug/badfiles.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/badfiles.py 2017-06-14 23:13:48.000000000 +0000 @@ -27,6 +27,24 @@ import os import errno import sys +import six + + +class CheckerCommandException(Exception): + """Raised when running a checker failed. + + Attributes: + checker: Checker command name. + path: Path to the file being validated. + errno: Error number from the checker execution error. + msg: Message from the checker execution error. + """ + + def __init__(self, cmd, oserror): + self.checker = cmd[0] + self.path = cmd[-1] + self.errno = oserror.errno + self.msg = str(oserror) class BadFiles(BeetsPlugin): @@ -42,11 +60,7 @@ errors = 1 status = e.returncode except OSError as e: - if e.errno == errno.ENOENT: - ui.print_(u"command not found: {}".format(cmd[0])) - sys.exit(1) - else: - raise + raise CheckerCommandException(cmd, e) output = output.decode(sys.getfilesystemencoding()) return status, errors, [line for line in output.split("\n") if line] @@ -92,29 +106,47 @@ ui.colorize('text_error', dpath))) # Run the checker against the file if one is found - ext = os.path.splitext(item.path)[1][1:] + ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore') checker = self.get_checker(ext) if not checker: + self._log.error(u"no checker specified in the config for {}", + ext) continue path = item.path - if not isinstance(path, unicode): + if not isinstance(path, six.text_type): path = item.path.decode(sys.getfilesystemencoding()) - status, errors, output = checker(path) + try: + status, errors, output = checker(path) + except CheckerCommandException as e: + if e.errno == errno.ENOENT: + self._log.error( + u"command not found: {} when validating file: {}", + e.checker, + e.path + ) + else: + self._log.error(u"error invoking {}: {}", e.checker, e.msg) + continue if status > 0: - ui.print_(u"{}: checker exited withs status {}" + ui.print_(u"{}: checker exited with status {}" .format(ui.colorize('text_error', dpath), status)) for line in output: - ui.print_(" {}".format(displayable_path(line))) + ui.print_(u" {}".format(displayable_path(line))) elif errors > 0: ui.print_(u"{}: checker found {} errors or warnings" .format(ui.colorize('text_warning', dpath), errors)) for line in output: ui.print_(u" {}".format(displayable_path(line))) - else: + elif opts.verbose: ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath))) def commands(self): bad_command = Subcommand('bad', help=u'check for corrupt or missing files') + bad_command.parser.add_option( + u'-v', u'--verbose', + action='store_true', default=False, dest='verbose', + help=u'view results for both the bad and uncorrupted files' + ) bad_command.func = self.check_bad return [bad_command] diff -Nru beets-1.3.19/beetsplug/beatport.py beets-1.4.6/beetsplug/beatport.py --- beets-1.3.19/beetsplug/beatport.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beetsplug/beatport.py 2017-06-14 23:13:48.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # @@ -18,6 +19,7 @@ import json import re +import six from datetime import datetime, timedelta from requests_oauthlib import OAuth1Session @@ -42,15 +44,15 @@ class BeatportObject(object): def __init__(self, data): self.beatport_id = data['id'] - self.name = unicode(data['name']) + self.name = six.text_type(data['name']) if 'releaseDate' in data: self.release_date = datetime.strptime(data['releaseDate'], '%Y-%m-%d') if 'artists' in data: - self.artists = [(x['id'], unicode(x['name'])) + self.artists = [(x['id'], six.text_type(x['name'])) for x in data['artists']] if 'genres' in data: - self.genres = [unicode(x['name']) + self.genres = [six.text_type(x['name']) for x in data['genres']] @@ -159,7 +161,8 @@ :returns: Tracks in the matching release :rtype: list of :py:class:`BeatportTrack` """ - response = self._get('/catalog/3/tracks', releaseId=beatport_id) + response = self._get('/catalog/3/tracks', releaseId=beatport_id, + perPage=100) return [BeatportTrack(t) for t in response] def get_track(self, beatport_id): @@ -196,8 +199,9 @@ return response.json()['results'] +@six.python_2_unicode_compatible class BeatportRelease(BeatportObject): - def __unicode__(self): + def __str__(self): if len(self.artists) < 4: artist_str = ", ".join(x[1] for x in self.artists) else: @@ -209,7 +213,7 @@ ) def __repr__(self): - return unicode(self).encode('utf8') + return six.text_type(self).encode('utf-8') def __init__(self, data): BeatportObject.__init__(self, data) @@ -224,21 +228,22 @@ data['slug'], data['id']) +@six.python_2_unicode_compatible class BeatportTrack(BeatportObject): - def __unicode__(self): + def __str__(self): artist_str = ", ".join(x[1] for x in self.artists) return (u"" .format(artist_str, self.name, self.mix_name)) def __repr__(self): - return unicode(self).encode('utf8') + return six.text_type(self).encode('utf-8') def __init__(self, data): BeatportObject.__init__(self, data) if 'title' in data: - self.title = unicode(data['title']) + self.title = six.text_type(data['title']) if 'mixName' in data: - self.mix_name = unicode(data['mixName']) + self.mix_name = six.text_type(data['mixName']) self.length = timedelta(milliseconds=data.get('lengthMs', 0) or 0) if not self.length: try: @@ -249,6 +254,7 @@ if 'slug' in data: self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'], data['id']) + self.track_number = data.get('trackNumber') class BeatportPlugin(BeetsPlugin): @@ -266,8 +272,8 @@ self.register_listener('import_begin', self.setup) def setup(self, session=None): - c_key = self.config['apikey'].get(unicode) - c_secret = self.config['apisecret'].get(unicode) + c_key = self.config['apikey'].as_str() + c_secret = self.config['apisecret'].as_str() # Get the OAuth token from a file or log in. try: @@ -388,10 +394,10 @@ # 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) + query = re.sub(r'\W+', ' ', query, flags=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) + query = re.sub(r'\b(CD|disc)\s*\d+', '', query, flags=re.I) albums = [self._get_album_info(x) for x in self.client.search(query)] return albums @@ -403,8 +409,7 @@ artist, artist_id = self._get_artist(release.artists) if va: artist = u"Various Artists" - tracks = [self._get_track_info(x, index=idx) - for idx, x in enumerate(release.tracks, 1)] + tracks = [self._get_track_info(x) for x in release.tracks] return AlbumInfo(album=release.name, album_id=release.beatport_id, artist=artist, artist_id=artist_id, tracks=tracks, @@ -416,7 +421,7 @@ catalognum=release.catalog_number, media=u'Digital', data_source=u'Beatport', data_url=release.url) - def _get_track_info(self, track, index=None): + def _get_track_info(self, track): """Returns a TrackInfo object for a Beatport Track object. """ title = track.name @@ -424,10 +429,10 @@ title += u" ({0})".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, + length=length, index=track.track_number, + medium_index=track.track_number, data_source=u'Beatport', data_url=track.url) def _get_artist(self, artists): diff -Nru beets-1.3.19/beetsplug/bpd/gstplayer.py beets-1.4.6/beetsplug/bpd/gstplayer.py --- beets-1.3.19/beetsplug/bpd/gstplayer.py 2016-06-20 17:09:11.000000000 +0000 +++ beets-1.4.6/beetsplug/bpd/gstplayer.py 2017-01-17 19:36:24.000000000 +0000 @@ -19,18 +19,19 @@ from __future__ import division, absolute_import, print_function +import six import sys import time -import thread +from six.moves import _thread import os import copy -import urllib +from six.moves import urllib from beets import ui import gi -from gi.repository import GLib, Gst - gi.require_version('Gst', '1.0') +from gi.repository import GLib, Gst # noqa: E402 + Gst.init(None) @@ -128,9 +129,9 @@ path. """ self.player.set_state(Gst.State.NULL) - if isinstance(path, unicode): - path = path.encode('utf8') - uri = 'file://' + urllib.quote(path) + if isinstance(path, six.text_type): + path = path.encode('utf-8') + uri = 'file://' + urllib.parse.quote(path) self.player.set_property("uri", uri) self.player.set_state(Gst.State.PLAYING) self.playing = True @@ -164,7 +165,7 @@ loop = GLib.MainLoop() loop.run() - thread.start_new_thread(start, ()) + _thread.start_new_thread(start, ()) def time(self): """Returns a tuple containing (position, length) where both @@ -176,12 +177,12 @@ posq = self.player.query_position(fmt) if not posq[0]: raise QueryError("query_position failed") - pos = posq[1] / (10 ** 9) + pos = posq[1] // (10 ** 9) lengthq = self.player.query_duration(fmt) if not lengthq[0]: raise QueryError("query_duration failed") - length = lengthq[1] / (10 ** 9) + length = lengthq[1] // (10 ** 9) self.cached_time = (pos, length) return (pos, length) diff -Nru beets-1.3.19/beetsplug/bpd/__init__.py beets-1.4.6/beetsplug/bpd/__init__.py --- beets-1.3.19/beetsplug/bpd/__init__.py 2016-06-20 17:09:11.000000000 +0000 +++ beets-1.4.6/beetsplug/bpd/__init__.py 2017-01-03 01:53:12.000000000 +0000 @@ -35,17 +35,18 @@ from beets.library import Item from beets import dbcore from beets.mediafile import MediaFile +import six PROTOCOL_VERSION = '0.13.0' BUFSIZE = 1024 -HELLO = 'OK MPD %s' % PROTOCOL_VERSION -CLIST_BEGIN = 'command_list_begin' -CLIST_VERBOSE_BEGIN = 'command_list_ok_begin' -CLIST_END = 'command_list_end' -RESP_OK = 'OK' -RESP_CLIST_VERBOSE = 'list_OK' -RESP_ERR = 'ACK' +HELLO = u'OK MPD %s' % PROTOCOL_VERSION +CLIST_BEGIN = u'command_list_begin' +CLIST_VERBOSE_BEGIN = u'command_list_ok_begin' +CLIST_END = u'command_list_end' +RESP_OK = u'OK' +RESP_CLIST_VERBOSE = u'list_OK' +RESP_ERR = u'ACK' NEWLINE = u"\n" @@ -305,12 +306,12 @@ playlist, playlistlength, and xfade. """ yield ( - u'volume: ' + unicode(self.volume), - u'repeat: ' + unicode(int(self.repeat)), - u'random: ' + unicode(int(self.random)), - u'playlist: ' + unicode(self.playlist_version), - u'playlistlength: ' + unicode(len(self.playlist)), - u'xfade: ' + unicode(self.crossfade), + u'volume: ' + six.text_type(self.volume), + u'repeat: ' + six.text_type(int(self.repeat)), + u'random: ' + six.text_type(int(self.random)), + u'playlist: ' + six.text_type(self.playlist_version), + u'playlistlength: ' + six.text_type(len(self.playlist)), + u'xfade: ' + six.text_type(self.crossfade), ) if self.current_index == -1: @@ -323,8 +324,8 @@ if self.current_index != -1: # i.e., paused or playing current_id = self._item_id(self.playlist[self.current_index]) - yield u'song: ' + unicode(self.current_index) - yield u'songid: ' + unicode(current_id) + yield u'song: ' + six.text_type(self.current_index) + yield u'songid: ' + six.text_type(current_id) if self.error: yield u'error: ' + self.error @@ -468,8 +469,8 @@ Also a dummy implementation. """ for idx, track in enumerate(self.playlist): - yield u'cpos: ' + unicode(idx) - yield u'Id: ' + unicode(track.id) + yield u'cpos: ' + six.text_type(idx) + yield u'Id: ' + six.text_type(track.id) def cmd_currentsong(self, conn): """Sends information about the currently-playing song. @@ -569,12 +570,12 @@ added after every string. Returns a Bluelet event that sends the data. """ - if isinstance(lines, basestring): + if isinstance(lines, six.string_types): lines = [lines] out = NEWLINE.join(lines) + NEWLINE log.debug('{}', out[:-1]) # Don't log trailing newline. - if isinstance(out, unicode): - out = out.encode('utf8') + if isinstance(out, six.text_type): + out = out.encode('utf-8') return self.sock.sendall(out) def do_command(self, command): @@ -603,7 +604,8 @@ line = line.strip() if not line: break - log.debug('{}', line) + line = line.decode('utf8') # MPD protocol uses UTF-8. + log.debug(u'{}', line) if clist is not None: # Command list already opened. @@ -639,8 +641,8 @@ """A command issued by the client for processing by the server. """ - command_re = re.compile(br'^([^ \t]+)[ \t]*') - arg_re = re.compile(br'"((?:\\"|[^"])+)"|([^ \t"]+)') + command_re = re.compile(r'^([^ \t]+)[ \t]*') + arg_re = re.compile(r'"((?:\\"|[^"])+)"|([^ \t"]+)') def __init__(self, s): """Creates a new `Command` from the given string, `s`, parsing @@ -655,11 +657,10 @@ if match[0]: # Quoted argument. arg = match[0] - arg = arg.replace(b'\\"', b'"').replace(b'\\\\', b'\\') + arg = arg.replace(u'\\"', u'"').replace(u'\\\\', u'\\') else: # Unquoted argument. arg = match[1] - arg = arg.decode('utf8') self.args.append(arg) def run(self, conn): @@ -771,28 +772,28 @@ def _item_info(self, item): info_lines = [ u'file: ' + item.destination(fragment=True), - u'Time: ' + unicode(int(item.length)), + u'Time: ' + six.text_type(int(item.length)), u'Title: ' + item.title, u'Artist: ' + item.artist, u'Album: ' + item.album, u'Genre: ' + item.genre, ] - track = unicode(item.track) + track = six.text_type(item.track) if item.tracktotal: - track += u'/' + unicode(item.tracktotal) + track += u'/' + six.text_type(item.tracktotal) info_lines.append(u'Track: ' + track) - info_lines.append(u'Date: ' + unicode(item.year)) + info_lines.append(u'Date: ' + six.text_type(item.year)) try: pos = self._id_to_index(item.id) - info_lines.append(u'Pos: ' + unicode(pos)) + info_lines.append(u'Pos: ' + six.text_type(pos)) except ArgumentNotFoundError: # Don't include position if not in playlist. pass - info_lines.append(u'Id: ' + unicode(item.id)) + info_lines.append(u'Id: ' + six.text_type(item.id)) return info_lines @@ -852,7 +853,7 @@ for name, itemid in iter(sorted(node.files.items())): item = self.lib.get_item(itemid) yield self._item_info(item) - for name, _ in iter(sorted(node.dirs.iteritems())): + for name, _ in iter(sorted(node.dirs.items())): dirpath = self._path_join(path, name) if dirpath.startswith(u"/"): # Strip leading slash (libmpc rejects this). @@ -872,12 +873,12 @@ yield u'file: ' + basepath else: # List a directory. Recurse into both directories and files. - for name, itemid in sorted(node.files.iteritems()): + for name, itemid in sorted(node.files.items()): newpath = self._path_join(basepath, name) # "yield from" for v in self._listall(newpath, itemid, info): yield v - for name, subdir in sorted(node.dirs.iteritems()): + for name, subdir in sorted(node.dirs.items()): newpath = self._path_join(basepath, name) yield u'directory: ' + newpath for v in self._listall(newpath, subdir, info): @@ -902,11 +903,11 @@ yield self.lib.get_item(node) else: # Recurse into a directory. - for name, itemid in sorted(node.files.iteritems()): + for name, itemid in sorted(node.files.items()): # "yield from" for v in self._all_items(itemid): yield v - for name, subdir in sorted(node.dirs.iteritems()): + for name, subdir in sorted(node.dirs.items()): for v in self._all_items(subdir): yield v @@ -917,7 +918,7 @@ for item in self._all_items(self._resolve_path(path)): self.playlist.append(item) if send_id: - yield u'Id: ' + unicode(item.id) + yield u'Id: ' + six.text_type(item.id) self.playlist_version += 1 def cmd_add(self, conn, path): @@ -938,11 +939,11 @@ if self.current_index > -1: item = self.playlist[self.current_index] - yield u'bitrate: ' + unicode(item.bitrate / 1000) + yield u'bitrate: ' + six.text_type(item.bitrate / 1000) # Missing 'audio'. (pos, total) = self.player.time() - yield u'time: ' + unicode(pos) + u':' + unicode(total) + yield u'time: ' + six.text_type(pos) + u':' + six.text_type(total) # Also missing 'updating_db'. @@ -957,13 +958,13 @@ artists, albums, songs, totaltime = tx.query(statement)[0] yield ( - u'artists: ' + unicode(artists), - u'albums: ' + unicode(albums), - u'songs: ' + unicode(songs), - u'uptime: ' + unicode(int(time.time() - self.startup_time)), + u'artists: ' + six.text_type(artists), + u'albums: ' + six.text_type(albums), + u'songs: ' + six.text_type(songs), + u'uptime: ' + six.text_type(int(time.time() - self.startup_time)), u'playtime: ' + u'0', # Missing. - u'db_playtime: ' + unicode(int(totaltime)), - u'db_update: ' + unicode(int(self.updated_time)), + u'db_playtime: ' + six.text_type(int(totaltime)), + u'db_update: ' + six.text_type(int(self.updated_time)), ) # Searching. @@ -1059,7 +1060,7 @@ rows = tx.query(statement, subvals) for row in rows: - yield show_tag_canon + u': ' + unicode(row[0]) + yield show_tag_canon + u': ' + six.text_type(row[0]) def cmd_count(self, conn, tag, value): """Returns the number and total time of songs matching the @@ -1071,8 +1072,8 @@ for item in self.lib.items(dbcore.query.MatchQuery(key, value)): songs += 1 playtime += item.length - yield u'songs: ' + unicode(songs) - yield u'playtime: ' + unicode(int(playtime)) + yield u'songs: ' + six.text_type(songs) + yield u'playtime: ' + six.text_type(int(playtime)) # "Outputs." Just a dummy implementation because we don't control # any outputs. @@ -1180,11 +1181,12 @@ ) def func(lib, opts, args): - host = args.pop(0) if args else self.config['host'].get(unicode) + host = self.config['host'].as_str() + host = args.pop(0) if args else host port = args.pop(0) if args else self.config['port'].get(int) if args: raise beets.ui.UserError(u'too many arguments') - password = self.config['password'].get(unicode) + password = self.config['password'].as_str() volume = self.config['volume'].get(int) debug = opts.debug or False self.start_bpd(lib, host, int(port), password, volume, debug) diff -Nru beets-1.3.19/beetsplug/bpm.py beets-1.4.6/beetsplug/bpm.py --- beets-1.3.19/beetsplug/bpm.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/bpm.py 2017-01-03 01:53:12.000000000 +0000 @@ -18,6 +18,7 @@ from __future__ import division, absolute_import, print_function import time +from six.moves import input from beets import ui from beets.plugins import BeetsPlugin @@ -31,7 +32,7 @@ dt = [] for i in range(max_strokes): # Press enter to the rhythm... - s = raw_input() + s = input() if s == '': t1 = time.time() # Only start measuring at the second stroke @@ -64,7 +65,9 @@ return [cmd] def command(self, lib, opts, args): - self.get_bpm(lib.items(ui.decargs(args))) + items = lib.items(ui.decargs(args)) + write = ui.should_write() + self.get_bpm(items, write) def get_bpm(self, items, write=False): overwrite = self.config['overwrite'].get(bool) diff -Nru beets-1.3.19/beetsplug/bucket.py beets-1.4.6/beetsplug/bucket.py --- beets-1.3.19/beetsplug/bucket.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/bucket.py 2016-12-17 03:01:22.000000000 +0000 @@ -21,7 +21,8 @@ from datetime import datetime import re import string -from itertools import tee, izip +from six.moves import zip +from itertools import tee from beets import plugins, ui @@ -37,7 +38,7 @@ "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = tee(iterable) next(b, None) - return izip(a, b) + return zip(a, b) def span_from_str(span_str): diff -Nru beets-1.3.19/beetsplug/chroma.py beets-1.4.6/beetsplug/chroma.py --- beets-1.3.19/beetsplug/chroma.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/chroma.py 2017-08-27 14:19:06.000000000 +0000 @@ -121,7 +121,7 @@ for release_id in release_ids: relcounts[release_id] += 1 - for release_id, count in relcounts.iteritems(): + for release_id, count in relcounts.items(): if float(count) / len(items) > COMMON_REL_THRESH: yield release_id @@ -181,7 +181,7 @@ def submit_cmd_func(lib, opts, args): try: - apikey = config['acoustid']['apikey'].get(unicode) + apikey = config['acoustid']['apikey'].as_str() except confit.NotFoundError: raise ui.UserError(u'no Acoustid user API key provided') submit_items(self._log, apikey, lib.items(ui.decargs(args))) @@ -236,7 +236,7 @@ try: acoustid.submit(API_KEY, userkey, data) except acoustid.AcoustidError as exc: - log.warn(u'acoustid submission error: {0}', exc) + log.warning(u'acoustid submission error: {0}', exc) del data[:] for item in items: @@ -295,7 +295,7 @@ log.info(u'{0}: fingerprinting', util.displayable_path(item.path)) try: - _, fp = acoustid.fingerprint_file(item.path) + _, fp = acoustid.fingerprint_file(util.syspath(item.path)) item.acoustid_fingerprint = fp if write: log.info(u'{0}: writing fingerprint', diff -Nru beets-1.3.19/beetsplug/convert.py beets-1.4.6/beetsplug/convert.py --- beets-1.3.19/beetsplug/convert.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/convert.py 2017-12-12 16:57:06.000000000 +0000 @@ -22,13 +22,17 @@ import subprocess import tempfile import shlex +import six from string import Template +import platform from beets import ui, util, plugins, config from beets.plugins import BeetsPlugin from beets.util.confit import ConfigTypeError from beets import art from beets.util.artresizer import ArtResizer +from beets.library import parse_query_string +from beets.library import Item _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. @@ -47,7 +51,7 @@ The new extension must not contain a leading dot. """ - ext_dot = util.bytestring_path('.' + ext) + ext_dot = b'.' + ext return os.path.splitext(path)[0] + ext_dot @@ -55,7 +59,7 @@ """Return the command template and the extension from the config. """ if not fmt: - fmt = config['convert']['format'].get(unicode).lower() + fmt = config['convert']['format'].as_str().lower() fmt = ALIASES.get(fmt, fmt) try: @@ -68,28 +72,34 @@ .format(fmt) ) except ConfigTypeError: - command = config['convert']['formats'][fmt].get(bytes) + command = config['convert']['formats'][fmt].get(str) extension = fmt # Convenience and backwards-compatibility shortcuts. keys = config['convert'].keys() if 'command' in keys: - command = config['convert']['command'].get(unicode) + command = config['convert']['command'].as_str() elif 'opts' in keys: # Undocumented option for backwards compatibility with < 1.3.1. command = u'ffmpeg -i $source -y {0} $dest'.format( - config['convert']['opts'].get(unicode) + config['convert']['opts'].as_str() ) if 'extension' in keys: - extension = config['convert']['extension'].get(unicode) + extension = config['convert']['extension'].as_str() - return (command.encode('utf8'), extension.encode('utf8')) + return (command.encode('utf-8'), extension.encode('utf-8')) def should_transcode(item, fmt): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). """ + no_convert_queries = config['convert']['no_convert'].as_str_seq() + if no_convert_queries: + for query_string in no_convert_queries: + query, _ = parse_query_string(query_string, Item) + if query.match(item): + return False if config['convert']['never_convert_lossy_files'] and \ not (item.format.lower() in LOSSLESS_FORMATS): return False @@ -108,8 +118,8 @@ u'format': u'mp3', u'formats': { u'aac': { - u'command': u'ffmpeg -i $source -y -vn -acodec libfaac ' - u'-aq 100 $dest', + u'command': u'ffmpeg -i $source -y -vn -acodec aac ' + u'-aq 1 $dest', u'extension': u'm4a', }, u'alac': { @@ -131,6 +141,7 @@ u'quiet': False, u'embed': True, u'paths': {}, + u'no_convert': u'', u'never_convert_lossy_files': False, u'copy_album_art': False, u'album_art_maxwidth': 0, @@ -182,20 +193,40 @@ if not quiet and not pretend: self._log.info(u'Encoding {0}', util.displayable_path(source)) + # On Python 3, we need to construct the command to invoke as a + # Unicode string. On Unix, this is a little unfortunate---the OS is + # expecting bytes---so we use surrogate escaping and decode with the + # argument encoding, which is the same encoding that will then be + # *reversed* to recover the same bytes before invoking the OS. On + # Windows, we want to preserve the Unicode filename "as is." + if not six.PY2: + command = command.decode(util.arg_encoding(), 'surrogateescape') + if platform.system() == 'Windows': + source = source.decode(util._fsencoding()) + dest = dest.decode(util._fsencoding()) + else: + source = source.decode(util.arg_encoding(), 'surrogateescape') + dest = dest.decode(util.arg_encoding(), 'surrogateescape') + # Substitute $source and $dest in the argument list. args = shlex.split(command) + encode_cmd = [] for i, arg in enumerate(args): args[i] = Template(arg).safe_substitute({ 'source': source, 'dest': dest, }) + if six.PY2: + encode_cmd.append(args[i]) + else: + encode_cmd.append(args[i].encode(util.arg_encoding())) if pretend: - self._log.info(u' '.join(ui.decargs(args))) + self._log.info(u'{0}', u' '.join(ui.decargs(args))) return try: - util.command_output(args) + util.command_output(encode_cmd) except subprocess.CalledProcessError as exc: # Something went wrong (probably Ctrl+C), remove temporary files self._log.info(u'Encoding {0} failed. Cleaning up...', @@ -374,61 +405,66 @@ util.copy(album.artpath, dest) def convert_func(self, lib, opts, args): - if not opts.dest: - opts.dest = self.config['dest'].get() - if not opts.dest: + dest = opts.dest or self.config['dest'].get() + if not dest: raise ui.UserError(u'no convert destination set') - opts.dest = util.bytestring_path(opts.dest) - - if not opts.threads: - opts.threads = self.config['threads'].get(int) + dest = util.bytestring_path(dest) - if self.config['paths']: - path_formats = ui.get_path_formats(self.config['paths']) - else: - path_formats = ui.get_path_formats() - - if not opts.format: - opts.format = self.config['format'].get(unicode).lower() + threads = opts.threads or self.config['threads'].get(int) - pretend = opts.pretend if opts.pretend is not None else \ - self.config['pretend'].get(bool) + path_formats = ui.get_path_formats(self.config['paths'] or None) - if not pretend: - ui.commands.list_items(lib, ui.decargs(args), opts.album) + fmt = opts.format or self.config['format'].as_str().lower() - if not (opts.yes or ui.input_yn(u"Convert? (Y/n)")): - return + if opts.pretend is not None: + pretend = opts.pretend + else: + pretend = self.config['pretend'].get(bool) if opts.album: albums = lib.albums(ui.decargs(args)) - items = (i for a in albums for i in a.items()) - if self.config['copy_album_art']: - for album in albums: - self.copy_album_art(album, opts.dest, path_formats, - pretend) + items = [i for a in albums for i in a.items()] + if not pretend: + for a in albums: + ui.print_(format(a, u'')) else: - items = iter(lib.items(ui.decargs(args))) - convert = [self.convert_item(opts.dest, + items = list(lib.items(ui.decargs(args))) + if not pretend: + for i in items: + ui.print_(format(i, u'')) + + if not items: + self._log.error(u'Empty query result.') + return + if not (pretend or opts.yes or ui.input_yn(u"Convert? (Y/n)")): + return + + if opts.album and self.config['copy_album_art']: + for album in albums: + self.copy_album_art(album, dest, path_formats, pretend) + + convert = [self.convert_item(dest, opts.keep_new, path_formats, - opts.format, + fmt, pretend) - for _ in range(opts.threads)] - pipe = util.pipeline.Pipeline([items, convert]) + for _ in range(threads)] + pipe = util.pipeline.Pipeline([iter(items), convert]) pipe.run_parallel() def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the library. """ - fmt = self.config['format'].get(unicode).lower() + fmt = self.config['format'].as_str().lower() if should_transcode(item, fmt): command, ext = get_format() # Create a temporary file for the conversion. tmpdir = self.config['tmpdir'].get() - fd, dest = tempfile.mkstemp('.' + ext, dir=tmpdir) + if tmpdir: + tmpdir = util.py3_path(util.bytestring_path(tmpdir)) + fd, dest = tempfile.mkstemp(util.py3_path(b'.' + ext), dir=tmpdir) os.close(fd) dest = util.bytestring_path(dest) _temp_files.append(dest) # Delete the transcode later. diff -Nru beets-1.3.19/beetsplug/cue.py beets-1.4.6/beetsplug/cue.py --- beets-1.3.19/beetsplug/cue.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/cue.py 2016-12-17 03:01:22.000000000 +0000 @@ -35,7 +35,7 @@ return if len(cues) > 1: self._log.info(u"Found multiple cue files doing nothing: {0}", - map(displayable_path, cues)) + list(map(displayable_path, cues))) cue_file = cues[0] self._log.info("Found {} for {}", displayable_path(cue_file), item) diff -Nru beets-1.3.19/beetsplug/discogs.py beets-1.4.6/beetsplug/discogs.py --- beets-1.3.19/beetsplug/discogs.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/discogs.py 2017-06-14 23:13:48.000000000 +0000 @@ -19,7 +19,6 @@ from __future__ import division, absolute_import, print_function import beets.ui -from beets import logging from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin @@ -27,23 +26,21 @@ from discogs_client import Release, Client from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError +from six.moves import http_client import beets import re import time import json import socket -import httplib import os +import traceback +from string import ascii_lowercase -# Silence spurious INFO log lines generated by urllib3. -urllib3_logger = logging.getLogger('requests.packages.urllib3') -urllib3_logger.setLevel(logging.CRITICAL) - USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__) # Exceptions that discogs_client should really handle but does not. -CONNECTION_ERRORS = (ConnectionError, socket.error, httplib.HTTPException, +CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException, ValueError, # JSON decoding raises a ValueError. DiscogsAPIError) @@ -57,17 +54,25 @@ 'apisecret': 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy', 'tokenfile': 'discogs_token.json', 'source_weight': 0.5, + 'user_token': '', }) self.config['apikey'].redact = True self.config['apisecret'].redact = True + self.config['user_token'].redact = True self.discogs_client = None self.register_listener('import_begin', self.setup) def setup(self, session=None): """Create the `discogs_client` field. Authenticate if necessary. """ - c_key = self.config['apikey'].get(unicode) - c_secret = self.config['apisecret'].get(unicode) + c_key = self.config['apikey'].as_str() + c_secret = self.config['apisecret'].as_str() + + # Try using a configured user token (bypassing OAuth login). + user_token = self.config['user_token'].as_str() + if user_token: + self.discogs_client = Client(USER_AGENT, user_token=user_token) + return # Get the OAuth token from a file or log in. try: @@ -84,7 +89,7 @@ token, secret) def reset_auth(self): - """Delete toke file & redo the auth steps. + """Delete token file & redo the auth steps. """ os.remove(self._tokenfile()) self.setup() @@ -200,7 +205,7 @@ query = re.sub(r'(?u)\W+', ' ', query).encode('ascii', "replace") # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. - query = re.sub(br'(?i)\b(CD|disc)\s*\d+', '', query) + query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query) try: releases = self.discogs_client.search(query, type='release').page(1) @@ -208,11 +213,27 @@ self._log.debug(u"Communication error while searching for {0!r}", query, exc_info=True) return [] - return [self.get_album_info(release) for release in releases[:5]] + return [album for album in map(self.get_album_info, releases[:5]) + if album] def get_album_info(self, result): """Returns an AlbumInfo object for a discogs Release object. """ + # Explicitly reload the `Release` fields, as they might not be yet + # present if the result is from a `discogs_client.search()`. + if not result.data.get('artists'): + result.refresh() + + # Sanity check for required fields. The list of required fields is + # defined at Guideline 1.3.1.a, but in practice some releases might be + # lacking some of these fields. This function expects at least: + # `artists` (>0), `title`, `id`, `tracklist` (>0) + # https://www.discogs.com/help/doc/submission-guidelines-general-rules + if not all([result.data.get(k) for k in ['artists', 'title', 'id', + 'tracklist']]): + self._log.warn(u"Release does not contain the required fields") + return None + artist, artist_id = self.get_artist([a.data for a in result.artists]) album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] @@ -221,20 +242,36 @@ # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks(result.data['tracklist']) - albumtype = ', '.join( - result.data['formats'][0].get('descriptions', [])) or None - va = result.data['artists'][0]['name'].lower() == 'various' - if va: - artist = config['va_name'].get(unicode) - year = result.data['year'] - label = result.data['labels'][0]['name'] + + # Extract information for the optional AlbumInfo fields, if possible. + va = result.data['artists'][0].get('name', '').lower() == 'various' + year = result.data.get('year') mediums = len(set(t.medium for t in tracks)) - catalogno = result.data['labels'][0]['catno'] - if catalogno == 'none': - catalogno = None country = result.data.get('country') - media = result.data['formats'][0]['name'] - data_url = result.data['uri'] + data_url = result.data.get('uri') + + # Extract information for the optional AlbumInfo fields that are + # contained on nested discogs fields. + albumtype = media = label = catalogno = None + if result.data.get('formats'): + albumtype = ', '.join( + result.data['formats'][0].get('descriptions', [])) or None + media = result.data['formats'][0]['name'] + if result.data.get('labels'): + label = result.data['labels'][0].get('name') + catalogno = result.data['labels'][0].get('catno') + + # Additional cleanups (various artists name, catalog number, media). + if va: + artist = config['va_name'].as_str() + if catalogno == 'none': + catalogno = None + # Explicitly set the `media` for the tracks, since it is expected by + # `autotag.apply_metadata`, and set `medium_total`. + for track in tracks: + track.media = media + track.medium_total = mediums + return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None, albumtype=albumtype, va=va, year=year, month=None, day=None, label=label, mediums=mediums, @@ -269,21 +306,44 @@ def get_tracks(self, tracklist): """Returns a list of TrackInfo objects for a discogs tracklist. """ + try: + clean_tracklist = self.coalesce_tracks(tracklist) + except Exception as exc: + # FIXME: this is an extra precaution for making sure there are no + # side effects after #2222. It should be removed after further + # testing. + self._log.debug(u'{}', traceback.format_exc()) + self._log.error(u'uncaught exception in coalesce_tracks: {}', exc) + clean_tracklist = tracklist tracks = [] index_tracks = {} index = 0 - for track in tracklist: + for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track['position']: index += 1 - tracks.append(self.get_track_info(track, index)) + track_info = self.get_track_info(track, index) + track_info.track_alt = track['position'] + tracks.append(track_info) else: index_tracks[index + 1] = track['title'] # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None - medium_count, index_count = 0, 0 + medium_count, index_count, side_count = 0, 0, 0 + sides_per_medium = 1 + + # If a medium has two sides (ie. vinyl or cassette), each pair of + # consecutive sides should belong to the same medium. + if all([track.medium is not None for track in tracks]): + m = sorted(set([track.medium.lower() for track in tracks])) + # If all track.medium are single consecutive letters, assume it is + # a 2-sided medium. + if ''.join(m) in ascii_lowercase: + sides_per_medium = 2 + side_count = 1 # Force for first item, where medium == None + for track in tracks: # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium @@ -295,12 +355,19 @@ ) if not medium_is_index and medium != track.medium: - # Increment medium_count and reset index_count when medium - # changes. - medium = track.medium - medium_count += 1 - index_count = 0 + if side_count < (sides_per_medium - 1): + # Increment side count: side changed, but not medium. + side_count += 1 + medium = track.medium + else: + # Increment medium_count and reset index_count and side + # count when medium changes. + medium = track.medium + medium_count += 1 + index_count = 0 + side_count = 0 index_count += 1 + medium_count = 1 if medium_count == 0 else medium_count track.medium, track.medium_index = medium_count, index_count # Get `disctitle` from Discogs index tracks. Assume that an index track @@ -315,12 +382,87 @@ return tracks + def coalesce_tracks(self, raw_tracklist): + """Pre-process a tracklist, merging subtracks into a single track. The + title for the merged track is the one from the previous index track, + if present; otherwise it is a combination of the subtracks titles. + """ + def add_merged_subtracks(tracklist, subtracks): + """Modify `tracklist` in place, merging a list of `subtracks` into + a single track into `tracklist`.""" + # Calculate position based on first subtrack, without subindex. + idx, medium_idx, sub_idx = \ + self.get_track_index(subtracks[0]['position']) + position = '%s%s' % (idx or '', medium_idx or '') + + if tracklist and not tracklist[-1]['position']: + # Assume the previous index track contains the track title. + if sub_idx: + # "Convert" the track title to a real track, discarding the + # subtracks assuming they are logical divisions of a + # physical track (12.2.9 Subtracks). + tracklist[-1]['position'] = position + else: + # Promote the subtracks to real tracks, discarding the + # index track, assuming the subtracks are physical tracks. + index_track = tracklist.pop() + # Fix artists when they are specified on the index track. + if index_track.get('artists'): + for subtrack in subtracks: + if not subtrack.get('artists'): + subtrack['artists'] = index_track['artists'] + tracklist.extend(subtracks) + else: + # Merge the subtracks, pick a title, and append the new track. + track = subtracks[0].copy() + track['title'] = ' / '.join([t['title'] for t in subtracks]) + tracklist.append(track) + + # Pre-process the tracklist, trying to identify subtracks. + subtracks = [] + tracklist = [] + prev_subindex = '' + for track in raw_tracklist: + # Regular subtrack (track with subindex). + if track['position']: + _, _, subindex = self.get_track_index(track['position']) + if subindex: + if subindex.rjust(len(raw_tracklist)) > prev_subindex: + # Subtrack still part of the current main track. + subtracks.append(track) + else: + # Subtrack part of a new group (..., 1.3, *2.1*, ...). + add_merged_subtracks(tracklist, subtracks) + subtracks = [track] + prev_subindex = subindex.rjust(len(raw_tracklist)) + continue + + # Index track with nested sub_tracks. + if not track['position'] and 'sub_tracks' in track: + # Append the index track, assuming it contains the track title. + tracklist.append(track) + add_merged_subtracks(tracklist, track['sub_tracks']) + continue + + # Regular track or index track without nested sub_tracks. + if subtracks: + add_merged_subtracks(tracklist, subtracks) + subtracks = [] + prev_subindex = '' + tracklist.append(track) + + # Merge and add the remaining subtracks, if any. + if subtracks: + add_merged_subtracks(tracklist, subtracks) + + return tracklist + def get_track_info(self, track, index): """Returns a TrackInfo object for a discogs track. """ title = track['title'] track_id = None - medium, medium_index = self.get_track_index(track['position']) + medium, medium_index, _ = self.get_track_index(track['position']) artist, artist_id = self.get_artist(track.get('artists', [])) length = self.get_track_length(track['duration']) return TrackInfo(title, track_id, artist, artist_id, length, index, @@ -328,17 +470,33 @@ disctitle=None, artist_credit=None) def get_track_index(self, position): - """Returns the medium and medium index for a discogs track position. - """ - # medium_index is a number at the end of position. medium is everything - # else. E.g. (A)(1), (Side A, Track )(1), (A)(), ()(1), etc. - match = re.match(r'^(.*?)(\d*)$', position.upper()) + """Returns the medium, medium index and subtrack index for a discogs + track position.""" + # Match the standard Discogs positions (12.2.9), which can have several + # forms (1, 1-1, A1, A1.1, A1a, ...). + match = re.match( + r'^(.*?)' # medium: everything before medium_index. + r'(\d*?)' # medium_index: a number at the end of + # `position`, except if followed by a subtrack + # index. + # subtrack_index: can only be matched if medium + # or medium_index have been matched, and can be + r'((?<=\w)\.[\w]+' # - a dot followed by a string (A.1, 2.A) + r'|(?<=\d)[A-Z]+' # - a string that follows a number (1A, B2a) + r')?' + r'$', + position.upper() + ) + if match: - medium, index = match.groups() + medium, index, subindex = match.groups() + + if subindex and subindex.startswith('.'): + subindex = subindex[1:] else: self._log.debug(u'Invalid position: {0}', position) - medium = index = None - return medium or None, index or None + medium = index = subindex = None + return medium or None, index or None, subindex or None def get_track_length(self, duration): """Returns the track length in seconds for a discogs duration. diff -Nru beets-1.3.19/beetsplug/duplicates.py beets-1.4.6/beetsplug/duplicates.py --- beets-1.3.19/beetsplug/duplicates.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/duplicates.py 2017-12-12 16:57:10.000000000 +0000 @@ -20,9 +20,11 @@ import shlex from beets.plugins import BeetsPlugin -from beets.ui import decargs, print_, vararg_callback, Subcommand, UserError -from beets.util import command_output, displayable_path, subprocess +from beets.ui import decargs, print_, Subcommand, UserError +from beets.util import command_output, displayable_path, subprocess, \ + bytestring_path, MoveOperation from beets.library import Item, Album +import six PLUGIN = 'duplicates' @@ -79,10 +81,9 @@ help=u'report duplicates only if all attributes are set', ) self._command.parser.add_option( - u'-k', u'--keys', dest='keys', - action='callback', metavar='KEY1 KEY2', - callback=vararg_callback, - help=u'report duplicates based on keys', + u'-k', u'--key', dest='keys', + action='append', metavar='KEY', + help=u'report duplicates based on keys (use multiple times)', ) self._command.parser.add_option( u'-M', u'--merge', dest='merge', @@ -112,14 +113,14 @@ self.config.set_args(opts) album = self.config['album'].get(bool) checksum = self.config['checksum'].get(str) - copy = self.config['copy'].get(str) + copy = bytestring_path(self.config['copy'].as_str()) count = self.config['count'].get(bool) delete = self.config['delete'].get(bool) fmt = self.config['format'].get(str) full = self.config['full'].get(bool) - keys = self.config['keys'].get(list) + keys = self.config['keys'].as_str_seq() merge = self.config['merge'].get(bool) - move = self.config['move'].get(str) + move = bytestring_path(self.config['move'].as_str()) path = self.config['path'].get(bool) tiebreak = self.config['tiebreak'].get(dict) strict = self.config['strict'].get(bool) @@ -135,15 +136,15 @@ items = lib.items(decargs(args)) if path: - fmt = '$path' + fmt = u'$path' # Default format string for count mode. if count and not fmt: if album: - fmt = '$albumartist - $album' + fmt = u'$albumartist - $album' else: - fmt = '$albumartist - $album - $title' - fmt += ': {0}' + fmt = u'$albumartist - $album - $title' + fmt += u': {0}' if checksum: for i in items: @@ -169,22 +170,22 @@ return [self._command] def _process_item(self, item, copy=False, move=False, delete=False, - tag=False, fmt=''): + tag=False, fmt=u''): """Process Item `item`. """ print_(format(item, fmt)) if copy: - item.move(basedir=copy, copy=True) + item.move(basedir=copy, operation=MoveOperation.COPY) item.store() if move: - item.move(basedir=move, copy=False) + item.move(basedir=move) item.store() if delete: item.remove(delete=True) if tag: try: k, v = tag.split('=') - except: + except Exception: raise UserError( u"{}: can't parse k=v tag: {}".format(PLUGIN, tag) ) @@ -252,20 +253,19 @@ "completeness" (objects with more non-null fields come first) and Albums are ordered by their track count. """ - if tiebreak: - kind = 'items' if all(isinstance(o, Item) - for o in objs) else 'albums' + kind = 'items' if all(isinstance(o, Item) for o in objs) else 'albums' + + if tiebreak and kind in tiebreak.keys(): key = lambda x: tuple(getattr(x, k) for k in tiebreak[kind]) else: - kind = Item if all(isinstance(o, Item) for o in objs) else Album - if kind is Item: + if kind == 'items': def truthy(v): # Avoid a Unicode warning by avoiding comparison # between a bytes object and the empty Unicode # string ''. return v is not None and \ - (v != '' if isinstance(v, unicode) else True) - fields = kind.all_keys() + (v != '' if isinstance(v, six.text_type) else True) + fields = Item.all_keys() key = lambda x: sum(1 for f in fields if truthy(getattr(x, f))) else: key = lambda x: len(x.items()) @@ -311,7 +311,7 @@ objs[0], displayable_path(o.path), displayable_path(missing.destination())) - missing.move(copy=True) + missing.move(operation=MoveOperation.COPY) return objs def _merge(self, objs): @@ -329,7 +329,7 @@ """Generate triples of keys, duplicate counts, and constituent objects. """ offset = 0 if full else 1 - for k, objs in self._group_by(objs, keys, strict).iteritems(): + for k, objs in self._group_by(objs, keys, strict).items(): if len(objs) > 1: objs = self._order(objs, tiebreak) if merge: diff -Nru beets-1.3.19/beetsplug/edit.py beets-1.4.6/beetsplug/edit.py --- beets-1.3.19/beetsplug/edit.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/edit.py 2017-11-01 23:12:35.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016 # @@ -22,11 +23,12 @@ from beets.dbcore import types from beets.importer import action from beets.ui.commands import _do_query, PromptChoice -from copy import deepcopy +import codecs import subprocess import yaml from tempfile import NamedTemporaryFile import os +import six # These "safe" types can avoid the format/parse cycle that most fields go @@ -82,7 +84,7 @@ # Convert all keys to strings. They started out as strings, # but the user may have inadvertently messed this up. - out.append({unicode(k): v for k, v in d.items()}) + out.append({six.text_type(k): v for k, v in d.items()}) except yaml.YAMLError as e: raise ParseError(u'invalid YAML: {}'.format(e)) @@ -141,7 +143,7 @@ else: # Either the field was stringified originally or the user changed # it from a safe type to an unsafe one. Parse it as a string. - obj.set_parse(key, unicode(value)) + obj.set_parse(key, six.text_type(value)) class EditPlugin(plugins.BeetsPlugin): @@ -242,9 +244,15 @@ old_data = [flatten(o, fields) for o in objs] # Set up a temporary file with the initial data for editing. - new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) + if six.PY2: + new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) + else: + new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False, + encoding='utf-8') old_str = dump(old_data) new.write(old_str) + if six.PY2: + old_str = old_str.decode('utf-8') new.close() # Loop until we have parseable data and the user confirms. @@ -255,7 +263,7 @@ # Read the data back after editing and check whether anything # changed. - with open(new.name) as f: + with codecs.open(new.name, encoding='utf-8') as f: new_str = f.read() if new_str == old_str: ui.print_(u"No changes; aborting.") @@ -274,7 +282,7 @@ # Show the changes. # If the objects are not on the DB yet, we need a copy of their # original state for show_model_changes. - objs_old = [deepcopy(obj) if not obj._db else None + objs_old = [obj.copy() if obj.id < 0 else None for obj in objs] self.apply_data(objs, old_data, new_data) changed = False @@ -293,9 +301,13 @@ elif choice == u'c': # Cancel. return False elif choice == u'e': # Keep editing. - # Reset the temporary changes to the objects. + # Reset the temporary changes to the objects. I we have a + # copy from above, use that, else reload from the database. + objs = [(old_obj or obj) + for old_obj, obj in zip(objs_old, objs)] for obj in objs: - obj.read() + if not obj.id < 0: + obj.load() continue # Remove the temporary file before returning. @@ -310,8 +322,8 @@ are temporary. """ if len(old_data) != len(new_data): - self._log.warn(u'number of objects changed from {} to {}', - len(old_data), len(new_data)) + self._log.warning(u'number of objects changed from {} to {}', + len(old_data), len(new_data)) obj_by_id = {o.id: o for o in objs} ignore_fields = self.config['ignore_fields'].as_str_seq() @@ -321,7 +333,7 @@ forbidden = False for key in ignore_fields: if old_dict.get(key) != new_dict.get(key): - self._log.warn(u'ignoring object whose {} changed', key) + self._log.warning(u'ignoring object whose {} changed', key) forbidden = True break if forbidden: @@ -356,9 +368,13 @@ """Callback for invoking the functionality during an interactive import session on the *original* item tags. """ - # Assign temporary ids to the Items. - for i, obj in enumerate(task.items): - obj.id = i + 1 + # Assign negative temporary ids to Items that are not in the database + # yet. By using negative values, no clash with items in the database + # can occur. + for i, obj in enumerate(task.items, start=1): + # The importer may set the id to None when re-importing albums. + if not obj._db or obj.id is None: + obj.id = -i # Present the YAML to the user and let her change it. fields = self._get_fields(album=False, extra=[]) @@ -366,7 +382,8 @@ # Remove temporary ids. for obj in task.items: - obj.id = None + if obj.id < 0: + obj.id = None # Save the new data. if success: diff -Nru beets-1.3.19/beetsplug/embedart.py beets-1.4.6/beetsplug/embedart.py --- beets-1.3.19/beetsplug/embedart.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/embedart.py 2017-10-29 19:52:50.000000000 +0000 @@ -20,13 +20,35 @@ from beets.plugins import BeetsPlugin from beets import ui -from beets.ui import decargs +from beets.ui import print_, decargs from beets.util import syspath, normpath, displayable_path, bytestring_path from beets.util.artresizer import ArtResizer from beets import config from beets import art +def _confirm(objs, album): + """Show the list of affected objects (items or albums) and confirm + that the user wants to modify their artwork. + + `album` is a Boolean indicating whether these are albums (as opposed + to items). + """ + noun = u'album' if album else u'file' + prompt = u'Modify artwork for {} {}{} (Y/n)?'.format( + len(objs), + noun, + u's' if len(objs) > 1 else u'' + ) + + # Show all the items or albums. + for obj in objs: + print_(format(obj)) + + # Confirm with user. + return ui.input_yn(prompt) + + class EmbedCoverArtPlugin(BeetsPlugin): """Allows albumart to be embedded into the actual files. """ @@ -60,6 +82,9 @@ embed_cmd.parser.add_option( u'-f', u'--file', metavar='PATH', help=u'the image file to embed' ) + embed_cmd.parser.add_option( + u"-y", u"--yes", action="store_true", help=u"skip confirmation" + ) maxwidth = self.config['maxwidth'].get(int) compare_threshold = self.config['compare_threshold'].get(int) ifempty = self.config['ifempty'].get(bool) @@ -71,11 +96,24 @@ raise ui.UserError(u'image file {0} not found'.format( displayable_path(imagepath) )) - for item in lib.items(decargs(args)): + + items = lib.items(decargs(args)) + + # Confirm with user. + if not opts.yes and not _confirm(items, not opts.file): + return + + for item in items: art.embed_item(self._log, item, imagepath, maxwidth, None, compare_threshold, ifempty) else: - for album in lib.albums(decargs(args)): + albums = lib.albums(decargs(args)) + + # Confirm with user. + if not opts.yes and not _confirm(albums, not opts.file): + return + + for album in albums: art.embed_album(self._log, album, maxwidth, False, compare_threshold, ifempty) self.remove_artfile(album) @@ -125,8 +163,15 @@ 'clearart', help=u'remove images from file metadata', ) + clear_cmd.parser.add_option( + u"-y", u"--yes", action="store_true", help=u"skip confirmation" + ) def clear_func(lib, opts, args): + items = lib.items(decargs(args)) + # Confirm with user. + if not opts.yes and not _confirm(items, False): + return art.clear(self._log, lib, decargs(args)) clear_cmd.func = clear_func diff -Nru beets-1.3.19/beetsplug/embyupdate.py beets-1.4.6/beetsplug/embyupdate.py --- beets-1.3.19/beetsplug/embyupdate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/embyupdate.py 2017-01-07 22:06:17.000000000 +0000 @@ -6,22 +6,51 @@ host: localhost port: 8096 username: user + apikey: apikey password: password """ from __future__ import division, absolute_import, print_function -from beets import config -from beets.plugins import BeetsPlugin -from urllib import urlencode -from urlparse import urljoin, parse_qs, urlsplit, urlunsplit import hashlib import requests +from six.moves.urllib.parse import urlencode +from six.moves.urllib.parse import urljoin, parse_qs, urlsplit, urlunsplit + +from beets import config +from beets.plugins import BeetsPlugin + def api_url(host, port, endpoint): """Returns a joined url. + + Takes host, port and endpoint and generates a valid emby API url. + + :param host: Hostname of the emby server + :param port: Portnumber of the emby server + :param endpoint: API endpoint + :type host: str + :type port: int + :type endpoint: str + :returns: Full API url + :rtype: str """ - joined = urljoin('http://{0}:{1}'.format(host, port), endpoint) + # check if http or https is defined as host and create hostname + hostname_list = [host] + if host.startswith('http://') or host.startswith('https://'): + hostname = ''.join(hostname_list) + else: + hostname_list.insert(0, 'http://') + hostname = ''.join(hostname_list) + + joined = urljoin( + '{hostname}:{port}'.format( + hostname=hostname, + port=port + ), + endpoint + ) + scheme, netloc, path, query_string, fragment = urlsplit(joined) query_params = parse_qs(query_string) @@ -33,6 +62,13 @@ def password_data(username, password): """Returns a dict with username and its encoded password. + + :param username: Emby username + :param password: Emby password + :type username: str + :type password: str + :returns: Dictionary with username and encoded password + :rtype: dict """ return { 'username': username, @@ -43,24 +79,45 @@ def create_headers(user_id, token=None): """Return header dict that is needed to talk to the Emby API. + + :param user_id: Emby user ID + :param token: Authentication token for Emby + :type user_id: str + :type token: str + :returns: Headers for requests + :rtype: dict """ - headers = { - 'Authorization': 'MediaBrowser', - 'UserId': user_id, - 'Client': 'other', - 'Device': 'empy', - 'DeviceId': 'beets', - 'Version': '0.0.0' - } + headers = {} + + authorization = ( + 'MediaBrowser UserId="{user_id}", ' + 'Client="other", ' + 'Device="beets", ' + 'DeviceId="beets", ' + 'Version="0.0.0"' + ).format(user_id=user_id) + + headers['x-emby-authorization'] = authorization if token: - headers['X-MediaBrowser-Token'] = token + headers['x-mediabrowser-token'] = token return headers def get_token(host, port, headers, auth_data): """Return token for a user. + + :param host: Emby host + :param port: Emby port + :param headers: Headers for requests + :param auth_data: Username and encoded password for authentication + :type host: str + :type port: int + :type headers: dict + :type auth_data: dict + :returns: Access Token + :rtype: str """ url = api_url(host, port, '/Users/AuthenticateByName') r = requests.post(url, headers=headers, data=auth_data) @@ -70,6 +127,15 @@ def get_user(host, port, username): """Return user dict from server or None if there is no user. + + :param host: Emby host + :param port: Emby port + :username: Username + :type host: str + :type port: int + :type username: str + :returns: Matched Users + :rtype: list """ url = api_url(host, port, '/Users/Public') r = requests.get(url) @@ -84,8 +150,10 @@ # Adding defaults. config['emby'].add({ - u'host': u'localhost', - u'port': 8096 + u'host': u'http://localhost', + u'port': 8096, + u'apikey': None, + u'password': None, }) self.register_listener('database_change', self.listen_for_db_change) @@ -104,6 +172,12 @@ port = config['emby']['port'].get() username = config['emby']['username'].get() password = config['emby']['password'].get() + token = config['emby']['apikey'].get() + + # Check if at least a apikey or password is given. + if not any([password, token]): + self._log.warning(u'Provide at least Emby password or apikey.') + return # Get user information from the Emby API. user = get_user(host, port, username) @@ -111,17 +185,18 @@ self._log.warning(u'User {0} could not be found.'.format(username)) return - # Create Authentication data and headers. - auth_data = password_data(username, password) - headers = create_headers(user[0]['Id']) - - # Get authentication token. - token = get_token(host, port, headers, auth_data) if not token: - self._log.warning( - u'Could not get token for user {0}', username - ) - return + # Create Authentication data and headers. + auth_data = password_data(username, password) + headers = create_headers(user[0]['Id']) + + # Get authentication token. + token = get_token(host, port, headers, auth_data) + if not token: + self._log.warning( + u'Could not get token for user {0}', username + ) + return # Recreate headers with a token. headers = create_headers(user[0]['Id'], token=token) diff -Nru beets-1.3.19/beetsplug/fetchart.py beets-1.4.6/beetsplug/fetchart.py --- beets-1.3.19/beetsplug/fetchart.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beetsplug/fetchart.py 2017-11-25 22:56:53.000000000 +0000 @@ -29,10 +29,11 @@ from beets import ui from beets import util from beets import config -from beets.mediafile import _image_mime_type +from beets.mediafile import image_mime_type from beets.util.artresizer import ArtResizer from beets.util import confit -from beets.util import syspath, bytestring_path +from beets.util import syspath, bytestring_path, py3_path +import six try: import itunes @@ -68,7 +69,7 @@ self.match = match self.size = size - def _validate(self, extra): + def _validate(self, plugin): """Determine whether the candidate artwork is valid based on its dimensions (width and ratio). @@ -79,9 +80,7 @@ if not self.path: return self.CANDIDATE_BAD - if not (extra['enforce_ratio'] or - extra['minwidth'] or - extra['maxwidth']): + if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth): return self.CANDIDATE_EXACT # get_size returns None if no local imaging backend is available @@ -100,22 +99,22 @@ long_edge = max(self.size) # Check minimum size. - if extra['minwidth'] and self.size[0] < extra['minwidth']: + if plugin.minwidth and self.size[0] < plugin.minwidth: self._log.debug(u'image too small ({} < {})', - self.size[0], extra['minwidth']) + self.size[0], plugin.minwidth) return self.CANDIDATE_BAD # Check aspect ratio. edge_diff = long_edge - short_edge - if extra['enforce_ratio']: - if extra['margin_px']: - if edge_diff > extra['margin_px']: + if plugin.enforce_ratio: + if plugin.margin_px: + if edge_diff > plugin.margin_px: self._log.debug(u'image is not close enough to being ' u'square, ({} - {} > {})', - long_edge, short_edge, extra['margin_px']) + long_edge, short_edge, plugin.margin_px) return self.CANDIDATE_BAD - elif extra['margin_percent']: - margin_px = extra['margin_percent'] * long_edge + elif plugin.margin_percent: + margin_px = plugin.margin_percent * long_edge if edge_diff > margin_px: self._log.debug(u'image is not close enough to being ' u'square, ({} - {} > {})', @@ -128,20 +127,20 @@ return self.CANDIDATE_BAD # Check maximum size. - if extra['maxwidth'] and self.size[0] > extra['maxwidth']: + if plugin.maxwidth and self.size[0] > plugin.maxwidth: self._log.debug(u'image needs resizing ({} > {})', - self.size[0], extra['maxwidth']) + self.size[0], plugin.maxwidth) return self.CANDIDATE_DOWNSCALE return self.CANDIDATE_EXACT - def validate(self, extra): - self.check = self._validate(extra) + def validate(self, plugin): + self.check = self._validate(plugin) return self.check - def resize(self, extra): - if extra['maxwidth'] and self.check == self.CANDIDATE_DOWNSCALE: - self.path = ArtResizer.shared.resize(extra['maxwidth'], self.path) + def resize(self, plugin): + if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE: + self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path) def _logged_get(log, *args, **kwargs): @@ -197,13 +196,13 @@ self._log = log self._config = config - def get(self, album, extra): + def get(self, album, plugin, paths): raise NotImplementedError() def _candidate(self, **kwargs): return Candidate(source=self, log=self._log, **kwargs) - def fetch_image(self, candidate, extra): + def fetch_image(self, candidate, plugin): raise NotImplementedError() @@ -211,7 +210,7 @@ IS_LOCAL = True LOC_STR = u'local' - def fetch_image(self, candidate, extra): + def fetch_image(self, candidate, plugin): pass @@ -219,13 +218,13 @@ IS_LOCAL = False LOC_STR = u'remote' - def fetch_image(self, candidate, extra): + def fetch_image(self, candidate, plugin): """Downloads an image from a URL and checks whether it seems to actually be an image. If so, returns a path to the downloaded image. Otherwise, returns None. """ - if extra['maxwidth']: - candidate.url = ArtResizer.shared.proxy_url(extra['maxwidth'], + if plugin.maxwidth: + candidate.url = ArtResizer.shared.proxy_url(plugin.maxwidth, candidate.url) try: with closing(self.request(candidate.url, stream=True, @@ -249,7 +248,7 @@ # server didn't return enough data, i.e. corrupt image return - real_ct = _image_mime_type(header) + real_ct = image_mime_type(header) if real_ct is None: # detection by file magic failed, fall back to the # server-supplied Content-Type @@ -264,12 +263,13 @@ ext = b'.' + CONTENT_TYPES[real_ct][0] if real_ct != ct: - self._log.warn(u'Server specified {}, but returned a ' - u'{} image. Correcting the extension ' - u'to {}', - ct, real_ct, ext) + self._log.warning(u'Server specified {}, but returned a ' + u'{} image. Correcting the extension ' + u'to {}', + ct, real_ct, ext) - with NamedTemporaryFile(suffix=ext, delete=False) as fh: + suffix = py3_path(ext) + with NamedTemporaryFile(suffix=suffix, delete=False) as fh: # write the first already loaded part of the image fh.write(header) # download the remaining part of the image @@ -290,10 +290,14 @@ class CoverArtArchive(RemoteArtSource): NAME = u"Cover Art Archive" - URL = 'http://coverartarchive.org/release/{mbid}/front' - GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front' + if util.SNI_SUPPORTED: + URL = 'https://coverartarchive.org/release/{mbid}/front' + GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front' + else: + URL = 'http://coverartarchive.org/release/{mbid}/front' + GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front' - def get(self, album, extra): + def get(self, album, plugin, paths): """Return the Cover Art Archive and Cover Art Archive release group URLs using album MusicBrainz release ID and release group ID. """ @@ -311,7 +315,7 @@ URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' INDICES = (1, 2) - def get(self, album, extra): + def get(self, album, plugin, paths): """Generate URLs using Amazon ID (ASIN) string. """ if album.asin: @@ -325,7 +329,7 @@ URL = 'http://www.albumart.org/index_detail.php' PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' - def get(self, album, extra): + def get(self, album, plugin, paths): """Return art URL from AlbumArt.org using album ASIN. """ if not album.asin: @@ -356,7 +360,7 @@ self.key = self._config['google_key'].get(), self.cx = self._config['google_engine'].get(), - def get(self, album, extra): + def get(self, album, plugin, paths): """Return art URL from google custom search engine given an album title and interpreter. """ @@ -392,8 +396,7 @@ class FanartTV(RemoteArtSource): """Art from fanart.tv requested using their API""" NAME = u"fanart.tv" - - API_URL = 'http://webservice.fanart.tv/v3/' + API_URL = 'https://webservice.fanart.tv/v3/' API_ALBUMS = API_URL + 'music/albums/' PROJECT_KEY = '61a7d0ab4e67162b7a0c7c35915cd48e' @@ -401,7 +404,7 @@ super(FanartTV, self).__init__(*args, **kwargs) self.client_key = self._config['fanarttv_key'].get() - def get(self, album, extra): + def get(self, album, plugin, paths): if not album.mb_releasegroupid: return @@ -452,7 +455,7 @@ class ITunesStore(RemoteArtSource): NAME = u"iTunes Store" - def get(self, album, extra): + def get(self, album, plugin, paths): """Return art URL from iTunes Store given an album title. """ if not (album.albumartist and album.album): @@ -486,8 +489,8 @@ class Wikipedia(RemoteArtSource): NAME = u"Wikipedia (queried through DBpedia)" - DBPEDIA_URL = 'http://dbpedia.org/sparql' - WIKIPEDIA_URL = 'http://en.wikipedia.org/w/api.php' + DBPEDIA_URL = 'https://dbpedia.org/sparql' + WIKIPEDIA_URL = 'https://en.wikipedia.org/w/api.php' SPARQL_QUERY = u'''PREFIX rdf: PREFIX dbpprop: PREFIX owl: @@ -510,7 +513,7 @@ }} Limit 1''' - def get(self, album, extra): + def get(self, album, plugin, paths): if not (album.albumartist and album.album): return @@ -600,7 +603,7 @@ try: data = wikipedia_response.json() results = data['query']['pages'] - for _, result in results.iteritems(): + for _, result in results.items(): image_url = result['imageinfo'][0]['url'] yield self._candidate(url=image_url, match=Candidate.MATCH_EXACT) @@ -622,16 +625,14 @@ """ return [idx for (idx, x) in enumerate(cover_names) if x in filename] - def get(self, album, extra): + def get(self, album, plugin, paths): """Look for album art files in the specified directories. """ - paths = extra['paths'] if not paths: return - cover_names = list(map(util.bytestring_path, extra['cover_names'])) + cover_names = list(map(util.bytestring_path, plugin.cover_names)) cover_names_str = b'|'.join(cover_names) cover_pat = br''.join([br"(\b|_)(", cover_names_str, br")(\b|_)"]) - cautious = extra['cautious'] for path in paths: if not os.path.isdir(syspath(path)): @@ -661,7 +662,7 @@ remaining.append(fn) # Fall back to any image in the folder. - if remaining and not cautious: + if remaining and not plugin.cautious: self._log.debug(u'using fallback art file {0}', util.displayable_path(remaining[0])) yield self._candidate(path=os.path.join(path, remaining[0]), @@ -727,7 +728,7 @@ confit.String(pattern=self.PAT_PERCENT)])) self.margin_px = None self.margin_percent = None - if type(self.enforce_ratio) is unicode: + if type(self.enforce_ratio) is six.text_type: if self.enforce_ratio[-1] == u'%': self.margin_percent = float(self.enforce_ratio[:-1]) / 100 elif self.enforce_ratio[-2:] == u'px': @@ -761,7 +762,8 @@ if 'remote_priority' in self.config: self._log.warning( u'The `fetch_art.remote_priority` configuration option has ' - u'been deprecated, see the documentation.') + u'been deprecated. Instead, place `filesystem` at the end of ' + u'your `sources` list.') if self.config['remote_priority'].get(bool): try: sources_name.remove(u'filesystem') @@ -781,7 +783,8 @@ if task.choice_flag == importer.action.ASIS: # For as-is imports, don't search Web sources for art. local = True - elif task.choice_flag == importer.action.APPLY: + elif task.choice_flag in (importer.action.APPLY, + importer.action.RETAG): # Search everywhere for art. local = False else: @@ -822,9 +825,15 @@ action='store_true', default=False, help=u're-download art when already present' ) + cmd.parser.add_option( + u'-q', u'--quiet', dest='quiet', + action='store_true', default=False, + help=u'shows only quiet art' + ) def func(lib, opts, args): - self.batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force) + self.batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force, + opts.quiet) cmd.func = func return [cmd] @@ -839,16 +848,6 @@ """ out = None - # all the information any of the sources might need - extra = {'paths': paths, - 'cover_names': self.cover_names, - 'cautious': self.cautious, - 'enforce_ratio': self.enforce_ratio, - 'margin_px': self.margin_px, - 'margin_percent': self.margin_percent, - 'minwidth': self.minwidth, - 'maxwidth': self.maxwidth} - for source in self.sources: if source.IS_LOCAL or not local_only: self._log.debug( @@ -858,9 +857,9 @@ ) # URLs might be invalid at this point, or the image may not # fulfill the requirements - for candidate in source.get(album, extra): - source.fetch_image(candidate, extra) - if candidate.validate(extra): + for candidate in source.get(album, self, paths): + source.fetch_image(candidate, self) + if candidate.validate(self): out = candidate self._log.debug( u'using {0.LOC_STR} image {1}'.format( @@ -870,17 +869,20 @@ break if out: - out.resize(extra) + out.resize(self) return out - def batch_fetch_art(self, lib, albums, force): + def batch_fetch_art(self, lib, albums, force, quiet): """Fetch album art for each of the albums. This implements the manual fetchart CLI command. """ for album in albums: if album.artpath and not force and os.path.isfile(album.artpath): - message = ui.colorize('text_highlight_minor', u'has album art') + if not quiet: + message = ui.colorize('text_highlight_minor', + u'has album art') + self._log.info(u'{0}: {1}', album, message) else: # In ordinary invocations, look for images on the # filesystem. When forcing, however, always go to the Web @@ -893,5 +895,4 @@ message = ui.colorize('text_success', u'found album art') else: message = ui.colorize('text_error', u'no art found') - - self._log.info(u'{0}: {1}', album, message) + self._log.info(u'{0}: {1}', album, message) diff -Nru beets-1.3.19/beetsplug/fromfilename.py beets-1.4.6/beetsplug/fromfilename.py --- beets-1.3.19/beetsplug/fromfilename.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/fromfilename.py 2017-12-21 16:09:27.000000000 +0000 @@ -22,34 +22,26 @@ from beets.util import displayable_path import os import re +import six # Filename field extraction patterns. PATTERNS = [ - # "01 - Track 01" and "01": do nothing - r'^(\d+)\s*-\s*track\s*\d$', - r'^\d+$', - - # Useful patterns. - r'^(?P.+)-(?P.+)-(?P<tag>.*)$', - r'^(?P<track>\d+)\s*-(?P<artist>.+)-(?P<title>.+)-(?P<tag>.*)$', - r'^(?P<track>\d+)\s(?P<artist>.+)-(?P<title>.+)-(?P<tag>.*)$', - r'^(?P<artist>.+)-(?P<title>.+)$', - r'^(?P<track>\d+)\.\s*(?P<artist>.+)-(?P<title>.+)$', - r'^(?P<track>\d+)\s*-\s*(?P<artist>.+)-(?P<title>.+)$', - r'^(?P<track>\d+)\s*-(?P<artist>.+)-(?P<title>.+)$', - r'^(?P<track>\d+)\s(?P<artist>.+)-(?P<title>.+)$', - r'^(?P<title>.+)$', - r'^(?P<track>\d+)\.\s*(?P<title>.+)$', - r'^(?P<track>\d+)\s*-\s*(?P<title>.+)$', - r'^(?P<track>\d+)\s(?P<title>.+)$', - r'^(?P<title>.+) by (?P<artist>.+)$', + # Useful patterns. + r'^(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$', + r'^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$', + r'^(?P<artist>.+)[\-_](?P<title>.+)$', + r'^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)$', + r'^(?P<title>.+)$', + r'^(?P<track>\d+)[\s.\-_]+(?P<title>.+)$', + r'^(?P<track>\d+)\s+(?P<title>.+)$', + r'^(?P<title>.+) by (?P<artist>.+)$', + r'^(?P<track>\d+).*$', ] # Titles considered "empty" and in need of replacement. BAD_TITLE_PATTERNS = [ r'^$', - r'\d+?\s?-?\s*track\s*\d+', ] @@ -100,7 +92,7 @@ """Given a mapping from items to field dicts, apply the fields to the objects. """ - some_map = d.values()[0] + some_map = list(d.values())[0] keys = some_map.keys() # Only proceed if the "tag" field is equal across all filenames. @@ -132,7 +124,7 @@ # Apply the title and track. for item in d: if bad_title(item.title): - item.title = unicode(d[item][title_field]) + item.title = six.text_type(d[item][title_field]) if 'track' in d[item] and item.track == 0: item.track = int(d[item]['track']) diff -Nru beets-1.3.19/beetsplug/ftintitle.py beets-1.4.6/beetsplug/ftintitle.py --- beets-1.3.19/beetsplug/ftintitle.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/ftintitle.py 2017-06-14 23:13:48.000000000 +0000 @@ -49,29 +49,28 @@ """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ - feat_part = None - # Look for the album artist in the artist field. If it's not # present, give up. albumartist_split = artist.split(albumartist, 1) if len(albumartist_split) <= 1: - return feat_part + return None # If the last element of the split (the right-hand side of the # album artist) is nonempty, then it probably contains the # featured artist. - elif albumartist_split[-1] != '': + elif albumartist_split[1] != '': # Extract the featured artist from the right-hand side. - _, feat_part = split_on_feat(albumartist_split[-1]) + _, feat_part = split_on_feat(albumartist_split[1]) + return feat_part # Otherwise, if there's nothing on the right-hand side, look for a # featuring artist on the left-hand side. else: lhs, rhs = split_on_feat(albumartist_split[0]) if lhs: - feat_part = lhs + return lhs - return feat_part + return None class FtInTitlePlugin(plugins.BeetsPlugin): @@ -137,7 +136,7 @@ # Only update the title if it does not already contain a featured # artist and if we do not drop featuring information. if not drop_feat and not contains_feat(item.title): - feat_format = self.config['format'].get(unicode) + feat_format = self.config['format'].as_str() new_format = feat_format.format(feat_part) new_title = u"{0} {1}".format(item.title, new_format) self._log.info(u'title: {0} -> {1}', item.title, new_title) diff -Nru beets-1.3.19/beetsplug/fuzzy.py beets-1.4.6/beetsplug/fuzzy.py --- beets-1.3.19/beetsplug/fuzzy.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/fuzzy.py 2016-12-17 03:01:22.000000000 +0000 @@ -44,5 +44,5 @@ }) def queries(self): - prefix = self.config['prefix'].get(basestring) + prefix = self.config['prefix'].as_str() return {prefix: FuzzyQuery} diff -Nru beets-1.3.19/beetsplug/gmusic.py beets-1.4.6/beetsplug/gmusic.py --- beets-1.3.19/beetsplug/gmusic.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/beetsplug/gmusic.py 2017-08-20 17:03:37.000000000 +0000 @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2017, Tigran Kostandyan. +# +# 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. + +"""Upload files to Google Play Music and list songs in its library.""" + +from __future__ import absolute_import, division, print_function +import os.path + +from beets.plugins import BeetsPlugin +from beets import ui +from beets import config +from beets.ui import Subcommand +from gmusicapi import Musicmanager, Mobileclient +from gmusicapi.exceptions import NotLoggedIn +import gmusicapi.clients + + +class Gmusic(BeetsPlugin): + def __init__(self): + super(Gmusic, self).__init__() + # Checks for OAuth2 credentials, + # if they don't exist - performs authorization + self.m = Musicmanager() + if os.path.isfile(gmusicapi.clients.OAUTH_FILEPATH): + self.m.login() + else: + self.m.perform_oauth() + + def commands(self): + gupload = Subcommand('gmusic-upload', + help=u'upload your tracks to Google Play Music') + gupload.func = self.upload + + search = Subcommand('gmusic-songs', + help=u'list of songs in Google Play Music library' + ) + search.parser.add_option('-t', '--track', dest='track', + action='store_true', + help='Search by track name') + search.parser.add_option('-a', '--artist', dest='artist', + action='store_true', + help='Search by artist') + search.func = self.search + return [gupload, search] + + def upload(self, lib, opts, args): + items = lib.items(ui.decargs(args)) + files = [x.path.decode('utf-8') for x in items] + ui.print_(u'Uploading your files...') + self.m.upload(filepaths=files) + ui.print_(u'Your files were successfully added to library') + + def search(self, lib, opts, args): + password = config['gmusic']['password'] + email = config['gmusic']['email'] + password.redact = True + email.redact = True + # Since Musicmanager doesn't support library management + # we need to use mobileclient interface + mobile = Mobileclient() + try: + mobile.login(email.as_str(), password.as_str(), + Mobileclient.FROM_MAC_ADDRESS) + files = mobile.get_all_songs() + except NotLoggedIn: + ui.print_( + u'Authentication error. Please check your email and password.' + ) + return + if not args: + for i, file in enumerate(files, start=1): + print(i, ui.colorize('blue', file['artist']), + file['title'], ui.colorize('red', file['album'])) + else: + if opts.track: + self.match(files, args, 'title') + else: + self.match(files, args, 'artist') + + @staticmethod + def match(files, args, search_by): + for file in files: + if ' '.join(ui.decargs(args)) in file[search_by]: + print(file['artist'], file['title'], file['album']) diff -Nru beets-1.3.19/beetsplug/hook.py beets-1.4.6/beetsplug/hook.py --- beets-1.3.19/beetsplug/hook.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/hook.py 2017-11-25 22:56:53.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2015, Adrian Sampson. # @@ -17,15 +18,20 @@ import string import subprocess +import six from beets.plugins import BeetsPlugin -from beets.ui import _arg_encoding -from beets.util import shlex_split +from beets.util import shlex_split, arg_encoding class CodingFormatter(string.Formatter): - """A custom string formatter that decodes the format string and it's - fields. + """A variant of `string.Formatter` that converts everything to `unicode` + strings. + + This is necessary on Python 2, where formatting otherwise occurs on + bytestrings. It intercepts two points in the formatting process to decode + the format string and all fields using the specified encoding. If decoding + fails, the values are used as-is. """ def __init__(self, coding): @@ -57,10 +63,9 @@ """ converted = super(CodingFormatter, self).convert_field(value, conversion) - try: - converted = converted.decode(self._coding) - except UnicodeEncodeError: - pass + + if isinstance(converted, bytes): + return converted.decode(self._coding) return converted @@ -79,8 +84,8 @@ for hook_index in range(len(hooks)): hook = self.config['hooks'][hook_index] - hook_event = hook['event'].get(unicode) - hook_command = hook['command'].get(unicode) + hook_event = hook['event'].as_str() + hook_command = hook['command'].as_str() self.create_and_register_hook(hook_event, hook_command) @@ -90,7 +95,12 @@ self._log.error('invalid command "{0}"', command) return - formatter = CodingFormatter(_arg_encoding()) + # Use a string formatter that works on Unicode strings. + if six.PY2: + formatter = CodingFormatter(arg_encoding()) + else: + formatter = string.Formatter() + command_pieces = shlex_split(command) for i, piece in enumerate(command_pieces): diff -Nru beets-1.3.19/beetsplug/importadded.py beets-1.4.6/beetsplug/importadded.py --- beets-1.3.19/beetsplug/importadded.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/importadded.py 2017-06-14 23:13:48.000000000 +0000 @@ -36,6 +36,7 @@ register('before_item_moved', self.record_import_mtime) register('item_copied', self.record_import_mtime) register('item_linked', self.record_import_mtime) + register('item_hardlinked', self.record_import_mtime) register('album_imported', self.update_album_times) register('item_imported', self.update_item_times) register('after_write', self.update_after_write_time) @@ -51,7 +52,7 @@ def record_if_inplace(self, task, session): if not (session.config['copy'] or session.config['move'] or - session.config['link']): + session.config['link'] or session.config['hardlink']): self._log.debug(u"In place import detected, recording mtimes from " u"source paths") items = [task.item] \ @@ -62,7 +63,7 @@ def record_reimported(self, task, session): self.reimported_item_ids = set(item.id for item, replaced_items - in task.replaced_items.iteritems() + in task.replaced_items.items() if replaced_items) self.replaced_album_paths = set(task.replaced_albums.keys()) diff -Nru beets-1.3.19/beetsplug/importfeeds.py beets-1.4.6/beetsplug/importfeeds.py --- beets-1.3.19/beetsplug/importfeeds.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/importfeeds.py 2017-08-20 16:51:32.000000000 +0000 @@ -24,26 +24,12 @@ import re from beets.plugins import BeetsPlugin -from beets.util import mkdirall, normpath, syspath, bytestring_path +from beets.util import mkdirall, normpath, syspath, bytestring_path, link from beets import config M3U_DEFAULT_NAME = 'imported.m3u' -def _get_feeds_dir(lib): - """Given a Library object, return the path to the feeds directory to be - used (either in the library directory or an explicitly configured - path). Ensures that the directory exists. - """ - # Inside library directory. - dirpath = lib.directory - - # Ensure directory exists. - if not os.path.exists(syspath(dirpath)): - os.makedirs(syspath(dirpath)) - return dirpath - - def _build_m3u_filename(basename): """Builds unique m3u filename by appending given basename to current date.""" @@ -78,30 +64,28 @@ 'absolute_path': False, }) - feeds_dir = self.config['dir'].get() - if feeds_dir: - feeds_dir = os.path.expanduser(bytestring_path(feeds_dir)) - self.config['dir'] = feeds_dir - if not os.path.exists(syspath(feeds_dir)): - os.makedirs(syspath(feeds_dir)) - relative_to = self.config['relative_to'].get() if relative_to: self.config['relative_to'] = normpath(relative_to) else: - self.config['relative_to'] = feeds_dir + self.config['relative_to'] = self.get_feeds_dir() - self.register_listener('library_opened', self.library_opened) self.register_listener('album_imported', self.album_imported) self.register_listener('item_imported', self.item_imported) + def get_feeds_dir(self): + feeds_dir = self.config['dir'].get() + if feeds_dir: + return os.path.expanduser(bytestring_path(feeds_dir)) + return config['directory'].as_filename() + def _record_items(self, lib, basename, items): """Records relative paths to the given items for each feed format """ - feedsdir = bytestring_path(self.config['dir'].as_filename()) + feedsdir = bytestring_path(self.get_feeds_dir()) formats = self.config['formats'].as_str_seq() relative_to = self.config['relative_to'].get() \ - or self.config['dir'].as_filename() + or self.get_feeds_dir() relative_to = bytestring_path(relative_to) paths = [] @@ -119,7 +103,7 @@ if 'm3u' in formats: m3u_basename = bytestring_path( - self.config['m3u_name'].get(unicode)) + self.config['m3u_name'].as_str()) m3u_path = os.path.join(feedsdir, m3u_basename) _write_m3u(m3u_path, paths) @@ -131,17 +115,13 @@ for path in paths: dest = os.path.join(feedsdir, os.path.basename(path)) if not os.path.exists(syspath(dest)): - os.symlink(syspath(path), syspath(dest)) + link(path, dest) if 'echo' in formats: self._log.info(u"Location of imported music:") for path in paths: self._log.info(u" {0}", path) - def library_opened(self, lib): - if self.config['dir'].get() is None: - self.config['dir'] = _get_feeds_dir(lib) - def album_imported(self, lib, album): self._record_items(lib, album.album, album.items()) diff -Nru beets-1.3.19/beetsplug/info.py beets-1.4.6/beetsplug/info.py --- beets-1.3.19/beetsplug/info.py 2016-06-20 17:08:57.000000000 +0000 +++ beets-1.4.6/beetsplug/info.py 2016-12-17 03:01:22.000000000 +0000 @@ -73,7 +73,7 @@ def update_summary(summary, tags): - for key, value in tags.iteritems(): + for key, value in tags.items(): if key not in summary: summary[key] = value elif summary[key] != value: @@ -96,7 +96,7 @@ path = displayable_path(item.path) if item else None formatted = {} - for key, value in data.iteritems(): + for key, value in data.items(): if isinstance(value, list): formatted[key] = u'; '.join(value) if value is not None: @@ -123,7 +123,7 @@ """ path = displayable_path(item.path) if item else None formatted = [] - for key, value in data.iteritems(): + for key, value in data.items(): formatted.append(key) if len(formatted) == 0: @@ -204,7 +204,8 @@ if opts.keys_only: print_data_keys(data, item) else: - print_data(data, item, opts.format) + fmt = ui.decargs([opts.format])[0] if opts.format else None + print_data(data, item, fmt) first = False if opts.summarize: diff -Nru beets-1.3.19/beetsplug/inline.py beets-1.4.6/beetsplug/inline.py --- beets-1.3.19/beetsplug/inline.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/inline.py 2016-12-17 03:01:22.000000000 +0000 @@ -22,6 +22,7 @@ from beets.plugins import BeetsPlugin from beets import config +import six FUNC_NAME = u'__INLINE_FUNC__' @@ -32,7 +33,7 @@ def __init__(self, code, exc): super(InlineError, self).__init__( (u"error in inline path field code:\n" - u"%s\n%s: %s") % (code, type(exc).__name__, unicode(exc)) + u"%s\n%s: %s") % (code, type(exc).__name__, six.text_type(exc)) ) @@ -64,14 +65,14 @@ for key, view in itertools.chain(config['item_fields'].items(), config['pathfields'].items()): self._log.debug(u'adding item field {0}', key) - func = self.compile_inline(view.get(unicode), False) + func = self.compile_inline(view.as_str(), False) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config['album_fields'].items(): self._log.debug(u'adding album field {0}', key) - func = self.compile_inline(view.get(unicode), True) + func = self.compile_inline(view.as_str(), True) if func is not None: self.album_template_fields[key] = func diff -Nru beets-1.3.19/beetsplug/ipfs.py beets-1.4.6/beetsplug/ipfs.py --- beets-1.3.19/beetsplug/ipfs.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/ipfs.py 2016-12-17 03:01:22.000000000 +0000 @@ -272,9 +272,11 @@ break except AttributeError: pass + item_path = os.path.basename(item.path).decode( + util._fsencoding(), 'ignore' + ) # Clear current path from item - item.path = '/ipfs/{0}/{1}'.format(album.ipfs, - os.path.basename(item.path)) + item.path = '/ipfs/{0}/{1}'.format(album.ipfs, item_path) item.id = None items.append(item) diff -Nru beets-1.3.19/beetsplug/keyfinder.py beets-1.4.6/beetsplug/keyfinder.py --- beets-1.3.19/beetsplug/keyfinder.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/keyfinder.py 2016-12-17 03:01:22.000000000 +0000 @@ -52,14 +52,14 @@ def find_key(self, items, write=False): overwrite = self.config['overwrite'].get(bool) - bin = util.bytestring_path(self.config['bin'].get(unicode)) + bin = self.config['bin'].as_str() for item in items: if item['initial_key'] and not overwrite: continue try: - output = util.command_output([bin, b'-f', + output = util.command_output([bin, '-f', util.syspath(item.path)]) except (subprocess.CalledProcessError, OSError) as exc: self._log.error(u'execution failed: {0}', exc) @@ -73,7 +73,7 @@ key_raw = output.rsplit(None, 1)[-1] try: - key = key_raw.decode('utf8') + key = util.text_string(key_raw) except UnicodeDecodeError: self._log.error(u'output is invalid UTF-8') continue diff -Nru beets-1.3.19/beetsplug/kodiupdate.py beets-1.4.6/beetsplug/kodiupdate.py --- beets-1.3.19/beetsplug/kodiupdate.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/beetsplug/kodiupdate.py 2017-08-26 15:13:02.000000000 +0000 @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2017, Pauli Kettunen. +# +# 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. + +"""Updates a Kodi library whenever the beets library is changed. +This is based on the Plex Update plugin. + +Put something like the following in your config.yaml to configure: + kodi: + host: localhost + port: 8080 + user: user + pwd: secret +""" +from __future__ import division, absolute_import, print_function + +import requests +from beets import config +from beets.plugins import BeetsPlugin +import six + + +def update_kodi(host, port, user, password): + """Sends request to the Kodi api to start a library refresh. + """ + url = "http://{0}:{1}/jsonrpc".format(host, port) + + """Content-Type: application/json is mandatory + according to the kodi jsonrpc documentation""" + + headers = {'Content-Type': 'application/json'} + + # Create the payload. Id seems to be mandatory. + payload = {'jsonrpc': '2.0', 'method': 'AudioLibrary.Scan', 'id': 1} + r = requests.post( + url, + auth=(user, password), + json=payload, + headers=headers) + + return r + + +class KodiUpdate(BeetsPlugin): + def __init__(self): + super(KodiUpdate, self).__init__() + + # Adding defaults. + config['kodi'].add({ + u'host': u'localhost', + u'port': 8080, + u'user': u'kodi', + u'pwd': u'kodi'}) + + config['kodi']['pwd'].redact = True + self.register_listener('database_change', self.listen_for_db_change) + + def listen_for_db_change(self, lib, model): + """Listens for beets db change and register the update""" + self.register_listener('cli_exit', self.update) + + def update(self, lib): + """When the client exists try to send refresh request to Kodi server. + """ + self._log.info(u'Requesting a Kodi library update...') + + # Try to send update request. + try: + r = update_kodi( + config['kodi']['host'].get(), + config['kodi']['port'].get(), + config['kodi']['user'].get(), + config['kodi']['pwd'].get()) + r.raise_for_status() + + except requests.exceptions.RequestException as e: + self._log.warning(u'Kodi update failed: {0}', + six.text_type(e)) + return + + json = r.json() + if json.get('result') != 'OK': + self._log.warning(u'Kodi update failed: JSON response was {0!r}', + json) + return + + self._log.info(u'Kodi update triggered') diff -Nru beets-1.3.19/beetsplug/lastgenre/__init__.py beets-1.4.6/beetsplug/lastgenre/__init__.py --- beets-1.3.19/beetsplug/lastgenre/__init__.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/lastgenre/__init__.py 2017-10-29 00:24:53.000000000 +0000 @@ -14,6 +14,7 @@ # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function +import six """Gets genres for imported music based on Last.fm tags. @@ -24,6 +25,7 @@ https://gist.github.com/1241307 """ import pylast +import codecs import os import yaml import traceback @@ -71,7 +73,7 @@ for sub in elem: flatten_tree(sub, path, branches) else: - branches.append(path + [unicode(elem)]) + branches.append(path + [six.text_type(elem)]) def find_parents(candidate, branches): @@ -107,6 +109,7 @@ 'force': True, 'auto': True, 'separator': u', ', + 'prefer_specific': False, }) self.setup() @@ -128,7 +131,7 @@ wl_filename = normpath(wl_filename) with open(wl_filename, 'rb') as f: for line in f: - line = line.decode('utf8').strip().lower() + line = line.decode('utf-8').strip().lower() if line and not line.startswith(u'#'): self.whitelist.add(line) @@ -139,7 +142,8 @@ c14n_filename = C14N_TREE if c14n_filename: c14n_filename = normpath(c14n_filename) - genres_tree = yaml.load(open(c14n_filename, 'r')) + with codecs.open(c14n_filename, 'r', encoding='utf-8') as f: + genres_tree = yaml.load(f) flatten_tree(genres_tree, [], self.c14n_branches) @property @@ -155,6 +159,25 @@ elif source == 'artist': return 'artist', + def _get_depth(self, tag): + """Find the depth of a tag in the genres tree. + """ + depth = None + for key, value in enumerate(self.c14n_branches): + if tag in value: + depth = value.index(tag) + break + return depth + + def _sort_by_depth(self, tags): + """Given a list of tags, sort the tags by their depths in the + genre tree. + """ + depth_tag_pairs = [(self._get_depth(t), t) for t in tags] + depth_tag_pairs = [e for e in depth_tag_pairs if e[0] is not None] + depth_tag_pairs.sort(reverse=True) + return [p[1] for p in depth_tag_pairs] + def _resolve_genres(self, tags): """Given a list of strings, return a genre by joining them into a single string and (optionally) canonicalizing each. @@ -176,17 +199,24 @@ parents = [find_parents(tag, self.c14n_branches)[-1]] tags_all += parents - if len(tags_all) >= count: + # Stop if we have enough tags already, unless we need to find + # the most specific tag (instead of the most popular). + if (not self.config['prefer_specific'] and + len(tags_all) >= count): break tags = tags_all tags = deduplicate(tags) + # Sort the tags by specificity. + if self.config['prefer_specific']: + tags = self._sort_by_depth(tags) + # c14n only adds allowed genres but we may have had forbidden genres in # the original tags list tags = [x.title() for x in tags if self._is_allowed(x)] - return self.config['separator'].get(unicode).join( + return self.config['separator'].as_str().join( tags[:self.config['count'].get(int)] ) @@ -221,7 +251,8 @@ if any(not s for s in args): return None - key = u'{0}.{1}'.format(entity, u'-'.join(unicode(a) for a in args)) + key = u'{0}.{1}'.format(entity, + u'-'.join(six.text_type(a) for a in args)) if key in self._genre_cache: return self._genre_cache[key] else: @@ -297,7 +328,7 @@ result = None if isinstance(obj, library.Item): result = self.fetch_artist_genre(obj) - elif obj.albumartist != config['va_name'].get(unicode): + elif obj.albumartist != config['va_name'].as_str(): result = self.fetch_album_artist_genre(obj) else: # For "Various Artists", pick the most popular track genre. @@ -400,7 +431,7 @@ """ # Work around an inconsistency in pylast where # Album.get_top_tags() does not return TopItem instances. - # https://code.google.com/p/pylast/issues/detail?id=85 + # https://github.com/pylast/pylast/issues/86 if isinstance(obj, pylast.Album): obj = super(pylast.Album, obj) diff -Nru beets-1.3.19/beetsplug/lastimport.py beets-1.4.6/beetsplug/lastimport.py --- beets-1.3.19/beetsplug/lastimport.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/lastimport.py 2017-01-03 01:54:17.000000000 +0000 @@ -23,7 +23,7 @@ from beets import plugins from beets.dbcore import types -API_URL = 'http://ws.audioscrobbler.com/2.0/' +API_URL = 'https://ws.audioscrobbler.com/2.0/' class LastImportPlugin(plugins.BeetsPlugin): @@ -110,7 +110,7 @@ def import_lastfm(lib, log): - user = config['lastfm']['user'].get(unicode) + user = config['lastfm']['user'].as_str() per_page = config['lastimport']['per_page'].get(int) if not user: @@ -192,7 +192,7 @@ total_fails = 0 log.info(u'Received {0} tracks in this page, processing...', total) - for num in xrange(0, total): + for num in range(0, total): song = None trackid = tracks[num]['mbid'].strip() artist = tracks[num]['artist'].get('name', '').strip() diff -Nru beets-1.3.19/beetsplug/lyrics.py beets-1.4.6/beetsplug/lyrics.py --- beets-1.3.19/beetsplug/lyrics.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/lyrics.py 2017-10-29 19:52:50.000000000 +0000 @@ -19,14 +19,18 @@ from __future__ import absolute_import, division, print_function import difflib +import errno import itertools import json +import struct +import os.path import re import requests import unicodedata -import urllib +from unidecode import unidecode import warnings -from HTMLParser import HTMLParseError +import six +from six.moves import urllib try: from bs4 import SoupStrainer, BeautifulSoup @@ -40,9 +44,18 @@ except ImportError: HAS_LANGDETECT = False +try: + # PY3: HTMLParseError was removed in 3.5 as strict mode + # was deprecated in 3.3. + # https://docs.python.org/3.3/library/html.parser.html + from six.moves.html_parser import HTMLParseError +except ImportError: + class HTMLParseError(Exception): + pass + from beets import plugins from beets import ui - +import beets DIV_RE = re.compile(r'<(/?)div>?', re.I) COMMENT_RE = re.compile(r'<!--.*-->', re.S) @@ -62,20 +75,62 @@ u'\u2016': u'-', u'\u2026': u'...', } +USER_AGENT = 'beets/{}'.format(beets.__version__) + +# The content for the base index.rst generated in ReST mode. +REST_INDEX_TEMPLATE = u'''Lyrics +====== + +* :ref:`Song index <genindex>` +* :ref:`search` + +Artist index: + +.. toctree:: + :maxdepth: 1 + :glob: + + artists/* +''' + +# The content for the base conf.py generated. +REST_CONF_TEMPLATE = u'''# -*- coding: utf-8 -*- +master_doc = 'index' +project = u'Lyrics' +copyright = u'none' +author = u'Various Authors' +latex_documents = [ + (master_doc, 'Lyrics.tex', project, + author, 'manual'), +] +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright +epub_exclude_files = ['search.html'] +epub_tocdepth = 1 +epub_tocdup = False +''' # Utilities. +def unichar(i): + try: + return six.unichr(i) + except ValueError: + return struct.pack('i', i).decode('utf-32') + def unescape(text): """Resolve &#xxx; HTML entities (and some others).""" if isinstance(text, bytes): - text = text.decode('utf8', 'ignore') + text = text.decode('utf-8', 'ignore') out = text.replace(u' ', u' ') def replchar(m): num = m.group(1) - return unichr(int(num)) + return unichar(int(num)) out = re.sub(u"&#(\d+);", replchar, out) return out @@ -93,7 +148,6 @@ """Extract the text from a <DIV> tag in the HTML starting with ``starttag``. Returns None if parsing fails. """ - # Strip off the leading text before opening tag. try: _, html = html.split(starttag, 1) @@ -134,30 +188,33 @@ and featured artists from the strings and add them as candidates. The method also tries to split multiple titles separated with `/`. """ + def generate_alternatives(string, patterns): + """Generate string alternatives by extracting first matching group for + each given pattern. + """ + alternatives = [string] + for pattern in patterns: + match = re.search(pattern, string, re.IGNORECASE) + if match: + alternatives.append(match.group(1)) + return alternatives title, artist = item.title, item.artist - titles = [title] - artists = [artist] - # Remove any featuring artists from the artists name - pattern = r"(.*?) {0}".format(plugins.feat_tokens()) - match = re.search(pattern, artist, re.IGNORECASE) - if match: - artists.append(match.group(1)) - - # Remove a parenthesized suffix from a title string. Common - # examples include (live), (remix), and (acoustic). - pattern = r"(.+?)\s+[(].*[)]$" - match = re.search(pattern, title, re.IGNORECASE) - if match: - titles.append(match.group(1)) - - # Remove any featuring artists from the title - pattern = r"(.*?) {0}".format(plugins.feat_tokens(for_artist=False)) - for title in titles[:]: - match = re.search(pattern, title, re.IGNORECASE) - if match: - titles.append(match.group(1)) + patterns = [ + # Remove any featuring artists from the artists name + r"(.*?) {0}".format(plugins.feat_tokens())] + artists = generate_alternatives(artist, patterns) + + patterns = [ + # Remove a parenthesized suffix from a title string. Common + # examples include (live), (remix), and (acoustic). + r"(.+?)\s+[(].*[)]$", + # Remove any featuring artists from the title + r"(.*?) {0}".format(plugins.feat_tokens(for_artist=False)), + # Remove part of title after colon ':' for songs with subtitles + r"(.+?)\s*:.*"] + titles = generate_alternatives(title, patterns) # Check for a dual song (e.g. Pink Floyd - Speak to Me / Breathe) # and each of them. @@ -170,6 +227,24 @@ return itertools.product(artists, multi_titles) +def slug(text): + """Make a URL-safe, human-readable version of the given text + + This will do the following: + + 1. decode unicode characters into ASCII + 2. shift everything to lowercase + 3. strip whitespace + 4. replace other non-word characters with dashes + 5. strip extra dashes + + This somewhat duplicates the :func:`Google.slugify` function but + slugify is not as generic as this one, which can be reused + elsewhere. + """ + return re.sub(r'\W+', '-', unidecode(text).lower().strip()).strip('-') + + class Backend(object): def __init__(self, config, log): self._log = log @@ -177,11 +252,11 @@ @staticmethod def _encode(s): """Encode the string for inclusion in a URL""" - if isinstance(s, unicode): + if isinstance(s, six.text_type): for char, repl in URL_CHARACTERS.items(): s = s.replace(char, repl) - s = s.encode('utf8', 'ignore') - return urllib.quote(s) + s = s.encode('utf-8', 'ignore') + return urllib.parse.quote(s) def build_url(self, artist, title): return self.URL_PATTERN % (self._encode(artist.title()), @@ -198,7 +273,9 @@ # We're not overly worried about the NSA MITMing our lyrics scraper with warnings.catch_warnings(): warnings.simplefilter('ignore') - r = requests.get(url, verify=False) + r = requests.get(url, verify=False, headers={ + 'User-Agent': USER_AGENT, + }) except requests.RequestException as exc: self._log.debug(u'lyrics request failed: {0}', exc) return @@ -218,12 +295,12 @@ '>': 'Greater_Than', '#': 'Number_', r'[\[\{]': '(', - r'[\[\{]': ')' + r'[\]\}]': ')', } @classmethod def _encode(cls, s): - for old, new in cls.REPLACEMENTS.iteritems(): + for old, new in cls.REPLACEMENTS.items(): s = re.sub(old, new, s) return super(SymbolsReplaced, cls)._encode(s) @@ -238,104 +315,82 @@ def fetch(self, artist, title): url = self.build_url(artist, title) + html = self.fetch_url(url) if not html: return - lyrics = extract_text_between(html, - '"body":', '"language":') - return lyrics.strip(',"').replace('\\n', '\n') + if "We detected that your IP is blocked" in html: + self._log.warning(u'we are blocked at MusixMatch: url %s failed' + % url) + return + html_part = html.split('<p class="mxm-lyrics__content')[-1] + lyrics = extract_text_between(html_part, '>', '</p>') + lyrics = lyrics.strip(',"').replace('\\n', '\n') + # another odd case: sometimes only that string remains, for + # missing songs. this seems to happen after being blocked + # above, when filling in the CAPTCHA. + if "Instant lyrics for all your music." in lyrics: + return + return lyrics class Genius(Backend): - """Fetch lyrics from Genius via genius-api.""" - def __init__(self, config, log): - super(Genius, self).__init__(config, log) - self.api_key = config['genius_api_key'].get(unicode) - self.headers = {'Authorization': "Bearer %s" % self.api_key} + """Fetch lyrics from Genius via genius-api. - def search_genius(self, artist, title): - query = u"%s %s" % (artist, title) - url = u'https://api.genius.com/search?q=%s' \ - % (urllib.quote(query.encode('utf8'))) - - self._log.debug(u'genius: requesting search {}', url) - try: - req = requests.get( - url, - headers=self.headers, - allow_redirects=True - ) - req.raise_for_status() - except requests.RequestException as exc: - self._log.debug(u'genius: request error: {}', exc) - return None - - try: - return req.json() - except ValueError: - self._log.debug(u'genius: invalid response: {}', req.text) - return None - - def get_lyrics(self, link): - url = u'http://genius-api.com/api/lyricsInfo' - - self._log.debug(u'genius: requesting lyrics for link {}', link) - try: - req = requests.post( - url, - data={'link': link}, - headers=self.headers, - allow_redirects=True - ) - req.raise_for_status() - except requests.RequestException as exc: - self._log.debug(u'genius: request error: {}', exc) - return None + Simply adapted from + bigishdata.com/2016/09/27/getting-song-lyrics-from-geniuss-api-scraping/ + """ - try: - return req.json() - except ValueError: - self._log.debug(u'genius: invalid response: {}', req.text) - return None + base_url = "https://api.genius.com" - def build_lyric_string(self, lyrics): - if 'lyrics' not in lyrics: - return - sections = lyrics['lyrics']['sections'] + def __init__(self, config, log): + super(Genius, self).__init__(config, log) + self.api_key = config['genius_api_key'].as_str() + self.headers = { + 'Authorization': "Bearer %s" % self.api_key, + 'User-Agent': USER_AGENT, + } - lyrics_list = [] - for section in sections: - lyrics_list.append(section['name']) - lyrics_list.append('\n') - for verse in section['verses']: - if 'content' in verse: - lyrics_list.append(verse['content']) + def lyrics_from_song_api_path(self, song_api_path): + song_url = self.base_url + song_api_path + response = requests.get(song_url, headers=self.headers) + json = response.json() + path = json["response"]["song"]["path"] + + # Gotta go regular html scraping... come on Genius. + page_url = "https://genius.com" + path + page = requests.get(page_url) + html = BeautifulSoup(page.text, "html.parser") + + # Remove script tags that they put in the middle of the lyrics. + [h.extract() for h in html('script')] + + # At least Genius is nice and has a tag called 'lyrics'! + # Updated css where the lyrics are based in HTML. + lyrics = html.find("div", class_="lyrics").get_text() - return ''.join(lyrics_list) + return lyrics def fetch(self, artist, title): - search_data = self.search_genius(artist, title) - if not search_data: - return - - if not search_data['meta']['status'] == 200: - return - else: - records = search_data['response']['hits'] - if not records: - return - - record_url = records[0]['result']['url'] - lyric_data = self.get_lyrics(record_url) - if not lyric_data: - return - lyrics = self.build_lyric_string(lyric_data) + search_url = self.base_url + "/search" + data = {'q': title} + response = requests.get(search_url, data=data, headers=self.headers) + json = response.json() + + song_info = None + for hit in json["response"]["hits"]: + if hit["result"]["primary_artist"]["name"] == artist: + song_info = hit + break - return lyrics + if song_info: + song_api_path = song_info["result"]["api_path"] + return self.lyrics_from_song_api_path(song_api_path) class LyricsWiki(SymbolsReplaced): """Fetch lyrics from LyricsWiki.""" + URL_PATTERN = 'http://lyrics.wikia.com/%s:%s' def fetch(self, artist, title): @@ -354,38 +409,6 @@ return lyrics -class LyricsCom(Backend): - """Fetch lyrics from Lyrics.com.""" - URL_PATTERN = 'http://www.lyrics.com/%s-lyrics-%s.html' - NOT_FOUND = ( - 'Sorry, we do not have the lyric', - 'Submit Lyrics', - ) - - @classmethod - def _encode(cls, s): - s = re.sub(r'[^\w\s-]', '', s) - s = re.sub(r'\s+', '-', s) - return super(LyricsCom, cls)._encode(s).lower() - - def fetch(self, artist, title): - url = self.build_url(artist, title) - html = self.fetch_url(url) - if not html: - return - lyrics = extract_text_between(html, '<div id="lyrics" class="SCREENO' - 'NLY" itemprop="description">', '</div>') - if not lyrics: - return - for not_found_str in self.NOT_FOUND: - if not_found_str in lyrics: - return - - parts = lyrics.split('\n---\nLyrics powered by', 1) - if parts: - return parts[0] - - def remove_credits(text): """Remove first/last line of text if it contains the word 'lyrics' eg 'Lyrics by songsdatabase.com' @@ -459,10 +482,11 @@ class Google(Backend): """Fetch lyrics from Google search results.""" + def __init__(self, config, log): super(Google, self).__init__(config, log) - self.api_key = config['google_API_key'].get(unicode) - self.engine_id = config['google_engine_ID'].get(unicode) + self.api_key = config['google_API_key'].as_str() + self.engine_id = config['google_engine_ID'].as_str() def is_lyrics(self, text, artist=None): """Determine whether the text seems to be valid lyrics. @@ -503,7 +527,7 @@ try: text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore') - text = unicode(re.sub('[-\s]+', ' ', text.decode('utf-8'))) + text = six.text_type(re.sub('[-\s]+', ' ', text.decode('utf-8'))) except UnicodeDecodeError: self._log.exception(u"Failing to normalize '{0}'", text) return text @@ -542,14 +566,20 @@ query = u"%s %s" % (artist, title) url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' \ % (self.api_key, self.engine_id, - urllib.quote(query.encode('utf8'))) + urllib.parse.quote(query.encode('utf-8'))) - data = urllib.urlopen(url) - data = json.load(data) + data = self.fetch_url(url) + if not data: + self._log.debug(u'google backend returned no data') + return None + try: + data = json.loads(data) + except ValueError as exc: + self._log.debug(u'google backend returned malformed JSON: {}', exc) if 'error' in data: reason = data['error']['errors'][0]['reason'] - self._log.debug(u'google lyrics backend error: {0}', reason) - return + self._log.debug(u'google backend error: {0}', reason) + return None if 'items' in data.keys(): for item in data['items']: @@ -570,11 +600,10 @@ class LyricsPlugin(plugins.BeetsPlugin): - SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch'] + SOURCES = ['google', 'lyricwiki', 'musixmatch', 'genius'] SOURCE_BACKENDS = { 'google': Google, 'lyricwiki': LyricsWiki, - 'lyrics.com': LyricsCom, 'musixmatch': MusiXmatch, 'genius': Genius, } @@ -594,6 +623,7 @@ "76V-uFL5jks5dNvcGCdarqFjDhP9c", 'fallback': None, 'force': False, + 'local': False, 'sources': self.SOURCES, }) self.config['bing_client_secret'].redact = True @@ -601,6 +631,15 @@ self.config['google_engine_ID'].redact = True self.config['genius_api_key'].redact = True + # State information for the ReST writer. + # First, the current artist we're writing. + self.artist = u'Unknown artist' + # The current album: False means no album yet. + self.album = False + # The current rest file content. None means the file is not + # open yet. + self.rest = None + available_sources = list(self.SOURCES) sources = plugins.sanitize_choices( self.config['sources'].as_str_seq(), available_sources) @@ -615,9 +654,9 @@ u'no API key configured.') sources.remove('google') elif not HAS_BEAUTIFUL_SOUP: - self._log.warn(u'To use the google lyrics source, you must ' - u'install the beautifulsoup4 module. See the ' - u'documentation for further details.') + self._log.warning(u'To use the google lyrics source, you must ' + u'install the beautifulsoup4 module. See ' + u'the documentation for further details.') sources.remove('google') self.config['bing_lang_from'] = [ @@ -625,9 +664,9 @@ self.bing_auth_token = None if not HAS_LANGDETECT and self.config['bing_client_secret'].get(): - self._log.warn(u'To use bing translations, you need to ' - u'install the langdetect module. See the ' - u'documentation for further details.') + self._log.warning(u'To use bing translations, you need to ' + u'install the langdetect module. See the ' + u'documentation for further details.') self.backends = [self.SOURCE_BACKENDS[source](self.config, self._log) for source in sources] @@ -636,14 +675,14 @@ params = { 'client_id': 'beets', 'client_secret': self.config['bing_client_secret'], - 'scope': 'http://api.microsofttranslator.com', + 'scope': "https://api.microsofttranslator.com", 'grant_type': 'client_credentials', } oauth_url = 'https://datamarket.accesscontrol.windows.net/v2/OAuth2-13' oauth_token = json.loads(requests.post( oauth_url, - data=urllib.urlencode(params)).content) + data=urllib.parse.urlencode(params)).content) if 'access_token' in oauth_token: return "Bearer " + oauth_token['access_token'] else: @@ -658,26 +697,105 @@ help=u'print lyrics to console', ) cmd.parser.add_option( + u'-r', u'--write-rest', dest='writerest', + action='store', default='.', metavar='dir', + help=u'write lyrics to given directory as ReST files', + ) + cmd.parser.add_option( u'-f', u'--force', dest='force_refetch', action='store_true', default=False, help=u'always re-download lyrics', ) + cmd.parser.add_option( + u'-l', u'--local', dest='local_only', + action='store_true', default=False, + help=u'do not fetch missing lyrics', + ) def func(lib, opts, args): # The "write to files" option corresponds to the # import_write config value. write = ui.should_write() + if opts.writerest: + self.writerest_indexes(opts.writerest) for item in lib.items(ui.decargs(args)): - self.fetch_item_lyrics( - lib, item, write, - opts.force_refetch or self.config['force'], - ) - if opts.printlyr and item.lyrics: - ui.print_(item.lyrics) - + if not opts.local_only and not self.config['local']: + self.fetch_item_lyrics( + lib, item, write, + opts.force_refetch or self.config['force'], + ) + if item.lyrics: + if opts.printlyr: + ui.print_(item.lyrics) + if opts.writerest: + self.writerest(opts.writerest, item) + if opts.writerest: + # flush last artist + self.writerest(opts.writerest, None) + ui.print_(u'ReST files generated. to build, use one of:') + ui.print_(u' sphinx-build -b html %s _build/html' + % opts.writerest) + ui.print_(u' sphinx-build -b epub %s _build/epub' + % opts.writerest) + ui.print_((u' sphinx-build -b latex %s _build/latex ' + u'&& make -C _build/latex all-pdf') + % opts.writerest) cmd.func = func return [cmd] + def writerest(self, directory, item): + """Write the item to an ReST file + + This will keep state (in the `rest` variable) in order to avoid + writing continuously to the same files. + """ + + if item is None or slug(self.artist) != slug(item.artist): + if self.rest is not None: + path = os.path.join(directory, 'artists', + slug(self.artist) + u'.rst') + with open(path, 'wb') as output: + output.write(self.rest.encode('utf-8')) + self.rest = None + if item is None: + return + self.artist = item.artist.strip() + self.rest = u"%s\n%s\n\n.. contents::\n :local:\n\n" \ + % (self.artist, + u'=' * len(self.artist)) + if self.album != item.album: + tmpalbum = self.album = item.album.strip() + if self.album == '': + tmpalbum = u'Unknown album' + self.rest += u"%s\n%s\n\n" % (tmpalbum, u'-' * len(tmpalbum)) + title_str = u":index:`%s`" % item.title.strip() + block = u'| ' + item.lyrics.replace(u'\n', u'\n| ') + self.rest += u"%s\n%s\n\n%s\n\n" % (title_str, + u'~' * len(title_str), + block) + + def writerest_indexes(self, directory): + """Write conf.py and index.rst files necessary for Sphinx + + We write minimal configurations that are necessary for Sphinx + to operate. We do not overwrite existing files so that + customizations are respected.""" + try: + os.makedirs(os.path.join(directory, 'artists')) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + raise + indexfile = os.path.join(directory, 'index.rst') + if not os.path.exists(indexfile): + with open(indexfile, 'w') as output: + output.write(REST_INDEX_TEMPLATE) + conffile = os.path.join(directory, 'conf.py') + if not os.path.exists(conffile): + with open(conffile, 'w') as output: + output.write(REST_CONF_TEMPLATE) + def imported(self, session, task): """Import hook for fetching lyrics automatically. """ @@ -688,7 +806,8 @@ def fetch_item_lyrics(self, lib, item, write, force): """Fetch and store lyrics for a single item. If ``write``, then the - lyrics will also be written to the file itself.""" + lyrics will also be written to the file itself. + """ # Skip if the item already has lyrics. if not force and item.lyrics: self._log.info(u'lyrics already present: {0}', item) @@ -743,7 +862,7 @@ if self.bing_auth_token: # Extract unique lines to limit API request size per song text_lines = set(text.split('\n')) - url = ('http://api.microsofttranslator.com/v2/Http.svc/' + url = ('https://api.microsofttranslator.com/v2/Http.svc/' 'Translate?text=%s&to=%s' % ('|'.join(text_lines), to_lang)) r = requests.get(url, headers={"Authorization ": self.bing_auth_token}) @@ -754,7 +873,7 @@ self.bing_auth_token = None return self.append_translation(text, to_lang) return text - lines_translated = ET.fromstring(r.text.encode('utf8')).text + lines_translated = ET.fromstring(r.text.encode('utf-8')).text # Use a translation mapping dict to build resulting lyrics translations = dict(zip(text_lines, lines_translated.split('|'))) result = '' diff -Nru beets-1.3.19/beetsplug/mbcollection.py beets-1.4.6/beetsplug/mbcollection.py --- beets-1.3.19/beetsplug/mbcollection.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/mbcollection.py 2017-11-25 22:56:53.000000000 +0000 @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011, Jeffrey Aylesworth <jeffrey@aylesworth.ca> +# This file is part of beets. +# Copyright (c) 2011, Jeffrey Aylesworth <mail@jeffrey.red> # -# Permission to use, copy, modify, and/or distribute this software for any -# purpose with or without fee is hereby granted, provided that the above -# copyright notice and this permission notice appear in all copies. +# 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 SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function @@ -24,6 +24,7 @@ import re SUBMISSION_CHUNK_SIZE = 200 +FETCH_CHUNK_SIZE = 100 UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$' @@ -57,44 +58,93 @@ super(MusicBrainzCollectionPlugin, self).__init__() config['musicbrainz']['pass'].redact = True musicbrainzngs.auth( - config['musicbrainz']['user'].get(unicode), - config['musicbrainz']['pass'].get(unicode), + config['musicbrainz']['user'].as_str(), + config['musicbrainz']['pass'].as_str(), ) - self.config.add({'auto': False}) + self.config.add({ + 'auto': False, + 'collection': u'', + 'remove': False, + }) if self.config['auto']: self.import_stages = [self.imported] + def _get_collection(self): + collections = mb_call(musicbrainzngs.get_collections) + if not collections['collection-list']: + raise ui.UserError(u'no collections exist for user') + + # Get all collection IDs, avoiding event collections + collection_ids = [x['id'] for x in collections['collection-list']] + if not collection_ids: + raise ui.UserError(u'No collection found.') + + # Check that the collection exists so we can present a nice error + collection = self.config['collection'].as_str() + if collection: + if collection not in collection_ids: + raise ui.UserError(u'invalid collection ID: {}' + .format(collection)) + return collection + + # No specified collection. Just return the first collection ID + return collection_ids[0] + + def _get_albums_in_collection(self, id): + def _fetch(offset): + res = mb_call( + musicbrainzngs.get_releases_in_collection, + id, + limit=FETCH_CHUNK_SIZE, + offset=offset + )['collection'] + return [x['id'] for x in res['release-list']], res['release-count'] + + offset = 0 + albums_in_collection, release_count = _fetch(offset) + for i in range(0, release_count, FETCH_CHUNK_SIZE): + offset += FETCH_CHUNK_SIZE + albums_in_collection += _fetch(offset)[0] + + return albums_in_collection + def commands(self): mbupdate = Subcommand('mbupdate', help=u'Update MusicBrainz collection') + mbupdate.parser.add_option('-r', '--remove', + action='store_true', + default=None, + dest='remove', + help='Remove albums not in beets library') mbupdate.func = self.update_collection return [mbupdate] + def remove_missing(self, collection_id, lib_albums): + lib_ids = set([x.mb_albumid for x in lib_albums]) + albums_in_collection = self._get_albums_in_collection(collection_id) + remove_me = list(lib_ids - set(albums_in_collection)) + for i in range(0, len(remove_me), FETCH_CHUNK_SIZE): + chunk = remove_me[i:i + FETCH_CHUNK_SIZE] + mb_call( + musicbrainzngs.remove_releases_from_collection, + collection_id, chunk + ) + def update_collection(self, lib, opts, args): - self.update_album_list(lib.albums()) + self.config.set_args(opts) + remove_missing = self.config['remove'].get(bool) + self.update_album_list(lib, lib.albums(), remove_missing) def imported(self, session, task): """Add each imported album to the collection. """ if task.is_album: - self.update_album_list([task.album]) + self.update_album_list(session.lib, [task.album]) - def update_album_list(self, album_list): - """Update the MusicBrainz colleciton from a list of Beets albums + def update_album_list(self, lib, album_list, remove_missing=False): + """Update the MusicBrainz collection from a list of Beets albums """ - # Get the available collections. - collections = mb_call(musicbrainzngs.get_collections) - if not collections['collection-list']: - raise ui.UserError(u'no collections exist for user') - - # Get the first release collection. MusicBrainz also has event - # collections, so we need to avoid adding to those. - for collection in collections['collection-list']: - if 'release-count' in collection: - collection_id = collection['id'] - break - else: - raise ui.UserError(u'No collection found.') + collection_id = self._get_collection() # Get a list of all the album IDs. album_ids = [] @@ -111,4 +161,6 @@ u'Updating MusicBrainz collection {0}...', collection_id ) submit_albums(collection_id, album_ids) + if remove_missing: + self.remove_missing(collection_id, lib.albums()) self._log.info(u'...MusicBrainz collection updated.') diff -Nru beets-1.3.19/beetsplug/mbsubmit.py beets-1.4.6/beetsplug/mbsubmit.py --- beets-1.3.19/beetsplug/mbsubmit.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/mbsubmit.py 2017-06-14 23:13:48.000000000 +0000 @@ -36,7 +36,7 @@ super(MBSubmitPlugin, self).__init__() self.config.add({ - 'format': '$track. $title - $artist ($length)', + 'format': u'$track. $title - $artist ($length)', 'threshold': 'medium', }) @@ -56,5 +56,5 @@ return [PromptChoice(u'p', u'Print tracks', self.print_tracks)] def print_tracks(self, session, task): - for i in task.items: - print_data(None, i, self.config['format'].get()) + for i in sorted(task.items, key=lambda i: i.track): + print_data(None, i, self.config['format'].as_str()) diff -Nru beets-1.3.19/beetsplug/metasync/amarok.py beets-1.4.6/beetsplug/metasync/amarok.py --- beets-1.3.19/beetsplug/metasync/amarok.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/metasync/amarok.py 2017-06-20 19:15:08.000000000 +0000 @@ -21,7 +21,7 @@ from os.path import basename from datetime import datetime from time import mktime -from xml.sax.saxutils import escape +from xml.sax.saxutils import quoteattr from beets.util import displayable_path from beets.dbcore import types @@ -51,7 +51,7 @@ queryXML = u'<query version="1.0"> \ <filters> \ - <and><include field="filename" value="%s" /></and> \ + <and><include field="filename" value=%s /></and> \ </filters> \ </query>' @@ -71,7 +71,9 @@ # for the patch relative to the mount point. But the full path is part # of the result set. So query for the filename and then try to match # the correct item from the results we get back - results = self.collection.Query(self.queryXML % escape(basename(path))) + results = self.collection.Query( + self.queryXML % quoteattr(basename(path)) + ) for result in results: if result['xesam:url'] != path: continue diff -Nru beets-1.3.19/beetsplug/metasync/__init__.py beets-1.4.6/beetsplug/metasync/__init__.py --- beets-1.3.19/beetsplug/metasync/__init__.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/metasync/__init__.py 2016-12-17 03:01:22.000000000 +0000 @@ -24,6 +24,7 @@ from beets.util.confit import ConfigValueError from beets import ui from beets.plugins import BeetsPlugin +import six METASYNC_MODULE = 'beetsplug.metasync' @@ -35,9 +36,7 @@ } -class MetaSource(object): - __metaclass__ = ABCMeta - +class MetaSource(six.with_metaclass(ABCMeta, object)): def __init__(self, config, log): self.item_types = {} self.config = config diff -Nru beets-1.3.19/beetsplug/metasync/itunes.py beets-1.4.6/beetsplug/metasync/itunes.py --- beets-1.3.19/beetsplug/metasync/itunes.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/metasync/itunes.py 2016-12-17 03:01:22.000000000 +0000 @@ -23,8 +23,8 @@ import shutil import tempfile import plistlib -import urllib -from urlparse import urlparse + +from six.moves.urllib.parse import urlparse, unquote from time import mktime from beets import util @@ -57,7 +57,7 @@ # E.g., '\\G:\\Music\\bar' needs to be stripped to 'G:\\Music\\bar' return util.bytestring_path(os.path.normpath( - urllib.unquote(urlparse(path).path)).lstrip('\\')).lower() + unquote(urlparse(path).path)).lstrip('\\')).lower() class Itunes(MetaSource): diff -Nru beets-1.3.19/beetsplug/missing.py beets-1.4.6/beetsplug/missing.py --- beets-1.3.19/beetsplug/missing.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/missing.py 2017-06-14 23:13:48.000000000 +0000 @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Pedro Silva. +# Copyright 2017, Quentin Young. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -17,11 +18,16 @@ """ from __future__ import division, absolute_import, print_function +import musicbrainzngs + +from musicbrainzngs.musicbrainz import MusicBrainzError +from collections import defaultdict from beets.autotag import hooks from beets.library import Item from beets.plugins import BeetsPlugin from beets.ui import decargs, print_, Subcommand from beets import config +from beets.dbcore import types def _missing_count(album): @@ -81,12 +87,18 @@ class MissingPlugin(BeetsPlugin): """List missing tracks """ + + album_types = { + 'missing': types.INTEGER, + } + def __init__(self): super(MissingPlugin, self).__init__() self.config.add({ 'count': False, 'total': False, + 'album': False, }) self.album_template_fields['missing'] = _missing_count @@ -100,35 +112,105 @@ self._command.parser.add_option( u'-t', u'--total', dest='total', action='store_true', help=u'count total of missing tracks') + self._command.parser.add_option( + u'-a', u'--album', dest='album', action='store_true', + help=u'show missing albums for artist instead of tracks') self._command.parser.add_format_option() def commands(self): def _miss(lib, opts, args): self.config.set_args(opts) - count = self.config['count'].get() - total = self.config['total'].get() - fmt = config['format_album' if count else 'format_item'].get() + albms = self.config['album'].get() - albums = lib.albums(decargs(args)) - if total: - print(sum([_missing_count(a) for a in albums])) - return + helper = self._missing_albums if albms else self._missing_tracks + helper(lib, decargs(args)) + + self._command.func = _miss + return [self._command] - # Default format string for count mode. + def _missing_tracks(self, lib, query): + """Print a listing of tracks missing from each album in the library + matching query. + """ + albums = lib.albums(query) + + count = self.config['count'].get() + total = self.config['total'].get() + fmt = config['format_album' if count else 'format_item'].get() + + if total: + print(sum([_missing_count(a) for a in albums])) + return + + # Default format string for count mode. + if count: + fmt += ': $missing' + + for album in albums: if count: - fmt += ': $missing' + if _missing_count(album): + print_(format(album, fmt)) - for album in albums: - if count: - if _missing_count(album): - print_(format(album, fmt)) - - else: - for item in self._missing(album): - print_(format(item, fmt)) + else: + for item in self._missing(album): + print_(format(item, fmt)) + + def _missing_albums(self, lib, query): + """Print a listing of albums missing from each artist in the library + matching query. + """ + total = self.config['total'].get() + + albums = lib.albums(query) + # build dict mapping artist to list of their albums in library + albums_by_artist = defaultdict(list) + for alb in albums: + artist = (alb['albumartist'], alb['mb_albumartistid']) + albums_by_artist[artist].append(alb) + + total_missing = 0 + + # build dict mapping artist to list of all albums + for artist, albums in albums_by_artist.items(): + if artist[1] is None or artist[1] == "": + albs_no_mbid = [u"'" + a['album'] + u"'" for a in albums] + self._log.info( + u"No musicbrainz ID for artist '{}' found in album(s) {}; " + "skipping", artist[0], u", ".join(albs_no_mbid) + ) + continue + + try: + resp = musicbrainzngs.browse_release_groups(artist=artist[1]) + release_groups = resp['release-group-list'] + except MusicBrainzError as err: + self._log.info( + u"Couldn't fetch info for artist '{}' ({}) - '{}'", + artist[0], artist[1], err + ) + continue + + missing = [] + present = [] + for rg in release_groups: + missing.append(rg) + for alb in albums: + if alb['mb_releasegroupid'] == rg['id']: + missing.remove(rg) + present.append(rg) + break - self._command.func = _miss - return [self._command] + total_missing += len(missing) + if total: + continue + + missing_titles = {rg['title'] for rg in missing} + + for release_title in missing_titles: + print_(u"{} - {}".format(artist[0], release_title)) + + if total: + print(total_missing) def _missing(self, album): """Query MusicBrainz to determine items missing from `album`. diff -Nru beets-1.3.19/beetsplug/mpdstats.py beets-1.4.6/beetsplug/mpdstats.py --- beets-1.3.19/beetsplug/mpdstats.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/mpdstats.py 2017-10-29 19:52:50.000000000 +0000 @@ -45,33 +45,19 @@ return path.split('://', 1)[0] in ['http', 'https'] -# Use the MPDClient internals to get unicode. -# see http://www.tarmack.eu/code/mpdunicode.py for the general idea -class MPDClient(mpd.MPDClient): - def _write_command(self, command, args=[]): - args = [unicode(arg).encode('utf-8') for arg in args] - super(MPDClient, self)._write_command(command, args) - - def _read_line(self): - line = super(MPDClient, self)._read_line() - if line is not None: - return line.decode('utf-8') - return None - - class MPDClientWrapper(object): def __init__(self, log): self._log = log self.music_directory = ( - mpd_config['music_directory'].get(unicode)) + mpd_config['music_directory'].as_str()) - self.client = MPDClient() + self.client = mpd.MPDClient(use_unicode=True) def connect(self): """Connect to the MPD. """ - host = mpd_config['host'].get(unicode) + host = mpd_config['host'].as_str() port = mpd_config['port'].get(int) if host[0] in ['/', '~']: @@ -83,7 +69,7 @@ except socket.error as e: raise ui.UserError(u'could not connect to MPD: {0}'.format(e)) - password = mpd_config['password'].get(unicode) + password = mpd_config['password'].as_str() if password: try: self.client.password(password) @@ -270,32 +256,41 @@ if not path: return - if is_url(path): - self._log.info(u'playing stream {0}', displayable_path(path)) - return - played, duration = map(int, status['time'].split(':', 1)) remaining = duration - played - if self.now_playing and self.now_playing['path'] != path: - skipped = self.handle_song_change(self.now_playing) - # mpd responds twice on a natural new song start - going_to_happen_twice = not skipped - else: - going_to_happen_twice = False + if self.now_playing: + if self.now_playing['path'] != path: + self.handle_song_change(self.now_playing) + else: + # In case we got mpd play event with same song playing + # multiple times, + # assume low diff means redundant second play event + # after natural song start. + diff = abs(time.time() - self.now_playing['started']) + + if diff <= self.time_threshold: + return + + if self.now_playing['path'] == path and played == 0: + self.handle_song_change(self.now_playing) + + if is_url(path): + self._log.info(u'playing stream {0}', displayable_path(path)) + self.now_playing = None + return - if not going_to_happen_twice: - self._log.info(u'playing {0}', displayable_path(path)) + self._log.info(u'playing {0}', displayable_path(path)) - self.now_playing = { - 'started': time.time(), - 'remaining': remaining, - 'path': path, - 'beets_item': self.get_item(path), - } + self.now_playing = { + 'started': time.time(), + 'remaining': remaining, + 'path': path, + 'beets_item': self.get_item(path), + } - self.update_item(self.now_playing['beets_item'], - 'last_played', value=int(time.time())) + self.update_item(self.now_playing['beets_item'], + 'last_played', value=int(time.time())) def run(self): self.mpd.connect() @@ -330,7 +325,7 @@ 'music_directory': config['directory'].as_filename(), 'rating': True, 'rating_mix': 0.75, - 'host': u'localhost', + 'host': os.environ.get('MPD_HOST', u'localhost'), 'port': 6600, 'password': u'', }) @@ -355,11 +350,11 @@ # Overrides for MPD settings. if opts.host: - mpd_config['host'] = opts.host.decode('utf8') + mpd_config['host'] = opts.host.decode('utf-8') if opts.port: mpd_config['host'] = int(opts.port) if opts.password: - mpd_config['password'] = opts.password.decode('utf8') + mpd_config['password'] = opts.password.decode('utf-8') try: MPDStats(lib, self._log).run() diff -Nru beets-1.3.19/beetsplug/mpdupdate.py beets-1.4.6/beetsplug/mpdupdate.py --- beets-1.3.19/beetsplug/mpdupdate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/mpdupdate.py 2017-01-11 19:15:59.000000000 +0000 @@ -27,6 +27,7 @@ import os import socket from beets import config +import six # No need to introduce a dependency on an MPD library for such a @@ -34,14 +35,14 @@ # easier. class BufferedSocket(object): """Socket abstraction that allows reading by line.""" - def __init__(self, host, port, sep='\n'): + def __init__(self, host, port, sep=b'\n'): if host[0] in ['/', '~']: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(os.path.expanduser(host)) else: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((host, port)) - self.buf = '' + self.buf = b'' self.sep = sep def readline(self): @@ -50,11 +51,11 @@ if not data: break self.buf += data - if '\n' in self.buf: + if self.sep in self.buf: res, self.buf = self.buf.split(self.sep, 1) return res + self.sep else: - return '' + return b'' def send(self, data): self.sock.send(data) @@ -67,7 +68,7 @@ def __init__(self): super(MPDUpdatePlugin, self).__init__() config['mpd'].add({ - 'host': u'localhost', + 'host': os.environ.get('MPD_HOST', u'localhost'), 'port': 6600, 'password': u'', }) @@ -86,9 +87,9 @@ def update(self, lib): self.update_mpd( - config['mpd']['host'].get(unicode), + config['mpd']['host'].as_str(), config['mpd']['port'].get(int), - config['mpd']['password'].get(unicode), + config['mpd']['password'].as_str(), ) def update_mpd(self, host='localhost', port=6600, password=None): @@ -101,28 +102,28 @@ s = BufferedSocket(host, port) except socket.error as e: self._log.warning(u'MPD connection failed: {0}', - unicode(e.strerror)) + six.text_type(e.strerror)) return resp = s.readline() - if 'OK MPD' not in resp: + if b'OK MPD' not in resp: self._log.warning(u'MPD connection failed: {0!r}', resp) return if password: - s.send('password "%s"\n' % password) + s.send(b'password "%s"\n' % password.encode('utf8')) resp = s.readline() - if 'OK' not in resp: + if b'OK' not in resp: self._log.warning(u'Authentication failed: {0!r}', resp) - s.send('close\n') + s.send(b'close\n') s.close() return - s.send('update\n') + s.send(b'update\n') resp = s.readline() - if 'updating_db' not in resp: + if b'updating_db' not in resp: self._log.warning(u'Update failed: {0!r}', resp) - s.send('close\n') + s.send(b'close\n') s.close() self._log.info(u'Database updated.') diff -Nru beets-1.3.19/beetsplug/permissions.py beets-1.4.6/beetsplug/permissions.py --- beets-1.3.19/beetsplug/permissions.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/permissions.py 2016-12-17 03:01:22.000000000 +0000 @@ -13,23 +13,43 @@ from beets import config, util from beets.plugins import BeetsPlugin from beets.util import ancestry +import six def convert_perm(perm): - """If the perm is a int then just return it, otherwise convert it to oct. + """Convert a string to an integer, interpreting the text as octal. + Or, if `perm` is an integer, reinterpret it as an octal number that + has been "misinterpreted" as decimal. """ - if isinstance(perm, int): - return perm - else: - return int(perm, 8) + if isinstance(perm, six.integer_types): + perm = six.text_type(perm) + return int(perm, 8) def check_permissions(path, permission): - """Checks the permissions of a path. + """Check whether the file's permissions equal the given vector. + Return a boolean. """ return oct(os.stat(path).st_mode & 0o777) == oct(permission) +def assert_permissions(path, permission, log): + """Check whether the file's permissions are as expected, otherwise, + log a warning message. Return a boolean indicating the match, like + `check_permissions`. + """ + if not check_permissions(util.syspath(path), permission): + log.warning( + u'could not set permissions on {}', + util.displayable_path(path), + ) + log.debug( + u'set permissions to {}, but permissions are now {}', + permission, + os.stat(util.syspath(path)).st_mode & 0o777, + ) + + def dirs_in_library(library, item): """Creates a list of ancestor directories in the beets library path. """ @@ -45,57 +65,59 @@ # Adding defaults. self.config.add({ u'file': '644', - u'dir': '755' + u'dir': '755', }) - self.register_listener('item_imported', permissions) - self.register_listener('album_imported', permissions) + self.register_listener('item_imported', self.fix) + self.register_listener('album_imported', self.fix) + def fix(self, lib, item=None, album=None): + """Fix the permissions for an imported Item or Album. + """ + # Get the configured permissions. The user can specify this either a + # string (in YAML quotes) or, for convenience, as an integer so the + # quotes can be omitted. In the latter case, we need to reinterpret the + # integer as octal, not decimal. + file_perm = config['permissions']['file'].get() + dir_perm = config['permissions']['dir'].get() + file_perm = convert_perm(file_perm) + dir_perm = convert_perm(dir_perm) + + # Create chmod_queue. + file_chmod_queue = [] + if item: + file_chmod_queue.append(item.path) + elif album: + for album_item in album.items(): + file_chmod_queue.append(album_item.path) + + # A set of directories to change permissions for. + dir_chmod_queue = set() + + for path in file_chmod_queue: + # Changing permissions on the destination file. + self._log.debug( + u'setting file permissions on {}', + util.displayable_path(path), + ) + os.chmod(util.syspath(path), file_perm) + + # Checks if the destination path has the permissions configured. + assert_permissions(path, file_perm, self._log) + + # Adding directories to the directory chmod queue. + dir_chmod_queue.update( + dirs_in_library(lib.directory, + path)) + + # Change permissions for the directories. + for path in dir_chmod_queue: + # Chaning permissions on the destination directory. + self._log.debug( + u'setting directory permissions on {}', + util.displayable_path(path), + ) + os.chmod(util.syspath(path), dir_perm) -def permissions(lib, item=None, album=None): - """Running the permission fixer. - """ - # Getting the config. - file_perm = config['permissions']['file'].get() - dir_perm = config['permissions']['dir'].get() - - # Converts permissions to oct. - file_perm = convert_perm(file_perm) - dir_perm = convert_perm(dir_perm) - - # Create chmod_queue. - file_chmod_queue = [] - if item: - file_chmod_queue.append(item.path) - elif album: - for album_item in album.items(): - file_chmod_queue.append(album_item.path) - - # A set of directories to change permissions for. - dir_chmod_queue = set() - - for path in file_chmod_queue: - # Changing permissions on the destination file. - os.chmod(util.bytestring_path(path), file_perm) - - # Checks if the destination path has the permissions configured. - if not check_permissions(util.bytestring_path(path), file_perm): - message = u'There was a problem setting permission on {}'.format( - path) - print(message) - - # Adding directories to the directory chmod queue. - dir_chmod_queue.update( - dirs_in_library(lib.directory, - path)) - - # Change permissions for the directories. - for path in dir_chmod_queue: - # Chaning permissions on the destination directory. - os.chmod(util.bytestring_path(path), dir_perm) - - # Checks if the destination path has the permissions configured. - if not check_permissions(util.bytestring_path(path), dir_perm): - message = u'There was a problem setting permission on {}'.format( - path) - print(message) + # Checks if the destination path has the permissions configured. + assert_permissions(path, dir_perm, self._log) diff -Nru beets-1.3.19/beetsplug/play.py beets-1.4.6/beetsplug/play.py --- beets-1.3.19/beetsplug/play.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/play.py 2017-10-03 19:33:23.000000000 +0000 @@ -19,17 +19,41 @@ from beets.plugins import BeetsPlugin from beets.ui import Subcommand +from beets.ui.commands import PromptChoice from beets import config from beets import ui from beets import util from os.path import relpath from tempfile import NamedTemporaryFile +import subprocess # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. ARGS_MARKER = '$args' +def play(command_str, selection, paths, open_args, log, item_type='track', + keep_open=False): + """Play items in paths with command_str and optional arguments. If + keep_open, return to beets, otherwise exit once command runs. + """ + # Print number of tracks or albums to be played, log command to be run. + item_type += 's' if len(selection) > 1 else '' + ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type)) + log.debug(u'executing command: {} {!r}', command_str, open_args) + + try: + if keep_open: + command = util.shlex_split(command_str) + command = command + open_args + subprocess.call(command) + else: + util.interactive_open(open_args, command_str) + except OSError as exc: + raise ui.UserError( + "Could not play the query: {0}".format(exc)) + + class PlayPlugin(BeetsPlugin): def __init__(self): @@ -40,11 +64,12 @@ 'use_folders': False, 'relative_to': None, 'raw': False, - # Backwards compatibility. See #1803 and line 74 - 'warning_threshold': -2, - 'warning_treshold': 100, + 'warning_threshold': 100, }) + self.register_listener('before_choose_candidate', + self.before_choose_candidate_listener) + def commands(self): play_command = Subcommand( 'play', @@ -56,41 +81,22 @@ action='store', help=u'add additional arguments to the command', ) - play_command.func = self.play_music + play_command.parser.add_option( + u'-y', u'--yes', + action="store_true", + help=u'skip the warning threshold', + ) + play_command.func = self._play_command return [play_command] - def play_music(self, lib, opts, args): - """Execute query, create temporary playlist and execute player - command passing that playlist, at request insert optional arguments. + def _play_command(self, lib, opts, args): + """The CLI command function for `beet play`. Create a list of paths + from query, determine if tracks or albums are to be played. """ - command_str = config['play']['command'].get() - if not command_str: - command_str = util.open_anything() use_folders = config['play']['use_folders'].get(bool) relative_to = config['play']['relative_to'].get() - raw = config['play']['raw'].get(bool) - warning_threshold = config['play']['warning_threshold'].get(int) - # We use -2 as a default value for warning_threshold to detect if it is - # set or not. We can't use a falsey value because it would have an - # actual meaning in the configuration of this plugin, and we do not use - # -1 because some people might use it as a value to obtain no warning, - # which wouldn't be that bad of a practice. - if warning_threshold == -2: - # if warning_threshold has not been set by user, look for - # warning_treshold, to preserve backwards compatibility. See #1803. - # warning_treshold has the correct default value of 100. - warning_threshold = config['play']['warning_treshold'].get(int) - if relative_to: relative_to = util.normpath(relative_to) - - # Add optional arguments to the player command. - if opts.args: - if ARGS_MARKER in command_str: - command_str = command_str.replace(ARGS_MARKER, opts.args) - else: - command_str = u"{} {}".format(command_str, opts.args) - # Perform search by album and add folders rather than tracks to # playlist. if opts.album: @@ -110,40 +116,71 @@ else: selection = lib.items(ui.decargs(args)) paths = [item.path for item in selection] - if relative_to: - paths = [relpath(path, relative_to) for path in paths] item_type = 'track' - item_type += 's' if len(selection) > 1 else '' + if relative_to: + paths = [relpath(path, relative_to) for path in paths] if not selection: ui.print_(ui.colorize('text_warning', u'No {0} to play.'.format(item_type))) return + open_args = self._playlist_or_paths(paths) + command_str = self._command_str(opts.args) + + # Check if the selection exceeds configured threshold. If True, + # cancel, otherwise proceed with play command. + if opts.yes or not self._exceeds_threshold( + selection, command_str, open_args, item_type): + play(command_str, selection, paths, open_args, self._log, + item_type) + + def _command_str(self, args=None): + """Create a command string from the config command and optional args. + """ + command_str = config['play']['command'].get() + if not command_str: + return util.open_anything() + # Add optional arguments to the player command. + if args: + if ARGS_MARKER in command_str: + return command_str.replace(ARGS_MARKER, args) + else: + return u"{} {}".format(command_str, args) + else: + # Don't include the marker in the command. + return command_str.replace(" " + ARGS_MARKER, "") + + def _playlist_or_paths(self, paths): + """Return either the raw paths of items or a playlist of the items. + """ + if config['play']['raw']: + return paths + else: + return [self._create_tmp_playlist(paths)] + + def _exceeds_threshold(self, selection, command_str, open_args, + item_type='track'): + """Prompt user whether to abort if playlist exceeds threshold. If + True, cancel playback. If False, execute play command. + """ + warning_threshold = config['play']['warning_threshold'].get(int) + # Warn user before playing any huge playlists. if warning_threshold and len(selection) > warning_threshold: + if len(selection) > 1: + item_type += 's' + ui.print_(ui.colorize( 'text_warning', u'You are about to queue {0} {1}.'.format( len(selection), item_type))) - if ui.input_options(('Continue', 'Abort')) == 'a': - return + if ui.input_options((u'Continue', u'Abort')) == 'a': + return True - ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type)) - if raw: - open_args = paths - else: - open_args = [self._create_tmp_playlist(paths)] - - self._log.debug(u'executing command: {} {}', command_str, - b' '.join(open_args)) - try: - util.interactive_open(open_args, command_str) - except OSError as exc: - raise ui.UserError( - "Could not play the query: {0}".format(exc)) + return False def _create_tmp_playlist(self, paths_list): """Create a temporary .m3u file. Return the filename. @@ -153,3 +190,21 @@ m3u.write(item + b'\n') m3u.close() return m3u.name + + def before_choose_candidate_listener(self, session, task): + """Append a "Play" choice to the interactive importer prompt. + """ + return [PromptChoice('y', 'plaY', self.importer_play)] + + def importer_play(self, session, task): + """Get items from current import task and send to play function. + """ + selection = task.items + paths = [item.path for item in selection] + + open_args = self._playlist_or_paths(paths) + command_str = self._command_str() + + if not self._exceeds_threshold(selection, command_str, open_args): + play(command_str, selection, paths, open_args, self._log, + keep_open=True) diff -Nru beets-1.3.19/beetsplug/plexupdate.py beets-1.4.6/beetsplug/plexupdate.py --- beets-1.3.19/beetsplug/plexupdate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/plexupdate.py 2016-12-17 03:01:22.000000000 +0000 @@ -12,9 +12,8 @@ from __future__ import division, absolute_import, print_function import requests -from urlparse import urljoin -from urllib import urlencode import xml.etree.ElementTree as ET +from six.moves.urllib.parse import urljoin, urlencode from beets import config from beets.plugins import BeetsPlugin diff -Nru beets-1.3.19/beetsplug/random.py beets-1.4.6/beetsplug/random.py --- beets-1.3.19/beetsplug/random.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/random.py 2017-01-03 01:53:12.000000000 +0000 @@ -24,56 +24,124 @@ from itertools import groupby -def random_item(lib, opts, args): - query = decargs(args) +def _length(obj, album): + """Get the duration of an item or album. + """ + if album: + return sum(i.length for i in obj.items()) + else: + return obj.length + + +def _equal_chance_permutation(objs, field='albumartist'): + """Generate (lazily) a permutation of the objects where every group + with equal values for `field` have an equal chance of appearing in + any given position. + """ + # Group the objects by artist so we can sample from them. + key = attrgetter(field) + objs.sort(key=key) + objs_by_artists = {} + for artist, v in groupby(objs, key): + objs_by_artists[artist] = list(v) + + # While we still have artists with music to choose from, pick one + # randomly and pick a track from that artist. + while objs_by_artists: + # Choose an artist and an object for that artist, removing + # this choice from the pool. + artist = random.choice(list(objs_by_artists.keys())) + objs_from_artist = objs_by_artists[artist] + i = random.randint(0, len(objs_from_artist) - 1) + yield objs_from_artist.pop(i) + + # Remove the artist if we've used up all of its objects. + if not objs_from_artist: + del objs_by_artists[artist] + + +def _take(iter, num): + """Return a list containing the first `num` values in `iter` (or + fewer, if the iterable ends early). + """ + out = [] + for val in iter: + out.append(val) + num -= 1 + if num <= 0: + break + return out + + +def _take_time(iter, secs, album): + """Return a list containing the first values in `iter`, which should + be Item or Album objects, that add up to the given amount of time in + seconds. + """ + out = [] + total_time = 0.0 + for obj in iter: + length = _length(obj, album) + if total_time + length <= secs: + out.append(obj) + total_time += length + return out + + +def random_objs(objs, album, number=1, time=None, equal_chance=False): + """Get a random subset of the provided `objs`. + + If `number` is provided, produce that many matches. Otherwise, if + `time` is provided, instead select a list whose total time is close + to that number of minutes. If `equal_chance` is true, give each + artist an equal chance of being included so that artists with more + songs are not represented disproportionately. + """ + # Permute the objects either in a straightforward way or an + # artist-balanced way. + if equal_chance: + perm = _equal_chance_permutation(objs) + else: + perm = objs + random.shuffle(perm) # N.B. This shuffles the original list. + + # Select objects by time our count. + if time: + return _take_time(perm, time * 60, album) + else: + return _take(perm, number) + +def random_func(lib, opts, args): + """Select some random items or albums and print the results. + """ + # Fetch all the objects matching the query into a list. + query = decargs(args) if opts.album: objs = list(lib.albums(query)) else: objs = list(lib.items(query)) - if opts.equal_chance: - # Group the objects by artist so we can sample from them. - key = attrgetter('albumartist') - objs.sort(key=key) - objs_by_artists = {} - for artist, v in groupby(objs, key): - objs_by_artists[artist] = list(v) - - objs = [] - for _ in range(opts.number): - # Terminate early if we're out of objects to select. - if not objs_by_artists: - break - - # Choose an artist and an object for that artist, removing - # this choice from the pool. - artist = random.choice(objs_by_artists.keys()) - objs_from_artist = objs_by_artists[artist] - i = random.randint(0, len(objs_from_artist) - 1) - objs.append(objs_from_artist.pop(i)) - - # Remove the artist if we've used up all of its objects. - if not objs_from_artist: - del objs_by_artists[artist] + # Print a random subset. + objs = random_objs(objs, opts.album, opts.number, opts.time, + opts.equal_chance) + for obj in objs: + print_(format(obj)) - else: - number = min(len(objs), opts.number) - objs = random.sample(objs, number) - - for item in objs: - print_(format(item)) random_cmd = Subcommand('random', - help=u'chose a random track or album') + help=u'choose a random track or album') random_cmd.parser.add_option( u'-n', u'--number', action='store', type="int", help=u'number of objects to choose', default=1) random_cmd.parser.add_option( u'-e', u'--equal-chance', action='store_true', help=u'each artist has the same chance') +random_cmd.parser.add_option( + u'-t', u'--time', action='store', type="float", + help=u'total length in minutes of objects to choose') random_cmd.parser.add_all_common_options() -random_cmd.func = random_item +random_cmd.func = random_func class Random(BeetsPlugin): diff -Nru beets-1.3.19/beetsplug/replaygain.py beets-1.4.6/beetsplug/replaygain.py --- beets-1.3.19/beetsplug/replaygain.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/beetsplug/replaygain.py 2017-10-29 19:52:50.000000000 +0000 @@ -18,15 +18,14 @@ import subprocess import os import collections -import itertools import sys import warnings import re +from six.moves import zip -from beets import logging from beets import ui from beets.plugins import BeetsPlugin -from beets.util import syspath, command_output, displayable_path +from beets.util import syspath, command_output, displayable_path, py3_path # Utilities. @@ -60,7 +59,7 @@ except UnicodeEncodeError: # Due to a bug in Python 2's subprocess on Windows, Unicode # filenames can fail to encode on that platform. See: - # http://code.google.com/p/beets/issues/detail?id=499 + # https://github.com/google-code-export/beets/issues/499 raise ReplayGainError(u"argument encoding failed") @@ -102,9 +101,9 @@ 'method': 'replaygain', }) self.chunk_at = config['chunk_at'].as_number() - self.method = b'--' + bytes(config['method'].get(unicode)) + self.method = '--' + config['method'].as_str() - cmd = b'bs1770gain' + cmd = 'bs1770gain' try: call([cmd, self.method]) self.command = cmd @@ -194,8 +193,8 @@ """ # Construct shell command. cmd = [self.command] - cmd = cmd + [self.method] - cmd = cmd + [b'-p'] + cmd += [self.method] + cmd += ['-p'] # Workaround for Windows: the underlying tool fails on paths # with the \\?\ prefix, so we don't use it here. This @@ -220,14 +219,14 @@ containing information about each analyzed file. """ out = [] - data = text.decode('utf8', errors='ignore') + data = text.decode('utf-8', errors='ignore') regex = re.compile( u'(\\s{2,2}\\[\\d+\\/\\d+\\].*?|\\[ALBUM\\].*?)' '(?=\\s{2,2}\\[\\d+\\/\\d+\\]|\\s{2,2}\\[ALBUM\\]' ':|done\\.\\s)', re.DOTALL | re.UNICODE) results = re.findall(regex, data) for parts in results[0:num_lines]: - part = parts.split(b'\n') + part = parts.split(u'\n') if len(part) == 0: self._log.debug(u'bad tool output: {0!r}', text) raise ReplayGainError(u'bs1770gain failed') @@ -256,7 +255,7 @@ 'noclip': True, }) - self.command = config["command"].get(unicode) + self.command = config["command"].as_str() if self.command: # Explicit executable path. @@ -267,9 +266,9 @@ ) else: # Check whether the program is in $PATH. - for cmd in (b'mp3gain', b'aacgain'): + for cmd in ('mp3gain', 'aacgain'): try: - call([cmd, b'-v']) + call([cmd, '-v']) self.command = cmd except OSError: pass @@ -334,14 +333,14 @@ # tag-writing; this turns the mp3gain/aacgain tool into a gain # calculator rather than a tag manipulator because we take care # of changing tags ourselves. - cmd = [self.command, b'-o', b'-s', b's'] + cmd = [self.command, '-o', '-s', 's'] if self.noclip: # Adjust to avoid clipping. - cmd = cmd + [b'-k'] + cmd = cmd + ['-k'] else: # Disable clipping warning. - cmd = cmd + [b'-c'] - cmd = cmd + [b'-d', bytes(self.gain_offset)] + cmd = cmd + ['-c'] + cmd = cmd + ['-d', str(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] self._log.debug(u'analyzing {0} files', len(items)) @@ -574,7 +573,7 @@ self._file = self._files.pop(0) self._pipe.set_state(self.Gst.State.NULL) - self._src.set_property("location", syspath(self._file.path)) + self._src.set_property("location", py3_path(syspath(self._file.path))) self._pipe.set_state(self.Gst.State.PLAYING) return True @@ -595,7 +594,7 @@ # Set a new file on the filesrc element, can only be done in the # READY state self._src.set_state(self.Gst.State.READY) - self._src.set_property("location", syspath(self._file.path)) + self._src.set_property("location", py3_path(syspath(self._file.path))) # Ensure the filesrc element received the paused state of the # pipeline in a blocking manner @@ -607,6 +606,10 @@ self._decbin.sync_state_with_parent() self._decbin.get_state(self.Gst.CLOCK_TIME_NONE) + self._decbin.link(self._conv) + self._pipe.set_state(self.Gst.State.READY) + self._pipe.set_state(self.Gst.State.PLAYING) + return True def _set_next_file(self): @@ -794,7 +797,7 @@ "command": CommandBackend, "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, - "bs1770gain": Bs1770gainBackend + "bs1770gain": Bs1770gainBackend, } def __init__(self): @@ -806,10 +809,11 @@ 'auto': True, 'backend': u'command', 'targetlevel': 89, + 'r128': ['Opus'], }) self.overwrite = self.config['overwrite'].get(bool) - backend_name = self.config['backend'].get(unicode) + backend_name = self.config['backend'].as_str() if backend_name not in self.backends: raise ui.UserError( u"Selected ReplayGain backend {0} is not supported. " @@ -823,6 +827,9 @@ if self.config['auto']: self.import_stages = [self.imported] + # Formats to use R128. + self.r128_whitelist = self.config['r128'].as_str_seq() + try: self.backend_instance = self.backends[backend_name]( self.config, self._log @@ -831,9 +838,19 @@ raise ui.UserError( u'replaygain initialization failed: {0}'.format(e)) + self.r128_backend_instance = '' + + def should_use_r128(self, item): + """Checks the plugin setting to decide whether the calculation + should be done using the EBU R128 standard and use R128_ tags instead. + """ + return item.format in self.r128_whitelist + def track_requires_gain(self, item): return self.overwrite or \ - (not item.rg_track_gain or not item.rg_track_peak) + (self.should_use_r128(item) and not item.r128_track_gain) or \ + (not self.should_use_r128(item) and + (not item.rg_track_gain or not item.rg_track_peak)) def album_requires_gain(self, album): # Skip calculating gain only when *all* files don't need @@ -841,8 +858,12 @@ # needs recalculation, we still get an accurate album gain # value. return self.overwrite or \ - any([not item.rg_album_gain or not item.rg_album_peak - for item in album.items()]) + any([self.should_use_r128(item) and + (not item.r128_track_gain or not item.r128_album_gain) + for item in album.items()]) or \ + any([not self.should_use_r128(item) and + (not item.rg_album_gain or not item.rg_album_peak) + for item in album.items()]) def store_track_gain(self, item, track_gain): item.rg_track_gain = track_gain.gain @@ -852,6 +873,12 @@ self._log.debug(u'applied track gain {0}, peak {1}', item.rg_track_gain, item.rg_track_peak) + def store_track_r128_gain(self, item, track_gain): + item.r128_track_gain = int(round(track_gain.gain * pow(2, 8))) + item.store() + + self._log.debug(u'applied track gain {0}', item.r128_track_gain) + def store_album_gain(self, album, album_gain): album.rg_album_gain = album_gain.gain album.rg_album_peak = album_gain.peak @@ -860,6 +887,12 @@ self._log.debug(u'applied album gain {0}, peak {1}', album.rg_album_gain, album.rg_album_peak) + def store_album_r128_gain(self, album, album_gain): + album.r128_album_gain = int(round(album_gain.gain * pow(2, 8))) + album.store() + + self._log.debug(u'applied album gain {0}', album.r128_album_gain) + def handle_album(self, album, write): """Compute album and track replay gain store it in all of the album's items. @@ -874,18 +907,35 @@ self._log.info(u'analyzing {0}', album) + if (any([self.should_use_r128(item) for item in album.items()]) and not + all(([self.should_use_r128(item) for item in album.items()]))): + raise ReplayGainError( + u"Mix of ReplayGain and EBU R128 detected" + u" for some tracks in album {0}".format(album) + ) + + if any([self.should_use_r128(item) for item in album.items()]): + if self.r128_backend_instance == '': + self.init_r128_backend() + backend_instance = self.r128_backend_instance + store_track_gain = self.store_track_r128_gain + store_album_gain = self.store_album_r128_gain + else: + backend_instance = self.backend_instance + store_track_gain = self.store_track_gain + store_album_gain = self.store_album_gain + try: - album_gain = self.backend_instance.compute_album_gain(album) + album_gain = backend_instance.compute_album_gain(album) if len(album_gain.track_gains) != len(album.items()): raise ReplayGainError( u"ReplayGain backend failed " u"for some tracks in album {0}".format(album) ) - self.store_album_gain(album, album_gain.album_gain) - for item, track_gain in itertools.izip(album.items(), - album_gain.track_gains): - self.store_track_gain(item, track_gain) + store_album_gain(album, album_gain.album_gain) + for item, track_gain in zip(album.items(), album_gain.track_gains): + store_track_gain(item, track_gain) if write: item.try_write() except ReplayGainError as e: @@ -907,14 +957,23 @@ self._log.info(u'analyzing {0}', item) + if self.should_use_r128(item): + if self.r128_backend_instance == '': + self.init_r128_backend() + backend_instance = self.r128_backend_instance + store_track_gain = self.store_track_r128_gain + else: + backend_instance = self.backend_instance + store_track_gain = self.store_track_gain + try: - track_gains = self.backend_instance.compute_track_gain([item]) + track_gains = backend_instance.compute_track_gain([item]) if len(track_gains) != 1: raise ReplayGainError( u"ReplayGain backend failed for track {0}".format(item) ) - self.store_track_gain(item, track_gains[0]) + store_track_gain(item, track_gains[0]) if write: item.try_write() except ReplayGainError as e: @@ -923,6 +982,19 @@ raise ui.UserError( u"Fatal replay gain error: {0}".format(e)) + def init_r128_backend(self): + backend_name = 'bs1770gain' + + try: + self.r128_backend_instance = self.backends[backend_name]( + self.config, self._log + ) + except (ReplayGainError, FatalReplayGainError) as e: + raise ui.UserError( + u'replaygain initialization failed: {0}'.format(e)) + + self.r128_backend_instance.method = '--ebu' + def imported(self, session, task): """Add replay gain info to items or albums of ``task``. """ @@ -935,8 +1007,6 @@ """Return the "replaygain" ui subcommand. """ def func(lib, opts, args): - self._log.setLevel(logging.INFO) - write = ui.should_write() if opts.album: diff -Nru beets-1.3.19/beetsplug/rewrite.py beets-1.4.6/beetsplug/rewrite.py --- beets-1.3.19/beetsplug/rewrite.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/rewrite.py 2016-12-17 03:01:22.000000000 +0000 @@ -51,7 +51,7 @@ # Gather all the rewrite rules for each field. rules = defaultdict(list) for key, view in self.config.items(): - value = view.get(unicode) + value = view.as_str() try: fieldname, pattern = key.split(None, 1) except ValueError: @@ -68,7 +68,7 @@ rules['albumartist'].append((pattern, value)) # Replace each template field with the new rewriter function. - for fieldname, fieldrules in rules.iteritems(): + for fieldname, fieldrules in rules.items(): getter = rewriter(fieldname, fieldrules) self.template_fields[fieldname] = getter if fieldname in library.Album._fields: diff -Nru beets-1.3.19/beetsplug/scrub.py beets-1.4.6/beetsplug/scrub.py --- beets-1.3.19/beetsplug/scrub.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/scrub.py 2017-10-29 19:52:50.000000000 +0000 @@ -24,6 +24,7 @@ from beets import util from beets import config from beets import mediafile +import mutagen _MUTAGEN_FORMATS = { 'asf': 'ASF', @@ -106,7 +107,7 @@ for tag in f.keys(): del f[tag] f.save() - except IOError as exc: + except (IOError, mutagen.MutagenError) as exc: self._log.error(u'could not scrub {0}: {1}', util.displayable_path(path), exc) @@ -119,10 +120,11 @@ try: mf = mediafile.MediaFile(util.syspath(item.path), config['id3v23'].get(bool)) - except IOError as exc: + except mediafile.UnreadableFileError as exc: self._log.error(u'could not open file to scrub: {0}', exc) - art = mf.art + return + images = mf.images # Remove all tags. self._scrub(item.path) @@ -131,12 +133,15 @@ if restore: self._log.debug(u'writing new tags after scrub') item.try_write() - if art: + if images: self._log.debug(u'restoring art') - mf = mediafile.MediaFile(util.syspath(item.path), - config['id3v23'].get(bool)) - mf.art = art - mf.save() + try: + mf = mediafile.MediaFile(util.syspath(item.path), + config['id3v23'].get(bool)) + mf.images = images + mf.save() + except mediafile.UnreadableFileError as exc: + self._log.error(u'could not write tags: {0}', exc) def import_task_files(self, session, task): """Automatically scrub imported files.""" diff -Nru beets-1.3.19/beetsplug/smartplaylist.py beets-1.4.6/beetsplug/smartplaylist.py --- beets-1.3.19/beetsplug/smartplaylist.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/smartplaylist.py 2017-06-14 23:13:48.000000000 +0000 @@ -20,11 +20,13 @@ from beets.plugins import BeetsPlugin from beets import ui -from beets.util import mkdirall, normpath, syspath, bytestring_path +from beets.util import (mkdirall, normpath, sanitize_path, syspath, + bytestring_path) from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import MultipleSort, ParsingError import os +import six class SmartPlaylistPlugin(BeetsPlugin): @@ -97,7 +99,7 @@ for playlist in self.config['playlists'].get(list): if 'name' not in playlist: - self._log.warn(u"playlist configuration is missing name") + self._log.warning(u"playlist configuration is missing name") continue playlist_data = (playlist['name'],) @@ -106,7 +108,7 @@ qs = playlist.get(key) if qs is None: query_and_sort = None, None - elif isinstance(qs, basestring): + elif isinstance(qs, six.string_types): query_and_sort = parse_query_string(qs, Model) elif len(qs) == 1: query_and_sort = parse_query_string(qs[0], Model) @@ -133,8 +135,8 @@ playlist_data += (query_and_sort,) except ParsingError as exc: - self._log.warn(u"invalid query in playlist {}: {}", - playlist['name'], exc) + self._log.warning(u"invalid query in playlist {}: {}", + playlist['name'], exc) continue self._unmatched_playlists.add(playlist_data) @@ -170,6 +172,9 @@ if relative_to: relative_to = normpath(relative_to) + # Maps playlist filenames to lists of track filenames. + m3us = {} + for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist self._log.debug(u"Creating playlist {0}", name) @@ -181,11 +186,11 @@ for album in lib.albums(album_query, a_q_sort): items.extend(album.items()) - m3us = {} # As we allow tags in the m3u names, we'll need to iterate through # the items and generate the correct m3u file names. for item in items: m3u_name = item.evaluate_template(name, True) + m3u_name = sanitize_path(m3u_name, lib.replacements) if m3u_name not in m3us: m3us[m3u_name] = [] item_path = item.path @@ -193,13 +198,14 @@ item_path = os.path.relpath(item.path, relative_to) if item_path not in m3us[m3u_name]: m3us[m3u_name].append(item_path) - # Now iterate through the m3us that we need to generate - for m3u in m3us: - m3u_path = normpath(os.path.join(playlist_dir, - bytestring_path(m3u))) - mkdirall(m3u_path) - with open(syspath(m3u_path), 'wb') as f: - for path in m3us[m3u]: - f.write(path + b'\n') + + # Write all of the accumulated track lists to files. + for m3u in m3us: + m3u_path = normpath(os.path.join(playlist_dir, + bytestring_path(m3u))) + mkdirall(m3u_path) + with open(syspath(m3u_path), 'wb') as f: + for path in m3us[m3u]: + f.write(path + b'\n') self._log.info(u"{0} playlists updated", len(self._matched_playlists)) diff -Nru beets-1.3.19/beetsplug/spotify.py beets-1.4.6/beetsplug/spotify.py --- beets-1.3.19/beetsplug/spotify.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/spotify.py 2016-12-17 03:01:22.000000000 +0000 @@ -63,8 +63,8 @@ self.config['show_failures'].set(True) if self.config['mode'].get() not in ['list', 'open']: - self._log.warn(u'{0} is not a valid mode', - self.config['mode'].get()) + self._log.warning(u'{0} is not a valid mode', + self.config['mode'].get()) return False self.opts = opts @@ -154,9 +154,9 @@ self._log.info(u'track: {0}', track) self._log.info(u'') else: - self._log.warn(u'{0} track(s) did not match a Spotify ID;\n' - u'use --show-failures to display', - failure_count) + self._log.warning(u'{0} track(s) did not match a Spotify ID;\n' + u'use --show-failures to display', + failure_count) return results @@ -170,6 +170,6 @@ else: for item in ids: - print(unicode.encode(self.open_url + item)) + print(self.open_url + item) else: - self._log.warn(u'No Spotify tracks found from beets query') + self._log.warning(u'No Spotify tracks found from beets query') diff -Nru beets-1.3.19/beetsplug/the.py beets-1.4.6/beetsplug/the.py --- beets-1.3.19/beetsplug/the.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/the.py 2016-12-17 03:01:22.000000000 +0000 @@ -54,14 +54,14 @@ self._log.error(u'invalid pattern: {0}', p) else: if not (p.startswith('^') or p.endswith('$')): - self._log.warn(u'warning: \"{0}\" will not ' - u'match string start/end', p) + self._log.warning(u'warning: \"{0}\" will not ' + u'match string start/end', p) if self.config['a']: self.patterns = [PATTERN_A] + self.patterns if self.config['the']: self.patterns = [PATTERN_THE] + self.patterns if not self.patterns: - self._log.warn(u'no patterns defined!') + self._log.warning(u'no patterns defined!') def unthe(self, text, pattern): """Moves pattern in the path format string or strips it @@ -81,7 +81,7 @@ if self.config['strip']: return r else: - fmt = self.config['format'].get(unicode) + fmt = self.config['format'].as_str() return fmt.format(r, t.strip()).strip() else: return u'' diff -Nru beets-1.3.19/beetsplug/thumbnails.py beets-1.4.6/beetsplug/thumbnails.py --- beets-1.3.19/beetsplug/thumbnails.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/thumbnails.py 2017-06-14 23:13:48.000000000 +0000 @@ -35,6 +35,7 @@ from beets.ui import Subcommand, decargs from beets import util from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version +import six BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") @@ -169,8 +170,9 @@ """Write required metadata to the thumbnail See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html """ + mtime = os.stat(album.artpath).st_mtime metadata = {"Thumb::URI": self.get_uri(album.artpath), - "Thumb::MTime": unicode(os.stat(album.artpath).st_mtime)} + "Thumb::MTime": six.text_type(mtime)} try: self.write_metadata(image_path, metadata) except Exception: @@ -272,8 +274,6 @@ try: uri_ptr = self.libgio.g_file_get_uri(g_file_ptr) - except: - raise finally: self.libgio.g_object_unref(g_file_ptr) if not uri_ptr: @@ -283,8 +283,12 @@ try: uri = copy_c_string(uri_ptr) - except: - raise finally: self.libgio.g_free(uri_ptr) - return uri + + try: + return uri.decode(util._fsencoding()) + except UnicodeDecodeError: + raise RuntimeError( + "Could not decode filename from GIO: {!r}".format(uri) + ) diff -Nru beets-1.3.19/beetsplug/web/__init__.py beets-1.4.6/beetsplug/web/__init__.py --- beets-1.3.19/beetsplug/web/__init__.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/web/__init__.py 2017-06-20 19:15:08.000000000 +0000 @@ -25,6 +25,7 @@ from werkzeug.routing import BaseConverter, PathConverter import os import json +import base64 # Utilities. @@ -37,7 +38,15 @@ out = dict(obj) if isinstance(obj, beets.library.Item): - del out['path'] + if app.config.get('INCLUDE_PATHS', False): + out['path'] = util.displayable_path(out['path']) + else: + del out['path'] + + # Filter all bytes attributes and convert them to strings. + for key, value in out.items(): + if isinstance(out[key], bytes): + out[key] = base64.b64encode(value).decode('ascii') # Get the size (in bytes) of the backing file. This is useful # for the Tomahawk resolver API. @@ -55,11 +64,13 @@ return out -def json_generator(items, root): +def json_generator(items, root, expand=False): """Generator that dumps list of beets Items or Albums as JSON :param root: root key for JSON :param items: list of :class:`Item` or :class:`Album` to dump + :param expand: If true every :class:`Album` contains its items in the json + representation :returns: generator that yields strings """ yield '{"%s":[' % root @@ -69,10 +80,16 @@ first = False else: yield ',' - yield json.dumps(_rep(item)) + yield json.dumps(_rep(item, expand=expand)) yield ']}' +def is_expand(): + """Returns whether the current request is for an expanded response.""" + + return flask.request.args.get('expand') is not None + + def resource(name): """Decorates a function to handle RESTful HTTP requests for a resource. """ @@ -82,7 +99,7 @@ entities = [entity for entity in entities if entity] if len(entities) == 1: - return flask.jsonify(_rep(entities[0])) + return flask.jsonify(_rep(entities[0], expand=is_expand())) elif entities: return app.response_class( json_generator(entities, root=name), @@ -101,7 +118,10 @@ def make_responder(query_func): def responder(queries): return app.response_class( - json_generator(query_func(queries), root='results'), + json_generator( + query_func(queries), + root='results', expand=is_expand() + ), mimetype='application/json' ) responder.__name__ = 'query_{0}'.format(name) @@ -116,7 +136,7 @@ def make_responder(list_all): def responder(): return app.response_class( - json_generator(list_all(), root=name), + json_generator(list_all(), root=name, expand=is_expand()), mimetype='application/json' ) responder.__name__ = 'all_{0}'.format(name) @@ -162,11 +182,16 @@ return ','.join(value) +class EverythingConverter(PathConverter): + regex = '.*?' + + # Flask setup. app = flask.Flask(__name__) app.url_map.converters['idlist'] = IdListConverter app.url_map.converters['query'] = QueryConverter +app.url_map.converters['everything'] = EverythingConverter @app.before_request @@ -192,9 +217,20 @@ @app.route('/item/<int:item_id>/file') def item_file(item_id): item = g.lib.get_item(item_id) - response = flask.send_file(item.path, as_attachment=True, - attachment_filename=os.path.basename(item.path)) - response.headers['Content-Length'] = os.path.getsize(item.path) + + # On Windows under Python 2, Flask wants a Unicode path. On Python 3, it + # *always* wants a Unicode path. + if os.name == 'nt': + item_path = util.syspath(item.path) + else: + item_path = util.py3_path(item.path) + + response = flask.send_file( + item_path, + as_attachment=True, + attachment_filename=os.path.basename(util.py3_path(item.path)), + ) + response.headers['Content-Length'] = os.path.getsize(item_path) return response @@ -204,6 +240,16 @@ return g.lib.items(queries) +@app.route('/item/path/<everything:path>') +def item_at_path(path): + query = beets.library.PathQuery('path', path.encode('utf-8')) + item = g.lib.items(query).get() + if item: + return flask.jsonify(_rep(item)) + else: + return flask.abort(404) + + @app.route('/item/values/<string:key>') def item_unique_field_values(key): sort_key = flask.request.args.get('sort_key', key) @@ -295,6 +341,8 @@ 'host': u'127.0.0.1', 'port': 8337, 'cors': '', + 'reverse_proxy': False, + 'include_paths': False, }) def commands(self): @@ -310,6 +358,11 @@ self.config['port'] = int(args.pop(0)) app.config['lib'] = lib + # Normalizes json output + app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + + app.config['INCLUDE_PATHS'] = self.config['include_paths'] + # Enable CORS if required. if self.config['cors']: self._log.info(u'Enabling CORS with origin: {0}', @@ -320,9 +373,50 @@ r"/*": {"origins": self.config['cors'].get(str)} } CORS(app) + + # Allow serving behind a reverse proxy + if self.config['reverse_proxy']: + app.wsgi_app = ReverseProxied(app.wsgi_app) + # Start the web application. - app.run(host=self.config['host'].get(unicode), + app.run(host=self.config['host'].as_str(), port=self.config['port'].get(int), debug=opts.debug, threaded=True) cmd.func = func return [cmd] + + +class ReverseProxied(object): + '''Wrap the application in this middleware and configure the + front-end server to add these headers, to let you quietly bind + this to a URL other than / and to an HTTP scheme that is + different than what is used locally. + + In nginx: + location /myprefix { + proxy_pass http://192.168.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Script-Name /myprefix; + } + + From: http://flask.pocoo.org/snippets/35/ + + :param app: the WSGI application + ''' + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_SCRIPT_NAME', '') + if script_name: + environ['SCRIPT_NAME'] = script_name + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO'] = path_info[len(script_name):] + + scheme = environ.get('HTTP_X_SCHEME', '') + if scheme: + environ['wsgi.url_scheme'] = scheme + return self.app(environ, start_response) diff -Nru beets-1.3.19/beetsplug/web/static/beets.js beets-1.4.6/beetsplug/web/static/beets.js --- beets-1.3.19/beetsplug/web/static/beets.js 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/web/static/beets.js 2017-06-14 23:13:48.000000000 +0000 @@ -147,7 +147,7 @@ }, itemQuery: function(query) { var queryURL = query.split(/\s+/).map(encodeURIComponent).join('/'); - $.getJSON('/item/query/' + queryURL, function(data) { + $.getJSON('item/query/' + queryURL, function(data) { var models = _.map( data['results'], function(d) { return new Item(d); } @@ -161,7 +161,7 @@ // Model. var Item = Backbone.Model.extend({ - urlRoot: '/item' + urlRoot: 'item' }); var Items = Backbone.Collection.extend({ model: Item @@ -264,7 +264,7 @@ $('#extra-detail').empty().append(extraDetailView.render().el); }, playItem: function(item) { - var url = '/item/' + item.get('id') + '/file'; + var url = 'item/' + item.get('id') + '/file'; $('#player audio').attr('src', url); $('#player audio').get(0).play(); diff -Nru beets-1.3.19/beetsplug/web/templates/index.html beets-1.4.6/beetsplug/web/templates/index.html --- beets-1.3.19/beetsplug/web/templates/index.html 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/web/templates/index.html 2017-06-14 23:13:48.000000000 +0000 @@ -82,7 +82,7 @@ <% } %> <dt>File</dt> <dd> - <a target="_blank" class="download" href="/item/<%= id %>/file">download</a> + <a target="_blank" class="download" href="item/<%= id %>/file">download</a> </dd> <% if (lyrics) { %> <dt>Lyrics</dt> diff -Nru beets-1.3.19/beetsplug/zero.py beets-1.4.6/beetsplug/zero.py --- beets-1.3.19/beetsplug/zero.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/beetsplug/zero.py 2017-06-17 16:26:37.000000000 +0000 @@ -16,125 +16,148 @@ """ Clears tag fields in media files.""" from __future__ import division, absolute_import, print_function +import six import re + from beets.plugins import BeetsPlugin from beets.mediafile import MediaFile from beets.importer import action +from beets.ui import Subcommand, decargs, input_yn from beets.util import confit __author__ = 'baobab@heresiarch.info' -__version__ = '0.10' class ZeroPlugin(BeetsPlugin): - - _instance = None - def __init__(self): super(ZeroPlugin, self).__init__() - # Listeners. self.register_listener('write', self.write_event) self.register_listener('import_task_choice', self.import_task_choice_event) self.config.add({ + 'auto': True, 'fields': [], 'keep_fields': [], 'update_database': False, }) - self.patterns = {} + self.fields_to_progs = {} self.warned = False - # We'll only handle `fields` or `keep_fields`, but not both. + """Read the bulk of the config into `self.fields_to_progs`. + After construction, `fields_to_progs` contains all the fields that + should be zeroed as keys and maps each of those to a list of compiled + regexes (progs) as values. + A field is zeroed if its value matches one of the associated progs. If + progs is empty, then the associated field is always zeroed. + """ if self.config['fields'] and self.config['keep_fields']: - self._log.warn(u'cannot blacklist and whitelist at the same time') - + self._log.warning( + u'cannot blacklist and whitelist at the same time' + ) # Blacklist mode. - if self.config['fields']: - self.validate_config('fields') + elif self.config['fields']: for field in self.config['fields'].as_str_seq(): - self.set_pattern(field) - + self._set_pattern(field) # Whitelist mode. elif self.config['keep_fields']: - self.validate_config('keep_fields') - for field in MediaFile.fields(): - if field in self.config['keep_fields'].as_str_seq(): - continue - self.set_pattern(field) - - # These fields should always be preserved. - for key in ('id', 'path', 'album_id'): - if key in self.patterns: - del self.patterns[key] - - def validate_config(self, mode): - """Check whether fields in the configuration are valid. - - `mode` should either be "fields" or "keep_fields", indicating - the section of the configuration to validate. - """ - for field in self.config[mode].as_str_seq(): - if field not in MediaFile.fields(): - self._log.error(u'invalid field: {0}', field) - continue - if mode == 'fields' and field in ('id', 'path', 'album_id'): - self._log.warn(u'field \'{0}\' ignored, zeroing ' - u'it would be dangerous', field) - continue - - def set_pattern(self, field): - """Set a field in `self.patterns` to a string list corresponding to - the configuration, or `True` if the field has no specific - configuration. + if (field not in self.config['keep_fields'].as_str_seq() and + # These fields should always be preserved. + field not in ('id', 'path', 'album_id')): + self._set_pattern(field) + + def commands(self): + zero_command = Subcommand('zero', help='set fields to null') + + def zero_fields(lib, opts, args): + if not decargs(args) and not input_yn( + u"Remove fields for all items? (Y/n)", + True): + return + for item in lib.items(decargs(args)): + self.process_item(item) + + zero_command.func = zero_fields + return [zero_command] + + def _set_pattern(self, field): + """Populate `self.fields_to_progs` for a given field. + Do some sanity checks then compile the regexes. """ - try: - self.patterns[field] = self.config[field].as_str_seq() - except confit.NotFoundError: - # Matches everything - self.patterns[field] = True + if field not in MediaFile.fields(): + self._log.error(u'invalid field: {0}', field) + elif field in ('id', 'path', 'album_id'): + self._log.warning(u'field \'{0}\' ignored, zeroing ' + u'it would be dangerous', field) + else: + try: + for pattern in self.config[field].as_str_seq(): + prog = re.compile(pattern, re.IGNORECASE) + self.fields_to_progs.setdefault(field, []).append(prog) + except confit.NotFoundError: + # Matches everything + self.fields_to_progs[field] = [] def import_task_choice_event(self, session, task): - """Listen for import_task_choice event.""" if task.choice_flag == action.ASIS and not self.warned: - self._log.warn(u'cannot zero in \"as-is\" mode') + self._log.warning(u'cannot zero in \"as-is\" mode') self.warned = True # TODO request write in as-is mode - @classmethod - def match_patterns(cls, field, patterns): - """Check if field (as string) is matching any of the patterns in - the list. - """ - if patterns is True: - return True - for p in patterns: - if re.search(p, unicode(field), flags=re.IGNORECASE): - return True - return False - def write_event(self, item, path, tags): - """Set values in tags to `None` if the key and value are matched - by `self.patterns`. + if self.config['auto']: + self.set_fields(item, tags) + + def set_fields(self, item, tags): + """Set values in `tags` to `None` if the field is in + `self.fields_to_progs` and any of the corresponding `progs` matches the + field value. + Also update the `item` itself if `update_database` is set in the + config. """ - if not self.patterns: - self._log.warn(u'no fields, nothing to do') - return + fields_set = False + + if not self.fields_to_progs: + self._log.warning(u'no fields, nothing to do') + return False - for field, patterns in self.patterns.items(): + for field, progs in self.fields_to_progs.items(): if field in tags: value = tags[field] - match = self.match_patterns(tags[field], patterns) + match = _match_progs(tags[field], progs) else: value = '' - match = patterns is True + match = not progs if match: + fields_set = True self._log.debug(u'{0}: {1} -> None', field, value) tags[field] = None if self.config['update_database']: item[field] = None + + return fields_set + + def process_item(self, item): + tags = dict(item) + + if self.set_fields(item, tags): + item.write(tags=tags) + if self.config['update_database']: + item.store(fields=tags) + + +def _match_progs(value, progs): + """Check if `value` (as string) is matching any of the compiled regexes in + the `progs` list. + """ + if not progs: + return True + for prog in progs: + if prog.search(six.text_type(value)): + return True + return False diff -Nru beets-1.3.19/debian/beets-doc.docs beets-1.4.6/debian/beets-doc.docs --- beets-1.3.19/debian/beets-doc.docs 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/beets-doc.docs 2018-03-12 22:20:40.000000000 +0000 @@ -1 +1 @@ -build/docs/html +docs/_build/html diff -Nru beets-1.3.19/debian/beets.install beets-1.4.6/debian/beets.install --- beets-1.3.19/debian/beets.install 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/beets.install 2018-03-12 22:20:40.000000000 +0000 @@ -1,2 +1,2 @@ -usr/bin/* +extra/_beet /usr/share/zsh/vendor-completions/ usr/share/beets/* diff -Nru beets-1.3.19/debian/beets.links beets-1.4.6/debian/beets.links --- beets-1.3.19/debian/beets.links 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/beets.links 2018-03-12 22:20:40.000000000 +0000 @@ -1,3 +1,4 @@ +/usr/share/beets/beet /usr/bin/beet /usr/share/javascript/backbone/backbone.js /usr/share/beets/beetsplug/web/static/backbone.js /usr/share/javascript/jquery/jquery.js /usr/share/beets/beetsplug/web/static/jquery.js /usr/share/javascript/underscore/underscore.js /usr/share/beets/beetsplug/web/static/underscore.js diff -Nru beets-1.3.19/debian/changelog beets-1.4.6/debian/changelog --- beets-1.3.19/debian/changelog 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/changelog 2018-03-12 22:20:40.000000000 +0000 @@ -1,16 +1,73 @@ -beets (1.3.19-2.1) unstable; urgency=medium +beets (1.4.6-2) unstable; urgency=medium + + [ Stefano Rivera ] + * Thanks for the NMU, Matthias. + * Declare Rules-Requires-Root: no. + * Migrate to git on salsa.debian.org. + * Simply have the autopkgtests install the build-deps. + * Patch update-unidecode-tests: unidecode changed behaviour. + * Bump debhelper compat to 10. + - Drop dh_compress override, no longer needed. + * Rely on pybuild for tests and clean. + * Get the test suite passing (Closes: #892735). + - Patch no-discogs: discogs is not available in debian. + - Patch pathlib-is-stdlib: We don't have python3-pathlib in Debian. + - Patch: skip-broken-test. + * Drop patch skip-test_query-path-tests, superseded upstream. + + [ Ondřej Nový ] + * d/copyright: Use https protocol in Format field + + -- Stefano Rivera <stefanor@debian.org> Mon, 12 Mar 2018 15:20:40 -0700 + +beets (1.4.6-1.1) unstable; urgency=medium * Non-maintainer upload. - * d/p/mediafile-Cleanup-mutagen-error-handling.patch: - Add patch backported from upstream to update exception handling for - python-mutagen >= 1.33. This fixes a test failure and - FTBFS (Closes: #851016) - * d/p/Test-true-FLAC-bitrate-from-Mutagen-1.35.patch: - Add patch backported from upstream to fix a failing test with - python-mutagen >= 1.35 - - d/control: depend and build-depend on a compatible version + * Remove Simon Chopin from the uploaders list. Closes: #889761. + * Run the tests during the build using python3. + * Run the autopkg tests using python3, update test dependencies. + Closes: #889078. + + -- Matthias Klose <doko@debian.org> Mon, 12 Mar 2018 11:44:22 +0100 + +beets (1.4.6-1) unstable; urgency=medium + + * New upstream release + * Bump standards version to 4.1.3 + + -- Ryan Kavanagh <rak@debian.org> Thu, 04 Jan 2018 15:51:54 -0500 + +beets (1.4.5-1) unstable; urgency=medium + + * Team upload + * New upstream release (Closes: #852257) + + version number displayed by beets now agrees with the Debian package + version (Closes: #837193) + + Fixes issues with broken utf8 filenames (Closes: #851630) + + [ Ryan Kavanagh ] + * Bumped standards version to 4.0.1 + * Switch to Python 3 now that it's supported by upstream. (Closes: #849085) + + Update the (build-)dependencies accordingly. + + Specify that we're using pybuild in the rules + + Drop (build-)dependencies on python-enum34, python-imaging + (Closes: #866416), and python-pathlib: these are no longer needed + with Python 3. + + We require at least Python 3.4 if we are to drop python-enum34; set + X-Python3-Version and other dependency versions accordingly. + * Refreshed patches, and dropped the following unneeded patches: + fix-test_hidden, fix-test_mediafile_edge, fix-test_nonexistent_file, + no-jellyfish + * Because #806716 has been resolved, added a build-dependency on + python3-jellyfish; this justifies dropping the no-jellyfish patch. + (Closes: #839640) + * Fixed insecure URLs for Vcs-Browser; no secure alternative exists for + Vcs-Svn + * Updated copyright holders + * Added myself to the uploaders field + * Install zsh completion file (Closes: #775811) - -- Simon McVittie <smcv@debian.org> Mon, 23 Jan 2017 09:41:08 +0000 + -- Ryan Kavanagh <rak@debian.org> Mon, 04 Sep 2017 14:26:49 -0400 beets (1.3.19-2) unstable; urgency=medium diff -Nru beets-1.3.19/debian/clean beets-1.4.6/debian/clean --- beets-1.3.19/debian/clean 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/clean 2018-03-12 22:20:40.000000000 +0000 @@ -1 +1,2 @@ *.egg-info/* +docs/_build/ diff -Nru beets-1.3.19/debian/compat beets-1.4.6/debian/compat --- beets-1.3.19/debian/compat 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/compat 2018-03-12 22:20:40.000000000 +0000 @@ -1 +1 @@ -9 +10 diff -Nru beets-1.3.19/debian/control beets-1.4.6/debian/control --- beets-1.3.19/debian/control 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/control 2018-03-12 22:20:40.000000000 +0000 @@ -4,34 +4,34 @@ Maintainer: Python Applications Packaging Team <python-apps-team@lists.alioth.debian.org> Uploaders: Stefano Rivera <stefanor@debian.org>, - Simon Chopin <chopin.simon@gmail.com> + Ryan Kavanagh <rak@debian.org> Build-Depends: - debhelper (>= 9), + debhelper (>= 10), dh-python, libc-bin (>= 2.13), - python-all (>= 2.6.6-3~), - python-bs4, - python-enum34 (>= 1.0.4), - python-flask, - python-mock, - python-mpd, - python-munkres, - python-musicbrainzngs (>= 0.4), - python-mutagen (>= 1.35), - python-pathlib, - python-pylast, - python-rarfile, - python-responses, - python-setuptools, - python-sphinx (>= 1.0.7+dfsg), - python-unidecode, - python-xdg, - python-yaml -X-Python-Version: >= 2.7 -Standards-Version: 3.9.8 + python3-all (>= 3.4), + python3-bs4, + python3-flask, + python3-jellyfish, + python3-mock, + python3-mpd, + python3-munkres, + python3-musicbrainzngs (>= 0.4), + python3-mutagen (>= 1.27), + python3-pylast, + python3-rarfile, + python3-responses, + python3-setuptools, + python3-sphinx (>= 1.0.7+dfsg), + python3-unidecode, + python3-xdg, + python3-yaml +Standards-Version: 4.1.3 +X-Python3-Version: >= 3.4 Homepage: http://beets.radbox.org/ -Vcs-Svn: svn://anonscm.debian.org/python-apps/packages/beets/trunk/ -Vcs-Browser: http://anonscm.debian.org/viewvc/python-apps/packages/beets/trunk/ +Vcs-Git: https://salsa.debian.org/python-team/applications/beets.git +Vcs-Browser: https://salsa.debian.org/python-team/applications/beets +Rules-Requires-Root: no Package: beets Architecture: all @@ -39,29 +39,26 @@ libjs-backbone, libjs-jquery, libjs-underscore, - python-enum34, - python-musicbrainzngs (>= 0.4), - python-mutagen (>= 1.35), - python-pkg-resources, + python3-musicbrainzngs (>= 0.4), + python3-mutagen (>= 1.21), + python3-pkg-resources, ${misc:Depends}, - ${python:Depends} + ${python3:Depends} Suggests: beets-doc, libav-tools, mp3gain, - python-acoustid, - python-bs4, - python-dbus, - python-flask, - python-gst-1.0, - python-imaging, - python-mpd, - python-pathlib, - python-pylast, - python-rarfile, - python-requests, - python-requests-oauthlib, - python-xdg + python3-acoustid, + python3-bs4, + python3-dbus, + python3-flask, + python3-gst-1.0, + python3-mpd, + python3-pylast, + python3-rarfile, + python3-requests, + python3-requests-oauthlib, + python3-xdg Description: music tagger and library organizer Beets is a media library management system for obsessive-compulsive music geeks. diff -Nru beets-1.3.19/debian/copyright beets-1.4.6/debian/copyright --- beets-1.3.19/debian/copyright 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/copyright 2018-03-12 22:20:40.000000000 +0000 @@ -1,10 +1,10 @@ -Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: Beets Upstream-Contact: Adrian Sampson <adrian@radbox.org> Source: http://beets.radbox.org/ Files: * -Copyright: 2010-2016, Adrian Sampson <adrian@radbox.org> +Copyright: 2010-2017, Adrian Sampson <adrian@radbox.org> 2012-2016, Fabrice Laporte 2013-2016, Thomas Scholtes License: Expat @@ -27,18 +27,22 @@ 2016, Malte Ried 2016, Matt Lichtenberg 2015-2016, Ohm Patel + 2017, Pauli Kettunen 2013-2016, Pedro Silva 2013-2016, Peter Schnebel 2011-2016, Philippe Mongeau + 2017, Quentin Young + 2016, Pieter Mulder 2016, Rafael Bodill <http://github.com/rafi> 2014-2016, Thomas Scholtes + 2017, Tigran Kostandyan 2016, Tom Jaspers 2013-2016, Verrus <github.com/Verrus/beets-plugin-featInTitle> 2014-2016, Yevgeny Bezman License: Expat Files: beetsplug/mbcollection.py -Copyright: 2011, Jeffrey Aylesworth <jeffrey@aylesworth.ca> +Copyright: 2011, Jeffrey Aylesworth <mail@jeffrey.red> License: ISC Files: beetsplug/web/static/backbone.js @@ -57,6 +61,7 @@ Files: debian/* Copyright: 2010-2016, Stefano Rivera <stefanor@debian.org> 2012-2014, Simon Chopin <chopin.simon@gmail.com> + 2017-2018 Ryan Kavanagh <rak@debian.org> License: Expat License: Expat diff -Nru beets-1.3.19/debian/gbp.conf beets-1.4.6/debian/gbp.conf --- beets-1.3.19/debian/gbp.conf 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/debian/gbp.conf 2018-03-12 22:20:40.000000000 +0000 @@ -0,0 +1,2 @@ +[DEFAULT] +debian-branch=debian/master diff -Nru beets-1.3.19/debian/patches/fix-test_hidden beets-1.4.6/debian/patches/fix-test_hidden --- beets-1.3.19/debian/patches/fix-test_hidden 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/fix-test_hidden 1970-01-01 00:00:00.000000000 +0000 @@ -1,17 +0,0 @@ -Description: test_hidden was missing suite() which made testall.suite() fail -Author: Christoph Reiter <reiter.christoph@gmail.com> -Origin: upstream, https://github.com/beetbox/beets/commit/06072c5d7d2bc33a9a7cf041b8fc5bd362758a69 - ---- a/test/test_hidden.py -+++ b/test/test_hidden.py -@@ -72,3 +72,10 @@ - - with tempfile.NamedTemporaryFile(prefix='.tmp') as f: - self.assertTrue(hidden.is_hidden(f.name)) -+ -+ -+def suite(): -+ return unittest.TestLoader().loadTestsFromName(__name__) -+ -+if __name__ == '__main__': -+ unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/debian/patches/fix-test_mediafile_edge beets-1.4.6/debian/patches/fix-test_mediafile_edge --- beets-1.3.19/debian/patches/fix-test_mediafile_edge 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/fix-test_mediafile_edge 1970-01-01 00:00:00.000000000 +0000 @@ -1,15 +0,0 @@ -Description: A different exception is now returned for unreadable files -Origin: upstream, https://github.com/beetbox/beets/pull/2088 -Author: Christoph Reiter <reiter.christoph@gmail.com> - ---- a/test/test_mediafile_edge.py -+++ b/test/test_mediafile_edge.py -@@ -192,7 +192,7 @@ - fn = os.path.join(_common.RSRC, b'brokenlink') - os.symlink('does_not_exist', fn) - try: -- self.assertRaises(IOError, -+ self.assertRaises(beets.mediafile.UnreadableFileError, - beets.mediafile.MediaFile, fn) - finally: - os.unlink(fn) diff -Nru beets-1.3.19/debian/patches/fix-test_nonexistent_file beets-1.4.6/debian/patches/fix-test_nonexistent_file --- beets-1.3.19/debian/patches/fix-test_nonexistent_file 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/fix-test_nonexistent_file 1970-01-01 00:00:00.000000000 +0000 @@ -1,34 +0,0 @@ -Description: Manage patching of try_filesize -Author: Jesse Weinstein <jesse@wefu.org> -Origin: upstream, https://github.com/beetbox/beets/issues/2137 -Bug-Debian: https://bugs.debian.org/835937 -Bug-Upstream: https://github.com/beetbox/beets/issues/2135 - ---- a/test/test_ui.py -+++ b/test/test_ui.py -@@ -24,7 +24,7 @@ import subprocess - import platform - from copy import deepcopy - --from mock import patch -+from mock import patch, Mock - from test import _common - from test._common import unittest - from test.helper import capture_stdout, has_program, TestHelper, control_stdin -@@ -1053,6 +1053,7 @@ class ShowChangeTest(_common.TestCase): - u'caf.mp3 ->' in msg) - - -+@patch('beets.library.Item.try_filesize', Mock(return_value=987)) - class SummarizeItemsTest(_common.TestCase): - def setUp(self): - super(SummarizeItemsTest, self).setUp() -@@ -1061,8 +1062,6 @@ class SummarizeItemsTest(_common.TestCase): - item.length = 10 * 60 + 54 - item.format = "F" - self.item = item -- fsize_mock = patch('beets.library.Item.try_filesize').start() -- fsize_mock.return_value = 987 - - def test_summarize_item(self): - summary = commands.summarize_items([], True) diff -Nru beets-1.3.19/debian/patches/mediafile-Cleanup-mutagen-error-handling.patch beets-1.4.6/debian/patches/mediafile-Cleanup-mutagen-error-handling.patch --- beets-1.3.19/debian/patches/mediafile-Cleanup-mutagen-error-handling.patch 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/mediafile-Cleanup-mutagen-error-handling.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,241 +0,0 @@ -From: Christoph Reiter <reiter.christoph@gmail.com> -Date: Mon, 27 Jun 2016 09:43:48 +0200 -Subject: mediafile: Cleanup mutagen error handling - -Instead of the individial mutagen format exceptions use the -mutagen.MutagenError exception introduced in 1.25. - -Since 1.33 mutagen will only raise MutagenError for load/save/delete -and no longer raise IOError. Translate both errors to UnreadableFileError -to support older and newer mutagen versions. Unify error handling -in __init__(), save() and delete(). - -Since it's no longer possible to get an IOError from MediaFile, adjust -all callers and tests accordingly. - -This was tested with mutagen 1.27 and current mutagen master. - -[smcv: backported to 1.3.19 by replacing six.text_type with unicode] - -Origin: upstream, 1.4.1, commit:629241efd389bea7b4075f2591a06f2ef462dc82 ---- - beets/library.py | 8 +++---- - beets/mediafile.py | 65 +++++++++++++++++++++++--------------------------- - beetsplug/scrub.py | 13 ++++++---- - test/test_mediafile.py | 23 +++++++++++++++++- - 4 files changed, 64 insertions(+), 45 deletions(-) - -diff --git a/beets/library.py b/beets/library.py -index 3450a35a..70fff1a7 100644 ---- a/beets/library.py -+++ b/beets/library.py -@@ -25,7 +25,7 @@ import re - from unidecode import unidecode - - from beets import logging --from beets.mediafile import MediaFile, MutagenError, UnreadableFileError -+from beets.mediafile import MediaFile, UnreadableFileError - from beets import plugins - from beets import util - from beets.util import bytestring_path, syspath, normpath, samefile -@@ -560,7 +560,7 @@ class Item(LibModel): - read_path = normpath(read_path) - try: - mediafile = MediaFile(syspath(read_path)) -- except (OSError, IOError, UnreadableFileError) as exc: -+ except UnreadableFileError as exc: - raise ReadError(read_path, exc) - - for key in self._media_fields: -@@ -607,14 +607,14 @@ class Item(LibModel): - try: - mediafile = MediaFile(syspath(path), - id3v23=beets.config['id3v23'].get(bool)) -- except (OSError, IOError, UnreadableFileError) as exc: -+ except UnreadableFileError as exc: - raise ReadError(self.path, exc) - - # Write the tags to the file. - mediafile.update(item_tags) - try: - mediafile.save() -- except (OSError, IOError, MutagenError) as exc: -+ except UnreadableFileError as exc: - raise WriteError(self.path, exc) - - # The file has a new mtime. -diff --git a/beets/mediafile.py b/beets/mediafile.py -index 556b41bb..026da3e1 100644 ---- a/beets/mediafile.py -+++ b/beets/mediafile.py -@@ -1344,32 +1344,12 @@ class MediaFile(object): - path = syspath(path) - self.path = path - -- unreadable_exc = ( -- mutagen.mp3.error, -- mutagen.id3.error, -- mutagen.flac.error, -- mutagen.monkeysaudio.MonkeysAudioHeaderError, -- mutagen.mp4.error, -- mutagen.oggopus.error, -- mutagen.oggvorbis.error, -- mutagen.ogg.error, -- mutagen.asf.error, -- mutagen.apev2.error, -- mutagen.aiff.error, -- ) - try: - self.mgfile = mutagen.File(path) -- except unreadable_exc as exc: -- log.debug(u'header parsing failed: {0}', unicode(exc)) -+ except (mutagen.MutagenError, IOError) as exc: -+ # Mutagen <1.33 could raise IOError -+ log.debug(u'parsing failed: {0}', unicode(exc)) - raise UnreadableFileError(path) -- except IOError as exc: -- if type(exc) == IOError: -- # This is a base IOError, not a subclass from Mutagen or -- # anywhere else. -- raise -- else: -- log.debug(u'{}', traceback.format_exc()) -- raise MutagenError(path, exc) - except Exception as exc: - # Isolate bugs in Mutagen. - log.debug(u'{}', traceback.format_exc()) -@@ -1426,7 +1406,8 @@ class MediaFile(object): - self.id3v23 = id3v23 and self.type == 'mp3' - - def save(self): -- """Write the object's tags back to the file. -+ """Write the object's tags back to the file. May -+ throw `UnreadableFileError`. - """ - # Possibly save the tags to ID3v2.3. - kwargs = {} -@@ -1438,27 +1419,41 @@ class MediaFile(object): - id3.update_to_v23() - kwargs['v2_version'] = 3 - -- # Isolate bugs in Mutagen. - try: - self.mgfile.save(**kwargs) -- except (IOError, OSError): -- # Propagate these through: they don't represent Mutagen bugs. -- raise -+ except (mutagen.MutagenError, IOError) as exc: -+ # Mutagen <1.33 could raise IOError -+ log.debug(u'saving failed: {0}', unicode(exc)) -+ raise UnreadableFileError(self.path) - except Exception as exc: -+ # Isolate bugs in Mutagen. - log.debug(u'{}', traceback.format_exc()) - log.error(u'uncaught Mutagen exception in save: {0}', exc) - raise MutagenError(self.path, exc) - - def delete(self): -- """Remove the current metadata tag from the file. -+ """Remove the current metadata tag from the file. May -+ throw `UnreadableFileError`. - """ -+ - try: -- self.mgfile.delete() -- except NotImplementedError: -- # For Mutagen types that don't support deletion (notably, -- # ASF), just delete each tag individually. -- for tag in self.mgfile.keys(): -- del self.mgfile[tag] -+ try: -+ self.mgfile.delete() -+ except NotImplementedError: -+ # FIXME: This is fixed in mutagen >=1.31 -+ # For Mutagen types that don't support deletion (notably, -+ # ASF), just delete each tag individually. -+ for tag in self.mgfile.keys(): -+ del self.mgfile[tag] -+ except (mutagen.MutagenError, IOError) as exc: -+ # Mutagen <1.33 could raise IOError -+ log.debug(u'deleting failed: {0}', unicode(exc)) -+ raise UnreadableFileError(self.path) -+ except Exception as exc: -+ # Isolate bugs in Mutagen. -+ log.debug(u'{}', traceback.format_exc()) -+ log.error(u'uncaught Mutagen exception in save: {0}', exc) -+ raise MutagenError(self.path, exc) - - # Convenient access to the set of available fields. - -diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py -index ed4040d5..4dcefe57 100644 ---- a/beetsplug/scrub.py -+++ b/beetsplug/scrub.py -@@ -119,7 +119,7 @@ class ScrubPlugin(BeetsPlugin): - try: - mf = mediafile.MediaFile(util.syspath(item.path), - config['id3v23'].get(bool)) -- except IOError as exc: -+ except mediafile.UnreadableFileError as exc: - self._log.error(u'could not open file to scrub: {0}', - exc) - art = mf.art -@@ -133,10 +133,13 @@ class ScrubPlugin(BeetsPlugin): - item.try_write() - if art: - self._log.debug(u'restoring art') -- mf = mediafile.MediaFile(util.syspath(item.path), -- config['id3v23'].get(bool)) -- mf.art = art -- mf.save() -+ try: -+ mf = mediafile.MediaFile(util.syspath(item.path), -+ config['id3v23'].get(bool)) -+ mf.art = art -+ mf.save() -+ except mediafile.UnreadableFileError as exc: -+ self._log.error(u'could not write tags: {0}', exc) - - def import_task_files(self, session, task): - """Automatically scrub imported files.""" -diff --git a/test/test_mediafile.py b/test/test_mediafile.py -index e2fdd9fc..2ec3704f 100644 ---- a/test/test_mediafile.py -+++ b/test/test_mediafile.py -@@ -28,7 +28,7 @@ from test import _common - from test._common import unittest - from beets.mediafile import MediaFile, MediaField, Image, \ - MP3DescStorageStyle, StorageStyle, MP4StorageStyle, \ -- ASFStorageStyle, ImageType, CoverArtField -+ ASFStorageStyle, ImageType, CoverArtField, UnreadableFileError - from beets.library import Item - from beets.plugins import BeetsPlugin - from beets.util import bytestring_path -@@ -453,6 +453,27 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, - if os.path.isdir(self.temp_dir): - shutil.rmtree(self.temp_dir) - -+ def test_read_nonexisting(self): -+ mediafile = self._mediafile_fixture('full') -+ os.remove(mediafile.path) -+ self.assertRaises(UnreadableFileError, MediaFile, mediafile.path) -+ -+ def test_save_nonexisting(self): -+ mediafile = self._mediafile_fixture('full') -+ os.remove(mediafile.path) -+ try: -+ mediafile.save() -+ except UnreadableFileError: -+ pass -+ -+ def test_delete_nonexisting(self): -+ mediafile = self._mediafile_fixture('full') -+ os.remove(mediafile.path) -+ try: -+ mediafile.delete() -+ except UnreadableFileError: -+ pass -+ - def test_read_audio_properties(self): - mediafile = self._mediafile_fixture('full') - for key, value in self.audio_properties.items(): diff -Nru beets-1.3.19/debian/patches/no-discogs beets-1.4.6/debian/patches/no-discogs --- beets-1.3.19/debian/patches/no-discogs 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/debian/patches/no-discogs 2018-03-12 22:20:40.000000000 +0000 @@ -0,0 +1,50 @@ +From: Stefano Rivera <stefanor@debian.org> +Date: Mon, 12 Mar 2018 14:01:05 -0700 +Subject: Discogs is not available in debian. + +--- + setup.py | 1 - + test/test_discogs.py | 1 + + test/testall.py | 5 ++++- + 3 files changed, 5 insertions(+), 2 deletions(-) + +diff --git a/setup.py b/setup.py +index 24c5710..dd89401 100755 +--- a/setup.py ++++ b/setup.py +@@ -106,7 +106,6 @@ setup( + 'pyxdg', + 'pathlib', + 'python-mpd2', +- 'discogs-client' + ], + + # Plugin (optional) dependencies: +diff --git a/test/test_discogs.py b/test/test_discogs.py +index 74f9a56..927ebcd 100644 +--- a/test/test_discogs.py ++++ b/test/test_discogs.py +@@ -22,6 +22,7 @@ from test import _common + from test._common import Bag + from test.helper import capture_log + ++raise unittest.SkipTest("discogs is not available in Debian") + from beetsplug.discogs import DiscogsPlugin + + +diff --git a/test/testall.py b/test/testall.py +index 88eb701..01081eb 100755 +--- a/test/testall.py ++++ b/test/testall.py +@@ -35,7 +35,10 @@ def suite(): + match = re.match(r'(test_\S+)\.py$', fname) + if match: + modname = match.group(1) +- s.addTest(__import__(modname).suite()) ++ try: ++ s.addTest(__import__(modname).suite()) ++ except unittest.SkipTest: ++ continue + return s + + diff -Nru beets-1.3.19/debian/patches/no-jellyfish beets-1.4.6/debian/patches/no-jellyfish --- beets-1.3.19/debian/patches/no-jellyfish 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/no-jellyfish 1970-01-01 00:00:00.000000000 +0000 @@ -1,86 +0,0 @@ -Description: Bundle levenshtein_distance from jellyfish - Debian already has a Python library called jellyfish. While we resolve that - problem, let's avoid the need for re-packaging jellyfish. -Bug-Debian: https://bugs.debian.org/806716 -Author: Stefano Rivera <stefanor@debian.org> - ---- a/beets/autotag/hooks.py -+++ b/beets/autotag/hooks.py -@@ -25,7 +25,7 @@ - from beets import config - from beets.util import as_string - from beets.autotag import mb --from jellyfish import levenshtein_distance -+from beets.util._jellyfish import levenshtein_distance - from unidecode import unidecode - - log = logging.getLogger('beets') ---- /dev/null -+++ b/beets/util/_jellyfish.py -@@ -0,0 +1,56 @@ -+# Borrowed from Jellyfish (https://github.com/jamesturk/jellyfish) -+# Copyright (c) 2015, James Turk -+# Copyright (c) 2015, Sunlight Foundation -+# -+# All rights reserved. -+# -+# Redistribution and use in source and binary forms, with or without -+# modification, are permitted provided that the following conditions are met: -+# -+# * Redistributions of source code must retain the above copyright notice, -+# this list of conditions and the following disclaimer. -+# * Redistributions in binary form must reproduce the above copyright -+# notice, this list of conditions and the following disclaimer in the -+# documentation and/or other materials provided with the distribution. -+# -+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -+# POSSIBILITY OF SUCH DAMAGE. -+ -+_range = xrange -+_no_bytes_err = 'expected unicode, got str' -+ -+ -+def levenshtein_distance(s1, s2): -+ if isinstance(s1, bytes) or isinstance(s2, bytes): -+ raise TypeError(_no_bytes_err) -+ -+ if s1 == s2: -+ return 0 -+ rows = len(s1)+1 -+ cols = len(s2)+1 -+ -+ if not s1: -+ return cols-1 -+ if not s2: -+ return rows-1 -+ -+ prev = None -+ cur = range(cols) -+ for r in _range(1, rows): -+ prev, cur = cur, [r] + [0]*(cols-1) -+ for c in _range(1, cols): -+ deletion = prev[c] + 1 -+ insertion = cur[c-1] + 1 -+ edit = prev[c-1] + (0 if s1[r-1] == s2[c-1] else 1) -+ cur[c] = min(edit, deletion, insertion) -+ -+ return cur[-1] ---- a/setup.py -+++ b/setup.py -@@ -92,7 +92,6 @@ - 'unidecode', - 'musicbrainzngs>=0.4', - 'pyyaml', -- 'jellyfish', - ] + (['colorama'] if (sys.platform == 'win32') else []), - - tests_require=[ diff -Nru beets-1.3.19/debian/patches/pathlib-is-stdlib beets-1.4.6/debian/patches/pathlib-is-stdlib --- beets-1.3.19/debian/patches/pathlib-is-stdlib 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/debian/patches/pathlib-is-stdlib 2018-03-12 22:20:40.000000000 +0000 @@ -0,0 +1,21 @@ +From: Stefano Rivera <stefanor@debian.org> +Date: Mon, 12 Mar 2018 14:15:58 -0700 +Subject: pathlib is stdlib + +We don't have a python3-pathlib package in Debian. +--- + setup.py | 1 - + 1 file changed, 1 deletion(-) + +diff --git a/setup.py b/setup.py +index dd89401..a0c9498 100755 +--- a/setup.py ++++ b/setup.py +@@ -104,7 +104,6 @@ setup( + 'rarfile', + 'responses', + 'pyxdg', +- 'pathlib', + 'python-mpd2', + ], + diff -Nru beets-1.3.19/debian/patches/series beets-1.4.6/debian/patches/series --- beets-1.3.19/debian/patches/series 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/series 2018-03-12 22:20:40.000000000 +0000 @@ -1,7 +1,4 @@ -fix-test_hidden -no-jellyfish -fix-test_mediafile_edge -fix-test_nonexistent_file -skip-test_query-path-tests -mediafile-Cleanup-mutagen-error-handling.patch -Test-true-FLAC-bitrate-from-Mutagen-1.35.patch +update-unidecode-tests +no-discogs +pathlib-is-stdlib +skip-broken-test diff -Nru beets-1.3.19/debian/patches/skip-broken-test beets-1.4.6/debian/patches/skip-broken-test --- beets-1.3.19/debian/patches/skip-broken-test 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/debian/patches/skip-broken-test 2018-03-12 22:20:40.000000000 +0000 @@ -0,0 +1,21 @@ +From: Stefano Rivera <stefanor@debian.org> +Date: Mon, 12 Mar 2018 14:36:58 -0700 +Subject: Skip test_command_line_option_relative_to_working_dir + +Bug-Upstream: https://github.com/beetbox/beets/issues/2400 +--- + test/test_ui.py | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/test/test_ui.py b/test/test_ui.py +index 77804d3..e8da774 100644 +--- a/test/test_ui.py ++++ b/test/test_ui.py +@@ -907,6 +907,7 @@ class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions): + os.path.join(self.beetsdir, b'state') + ) + ++ @unittest.skip("Broken") + def test_command_line_option_relative_to_working_dir(self): + os.chdir(self.temp_dir) + self.run_command('--library', 'foo.db', 'test', lib=None) diff -Nru beets-1.3.19/debian/patches/skip-test_query-path-tests beets-1.4.6/debian/patches/skip-test_query-path-tests --- beets-1.3.19/debian/patches/skip-test_query-path-tests 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/skip-test_query-path-tests 1970-01-01 00:00:00.000000000 +0000 @@ -1,80 +0,0 @@ -Description: Skip failing tests caused by SQLITE_LIKE_DOESNT_MATCH_BLOBS - Path matching in beets is broken by SQLITE_LIKE_DOESNT_MATCH_BLOBS. - Let's just skip these tests until upstream has a solution. -Author: Stefano Rivera <stefanor@debian.org> -Bug-Upstream: https://github.com/beetbox/beets/issues/2172 - ---- a/test/test_query.py -+++ b/test/test_query.py -@@ -411,6 +411,7 @@ - self.patcher_samefile.stop() - self.patcher_exists.stop() - -+ @unittest.skip('unfixed (#2172)') - def test_path_exact_match(self): - q = u'path:/a/b/c.mp3' - results = self.lib.items(q) -@@ -419,6 +420,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, []) - -+ @unittest.skip('unfixed (#2172)') - def test_parent_directory_no_slash(self): - q = u'path:/a' - results = self.lib.items(q) -@@ -427,6 +429,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, [u'path album']) - -+ @unittest.skip('unfixed (#2172)') - def test_parent_directory_with_slash(self): - q = u'path:/a/' - results = self.lib.items(q) -@@ -451,6 +454,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, []) - -+ @unittest.skip('unfixed (#2172)') - def test_nonnorm_path(self): - q = u'path:/x/../a/b' - results = self.lib.items(q) -@@ -459,6 +463,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, [u'path album']) - -+ @unittest.skip('unfixed (#2172)') - def test_slashed_query_matches_path(self): - q = u'/a/b' - results = self.lib.items(q) -@@ -496,6 +501,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, [u'path album']) - -+ @unittest.skip('unfixed (#2172)') - def test_escape_underscore(self): - self.add_album(path=b'/a/_/title.mp3', title=u'with underscore', - album=u'album with underscore') -@@ -506,6 +512,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, [u'album with underscore']) - -+ @unittest.skip('unfixed (#2172)') - def test_escape_percent(self): - self.add_album(path=b'/a/%/title.mp3', title=u'with percent', - album=u'album with percent') -@@ -516,6 +523,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, [u'album with percent']) - -+ @unittest.skip('unfixed (#2172)') - def test_escape_backslash(self): - self.add_album(path=br'/a/\x/title.mp3', title=u'with backslash', - album=u'album with backslash') -@@ -526,6 +534,7 @@ - results = self.lib.albums(q) - self.assert_albums_matched(results, [u'album with backslash']) - -+ @unittest.skip('unfixed (#2172)') - def test_case_sensitivity(self): - self.add_album(path=b'/A/B/C2.mp3', title=u'caps path') - diff -Nru beets-1.3.19/debian/patches/Test-true-FLAC-bitrate-from-Mutagen-1.35.patch beets-1.4.6/debian/patches/Test-true-FLAC-bitrate-from-Mutagen-1.35.patch --- beets-1.3.19/debian/patches/Test-true-FLAC-bitrate-from-Mutagen-1.35.patch 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/patches/Test-true-FLAC-bitrate-from-Mutagen-1.35.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,24 +0,0 @@ -From: Adrian Sampson <adrian@radbox.org> -Date: Fri, 23 Dec 2016 20:23:23 -0500 -Subject: Test "true" FLAC bitrate from Mutagen 1.35 - -Fix #2343. - -Origin: upstream, 1.4.3, commit:10f0d03d790da2e849125104a718a9f14ac535e6 ---- - 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 2ec3704f..6fea9ab7 100644 ---- a/test/test_mediafile.py -+++ b/test/test_mediafile.py -@@ -896,7 +896,7 @@ class FlacTest(ReadWriteTestBase, PartialTestMixin, - extension = 'flac' - audio_properties = { - 'length': 1.0, -- 'bitrate': 175120, -+ 'bitrate': 108688, - 'format': u'FLAC', - 'samplerate': 44100, - 'bitdepth': 16, diff -Nru beets-1.3.19/debian/patches/update-unidecode-tests beets-1.4.6/debian/patches/update-unidecode-tests --- beets-1.3.19/debian/patches/update-unidecode-tests 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/debian/patches/update-unidecode-tests 2018-03-12 22:20:40.000000000 +0000 @@ -0,0 +1,34 @@ +From: Adrian Sampson <adrian@radbox.org> +Date: Tue, 30 Jan 2018 20:50:43 -0500 +Subject: Update tests for new Unidecode behavior: fix #2799 + +The library has started putting spaces around the expanded versions of +vulgar fraction characters. + +Origin: upstream, https://github.com/beetbox/beets/commit/9577a511cb055f143deb2ad8f2b801595b5f5c3f +--- + test/test_library.py | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/test/test_library.py b/test/test_library.py +index aaab6fe..ea56696 100644 +--- a/test/test_library.py ++++ b/test/test_library.py +@@ -427,7 +427,7 @@ class DestinationTest(_common.TestCase): + self.lib.directory = b'lib' + self.lib.path_formats = [(u'default', u'$title')] + self.i.title = u'ab\xa2\xbdd' +- self.assertEqual(self.i.destination(), np('lib/abC_1_2d')) ++ self.assertEqual(self.i.destination(), np('lib/abC_ 1_2 d')) + + def test_destination_with_replacements(self): + self.lib.directory = b'base' +@@ -591,7 +591,7 @@ class DestinationFunctionTest(_common.TestCase, PathFormattingMixin): + + def test_asciify_variable(self): + self._setf(u'%asciify{ab\xa2\xbdd}') +- self._assert_dest(b'/base/abC_1_2d') ++ self._assert_dest(b'/base/abC_ 1_2 d') + + def test_left_variable(self): + self._setf(u'%left{$title, 3}') diff -Nru beets-1.3.19/debian/rules beets-1.4.6/debian/rules --- beets-1.3.19/debian/rules 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/rules 2018-03-12 22:20:40.000000000 +0000 @@ -1,39 +1,14 @@ #!/usr/bin/make -f +export PYBUILD_INSTALL_ARGS=--install-lib=/usr/share/beets/ --install-scripts=/usr/share/beets/ +export LC_ALL=C.UTF-8 + %: - dh $@ --with python2,sphinxdoc + dh $@ --with python3,sphinxdoc --buildsystem=pybuild override_dh_auto_build: dh_auto_build - PYTHONPATH=$(CURDIR) $(MAKE) -C docs html man BUILDDIR=$(CURDIR)/build/docs - -override_dh_auto_test: -ifeq (,$(filter nocheck,$(DEB_BUILD_OPTIONS))) - # Tests (non-destructively) open the database - mkdir -p build/home - set -e -x; \ - for python in $(shell pyversions -r); do \ - HOME=$(CURDIR)/build/home \ - LC_ALL=C.UTF-8 \ - $$python setup.py test; \ - done -endif - -override_dh_auto_install: - dh_auto_install -- --install-lib=/usr/share/beets/ - -override_dh_install: - dh_install - mv debian/beets/usr/bin/beet debian/beets/usr/share/beets - dh_link /usr/share/beets/beet /usr/bin/beet - -override_dh_auto_clean: - dh_auto_clean - rm -rf build - rm -rf beets.egg-info + PYTHONPATH=$(CURDIR) $(MAKE) -C docs html man override_dh_installchangelogs: dh_installchangelogs docs/changelog.rst - -override_dh_compress: - dh_compress -X.html -X.txt -X.inv diff -Nru beets-1.3.19/debian/tests/control beets-1.4.6/debian/tests/control --- beets-1.3.19/debian/tests/control 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/tests/control 2018-03-12 22:20:40.000000000 +0000 @@ -1,13 +1,4 @@ Tests: unittests Depends: beets, - python-all, - python-bs4, - python-flask, - python-mock, - python-mpd, - python-pathlib, - python-pylast, - python-rarfile, - python-responses, - python-xdg + @builddeps@ diff -Nru beets-1.3.19/debian/tests/unittests beets-1.4.6/debian/tests/unittests --- beets-1.3.19/debian/tests/unittests 2017-01-23 09:41:08.000000000 +0000 +++ beets-1.4.6/debian/tests/unittests 2018-03-12 22:20:40.000000000 +0000 @@ -5,7 +5,7 @@ export LC_ALL=C.UTF-8 export PYTHONPATH=/usr/share/beets -pythons="$(pyversions -r)" +pythons="$(py3versions -r)" cp -a test $ADTTMP cd "$ADTTMP" diff -Nru beets-1.3.19/docs/changelog.rst beets-1.4.6/docs/changelog.rst --- beets-1.3.19/docs/changelog.rst 2016-06-26 00:52:28.000000000 +0000 +++ beets-1.4.6/docs/changelog.rst 2017-12-21 18:12:02.000000000 +0000 @@ -1,6 +1,496 @@ Changelog ========= +1.4.6 (December 21, 2017) +------------------------- + +The highlight of this release is "album merging," an oft-requested option in +the importer to add new tracks to an existing album you already have in your +library. This way, you no longer need to resort to removing the partial album +from your library, combining the files manually, and importing again. + +Here are the larger new features in this release: + +* When the importer finds duplicate albums, you can now merge all the + tracks---old and new---together and try importing them as a single, combined + album. + Thanks to :user:`udiboy1209`. + :bug:`112` :bug:`2725` +* :doc:`/plugins/lyrics`: The plugin can now produce reStructuredText files + for beautiful, readable books of lyrics. Thanks to :user:`anarcat`. + :bug:`2628` +* A new :ref:`from_scratch` configuration option makes the importer remove old + metadata before applying new metadata. This new feature complements the + :doc:`zero </plugins/zero>` and :doc:`scrub </plugins/scrub>` plugins but is + slightly different: beets clears out all the old tags it knows about and + only keeps the new data it gets from the remote metadata source. + Thanks to :user:`tummychow`. + :bug:`934` :bug:`2755` + +There are also somewhat littler, but still great, new features: + +* :doc:`/plugins/convert`: A new ``no_convert`` option lets you skip + transcoding items matching a query. Instead, the files are just copied + as-is. Thanks to :user:`Stunner`. + :bug:`2732` :bug:`2751` +* :doc:`/plugins/fetchart`: A new quiet switch that only prints out messages + when album art is missing. + Thanks to :user:`euri10`. + :bug:`2683` +* :doc:`/plugins/mbcollection`: You can configure a custom MusicBrainz + collection via the new ``collection`` configuration option. + :bug:`2685` +* :doc:`/plugins/mbcollection`: The collection update command can now remove + albums from collections that are longer in the beets library. +* :doc:`/plugins/fetchart`: The ``clearart`` command now asks for confirmation + before touching your files. + Thanks to :user:`konman2`. + :bug:`2708` :bug:`2427` +* :doc:`/plugins/mpdstats`: The plugin now correctly updates song statistics + when MPD switches from a song to a stream and when it plays the same song + multiple times consecutively. + :bug:`2707` +* :doc:`/plugins/acousticbrainz`: The plugin can now be configured to write only + a specific list of tags. + Thanks to :user:`woparry`. + +There are lots and lots of bug fixes: + +* :doc:`/plugins/hook`: Fixed a problem where accessing non-string properties + of ``item`` or ``album`` (e.g., ``item.track``) would cause a crash. + Thanks to :user:`broddo`. + :bug:`2740` +* :doc:`/plugins/play`: When ``relative_to`` is set, the plugin correctly + emits relative paths even when querying for albums rather than tracks. + Thanks to :user:`j000`. + :bug:`2702` +* We suppress a spurious Python warning about a ``BrokenPipeError`` being + ignored. This was an issue when using beets in simple shell scripts. + Thanks to :user:`Azphreal`. + :bug:`2622` :bug:`2631` +* :doc:`/plugins/replaygain`: Fix a regression in the previous release related + to the new R128 tags. :bug:`2615` :bug:`2623` +* :doc:`/plugins/lyrics`: The MusixMatch backend now detects and warns + when the server has blocked the client. + Thanks to :user:`anarcat`. :bug:`2634` :bug:`2632` +* :doc:`/plugins/importfeeds`: Fix an error on Python 3 in certain + configurations. Thanks to :user:`djl`. :bug:`2467` :bug:`2658` +* :doc:`/plugins/edit`: Fix a bug when editing items during a re-import with + the ``-L`` flag. Previously, diffs against against unrelated items could be + shown or beets could crash. :bug:`2659` +* :doc:`/plugins/kodiupdate`: Fix the server URL and add better error + reporting. + :bug:`2662` +* Fixed a problem where "no-op" modifications would reset files' mtimes, + resulting in unnecessary writes. This most prominently affected the + :doc:`/plugins/edit` when saving the text file without making changes to some + music. :bug:`2667` +* :doc:`/plugins/chroma`: Fix a crash when running the ``submit`` command on + Python 3 on Windows with non-ASCII filenames. :bug:`2671` +* :doc:`/plugins/absubmit`: Fix an occasional crash on Python 3 when the AB + analysis tool produced non-ASCII metadata. :bug:`2673` +* :doc:`/plugins/duplicates`: Use the default tiebreak for items or albums + when the configuration only specifies a tiebreak for the other kind of + entity. + Thanks to :user:`cgevans`. + :bug:`2758` +* :doc:`/plugins/duplicates`: Fix the ``--key`` command line option, which was + ignored. +* :doc:`/plugins/replaygain`: Fix album ReplayGain calculation with the + GStreamer backend. :bug:`2636` +* :doc:`/plugins/scrub`: Handle errors when manipulating files using newer + versions of Mutagen. :bug:`2716` +* :doc:`/plugins/fetchart`: The plugin no longer gets skipped during import + when the "Edit Candidates" option is used from the :doc:`/plugins/edit`. + :bug:`2734` +* Fix a crash when numeric metadata fields contain just a minus or plus sign + with no following numbers. Thanks to :user:`eigengrau`. :bug:`2741` +* :doc:`/plugins/fromfilename`: Recognize file names that contain *only* a + track number, such as `01.mp3`. Also, the plugin now allows underscores as a + separator between fields. + Thanks to :user:`Vrihub`. + :bug:`2738` :bug:`2759` +* Fixed an issue where images would be resized according to their longest + edge, instead of their width, when using the ``maxwidth`` config option in + the :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`. Thanks to + :user:`sekjun9878`. :bug:`2729` + +There are some changes for developers: + +* "Fixed fields" in Album and Item objects are now more strict about translating + missing values into type-specific null-like values. This should help in + cases where a string field is unexpectedly `None` sometimes instead of just + showing up as an empty string. :bug:`2605` +* Refactored the move functions the `beets.library` module and the + `manipulate_files` function in `beets.importer` to use a single parameter + describing the file operation instead of multiple Boolean flags. + There is a new numerated type describing how to move, copy, or link files. + :bug:`2682` + + +1.4.5 (June 20, 2017) +--------------------- + +Version 1.4.5 adds some oft-requested features. When you're importing files, +you can now manually set fields on the new music. Date queries have gotten +much more powerful: you can write precise queries down to the second, and we +now have *relative* queries like ``-1w``, which means *one week ago*. + +Here are the new features: + +* You can now set fields to certain values during :ref:`import-cmd`, using + either a ``--set field=value`` command-line flag or a new :ref:`set_fields` + configuration option under the `importer` section. + Thanks to :user:`bartkl`. :bug:`1881` :bug:`2581` +* :ref:`Date queries <datequery>` can now include times, so you can filter + your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506` + :bug:`2528` +* :ref:`Date queries <datequery>` can also be *relative*. You can say + ``added:-1w..`` to match music added in the last week, for example. Thanks + to :user:`euri10`. :bug:`2598` +* A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music + library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586` +* :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from + classic ReplayGain data for formats that need it (namely, Ogg Opus). A new + `r128` configuration option enables this behavior for specific formats. + Thanks to :user:`autrimpo`. :bug:`2557` :bug:`2560` +* The :ref:`move-cmd` command gained a new ``--export`` flag, which copies + files to an external location without changing their paths in the library + database. Thanks to :user:`SpirosChadoulos`. :bug:`435` :bug:`2510` + +There are also some bug fixes: + +* :doc:`/plugins/lastgenre`: Fix a crash when using the `prefer_specific` and + `canonical` options together. Thanks to :user:`yacoob`. :bug:`2459` + :bug:`2583` +* :doc:`/plugins/web`: Fix a crash on Windows under Python 2 when serving + non-ASCII filenames. Thanks to :user:`robot3498712`. :bug:`2592` :bug:`2593` +* :doc:`/plugins/metasync`: Fix a crash in the Amarok backend when filenames + contain quotes. Thanks to :user:`aranc23`. :bug:`2595` :bug:`2596` +* More informative error messages are displayed when the file format is not + recognized. :bug:`2599` + + +1.4.4 (June 10, 2017) +--------------------- + +This release built up a longer-than-normal list of nifty new features. We now +support DSF audio files and the importer can hard-link your files, for +example. + +Here's a full list of new features: + +* Added support for DSF files, once a future version of Mutagen is released + that supports them. Thanks to :user:`docbobo`. :bug:`459` :bug:`2379` +* A new :ref:`hardlink` config option instructs the importer to create hard + links on filesystems that support them. Thanks to :user:`jacobwgillespie`. + :bug:`2445` +* A new :doc:`/plugins/kodiupdate` lets you keep your Kodi library in sync + with beets. Thanks to :user:`Pauligrinder`. :bug:`2411` +* A new :ref:`bell` configuration option under the ``import`` section enables + a terminal bell when input is required. Thanks to :user:`SpirosChadoulos`. + :bug:`2366` :bug:`2495` +* A new field, ``composer_sort``, is now supported and fetched from + MusicBrainz. + Thanks to :user:`dosoe`. + :bug:`2519` :bug:`2529` +* The MusicBrainz backend and :doc:`/plugins/discogs` now both provide a new + attribute called ``track_alt`` that stores more nuanced, possibly + non-numeric track index data. For example, some vinyl or tape media will + report the side of the record using a letter instead of a number in that + field. :bug:`1831` :bug:`2363` +* :doc:`/plugins/web`: Added a new endpoint, ``/item/path/foo``, which will + return the item info for the file at the given path, or 404. +* :doc:`/plugins/web`: Added a new config option, ``include_paths``, + which will cause paths to be included in item API responses if set to true. +* The ``%aunique`` template function for :ref:`aunique` now takes a third + argument that specifies which brackets to use around the disambiguator + value. The argument can be any two characters that represent the left and + right brackets. It defaults to `[]` and can also be blank to turn off + bracketing. :bug:`2397` :bug:`2399` +* Added a ``--move`` or ``-m`` option to the importer so that the files can be + moved to the library instead of being copied or added "in place." + :bug:`2252` :bug:`2429` +* :doc:`/plugins/badfiles`: Added a ``--verbose`` or ``-v`` option. Results are + now displayed only for corrupted files by default and for all the files when + the verbose option is set. :bug:`1654` :bug:`2434` +* :doc:`/plugins/embedart`: The explicit ``embedart`` command now asks for + confirmation before embedding art into music files. Thanks to + :user:`Stunner`. :bug:`1999` +* You can now run beets by typing `python -m beets`. :bug:`2453` +* :doc:`/plugins/smartplaylist`: Different playlist specifications that + generate identically-named playlist files no longer conflict; instead, the + resulting lists of tracks are concatenated. :bug:`2468` +* :doc:`/plugins/missing`: A new mode lets you see missing albums from artists + you have in your library. Thanks to :user:`qlyoung`. :bug:`2481` +* :doc:`/plugins/web` : Add new `reverse_proxy` config option to allow serving + the web plugins under a reverse proxy. +* Importing a release with multiple release events now selects the + event based on your :ref:`preferred` countries. :bug:`2501` +* :doc:`/plugins/play`: A new ``-y`` or ``--yes`` parameter lets you skip + the warning message if you enqueue more items than the warning threshold + usually allows. +* Fix a bug where commands which forked subprocesses would sometimes prevent + further inputs. This bug mainly affected :doc:`/plugins/convert`. + Thanks to :user:`jansol`. + :bug:`2488` + :bug:`2524` + +There are also quite a few fixes: + +* In the :ref:`replace` configuration option, we now replace a leading hyphen + (-) with an underscore. :bug:`549` :bug:`2509` +* :doc:`/plugins/absubmit`: We no longer filter audio files for specific + formats---we will attempt the submission process for all formats. :bug:`2471` +* :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381` +* :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain`` + backend. :bug:`2382` +* :doc:`/plugins/bpd`: Report playback times as integers. :bug:`2394` +* :doc:`/plugins/mpdstats`: Fix Python 3 compatibility. The plugin also now + requires version 0.4.2 or later of the ``python-mpd2`` library. :bug:`2405` +* :doc:`/plugins/mpdstats`: Improve handling of MPD status queries. +* :doc:`/plugins/badfiles`: Fix Python 3 compatibility. +* Fix some cases where album-level ReplayGain/SoundCheck metadata would be + written to files incorrectly. :bug:`2426` +* :doc:`/plugins/badfiles`: The command no longer bails out if the validator + command is not found or exits with an error. :bug:`2430` :bug:`2433` +* :doc:`/plugins/lyrics`: The Google search backend no longer crashes when the + server responds with an error. :bug:`2437` +* :doc:`/plugins/discogs`: You can now authenticate with Discogs using a + personal access token. :bug:`2447` +* Fix Python 3 compatibility when extracting rar archives in the importer. + Thanks to :user:`Lompik`. :bug:`2443` :bug:`2448` +* :doc:`/plugins/duplicates`: Fix Python 3 compatibility when using the + ``copy`` and ``move`` options. :bug:`2444` +* :doc:`/plugins/mbsubmit`: The tracks are now sorted properly. Thanks to + :user:`awesomer`. :bug:`2457` +* :doc:`/plugins/thumbnails`: Fix a string-related crash on Python 3. + :bug:`2466` +* :doc:`/plugins/beatport`: More than just 10 songs are now fetched per album. + :bug:`2469` +* On Python 3, the :ref:`terminal_encoding` setting is respected again for + output and printing will no longer crash on systems configured with a + limited encoding. +* :doc:`/plugins/convert`: The default configuration uses FFmpeg's built-in + AAC codec instead of faac. Thanks to :user:`jansol`. :bug:`2484` +* Fix the importer's detection of multi-disc albums when other subdirectories + are present. :bug:`2493` +* Invalid date queries now print an error message instead of being silently + ignored. Thanks to :user:`discopatrick`. :bug:`2513` :bug:`2517` +* When the SQLite database stops being accessible, we now print a friendly + error message. Thanks to :user:`Mary011196`. :bug:`1676` :bug:`2508` +* :doc:`/plugins/web`: Avoid a crash when sending binary data, such as + Chromaprint fingerprints, in music attributes. :bug:`2542` :bug:`2532` +* Fix a hang when parsing templates that end in newlines. :bug:`2562` +* Fix a crash when reading non-ASCII characters in configuration files on + Windows under Python 3. :bug:`2456` :bug:`2565` :bug:`2566` + +We removed backends from two metadata plugins because of bitrot: + +* :doc:`/plugins/lyrics`: The Lyrics.com backend has been removed. (It stopped + working because of changes to the site's URL structure.) + :bug:`2548` :bug:`2549` +* :doc:`/plugins/fetchart`: The documentation no longer recommends iTunes + Store artwork lookup because the unmaintained `python-itunes`_ is broken. + Want to adopt it? :bug:`2371` :bug:`1610` + +.. _python-itunes: https://github.com/ocelma/python-itunes + + +1.4.3 (January 9, 2017) +----------------------- + +Happy new year! This new version includes a cornucopia of new features from +contributors, including new tags related to classical music and a new +:doc:`/plugins/absubmit` for performing acoustic analysis on your music. The +:doc:`/plugins/random` has a new mode that lets you generate time-limited +music---for example, you might generate a random playlist that lasts the +perfect length for your walk to work. We also access as many Web services as +possible over secure connections now---HTTPS everywhere! + +The most visible new features are: + +* We now support the composer, lyricist, and arranger tags. The MusicBrainz + data source will fetch data for these fields when the next version of + `python-musicbrainzngs`_ is released. Thanks to :user:`ibmibmibm`. + :bug:`506` :bug:`507` :bug:`1547` :bug:`2333` +* A new :doc:`/plugins/absubmit` lets you run acoustic analysis software and + upload the results for others to use. Thanks to :user:`inytar`. :bug:`2253` + :bug:`2342` +* :doc:`/plugins/play`: The plugin now provides an importer prompt choice to + play the music you're about to import. Thanks to :user:`diomekes`. + :bug:`2008` :bug:`2360` +* We now use SSL to access Web services whenever possible. That includes + MusicBrainz itself, several album art sources, some lyrics sources, and + other servers. Thanks to :user:`tigranl`. :bug:`2307` +* :doc:`/plugins/random`: A new ``--time`` option lets you generate a random + playlist that takes a given amount of time. Thanks to :user:`diomekes`. + :bug:`2305` :bug:`2322` + +Some smaller new features: + +* :doc:`/plugins/zero`: A new ``zero`` command manually triggers the zero + plugin. Thanks to :user:`SJoshBrown`. :bug:`2274` :bug:`2329` +* :doc:`/plugins/acousticbrainz`: The plugin will avoid re-downloading data + for files that already have it by default. You can override this behavior + using a new ``force`` option. Thanks to :user:`SusannaMaria`. :bug:`2347` + :bug:`2349` +* :doc:`/plugins/bpm`: The ``import.write`` configuration option now + decides whether or not to write tracks after updating their BPM. :bug:`1992` + +And the fixes: + +* :doc:`/plugins/bpd`: Fix a crash on non-ASCII MPD commands. :bug:`2332` +* :doc:`/plugins/scrub`: Avoid a crash when files cannot be read or written. + :bug:`2351` +* :doc:`/plugins/scrub`: The image type values on scrubbed files are preserved + instead of being reset to "other." :bug:`2339` +* :doc:`/plugins/web`: Fix a crash on Python 3 when serving files from the + filesystem. :bug:`2353` +* :doc:`/plugins/discogs`: Improve the handling of releases that contain + subtracks. :bug:`2318` +* :doc:`/plugins/discogs`: Fix a crash when a release does not contain format + information, and increase robustness when other fields are missing. + :bug:`2302` +* :doc:`/plugins/lyrics`: The plugin now reports a beets-specific User-Agent + header when requesting lyrics. :bug:`2357` +* :doc:`/plugins/embyupdate`: The plugin now checks whether an API key or a + password is provided in the configuration. +* :doc:`/plugins/play`: The misspelled configuration option + ``warning_treshold`` is no longer supported. + +For plugin developers: when providing new importer prompt choices (see +:ref:`append_prompt_choices`), you can now provide new candidates for the user +to consider. For example, you might provide an alternative strategy for +picking between the available alternatives or for looking up a release on +MusicBrainz. + + +1.4.2 (December 16, 2016) +------------------------- + +This is just a little bug fix release. With 1.4.2, we're also confident enough +to recommend that anyone who's interested give Python 3 a try: bugs may still +lurk, but we've deemed things safe enough for broad adoption. If you can, +please install beets with ``pip3`` instead of ``pip2`` this time and let us +know how it goes! + +Here are the fixes: + +* :doc:`/plugins/badfiles`: Fix a crash on non-ASCII filenames. :bug:`2299` +* The ``%asciify{}`` path formatting function and the :ref:`asciify-paths` + setting properly substitute path separators generated by converting some + Unicode characters, such as ½ and ¢, into ASCII. +* :doc:`/plugins/convert`: Fix a logging-related crash when filenames contain + curly braces. Thanks to :user:`kierdavis`. :bug:`2323` +* We've rolled back some changes to the included zsh completion script that + were causing problems for some users. :bug:`2266` + +Also, we've removed some special handling for logging in the +:doc:`/plugins/discogs` that we believe was unnecessary. If spurious log +messages appear in this version, please let us know by filing a bug. + + +1.4.1 (November 25, 2016) +------------------------- + +Version 1.4 has **alpha-level** Python 3 support. Thanks to the heroic efforts +of :user:`jrobeson`, beets should run both under Python 2.7, as before, and +now under Python 3.4 and above. The support is still new: it undoubtedly +contains bugs, so it may replace all your music with Limp Bizkit---but if +you're brave and you have backups, please try installing on Python 3. Let us +know how it goes. + +If you package beets for distribution, here's what you'll want to know: + +* This version of beets now depends on the `six`_ library. +* We also bumped our minimum required version of `Mutagen`_ to 1.33 (from + 1.27). +* Please don't package beets as a Python 3 application *yet*, even though most + things work under Python 3.4 and later. + +This version also makes a few changes to the command-line interface and +configuration that you may need to know about: + +* :doc:`/plugins/duplicates`: The ``duplicates`` command no longer accepts + multiple field arguments in the form ``-k title albumartist album``. Each + argument must be prefixed with ``-k``, as in ``-k title -k albumartist -k + album``. +* The old top-level ``colors`` configuration option has been removed (the + setting is now under ``ui``). +* The deprecated ``list_format_album`` and ``list_format_item`` + configuration options have been removed (see :ref:`format_album` and + :ref:`format_item`). + +The are a few new features: + +* :doc:`/plugins/mpdupdate`, :doc:`/plugins/mpdstats`: When the ``host`` option + is not set, these plugins will now look for the ``$MPD_HOST`` environment + variable before falling back to ``localhost``. Thanks to :user:`tarruda`. + :bug:`2175` +* :doc:`/plugins/web`: Added an ``expand`` option to show the items of an + album. :bug:`2050` +* :doc:`/plugins/embyupdate`: The plugin can now use an API key instead of a + password to authenticate with Emby. :bug:`2045` :bug:`2117` +* :doc:`/plugins/acousticbrainz`: The plugin now adds a ``bpm`` field. +* ``beet --version`` now includes the Python version used to run beets. +* :doc:`/reference/pathformat` can now include unescaped commas (``,``) when + they are not part of a function call. :bug:`2166` :bug:`2213` +* The :ref:`update-cmd` command takes a new ``-F`` flag to specify the fields + to update. Thanks to :user:`dangmai`. :bug:`2229` :bug:`2231` + +And there are a few bug fixes too: + +* :doc:`/plugins/convert`: The plugin no longer asks for confirmation if the + query did not return anything to convert. :bug:`2260` :bug:`2262` +* :doc:`/plugins/embedart`: The plugin now uses ``jpg`` as an extension rather + than ``jpeg``, to ensure consistency with the :doc:`plugins/fetchart`. + Thanks to :user:`tweitzel`. :bug:`2254` :bug:`2255` +* :doc:`/plugins/embedart`: The plugin now works for all jpeg files, including + those that are only recognizable by their magic bytes. + :bug:`1545` :bug:`2255` +* :doc:`/plugins/web`: The JSON output is no longer pretty-printed (for a + space savings). :bug:`2050` +* :doc:`/plugins/permissions`: Fix a regression in the previous release where + the plugin would always fail to set permissions (and log a warning). + :bug:`2089` +* :doc:`/plugins/beatport`: Use track numbers from Beatport (instead of + determining them from the order of tracks) and set the `medium_index` + value. +* With :ref:`per_disc_numbering` enabled, some metadata sources (notably, the + :doc:`/plugins/beatport`) would not set the track number at all. This is + fixed. :bug:`2085` +* :doc:`/plugins/play`: Fix ``$args`` getting passed verbatim to the play + command if it was set in the configuration but ``-A`` or ``--args`` was + omitted. +* With :ref:`ignore_hidden` enabled, non-UTF-8 filenames would cause a crash. + This is fixed. :bug:`2168` +* :doc:`/plugins/embyupdate`: Fixes authentication header problem that caused + a problem that it was not possible to get tokens from the Emby API. +* :doc:`/plugins/lyrics`: Some titles use a colon to separate the main title + from a subtitle. To find more matches, the plugin now also searches for + lyrics using the part part preceding the colon character. :bug:`2206` +* Fix a crash when a query uses a date field and some items are missing that + field. :bug:`1938` +* :doc:`/plugins/discogs`: Subtracks are now detected and combined into a + single track, two-sided mediums are treated as single discs, and tracks + have ``media``, ``medium_total`` and ``medium`` set correctly. :bug:`2222` + :bug:`2228`. +* :doc:`/plugins/missing`: ``missing`` is now treated as an integer, allowing + the use of (for example) ranges in queries. +* :doc:`/plugins/smartplaylist`: Playlist names will be sanitized to + ensure valid filenames. :bug:`2258` +* The ID3 APIC tag now uses the Latin-1 encoding when possible instead of a + Unicode encoding. This should increase compatibility with other software, + especially with iTunes and when using ID3v2.3. Thanks to :user:`lazka`. + :bug:`899` :bug:`2264` :bug:`2270` + +The last release, 1.3.19, also erroneously reported its version as "1.3.18" +when you typed ``beet version``. This has been corrected. + +.. _six: https://pythonhosted.org/six/ + + 1.3.19 (June 25, 2016) ---------------------- @@ -420,7 +910,7 @@ how the items will be moved without actually changing anything. * The importer now supports matching of "pregap" or HTOA (hidden track-one audio) tracks when they are listed in MusicBrainz. (This feature depends on a - new version of the ``musicbrainzngs`` library that is not yet released, but + new version of the `python-musicbrainzngs`_ library that is not yet released, but will start working when it is available.) Thanks to :user:`ruippeixotog`. :bug:`1104` :bug:`1493` * :doc:`/plugins/plexupdate`: A new ``token`` configuration option lets you @@ -489,6 +979,8 @@ does not exist, beets creates an empty file before editing it. This fixes an error on OS X, where the ``open`` command does not work with non-existent files. :bug:`1480` +* :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows + under Python 3. :bug:`2515` :bug:`2516` .. _Python bug: http://bugs.python.org/issue16512 .. _ipfs: http://ipfs.io @@ -1868,7 +2360,7 @@ New configuration options: * :ref:`languages` controls the preferred languages when selecting an alias - from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or + from MusicBrainz. This feature requires `python-musicbrainzngs`_ 0.3 or later. Thanks to Sam Doshi. * :ref:`detail` enables a mode where all tracks are listed in the importer UI, as opposed to only changed tracks. @@ -2534,7 +3026,7 @@ release: one for assigning genres and another for ReplayGain analysis. * Beets now communicates with MusicBrainz via the new `Next Generation Schema`_ - (NGS) service via `python-musicbrainz-ngs`_. The bindings are included with + (NGS) service via `python-musicbrainzngs`_. The bindings are included with this version of beets, but a future version will make them an external dependency. * The importer now detects **multi-disc albums** and tags them together. Using a @@ -2580,7 +3072,7 @@ .. _KraYmer: https://github.com/KraYmer .. _Next Generation Schema: http://musicbrainz.org/doc/XML_Web_Service/Version_2 -.. _python-musicbrainz-ngs: https://github.com/alastair/python-musicbrainz-ngs +.. _python-musicbrainzngs: https://github.com/alastair/python-musicbrainzngs .. _acoustid: http://acoustid.org/ .. _Peter Brunner: https://github.com/Lugoues .. _Simon Chopin: https://github.com/laarmen @@ -2949,7 +3441,7 @@ * Added `` beet version`` command that just shows the current release version. -.. _upstream bug: http://code.google.com/p/mutagen/issues/detail?id=7 +.. _upstream bug: https://github.com/quodlibet/mutagen/issues/7 .. _Bluelet: https://github.com/sampsyo/bluelet 1.0b5 (September 28, 2010) @@ -3160,16 +3652,16 @@ your library path format strings like ``$artist - $album ($format)`` to get directories with names like ``Paul Simon - Graceland (FLAC)``. -.. _for the future: http://code.google.com/p/beets/issues/detail?id=69 +.. _for the future: https://github.com/google-code-export/beets/issues/69 .. _the beetsplug directory: - http://code.google.com/p/beets/source/browse/#hg/beetsplug + https://github.com/beetbox/beets/tree/master/beetsplug Beets also now has its first third-party plugin: `beetfs`_, by Martin Eve! It exposes your music in a FUSE filesystem using a custom directory structure. Even cooler: it lets you keep your files intact on-disk while correcting their tags when accessed through FUSE. Check it out! -.. _beetfs: http://code.google.com/p/beetfs/ +.. _beetfs: https://github.com/jbaiter/beetfs 1.0b2 (July 7, 2010) -------------------- diff -Nru beets-1.3.19/docs/conf.py beets-1.4.6/docs/conf.py --- beets-1.3.19/docs/conf.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/conf.py 2017-06-20 19:15:08.000000000 +0000 @@ -15,8 +15,8 @@ project = u'beets' copyright = u'2016, Adrian Sampson' -version = '1.3' -release = '1.3.19' +version = '1.4' +release = '1.4.6' pygments_style = 'sphinx' diff -Nru beets-1.3.19/docs/dev/plugins.rst beets-1.4.6/docs/dev/plugins.rst --- beets-1.3.19/docs/dev/plugins.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/dev/plugins.rst 2017-10-29 20:27:34.000000000 +0000 @@ -161,6 +161,10 @@ for a file. Parameters: ``item``, ``source`` path, ``destination`` path +* `item_hardlinked`: called with an ``Item`` object whenever a hardlink is + created for a file. + Parameters: ``item``, ``source`` path, ``destination`` path + * `item_removed`: called with an ``Item`` object every time an item (singleton or album's part) is removed from the library (even when its file is not deleted from disk). @@ -592,8 +596,6 @@ ``a``, ``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``. Additionally, the callback function can optionally specify the next action to -be performed by returning one of the values from ``importer.action``, which -will be passed to the main loop upon the callback has been processed. Note that -``action.MANUAL`` and ``action.MANUAL_ID`` will have no effect even if -returned by the callback, due to the current architecture of the import -process. +be performed by returning a ``importer.action`` value. It may also return a +``autotag.Proposal`` value to update the set of current proposals to be +considered. diff -Nru beets-1.3.19/docs/faq.rst beets-1.4.6/docs/faq.rst --- beets-1.3.19/docs/faq.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/faq.rst 2017-01-03 01:53:12.000000000 +0000 @@ -2,11 +2,12 @@ ### Here are some answers to frequently-asked questions from IRC and elsewhere. -Got a question that isn't answered here? Try `IRC`_, the `mailing list`_, or +Got a question that isn't answered here? Try `IRC`_, the `discussion board`_, or :ref:`filing an issue <bugs>` in the bug tracker. .. _IRC: irc://irc.freenode.net/beets .. _mailing list: http://groups.google.com/group/beets-users +.. _discussion board: http://discourse.beets.io .. contents:: :local: @@ -289,7 +290,7 @@ tries to clean up after itself briefly even when canceled. (For developers: this is because the UI thread is blocking on -``raw_input`` and cannot be interrupted by the main thread, which is +``input`` and cannot be interrupted by the main thread, which is trying to close all pipeline stages in the exception handler by setting a flag. There is no simple way to remedy this.) @@ -327,7 +328,7 @@ If beets still complains about a file that seems to be valid, `file a bug <https://github.com/beetbox/beets/issues/new>`__ and we'll look into it. There's always a possibility that there's a bug "upstream" in the -`Mutagen <http://code.google.com/p/mutagen/>`__ library used by beets, +`Mutagen <https://github.com/quodlibet/mutagen>`__ library used by beets, in which case we'll forward the bug to that project's tracker. diff -Nru beets-1.3.19/docs/guides/main.rst beets-1.4.6/docs/guides/main.rst --- beets-1.3.19/docs/guides/main.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/guides/main.rst 2017-11-25 22:56:59.000000000 +0000 @@ -9,12 +9,14 @@ Installing ---------- -You will need Python. (Beets is written for `Python 2.7`_. 2.6 support has been -dropped, and Python 3.x is not yet supported.) +You will need Python. +Beets works on `Python 2.7`_ and Python 3.4 or later. .. _Python 2.7: http://www.python.org/download/ -* **Mac OS X** v10.7 (Lion) and later include Python 2.7 out of the box. +* **macOS** v10.7 (Lion) and later include Python 2.7 out of the box. + You can opt for Python 3 by installing it via `Homebrew`_: + ``brew install python3`` * On **Debian or Ubuntu**, depending on the version, beets is available as an official package (`Debian details`_, `Ubuntu details`_), so try typing: @@ -41,6 +43,10 @@ * On **Fedora** 22 or later, there is a `DNF package`_ (or three):: $ sudo dnf install beets beets-plugins beets-doc + +* On **Solus**, run ``eopkg install beets``. + +* On **NixOS**, run ``nix-env -i beets``. .. _copr: https://copr.fedoraproject.org/coprs/afreof/beets/ .. _dnf package: https://apps.fedoraproject.org/packages/beets @@ -53,13 +59,13 @@ .. _beets is in [community]: https://www.archlinux.org/packages/community/any/beets/ If you have `pip`_, just say ``pip install beets`` (you might need ``sudo`` in -front of that). On Arch, you'll need to use ``pip2`` instead of ``pip``. +front of that). To install without pip, download beets from `its PyPI page`_ and run ``python setup.py install`` in the directory therein. .. _its PyPI page: http://pypi.python.org/pypi/beets#downloads -.. _pip: http://pip.openplans.org/ +.. _pip: http://www.pip-installer.org/ The best way to upgrade beets to a new version is by running ``pip install -U beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on @@ -67,34 +73,50 @@ .. _@b33ts: http://twitter.com/b33ts +Installing on macOS 10.11 and Higher +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Starting with version 10.11 (El Capitan), macOS has a new security feature +called `System Integrity Protection`_ (SIP) that prevents you from modifying +some parts of the system. This means that some ``pip`` commands may fail with +a permissions error, even when you use ``sudo``. (You probably *won't* run +into this if you've installed Python yourself with `Homebrew`_ or otherwise.) + +If this happens, you can install beets for the current user only (sans +``sudo``) by typing ``pip install --user beets``. If you do that, you might want +to add ``~/Library/Python/3.6/bin`` to your ``$PATH``. + +.. _System Integrity Protection: https://support.apple.com/en-us/HT204899 +.. _Homebrew: http://brew.sh + Installing on Windows ^^^^^^^^^^^^^^^^^^^^^ Installing beets on Windows can be tricky. Following these steps might help you get it right: -1. If you don't have it, `install Python`_ (you want Python 2.7). +1. If you don't have it, `install Python`_ (you want Python 3.6). The + installer should give you the option to "add Python to PATH." Check this + box. If you do that, you can skip the next step. 2. If you haven't done so already, set your ``PATH`` environment variable to include Python and its scripts. To do so, you have to get the "Properties" window for "My Computer", then choose the "Advanced" tab, then hit the "Environment Variables" button, and then look for the ``PATH`` variable in the table. Add the following to the end of the variable's value: - ``;C:\Python27;C:\Python27\Scripts``. + ``;C:\Python36;C:\Python36\Scripts``. You may need to adjust these paths to + point to your Python installation. -3. Next, `install pip`_ (if you don't have it already) by downloading and - running the `get-pip.py`_ script. +3. Now install beets by running: ``pip install beets`` -4. Now install beets by running: ``pip install beets`` - -5. You're all set! Type ``beet`` at the command prompt to make sure everything's +4. You're all set! Type ``beet`` at the command prompt to make sure everything's in order. Windows users may also want to install a context menu item for importing files -into beets. Just download and open `beets.reg`_ to add the necessary keys to the -registry. You can then right-click a directory and choose "Import with beets". -If Python is in a nonstandard location on your system, you may have to edit the -command path manually. +into beets. Download the `beets.reg`_ file and open it in a text file to make +sure the paths to Python match your system. Then double-click the file add the +necessary keys to your registry. You can then right-click a directory and +choose "Import with beets". Because I don't use Windows myself, I may have missed something. If you have trouble or you have more detail to contribute here, please direct it to @@ -117,13 +139,13 @@ place to start:: directory: ~/music - library: ~/data/musiclibrary.blb + library: ~/data/musiclibrary.db Change that first path to a directory where you'd like to keep your music. Then, for ``library``, choose a good place to keep a database file that keeps an index of your music. (The config's format is `YAML`_. You'll want to configure your -text editor to use spaces, not real tabs, for indentation.) - +text editor to use spaces, not real tabs, for indentation. Also, ``~`` means +your home directory in these paths, even on Windows.) The default configuration assumes you want to start a new organized music folder (that ``directory`` above) and that you'll *copy* cleaned-up music into that @@ -162,6 +184,11 @@ Importing Your Library ---------------------- +The next step is to import your music files into the beets library database. +Because this can involve modifying files and moving them around, data loss is +always a possibility, so now would be a good time to make sure you have a +recent backup of all your music. We'll wait. + There are two good ways to bring your existing library into beets. You can either: (a) quickly bring all your files with all their current metadata into beets' database, or (b) use beets' highly-refined autotagger to find canonical @@ -218,21 +245,30 @@ $ beet ls album:bird The Mae Shi - Terrorbird - Revelation Six -As you can see, search terms by default search all attributes of songs. (They're +By default, a search term will match any of a handful of :ref:`common +attributes <keywordquery>` of songs. +(They're also implicitly joined by ANDs: a track must match *all* criteria in order to match the query.) To narrow a search term to a particular metadata field, just put the field before the term, separated by a : character. So ``album:bird`` only looks for ``bird`` in the "album" field of your songs. (Need to know more? :doc:`/reference/query/` will answer all your questions.) -The ``beet list`` command has another useful option worth mentioning, ``-a``, -which searches for albums instead of songs:: +The ``beet list`` command also has an ``-a`` option, which searches for albums instead of songs:: $ beet ls -a forever Bon Iver - For Emma, Forever Ago Freezepop - Freezepop Forever -So handy! +There's also an ``-f`` option (for *format*) that lets you specify what gets displayed in the results of a search:: + + $ beet ls -a forever -f "[$format] $album ($year) - $artist - $title" + [MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume + [AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme + +In the format option, field references like `$format` and `$year` are filled +in with data from each result. You can see a full list of available fields by +running ``beet fields``. Beets also has a ``stats`` command, just in case you want to see how much music you have:: @@ -261,8 +297,9 @@ command lists all the available commands; then, for example, ``beet help import`` gives more specific help about the ``import`` command. -Please let me know what you think of beets via `the mailing list`_ or +Please let me know what you think of beets via `the discussion board`_ or `Twitter`_. .. _the mailing list: http://groups.google.com/group/beets-users +.. _the discussion board: http://discourse.beets.io .. _twitter: http://twitter.com/b33ts diff -Nru beets-1.3.19/docs/guides/tagger.rst beets-1.4.6/docs/guides/tagger.rst --- beets-1.3.19/docs/guides/tagger.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/guides/tagger.rst 2017-11-25 22:56:53.000000000 +0000 @@ -95,6 +95,9 @@ * ``beet import -C``: don't copy imported files to your music directory; leave them where they are +* ``beet import -m``: move imported files to your music directory (overrides + the ``-c`` option) + * ``beet import -l LOGFILE``: write a message to ``LOGFILE`` every time you skip an album or choose to take its tags "as-is" (see below) or the album is skipped as a duplicate; this lets you come back later and reexamine albums @@ -231,17 +234,25 @@ one you're importing, you may see a prompt like this:: This album is already in the library! - [S]kip new, Keep both, Remove old? + [S]kip new, Keep both, Remove old, Merge all? Beets wants to keep you safe from duplicates, which can be a real pain, so you -have three choices in this situation. You can skip importing the new music, +have four choices in this situation. You can skip importing the new music, choosing to keep the stuff you already have in your library; you can keep both -the old and the new music; or you can remove the existing music and choose the -new stuff. If you choose that last "trump" option, any duplicates will be +the old and the new music; you can remove the existing music and choose the +new stuff; or you can merge all the new and old tracks into a single album. +If you choose that "remove" option, any duplicates will be removed from your library database---and, if the corresponding files are located inside of your beets library directory, the files themselves will be deleted as well. +If you choose "merge", beets will try re-importing the existing and new tracks +as one bundle together. +This is particularly helpful when you have an album that's missing some tracks +and then want to import the remaining songs. +The importer will ask you the same questions as it would if you were importing +all tracks at once. + If you choose to keep two identically-named albums, beets can avoid storing both in the same directory. See :ref:`aunique` for details. @@ -291,7 +302,8 @@ I Hope That Makes Sense ----------------------- -If I haven't made the process clear, please send an email to `the mailing -list`_ and I'll try to improve this guide. +If we haven't made the process clear, please post on `the discussion +board`_ and we'll try to improve this guide. .. _the mailing list: http://groups.google.com/group/beets-users +.. _the discussion board: http://discourse.beets.io diff -Nru beets-1.3.19/docs/index.rst beets-1.4.6/docs/index.rst --- beets-1.3.19/docs/index.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/index.rst 2017-01-03 01:53:12.000000000 +0000 @@ -13,13 +13,14 @@ be interested in exploring the :doc:`plugins </plugins/index>`. If you still need help, your can drop by the ``#beets`` IRC channel on -Freenode, send email to `the mailing list`_, or `file a bug`_ in the issue -tracker. Please let us know where you think this documentation can be -improved. +Freenode, drop by `the discussion board`_, send email to `the mailing list`_, +or `file a bug`_ in the issue tracker. Please let us know where you think this +documentation can be improved. .. _beets: http://beets.io/ .. _the mailing list: http://groups.google.com/group/beets-users .. _file a bug: https://github.com/beetbox/beets/issues +.. _the discussion board: http://discourse.beets.io Contents -------- diff -Nru beets-1.3.19/docs/plugins/absubmit.rst beets-1.4.6/docs/plugins/absubmit.rst --- beets-1.3.19/docs/plugins/absubmit.rst 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/docs/plugins/absubmit.rst 2017-06-14 23:13:48.000000000 +0000 @@ -0,0 +1,49 @@ +AcousticBrainz Submit Plugin +============================ + +The `absubmit` plugin lets you submit acoustic analysis results to the +`AcousticBrainz`_ server. + +Installation +------------ + +The `absubmit` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_). + +The `absubmit` also plugin requires `requests`_, which you can install using `pip`_ by typing:: + + pip install requests + +After installing both the extractor binary and requests you can enable the plugin ``absubmit`` in your configuration (see :ref:`using-plugins`). + +Submitting Data +--------------- + +Type:: + + beet absubmit [QUERY] + +to run the analysis program and upload its results. + +The plugin works on music with a MusicBrainz track ID attached. The plugin +will also skip music that the analysis tool doesn't support. +`streaming_extractor_music`_ currently supports files with the extensions +``mp3``, ``ogg``, ``oga``, ``flac``, ``mp4``, ``m4a``, ``m4r``, ``m4b``, +``m4p``, ``aac``, ``wma``, ``asf``, ``mpc``, ``wv``, ``spx``, ``tta``, +``3g2``, ``aif``, ``aiff`` and ``ape``. + +Configuration +------------- + +To configure the plugin, make a ``absubmit:`` section in your configuration file. The available options are: + +- **auto**: Analyze every file on import. Otherwise, you need to use the ``beet absubmit`` command explicitly. + Default: ``no`` +- **extractor**: The absolute path to the `streaming_extractor_music`_ binary. + Default: search for the program in your ``$PATH`` + +.. _streaming_extractor_music: http://acousticbrainz.org/download +.. _FAQ: http://acousticbrainz.org/faq +.. _pip: http://www.pip-installer.org/ +.. _requests: http://docs.python-requests.org/en/master/ +.. _github: https://github.com/MTG/essentia +.. _AcousticBrainz: https://acousticbrainz.org diff -Nru beets-1.3.19/docs/plugins/acousticbrainz.rst beets-1.4.6/docs/plugins/acousticbrainz.rst --- beets-1.3.19/docs/plugins/acousticbrainz.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/acousticbrainz.rst 2017-12-12 16:57:06.000000000 +0000 @@ -8,12 +8,19 @@ Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing:: - $ beet acousticbrainz [QUERY] + $ beet acousticbrainz [-f] [QUERY] + +By default, the command will only look for AcousticBrainz data when the tracks +doesn't already have it; the ``-f`` or ``--force`` switch makes it re-download +data even when it already exists. If you specify a query, only matching tracks +will be processed; otherwise, the command processes every track in your +library. For all tracks with a MusicBrainz recording ID, the plugin currently sets these fields: * ``average_loudness`` +* ``bpm`` * ``chords_changes_rate`` * ``chords_key`` * ``chords_number_rate`` @@ -39,7 +46,7 @@ ----------------- To automatically tag files using AcousticBrainz data during import, just -enable the ``acousticbrainz`` plugin (see :ref:`using-plugins`). When importing +enable the ``acousticbrainz`` plugin (see :ref:`using-plugins`). When importing new files, beets will query the AcousticBrainz API using MBID and set the appropriate metadata. @@ -47,7 +54,12 @@ ------------- To configure the plugin, make a ``acousticbrainz:`` section in your -configuration file. There is one option: +configuration file. There are three options: - **auto**: Enable AcousticBrainz during ``beet import``. Default: ``yes``. +- **force**: Download AcousticBrainz data even for tracks that already have + it. + Default: ``no``. +- **tags**: Which tags from the list above to set on your files. + Default: [] (all) diff -Nru beets-1.3.19/docs/plugins/badfiles.rst beets-1.4.6/docs/plugins/badfiles.rst --- beets-1.3.19/docs/plugins/badfiles.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/badfiles.rst 2017-06-14 23:13:48.000000000 +0000 @@ -52,3 +52,7 @@ of "stream error" messages, even for files that play perfectly well. Generally, if more than one stream error happens, or if a stream error happens in the middle of a file, this is a bad sign. + +By default, only errors for the bad files will be shown. In order for the +results for all of the checked files to be seen, including the uncorrupted +ones, use the ``-v`` or ``--verbose`` option. Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/docs/plugins/beetsweb.png and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/docs/plugins/beetsweb.png differ diff -Nru beets-1.3.19/docs/plugins/bpd.rst beets-1.4.6/docs/plugins/bpd.rst --- beets-1.3.19/docs/plugins/bpd.rst 2016-06-20 17:32:29.000000000 +0000 +++ beets-1.4.6/docs/plugins/bpd.rst 2017-10-03 19:33:23.000000000 +0000 @@ -16,10 +16,8 @@ Before you can use BPD, you'll need the media library called GStreamer (along with its Python bindings) on your system. -* On Mac OS X, you can use `Homebrew`_. Run ``brew install gstreamer`` and then - ``brew install pygobject3``. - -.. _homebrew-versions: https://github.com/Homebrew/homebrew-versions +* On Mac OS X, you can use `Homebrew`_. Run ``brew install gstreamer + gst-plugins-base pygobject3``. * On Linux, you need to install GStreamer 1.0 and the GObject bindings for python. Under Ubuntu, they are called `python-gi` and `gstreamer1.0`. diff -Nru beets-1.3.19/docs/plugins/bpm.rst beets-1.4.6/docs/plugins/bpm.rst --- beets-1.3.19/docs/plugins/bpm.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/bpm.rst 2017-01-03 01:53:12.000000000 +0000 @@ -22,6 +22,21 @@ beet bpm $(mpc |head -1|tr -d "-") +If :ref:`import.write <config-import-write>` is ``yes``, the song's tags are +written to disk. + +Configuration +------------- + +To configure the plugin, make a ``bpm:`` section in your configuration file. +The available options are: + +- **max_strokes**: The maximum number of strokes to accept when tapping out the + BPM. + Default: 3. +- **overwrite**: Overwrite the track's existing BPM. + Default: ``yes``. + Credit ------ diff -Nru beets-1.3.19/docs/plugins/chroma.rst beets-1.4.6/docs/plugins/chroma.rst --- beets-1.3.19/docs/plugins/chroma.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/chroma.rst 2017-01-03 01:53:12.000000000 +0000 @@ -31,7 +31,7 @@ $ pip install pyacoustid -.. _pip: http://pip.openplans.org/ +.. _pip: http://www.pip-installer.org/ Then, you will need to install `Chromaprint`_, either as a dynamic library or in the form of a command-line tool (``fpcalc``). @@ -75,7 +75,7 @@ Note that if you install beets in a virtualenv, you'll need it to have ``--system-site-packages`` enabled for Python to see the GStreamer bindings. -* On Windows, try the Gstreamer "WinBuilds" from the `OSSBuild`_ project. +* On Windows, builds are provided by `GStreamer`_ .. _audioread: https://github.com/beetbox/audioread .. _pyacoustid: http://github.com/beetbox/pyacoustid @@ -83,14 +83,13 @@ .. _MAD: http://spacepants.org/src/pymad/ .. _pymad: http://www.underbit.com/products/mad/ .. _Core Audio: http://developer.apple.com/technologies/mac/audio-and-video.html -.. _OSSBuild: http://code.google.com/p/ossbuild/ .. _Gstreamer: http://gstreamer.freedesktop.org/ .. _PyGObject: https://wiki.gnome.org/Projects/PyGObject To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the standard set of Gstreamer plugins. For example, on Ubuntu, install the packages -``gstreamer0.10-plugins-good``, ``gstreamer0.10-plugins-bad``, and -``gstreamer0.10-plugins-ugly``. +``gstreamer1.0-plugins-good``, ``gstreamer1.0-plugins-bad``, and +``gstreamer1.0-plugins-ugly``. Usage ----- diff -Nru beets-1.3.19/docs/plugins/convert.rst beets-1.4.6/docs/plugins/convert.rst --- beets-1.3.19/docs/plugins/convert.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/convert.rst 2017-12-12 16:57:06.000000000 +0000 @@ -73,6 +73,9 @@ this does not guarantee that all converted files will have a lower bitrate---that depends on the encoder and its configuration. Default: none. +- **no_convert**: Does not transcode items matching provided query string + (see :doc:`/reference/query`). (i.e. ``format:AAC, format:WMA`` or + ``path::\.(m4a|wma)$``) - **never_convert_lossy_files**: Cross-conversions between lossy codecs---such as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality even further. If set to ``yes``, lossy files are always copied. @@ -87,7 +90,14 @@ By default, the plugin will detect the number of processors available and use them all. -You can also configure the format to use for transcoding. +You can also configure the format to use for transcoding (see the next +section): + +- **format**: The name of the format to transcode to when none is specified on + the command line. + Default: ``mp3``. +- **formats**: A set of formats and associated command lines for transcoding + each. .. _convert-format-config: @@ -133,3 +143,31 @@ convert: command: ffmpeg -i $source -y -vn -aq 2 $dest extension: mp3 + + +Gapless MP3 encoding +```````````````````` + +While FFmpeg cannot produce "`gapless`_" MP3s by itself, you can create them +by using `LAME`_ directly. Use a shell script like this to pipe the output of +FFmpeg into the LAME tool:: + + #!/bin/sh + ffmpeg -i "$1" -f wav - | lame -V 2 --noreplaygain - "$2" + +Then configure the ``convert`` plugin to use the script:: + + convert: + command: /path/to/script.sh $source $dest + extension: mp3 + +This strategy configures FFmpeg to produce a WAV file with an accurate length +header for LAME to use. Using ``--noreplaygain`` disables gain analysis; you +can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME +`documentation`_ and the `HydrogenAudio wiki`_ for other LAME configuration +options and a thorough discussion of MP3 encoding. + +.. _documentation: http://lame.sourceforge.net/using.php +.. _HydrogenAudio wiki: http://wiki.hydrogenaud.io/index.php?title=LAME +.. _gapless: http://wiki.hydrogenaud.io/index.php?title=Gapless_playback +.. _LAME: http://lame.sourceforge.net/ diff -Nru beets-1.3.19/docs/plugins/discogs.rst beets-1.4.6/docs/plugins/discogs.rst --- beets-1.3.19/docs/plugins/discogs.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/discogs.rst 2017-06-14 23:13:48.000000000 +0000 @@ -14,10 +14,9 @@ pip install discogs-client -You will also need to register for a `Discogs`_ account. The first time you -run the :ref:`import-cmd` command after enabling the plugin, it will ask you -to authorize with Discogs by visiting the site in a browser. Subsequent runs -will not require re-authorization. +You will also need to register for a `Discogs`_ account, and provide +authentication credentials via a personal access token or an OAuth2 +authorization. Matches from Discogs will now show up during import alongside matches from MusicBrainz. @@ -25,6 +24,25 @@ If you have a Discogs ID for an album you want to tag, you can also enter it at the "enter Id" prompt in the importer. +OAuth Authorization +``````````````````` + +The first time you run the :ref:`import-cmd` command after enabling the plugin, +it will ask you to authorize with Discogs by visiting the site in a browser. +Subsequent runs will not require re-authorization. + +Authentication via Personal Access Token +```````````````````````````````````````` + +As an alternative to OAuth, you can get a token from Discogs and add it to +your configuration. +To get a personal access token (called a "user token" in the `discogs-client`_ +documentation), login to `Discogs`_, and visit the +`Developer settings page +<https://www.discogs.com/settings/developers>`_. Press the ``Generate new +token`` button, and place the generated token in your configuration, as the +``user_token`` config option in the ``discogs`` section. + Troubleshooting --------------- diff -Nru beets-1.3.19/docs/plugins/duplicates.rst beets-1.4.6/docs/plugins/duplicates.rst --- beets-1.3.19/docs/plugins/duplicates.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/duplicates.rst 2016-12-17 03:01:22.000000000 +0000 @@ -28,7 +28,7 @@ -d, --delete delete items from library and disk -F, --full show all versions of duplicate tracks or albums -s, --strict report duplicates only if all attributes are set - -k, --keys report duplicates based on keys + -k, --key report duplicates based on keys (can be used multiple times) -M, --merge merge duplicate items -m DEST, --move=DEST move items to dest -o DEST, --copy=DEST copy items to dest @@ -46,9 +46,9 @@ - **checksum**: Use an arbitrary command to compute a checksum of items. This overrides the ``keys`` option the first time it is run; however, because it caches the resulting checksum as ``flexattrs`` in the - database, you can use ``--keys=name_of_the_checksumming_program - any_other_keys`` (or set configuration ``keys`` option) the second time - around. + database, you can use ``--key=name_of_the_checksumming_program + --key=any_other_keys`` (or set the ``keys`` configuration option) the second + time around. Default: ``ffmpeg -i {file} -f crc -``. - **copy**: A destination base directory into which to copy matched items. @@ -120,7 +120,7 @@ Get tracks with the same title, artist, and album:: - beet duplicates -k title albumartist album + beet duplicates -k title -k albumartist -k album Compute Adler CRC32 or MD5 checksums, storing them as flexattrs, and report back duplicates based on those values:: @@ -138,7 +138,7 @@ Delete items (careful!), if they're Nickelback:: - beet duplicates --delete --keys albumartist albumartist:nickelback + beet duplicates --delete -k albumartist -k albumartist:nickelback Tag duplicate items with some flag:: diff -Nru beets-1.3.19/docs/plugins/embedart.rst beets-1.4.6/docs/plugins/embedart.rst --- beets-1.3.19/docs/plugins/embedart.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/embedart.rst 2017-10-29 19:52:50.000000000 +0000 @@ -81,7 +81,8 @@ * ``beet embedart [-f IMAGE] QUERY``: embed images into the every track on the albums matching the query. If the ``-f`` (``--file``) option is given, then use a specific image file from the filesystem; otherwise, each album embeds - its own currently associated album art. + its own currently associated album art. The command prompts for confirmation + before making the change unless you specify the ``-y`` (``--yes``) option. * ``beet extractart [-a] [-n FILE] QUERY``: extracts the images for all albums matching the query. The images are placed inside the album folder. You can @@ -98,4 +99,5 @@ automatically. * ``beet clearart QUERY``: removes all embedded images from all items matching - the query. (Use with caution!) + the query. The command prompts for confirmation before making the change + unless you specify the ``-y`` (``--yes``) option. diff -Nru beets-1.3.19/docs/plugins/embyupdate.rst beets-1.4.6/docs/plugins/embyupdate.rst --- beets-1.3.19/docs/plugins/embyupdate.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/embyupdate.rst 2017-01-07 22:06:48.000000000 +0000 @@ -3,13 +3,13 @@ ``embyupdate`` is a plugin that lets you automatically update `Emby`_'s library whenever you change your beets library. -To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your Emby server. You can do that using an ``emby:`` section in your ``config.yaml``, which looks like this:: +To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll want to configure the specifics of your Emby server. You can do that using an ``emby:`` section in your ``config.yaml``, which looks like this:: emby: host: localhost port: 8096 username: user - password: password + apikey: apikey To use the ``embyupdate`` plugin you need to install the `requests`_ library with:: @@ -25,9 +25,14 @@ The available options under the ``emby:`` section are: -- **host**: The Emby server name. +- **host**: The Emby server host. You also can include ``http://`` or ``https://``. Default: ``localhost`` - **port**: The Emby server port. Default: 8096 - **username**: A username of a Emby user that is allowed to refresh the library. -- **password**: That user's password. +- **apikey**: An Emby API key for the user. +- **password**: The password for the user. (This is only necessary if no API + key is provided.) + +You can choose to authenticate either with `apikey` or `password`, but only +one of those two is required. diff -Nru beets-1.3.19/docs/plugins/fetchart.rst beets-1.4.6/docs/plugins/fetchart.rst --- beets-1.3.19/docs/plugins/fetchart.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/fetchart.rst 2017-11-01 22:53:52.000000000 +0000 @@ -49,38 +49,46 @@ (``enforce_ratio: 0.5%``). Default: ``no``. - **sources**: List of sources to search for images. An asterisk `*` expands to all available sources. - Default: ``filesystem coverart itunes amazon albumart``, i.e., everything but + Default: ``filesystem coverart amazon albumart``, i.e., everything but ``wikipedia``, ``google`` and ``fanarttv``. Enable those sources for more - matches at the cost of some speed. They are searched in the given order, + matches at the cost of some speed. They are searched in the given order, thus in the default config, no remote (Web) art source are queried if - local art is found in the filesystem. To use a local image as fallback, + local art is found in the filesystem. To use a local image as fallback, move it to the end of the list. - **google_key**: Your Google API key (to enable the Google Custom Search backend). Default: None. - **google_engine**: The custom search engine to use. Default: The `beets custom search engine`_, which searches the entire web. - **fanarttv_key**: The personal API key for requesting art from +- **fanarttv_key**: The personal API key for requesting art from fanart.tv. See below. - **store_source**: If enabled, fetchart stores the artwork's source in a flexible tag named ``art_source``. See below for the rationale behind this. Default: ``no``. -Note: ``minwidth`` and ``enforce_ratio`` options require either `ImageMagick`_ +Note: ``maxwidth`` and ``enforce_ratio`` options require either `ImageMagick`_ or `Pillow`_. +.. note:: + + Previously, there was a `remote_priority` option to specify when to + look for art on the filesystem. This is + still respected, but a deprecation message will be shown until you + replace this configuration with the new `filesystem` value in the + `sources` array. + .. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm .. _Pillow: https://github.com/python-pillow/Pillow .. _ImageMagick: http://www.imagemagick.org/ Here's an example that makes plugin select only images that contain *front* or -*back* keywords in their filenames and prioritizes the iTunes source over +*back* keywords in their filenames and prioritizes the Amazon source over others:: fetchart: cautious: true cover_names: front back - sources: itunes * + sources: amazon * Manually Fetching Album Art @@ -98,6 +106,17 @@ .. _image-resizing: +Display Only Missing Album Art +------------------------------ + +Use the ``fetchart`` command with the ``-q`` switch in order to display only missing +art:: + + $ beet fetchart [-q] [query] + +By default the command will display all results, the ``-q`` or ``--quiet`` +switch will only display results for album arts that are still missing. + Image Resizing -------------- @@ -120,7 +139,7 @@ ----------------- By default, this plugin searches for art in the local filesystem as well as on -the Cover Art Archive, the iTunes Store, Amazon, and AlbumArt.org, in that +the Cover Art Archive, Amazon, and AlbumArt.org, in that order. You can reorder the sources or remove some to speed up the process using the ``sources`` configuration option. @@ -135,22 +154,6 @@ described above. For "as-is" imports (and non-autotagged imports using the ``-A`` flag), beets only looks for art on the local filesystem. -iTunes Store -'''''''''''' - -To use the iTunes Store as an art source, install the `python-itunes`_ -library. You can do this using `pip`_, like so:: - - $ pip install https://github.com/ocelma/python-itunes/archive/master.zip - -(There's currently `a problem`_ that prevents a plain ``pip install -python-itunes`` from working.) -Once the library is installed, the plugin will use it to search automatically. - -.. _a problem: https://github.com/ocelma/python-itunes/issues/9 -.. _python-itunes: https://github.com/ocelma/python-itunes -.. _pip: http://pip.openplans.org/ - Google custom search '''''''''''''''''''' @@ -159,7 +162,7 @@ option to your key, then add ``google`` to the list of sources in your configuration. -.. _register for a Google API key: https://code.google.com/apis/console. +.. _register for a Google API key: https://console.developers.google.com. Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine`` configuration option. The diff -Nru beets-1.3.19/docs/plugins/gmusic.rst beets-1.4.6/docs/plugins/gmusic.rst --- beets-1.3.19/docs/plugins/gmusic.rst 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/docs/plugins/gmusic.rst 2017-08-26 15:13:02.000000000 +0000 @@ -0,0 +1,53 @@ +Gmusic Plugin +============= + +The ``gmusic`` plugin lets you upload songs to Google Play Music and query +songs in your library. + + +Installation +------------ + +The plugin requires `gmusicapi`_. You can install it using `pip`:: + + pip install gmusicapi + +.. _gmusicapi: https://github.com/simon-weber/gmusicapi/ + +Then, you can enable the ``gmusic`` plugin in your configuration (see +:ref:`using-plugins`). + + +Usage +----- + +To upload tracks to Google Play Music, use the ``gmusic-upload`` command:: + + beet gmusic-upload [QUERY] + +If you don't include a query, the plugin will upload your entire collection. + +To query the songs in your collection, you will need to add your Google +credentials to your beets configuration file. Put your Google username and +password under a section called ``gmusic``, like so:: + + gmusic: + email: user@example.com + password: seekrit + +If you have enabled two-factor authentication in your Google account, you will +need to set up and use an *application-specific password*. You can obtain one +from your Google security settings page. + +Then, use the ``gmusic-songs`` command to list music:: + + beet gmusic-songs [-at] [ARGS] + +Use the ``-a`` option to search by artist and ``-t`` to search by track. For +example:: + + beet gmusic-songs -a John Frusciante + beet gmusic-songs -t Black Hole Sun + +For a list of all songs in your library, run ``beet gmusic-songs`` without any +arguments. diff -Nru beets-1.3.19/docs/plugins/index.rst beets-1.4.6/docs/plugins/index.rst --- beets-1.3.19/docs/plugins/index.rst 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/docs/plugins/index.rst 2017-06-20 19:15:08.000000000 +0000 @@ -28,9 +28,17 @@ convert: auto: true +Some plugins have special dependencies that you'll need to install. The +documentation page for each plugin will list them in the setup instructions. +For some, you can use `pip`'s "extras" feature to install the dependencies, +like this:: + + pip install beets[fetchart,lyrics,lastgenre] + .. toctree:: :hidden: + absubmit acousticbrainz badfiles beatport @@ -50,6 +58,7 @@ ftintitle fuzzy freedesktop + gmusic hook ihate importadded @@ -58,6 +67,7 @@ inline ipfs keyfinder + kodiupdate lastgenre lastimport lyrics @@ -98,6 +108,7 @@ Metadata -------- +* :doc:`absubmit`: Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server * :doc:`acousticbrainz`: Fetch various AcousticBrainz metadata * :doc:`bpm`: Measure tempo using keystrokes. * :doc:`edit`: Edit metadata from a text editor. @@ -121,6 +132,7 @@ * :doc:`zero`: Nullify fields by pattern or unconditionally. .. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/ +.. _streaming_extractor_music: http://acousticbrainz.org/download Path Formats ------------ @@ -138,6 +150,8 @@ * :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes. * :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks. * :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs. +* :doc:`kodiupdate`: Automatically notifies `Kodi`_ whenever the beets library + changes. * :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library changes. * :doc:`play`: Play beets queries in your music player. @@ -150,6 +164,7 @@ .. _Emby: http://emby.media .. _Plex: http://plex.tv +.. _Kodi: http://kodi.tv Miscellaneous ------------- @@ -161,6 +176,7 @@ * :doc:`duplicates`: List duplicate tracks or albums. * :doc:`export`: Export data from queries to a format. * :doc:`fuzzy`: Search albums and tracks with fuzzy string matching. +* :doc:`gmusic`: Search and upload files to Google Play Music. * :doc:`hook`: Run a command when an event is emitted by beets. * :doc:`ihate`: Automatically skip albums and tracks during the import process. * :doc:`info`: Print music files' tags to the console. @@ -226,11 +242,13 @@ * `beets-usertag`_ lets you use keywords to tag and organize your music. +* `beets-popularity`_ fetches popularity values from Spotify. + .. _beets-check: https://github.com/geigerzaehler/beets-check .. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts .. _dsedivec: https://github.com/dsedivec/beets-plugins .. _beets-artistcountry: https://github.com/agrausem/beets-artistcountry -.. _beetFs: https://code.google.com/p/beetfs/ +.. _beetFs: https://github.com/jbaiter/beetfs .. _Beet-MusicBrainz-Collection: https://github.com/jeffayle/Beet-MusicBrainz-Collection/ .. _A cmus plugin: @@ -243,3 +261,5 @@ .. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport .. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets .. _beets-usertag: https://github.com/igordertigor/beets-usertag +.. _beets-popularity: https://github.com/abba23/beets-popularity + diff -Nru beets-1.3.19/docs/plugins/kodiupdate.rst beets-1.4.6/docs/plugins/kodiupdate.rst --- beets-1.3.19/docs/plugins/kodiupdate.rst 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/docs/plugins/kodiupdate.rst 2017-06-14 23:13:48.000000000 +0000 @@ -0,0 +1,44 @@ +KodiUpdate Plugin +================= + +The ``kodiupdate`` plugin lets you automatically update `Kodi`_'s music +library whenever you change your beets library. + +To use ``kodiupdate`` plugin, enable it in your configuration +(see :ref:`using-plugins`). +Then, you'll want to configure the specifics of your Kodi host. +You can do that using a ``kodi:`` section in your ``config.yaml``, +which looks like this:: + + kodi: + host: localhost + port: 8080 + user: kodi + pwd: kodi + +To use the ``kodiupdate`` plugin you need to install the `requests`_ library with:: + + pip install requests + +You'll also need to enable JSON-RPC in Kodi in order the use the plugin. +In Kodi's interface, navigate to System/Settings/Network/Services and choose "Allow control of Kodi via HTTP." + +With that all in place, you'll see beets send the "update" command to your Kodi +host every time you change your beets library. + +.. _Kodi: http://kodi.tv/ +.. _requests: http://docs.python-requests.org/en/latest/ + +Configuration +------------- + +The available options under the ``kodi:`` section are: + +- **host**: The Kodi host name. + Default: ``localhost`` +- **port**: The Kodi host port. + Default: 8080 +- **user**: The Kodi host user. + Default: ``kodi`` +- **pwd**: The Kodi host password. + Default: ``kodi`` diff -Nru beets-1.3.19/docs/plugins/lastgenre.rst beets-1.4.6/docs/plugins/lastgenre.rst --- beets-1.3.19/docs/plugins/lastgenre.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/lastgenre.rst 2016-12-17 03:01:22.000000000 +0000 @@ -35,7 +35,7 @@ Wikipedia`_. .. _pip: http://www.pip-installer.org/ -.. _pylast: http://code.google.com/p/pylast/ +.. _pylast: https://github.com/pylast/pylast .. _script that scrapes Wikipedia: https://gist.github.com/1241307 .. _internal whitelist: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres.txt @@ -53,7 +53,7 @@ rock heavy metal pop - + together with the default genre tree. Then an item that has its genre specified as *viking metal* would actually be tagged as *heavy metal* because neither *viking metal* nor its parent *black metal* are in the whitelist. It always @@ -103,6 +103,19 @@ ignore tags with a weight less then 10. You can change this by setting the ``min_weight`` config option. +Specific vs. Popular Genres +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, the plugin sorts genres by popularity. However, you can use the +``prefer_specific`` option to override this behavior and instead sort genres +by specificity, as determined by your whitelist and canonicalization tree. + +For instance, say you have both ``folk`` and ``americana`` in your whitelist +and canonicalization tree and ``americana`` is a leaf within ``folk``. If +Last.fm returns both of those tags, lastgenre is going to use the most +popular, which is often the most generic (in this case ``folk``). By setting +``prefer_specific`` to true, lastgenre would use ``americana`` instead. + Configuration ------------- @@ -126,6 +139,8 @@ Default: ``yes``. - **min_weight**: Minimum popularity factor below which genres are discarded. Default: 10. +- **prefer_specific**: Sort genres by the most to least specific, rather than + most to least popular. Default: ``no``. - **source**: Which entity to look up in Last.fm. Can be either ``artist``, ``album`` or ``track``. Default: ``album``. diff -Nru beets-1.3.19/docs/plugins/lastimport.rst beets-1.4.6/docs/plugins/lastimport.rst --- beets-1.3.19/docs/plugins/lastimport.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/lastimport.rst 2016-12-17 03:01:22.000000000 +0000 @@ -24,7 +24,7 @@ user: beetsfanatic .. _pip: http://www.pip-installer.org/ -.. _pylast: http://code.google.com/p/pylast/ +.. _pylast: https://github.com/pylast/pylast Importing Play Counts --------------------- diff -Nru beets-1.3.19/docs/plugins/lyrics.rst beets-1.4.6/docs/plugins/lyrics.rst --- beets-1.3.19/docs/plugins/lyrics.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/lyrics.rst 2017-07-17 14:56:29.000000000 +0000 @@ -2,11 +2,10 @@ ============= The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web. -Namely, the current version of the plugin uses `Lyric Wiki`_, `Lyrics.com`_, +Namely, the current version of the plugin uses `Lyric Wiki`_, `Musixmatch`_, `Genius.com`_, and, optionally, the Google custom search API. .. _Lyric Wiki: http://lyrics.wikia.com/ -.. _Lyrics.com: http://www.lyrics.com/ .. _Musixmatch: https://www.musixmatch.com/ .. _Genius.com: http://genius.com/ @@ -60,7 +59,7 @@ sources known to be scrapeable. - **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. - Default: ``google lyricwiki lyrics.com musixmatch``, i.e., all the + Default: ``google lyricwiki musixmatch``, i.e., all the sources except for `genius`. The `google` source will be automatically deactivated if no ``google_API_key`` is setup. @@ -88,11 +87,44 @@ console so you can view the fetched (or previously-stored) lyrics. The ``-f`` option forces the command to fetch lyrics, even for tracks that -already have lyrics. +already have lyrics. Inversely, the ``-l`` option restricts operations +to lyrics that are locally available, which show lyrics faster without using +the network at all. + +Rendering Lyrics into Other Formats +----------------------------------- + +The ``-r directory`` option renders all lyrics as `reStructuredText`_ (ReST) +documents in ``directory`` (by default, the current directory). That +directory, in turn, can be parsed by tools like `Sphinx`_ to generate HTML, +ePUB, or PDF documents. + +A minimal ``conf.py`` and ``index.rst`` files are created the first time the +command is run. They are not overwritten on subsequent runs, so you can safely +modify these files to customize the output. + +.. _Sphinx: http://www.sphinx-doc.org/ +.. _reStructuredText: http://docutils.sourceforge.net/rst.html + +Sphinx supports various `builders +<http://www.sphinx-doc.org/en/stable/builders.html>`_, but here are a +few suggestions. + + * Build an HTML version:: + + sphinx-build -b html . _build/html + + * Build an ePUB3 formatted file, usable on ebook readers:: + + sphinx-build -b epub3 . _build/epub + + * Build a PDF file, which incidentally also builds a LaTeX file:: + + sphinx-build -b latex %s _build/latex && make -C _build/latex all-pdf .. _activate-google-custom-search: -Activate Google custom search +Activate Google Custom Search ------------------------------ Using the Google backend requires `BeautifulSoup`_, which you can install @@ -108,7 +140,7 @@ ``musixmatch google`` as the other sources are already included in the Google results. -.. _register for a Google API key: https://code.google.com/apis/console +.. _register for a Google API key: https://console.developers.google.com/ Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine_ID`` configuration option. By @@ -133,9 +165,9 @@ pip install langdetect You also need to register for a Microsoft Azure Marketplace free account and -to the `Microsoft Translator API`_. Follow the four steps process, specifically -at step 3 enter ``beets`` as *Client ID* and copy/paste the generated -*Client secret* into your ``bing_client_secret`` configuration, alongside +to the `Microsoft Translator API`_. Follow the four steps process, specifically +at step 3 enter ``beets`` as *Client ID* and copy/paste the generated +*Client secret* into your ``bing_client_secret`` configuration, alongside ``bing_lang_to`` target `language code`_. .. _langdetect: https://pypi.python.org/pypi/langdetect diff -Nru beets-1.3.19/docs/plugins/mbcollection.rst beets-1.4.6/docs/plugins/mbcollection.rst --- beets-1.3.19/docs/plugins/mbcollection.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/mbcollection.rst 2017-10-03 19:33:23.000000000 +0000 @@ -20,6 +20,12 @@ If you don't have a MusicBrainz collection yet, you may need to add one to your profile first. +The command has one command-line option: + +* To remove albums from the collection which are no longer present in + the beets database, use the ``-r`` (``--remove``) flag. + + Configuration ------------- @@ -29,3 +35,8 @@ - **auto**: Automatically amend your MusicBrainz collection whenever you import a new album. Default: ``no``. +- **collection**: Which MusicBrainz collection to update. + Default: ``None``. +- **remove**: Remove albums from collections which are no longer + present in the beets database. + Default: ``None``. diff -Nru beets-1.3.19/docs/plugins/missing.rst beets-1.4.6/docs/plugins/missing.rst --- beets-1.3.19/docs/plugins/missing.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/missing.rst 2017-06-14 23:13:48.000000000 +0000 @@ -11,7 +11,8 @@ Add the ``missing`` plugin to your configuration (see :ref:`using-plugins`). By default, the ``beet missing`` command lists the names of tracks that your -library is missing from each album. +library is missing from each album. It can also list the names of albums that +your library is missing from each artist. You can customize the output format, count the number of missing tracks per album, or total up the number of missing tracks over your whole library, using command-line switches:: @@ -19,10 +20,13 @@ -f FORMAT, --format=FORMAT print with custom FORMAT -c, --count count missing tracks per album - -t, --total count total of missing tracks + -t, --total count total of missing tracks or albums + -a, --album show missing albums for artist instead of tracks …or by editing corresponding options. +Note that ``-c`` is ignored when used with ``-a``. + Configuration ------------- @@ -60,6 +64,10 @@ beet missing +List all missing albums in your collection:: + + beet missing -a + List all missing tracks from 2008:: beet missing year:2008 diff -Nru beets-1.3.19/docs/plugins/mpdstats.rst beets-1.4.6/docs/plugins/mpdstats.rst --- beets-1.3.19/docs/plugins/mpdstats.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/mpdstats.rst 2016-12-17 03:01:22.000000000 +0000 @@ -39,7 +39,8 @@ configuration file. The available options are: - **host**: The MPD server hostname. - Default: ``localhost``. + Default: The ``$MPD_HOST`` environment variable if set, + falling back to ``localhost`` otherwise. - **port**: The MPD server port. Default: 6600. - **password**: The MPD server password. diff -Nru beets-1.3.19/docs/plugins/mpdupdate.rst beets-1.4.6/docs/plugins/mpdupdate.rst --- beets-1.3.19/docs/plugins/mpdupdate.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/mpdupdate.rst 2016-12-17 03:01:22.000000000 +0000 @@ -31,7 +31,7 @@ The available options under the ``mpd:`` section are: - **host**: The MPD server name. - Default: ``localhost``. + Default: The ``$MPD_HOST`` environment variable if set, falling back to ``localhost`` otherwise. - **port**: The MPD server port. Default: 6600. - **password**: The MPD server password. diff -Nru beets-1.3.19/docs/plugins/play.rst beets-1.4.6/docs/plugins/play.rst --- beets-1.3.19/docs/plugins/play.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/play.rst 2017-06-14 23:13:48.000000000 +0000 @@ -2,10 +2,10 @@ =========== The ``play`` plugin allows you to pass the results of a query to a music -player in the form of an m3u playlist. +player in the form of an m3u playlist or paths on the command line. -Usage ------ +Command Line Usage +------------------ To use the ``play`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then use it by invoking the ``beet play`` command with @@ -29,6 +29,18 @@ While playing you'll be able to interact with the player if it is a command-line oriented, and you'll get its output in real time. +Interactive Usage +----------------- + +The `play` plugin can also be invoked during an import. If enabled, the plugin +adds a `plaY` option to the prompt, so pressing `y` will execute the configured +command and play the items currently being imported. + +Once the configured command exits, you will be returned to the import +decision prompt. If your player is configured to run in the background (in a +client/server setup), the music will play until you choose to stop it, and the +import operation continues immediately. + Configuration ------------- @@ -37,7 +49,7 @@ - **command**: The command used to open the playlist. Default: ``open`` on OS X, ``xdg-open`` on other Unixes and ``start`` on - Windows. Insert ``{}`` to make use of the ``--args``-feature. + Windows. Insert ``$args`` to use the ``--args`` feature. - **relative_to**: If set, emit paths relative to this directory. Default: None. - **use_folders**: When using the ``-a`` option, the m3u will contain the @@ -83,16 +95,20 @@ indicates that you need to insert extra arguments before specifying the playlist. +The ``--yes`` (or ``-y``) flag to the ``play`` command will skip the warning +message if you choose to play more items than the **warning_threshold** +value usually allows. + Note on the Leakage of the Generated Playlists -_______________________________________________ +---------------------------------------------- Because the command that will open the generated ``.m3u`` files can be arbitrarily configured by the user, beets won't try to delete those files. For this reason, using this plugin will leave one or several playlist(s) in the directory selected to create temporary files (Most likely ``/tmp/`` on Unix-like -systems. See `tempfile.tempdir`_.). Leaking those playlists until they are -externally wiped could be an issue for privacy or storage reasons. If this is -the case for you, you might want to use the ``raw`` config option described -above. +systems. See `tempfile.tempdir`_ in the Python docs.). Leaking those playlists until +they are externally wiped could be an issue for privacy or storage reasons. If +this is the case for you, you might want to use the ``raw`` config option +described above. .. _tempfile.tempdir: https://docs.python.org/2/library/tempfile.html#tempfile.tempdir diff -Nru beets-1.3.19/docs/plugins/random.rst beets-1.4.6/docs/plugins/random.rst --- beets-1.3.19/docs/plugins/random.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/random.rst 2017-01-03 01:53:12.000000000 +0000 @@ -23,3 +23,7 @@ The ``-n NUMBER`` option controls the number of objects that are selected and printed (default 1). To select 5 tracks from your library, type ``beet random -n5``. + +As an alternative, you can use ``-t MINUTES`` to choose a set of music with a +given play time. To select tracks that total one hour, for example, type +``beet random -t60``. diff -Nru beets-1.3.19/docs/plugins/replaygain.rst beets-1.4.6/docs/plugins/replaygain.rst --- beets-1.3.19/docs/plugins/replaygain.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/replaygain.rst 2017-06-20 19:15:08.000000000 +0000 @@ -108,6 +108,10 @@ Default: ``no``. - **targetlevel**: A number of decibels for the target loudness level. Default: 89. +- **r128**: A space separated list of formats that will use ``R128_`` tags with + integer values instead of the common ``REPLAYGAIN_`` tags with floating point + values. Requires the "bs1770gain" backend. + Default: ``Opus``. These options only work with the "command" backend: diff -Nru beets-1.3.19/docs/plugins/spotify.rst beets-1.4.6/docs/plugins/spotify.rst --- beets-1.3.19/docs/plugins/spotify.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/spotify.rst 2017-01-03 01:53:12.000000000 +0000 @@ -73,7 +73,7 @@ spotify: mode: open region_filter: US - show_faiulres: on + show_failures: on tiebreak: first regex: [ diff -Nru beets-1.3.19/docs/plugins/web.rst beets-1.4.6/docs/plugins/web.rst --- beets-1.3.19/docs/plugins/web.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/web.rst 2017-06-14 23:13:48.000000000 +0000 @@ -63,6 +63,11 @@ Default: 8337. - **cors**: The CORS allowed origin (see :ref:`web-cors`, below). Default: CORS is disabled. +- **reverse_proxy**: If true, enable reverse proxy support (see + :ref:`reverse-proxy`, below). + Default: false. +- **include_paths**: If true, includes paths in item objects. + Default: false. Implementation -------------- @@ -72,7 +77,7 @@ `Backbone.js`_. This allows future non-Web clients to use the same backend API. .. _Flask: http://flask.pocoo.org/ -.. _Backbone.js: http://documentcloud.github.com/backbone/ +.. _Backbone.js: http://backbonejs.org Eventually, to make the Web player really viable, we should use a Flash fallback for unsupported formats/browsers. There are a number of options for this: @@ -109,11 +114,37 @@ host: 0.0.0.0 cors: 'http://example.com' +.. _reverse-proxy: + +Reverse Proxy Support +--------------------- + +When the server is running behind a reverse proxy, you can tell the plugin to +respect forwarded headers. Specifically, this can help when you host the +plugin at a base URL other than the root ``/`` or when you use the proxy to +handle secure connections. Enable the ``reverse_proxy`` configuration option +if you do this. + +Technically, this option lets the proxy provide ``X-Script-Name`` and +``X-Scheme`` HTTP headers to control the plugin's the ``SCRIPT_NAME`` and its +``wsgi.url_scheme`` parameter. + +Here's a sample `Nginx`_ configuration that serves the web plugin under the +/beets directory:: + + location /beets { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Script-Name /beets; + } + +.. _Nginx: https://www.nginx.com JSON API -------- - ``GET /item/`` ++++++++++++++ @@ -160,6 +191,16 @@ dropped from the response. +``GET /item/path/...`` +++++++++++++++++++++++ + +Look for an item at the given absolute path on the server. If it corresponds to +a track, return the track in the same format as ``/item/*``. + +If the server runs UNIX, you'll need to include an extra leading slash: +``http://localhost:8337/item/path//Users/beets/Music/Foo/Bar/Baz.mp3`` + + ``GET /item/query/querystring`` +++++++++++++++++++++++++++++++ @@ -197,6 +238,7 @@ the encapsulation key ``"items"`` with ``"albums"`` when requesting ``/album/`` or ``/album/5,7``. In addition we can request the cover art of an album with ``GET /album/5/art``. +You can also add the '?expand' flag to get the individual items of an album. ``GET /stats`` diff -Nru beets-1.3.19/docs/plugins/zero.rst beets-1.4.6/docs/plugins/zero.rst --- beets-1.3.19/docs/plugins/zero.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/plugins/zero.rst 2017-08-11 18:35:10.000000000 +0000 @@ -18,6 +18,8 @@ Make a ``zero:`` section in your configuration file. You can specify the fields to nullify and the conditions for nullifying them: +* Set ``auto`` to ``yes`` to null fields automatically on import. + Default: ``yes``. * Set ``fields`` to a whitespace-separated list of fields to change. You can get the list of all available fields by running ``beet fields``. In addition, the ``images`` field allows you to remove any images @@ -42,3 +44,19 @@ unconditionally. Note that the plugin currently does not zero fields when importing "as-is". + +Manually Triggering Zero +------------------------ + +You can also type ``beet zero [QUERY]`` to manually invoke the plugin on music +in your library. + +Preserving Album Art +-------------------- + +If you use the ``keep_fields`` option, the plugin will remove embedded album +art from files' tags unless you tell it not to. To keep the album art, include +the special field ``images`` in the list. For example:: + + zero: + keep_fields: title artist album year track genre images diff -Nru beets-1.3.19/docs/reference/cli.rst beets-1.4.6/docs/reference/cli.rst --- beets-1.3.19/docs/reference/cli.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/reference/cli.rst 2017-12-16 20:00:33.000000000 +0000 @@ -72,7 +72,8 @@ Optional command flags: * By default, the command copies files your the library directory and - updates the ID3 tags on your music. If you'd like to leave your music + updates the ID3 tags on your music. In order to move the files, instead of + copying, use the ``-m`` (move) option. If you'd like to leave your music files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write tags) options. You can also disable this behavior by default in the configuration file (below). @@ -110,6 +111,12 @@ time, when no subdirectories will be skipped. So consider enabling the ``incremental`` configuration option. +* When beets applies metadata to your music, it will retain the value of any + existing tags that weren't overwritten, and import them into the database. You + may prefer to only use existing metadata for finding matches, and to erase it + completely when new metadata is applied. You can enforce this behavior with + the ``--from-scratch`` option, or the ``from_scratch`` configuration option. + * By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer ask you every time, use the ``-t`` (for *timid*) option. @@ -137,6 +144,13 @@ searching for other candidates by using the ``--search-id SEARCH_ID`` option. Multiple IDs can be specified by simply repeating the option several times. +* You can supply ``--set field=value`` to assign `field` to `value` on import. + These assignments will merge with (and possibly override) the + :ref:`set_fields` configuration dictionary. You can use the option multiple + times on the command line, like so:: + + beet import --set genre="Alternative Rock" --set mood="emotional" + .. _rarfile: https://pypi.python.org/pypi/rarfile/2.2 .. only:: html @@ -227,7 +241,7 @@ `````` :: - beet modify [-MWay] QUERY [FIELD=VALUE...] [FIELD!...] + beet modify [-MWay] [-f FORMAT] QUERY [FIELD=VALUE...] [FIELD!...] Change the metadata for items or albums in the database. @@ -237,13 +251,27 @@ To remove fields (which is only possible for flexible attributes), follow a field name with an exclamation point: ``field!``. -The ``-a`` switch operates on albums instead of -individual tracks. Items will automatically be moved around when necessary if -they're in your library directory, but you can disable that with ``-M``. Tags -will be written to the files according to the settings you have for imports, -but these can be overridden with ``-w`` (write tags, the default) and ``-W`` -(don't write tags). Finally, this command politely asks for your permission -before making any changes, but you can skip that prompt with the ``-y`` switch. +The ``-a`` switch operates on albums instead of individual tracks. Without +this flag, the command will only change *track-level* data, even if all the +tracks belong to the same album. If you want to change an *album-level* field, +such as ``year`` or ``albumartist``, you'll want to use the ``-a`` flag to +avoid a confusing situation where the data for individual tracks conflicts +with the data for the whole album. + +Items will automatically be moved around when necessary if they're in your +library directory, but you can disable that with ``-M``. Tags will be written +to the files according to the settings you have for imports, but these can be +overridden with ``-w`` (write tags, the default) and ``-W`` (don't write +tags). + +When you run the ``modify`` command, it prints a list of all +affected items in the library and asks for your permission before making any +changes. You can then choose to abort the change (type `n`), confirm +(`y`), or interactively choose some of the items (`s`). In the latter case, +the command will prompt you for every matching item or album and invite you to +type `y` or `n`. This option lets you choose precisely which data to change +without spending too much time to carefully craft a query. To skip the prompts +entirely, use the ``-y`` option. .. _move-cmd: @@ -260,6 +288,7 @@ destination directory with ``-d`` manually, you can move items matching a query anywhere in your filesystem. The ``-c`` option copies files instead of moving them. As with other commands, the ``-a`` option matches albums instead of items. +The ``-e`` flag (for "export") copies files without changing the database. To perform a "dry run", just use the ``-p`` (for "pretend") flag. This will show you a list of files that would be moved but won't actually change anything @@ -272,7 +301,7 @@ `````` :: - beet update [-aM] QUERY + beet update [-F] FIELD [-aM] QUERY Update the library (and, optionally, move files) to reflect out-of-band metadata changes and file deletions. @@ -288,6 +317,11 @@ This will show you all the proposed changes but won't actually change anything on disk. +By default, all the changed metadata will be populated back to the database. +If you only want certain fields to be written, specify them with the ```-F``` +flags (which can be used multiple times). For the list of supported fields, +please see ```beet fields```. + When an updated track is part of an album, the album-level fields of *all* tracks from the album are also updated. (Specifically, the command copies album-level data from the first track on the album and applies it to the @@ -318,7 +352,7 @@ The ``-p`` option previews metadata changes without actually applying them. -The ``-f`` option forces a write to the file, even if the file tags match the database. This is useful for making sure that enabled plugins that run on write (e.g., the Scrub and Zero plugins) are run on the file. +The ``-f`` option forces a write to the file, even if the file tags match the database. This is useful for making sure that enabled plugins that run on write (e.g., the Scrub and Zero plugins) are run on the file. @@ -389,7 +423,11 @@ * ``-v``: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. You can use it twice, as in ``-vv``, to make beets even more verbose. -* ``-c FILE``: read a specified YAML :doc:`configuration file <config>`. +* ``-c FILE``: read a specified YAML :doc:`configuration file <config>`. This + configuration works as an overlay: rather than replacing your normal + configuration options entirely, the two are merged. Any individual options set + in this config file will override the corresponding settings in your base + configuration. Beets also uses the ``BEETSDIR`` environment variable to look for configuration and data. diff -Nru beets-1.3.19/docs/reference/config.rst beets-1.4.6/docs/reference/config.rst --- beets-1.3.19/docs/reference/config.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/reference/config.rst 2017-12-16 20:00:48.000000000 +0000 @@ -131,6 +131,7 @@ '\.$': _ '\s+$': '' '^\s+': '' + '^-': _ These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line @@ -229,7 +230,7 @@ sort_album ~~~~~~~~~~ -Default sort order to use when fetching items from the database. Defaults to +Default sort order to use when fetching albums from the database. Defaults to ``albumartist+ album+``. Explicit sort orders override this default. .. _sort_case_insensitive: @@ -387,6 +388,8 @@ These options are available in this section: +.. _config-import-write: + write ~~~~~ @@ -431,8 +434,8 @@ ~~~~ Either ``yes`` or ``no``, indicating whether to use symbolic links instead of -moving or copying files. (It conflicts with the ``move`` and ``copy`` -options.) Defaults to ``no``. +moving or copying files. (It conflicts with the ``move``, ``copy`` and +``hardlink`` options.) Defaults to ``no``. This option only works on platforms that support symbolic links: i.e., Unixes. It will fail on Windows. @@ -440,6 +443,19 @@ It's likely that you'll also want to set ``write`` to ``no`` if you use this option to preserve the metadata on the linked files. +.. _hardlink: + +hardlink +~~~~~~~~ + +Either ``yes`` or ``no``, indicating whether to use hard links instead of +moving or copying or symlinking files. (It conflicts with the ``move``, +``copy``, and ``link`` options.) Defaults to ``no``. + +As with symbolic links (see :ref:`link`, above), this will not work on Windows +and you will want to set ``write`` to ``no``. Otherwise, metadata on the +original file will be modified. + resume ~~~~~~ @@ -459,6 +475,15 @@ recorded and whether these recorded directories are skipped. This corresponds to the ``-i`` flag to ``beet import``. +.. _from_scratch: + +from_scratch +~~~~~~~~~~~~ + +Either ``yes`` or ``no`` (default), controlling whether existing metadata is +discarded when a match is applied. This corresponds to the ``--from_scratch`` +flag to ``beet import``. + quiet_fallback ~~~~~~~~~~~~~~ @@ -555,12 +580,38 @@ duplicate_action ~~~~~~~~~~~~~~~~ -Either ``skip``, ``keep``, ``remove``, or ``ask``. Controls how duplicates -are treated in import task. "skip" means that new item(album or track) will be -skiped; "keep" means keep both old and new items; "remove" means remove old -item; "ask" means the user should be prompted for the action each time. -The default is ``ask``. +Either ``skip``, ``keep``, ``remove``, ``merge`` or ``ask``. +Controls how duplicates are treated in import task. +"skip" means that new item(album or track) will be skipped; +"keep" means keep both old and new items; "remove" means remove old +item; "merge" means merge into one album; "ask" means the user +should be prompted for the action each time. The default is ``ask``. + +.. _bell: + +bell +~~~~ + +Ring the terminal bell to get your attention when the importer needs your input. + +Default: ``no``. + +.. _set_fields: + +set_fields +~~~~~~~~~~ + +A dictionary indicating fields to set to values for newly imported music. +Here's an example:: + + set_fields: + genre: 'To Listen' + collection: 'Unordered' + +Other field/value pairs supplied via the ``--set`` option on the command-line +override any settings here for fields with the same name. +Default: ``{}`` (empty). .. _musicbrainz-config: @@ -618,7 +669,7 @@ The default strong recommendation threshold is 0.04. The ``medium_rec_thresh`` and ``rec_gap_thresh`` options work similarly. When a -match is above the *medium* recommendation threshold or the distance between it +match is below the *medium* recommendation threshold or the distance between it and the next-best match is above the *gap* threshold, the importer will suggest that match but not automatically confirm it. Otherwise, you'll see a list of options to choose from. @@ -814,21 +865,14 @@ Here's an example file:: - library: /var/music.blb directory: /var/mp3 import: copy: yes write: yes - resume: ask - quiet_fallback: skip - timid: no log: beetslog.txt - ignore: .AppleDouble ._* *~ .DS_Store - ignore_hidden: yes art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins - threaded: yes ui: color: yes diff -Nru beets-1.3.19/docs/reference/pathformat.rst beets-1.4.6/docs/reference/pathformat.rst --- beets-1.3.19/docs/reference/pathformat.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/reference/pathformat.rst 2017-01-21 04:54:30.000000000 +0000 @@ -71,17 +71,17 @@ For example, "café" becomes "cafe". Uses the mapping provided by the `unidecode module`_. See the :ref:`asciify-paths` configuration option. -* ``%aunique{identifiers,disambiguators}``: Provides a unique string to - disambiguate similar albums in the database. See :ref:`aunique`, below. +* ``%aunique{identifiers,disambiguators,brackets}``: Provides a unique string + to disambiguate similar albums in the database. See :ref:`aunique`, below. * ``%time{date_time,format}``: Return the date and time in any format accepted by `strftime`_. For example, to get the year some music was added to your library, use ``%time{$added,%Y}``. -* ``%first{text}``: Returns the first item, separated by ``; ``. +* ``%first{text}``: Returns the first item, separated by ``;`` (a semicolon + followed by a space). You can use ``%first{text,count,skip}``, where ``count`` is the number of items (default 1) and ``skip`` is number to skip (default 0). You can also use ``%first{text,count,skip,sep,join}`` where ``sep`` is the separator, like ``;`` or ``/`` and join is the text to concatenate the items. - For example, * ``%ifdef{field}``, ``%ifdef{field,truetext}`` or ``%ifdef{field,truetext,falsetext}``: If ``field`` exists, then return ``truetext`` or ``field`` (default). Otherwise, returns ``falsetext``. @@ -112,14 +112,16 @@ function detects that you have two albums with the same artist and title but that they have different release years. -For full flexibility, the ``%aunique`` function takes two arguments, each of -which are whitespace-separated lists of album field names: a set of -*identifiers* and a set of *disambiguators*. Any group of albums with identical -values for all the identifiers will be considered "duplicates". Then, the -function tries each disambiguator field, looking for one that distinguishes each -of the duplicate albums from each other. The first such field is used as the -result for ``%aunique``. If no field suffices, an arbitrary number is used to -distinguish the two albums. +For full flexibility, the ``%aunique`` function takes three arguments. The +first two are whitespace-separated lists of album field names: a set of +*identifiers* and a set of *disambiguators*. The third argument is a pair of +characters used to surround the disambiguator. + +Any group of albums with identical values for all the identifiers will be +considered "duplicates". Then, the function tries each disambiguator field, +looking for one that distinguishes each of the duplicate albums from each +other. The first such field is used as the result for ``%aunique``. If no field +suffices, an arbitrary number is used to distinguish the two albums. The default identifiers are ``albumartist album`` and the default disambiguators are ``albumtype year label catalognum albumdisambig``. So you can get reasonable @@ -127,6 +129,10 @@ your path forms (as in the default path formats), but you can customize the disambiguation if, for example, you include the year by default in path formats. +The default characters used as brackets are ``[]``. To change this, provide a +third argument to the ``%aunique`` function consisting of two characters: the left +and right brackets. Or, to turn off bracketing entirely, leave argument blank. + One caveat: When you import an album that is named identically to one already in your library, the *first* album—the one already in your library— will not consider itself a duplicate at import time. This means that ``%aunique{}`` will @@ -142,11 +148,17 @@ The characters ``$``, ``%``, ``{``, ``}``, and ``,`` are "special" in the path template syntax. This means that, for example, if you want a ``%`` character to appear in your paths, you'll need to be careful that you don't accidentally -write a function call. To escape any of these characters (except ``{``), prefix -it with a ``$``. For example, ``$$`` becomes ``$``; ``$%`` becomes ``%``, etc. -The only exception is ``${``, which is ambiguous with the variable reference -syntax (like ``${title}``). To insert a ``{`` alone, it's always sufficient to -just type ``{``. +write a function call. To escape any of these characters (except ``{``, and +``,`` outside a function argument), prefix it with a ``$``. For example, +``$$`` becomes ``$``; ``$%`` becomes ``%``, etc. The only exceptions are: + +* ``${``, which is ambiguous with the variable reference syntax (like + ``${title}``). To insert a ``{`` alone, it's always sufficient to just type + ``{``. +* commas are used as argument separators in function calls. Inside of a + function's argument, use ``$,`` to get a literal ``,`` character. Outside of + any function argument, escaping is not necessary: ``,`` by itself will + produce ``,`` in the output. If a value or function is undefined, the syntax is simply left unreplaced. For example, if you write ``$foo`` in a path template, this will yield ``$foo`` in diff -Nru beets-1.3.19/docs/reference/query.rst beets-1.4.6/docs/reference/query.rst --- beets-1.3.19/docs/reference/query.rst 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/reference/query.rst 2017-06-20 19:15:08.000000000 +0000 @@ -6,6 +6,8 @@ query string syntax, which is meant to vaguely resemble the syntax used by Web search engines. +.. _keywordquery: + Keyword ------- @@ -186,6 +188,54 @@ $ beet ls 'mtime:2008-12-01..2008-12-02' +You can also add an optional time value to date queries, specifying hours, +minutes, and seconds. + +Times are separated from dates by a space, an uppercase 'T' or a lowercase +'t', for example: ``2008-12-01T23:59:59``. If you specify a time, then the +date must contain a year, month, and day. The minutes and seconds are +optional. + +Here is an example that finds all items added on 2008-12-01 at or after 22:00 +but before 23:00:: + + $ beet ls 'added:2008-12-01T22' + +To find all items added on or after 2008-12-01 at 22:45:: + + $ beet ls 'added:2008-12-01T22:45..' + +To find all items added on 2008-12-01, at or after 22:45:20 but before +22:45:41:: + + $ beet ls 'added:2008-12-01T22:45:20..2008-12-01T22:45:40' + +Here are example of the three ways to separate dates from times. All of these +queries do the same thing:: + + $ beet ls 'added:2008-12-01T22:45:20' + $ beet ls 'added:2008-12-01t22:45:20' + $ beet ls 'added:2008-12-01 22:45:20' + +You can also use *relative* dates. For example, ``-3w`` means three weeks ago, +and ``+4d`` means four days in the future. A relative date has three parts: + +- Either ``+`` or ``-``, to indicate the past or the future. The sign is + optional; if you leave this off, it defaults to the future. +- A number. +- A letter indicating the unit: ``d``, ``w``, ``m`` or ``y``, meaning days, + weeks, months or years. (A "month" is always 30 days and a "year" is always + 365 days.) + +Here's an example that finds all the albums added since last week:: + + $ beet ls -a 'added:-1w..' + +And here's an example that lists items added in a two-week period starting +four weeks ago:: + + $ beet ls 'added:-6w..-4w' + .. _not_query: Query Term Negation diff -Nru beets-1.3.19/docs/serve.py beets-1.4.6/docs/serve.py --- beets-1.3.19/docs/serve.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/docs/serve.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,9 +0,0 @@ -#!/usr/bin/env python - -from __future__ import division, absolute_import, print_function - -from livereload import Server, shell - -server = Server() -server.watch('*.rst', shell('make html')) -server.serve(root='_build/html') diff -Nru beets-1.3.19/extra/ascii_logo.txt beets-1.4.6/extra/ascii_logo.txt --- beets-1.3.19/extra/ascii_logo.txt 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/extra/ascii_logo.txt 2016-12-17 03:01:22.000000000 +0000 @@ -0,0 +1,17 @@ +[][][][] +[][] [] +[][][][] +[][] [] +[][][][] + +[][][][] [][][][] +[][] [] [][] [] +[][][][] [][][][] +[][] [][] +[][][][] [][][][] + +[][][][] [][][][] +[][][][] [][] + [][] [][] + [][] [][] + [][] [][][][] diff -Nru beets-1.3.19/extra/_beet beets-1.4.6/extra/_beet --- beets-1.3.19/extra/_beet 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/extra/_beet 2017-01-03 01:53:12.000000000 +0000 @@ -0,0 +1,269 @@ +#compdef beet + +# zsh completion for beets music library manager and MusicBrainz tagger: http://beets.radbox.org/ + +# NOTE: it will be very slow the first time you try to complete in a zsh shell (especially if you've enable many plugins) +# You can make it faster in future by creating a cached version: +# 1) perform a query completion with this file (_beet), e.g. do: beet list artist:"<TAB> +# to create the completion function (takes a few seconds) +# 2) save a copy of the completion function: which _beet > _beet_cached +# 3) save a copy of the query completion function: which _beet_query > _beet_query_cached +# 4) copy the contents of _beet_query_cached to the top of _beet_cached +# 5) copy and paste the _beet_field_values function from _beet to the top of _beet_cached +# 6) add the following line to the top of _beet_cached: #compdef beet +# 7) add the following line to the bottom of _beet_cached: _beet "$@" +# 8) save _beet_cached to your completions directory (e.g. /usr/share/zsh/functions/Completion) +# 9) add the following line to your .zshrc file: compdef _beet_cached beet +# You will need to repeat this proceedure each time you enable new plugins if you want them to complete properly. + +# useful: argument to _regex_arguments for matching any word +local matchany=/$'[^\0]##\0'/ + +# Deal with completions for querying and modifying fields.. +local fieldargs matchquery matchmodify +local -a fields +# get list of all fields +fields=(`beet fields | grep -G '^ ' | sort -u | colrm 1 2`) +# regexps for matching query and modify terms on the command line +matchquery=/"(${(j/|/)fields[@]})"$':[^\0]##\0'/ +matchmodify=/"(${(j/|/)fields[@]})"$'(=[^\0]##|!)\0'/ + +# Function for joining grouped lines of output into single lines (taken from _completion_helpers) +function _join_lines() { + awk -v SEP="$1" -v ARG2="$2" -v START="$3" -v END2="$4" 'BEGIN {if(START==""){f=1}{f=0}; + if(ARG2 ~ "^[0-9]+"){LINE1 = "^[[:space:]]{,"ARG2"}[^[:space:]]"}else{LINE1 = ARG2}} + ($0 ~ END2 && f>0 && END2!="") {exit} + ($0 ~ START && f<1) {f=1; if(length(START)!=0){next}} + ($0 ~ LINE1 && f>0) {if(f<2){f=2; printf("%s",$0)}else{printf("\n%s",$0)}; next} + (f>1) {gsub(/^[[:space:]]+|[[:space:]]+$/,"",$0); printf("%s%s",SEP, $0); next} + END {print ""}' +} + +# Function for getting unique values for field from database (you may need to change the path to the database). +function _beet_field_values() +{ + local -a output fieldvals + local library="$(beet config|grep library|cut -f 2 -d ' ')" + output=$(sqlite3 ${~library} "select distinct $1 from items;") + case $1 + in + lyrics) + fieldvals= + ;; + *) + fieldvals=("${(f)output[@]}") + ;; + esac + compadd -P \" -S \" -M 'm:{[:lower:][:upper:]}={[:upper:][:lower:]}' -Q -a fieldvals +} +# store call to _values function for completing query terms +# (first build arguments for completing field values) +local field +for field in "${fields[@]}" +do + fieldargs="$fieldargs '$field:::{_beet_field_values $field}'" +done +local queryelem modifyelem +queryelem="_values -S : 'query field (add an extra : to match by regexp)' '::' $fieldargs" +# store call to _values function for completing modify terms (no need to complete field values) +modifyelem="_values -S = 'modify field (replace = with ! to remove field)' $(echo "'${^fields[@]}:: '")" +# Create completion function for queries +_regex_arguments _beet_query "$matchany" \# \( "$matchquery" ":query:query string:$queryelem" \) \ + \( "$matchquery" ":query:query string:$queryelem" \) \# +# store regexps for completing lists of queries and modifications +local -a query modify +query=( \( "$matchquery" ":query:query string:{_beet_query}" \) \( "$matchquery" ":query:query string:{_beet_query}" \) \# ) +modify=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# ) + +# arguments to _regex_arguments for completing files and directories +local -a files dirs +files=("$matchany" ':file:file:_files') +dirs=("$matchany" ':dir:directory:_dirs') + +# Individual options used by subcommands, and global options (must be single quoted). +# Its much faster if these are hard-coded rather generated using _beet_subcmd_options +local helpopt formatopt albumopt dontmoveopt writeopt nowriteopt pretendopt pathopt destopt copyopt nocopyopt +local inferopt noinferopt resumeopt noresumeopt nopromptopt logopt individualopt confirmopt retagopt skipopt noskipopt +local flatopt groupopt editopt defaultopt noconfirmopt exactopt removeopt configopt debugopt +helpopt='-h:show this help message and exit' +formatopt='-f:print with custom format:$matchany' +albumopt='-a:match albums instead of tracks' +dontmoveopt='-M:dont move files in library' +writeopt='-w:write new metadata to files tags (default)' +nowriteopt='-W:dont write metadata (opposite of -w)' +pretendopt='-p:show all changes but do nothing' +pathopt='-p:print paths for matched items or albums' +destopt='-d:destination music directory:$dirs' +copyopt='-c:copy tracks into library directory (default)' +nocopyopt='-C:dont copy tracks (opposite of -c)' +inferopt='-a:infer tags for imported files (default)' +noinferopt='-A:dont infer tags for imported files (opposite of -a)' +resumeopt='-p:resume importing if interrupted' +noresumeopt='-P:do not try to resume importing' +nopromptopt='-q:never prompt for input, skip albums instead' +logopt='-l:file to log untaggable albums for later review:$files' +individualopt='-s:import individual tracks instead of full albums' +confirmopt='-t:always confirm all actions' +retagopt='-L:retag items matching a query:${query[@]}' +skipopt='-i:skip already-imported directories' +noskipopt='-I:do not skip already-imported directories' +flatopt='--flat:import an entire tree as a single album' +groupopt='-g:group tracks in a folder into seperate albums' +editopt='-e:edit user configuration with $EDITOR' +defaultopt='-d:include the default configuration' +copynomoveopt='-c:copy instead of moving' +noconfirmopt='-y:skip confirmation' +exactopt='-e:get exact file sizes' +removeopt='-d:also remove files from disk' +configopt='-c:path to configuration file:$files' +debugopt='-v:print debugging information' +libopt='-l:library database file to use:$files' + +# This function takes a beet subcommand as its first argument, and then uses _regex_words to set ${reply[@]} +# to an array containing arguments for the _regex_arguments function. +function _beet_subcmd_options() +{ + local shortopt optarg optdesc + local -a regex_words + regex_words=() + for i in ${${(f)"$(beet help $1 | awk '/^ +-/{if(x)print x;x=$0;next}/^ *$/{if(x) exit}{if(x) x=x$0}END{print x}')"}[@]} + do + opt="${i[(w)1]/,/}" + optarg="${${${i## #[-a-zA-Z]# }##[- ]##*}%%[, ]*}" + optdesc="${${${${${i[(w)2,-1]/[A-Z, ]#--[-a-z]##[=A-Z]# #/}//:/-}//\[/(}//\]/)}//\'/}" + case $optarg + in + ("") + if [[ "$1" == "import" && "$opt" == "-L" ]]; then + regex_words+=("$opt:$optdesc:\${query[@]}") + else + regex_words+=("$opt:$optdesc") + fi + ;; + (LOG) + regex_words+=("$opt:$optdesc:\$files") + ;; + (CONFIG) + local -a configfile + configfile=("$matchany" ':file:config file:{_files -g *.yaml}') + regex_words+=("$opt:$optdesc:\$configfile") + ;; + (LIB|LIBRARY) + local -a libfile + libfile=("$matchany" ':file:database file:{_files -g *.db}') + regex_words+=("$opt:$optdesc:\$libfile") + ;; + (DIR|DIRECTORY) + regex_words+=("$opt:$optdesc:\$dirs") + ;; + (SOURCE) + if [[ $1 -eq lastgenre ]]; then + local -a lastgenresource + lastgenresource=(/$'(artist|album|track)\0'/ ':source:genre source:(artist album track)') + regex_words+=("$opt:$optdesc:\$lastgenresource") + else + regex_words+=("$opt:$optdesc:\$matchany") + fi + ;; + (*) + regex_words+=("$opt:$optdesc:\$matchany") + ;; + esac + done + _regex_words options "$1 options" "${regex_words[@]}" +} + +# Now build the arguments to _regex_arguments for each subcommand. +local -a options regex_words_subcmds regex_words_help +local subcmd cmddesc +for i in ${${(f)"$(beet help | _join_lines ' ' 3 'Commands:')"[@]}[@]} +do + subcmd="${i[(w)1]}" + # remove first word and parenthesised alias, replace : with -, [ with (, ] with ), and remove single quotes + cmddesc="${${${${${i[(w)2,-1]##\(*\) #}//:/-}//\[/(}//\]/)}//\'/}" + case $subcmd + in + (config) + _regex_words options "config options" "$helpopt" "$pathopt" "$editopt" "$defaultopt" + options=("${reply[@]}") + ;; + (import) + _regex_words options "import options" "$helpopt" "$writeopt" "$nowriteopt" "$copyopt" "$nocopyopt"\ + "$inferopt" "$noinferopt" "$resumeopt" "$noresumeopt" "$nopromptopt" "$logopt" "$individualopt" "$confirmopt"\ + "$retagopt" "$skipopt" "$noskipopt" "$flatopt" "$groupopt" + options=( "${reply[@]}" \# "${files[@]}" \# ) + ;; + (list) + _regex_words options "list options" "$helpopt" "$pathopt" "$albumopt" "$formatopt" + options=( "$reply[@]" \# "${query[@]}" ) + ;; + (modify) + _regex_words options "modify options" "$helpopt" "$dontmoveopt" "$writeopt" "$nowriteopt" "$albumopt" \ + "$noconfirmopt" "$formatopt" + options=( "${reply[@]}" \# "${query[@]}" "${modify[@]}" ) + ;; + (move) + _regex_words options "move options" "$helpopt" "$albumopt" "$destopt" "$copynomoveopt" + options=( "${reply[@]}" \# "${query[@]}") + ;; + (remove) + _regex_words options "remove options" "$helpopt" "$albumopt" "$removeopt" + options=( "${reply[@]}" \# "${query[@]}" ) + ;; + (stats) + _regex_words options "stats options" "$helpopt" "$exactopt" + options=( "${reply[@]}" \# "${query[@]}" ) + ;; + (update) + _regex_words options "update options" "$helpopt" "$albumopt" "$dontmoveopt" "$pretendopt" "$formatopt" + options=( "${reply[@]}" \# "${query[@]}" ) + ;; + (write) + _regex_words options "write options" "$helpopt" "$pretendopt" + options=( "${reply[@]}" \# "${query[@]}" ) + ;; + (fields|migrate|version) + options=() + ;; + (help) + # The help subcommand is treated separately + continue + ;; + (*) # completions for plugin commands are generated using _beet_subcmd_options + _beet_subcmd_options "$subcmd" + options=( \( "${reply[@]}" \# "${query[@]}" \) ) + ;; + esac + # Create variable for holding option for this subcommand, and assign to it (needs to have a unique name). + typeset -a opts_for_$subcmd + set -A opts_for_$subcmd ${options[@]} # Assignment MUST be done using set (other methods fail). + regex_words_subcmds+=("$subcmd:$cmddesc:\${(@)opts_for_$subcmd}") + # Add to regex_words args for help subcommand + regex_words_help+=("$subcmd:$cmddesc") +done + +local -a opts_for_help +_regex_words subcmds "subcommands" "${regex_words_help[@]}" +opts_for_help=("${reply[@]}") +regex_words_subcmds+=('help:show help:$opts_for_help') + +# Argument for global options +local -a globalopts +_regex_words options "global options" "$configopt" "$debugopt" "$libopt" "$helpopt" "$destopt" +globalopts=("${reply[@]}") + +# Create main completion function +#local -a subcmds +_regex_words subcmds "subcommands" "${regex_words_subcmds[@]}" +subcmds=("${reply[@]}") +_regex_arguments _beet "$matchany" \( "${globalopts[@]}" \# \) "${subcmds[@]}" + +# Set tag-order so that options are completed separately from arguments +zstyle ":completion:${curcontext}:" tag-order '! options' + +# Execute the completion function +_beet "$@" + +# Local Variables: +# mode:shell-script +# End: Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/extra/beets.reg and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/extra/beets.reg differ diff -Nru beets-1.3.19/extra/release.py beets-1.4.6/extra/release.py --- beets-1.3.19/extra/release.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/extra/release.py 2016-12-17 03:01:22.000000000 +0000 @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""A utility script for automating the beets release process. +""" +import click +import os +import re +import subprocess +from contextlib import contextmanager +import datetime + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHANGELOG = os.path.join(BASE, 'docs', 'changelog.rst') + + +@contextmanager +def chdir(d): + """A context manager that temporary changes the working directory. + """ + olddir = os.getcwd() + os.chdir(d) + yield + os.chdir(olddir) + + +@click.group() +def release(): + pass + + +# Locations (filenames and patterns) of the version number. +VERSION_LOCS = [ + ( + os.path.join(BASE, 'beets', '__init__.py'), + [ + ( + r'__version__\s*=\s*u[\'"]([0-9\.]+)[\'"]', + "__version__ = u'{version}'", + ) + ] + ), + ( + os.path.join(BASE, 'docs', 'conf.py'), + [ + ( + r'version\s*=\s*[\'"]([0-9\.]+)[\'"]', + "version = '{minor}'", + ), + ( + r'release\s*=\s*[\'"]([0-9\.]+)[\'"]', + "release = '{version}'", + ), + ] + ), + ( + os.path.join(BASE, 'setup.py'), + [ + ( + r'\s*version\s*=\s*[\'"]([0-9\.]+)[\'"]', + " version='{version}',", + ) + ] + ), +] + +GITHUB_USER = 'beetbox' +GITHUB_REPO = 'beets' + + +def bump_version(version): + """Update the version number in setup.py, docs config, changelog, + and root module. + """ + version_parts = [int(p) for p in version.split('.')] + assert len(version_parts) == 3, "invalid version number" + minor = '{}.{}'.format(*version_parts) + major = '{}'.format(*version_parts) + + # Replace the version each place where it lives. + for filename, locations in VERSION_LOCS: + # Read and transform the file. + out_lines = [] + with open(filename) as f: + found = False + for line in f: + for pattern, template in locations: + match = re.match(pattern, line) + if match: + # Check that this version is actually newer. + old_version = match.group(1) + old_parts = [int(p) for p in old_version.split('.')] + assert version_parts > old_parts, \ + "version must be newer than {}".format( + old_version + ) + + # Insert the new version. + out_lines.append(template.format( + version=version, + major=major, + minor=minor, + ) + '\n') + + found = True + break + + else: + # Normal line. + out_lines.append(line) + + if not found: + print("No pattern found in {}".format(filename)) + + # Write the file back. + with open(filename, 'w') as f: + f.write(''.join(out_lines)) + + # Generate bits to insert into changelog. + header_line = '{} (in development)'.format(version) + header = '\n\n' + header_line + '\n' + '-' * len(header_line) + '\n\n' + header += 'Changelog goes here!\n' + + # Insert into the right place. + with open(CHANGELOG) as f: + contents = f.read() + location = contents.find('\n\n') # First blank line. + contents = contents[:location] + header + contents[location:] + + # Write back. + with open(CHANGELOG, 'w') as f: + f.write(contents) + + +@release.command() +@click.argument('version') +def bump(version): + """Bump the version number. + """ + bump_version(version) + + +def get_latest_changelog(): + """Extract the first section of the changelog. + """ + started = False + lines = [] + with open(CHANGELOG) as f: + for line in f: + if re.match(r'^--+$', line.strip()): + # Section boundary. Start or end. + if started: + # Remove last line, which is the header of the next + # section. + del lines[-1] + break + else: + started = True + + elif started: + lines.append(line) + return ''.join(lines).strip() + + +def rst2md(text): + """Use Pandoc to convert text from ReST to Markdown. + """ + pandoc = subprocess.Popen( + ['pandoc', '--from=rst', '--to=markdown', '--no-wrap'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, _ = pandoc.communicate(text.encode('utf-8')) + md = stdout.decode('utf-8').strip() + + # Fix up odd spacing in lists. + return re.sub(r'^- ', '- ', md, flags=re.M) + + +def changelog_as_markdown(): + """Get the latest changelog entry as hacked up Markdown. + """ + rst = get_latest_changelog() + + # Replace plugin links with plugin names. + rst = re.sub(r':doc:`/plugins/(\w+)`', r'``\1``', rst) + + # References with text. + rst = re.sub(r':ref:`([^<]+)(<[^>]+>)`', r'\1', rst) + + # Other backslashes with verbatim ranges. + rst = re.sub(r'(\s)`([^`]+)`([^_])', r'\1``\2``\3', rst) + + # Command links with command names. + rst = re.sub(r':ref:`(\w+)-cmd`', r'``\1``', rst) + + # Bug numbers. + rst = re.sub(r':bug:`(\d+)`', r'#\1', rst) + + # Users. + rst = re.sub(r':user:`(\w+)`', r'@\1', rst) + + # Convert with Pandoc. + md = rst2md(rst) + + # Restore escaped issue numbers. + md = re.sub(r'\\#(\d+)\b', r'#\1', md) + + return md + + +@release.command() +def changelog(): + """Get the most recent version's changelog as Markdown. + """ + print(changelog_as_markdown()) + + +def get_version(index=0): + """Read the current version from the changelog. + """ + with open(CHANGELOG) as f: + cur_index = 0 + for line in f: + match = re.search(r'^\d+\.\d+\.\d+', line) + if match: + if cur_index == index: + return match.group(0) + else: + cur_index += 1 + + +@release.command() +def version(): + """Display the current version. + """ + print(get_version()) + + +@release.command() +def datestamp(): + """Enter today's date as the release date in the changelog. + """ + dt = datetime.datetime.now() + stamp = '({} {}, {})'.format(dt.strftime('%B'), dt.day, dt.year) + marker = '(in development)' + + lines = [] + underline_length = None + with open(CHANGELOG) as f: + for line in f: + if marker in line: + # The header line. + line = line.replace(marker, stamp) + lines.append(line) + underline_length = len(line.strip()) + elif underline_length: + # This is the line after the header. Rewrite the dashes. + lines.append('-' * underline_length + '\n') + underline_length = None + else: + lines.append(line) + + with open(CHANGELOG, 'w') as f: + for line in lines: + f.write(line) + + +@release.command() +def prep(): + """Run all steps to prepare a release. + + - Tag the commit. + - Build the sdist package. + - Generate the Markdown changelog to ``changelog.md``. + - Bump the version number to the next version. + """ + cur_version = get_version() + + # Tag. + subprocess.check_output(['git', 'tag', 'v{}'.format(cur_version)]) + + # Build. + with chdir(BASE): + subprocess.check_call(['python2', 'setup.py', 'sdist']) + + # Generate Markdown changelog. + cl = changelog_as_markdown() + with open(os.path.join(BASE, 'changelog.md'), 'w') as f: + f.write(cl) + + # Version number bump. + # FIXME It should be possible to specify this as an argument. + version_parts = [int(n) for n in cur_version.split('.')] + version_parts[-1] += 1 + next_version = u'.'.join(map(str, version_parts)) + bump_version(next_version) + + +@release.command() +def publish(): + """Unleash a release unto the world. + + - Push the tag to GitHub. + - Upload to PyPI. + """ + version = get_version(1) + + # Push to GitHub. + with chdir(BASE): + subprocess.check_call(['git', 'push']) + subprocess.check_call(['git', 'push', '--tags']) + + # Upload to PyPI. + path = os.path.join(BASE, 'dist', 'beets-{}.tar.gz'.format(version)) + subprocess.check_call(['twine', 'upload', path]) + + +@release.command() +def ghrelease(): + """Create a GitHub release using the `github-release` command-line + tool. + + Reads the changelog to upload from `changelog.md`. Uploads the + tarball from the `dist` directory. + """ + version = get_version(1) + tag = 'v' + version + + # Load the changelog. + with open(os.path.join(BASE, 'changelog.md')) as f: + cl_md = f.read() + + # Create the release. + subprocess.check_call([ + 'github-release', 'release', + '-u', GITHUB_USER, '-r', GITHUB_REPO, + '--tag', tag, + '--name', '{} {}'.format(GITHUB_REPO, version), + '--description', cl_md, + ]) + + # Attach the release tarball. + tarball = os.path.join(BASE, 'dist', 'beets-{}.tar.gz'.format(version)) + subprocess.check_call([ + 'github-release', 'upload', + '-u', GITHUB_USER, '-r', GITHUB_REPO, + '--tag', tag, + '--name', os.path.basename(tarball), + '--file', tarball, + ]) + + +if __name__ == '__main__': + release() diff -Nru beets-1.3.19/man/beet.1 beets-1.4.6/man/beet.1 --- beets-1.3.19/man/beet.1 2016-06-26 00:52:50.000000000 +0000 +++ beets-1.4.6/man/beet.1 2017-12-21 18:12:27.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH "BEET" "1" "Jun 25, 2016" "1.3" "beets" +.TH "BEET" "1" "Dec 21, 2017" "1.4" "beets" .SH NAME beet \- music tagger and library organizer . @@ -32,7 +32,7 @@ .. .SH SYNOPSIS .nf -\fBbeet\fP [\fIargs\fP\&...] \fIcommand\fP [\fIargs\fP\&...] +\fBbeet\fP [\fIargs\fP…] \fIcommand\fP [\fIargs\fP…] \fBbeet help\fP \fIcommand\fP .fi .sp @@ -63,7 +63,7 @@ Directories passed to the import command can contain either a single album or many, in which case the leaf directories will be considered albums (the latter case is true of typical Artist/Album organizations -and many people\(aqs "downloads" folders). The path can also be a single +and many people’s “downloads” folders). The path can also be a single song or an archive. Beets supports \fIzip\fP and \fItar\fP archives out of the box. To extract \fIrar\fP files, install the \fI\%rarfile\fP package and the \fIunrar\fP command. @@ -72,44 +72,51 @@ .INDENT 0.0 .IP \(bu 2 By default, the command copies files your the library directory and -updates the ID3 tags on your music. If you\(aqd like to leave your music -files untouched, try the \fB\-C\fP (don\(aqt copy) and \fB\-W\fP (don\(aqt write tags) +updates the ID3 tags on your music. In order to move the files, instead of +copying, use the \fB\-m\fP (move) option. If you’d like to leave your music +files untouched, try the \fB\-C\fP (don’t copy) and \fB\-W\fP (don’t write tags) options. You can also disable this behavior by default in the configuration file (below). .IP \(bu 2 Also, you can disable the autotagging behavior entirely using \fB\-A\fP -(don\(aqt autotag)\-\-\-then your music will be imported with its existing +(don’t autotag)—then your music will be imported with its existing metadata. .IP \(bu 2 During a long tagging import, it can be useful to keep track of albums -that weren\(aqt tagged successfully\-\-\-either because they\(aqre not in the -MusicBrainz database or because something\(aqs wrong with the files. Use the +that weren’t tagged successfully—either because they’re not in the +MusicBrainz database or because something’s wrong with the files. Use the \fB\-l\fP option to specify a filename to log every time you skip an album -or import it "as\-is" or an album gets skipped as a duplicate. +or import it “as\-is” or an album gets skipped as a duplicate. .IP \(bu 2 Relatedly, the \fB\-q\fP (quiet) option can help with large imports by autotagging without ever bothering to ask for user input. Whenever the normal autotagger mode would ask for confirmation, the quiet mode -pessimistically skips the album. The quiet mode also disables the tagger\(aqs +pessimistically skips the album. The quiet mode also disables the tagger’s ability to resume interrupted imports. .IP \(bu 2 Speaking of resuming interrupted imports, the tagger will prompt you if it seems like the last import of the directory was interrupted (by you or by -a crash). If you want to skip this prompt, you can say "yes" automatically -by providing \fB\-p\fP or "no" using \fB\-P\fP\&. The resuming feature can be +a crash). If you want to skip this prompt, you can say “yes” automatically +by providing \fB\-p\fP or “no” using \fB\-P\fP\&. The resuming feature can be disabled by default using a configuration option (see below). .IP \(bu 2 If you want to import only the \fInew\fP stuff from a directory, use the \fB\-i\fP option to run an \fIincremental\fP import. With this flag, beets will keep track of every directory it ever imports and avoid importing them again. -This is useful if you have an "incoming" directory that you periodically +This is useful if you have an “incoming” directory that you periodically add things to. -To get this to work correctly, you\(aqll need to use an incremental import \fIevery -time\fP you run an import on the directory in question\-\-\-including the first +To get this to work correctly, you’ll need to use an incremental import \fIevery +time\fP you run an import on the directory in question—including the first time, when no subdirectories will be skipped. So consider enabling the \fBincremental\fP configuration option. .IP \(bu 2 +When beets applies metadata to your music, it will retain the value of any +existing tags that weren’t overwritten, and import them into the database. You +may prefer to only use existing metadata for finding matches, and to erase it +completely when new metadata is applied. You can enforce this behavior with +the \fB\-\-from\-scratch\fP option, or the \fBfrom_scratch\fP configuration option. +.IP \(bu 2 By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer ask you every time, use the \fB\-t\fP (for \fItimid\fP) option. @@ -118,7 +125,7 @@ instead want to import individual, non\-album tracks, use the \fIsingleton\fP mode by supplying the \fB\-s\fP option. .IP \(bu 2 -If you have an album that\(aqs split across several directories under a common +If you have an album that’s split across several directories under a common top directory, use the \fB\-\-flat\fP option. This takes all the music files under the directory (recursively) and treats them as a single large album instead of as one album per directory. This can help with your more stubborn @@ -136,6 +143,21 @@ imported, you can instruct beets to restrict the search to that ID instead of searching for other candidates by using the \fB\-\-search\-id SEARCH_ID\fP option. Multiple IDs can be specified by simply repeating the option several times. +.IP \(bu 2 +You can supply \fB\-\-set field=value\fP to assign \fIfield\fP to \fIvalue\fP on import. +These assignments will merge with (and possibly override) the +set_fields configuration dictionary. You can use the option multiple +times on the command line, like so: +.INDENT 2.0 +.INDENT 3.5 +.sp +.nf +.ft C +beet import \-\-set genre="Alternative Rock" \-\-set mood="emotional" +.ft P +.fi +.UNINDENT +.UNINDENT .UNINDENT .SS list .INDENT 0.0 @@ -151,9 +173,9 @@ .sp Queries the database for music. .sp -Want to search for "Gronlandic Edit" by of Montreal? Try \fBbeet list +Want to search for “Gronlandic Edit” by of Montreal? Try \fBbeet list gronlandic\fP\&. Maybe you want to see everything released in 2009 with -"vegetables" in the title? Try \fBbeet list year:2009 title:vegetables\fP\&. You +“vegetables” in the title? Try \fBbeet list year:2009 title:vegetables\fP\&. You can also specify the sort order. (Read more in query\&.) .sp You can use the \fB\-a\fP switch to search for albums instead of individual items. @@ -165,7 +187,7 @@ The \fB\-p\fP option makes beets print out filenames of matched items, which might be useful for piping into other Unix commands (such as \fI\%xargs\fP). Similarly, the \fB\-f\fP option lets you specify a specific format with which to print every album -or track. This uses the same template syntax as beets\(aq path formats\&. For example, the command \fBbeet ls \-af \(aq$album: $tracktotal\(aq +or track. This uses the same template syntax as beets’ path formats\&. For example, the command \fBbeet ls \-af \(aq$album: $tracktotal\(aq beatles\fP prints out the number of tracks on each Beatles album. In Unix shells, remember to enclose the template argument in single quotes to avoid environment variable expansion. @@ -184,8 +206,8 @@ Remove music from your library. .sp This command uses the same query syntax as the \fBlist\fP command. -You\(aqll be shown a list of the files that will be removed and asked to confirm. -By default, this just removes entries from the library database; it doesn\(aqt +You’ll be shown a list of the files that will be removed and asked to confirm. +By default, this just removes entries from the library database; it doesn’t touch the files on disk. To actually delete the files, use \fBbeet remove \-d\fP\&. If you do not want to be prompted to remove the files, use \fBbeet remove \-f\fP\&. .SS modify @@ -194,7 +216,7 @@ .sp .nf .ft C -beet modify [\-MWay] QUERY [FIELD=VALUE...] [FIELD!...] +beet modify [\-MWay] [\-f FORMAT] QUERY [FIELD=VALUE...] [FIELD!...] .ft P .fi .UNINDENT @@ -204,17 +226,31 @@ .sp Supply a query matching the things you want to change and a series of \fBfield=value\fP pairs. For example, \fBbeet modify genius of love -artist="Tom Tom Club"\fP will change the artist for the track "Genius of Love." +artist="Tom Tom Club"\fP will change the artist for the track “Genius of Love.” To remove fields (which is only possible for flexible attributes), follow a field name with an exclamation point: \fBfield!\fP\&. .sp -The \fB\-a\fP switch operates on albums instead of -individual tracks. Items will automatically be moved around when necessary if -they\(aqre in your library directory, but you can disable that with \fB\-M\fP\&. Tags -will be written to the files according to the settings you have for imports, -but these can be overridden with \fB\-w\fP (write tags, the default) and \fB\-W\fP -(don\(aqt write tags). Finally, this command politely asks for your permission -before making any changes, but you can skip that prompt with the \fB\-y\fP switch. +The \fB\-a\fP switch operates on albums instead of individual tracks. Without +this flag, the command will only change \fItrack\-level\fP data, even if all the +tracks belong to the same album. If you want to change an \fIalbum\-level\fP field, +such as \fByear\fP or \fBalbumartist\fP, you’ll want to use the \fB\-a\fP flag to +avoid a confusing situation where the data for individual tracks conflicts +with the data for the whole album. +.sp +Items will automatically be moved around when necessary if they’re in your +library directory, but you can disable that with \fB\-M\fP\&. Tags will be written +to the files according to the settings you have for imports, but these can be +overridden with \fB\-w\fP (write tags, the default) and \fB\-W\fP (don’t write +tags). +.sp +When you run the \fBmodify\fP command, it prints a list of all +affected items in the library and asks for your permission before making any +changes. You can then choose to abort the change (type \fIn\fP), confirm +(\fIy\fP), or interactively choose some of the items (\fIs\fP). In the latter case, +the command will prompt you for every matching item or album and invite you to +type \fIy\fP or \fIn\fP\&. This option lets you choose precisely which data to change +without spending too much time to carefully craft a query. To skip the prompts +entirely, use the \fB\-y\fP option. .SS move .INDENT 0.0 .INDENT 3.5 @@ -234,9 +270,10 @@ destination directory with \fB\-d\fP manually, you can move items matching a query anywhere in your filesystem. The \fB\-c\fP option copies files instead of moving them. As with other commands, the \fB\-a\fP option matches albums instead of items. +The \fB\-e\fP flag (for “export”) copies files without changing the database. .sp -To perform a "dry run", just use the \fB\-p\fP (for "pretend") flag. This will -show you a list of files that would be moved but won\(aqt actually change anything +To perform a “dry run”, just use the \fB\-p\fP (for “pretend”) flag. This will +show you a list of files that would be moved but won’t actually change anything on disk. The \fB\-t\fP option sets the timid mode which will ask again before really moving or copying the files. .SS update @@ -245,7 +282,7 @@ .sp .nf .ft C -beet update [\-aM] QUERY +beet update [\-F] FIELD [\-aM] QUERY .ft P .fi .UNINDENT @@ -261,14 +298,19 @@ also update these for \fBbeet update\fP to recognise that the files have been edited. .sp -To perform a "dry run" of an update, just use the \fB\-p\fP (for "pretend") flag. -This will show you all the proposed changes but won\(aqt actually change anything +To perform a “dry run” of an update, just use the \fB\-p\fP (for “pretend”) flag. +This will show you all the proposed changes but won’t actually change anything on disk. .sp +By default, all the changed metadata will be populated back to the database. +If you only want certain fields to be written, specify them with the \fB\(ga\-F\(ga\fP +flags (which can be used multiple times). For the list of supported fields, +please see \fB\(gabeet fields\(ga\fP\&. +.sp When an updated track is part of an album, the album\-level fields of \fIall\fP tracks from the album are also updated. (Specifically, the command copies album\-level data from the first track on the album and applies it to the -rest of the tracks.) This means that, if album\-level fields aren\(aqt identical +rest of the tracks.) This means that, if album\-level fields aren’t identical within an album, some changes shown by the \fBupdate\fP command may be overridden by data from other tracks on the same album. This means that running the \fBupdate\fP command multiple times may show the same changes being @@ -285,9 +327,9 @@ .UNINDENT .UNINDENT .sp -Write metadata from the database into files\(aq tags. +Write metadata from the database into files’ tags. .sp -When you make changes to the metadata stored in beets\(aq library database +When you make changes to the metadata stored in beets’ library database (during import or with the \fI\%modify\fP command, for example), you often have the option of storing changes only in the database, leaving your files untouched. The \fBwrite\fP command lets you later change your mind and write the @@ -310,7 +352,7 @@ .UNINDENT .UNINDENT .sp -Show some statistics on your entire library (if you don\(aqt provide a +Show some statistics on your entire library (if you don’t provide a query) or the matched items (if you do). .sp By default, the command calculates file sizes using their bitrate and @@ -330,7 +372,7 @@ .sp Show the item and album metadata fields available for use in query and pathformat\&. The listing includes any template fields provided by -plugins and any flexible attributes you\(aqve manually assigned to your items and +plugins and any flexible attributes you’ve manually assigned to your items and albums. .SS config .INDENT 0.0 @@ -349,7 +391,7 @@ .INDENT 0.0 .IP \(bu 2 With no options, print a YAML representation of the current user -configuration. With the \fB\-\-default\fP option, beets\(aq default options are +configuration. With the \fB\-\-default\fP option, beets’ default options are also included in the dump. .IP \(bu 2 The \fB\-\-path\fP option instead shows the path to your configuration file. @@ -366,8 +408,8 @@ .UNINDENT .SH GLOBAL FLAGS .sp -Beets has a few "global" flags that affect all commands. These must appear -between the executable name (\fBbeet\fP) and the command\-\-\-for example, \fBbeet \-v +Beets has a few “global” flags that affect all commands. These must appear +between the executable name (\fBbeet\fP) and the command—for example, \fBbeet \-v import ...\fP\&. .INDENT 0.0 .IP \(bu 2 @@ -379,7 +421,11 @@ this flag when reporting bugs. You can use it twice, as in \fB\-vv\fP, to make beets even more verbose. .IP \(bu 2 -\fB\-c FILE\fP: read a specified YAML configuration file\&. +\fB\-c FILE\fP: read a specified YAML configuration file\&. This +configuration works as an overlay: rather than replacing your normal +configuration options entirely, the two are merged. Any individual options set +in this config file will override the corresponding settings in your base +configuration. .UNINDENT .sp Beets also uses the \fBBEETSDIR\fP environment variable to look for @@ -425,8 +471,8 @@ .UNINDENT .UNINDENT .sp -(Don\(aqt worry about the slash in front of the colon: this is a escape -sequence for the shell and won\(aqt be seen by beets.) +(Don’t worry about the slash in front of the colon: this is a escape +sequence for the shell and won’t be seen by beets.) .sp Completion of plugin commands only works for those plugins that were enabled when running \fBbeet completion\fP\&. If you add a plugin @@ -438,7 +484,7 @@ sourced in your \fB\&.zshrc\fP\&. Running \fBecho $fpath\fP will give you a list of valid directories. .sp -Another approach is to use zsh\(aqs bash completion compatibility. This snippet +Another approach is to use zsh’s bash completion compatibility. This snippet defines some bash\-specific functions to make this work without errors: .INDENT 0.0 .INDENT 3.5 diff -Nru beets-1.3.19/man/beetsconfig.5 beets-1.4.6/man/beetsconfig.5 --- beets-1.3.19/man/beetsconfig.5 2016-06-26 00:52:50.000000000 +0000 +++ beets-1.4.6/man/beetsconfig.5 2017-12-21 18:12:27.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH "BEETSCONFIG" "5" "Jun 25, 2016" "1.3" "beets" +.TH "BEETSCONFIG" "5" "Dec 21, 2017" "1.4" "beets" .SH NAME beetsconfig \- beets configuration file . @@ -147,10 +147,14 @@ .IP \(bu 2 \fI\%link\fP .IP \(bu 2 +\fI\%hardlink\fP +.IP \(bu 2 \fI\%resume\fP .IP \(bu 2 \fI\%incremental\fP .IP \(bu 2 +\fI\%from_scratch\fP +.IP \(bu 2 \fI\%quiet_fallback\fP .IP \(bu 2 \fI\%none_rec_action\fP @@ -170,6 +174,10 @@ \fI\%autotag\fP .IP \(bu 2 \fI\%duplicate_action\fP +.IP \(bu 2 +\fI\%bell\fP +.IP \(bu 2 +\fI\%set_fields\fP .UNINDENT .IP \(bu 2 \fI\%MusicBrainz Options\fP @@ -206,7 +214,7 @@ .UNINDENT .SH GLOBAL OPTIONS .sp -These options control beets\(aq global operation. +These options control beets’ global operation. .SS library .sp Path to the beets library file. By default, beets will use a file called @@ -228,7 +236,7 @@ Directories to search for plugins. Each Python file or directory in a plugin path represents a plugin and should define a subclass of \fBBeetsPlugin\fP\&. A plugin can then be loaded by adding the filename to the \fIplugins\fP configuration. -The plugin path can either be a single string or a list of strings\-\-\-so, if you +The plugin path can either be a single string or a list of strings—so, if you have multiple paths, format them as a YAML list like so: .INDENT 0.0 .INDENT 3.5 @@ -252,8 +260,8 @@ .SS ignore_hidden .sp Either \fByes\fP or \fBno\fP; whether to ignore hidden files when importing. On -Windows, the "Hidden" property of files is used to detect whether or not a file -is hidden. On OS X, the file\(aqs "IsHidden" flag is used to detect whether or not +Windows, the “Hidden” property of files is used to detect whether or not a file +is hidden. On OS X, the file’s “IsHidden” flag is used to detect whether or not a file is hidden. On both OS X and other platforms (excluding Windows), files (and directories) starting with a dot are detected as hidden files. .SS replace @@ -283,6 +291,7 @@ \(aq\e.$\(aq: _ \(aq\es+$\(aq: \(aq\(aq \(aq^\es+\(aq: \(aq\(aq + \(aq^\-\(aq: _ .ft P .fi .UNINDENT @@ -290,7 +299,7 @@ .sp These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line -removes the Windows "reserved characters" (useful even on Unix for for +removes the Windows “reserved characters” (useful even on Unix for for compatibility with Windows\-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. @@ -302,7 +311,7 @@ .sp Note that paths might contain special characters such as typographical quotes (\fB“”\fP). With the configuration above, those will not be -replaced as they don\(aqt match the typewriter quote (\fB"\fP). To also strip these +replaced as they don’t match the typewriter quote (\fB"\fP). To also strip these special characters, you can either add them to the replacement list or use the \fI\%asciify_paths\fP configuration option below. .SS asciify_paths @@ -310,7 +319,7 @@ Convert all non\-ASCII characters in paths to ASCII equivalents. .sp For example, if your path template for -singletons is \fBsingletons/$title\fP and the title of a track is "Café", +singletons is \fBsingletons/$title\fP and the title of a track is “Café”, then the track will be saved as \fBsingletons/Cafe.mp3\fP\&. The changes take place before applying the \fI\%replace\fP configuration and are roughly equivalent to wrapping all your path templates in the \fB%asciify{}\fP @@ -323,7 +332,7 @@ cover art image should be placed. This is a template string, so you can use any of the syntax available to /reference/pathformat\&. Defaults to \fBcover\fP (i.e., images will be named \fBcover.jpg\fP or \fBcover.png\fP and placed in the -album\(aqs directory). +album’s directory). .SS threaded .sp Either \fByes\fP or \fBno\fP, indicating whether the autotagger should use @@ -353,7 +362,7 @@ \fBartist+ album+ disc+ track+\fP\&. Explicit sort orders override this default. .SS sort_album .sp -Default sort order to use when fetching items from the database. Defaults to +Default sort order to use when fetching albums from the database. Defaults to \fBalbumartist+ album+\fP\&. Explicit sort orders override this default. .SS sort_case_insensitive .sp @@ -392,23 +401,23 @@ .UNINDENT .UNINDENT .sp -When this option is off (the default), even "pregap" hidden tracks are +When this option is off (the default), even “pregap” hidden tracks are numbered from one, not zero, so other track numbers may appear to be bumped up by one. When it is on, the pregap track for each disc can be numbered zero. .SS terminal_encoding .sp The text encoding, as \fI\%known to Python\fP, to use for messages printed to the -standard output. It\(aqs also used to read messages from the standard input. +standard output. It’s also used to read messages from the standard input. By default, this is determined automatically from the locale environment variables. .SS clutter .sp When beets imports all the files in a directory, it tries to remove the -directory if it\(aqs empty. A directory is considered empty if it only contains +directory if it’s empty. A directory is considered empty if it only contains files whose names match the glob patterns in \fIclutter\fP, which should be a list -of strings. The default list consists of "Thumbs.DB" and ".DS_Store". +of strings. The default list consists of “Thumbs.DB” and “.DS_Store”. .sp -The importer only removes recursively searched subdirectories\-\-\-the top\-level +The importer only removes recursively searched subdirectories—the top\-level directory you specify on the command line is never deleted. .SS max_filename_length .sp @@ -434,7 +443,7 @@ .SS color .sp Either \fByes\fP or \fBno\fP; whether to use color in console output (currently -only in the \fBimport\fP command). Turn this off if your terminal doesn\(aqt +only in the \fBimport\fP command). Turn this off if your terminal doesn’t support ANSI colors. .sp \fBNOTE:\fP @@ -505,7 +514,7 @@ overridden with the \fB\-c\fP and \fB\-C\fP command\-line options. .sp The option is ignored if \fBmove\fP is enabled (i.e., beets can move or -copy files but it doesn\(aqt make sense to do both). +copy files but it doesn’t make sense to do both). .SS move .sp Either \fByes\fP or \fBno\fP, indicating whether to \fBmove\fP files into the @@ -513,10 +522,10 @@ Defaults to \fBno\fP\&. .sp The effect is similar to the \fBcopy\fP option but you end up with only -one copy of the imported file. ("Moving" works even across filesystems; if +one copy of the imported file. (“Moving” works even across filesystems; if necessary, beets will copy and then delete when a simple rename is -impossible.) Moving files can be risky—it\(aqs a good idea to keep a backup in -case beets doesn\(aqt do what you expect with your files. +impossible.) Moving files can be risky—it’s a good idea to keep a backup in +case beets doesn’t do what you expect with your files. .sp This option \fIoverrides\fP \fBcopy\fP, so enabling it will always move (and not copy) files. The \fB\-c\fP switch to the \fBbeet import\fP command, @@ -524,27 +533,41 @@ .SS link .sp Either \fByes\fP or \fBno\fP, indicating whether to use symbolic links instead of -moving or copying files. (It conflicts with the \fBmove\fP and \fBcopy\fP -options.) Defaults to \fBno\fP\&. +moving or copying files. (It conflicts with the \fBmove\fP, \fBcopy\fP and +\fBhardlink\fP options.) Defaults to \fBno\fP\&. .sp This option only works on platforms that support symbolic links: i.e., Unixes. It will fail on Windows. .sp -It\(aqs likely that you\(aqll also want to set \fBwrite\fP to \fBno\fP if you use this +It’s likely that you’ll also want to set \fBwrite\fP to \fBno\fP if you use this option to preserve the metadata on the linked files. +.SS hardlink +.sp +Either \fByes\fP or \fBno\fP, indicating whether to use hard links instead of +moving or copying or symlinking files. (It conflicts with the \fBmove\fP, +\fBcopy\fP, and \fBlink\fP options.) Defaults to \fBno\fP\&. +.sp +As with symbolic links (see \fI\%link\fP, above), this will not work on Windows +and you will want to set \fBwrite\fP to \fBno\fP\&. Otherwise, metadata on the +original file will be modified. .SS resume .sp Either \fByes\fP, \fBno\fP, or \fBask\fP\&. Controls whether interrupted imports -should be resumed. "Yes" means that imports are always resumed when -possible; "no" means resuming is disabled entirely; "ask" (the default) +should be resumed. “Yes” means that imports are always resumed when +possible; “no” means resuming is disabled entirely; “ask” (the default) means that the user should be prompted when resuming is possible. The \fB\-p\fP -and \fB\-P\fP flags correspond to the "yes" and "no" settings and override this +and \fB\-P\fP flags correspond to the “yes” and “no” settings and override this option. .SS incremental .sp Either \fByes\fP or \fBno\fP, controlling whether imported directories are recorded and whether these recorded directories are skipped. This corresponds to the \fB\-i\fP flag to \fBbeet import\fP\&. +.SS from_scratch +.sp +Either \fByes\fP or \fBno\fP (default), controlling whether existing metadata is +discarded when a match is applied. This corresponds to the \fB\-\-from_scratch\fP +flag to \fBbeet import\fP\&. .SS quiet_fallback .sp Either \fBskip\fP (default) or \fBasis\fP, specifying what should happen in @@ -564,7 +587,7 @@ controls the same setting. .SS log .sp -Specifies a filename where the importer\(aqs log should be kept. By default, +Specifies a filename where the importer’s log should be kept. By default, no log is written. This can be overridden with the \fB\-l\fP flag to \fBimport\fP\&. .SS default_action @@ -576,8 +599,8 @@ .SS languages .sp A list of locale names to search for preferred aliases. For example, setting -this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" -instead of the Cyrillic script for the composer\(aqs name when tagging from +this to “en” uses the transliterated artist name “Pyotr Ilyich Tchaikovsky” +instead of the Cyrillic script for the composer’s name when tagging from MusicBrainz. Defaults to an empty list, meaning that no language is preferred. .SS detail .sp @@ -588,7 +611,7 @@ .SS group_albums .sp By default, the beets importer groups tracks into albums based on the -directories they reside in. This option instead uses files\(aq metadata to +directories they reside in. This option instead uses files’ metadata to partition albums. Enable this option if you have directories that contain tracks from many albums mixed together. .sp @@ -606,11 +629,38 @@ Default: \fByes\fP\&. .SS duplicate_action .sp -Either \fBskip\fP, \fBkeep\fP, \fBremove\fP, or \fBask\fP\&. Controls how duplicates -are treated in import task. "skip" means that new item(album or track) will be -skiped; "keep" means keep both old and new items; "remove" means remove old -item; "ask" means the user should be prompted for the action each time. -The default is \fBask\fP\&. +Either \fBskip\fP, \fBkeep\fP, \fBremove\fP, \fBmerge\fP or \fBask\fP\&. +Controls how duplicates are treated in import task. +“skip” means that new item(album or track) will be skipped; +“keep” means keep both old and new items; “remove” means remove old +item; “merge” means merge into one album; “ask” means the user +should be prompted for the action each time. The default is \fBask\fP\&. +.SS bell +.sp +Ring the terminal bell to get your attention when the importer needs your input. +.sp +Default: \fBno\fP\&. +.SS set_fields +.sp +A dictionary indicating fields to set to values for newly imported music. +Here’s an example: +.INDENT 0.0 +.INDENT 3.5 +.sp +.nf +.ft C +set_fields: + genre: \(aqTo Listen\(aq + collection: \(aqUnordered\(aq +.ft P +.fi +.UNINDENT +.UNINDENT +.sp +Other field/value pairs supplied via the \fB\-\-set\fP option on the command\-line +override any settings here for fields with the same name. +.sp +Default: \fB{}\fP (empty). .SH MUSICBRAINZ OPTIONS .sp If you run your own \fI\%MusicBrainz\fP server, you can instruct beets to use it @@ -632,8 +682,8 @@ The \fBhost\fP key, of course, controls the Web server hostname (and port, optionally) that will be contacted by beets (default: musicbrainz.org). The \fBratelimit\fP option, an integer, controls the number of Web service requests -per second (default: 1). \fBDo not change the rate limit setting\fP if you\(aqre -using the main MusicBrainz server\-\-\-on this public server, you\(aqre \fI\%limited\fP +per second (default: 1). \fBDo not change the rate limit setting\fP if you’re +using the main MusicBrainz server—on this public server, you’re \fI\%limited\fP to one request per second. .SS searchlimit .sp @@ -647,8 +697,8 @@ matching MusicBrainz results under the \fBmatch:\fP section. To control how \fItolerant\fP the autotagger is of differences, use the \fBstrong_rec_thresh\fP option, which reflects the distance threshold below which beets will make a -"strong recommendation" that the metadata be used. Strong recommendations -are accepted automatically (except in "timid" mode), so you can use this to +“strong recommendation” that the metadata be used. Strong recommendations +are accepted automatically (except in “timid” mode), so you can use this to make beets ask your opinion more or less often. .sp The threshold is a \fIdistance\fP value between 0.0 and 1.0, so you can think of it @@ -669,9 +719,9 @@ The default strong recommendation threshold is 0.04. .sp The \fBmedium_rec_thresh\fP and \fBrec_gap_thresh\fP options work similarly. When a -match is above the \fImedium\fP recommendation threshold or the distance between it +match is below the \fImedium\fP recommendation threshold or the distance between it and the next\-best match is above the \fIgap\fP threshold, the importer will suggest -that match but not automatically confirm it. Otherwise, you\(aqll see a list of +that match but not automatically confirm it. Otherwise, you’ll see a list of options to choose from. .SS max_rec .sp @@ -682,7 +732,7 @@ \fImaximum\fP recommendations for each field: .sp To define maxima, use keys under \fBmax_rec:\fP in the \fBmatch\fP section. The -defaults are "medium" for missing and unmatched tracks and "strong" (i.e., no +defaults are “medium” for missing and unmatched tracks and “strong” (i.e., no maximum) for everything else: .INDENT 0.0 .INDENT 3.5 @@ -701,7 +751,7 @@ If a recommendation is higher than the configured maximum and the indicated penalty is applied, the recommendation is downgraded. The setting for each field can be one of \fBnone\fP, \fBlow\fP, \fBmedium\fP or \fBstrong\fP\&. When the -maximum recommendation is \fBstrong\fP, no "downgrading" occurs. The available +maximum recommendation is \fBstrong\fP, no “downgrading” occurs. The available penalty names here are: .INDENT 0.0 .IP \(bu 2 @@ -750,15 +800,15 @@ media types. .sp A distance penalty will be applied if the country or media type from the match -metadata doesn\(aqt match. The specified values are preferred in descending order +metadata doesn’t match. The specified values are preferred in descending order (i.e., 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"). +be stripped when matching preferred media (e.g. “2x” in “2xCD”). .sp You can also tell the autotagger to prefer matches that have a release year closest to the original year for an album. .sp -Here\(aqs an example: +Here’s an example: .INDENT 0.0 .INDENT 3.5 .sp @@ -815,7 +865,7 @@ These settings appear under the \fBpaths:\fP key. Each string is a template string that can refer to metadata fields like \fB$artist\fP or \fB$title\fP\&. The filename extension is added automatically. At the moment, you can specify three -special paths: \fBdefault\fP for most releases, \fBcomp\fP for "various artist" +special paths: \fBdefault\fP for most releases, \fBcomp\fP for “various artist” releases with no dominant artist, and \fBsingleton\fP for non\-album tracks. The defaults look like this: .INDENT 0.0 @@ -870,7 +920,7 @@ First, you can set the \fBBEETSDIR\fP environment variable to a directory containing a \fBconfig.yaml\fP file. This replaces your configuration in the default location. This also affects where auxiliary files, like the library -database, are stored by default (that\(aqs where relative paths are resolved to). +database, are stored by default (that’s where relative paths are resolved to). This environment variable is useful if you need to manage multiple beets libraries with separate configurations. .SS Command\-Line Option @@ -897,31 +947,24 @@ the environment variable is set. .UNINDENT .sp -Beets uses the first directory in your platform\(aqs list that contains +Beets uses the first directory in your platform’s list that contains \fBconfig.yaml\fP\&. If no config file exists, the last path in the list is used. .SH EXAMPLE .sp -Here\(aqs an example file: +Here’s an example file: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C -library: /var/music.blb directory: /var/mp3 import: copy: yes write: yes - resume: ask - quiet_fallback: skip - timid: no log: beetslog.txt -ignore: .AppleDouble ._* *~ .DS_Store -ignore_hidden: yes art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins -threaded: yes ui: color: yes diff -Nru beets-1.3.19/MANIFEST.in beets-1.4.6/MANIFEST.in --- beets-1.3.19/MANIFEST.in 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/MANIFEST.in 2016-12-17 03:01:22.000000000 +0000 @@ -31,3 +31,6 @@ # Shell completion template include beets/ui/completion_base.sh + +# Include extra bits +recursive-include extra * diff -Nru beets-1.3.19/PKG-INFO beets-1.4.6/PKG-INFO --- beets-1.3.19/PKG-INFO 2016-06-26 00:52:53.000000000 +0000 +++ beets-1.4.6/PKG-INFO 2017-12-21 18:12:27.000000000 +0000 @@ -1,17 +1,15 @@ Metadata-Version: 1.1 Name: beets -Version: 1.3.19 +Version: 1.4.6 Summary: music tagger and library organizer Home-page: http://beets.io/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT +Description-Content-Type: UNKNOWN Description: .. image:: http://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets - .. image:: https://img.shields.io/pypi/dw/beets.svg - :target: https://pypi.python.org/pypi/beets#downloads - .. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets @@ -111,5 +109,11 @@ Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console Classifier: Environment :: Web Environment +Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: Implementation :: CPython diff -Nru beets-1.3.19/README.rst beets-1.4.6/README.rst --- beets-1.3.19/README.rst 2016-06-26 00:44:08.000000000 +0000 +++ beets-1.4.6/README.rst 2016-12-17 03:01:22.000000000 +0000 @@ -1,9 +1,6 @@ .. image:: http://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets -.. image:: https://img.shields.io/pypi/dw/beets.svg - :target: https://pypi.python.org/pypi/beets#downloads - .. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets diff -Nru beets-1.3.19/setup.cfg beets-1.4.6/setup.cfg --- beets-1.3.19/setup.cfg 2016-06-26 00:52:53.000000000 +0000 +++ beets-1.4.6/setup.cfg 2017-12-21 18:12:27.000000000 +0000 @@ -1,14 +1,13 @@ [nosetests] verbosity = 1 logging-clear-handlers = 1 -eval-attr = "!=slow" [flake8] min-version = 2.7 -ignore = C901,E241,E221,E731,F405,FI50,FI51,FI12,FI53,FI14,FI15 +accept-encodings = utf-8 +ignore = E121,E123,E126,E226,E24,E704,W503,W504,E305,C901,E221,E731,F405,FI50,FI51,FI12,FI53,FI14,FI15,E741 [egg_info] tag_build = tag_date = 0 -tag_svn_revision = 0 diff -Nru beets-1.3.19/setup.py beets-1.4.6/setup.py --- beets-1.3.19/setup.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/setup.py 2017-11-01 22:53:52.000000000 +0000 @@ -56,7 +56,7 @@ setup( name='beets', - version='1.3.19', + version='1.4.6', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', @@ -86,14 +86,15 @@ }, install_requires=[ - 'enum34>=1.0.4', - 'mutagen>=1.27', + 'six>=1.9', + 'mutagen>=1.33', 'munkres', 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', 'jellyfish', - ] + (['colorama'] if (sys.platform == 'win32') else []), + ] + (['colorama'] if (sys.platform == 'win32') else []) + + (['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else []), tests_require=[ 'beautifulsoup4', @@ -105,24 +106,28 @@ 'pyxdg', 'pathlib', 'python-mpd2', + 'discogs-client' ], # Plugin (optional) dependencies: extras_require={ + 'absubmit': ['requests'], 'fetchart': ['requests'], 'chroma': ['pyacoustid'], - 'discogs': ['discogs-client>=2.1.0'], + 'discogs': ['discogs-client>=2.2.1'], 'beatport': ['requests-oauthlib>=0.6.1'], 'lastgenre': ['pylast'], - 'mpdstats': ['python-mpd2'], + 'mpdstats': ['python-mpd2>=0.4.2'], 'web': ['flask', 'flask-cors'], 'import': ['rarfile'], - 'thumbnails': ['pathlib', 'pyxdg'], + 'thumbnails': ['pyxdg'] + + (['pathlib'] if (sys.version_info < (3, 4, 0)) else []), 'metasync': ['dbus-python'], }, # Non-Python/non-PyPI plugin dependencies: # convert: ffmpeg - # bpd: pygst + # bpd: python-gi and GStreamer + # absubmit: extractor binary from http://acousticbrainz.org/download classifiers=[ 'Topic :: Multimedia :: Sound/Audio', @@ -130,7 +135,13 @@ 'License :: OSI Approved :: MIT License', 'Environment :: Console', 'Environment :: Web Environment', + 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: CPython', ], ) diff -Nru beets-1.3.19/test/_common.py beets-1.4.6/test/_common.py --- beets-1.3.19/test/_common.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/_common.py 2017-10-29 20:27:34.000000000 +0000 @@ -21,20 +21,21 @@ import os import tempfile import shutil +import six import unittest from contextlib import contextmanager # Mangle the search path to include the beets sources. -sys.path.insert(0, '..') # noqa -import beets.library -from beets import importer, logging -from beets.ui import commands -from beets import util -import beets +sys.path.insert(0, '..') +import beets.library # noqa: E402 +from beets import importer, logging # noqa: E402 +from beets.ui import commands # noqa: E402 +from beets import util # noqa: E402 +import beets # noqa: E402 # Make sure the development versions of the plugins are used -import beetsplug +import beetsplug # noqa: E402 beetsplug.__path__ = [os.path.abspath( os.path.join(__file__, '..', '..', 'beetsplug') )] @@ -43,7 +44,7 @@ RSRC = util.bytestring_path(os.path.join(os.path.dirname(__file__), 'rsrc')) PLUGINPATH = os.path.join(os.path.dirname(__file__), 'rsrc', 'beetsplug') -# Propagate to root loger so nosetest can capture it +# Propagate to root logger so nosetest can capture it log = logging.getLogger('beets') log.propagate = True log.setLevel(logging.DEBUG) @@ -52,7 +53,8 @@ _item_ident = 0 # OS feature test. -HAVE_SYMLINK = hasattr(os, 'symlink') +HAVE_SYMLINK = sys.platform != 'win32' +HAVE_HARDLINK = sys.platform != 'win32' def item(lib=None): @@ -64,7 +66,9 @@ albumartist=u'the album artist', album=u'the album', genre=u'the genre', + lyricist=u'the lyricist', composer=u'the composer', + arranger=u'the arranger', grouping=u'the grouping', year=1, month=2, @@ -86,6 +90,7 @@ mb_artistid='someID-3', mb_albumartistid='someID-4', album_id=None, + mtime=12345, ) if lib: lib.add(i) @@ -137,10 +142,6 @@ def assert_equal_path(self, a, b): """Check that two paths are equal.""" - # The common case. - if a == b: - return - self.assertEqual(util.normpath(a), util.normpath(b), u'paths are not equal: {!r} and {!r}'.format(a, b)) @@ -161,15 +162,19 @@ # Direct paths to a temporary directory. Tests can also use this # temporary directory. - self.temp_dir = tempfile.mkdtemp() - beets.config['statefile'] = os.path.join(self.temp_dir, 'state.pickle') - beets.config['library'] = os.path.join(self.temp_dir, 'library.db') - beets.config['directory'] = os.path.join(self.temp_dir, 'libdir') + self.temp_dir = util.bytestring_path(tempfile.mkdtemp()) + + beets.config['statefile'] = \ + util.py3_path(os.path.join(self.temp_dir, b'state.pickle')) + beets.config['library'] = \ + util.py3_path(os.path.join(self.temp_dir, b'library.db')) + beets.config['directory'] = \ + util.py3_path(os.path.join(self.temp_dir, b'libdir')) # Set $HOME, which is used by confit's `config_dir()` to create # directories. self._old_home = os.environ.get('HOME') - os.environ['HOME'] = self.temp_dir + os.environ['HOME'] = util.py3_path(self.temp_dir) # Initialize, but don't install, a DummyIO. self.io = DummyIO() @@ -243,7 +248,7 @@ class DummyOut(object): - encoding = 'utf8' + encoding = 'utf-8' def __init__(self): self.buf = [] @@ -252,14 +257,20 @@ self.buf.append(s) def get(self): - return b''.join(self.buf) + if six.PY2: + return b''.join(self.buf) + else: + return ''.join(self.buf) + + def flush(self): + self.clear() def clear(self): self.buf = [] class DummyIn(object): - encoding = 'utf8' + encoding = 'utf-8' def __init__(self, out=None): self.buf = [] @@ -267,7 +278,10 @@ self.out = out def add(self, s): - self.buf.append(s + b'\n') + if six.PY2: + self.buf.append(s + b'\n') + else: + self.buf.append(s + '\n') def readline(self): if not self.buf: @@ -323,6 +337,29 @@ return self.fields.get(key) +# Convenience methods for setting up a temporary sandbox directory for tests +# that need to interact with the filesystem. + +class TempDirMixin(object): + """Text mixin for creating and deleting a temporary directory. + """ + + def create_temp_dir(self): + """Create a temporary directory and assign it into `self.temp_dir`. + Call `remove_temp_dir` later to delete it. + """ + path = tempfile.mkdtemp() + if not isinstance(path, bytes): + path = path.encode('utf8') + self.temp_dir = path + + def remove_temp_dir(self): + """Delete the temporary directory created by `create_temp_dir`. + """ + if os.path.isdir(self.temp_dir): + shutil.rmtree(self.temp_dir) + + # Platform mocking. @contextmanager diff -Nru beets-1.3.19/test/helper.py beets-1.4.6/test/helper.py --- beets-1.3.19/test/helper.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/helper.py 2017-11-25 22:56:53.000000000 +0000 @@ -40,7 +40,7 @@ import subprocess from tempfile import mkdtemp, mkstemp from contextlib import contextmanager -from StringIO import StringIO +from six import StringIO from enum import Enum import beets @@ -51,11 +51,12 @@ from beets import importer from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.mediafile import MediaFile, Image -from beets.ui import _arg_encoding from beets import util +from beets.util import MoveOperation # TODO Move AutotagMock here from test import _common +import six class LogCapture(logging.Handler): @@ -65,7 +66,7 @@ self.messages = [] def emit(self, record): - self.messages.append(unicode(record.msg)) + self.messages.append(six.text_type(record.msg)) @contextmanager @@ -89,7 +90,8 @@ """ org = sys.stdin sys.stdin = StringIO(input) - sys.stdin.encoding = 'utf8' + if six.PY2: # StringIO encoding attr isn't writable in python >= 3 + sys.stdin.encoding = 'utf-8' try: yield sys.stdin finally: @@ -108,7 +110,8 @@ """ org = sys.stdout sys.stdout = capture = StringIO() - sys.stdout.encoding = 'utf8' + if six.PY2: # StringIO encoding attr isn't writable in python >= 3 + sys.stdout.encoding = 'utf-8' try: yield sys.stdout finally: @@ -116,13 +119,25 @@ print(capture.getvalue()) +def _convert_args(args): + """Convert args to bytestrings for Python 2 and convert them to strings + on Python 3. + """ + for i, elem in enumerate(args): + if six.PY2: + if isinstance(elem, six.text_type): + args[i] = elem.encode(util.arg_encoding()) + else: + if isinstance(elem, bytes): + args[i] = elem.decode(util.arg_encoding()) + + return args + + def has_program(cmd, args=['--version']): """Returns `True` if `cmd` can be executed. """ - full_cmd = [cmd] + args - for i, elem in enumerate(full_cmd): - if isinstance(elem, unicode): - full_cmd[i] = elem.encode(_arg_encoding()) + full_cmd = _convert_args([cmd] + args) try: with open(os.devnull, 'wb') as devnull: subprocess.check_call(full_cmd, stderr=devnull, @@ -166,7 +181,7 @@ Make sure you call ``teardown_beets()`` afterwards. """ self.create_temp_dir() - os.environ['BEETSDIR'] = self.temp_dir + os.environ['BEETSDIR'] = util.py3_path(self.temp_dir) self.config = beets.config self.config.clear() @@ -179,10 +194,12 @@ self.libdir = os.path.join(self.temp_dir, b'libdir') os.mkdir(self.libdir) - self.config['directory'] = self.libdir + self.config['directory'] = util.py3_path(self.libdir) if disk: - dbpath = self.config['library'].as_filename() + dbpath = util.bytestring_path( + self.config['library'].as_filename() + ) else: dbpath = ':memory:' self.lib = Library(dbpath, self.libdir) @@ -299,6 +316,8 @@ item = Item(**values_) if 'path' not in values: item['path'] = 'audio.' + item['format'].lower() + # mtime needs to be set last since other assignments reset it. + item.mtime = 12345 return item def add_item(self, **values): @@ -331,7 +350,7 @@ item['path'] = os.path.join(_common.RSRC, util.bytestring_path('min.' + extension)) item.add(self.lib) - item.move(copy=True) + item.move(operation=MoveOperation.COPY) item.store() return item @@ -349,8 +368,10 @@ item = Item.from_path(path) item.album = u'\u00e4lbum {0}'.format(i) # Check unicode paths item.title = u't\u00eftle {0}'.format(i) + # mtime needs to be set last since other assignments reset it. + item.mtime = 12345 item.add(self.lib) - item.move(copy=True) + item.move(operation=MoveOperation.COPY) item.store() items.append(item) return items @@ -361,11 +382,13 @@ items = [] path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) for i in range(track_count): - item = Item.from_path(bytes(path)) + item = Item.from_path(path) item.album = u'\u00e4lbum' # Check unicode paths item.title = u't\u00eftle {0}'.format(i) + # mtime needs to be set last since other assignments reset it. + item.mtime = 12345 item.add(self.lib) - item.move(copy=True) + item.move(operation=MoveOperation.COPY) item.store() items.append(item) return self.lib.add_album(items) @@ -416,17 +439,22 @@ # Running beets commands - def run_command(self, *args): + def run_command(self, *args, **kwargs): + """Run a beets command with an arbitrary amount of arguments. The + Library` defaults to `self.lib`, but can be overridden with + the keyword argument `lib`. + """ + sys.argv = ['beet'] # avoid leakage from test suite args + lib = None if hasattr(self, 'lib'): lib = self.lib - else: - lib = Library(':memory:') - beets.ui._raw_main(list(args), lib) + lib = kwargs.get('lib', lib) + beets.ui._raw_main(_convert_args(list(args)), lib) def run_with_output(self, *args): with capture_stdout() as out: self.run_command(*args) - return out.getvalue().decode('utf-8') + return util.text_string(out.getvalue()) # Safe file operations @@ -434,7 +462,8 @@ """Create a temporary directory and assign it into `self.temp_dir`. Call `remove_temp_dir` later to delete it. """ - self.temp_dir = mkdtemp() + temp_dir = mkdtemp() + self.temp_dir = util.bytestring_path(temp_dir) def remove_temp_dir(self): """Delete the temporary directory created by `create_temp_dir`. @@ -506,7 +535,7 @@ choose_item = choose_match - Resolution = Enum('Resolution', 'REMOVE SKIP KEEPBOTH') + Resolution = Enum('Resolution', 'REMOVE SKIP KEEPBOTH MERGE') default_resolution = 'REMOVE' @@ -524,6 +553,8 @@ task.set_choice(importer.action.SKIP) elif res == self.Resolution.REMOVE: task.should_remove_duplicates = True + elif res == self.Resolution.MERGE: + task.should_merge_duplicates = True def generate_album_info(album_id, track_ids): diff -Nru beets-1.3.19/test/lyrics_download_samples.py beets-1.4.6/test/lyrics_download_samples.py --- beets-1.3.19/test/lyrics_download_samples.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/lyrics_download_samples.py 2016-12-17 03:01:22.000000000 +0000 @@ -52,7 +52,7 @@ if not os.path.isfile(fn): html = requests.get(url, verify=False).text with safe_open_w(fn) as f: - f.write(html.encode('utf8')) + f.write(html.encode('utf-8')) if __name__ == "__main__": sys.exit(main()) diff -Nru beets-1.3.19/test/rsrc/acousticbrainz/data.json beets-1.4.6/test/rsrc/acousticbrainz/data.json --- beets-1.3.19/test/rsrc/acousticbrainz/data.json 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/test/rsrc/acousticbrainz/data.json 2016-12-17 03:01:22.000000000 +0000 @@ -0,0 +1,4115 @@ +{ + "tonal":{ + "thpcp":[ + 1, + 0.638657510281, + 0.293813556433, + 0.259863913059, + 0.21968896687, + 0.218203336, + 0.252398610115, + 0.22969686985, + 0.447383195162, + 0.749422073364, + 0.580664932728, + 0.310822367668, + 0.238883554935, + 0.178785249591, + 0.194924846292, + 0.299323320389, + 0.282649427652, + 0.18946044147, + 0.181915551424, + 0.231100782752, + 0.554247200489, + 0.831909179688, + 0.589426040649, + 0.387799620628, + 0.422363936901, + 0.429372549057, + 0.408978521824, + 0.326897829771, + 0.266663640738, + 0.429461866617, + 0.633336126804, + 0.477401226759, + 0.261826515198, + 0.238164439797, + 0.287726253271, + 0.690547764301 + ], + "chords_number_rate":0.00194468453992, + "chords_scale":"minor", + "chords_changes_rate":0.0445116683841, + "key_strength":0.636936545372, + "tuning_diatonic_strength":0.495492935181, + "hpcp_entropy":{ + "min":0, + "max":4.48086500168, + "dvar2":0.867867648602, + "median":2.02990412712, + "dmean2":1.14721953869, + "dmean":0.68769723177, + "var":0.635742008686, + "dvar":0.33780092001, + "mean":2.00384068489 + }, + "key_scale":"minor", + "chords_strength":{ + "min":0.24240244925, + "max":0.793840110302, + "dvar2":9.58399032243e-05, + "median":0.586153388023, + "dmean2":0.0106231365353, + "dmean":0.00929547380656, + "var":0.00910324696451, + "dvar":6.61800950184e-05, + "mean":0.576524615288 + }, + "key_key":"A", + "tuning_nontempered_energy_ratio":0.721719145775, + "tuning_equal_tempered_deviation":0.0515233427286, + "chords_histogram":[ + 56.2445983887, + 8.10285186768, + 1.79343128204, + 0.0864304229617, + 0, + 0.605012953281, + 2.20397591591, + 12.1650819778, + 0.0216076057404, + 0.0216076057404, + 0, + 0, + 0, + 0, + 0, + 0, + 2.67934322357, + 0.21607606113, + 10.8686256409, + 0, + 2.07433009148, + 0.0864304229617, + 0.648228168488, + 2.1823682785 + ], + "chords_key":"A", + "tuning_frequency":441.272583008, + "hpcp":{ + "min":[ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "max":[ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "dvar2":[ + 0.159377709031, + 0.118723139167, + 0.0969077348709, + 0.0841393470764, + 0.0857475027442, + 0.0681946650147, + 0.0922033339739, + 0.0763805955648, + 0.08953332901, + 0.134413808584, + 0.117157392204, + 0.0784328207374, + 0.0576078519225, + 0.0540019907057, + 0.0537950210273, + 0.0788798704743, + 0.0758254900575, + 0.0659088715911, + 0.0595937520266, + 0.0897909551859, + 0.117696471512, + 0.141075149179, + 0.116812512279, + 0.143778041005, + 0.157332316041, + 0.206293225288, + 0.187901929021, + 0.16186593473, + 0.119209326804, + 0.107413217425, + 0.119033068419, + 0.101279519498, + 0.102868333459, + 0.108767814934, + 0.105039291084, + 0.133028581738 + ], + "median":[ + 0.200053632259, + 0.138681918383, + 0.0352100357413, + 0.0198299698532, + 0.0150980614126, + 0.0205171480775, + 0.026256872341, + 0.0286095552146, + 0.0424337461591, + 0.0602139532566, + 0.0744276791811, + 0.0494470596313, + 0.0350396670401, + 0.0212194472551, + 0.0196186862886, + 0.0201223343611, + 0.0170610919595, + 0.0120322443545, + 0.0105668697506, + 0.0191436801106, + 0.0846247002482, + 0.135070502758, + 0.113241240382, + 0.0424886606634, + 0.0378469713032, + 0.02946896106, + 0.0286043733358, + 0.0198593121022, + 0.0253958441317, + 0.0672319456935, + 0.0957452505827, + 0.0639808028936, + 0.026175301522, + 0.0180807597935, + 0.0324410535395, + 0.125212281942 + ], + "dmean2":[ + 0.353859990835, + 0.294070899487, + 0.217189013958, + 0.180236533284, + 0.162956178188, + 0.144071772695, + 0.172789543867, + 0.171253859997, + 0.22391949594, + 0.284728109837, + 0.274570196867, + 0.198656648397, + 0.154297873378, + 0.124744221568, + 0.124510832131, + 0.157537952065, + 0.158150732517, + 0.135058999062, + 0.123808719218, + 0.177607372403, + 0.277576059103, + 0.324962347746, + 0.298212528229, + 0.308668285608, + 0.311420857906, + 0.344731658697, + 0.335885316133, + 0.283822774887, + 0.227637752891, + 0.252195805311, + 0.288448363543, + 0.249307155609, + 0.212308287621, + 0.205825775862, + 0.223324626684, + 0.315198987722 + ], + "dmean":[ + 0.206140458584, + 0.16949801147, + 0.121237404644, + 0.101308584213, + 0.0920756608248, + 0.0820758640766, + 0.0983714461327, + 0.0957674309611, + 0.130757495761, + 0.167563140392, + 0.159212738276, + 0.113431841135, + 0.0875298455358, + 0.0698468312621, + 0.0709747001529, + 0.092557400465, + 0.0927894487977, + 0.0761438235641, + 0.0697580873966, + 0.0992580577731, + 0.162719354033, + 0.194310605526, + 0.174188151956, + 0.171439617872, + 0.174783751369, + 0.191449582577, + 0.185274213552, + 0.155063658953, + 0.124152831733, + 0.143824338913, + 0.167870178819, + 0.144021183252, + 0.116702638566, + 0.112653404474, + 0.125104308128, + 0.184472203255 + ], + "var":[ + 0.143021538854, + 0.0637424811721, + 0.0320195667446, + 0.037381041795, + 0.0286979582161, + 0.0301742851734, + 0.0303927082568, + 0.0223984327167, + 0.052778493613, + 0.13409627974, + 0.0731517747045, + 0.0302681028843, + 0.0220995694399, + 0.0194978509098, + 0.0208596177399, + 0.0510722063482, + 0.0462048053741, + 0.0236530210823, + 0.0288583170623, + 0.0295663233846, + 0.0644663274288, + 0.119045428932, + 0.0609758161008, + 0.0493184439838, + 0.0607626289129, + 0.070556551218, + 0.0664848089218, + 0.0493576526642, + 0.0335861295462, + 0.0447917319834, + 0.0859667509794, + 0.0526875928044, + 0.0304508917034, + 0.0315008088946, + 0.0328483134508, + 0.0794815197587 + ], + "dvar":[ + 0.0686646401882, + 0.0436623394489, + 0.0358338914812, + 0.0332863405347, + 0.0316647030413, + 0.027725789696, + 0.0338032282889, + 0.0273791830987, + 0.0345646068454, + 0.0615020208061, + 0.0457257218659, + 0.0297911148518, + 0.0221415478736, + 0.0201385207474, + 0.0201426595449, + 0.033235758543, + 0.0318886972964, + 0.0243560094386, + 0.0229729004204, + 0.0327679589391, + 0.0460740588605, + 0.0626438856125, + 0.0435314439237, + 0.0521778166294, + 0.0578555390239, + 0.0772548541427, + 0.0728902295232, + 0.05926451087, + 0.0424739196897, + 0.0389089211822, + 0.0483759790659, + 0.0381603203714, + 0.036982499063, + 0.0398408733308, + 0.0387278683484, + 0.0517609193921 + ], + "mean":[ + 0.366864055395, + 0.234300479293, + 0.107789635658, + 0.0953347310424, + 0.080595985055, + 0.0800509601831, + 0.0925959795713, + 0.084267526865, + 0.164128810167, + 0.274936020374, + 0.213025093079, + 0.114029549062, + 0.0876377895474, + 0.0655898824334, + 0.0715109184384, + 0.109810970724, + 0.103693917394, + 0.0695062279701, + 0.0667382776737, + 0.0847825706005, + 0.203333377838, + 0.305197566748, + 0.216239228845, + 0.142269745469, + 0.154950141907, + 0.157521352172, + 0.15003952384, + 0.119927063584, + 0.0978293046355, + 0.157554119825, + 0.232348263264, + 0.175141349435, + 0.0960547402501, + 0.0873739719391, + 0.105556420982, + 0.253337144852 + ] + } + }, + "rhythm":{ + "bpm_histogram_second_peak_bpm":{ + "min":167, + "max":167, + "dvar2":0, + "median":167, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":167 + }, + "bpm_histogram_second_peak_spread":{ + "min":0, + "max":0, + "dvar2":0, + "median":0, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":0 + }, + "beats_count":577, + "beats_loudness":{ + "min":4.48232695405e-09, + "max":0.181520029902, + "dvar2":0.00434844521806, + "median":0.0302296206355, + "dmean2":0.0709233134985, + "dmean":0.0362482257187, + "var":0.0014705004869, + "dvar":0.00124853104353, + "mean":0.0407853461802 + }, + "bpm":162.532119751, + "bpm_histogram_first_peak_spread":{ + "min":0.164835140109, + "max":0.164835140109, + "dvar2":0, + "median":0.164835140109, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":0.164835140109 + }, + "danceability":1.14192211628, + "bpm_histogram_first_peak_bpm":{ + "min":161, + "max":161, + "dvar2":0, + "median":161, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":161 + }, + "beats_loudness_band_ratio":{ + "min":[ + 0.00683269277215, + 0.00988945644349, + 0.00177479430567, + 0.000523661612533, + 0.000248342636041, + 0.00070228939876 + ], + "max":[ + 0.970164954662, + 0.725397408009, + 0.739950060844, + 0.658194899559, + 0.676319360733, + 0.622089266777 + ], + "dvar2":[ + 0.0699004009366, + 0.0290632098913, + 0.035205449909, + 0.0226975940168, + 0.0168868545443, + 0.0086750369519 + ], + "median":[ + 0.619332075119, + 0.176913484931, + 0.0926762372255, + 0.0303400773555, + 0.0398489944637, + 0.0262520890683 + ], + "dmean2":[ + 0.369491040707, + 0.230186283588, + 0.192849993706, + 0.106605380774, + 0.107576675713, + 0.0616580061615 + ], + "dmean":[ + 0.207789510489, + 0.129003107548, + 0.110624760389, + 0.0578341074288, + 0.0602345280349, + 0.035002540797 + ], + "var":[ + 0.0565228238702, + 0.0165011454374, + 0.0193302389234, + 0.0086074648425, + 0.00634109321982, + 0.00296737346798 + ], + "dvar":[ + 0.0250691790134, + 0.00962078291923, + 0.0126609709114, + 0.00782771967351, + 0.00567667419091, + 0.00305109703913 + ], + "mean":[ + 0.577607989311, + 0.195795580745, + 0.138790622354, + 0.0610357522964, + 0.065284781158, + 0.04240244627 + ] + }, + "onset_rate":5.17941665649, + "beats_position":[ + 0.383129239082, + 0.789478421211, + 1.16099774837, + 1.53251695633, + 1.90403628349, + 2.27555561066, + 2.6470746994, + 3.01859402657, + 3.39011335373, + 3.76163268089, + 4.13315200806, + 4.5046710968, + 4.87619018555, + 5.24770975113, + 5.60761880875, + 5.9675283432, + 6.33904743195, + 6.71056699753, + 7.08208608627, + 7.45360517502, + 7.81351470947, + 8.17342376709, + 8.54494285583, + 8.91646194458, + 9.287981987, + 9.64789104462, + 10.0194101334, + 10.379319191, + 10.7508392334, + 11.110748291, + 11.4822673798, + 11.8421764374, + 12.2136955261, + 12.5852155685, + 12.9567346573, + 13.3166437149, + 13.6765527725, + 14.0364627838, + 14.3963718414, + 14.7678909302, + 15.1394100189, + 15.5109291077, + 15.870839119, + 16.2307472229, + 16.602268219, + 16.9621772766, + 17.3220863342, + 17.693605423, + 18.0535144806, + 18.4134235382, + 18.7733325958, + 19.1332416534, + 19.4931507111, + 19.853061676, + 20.2245807648, + 20.5844898224, + 20.9560089111, + 21.3159179688, + 21.6874370575, + 22.0473461151, + 22.4072551727, + 22.7787742615, + 23.1386852264, + 23.4985942841, + 23.8701133728, + 24.2416324615, + 24.6131515503, + 24.984670639, + 25.3561897278, + 25.7277088165, + 26.0992279053, + 26.4591369629, + 26.830657959, + 27.2021770477, + 27.5736961365, + 27.9452152252, + 28.316734314, + 28.6766433716, + 29.0481624603, + 29.4196815491, + 29.7912006378, + 30.1627197266, + 30.5342407227, + 30.9057598114, + 31.265668869, + 31.6255779266, + 31.9854869843, + 32.3453979492, + 32.6704750061, + 33.0071640015, + 33.3554649353, + 33.7153739929, + 34.0868911743, + 34.4351921082, + 34.8067131042, + 35.1898422241, + 35.5961914062, + 35.9793205261, + 36.362449646, + 36.7339668274, + 37.1054878235, + 37.4886169434, + 37.8601341248, + 38.2316551208, + 38.6147842407, + 38.9863014221, + 39.3578224182, + 39.7293434143, + 40.1124725342, + 40.4723815918, + 40.8438987732, + 41.2154197693, + 41.5869369507, + 41.9584579468, + 42.3299751282, + 42.7014961243, + 43.0730133057, + 43.4445343018, + 43.8160552979, + 44.1875724792, + 44.5590934753, + 44.9306106567, + 45.3021316528, + 45.6736488342, + 46.0335578918, + 46.3934669495, + 46.7533760071, + 47.1132888794, + 47.4848060608, + 47.8563270569, + 48.2394561768, + 48.6225852966, + 48.994102478, + 49.3656234741, + 49.748752594, + 50.1318817139, + 50.5033988953, + 50.8749198914, + 51.2580490112, + 51.6411781311, + 52.0126991272, + 52.3842163086, + 52.7557373047, + 53.1272544861, + 53.4987754822, + 53.8702926636, + 54.2534217834, + 54.6365509033, + 55.0080718994, + 55.3795890808, + 55.7511100769, + 56.122631073, + 56.4941482544, + 56.8656692505, + 57.2487983704, + 57.6203155518, + 58.0034446716, + 58.3749656677, + 58.7464828491, + 59.1180038452, + 59.4895210266, + 59.8610420227, + 60.2325630188, + 60.6040802002, + 60.9756011963, + 61.3471183777, + 61.7186393738, + 62.0901565552, + 62.4616775513, + 62.8331947327, + 63.2163238525, + 63.5994529724, + 63.9709739685, + 64.3424911499, + 64.714012146, + 65.0855331421, + 65.4570541382, + 65.8285675049, + 66.200088501, + 66.5716094971, + 66.9431304932, + 67.3146438599, + 67.686164856, + 68.0344696045, + 68.4175949097, + 68.8007278442, + 69.1722412109, + 69.5321502686, + 69.8920593262, + 70.2635803223, + 70.6351013184, + 70.995010376, + 71.3549194336, + 71.7264404297, + 72.1095657349, + 72.481086731, + 72.8642196655, + 73.2357330322, + 73.6072540283, + 73.9787750244, + 74.3502960205, + 74.7218093872, + 75.1049423218, + 75.488067627, + 75.859588623, + 76.2311096191, + 76.6026306152, + 76.9741516113, + 77.345664978, + 77.7171859741, + 78.1003189087, + 78.4834442139, + 78.85496521, + 79.2264862061, + 79.5979995728, + 79.9695205688, + 80.3410415649, + 80.712562561, + 81.0840835571, + 81.4555969238, + 81.8271179199, + 82.198638916, + 82.5701599121, + 82.9416732788, + 83.3131942749, + 83.684715271, + 84.0678405762, + 84.4509735107, + 84.8224945068, + 85.1824035645, + 85.5423126221, + 85.9138336182, + 86.2853469849, + 86.656867981, + 87.0283889771, + 87.3999099731, + 87.7714233398, + 88.1429443359, + 88.514465332, + 88.8859863281, + 89.2575073242, + 89.6290206909, + 89.9889297485, + 90.3488388062, + 90.7203598022, + 91.0918807983, + 91.451789856, + 91.8116989136, + 92.1832199097, + 92.5547409058, + 92.9262542725, + 93.2861633301, + 93.6460723877, + 94.0175933838, + 94.3891143799, + 94.760635376, + 95.1205444336, + 95.4804534912, + 95.8403625488, + 96.2002716064, + 96.5717926025, + 96.9433059692, + 97.3148269653, + 97.6863479614, + 98.0578689575, + 98.4293823242, + 98.8009033203, + 99.1724243164, + 99.532333374, + 99.9038543701, + 100.263763428, + 100.635284424, + 101.006797791, + 101.378318787, + 101.738227844, + 102.098136902, + 102.469657898, + 102.841178894, + 103.21269989, + 103.584213257, + 103.944122314, + 104.304031372, + 104.675552368, + 105.047073364, + 105.406982422, + 105.766891479, + 106.138412476, + 106.509933472, + 106.881446838, + 107.252967834, + 107.624488831, + 107.984397888, + 108.355918884, + 108.715827942, + 109.087341309, + 109.458862305, + 109.818771362, + 110.17868042, + 110.538589478, + 110.898498535, + 111.270019531, + 111.641540527, + 112.001449585, + 112.361358643, + 112.732879639, + 113.104400635, + 113.464309692, + 113.82421875, + 114.184127808, + 114.544036865, + 114.915550232, + 115.287071228, + 115.658592224, + 116.006889343, + 116.366798401, + 116.726707458, + 117.086616516, + 117.446525574, + 117.794830322, + 118.15473938, + 118.514648438, + 118.886169434, + 119.269294739, + 119.652427673, + 120.035552979, + 120.418685913, + 120.813423157, + 121.2081604, + 121.602897644, + 121.997642517, + 122.380767822, + 122.763900757, + 123.147026062, + 123.530158997, + 123.92489624, + 124.319633484, + 124.69115448, + 125.085891724, + 125.480628967, + 125.863761902, + 126.246894836, + 126.630020142, + 127.013153076, + 127.396278381, + 127.779411316, + 128.17414856, + 128.568893433, + 128.963623047, + 129.346755981, + 129.729888916, + 130.113006592, + 130.496139526, + 130.867660522, + 131.239181519, + 131.610702515, + 131.982223511, + 132.353744507, + 132.725265503, + 133.09677124, + 133.456680298, + 133.816589355, + 134.188110352, + 134.559631348, + 134.931152344, + 135.30267334, + 135.674194336, + 136.045715332, + 136.417236328, + 136.788757324, + 137.160263062, + 137.520172119, + 137.880081177, + 138.251602173, + 138.623123169, + 138.994644165, + 139.366165161, + 139.737686157, + 140.109207153, + 140.480728149, + 140.852249146, + 141.223754883, + 141.595275879, + 141.943572998, + 142.315093994, + 142.675003052, + 143.034912109, + 143.406433105, + 143.777954102, + 144.149475098, + 144.520996094, + 144.89251709, + 145.264038086, + 145.635559082, + 146.007064819, + 146.378585815, + 146.750106812, + 147.121627808, + 147.493148804, + 147.8646698, + 148.236190796, + 148.607711792, + 148.979232788, + 149.362350464, + 149.745483398, + 150.117004395, + 150.488525391, + 150.860046387, + 151.231567383, + 151.603088379, + 151.974594116, + 152.346115112, + 152.717636108, + 153.089157104, + 153.460678101, + 153.832199097, + 154.203720093, + 154.575241089, + 154.946762085, + 155.318267822, + 155.689788818, + 156.061309814, + 156.432830811, + 156.804351807, + 157.175872803, + 157.547393799, + 157.918914795, + 158.290420532, + 158.661941528, + 159.033462524, + 159.404983521, + 159.776504517, + 160.148025513, + 160.519546509, + 160.891067505, + 161.262588501, + 161.634094238, + 162.005615234, + 162.37713623, + 162.737045288, + 163.096954346, + 163.468475342, + 163.839996338, + 164.211517334, + 164.58303833, + 164.954559326, + 165.326080322, + 165.674377441, + 166.045898438, + 166.417419434, + 166.788925171, + 167.160446167, + 167.531967163, + 167.903488159, + 168.275009155, + 168.646530151, + 169.018051147, + 169.389572144, + 169.772689819, + 170.155822754, + 170.52734375, + 170.898864746, + 171.270385742, + 171.641906738, + 172.025024414, + 172.408157349, + 172.779678345, + 173.151199341, + 173.522720337, + 173.894241333, + 174.265762329, + 174.637283325, + 175.032012939, + 175.415145874, + 175.798278809, + 176.169799805, + 176.541305542, + 176.9012146, + 177.272735596, + 177.632644653, + 178.004165649, + 178.375686646, + 178.747207642, + 179.130340576, + 179.513473511, + 179.884979248, + 180.256500244, + 180.62802124, + 180.999542236, + 181.382675171, + 181.754196167, + 182.137313843, + 182.508834839, + 182.880355835, + 183.251876831, + 183.623397827, + 183.994918823, + 184.378051758, + 184.761169434, + 185.13269043, + 185.504211426, + 185.875732422, + 186.247253418, + 186.618774414, + 186.99029541, + 187.361816406, + 187.733337402, + 188.10484314, + 188.476364136, + 188.847885132, + 189.219406128, + 189.590927124, + 189.96244812, + 190.345581055, + 190.72869873, + 191.100219727, + 191.471740723, + 191.843261719, + 192.214782715, + 192.597915649, + 192.981033325, + 193.352554321, + 193.724075317, + 194.095596313, + 194.478729248, + 194.861862183, + 195.23336792, + 195.604888916, + 195.976409912, + 196.347930908, + 196.719451904, + 197.0909729, + 197.474105835, + 197.857223511, + 198.228744507, + 198.600265503, + 198.971786499, + 199.343307495, + 199.714828491, + 200.086349487, + 200.457870483, + 200.829391479, + 201.200897217, + 201.572418213, + 201.943939209, + 202.303848267, + 202.663757324, + 203.03527832, + 203.406799316, + 203.778320312, + 204.149841309, + 204.521362305, + 204.892883301, + 205.264389038, + 205.647521973, + 206.030654907, + 206.402175903, + 206.773696899, + 207.145217896, + 207.516723633, + 207.888244629, + 208.259765625, + 208.631286621, + 209.002807617, + 209.385940552, + 209.757461548, + 210.128982544, + 210.500488281, + 210.883621216, + 211.255142212, + 211.626663208, + 212.009796143, + 212.392913818, + 212.776046753, + 213.170791626, + 213.565536499, + 213.971878052, + 214.378234863 + ], + "bpm_histogram_second_peak_weight":{ + "min":0.163194447756, + "max":0.163194447756, + "dvar2":0, + "median":0.163194447756, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":0.163194447756 + }, + "bpm_histogram_first_peak_weight":{ + "min":0.659722208977, + "max":0.659722208977, + "dvar2":0, + "median":0.659722208977, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":0.659722208977 + } + }, + "lowlevel":{ + "spectral_complexity":{ + "min":0, + "max":51, + "dvar2":34.109500885, + "median":15, + "dmean2":5.03252983093, + "dmean":3.31208133698, + "var":108.135475159, + "dvar":16.1623840332, + "mean":15.1260938644 + }, + "silence_rate_20dB":{ + "min":1, + "max":1, + "dvar2":0, + "median":1, + "dmean2":0, + "dmean":0, + "var":0, + "dvar":0, + "mean":1 + }, + "average_loudness":0.815025985241, + "erbbands_spread":{ + "min":0.907018482685, + "max":163.113571167, + "dvar2":511.106719971, + "median":34.9861183167, + "dmean2":17.4016342163, + "dmean":11.4227361679, + "var":572.351501465, + "dvar":220.769592285, + "mean":39.7502365112 + }, + "spectral_kurtosis":{ + "min":-1.22816824913, + "max":77.2247085571, + "dvar2":28.6044521332, + "median":4.62249898911, + "dmean2":4.51546525955, + "dmean":2.82785224915, + "var":33.1767959595, + "dvar":13.1654214859, + "mean":6.00768327713 + }, + "barkbands_kurtosis":{ + "min":-1.96124887466, + "max":732.31427002, + "dvar2":588.577392578, + "median":2.02856016159, + "dmean2":8.05669307709, + "dmean":5.11299228668, + "var":374.321380615, + "dvar":247.842544556, + "mean":7.06718301773 + }, + "spectral_strongpeak":{ + "min":1.71490974199e-10, + "max":16.1200447083, + "dvar2":3.3518705368, + "median":0.39955753088, + "dmean2":0.941311240196, + "dmean":0.545010268688, + "var":2.96148967743, + "dvar":1.33243703842, + "mean":0.93408870697 + }, + "spectral_spread":{ + "min":1388334.875, + "max":41575076, + "dvar2":1619247235070.0, + "median":3420763.25, + "dmean2":983456.875, + "dmean":732694.4375, + "var":4945877663740.0, + "dvar":854983901184, + "mean":4104934.25 + }, + "spectral_rms":{ + "min":3.15950651059e-12, + "max":0.0132316453382, + "dvar2":3.71212809114e-06, + "median":0.00425734650344, + "dmean2":0.0013317943085, + "dmean":0.000880118692294, + "var":5.96411746301e-06, + "dvar":1.66359927789e-06, + "mean":0.0039903158322 + }, + "erbbands":{ + "min":[ + 1.77816177391e-22, + 1.16505097519e-21, + 1.25622764484e-20, + 2.42817334007e-20, + 1.54258117469e-20, + 1.0398112018e-19, + 1.45132858053e-19, + 2.45289269564e-19, + 4.01068342766e-19, + 8.21050273636e-19, + 3.81530310848e-19, + 8.81511903137e-19, + 1.07499647945e-18, + 8.4783438187e-19, + 1.00474413342e-18, + 2.63416224414e-18, + 2.7020495774e-18, + 3.62243276547e-18, + 3.19863277569e-18, + 3.43176763428e-18, + 6.46998299903e-18, + 5.15079866686e-18, + 8.52004633608e-18, + 1.02257547977e-17, + 1.00997123247e-17, + 1.05472856921e-17, + 1.00685656659e-17, + 1.10003920706e-17, + 1.51110777052e-17, + 1.5046216819e-17, + 1.31580720189e-17, + 1.30844504628e-17, + 1.13508668418e-17, + 1.02681982621e-17, + 8.04728449187e-18, + 4.73097927698e-18, + 2.75997186582e-18, + 1.08864568334e-18, + 2.8601904839e-19, + 3.5584382605e-20 + ], + "max":[ + 2.05614852905, + 10.580906868, + 60.5060195923, + 152.843505859, + 450.042755127, + 175.338409424, + 237.807418823, + 238.884460449, + 443.897521973, + 525.930541992, + 518.611022949, + 1031.67468262, + 954.232910156, + 1123.45935059, + 556.857299805, + 356.125640869, + 342.635467529, + 1293.60327148, + 1103.80981445, + 1287.36242676, + 2169.76928711, + 1266.18579102, + 601.754516602, + 1258.96850586, + 444.097320557, + 240.62600708, + 303.504180908, + 56.5521354675, + 72.2374343872, + 85.4121780396, + 25.407957077, + 19.8146400452, + 46.2547264099, + 16.3226490021, + 16.8903636932, + 5.15064907074, + 0.893250703812, + 0.284754753113, + 0.106889992952, + 0.00452945847064 + ], + "dvar2":[ + 0.0137821119279, + 4.26913785934, + 97.7259292603, + 622.611999512, + 3656.42504883, + 376.053527832, + 307.56942749, + 320.293243408, + 2445.90405273, + 1300.15063477, + 729.621582031, + 2693.6159668, + 2013.25512695, + 3353.13598633, + 573.952941895, + 269.22253418, + 457.528259277, + 1406.78710938, + 1557.82128906, + 793.165527344, + 5019.24316406, + 4039.60839844, + 970.312316895, + 1538.21313477, + 721.875244141, + 195.41003418, + 88.4793777466, + 31.4306335449, + 42.7237434387, + 32.8801956177, + 6.04226541519, + 1.52690029144, + 1.47338938713, + 0.542356073856, + 0.211627364159, + 0.0269440039992, + 0.00465576630086, + 0.000269315525657, + 1.98406451091e-05, + 4.38383267465e-08 + ], + "median":[ + 0.00466703856364, + 0.101253904402, + 1.64267027378, + 5.13034963608, + 6.49109458923, + 4.258228302, + 3.18711066246, + 4.03815889359, + 7.81102657318, + 7.54452610016, + 5.75877952576, + 8.21393203735, + 11.6316785812, + 9.69087696075, + 4.23464298248, + 1.69191777706, + 2.67072510719, + 4.43968248367, + 4.24516201019, + 7.75287914276, + 16.0199928284, + 12.5332527161, + 9.28721427917, + 9.18504428864, + 6.87661838531, + 4.15781497955, + 3.11850094795, + 0.707400023937, + 0.422007799149, + 0.326348185539, + 0.171605303884, + 0.0764799118042, + 0.0530340671539, + 0.0369812250137, + 0.0170799326152, + 0.00388540537097, + 0.000607917027082, + 0.000106923354906, + 2.54503829638e-05, + 2.41674279096e-05 + ], + "dmean2":[ + 0.0468679144979, + 1.00080478191, + 4.60071754456, + 11.530172348, + 27.0302009583, + 10.1726131439, + 8.18716049194, + 9.02602672577, + 21.6493034363, + 17.5054721832, + 11.9851293564, + 23.2072429657, + 23.1678962708, + 24.8249855042, + 9.62737751007, + 5.9968290329, + 8.08820819855, + 12.5483970642, + 11.0777654648, + 12.6770420074, + 31.1724281311, + 22.2209968567, + 15.6441850662, + 17.6994724274, + 11.4936075211, + 6.59135293961, + 5.08022689819, + 2.30506944656, + 2.95904040337, + 2.46585559845, + 1.15297198296, + 0.531184136868, + 0.382049500942, + 0.275081396103, + 0.141834661365, + 0.0498343594372, + 0.0225136969239, + 0.00503297196701, + 0.000977287418209, + 5.82068387303e-05 + ], + "dmean":[ + 0.0350424982607, + 0.580041825771, + 2.61035442352, + 6.80757236481, + 16.318693161, + 6.28967905045, + 5.23286628723, + 5.70099258423, + 13.4230766296, + 10.8768568039, + 7.74926900864, + 15.0034389496, + 15.0271100998, + 16.6018199921, + 6.02632713318, + 3.79855132103, + 5.38237333298, + 8.43670463562, + 7.43314123154, + 8.2191324234, + 20.6921386719, + 13.7538080215, + 9.77100944519, + 11.3139772415, + 7.25735664368, + 4.1218457222, + 3.17433810234, + 1.45581579208, + 1.87883663177, + 1.61556243896, + 0.779599308968, + 0.371349811554, + 0.264476120472, + 0.193502560258, + 0.100708886981, + 0.0353026203811, + 0.0144239943475, + 0.00325388251804, + 0.000670222449116, + 3.95594870497e-05 + ], + "var":[ + 0.0116662262008, + 1.27503490448, + 25.6204109192, + 206.069885254, + 1430.30371094, + 234.442382812, + 206.473526001, + 288.805267334, + 1444.05480957, + 817.272949219, + 625.690795898, + 2697.92749023, + 2397.11669922, + 7130.13330078, + 515.86315918, + 346.633789062, + 809.448486328, + 2584.76660156, + 2616.26757812, + 3138.03320312, + 20583.1132812, + 3381.78515625, + 1288.27880859, + 2477.11401367, + 682.099060059, + 216.604751587, + 97.8326339722, + 13.7823019028, + 19.9818115234, + 16.6690158844, + 4.43811273575, + 1.2346996069, + 1.27807950974, + 0.517578363419, + 0.177246898413, + 0.0190207827836, + 0.00211605615914, + 0.000168058744748, + 1.65621640917e-05, + 3.32350325039e-08 + ], + "dvar":[ + 0.00734147289768, + 1.59334981441, + 34.9658050537, + 236.386993408, + 1519.1640625, + 157.498901367, + 126.366111755, + 127.708183289, + 921.020629883, + 497.140319824, + 303.813903809, + 1122.60107422, + 868.916992188, + 1545.74194336, + 241.721923828, + 118.562904358, + 218.458129883, + 734.227844238, + 762.639587402, + 405.900024414, + 2714.4777832, + 1520.69287109, + 428.032836914, + 696.146972656, + 272.255096436, + 80.3479690552, + 38.2512245178, + 12.4188299179, + 17.8483753204, + 13.7868928909, + 2.77658462524, + 0.731714189053, + 0.67433899641, + 0.263227701187, + 0.108624838293, + 0.0132785160094, + 0.00197272375226, + 0.000125091624795, + 1.03948887045e-05, + 2.18264126772e-08 + ], + "mean":[ + 0.0470404699445, + 0.51214581728, + 2.94917726517, + 8.56711673737, + 16.8939933777, + 9.74743175507, + 8.58794498444, + 10.7102499008, + 23.4056625366, + 18.6381034851, + 13.9866991043, + 27.1086997986, + 28.1797733307, + 34.376159668, + 10.7114057541, + 6.6013917923, + 10.7804918289, + 16.5257949829, + 15.1251926422, + 19.3569641113, + 49.2880210876, + 27.8654499054, + 20.180316925, + 22.9017887115, + 15.1088733673, + 8.45034122467, + 5.79618597031, + 1.91187810898, + 2.04077577591, + 1.82269322872, + 0.964613378048, + 0.475511223078, + 0.359998822212, + 0.270887732506, + 0.138232842088, + 0.0431671403348, + 0.0138024548069, + 0.00314460648224, + 0.000714557303581, + 5.64505244256e-05 + ] + }, + "zerocrossingrate":{ + "min":0.00244140625, + "max":0.525390625, + "dvar2":0.000216632659431, + "median":0.04736328125, + "dmean2":0.0121012274176, + "dmean":0.0100563038141, + "var":0.00131242838688, + "dvar":0.000186675912119, + "mean":0.0536084286869 + }, + "spectral_contrast_coeffs":{ + "min":[ + -0.981265366077, + -0.957678437233, + -0.97364538908, + -0.967074155807, + -0.96257597208, + -0.963937044144 + ], + "max":[ + -0.232828617096, + -0.450794398785, + -0.464783608913, + -0.367899239063, + -0.556100070477, + -0.624147236347 + ], + "dvar2":[ + 0.0063711241819, + 0.00358002260327, + 0.00270585925318, + 0.0017158848932, + 0.00113679806236, + 0.00189194327686 + ], + "median":[ + -0.592801511288, + -0.736532092094, + -0.745844006538, + -0.781389117241, + -0.797656714916, + -0.772757589817 + ], + "dmean2":[ + 0.0916584655643, + 0.074229337275, + 0.0648957416415, + 0.0500396862626, + 0.0380141288042, + 0.0374387130141 + ], + "dmean":[ + 0.0592447705567, + 0.0465519614518, + 0.0414465926588, + 0.0322540737689, + 0.024834824726, + 0.0243127625436 + ], + "var":[ + 0.00911337323487, + 0.00655136583373, + 0.00618056347594, + 0.00577284349129, + 0.00380114861764, + 0.00231571029872 + ], + "dvar":[ + 0.00270068016835, + 0.00148758781143, + 0.00118501938414, + 0.000784370582551, + 0.000524090020917, + 0.000761664705351 + ], + "mean":[ + -0.594863533974, + -0.735448241234, + -0.745020091534, + -0.775078713894, + -0.790849328041, + -0.779701292515 + ] + }, + "dissonance":{ + "min":0.155567497015, + "max":0.500000119209, + "dvar2":0.0011708199745, + "median":0.469446510077, + "dmean2":0.0329371914268, + "dmean":0.0200165640563, + "var":0.00120261416305, + "dvar":0.000480501999846, + "mean":0.460001558065 + }, + "spectral_energyband_high":{ + "min":7.11060368084e-21, + "max":0.00607443926856, + "dvar2":2.99654345781e-07, + "median":0.000152749445988, + "dmean2":0.000292699754937, + "dmean":0.000196773456992, + "var":2.3810963512e-07, + "dvar":1.35860290129e-07, + "mean":0.000318794423947 + }, + "gfcc":{ + "mean":[ + -75.1009368896, + 108.134063721, + -114.922393799, + 35.9068107605, + -53.9456176758, + -7.74362421036, + -37.9716300964, + 9.9239988327, + -24.244802475, + -10.4905223846, + -23.3989257812, + -9.61326217651, + -9.66184806824 + ], + "icov":[ + [ + 8.00217167125e-05, + -0.00014524954895, + 0.000159682429512, + -0.000167002261151, + 9.01917956071e-05, + -0.000181695446372, + 0.000171542298631, + -0.000129780688439, + 0.000197500339709, + -0.000278050283669, + 0.000315256824251, + -0.000206819575396, + 0.000114160277008 + ], + [ + -0.00014524954895, + 0.00178359099664, + -0.000247094896622, + 0.00111800106242, + -0.000636783661321, + 0.000774645654019, + -0.000396145915147, + 0.000103466474684, + -0.00112135277595, + 0.00229536322877, + -0.00234188255854, + 0.00232740258798, + -0.00131408020388 + ], + [ + 0.000159682429512, + -0.000247094896622, + 0.00116660131607, + -0.000234406528762, + 0.000154832057888, + -0.00112980930135, + 0.000650760543067, + -5.60496459912e-07, + 0.000167754784343, + -0.000856044876855, + 0.0014209097717, + -0.00128671491984, + 0.00078388658585 + ], + [ + -0.000167002261151, + 0.00111800106242, + -0.000234406528762, + 0.00312983314507, + -0.000794405816123, + -0.00058439996792, + 2.24104460358e-05, + 0.000424961413955, + -0.00152624503244, + 0.00239069177769, + -0.00202889670618, + 0.00110484787729, + -0.00148455286399 + ], + [ + 9.01917956071e-05, + -0.000636783661321, + 0.000154832057888, + -0.000794405816123, + 0.00397709663957, + -0.000828172138426, + -0.000291677570203, + -0.000975954462774, + 0.00101370830089, + -0.00107421039138, + -0.000894718454219, + 0.00066822959343, + -0.000943991879467 + ], + [ + -0.000181695446372, + 0.000774645654019, + -0.00112980930135, + -0.00058439996792, + -0.000828172138426, + 0.00597975216806, + -0.00177403702401, + -0.000384801533073, + 0.00153620564379, + -0.00195281521883, + 0.00158954621293, + 0.000321330269799, + 0.000257747073192 + ], + [ + 0.000171542298631, + -0.000396145915147, + 0.000650760543067, + 2.24104460358e-05, + -0.000291677570203, + -0.00177403702401, + 0.0080866701901, + -0.00180288043339, + 7.330061635e-05, + 0.00111615261994, + -0.0011511715129, + -0.000984402606264, + 0.000181279159733 + ], + [ + -0.000129780688439, + 0.000103466474684, + -5.60496459912e-07, + 0.000424961413955, + -0.000975954462774, + -0.000384801533073, + -0.00180288043339, + 0.00851836241782, + -0.00320849893615, + 0.00141180318315, + -0.00124999764375, + 0.000833041733131, + -0.00159861764405 + ], + [ + 0.000197500339709, + -0.00112135277595, + 0.000167754784343, + -0.00152624503244, + 0.00101370830089, + 0.00153620564379, + 7.330061635e-05, + -0.00320849893615, + 0.0134673845023, + -0.00816399604082, + 0.00590222747996, + -0.00234723254107, + -0.000930359237827 + ], + [ + -0.000278050283669, + 0.00229536322877, + -0.000856044876855, + 0.00239069177769, + -0.00107421039138, + -0.00195281521883, + 0.00111615261994, + 0.00141180318315, + -0.00816399604082, + 0.0195522867143, + -0.0122134555131, + 0.00327054248191, + 0.000577625120059 + ], + [ + 0.000315256824251, + -0.00234188255854, + 0.0014209097717, + -0.00202889670618, + -0.000894718454219, + 0.00158954621293, + -0.0011511715129, + -0.00124999764375, + 0.00590222747996, + -0.0122134555131, + 0.021111080423, + -0.00721056666225, + 0.00365835893899 + ], + [ + -0.000206819575396, + 0.00232740258798, + -0.00128671491984, + 0.00110484787729, + 0.00066822959343, + 0.000321330269799, + -0.000984402606264, + 0.000833041733131, + -0.00234723254107, + 0.00327054248191, + -0.00721056666225, + 0.015932681039, + -0.00565396249294 + ], + [ + 0.000114160277008, + -0.00131408020388, + 0.00078388658585, + -0.00148455286399, + -0.000943991879467, + 0.000257747073192, + 0.000181279159733, + -0.00159861764405, + -0.000930359237827, + 0.000577625120059, + 0.00365835893899, + -0.00565396249294, + 0.0153731228784 + ] + ], + "cov":[ + [ + 21821.4921875, + 1302.12060547, + -2790.62133789, + 583.293151855, + 63.9840736389, + 26.9333572388, + -119.419998169, + 277.984161377, + -110.454193115, + -76.0569458008, + -16.7917041779, + -162.89515686, + 121.933448792 + ], + [ + 1302.12060547, + 1445.02355957, + -661.351135254, + -311.094665527, + 156.139587402, + -306.730987549, + 60.9609375, + 92.3673324585, + 3.47471880913, + -108.692207336, + 66.6088409424, + -147.776809692, + 75.2410125732 + ], + [ + -2790.62133789, + -661.351135254, + 1889.47399902, + 92.8338088989, + -92.8102874756, + 368.803192139, + -78.5678482056, + -95.1785125732, + 24.1375102997, + 86.254699707, + -94.2749252319, + 117.902046204, + -80.0259933472 + ], + [ + 583.293151855, + -311.094665527, + 92.8338088989, + 515.187255859, + 60.5850448608, + 128.642654419, + -2.62003684044, + 6.46807575226, + 13.7108755112, + -3.07335114479, + -7.74705600739, + 28.8581352234, + 29.7626171112 + ], + [ + 63.9840736389, + 156.139587402, + -92.8102874756, + 60.5850448608, + 339.564758301, + 28.9691524506, + 45.5025405884, + 54.3213920593, + -9.63641166687, + 12.0036411285, + 43.2324829102, + -22.4280166626, + 29.361246109 + ], + [ + 26.9333572388, + -306.730987549, + 368.803192139, + 128.642654419, + 28.9691524506, + 324.149993896, + 20.1203899384, + -3.79410982132, + -15.0688257217, + 43.0340042114, + -26.3511829376, + 28.6324996948, + -22.8170604706 + ], + [ + -119.419998169, + 60.9609375, + -78.5678482056, + -2.62003684044, + 45.5025405884, + 20.1203899384, + 155.001724243, + 42.1054992676, + -1.47882866859, + -8.76181983948, + 19.2513332367, + 2.83816099167, + 11.5606393814 + ], + [ + 277.984161377, + 92.3673324585, + -95.1785125732, + 6.46807575226, + 54.3213920593, + -3.79410982132, + 42.1054992676, + 155.586120605, + 33.8702697754, + -2.8296790123, + 9.17068958282, + -6.04741716385, + 28.1404056549 + ], + [ + -110.454193115, + 3.47471880913, + 24.1375102997, + 13.7108755112, + -9.63641166687, + -15.0688257217, + -1.47882866859, + 33.8702697754, + 114.661575317, + 34.3392486572, + -7.06085252762, + 10.0253458023, + 15.4272651672 + ], + [ + -76.0569458008, + -108.692207336, + 86.254699707, + -3.07335114479, + 12.0036411285, + 43.0340042114, + -8.76181983948, + -2.8296790123, + 34.3392486572, + 112.38079071, + 43.6220817566, + 14.6831378937, + -20.7214431763 + ], + [ + -16.7917041779, + 66.6088409424, + -94.2749252319, + -7.74705600739, + 43.2324829102, + -26.3511829376, + 19.2513332367, + 9.17068958282, + -7.06085252762, + 43.6220817566, + 99.527053833, + 15.1529960632, + -6.4773888588 + ], + [ + -162.89515686, + -147.776809692, + 117.902046204, + 28.8581352234, + -22.4280166626, + 28.6324996948, + 2.83816099167, + -6.04741716385, + 10.0253458023, + 14.6831378937, + 15.1529960632, + 101.876480103, + 16.7505264282 + ], + [ + 121.933448792, + 75.2410125732, + -80.0259933472, + 29.7626171112, + 29.361246109, + -22.8170604706, + 11.5606393814, + 28.1404056549, + 15.4272651672, + -20.7214431763, + -6.4773888588, + 16.7505264282, + 91.9189758301 + ] + ] + }, + "spectral_flux":{ + "min":6.40516528705e-11, + "max":0.355690479279, + "dvar2":0.00278266565874, + "median":0.0625253766775, + "dmean2":0.0423415489495, + "dmean":0.0282820314169, + "var":0.00376007612795, + "dvar":0.00135926820803, + "mean":0.0749769806862 + }, + "silence_rate_30dB":{ + "min":0, + "max":1, + "dvar2":0.0354892387986, + "median":1, + "dmean2":0.0246406570077, + "dmean":0.0123189976439, + "var":0.00654759025201, + "dvar":0.0121672395617, + "mean":0.993408977985 + }, + "spectral_energyband_middle_high":{ + "min":1.27805372214e-21, + "max":0.0389622859657, + "dvar2":9.42645056057e-06, + "median":0.00293085677549, + "dmean2":0.00200005969964, + "dmean":0.00133119279053, + "var":2.5883437047e-05, + "dvar":4.14980877395e-06, + "mean":0.00445337453857 + }, + "barkbands_spread":{ + "min":0.167884364724, + "max":139.821090698, + "dvar2":253.175750732, + "median":18.0125980377, + "dmean2":10.9470348358, + "dmean":6.75650119781, + "var":180.234161377, + "dvar":101.147232056, + "mean":20.320306778 + }, + "spectral_centroid":{ + "min":111.030769348, + "max":11065.2148438, + "dvar2":701335.5625, + "median":1143.22497559, + "dmean2":567.022827148, + "dmean":354.07824707, + "var":626410.0625, + "dvar":292997.4375, + "mean":1266.39624023 + }, + "pitch_salience":{ + "min":0.103756688535, + "max":0.928133249283, + "dvar2":0.013650230132, + "median":0.569122076035, + "dmean2":0.124187774956, + "dmean":0.0767409279943, + "var":0.0128469280899, + "dvar":0.00529233785346, + "mean":0.566493272781 + }, + "erbbands_skewness":{ + "min":-8.09688186646, + "max":6.17588758469, + "dvar2":1.00871086121, + "median":0.283587485552, + "dmean2":0.786845207214, + "dmean":0.512230575085, + "var":1.42545306683, + "dvar":0.427418738604, + "mean":0.235957682133 + }, + "erbbands_crest":{ + "min":2.31988024712, + "max":34.2482185364, + "dvar2":16.2832355499, + "median":8.70411396027, + "dmean2":4.28936433792, + "dmean":2.70263242722, + "var":24.3617858887, + "dvar":6.85065603256, + "mean":9.92293930054 + }, + "melbands":{ + "min":[ + 1.7312522864e-24, + 4.69779527492e-24, + 4.46513652814e-24, + 3.32025441336e-24, + 6.6967940523e-24, + 5.54350662457e-24, + 5.62380004294e-24, + 7.24197266845e-24, + 4.37425935743e-24, + 6.31036856572e-24, + 4.53679113075e-24, + 3.08454218323e-24, + 4.01757944901e-24, + 5.05271286808e-24, + 5.25895581858e-24, + 3.18568913964e-24, + 7.13208118891e-24, + 4.5636159514e-24, + 5.04583400098e-24, + 6.17228950832e-24, + 6.96855978959e-24, + 4.44096109684e-24, + 6.9472211021e-24, + 7.30462636813e-24, + 7.82425851273e-24, + 7.32260214157e-24, + 5.87433201125e-24, + 6.79884662102e-24, + 6.23345462746e-24, + 6.36714722381e-24, + 5.56132344254e-24, + 7.90887173342e-24, + 7.08392990812e-24, + 8.85545433259e-24, + 6.01856418372e-24, + 6.31963413149e-24, + 6.85279050744e-24, + 7.75657976909e-24, + 8.50479856047e-24, + 7.94873918585e-24 + ], + "max":[ + 0.0150078302249, + 0.0248268786818, + 0.0372853949666, + 0.0202775932848, + 0.00924268271774, + 0.00459495186806, + 0.00642714649439, + 0.00673051225021, + 0.00483322422951, + 0.0058145863004, + 0.00487699825317, + 0.00474451202899, + 0.00216139364056, + 0.00089383253362, + 0.000890302297194, + 0.00114067236427, + 0.00265396363102, + 0.00179244857281, + 0.00171541213058, + 0.00327429641038, + 0.00261263479479, + 0.00140622933395, + 0.000604341796134, + 0.000546872266568, + 0.00119282689411, + 0.000385722087231, + 0.000307112553855, + 0.000185638607945, + 0.000196822540602, + 7.07383733243e-05, + 3.59257646778e-05, + 4.41324045823e-05, + 5.90648887737e-05, + 5.03649462189e-05, + 1.98958878173e-05, + 1.04367582026e-05, + 1.49458364831e-05, + 3.54613039235e-05, + 2.11343995034e-05, + 1.33671064759e-05 + ], + "dvar2":[ + 1.97027497961e-06, + 1.79839098564e-05, + 3.41721388395e-05, + 6.39068775854e-06, + 4.12703258235e-07, + 1.60991760367e-07, + 5.84672534387e-07, + 1.35801229817e-07, + 5.67447884237e-08, + 9.04100758703e-08, + 5.12682660769e-08, + 6.00754361813e-08, + 8.33282598478e-09, + 2.19900075926e-09, + 2.22529794591e-09, + 2.68628164157e-09, + 6.38194253e-09, + 4.48827552901e-09, + 1.61791646747e-09, + 5.6524527281e-09, + 7.00124225261e-09, + 5.1307971205e-09, + 1.8114484357e-09, + 6.22804863237e-10, + 1.41800715614e-09, + 5.03870334345e-10, + 3.42071621029e-10, + 8.80266623482e-11, + 5.10801435871e-11, + 1.6358601973e-11, + 1.09966497713e-11, + 1.34551406475e-11, + 2.12651927317e-11, + 8.70376774126e-12, + 2.70498077062e-12, + 8.23377390574e-13, + 6.18267915163e-13, + 8.94622212682e-13, + 5.74140724113e-13, + 4.04918747187e-13 + ], + "median":[ + 6.78846045048e-05, + 0.000686653598677, + 0.00100371893495, + 0.000425793463364, + 0.000121567500173, + 8.67325506988e-05, + 0.000119830452604, + 7.64572032494e-05, + 4.73126528959e-05, + 4.95156273246e-05, + 5.10508471052e-05, + 3.72932154278e-05, + 1.39631702041e-05, + 4.18296076532e-06, + 3.36458288075e-06, + 5.95153596805e-06, + 7.06666241967e-06, + 5.36886500413e-06, + 6.55584017295e-06, + 1.27752928165e-05, + 1.80421066034e-05, + 1.10708388092e-05, + 9.13756139198e-06, + 6.40786538497e-06, + 6.87108331476e-06, + 5.39385519005e-06, + 3.31015212396e-06, + 2.2452804842e-06, + 2.11795463656e-06, + 8.45357135404e-07, + 1.55302529947e-07, + 2.16630468231e-07, + 2.0936421663e-07, + 1.59076819273e-07, + 1.03014848207e-07, + 5.60769883862e-08, + 4.16746956944e-08, + 3.91173387015e-08, + 3.08294119122e-08, + 2.99958919925e-08 + ], + "dmean2":[ + 0.000655197829474, + 0.00202016695403, + 0.00265697692521, + 0.00127012608573, + 0.000302697066218, + 0.000204388590646, + 0.000336306344252, + 0.000177521447768, + 0.000107648374978, + 0.000137986353366, + 0.000116639275802, + 0.000105825478386, + 3.63973231288e-05, + 1.78256286745e-05, + 1.63039130712e-05, + 1.98472516786e-05, + 2.55192408076e-05, + 1.86820070667e-05, + 1.45677859109e-05, + 2.93159864668e-05, + 3.81224999728e-05, + 2.55198028754e-05, + 1.86311717698e-05, + 1.29478112285e-05, + 1.63034928846e-05, + 1.03988468254e-05, + 6.90801107339e-06, + 4.38494134869e-06, + 3.91557023249e-06, + 1.95062216335e-06, + 1.26417035062e-06, + 1.61554555689e-06, + 1.957419272e-06, + 1.21927041619e-06, + 7.50478704958e-07, + 4.08925643569e-07, + 3.15493565495e-07, + 2.87596407134e-07, + 2.50877462804e-07, + 2.38469112901e-07 + ], + "dmean":[ + 0.000433199049439, + 0.00115317711607, + 0.00158822687808, + 0.000778516754508, + 0.000194163410924, + 0.000129944470245, + 0.00020776965539, + 0.000110655011667, + 7.02645556885e-05, + 8.88201902853e-05, + 7.56665394874e-05, + 7.11579850758e-05, + 2.26343472605e-05, + 1.1214566257e-05, + 1.06519555629e-05, + 1.3082231817e-05, + 1.72533091245e-05, + 1.2507844076e-05, + 9.44854855334e-06, + 1.93181331269e-05, + 2.54146216321e-05, + 1.58707625815e-05, + 1.14972417578e-05, + 8.10282745078e-06, + 1.0418824786e-05, + 6.57239706925e-06, + 4.29072770203e-06, + 2.71895851256e-06, + 2.42956934926e-06, + 1.20713775686e-06, + 7.98163171112e-07, + 1.02098283605e-06, + 1.22477297282e-06, + 8.0222929455e-07, + 4.99395412135e-07, + 2.76074587191e-07, + 2.18049038381e-07, + 1.94722304059e-07, + 1.73989079144e-07, + 1.64999249819e-07 + ], + "var":[ + 1.04273829038e-06, + 4.88182786285e-06, + 1.22067667689e-05, + 3.09852111968e-06, + 2.95254579896e-07, + 1.60118688086e-07, + 3.36397903311e-07, + 9.02441499306e-08, + 5.12204714198e-08, + 8.87765096991e-08, + 6.13447141973e-08, + 1.30928043518e-07, + 7.16093628839e-09, + 2.90166846106e-09, + 3.30743610277e-09, + 3.75769282357e-09, + 1.11862910046e-08, + 7.38158290048e-09, + 3.41670158832e-09, + 2.24571348184e-08, + 3.01072198283e-08, + 4.89972462603e-09, + 1.57648072374e-09, + 7.79673825502e-10, + 2.14637396745e-09, + 5.14293607701e-10, + 2.73522648975e-10, + 8.45902653479e-11, + 5.53298171169e-11, + 1.09233455614e-11, + 4.45680905375e-12, + 6.09628753728e-12, + 8.53084356628e-12, + 4.52617804347e-12, + 1.82034248786e-12, + 6.15677430912e-13, + 4.4449592119e-13, + 6.71954570979e-13, + 5.48820374997e-13, + 3.48484424911e-13 + ], + "dvar":[ + 8.33708043046e-07, + 6.49838420941e-06, + 1.36113221743e-05, + 2.76258151644e-06, + 1.75387782519e-07, + 6.53309797372e-08, + 2.18858644985e-07, + 5.17116269805e-08, + 2.40723956324e-08, + 3.69722101823e-08, + 2.22425278196e-08, + 2.84932291095e-08, + 3.64641872252e-09, + 9.5759922214e-10, + 9.86244308443e-10, + 1.3016818734e-09, + 3.31316440949e-09, + 2.19745110996e-09, + 7.73902275597e-10, + 3.32642535739e-09, + 3.88597554135e-09, + 1.99106442444e-09, + 7.00074609394e-10, + 2.79730322239e-10, + 6.32938867984e-10, + 2.10190226335e-10, + 1.22236526456e-10, + 3.66416376407e-11, + 2.22450686344e-11, + 6.52528248449e-12, + 4.34595848892e-12, + 5.58480735616e-12, + 8.46765019213e-12, + 3.64534357561e-12, + 1.20417478072e-12, + 3.81005014864e-13, + 2.8428255301e-13, + 3.87186458025e-13, + 2.78560404994e-13, + 1.87431545436e-13 + ], + "mean":[ + 0.00047609579633, + 0.00126094208099, + 0.00185862951912, + 0.000971358967945, + 0.000324478023686, + 0.000246531388257, + 0.000361267186236, + 0.000190427192138, + 0.000126440427266, + 0.000159403600264, + 0.000136641858262, + 0.000143978060805, + 3.71072310372e-05, + 1.87067053048e-05, + 1.93349878828e-05, + 2.34964645642e-05, + 3.1896517612e-05, + 2.36660616793e-05, + 1.83704396477e-05, + 4.34660541941e-05, + 5.81043132115e-05, + 2.89009076369e-05, + 2.15983145608e-05, + 1.50616651808e-05, + 1.95628890651e-05, + 1.24749267343e-05, + 8.04845058155e-06, + 4.90500542583e-06, + 4.20188916905e-06, + 1.82525320724e-06, + 8.70177814249e-07, + 1.09259167402e-06, + 1.25471365209e-06, + 9.23780646644e-07, + 6.02008753958e-07, + 3.45971272964e-07, + 2.69976595746e-07, + 2.55056221476e-07, + 2.36791535713e-07, + 2.26877631349e-07 + ] + }, + "spectral_entropy":{ + "min":4.61501169205, + "max":9.81467628479, + "dvar2":0.184129029512, + "median":7.38190746307, + "dmean2":0.352754920721, + "dmean":0.243219792843, + "var":0.300432950258, + "dvar":0.0902535244823, + "mean":7.3010840416 + }, + "spectral_rolloff":{ + "min":64.599609375, + "max":21037.9394531, + "dvar2":3396222, + "median":861.328125, + "dmean2":1057.10974121, + "dmean":604.693481445, + "var":2179523.25, + "dvar":1425528, + "mean":1383.6763916 + }, + "barkbands":{ + "min":[ + 6.93473619499e-25, + 5.19568601854e-24, + 1.02416202049e-23, + 4.28463410897e-24, + 3.04471689542e-23, + 2.72335547301e-23, + 3.08580014027e-23, + 1.15517745957e-23, + 5.01556722243e-23, + 3.01743335215e-23, + 3.20834273992e-23, + 6.39242832255e-23, + 4.96037188707e-23, + 6.11980114915e-23, + 7.73605723709e-23, + 1.27850093686e-22, + 8.33913319498e-23, + 1.55698133196e-22, + 1.80609161514e-22, + 2.27535985033e-22, + 3.03975723224e-22, + 3.99622470031e-22, + 4.5185483121e-22, + 7.15649816942e-22, + 1.01532623561e-21, + 1.53862207745e-21, + 2.31706194078e-21 + ], + "max":[ + 0.00229191919789, + 0.047907166183, + 0.0691591277719, + 0.0995827168226, + 0.0820566862822, + 0.0232072826475, + 0.0308443717659, + 0.0302771702409, + 0.0344947054982, + 0.0269099473953, + 0.0134304724634, + 0.00715320091695, + 0.00838674418628, + 0.0218261200935, + 0.019664183259, + 0.0362881943583, + 0.0182446800172, + 0.0173479039222, + 0.0146563379094, + 0.00540218688548, + 0.00442544184625, + 0.00217473763041, + 0.0013186649885, + 0.00208773952909, + 0.00195873784833, + 0.000584891589824, + 0.00050722778542 + ], + "dvar2":[ + 2.09710808718e-08, + 5.00840687891e-05, + 9.50076937443e-05, + 0.000209878431633, + 8.73877215781e-05, + 3.97830672227e-06, + 1.24859725474e-05, + 1.86764350474e-06, + 3.62891637451e-06, + 3.01378167933e-06, + 5.96735674208e-07, + 1.57074438789e-07, + 2.46402947823e-07, + 4.87388831516e-07, + 4.96838595154e-07, + 1.52867119141e-06, + 1.2727361991e-06, + 3.34951494096e-07, + 4.58745859078e-07, + 1.02385989464e-07, + 2.90899002664e-08, + 3.47254207611e-08, + 1.03255741735e-08, + 3.98843491567e-09, + 4.08773503935e-09, + 1.11437570283e-09, + 4.70612881998e-10 + ], + "median":[ + 1.01329169411e-06, + 0.000326245411998, + 0.00187892094254, + 0.00155476410873, + 0.0016892075073, + 0.000451811589301, + 0.000548189680558, + 0.000192572828382, + 0.000307663634885, + 0.000414336362155, + 9.44759449339e-05, + 2.73611021839e-05, + 5.38471249456e-05, + 6.47374472464e-05, + 8.72917589732e-05, + 0.000232024758589, + 0.000200124268304, + 0.000144036966958, + 0.000164192693774, + 8.08068361948e-05, + 4.40170915681e-05, + 1.11471890705e-05, + 6.5616086431e-06, + 3.2568784718e-06, + 2.92447043648e-06, + 4.78593108255e-07, + 7.76246977807e-08 + ], + "dmean2":[ + 5.63530775253e-05, + 0.00340586039238, + 0.00441808719188, + 0.00632638018578, + 0.0048057041131, + 0.00103038607631, + 0.00156902591698, + 0.000596007099375, + 0.000905426510144, + 0.000906587869395, + 0.000307931040879, + 0.000146102538565, + 0.000193360538105, + 0.000240257213591, + 0.000224526840611, + 0.00052686111303, + 0.000436997273937, + 0.000281702930806, + 0.000314143690048, + 0.000150212654262, + 8.88355425559e-05, + 8.39097774588e-05, + 4.62743046228e-05, + 2.28419812629e-05, + 2.21090576815e-05, + 1.11555727926e-05, + 5.03497130921e-06 + ], + "dmean":[ + 4.36054288002e-05, + 0.00207182555459, + 0.00250360206701, + 0.00372918182984, + 0.0029777479358, + 0.00065627245931, + 0.000966914638411, + 0.000378262484446, + 0.000581912638154, + 0.000613346113823, + 0.000192098785192, + 9.08574875211e-05, + 0.000126861719764, + 0.000160341092851, + 0.000145415237057, + 0.00034682394471, + 0.000273586803814, + 0.000176411645953, + 0.000198787456611, + 9.40177123994e-05, + 5.56063205295e-05, + 5.36341467523e-05, + 3.11664407491e-05, + 1.60014496942e-05, + 1.57560625667e-05, + 7.42050679037e-06, + 3.44123100149e-06 + ], + "var":[ + 1.73105885182e-08, + 1.84976615856e-05, + 2.50716802839e-05, + 7.04820486135e-05, + 4.76136665384e-05, + 3.67056259165e-06, + 6.74153125146e-06, + 1.57238355314e-06, + 3.58573174708e-06, + 5.54312009626e-06, + 5.51931407244e-07, + 1.78970466891e-07, + 4.17550438669e-07, + 9.57703605309e-07, + 8.59389047037e-07, + 6.6176985456e-06, + 1.22271069358e-06, + 4.2584204607e-07, + 5.58315321086e-07, + 9.85875061588e-08, + 2.47446703128e-08, + 1.64356386279e-08, + 7.04793423623e-09, + 3.46884565516e-09, + 3.82823417411e-09, + 6.34027108593e-10, + 3.8906944333e-10 + ], + "dvar":[ + 1.15370024645e-08, + 1.92330826394e-05, + 3.36813718604e-05, + 8.24065355118e-05, + 3.870560613e-05, + 1.55360748977e-06, + 4.66681467515e-06, + 7.47769490772e-07, + 1.50442451741e-06, + 1.46062268414e-06, + 2.81505151634e-07, + 6.65834889446e-08, + 1.17960382795e-07, + 2.50351632758e-07, + 2.41593681949e-07, + 8.20324487449e-07, + 5.11046778229e-07, + 1.56924727435e-07, + 1.97066171381e-07, + 4.1844121057e-08, + 1.20861924913e-08, + 1.45208129965e-08, + 4.66174743252e-09, + 1.88047488692e-09, + 2.07477035552e-09, + 5.05100294923e-10, + 2.44846420916e-10 + ], + "mean":[ + 5.19759996678e-05, + 0.00199001817964, + 0.00309076649137, + 0.00392009504139, + 0.0039071268402, + 0.00123883492779, + 0.00161516538355, + 0.000634168100078, + 0.00102682900615, + 0.00122780457605, + 0.000289549614536, + 0.000142228382174, + 0.000236654945184, + 0.000305703491904, + 0.000276061356999, + 0.000822361966129, + 0.000508801313117, + 0.000334064679919, + 0.000397378782509, + 0.00017829038552, + 8.88610738912e-05, + 5.81649801461e-05, + 3.79984667234e-05, + 2.1309551812e-05, + 2.22307517106e-05, + 7.82890947448e-06, + 3.57463068212e-06 + ] + }, + "melbands_flatness_db":{ + "min":0.00437760027125, + "max":0.607957184315, + "dvar2":0.00331180845387, + "median":0.219427987933, + "dmean2":0.0528259426355, + "dmean":0.034728333354, + "var":0.00492821913213, + "dvar":0.00150177872274, + "mean":0.230123117566 + }, + "melbands_skewness":{ + "min":-2.44428515434, + "max":15.3962888718, + "dvar2":2.92571592331, + "median":2.28337860107, + "dmean2":1.4999755621, + "dmean":0.987386882305, + "var":4.25560426712, + "dvar":1.34734094143, + "mean":2.84234952927 + }, + "barkbands_skewness":{ + "min":-6.20005989075, + "max":18.9707603455, + "dvar2":2.02687716484, + "median":1.45346200466, + "dmean2":1.15091514587, + "dmean":0.750691831112, + "var":2.42782378197, + "dvar":0.866045475006, + "mean":1.71697402 + }, + "silence_rate_60dB":{ + "min":0, + "max":1, + "dvar2":0.0371209047735, + "median":0, + "dmean2":0.0337187945843, + "dmean":0.0168575756252, + "var":0.179377868772, + "dvar":0.0165733974427, + "mean":0.234251752496 + }, + "spectral_energyband_low":{ + "min":4.89638611687e-23, + "max":0.116083092988, + "dvar2":0.000277574028587, + "median":0.00443472480401, + "dmean2":0.00833203457296, + "dmean":0.00496239587665, + "var":9.57977899816e-05, + "dvar":0.000101656405604, + "mean":0.00667642848566 + }, + "spectral_energyband_middle_low":{ + "min":2.98093395713e-22, + "max":0.171243280172, + "dvar2":0.000569899275433, + "median":0.0082081919536, + "dmean2":0.0117948763072, + "dmean":0.00737277884036, + "var":0.000299356790492, + "dvar":0.000242799738771, + "mean":0.0127554573119 + }, + "melbands_kurtosis":{ + "min":-1.94107413292, + "max":380.680023193, + "dvar2":1038.95019531, + "median":7.13754844666, + "dmean2":17.6919975281, + "dmean":11.5336751938, + "var":1275.55053711, + "dvar":493.653686523, + "mean":19.2873706818 + }, + "spectral_decrease":{ + "min":-4.64023344193e-08, + "max":6.78438804261e-19, + "dvar2":6.20802821665e-17, + "median":-4.53350113006e-09, + "dmean2":4.26796598063e-09, + "dmean":2.69736544212e-09, + "var":3.69683427013e-17, + "dvar":2.61311086979e-17, + "mean":-5.59147705914e-09 + }, + "erbbands_kurtosis":{ + "min":-1.86338639259, + "max":171.201263428, + "dvar2":23.2438850403, + "median":-0.320037484169, + "dmean2":2.39585089684, + "dmean":1.52739417553, + "var":35.0008544922, + "dvar":11.0155954361, + "mean":1.24987971783 + }, + "melbands_crest":{ + "min":1.85922825336, + "max":32.9627304077, + "dvar2":27.1548709869, + "median":12.5910797119, + "dmean2":5.60343551636, + "dmean":3.39730739594, + "var":20.3299713135, + "dvar":10.5175638199, + "mean":13.3324451447 + }, + "melbands_spread":{ + "min":0.26147004962, + "max":299.135498047, + "dvar2":1027.73010254, + "median":17.4329528809, + "dmean2":16.3484096527, + "dmean":10.0459423065, + "var":529.263366699, + "dvar":403.605499268, + "mean":23.2355747223 + }, + "spectral_energy":{ + "min":1.02320440393e-20, + "max":0.179453358054, + "dvar2":0.000923860818148, + "median":0.0185781233013, + "dmean2":0.0165870357305, + "dmean":0.0105188144371, + "var":0.000572183460463, + "dvar":0.00039060486597, + "mean":0.0224339049309 + }, + "mfcc":{ + "mean":[ + -715.191650391, + 133.878646851, + -2.74888682365, + 38.7127075195, + 3.85699295998, + -10.4443674088, + -5.89105558395, + 4.36424779892, + 2.37372231483, + 1.66640949249, + -7.12301874161, + -9.74494552612, + -3.86338329315 + ], + "icov":[ + [ + 7.98077235231e-05, + -5.86888636462e-05, + 0.000145378129673, + -9.86643281067e-05, + 3.03972447e-05, + -0.000187377940165, + 0.000134168396471, + -0.000129314183141, + 1.27186794998e-05, + -7.31398395146e-05, + 1.38320649512e-06, + 0.000111114335596, + -8.96842757356e-05 + ], + [ + -5.86888636462e-05, + 0.000914695789106, + -1.55892048497e-05, + 0.000330161477905, + -0.000239893066464, + 0.00115360564087, + -0.000499361369293, + 0.000324924068991, + -1.15215043479e-05, + -0.000243278453127, + 0.000191972227185, + -9.64893956734e-07, + 0.000270225456916 + ], + [ + 0.000145378129673, + -1.55892048497e-05, + 0.0013757571578, + 7.94086445239e-05, + -0.000468786456622, + -0.00135524733923, + 0.00098228675779, + -0.000316469959216, + -0.000591932330281, + 0.000769039965235, + -0.000745072495192, + 0.000638137804344, + 0.000274067162536 + ], + [ + -9.86643281067e-05, + 0.000330161477905, + 7.94086445239e-05, + 0.00385635741986, + -0.00140862353146, + 0.000500513473526, + 0.000532710750122, + -0.00153480307199, + 0.000852926750667, + -0.000837227795273, + 0.00151946756523, + -0.00070260866778, + 0.000143392870086 + ], + [ + 3.03972447e-05, + -0.000239893066464, + -0.000468786456622, + -0.00140862353146, + 0.0056857005693, + -0.00172490451951, + -0.00111165409908, + 0.000201825780096, + 0.000247466086876, + -0.00217686733231, + 0.00112909392919, + -0.000958321907092, + -0.000166401950992 + ], + [ + -0.000187377940165, + 0.00115360564087, + -0.00135524733923, + 0.000500513473526, + -0.00172490451951, + 0.00837340857834, + -0.00406587868929, + 0.00216831197031, + -0.0023798532784, + 0.00346444058232, + -0.00286303297617, + 0.00123248388991, + -0.000868651026394 + ], + [ + 0.000134168396471, + -0.000499361369293, + 0.00098228675779, + 0.000532710750122, + -0.00111165409908, + -0.00406587868929, + 0.00986782740802, + -0.00542975915596, + 0.00409825751558, + -0.0044681020081, + 0.00282385596074, + -0.00354772782885, + 0.00168551225215 + ], + [ + -0.000129314183141, + 0.000324924068991, + -0.000316469959216, + -0.00153480307199, + 0.000201825780096, + 0.00216831197031, + -0.00542975915596, + 0.0144132943824, + -0.00864001736045, + 0.00550767453387, + -0.00397388311103, + 0.00314126117155, + -0.00206855195574 + ], + [ + 1.27186794998e-05, + -1.15215043479e-05, + -0.000591932330281, + 0.000852926750667, + 0.000247466086876, + -0.0023798532784, + 0.00409825751558, + -0.00864001736045, + 0.0176015142351, + -0.0112102692947, + 0.0083336783573, + -0.00373222492635, + 0.00208288733847 + ], + [ + -7.31398395146e-05, + -0.000243278453127, + 0.000769039965235, + -0.000837227795273, + -0.00217686733231, + 0.00346444058232, + -0.0044681020081, + 0.00550767453387, + -0.0112102692947, + 0.0205363947898, + -0.0105180032551, + 0.00535045098513, + -0.00213157944381 + ], + [ + 1.38320649512e-06, + 0.000191972227185, + -0.000745072495192, + 0.00151946756523, + 0.00112909392919, + -0.00286303297617, + 0.00282385596074, + -0.00397388311103, + 0.0083336783573, + -0.0105180032551, + 0.0160211343318, + -0.00672314595431, + 0.00358490552753 + ], + [ + 0.000111114335596, + -9.64893956734e-07, + 0.000638137804344, + -0.00070260866778, + -0.000958321907092, + 0.00123248388991, + -0.00354772782885, + 0.00314126117155, + -0.00373222492635, + 0.00535045098513, + -0.00672314595431, + 0.0181408431381, + -0.00800348073244 + ], + [ + -8.96842757356e-05, + 0.000270225456916, + 0.000274067162536, + 0.000143392870086, + -0.000166401950992, + -0.000868651026394, + 0.00168551225215, + -0.00206855195574, + 0.00208288733847, + -0.00213157944381, + 0.00358490552753, + -0.00800348073244, + 0.0156487096101 + ] + ], + "cov":[ + [ + 18717.6230469, + 1395.88500977, + -2566.72802734, + 557.851989746, + -126.109024048, + -374.194091797, + 26.1053237915, + 164.080841064, + 80.1878204346, + 192.214401245, + -182.145751953, + -47.5592041016, + 152.686767578 + ], + [ + 1395.88500977, + 1649.59350586, + -550.320007324, + -49.1516876221, + -56.1948204041, + -331.535217285, + 9.86240959167, + -34.6940498352, + -6.41311454773, + 74.1020736694, + -56.9916381836, + -16.3797187805, + -19.4202880859 + ], + [ + -2566.72802734, + -550.320007324, + 1546.81799316, + -94.7429580688, + 133.984313965, + 339.89239502, + -54.6255912781, + 10.0923881531, + 34.4428405762, + -69.080619812, + 85.0990753174, + -36.5973091125, + -56.1265907288 + ], + [ + 557.851989746, + -49.1516876221, + -94.7429580688, + 342.6902771, + 83.1379013062, + -15.0537748337, + 17.4024486542, + 46.2917900085, + 21.9955101013, + 12.8328580856, + -40.3507461548, + 8.21014022827, + 19.1214065552 + ], + [ + -126.109024048, + -56.1948204041, + 133.984313965, + 83.1379013062, + 261.195220947, + 98.0570144653, + 69.749458313, + 21.4776115417, + 23.9828796387, + 28.7067451477, + 2.99768400192, + 18.3751049042, + 10.1154251099 + ], + [ + -374.194091797, + -331.535217285, + 339.89239502, + -15.0537748337, + 98.0570144653, + 288.139526367, + 71.6913833618, + 15.3370637894, + 9.08637428284, + -21.2720279694, + 42.7490844727, + 11.0748538971, + 0.872315227985 + ], + [ + 26.1053237915, + 9.86240959167, + -54.6255912781, + 17.4024486542, + 69.749458313, + 71.6913833618, + 187.006271362, + 49.2006340027, + 4.36477804184, + 25.2697525024, + 7.5669798851, + 29.0018577576, + 7.81967544556 + ], + [ + 164.080841064, + -34.6940498352, + 10.0923881531, + 46.2917900085, + 21.4776115417, + 15.3370637894, + 49.2006340027, + 122.378990173, + 54.7077331543, + 6.68141078949, + -6.7650809288, + -0.911539852619, + 7.60771179199 + ], + [ + 80.1878204346, + -6.41311454773, + 34.4428405762, + 21.9955101013, + 23.9828796387, + 9.08637428284, + 4.36477804184, + 54.7077331543, + 120.954666138, + 41.4050827026, + -25.8017272949, + -5.81494665146, + -0.236202299595 + ], + [ + 192.214401245, + 74.1020736694, + -69.080619812, + 12.8328580856, + 28.7067451477, + -21.2720279694, + 25.2697525024, + 6.68141078949, + 41.4050827026, + 102.835891724, + 31.3814048767, + -2.45175004005, + -1.74627566338 + ], + [ + -182.145751953, + -56.9916381836, + 85.0990753174, + -40.3507461548, + 2.99768400192, + 42.7490844727, + 7.5669798851, + -6.7650809288, + -25.8017272949, + 31.3814048767, + 120.884010315, + 22.8159122467, + -8.79971408844 + ], + [ + -47.5592041016, + -16.3797187805, + -36.5973091125, + 8.21014022827, + 18.3751049042, + 11.0748538971, + 29.0018577576, + -0.911539852619, + -5.81494665146, + -2.45175004005, + 22.8159122467, + 87.9682235718, + 38.346157074 + ], + [ + 152.686767578, + -19.4202880859, + -56.1265907288, + 19.1214065552, + 10.1154251099, + 0.872315227985, + 7.81967544556, + 7.60771179199, + -0.236202299595, + -1.74627566338, + -8.79971408844, + 38.346157074, + 87.662071228 + ] + ] + }, + "spectral_contrast_valleys":{ + "min":[ + -27.644701004, + -27.7437572479, + -27.5804691315, + -27.6421985626, + -27.3466243744, + -27.4174346924 + ], + "max":[ + -5.21189641953, + -4.43200492859, + -5.37789392471, + -5.91939544678, + -5.84870004654, + -8.253657341 + ], + "dvar2":[ + 0.418128162622, + 0.42821636796, + 0.406443417072, + 0.387645244598, + 0.499291837215, + 0.697984993458 + ], + "median":[ + -7.62850189209, + -6.92876195908, + -7.67359733582, + -8.07751655579, + -7.46400737762, + -11.0671672821 + ], + "dmean2":[ + 0.747626721859, + 0.710296332836, + 0.67908090353, + 0.625626146793, + 0.595694363117, + 0.680769026279 + ], + "dmean":[ + 0.493877381086, + 0.47190451622, + 0.458809673786, + 0.433981686831, + 0.418061226606, + 0.529967188835 + ], + "var":[ + 2.56318879128, + 2.56686043739, + 2.68657803535, + 2.71048474312, + 2.86386156082, + 2.03321886063 + ], + "dvar":[ + 0.185303419828, + 0.210249379277, + 0.205063581467, + 0.197276696563, + 0.240655809641, + 0.327577888966 + ], + "mean":[ + -7.9825963974, + -7.30778980255, + -8.13263988495, + -8.62220096588, + -8.10116863251, + -11.1167650223 + ] + }, + "barkbands_flatness_db":{ + "min":0.00845116842538, + "max":0.463767468929, + "dvar2":0.00153901893646, + "median":0.152025014162, + "dmean2":0.0385256558657, + "dmean":0.0259312130511, + "var":0.00293617043644, + "dvar":0.000700293399859, + "mean":0.161552548409 + }, + "dynamic_complexity":5.97568511963, + "spectral_skewness":{ + "min":-0.258594423532, + "max":7.78090715408, + "dvar2":0.501113593578, + "median":1.41668355465, + "dmean2":0.598508477211, + "dmean":0.382409095764, + "var":0.599681735039, + "dvar":0.224964693189, + "mean":1.57063114643 + }, + "erbbands_flatness_db":{ + "min":0.0321905463934, + "max":0.434577912092, + "dvar2":0.00118384102825, + "median":0.181439816952, + "dmean2":0.0333808884025, + "dmean":0.024810899049, + "var":0.00294912024401, + "dvar":0.000594827288296, + "mean":0.178649738431 + }, + "hfc":{ + "min":1.12110872149e-16, + "max":96.712890625, + "dvar2":109.576454163, + "median":11.9836702347, + "dmean2":6.97959280014, + "dmean":4.67007303238, + "var":197.084396362, + "dvar":50.9854736328, + "mean":14.6673564911 + }, + "barkbands_crest":{ + "min":2.1863629818, + "max":25.4534358978, + "dvar2":11.5983400345, + "median":8.56367301941, + "dmean2":3.74820661545, + "dmean":2.30216050148, + "var":11.5966396332, + "dvar":4.54739236832, + "mean":9.14883136749 + } + }, + "highlevel":{ + "timbre":{ + "all":{ + "dark":0.0808309540153, + "bright":0.919169068336 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"bright", + "probability":0.919169068336 + }, + "ismir04_rhythm":{ + "all":{ + "Rumba-American":0.0406456775963, + "VienneseWaltz":0.338310062885, + "Samba":0.0297329928726, + "Rumba-Misc":0.0135653112084, + "Rumba-International":0.0278510767967, + "Tango":0.330144882202, + "Waltz":0.00898563489318, + "ChaChaCha":0.0889096781611, + "Jive":0.11033257097, + "Quickstep":0.0115221142769 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"VienneseWaltz", + "probability":0.338310062885 + }, + "voice_instrumental":{ + "all":{ + "instrumental":0.999981045723, + "voice":1.89501206478e-05 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"instrumental", + "probability":0.999981045723 + }, + "gender":{ + "all":{ + "male":0.108683988452, + "female":0.891315996647 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"female", + "probability":0.891315996647 + }, + "genre_rosamerica":{ + "all":{ + "hip":0.070330247283, + "rhy":0.225707545877, + "jaz":0.0771619826555, + "dan":0.0574826933444, + "roc":0.270465612411, + "cla":0.0938607081771, + "pop":0.175827592611, + "spe":0.0291636306792 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"roc", + "probability":0.270465612411 + }, + "mood_electronic":{ + "all":{ + "electronic":0.339881360531, + "not_electronic":0.660118639469 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_electronic", + "probability":0.660118639469 + }, + "genre_electronic":{ + "all":{ + "house":0.187250360847, + "trance":0.185409858823, + "dnb":0.00702595943585, + "techno":0.0184047427028, + "ambient":0.601909101009 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"ambient", + "probability":0.601909101009 + }, + "mood_sad":{ + "all":{ + "not_sad":0.700305402279, + "sad":0.299694597721 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_sad", + "probability":0.700305402279 + }, + "tonal_atonal":{ + "all":{ + "atonal":0.125749841332, + "tonal":0.874250173569 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"tonal", + "probability":0.874250173569 + }, + "mood_party":{ + "all":{ + "party":0.234383180737, + "not_party":0.765616834164 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_party", + "probability":0.765616834164 + }, + "moods_mirex":{ + "all":{ + "Cluster2":0.0673071071506, + "Cluster3":0.397048592567, + "Cluster1":0.061667304486, + "Cluster4":0.190215244889, + "Cluster5":0.283761769533 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"Cluster3", + "probability":0.397048592567 + }, + "danceability":{ + "all":{ + "danceable":0.143928021193, + "not_danceable":0.85607200861 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_danceable", + "probability":0.85607200861 + }, + "genre_dortmund":{ + "all":{ + "raphiphop":4.74844455312e-05, + "electronic":0.984485208988, + "jazz":0.000787914788816, + "pop":0.000125292601297, + "folkcountry":0.00235203420743, + "rock":0.0010081063956, + "alternative":0.00961782038212, + "funksoulrnb":6.0458383814e-05, + "blues":0.00151570443995 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"electronic", + "probability":0.984485208988 + }, + "mood_acoustic":{ + "all":{ + "acoustic":0.415711194277, + "not_acoustic":0.584288835526 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_acoustic", + "probability":0.584288835526 + }, + "mood_happy":{ + "all":{ + "not_happy":0.910523295403, + "happy":0.0894767045975 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_happy", + "probability":0.910523295403 + }, + "mood_aggressive":{ + "all":{ + "not_aggressive":0.922077834606, + "aggressive":0.0779221653938 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_aggressive", + "probability":0.922077834606 + }, + "genre_tzanetakis":{ + "all":{ + "hip":0.154464527965, + "jaz":0.308918893337, + "bl":0.0514711923897, + "roc":0.0772226303816, + "cla":0.0343172624707, + "pop":0.0617748275399, + "met":0.0441242903471, + "co":0.102957598865, + "reg":0.0617764480412, + "dis":0.102972343564 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"jaz", + "probability":0.308918893337 + }, + "mood_relaxed":{ + "all":{ + "not_relaxed":0.87636756897, + "relaxed":0.123632438481 + }, + "version":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + }, + "value":"not_relaxed", + "probability":0.87636756897 + } + }, + "metadata":{ + "audio_properties":{ + "analysis_sample_rate":44100, + "length":214.866668701, + "downmix":"mix", + "bit_rate":0, + "codec":"flac", + "md5_encoded":"2b46dab358c1b79a3decd5bd93d7221f", + "equal_loudness":0, + "replay_gain":-14.4778690338, + "lossless":1 + }, + "version":{ + "lowlevel":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "essentia_build_sha":"50a0fbec89d6a9cedea3d45b6611406f7e8c7b1a", + "essentia_git_sha":"v2.1_beta1-7-ge0e83e8-dirty" + }, + "highlevel":{ + "essentia":"2.1-beta1", + "extractor":"music 1.0", + "gaia_git_sha":"857329b", + "models_essentia_git_sha":"v2.1_beta1", + "essentia_git_sha":"v2.1_beta1-228-g260734a", + "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", + "gaia":"2.4-dev" + } + }, + "tags":{ + "albumartistsort":[ + "Various Artists" + ], + "disctotal":[ + "1" + ], + "file_name":"04 La Grange.flac", + "artists":[ + "ZZ Top" + ], + "musicbrainz_workid":[ + "42722fe8-9de7-3729-a506-3c7f41c617a9" + ], + "releasecountry":[ + "DE" + ], + "totaldiscs":[ + "1" + ], + "albumartist":[ + "Various Artists" + ], + "musicbrainz_albumartistid":[ + "89ad4ac3-39f7-470e-963a-56509c546377" + ], + "composer":[ + "Dusty Hill", + "Frank Beard", + "Billy Gibbons" + ], + "catalognumber":[ + "491384 2" + ], + "tracknumber":[ + "4" + ], + "replaygain_track_peak":[ + "0.999969" + ], + "engineer":[ + "Terry Manning", + "Robin Hood Brians" + ], + "album":[ + "Armageddon:The Album" + ], + "asin":[ + "B000024C3A" + ], + "replaygain_album_gain":[ + "-9.32 dB" + ], + "musicbrainz_artistid":[ + "a81259a0-a2f5-464b-866e-71220f2739f1" + ], + "producer":[ + "Bill Ham" + ], + "script":[ + "Latn" + ], + "media":[ + "CD" + ], + "label":[ + "Columbia" + ], + "artistsort":[ + "ZZ Top" + ], + "acoustid_id":[ + "3ed3441e-facc-4fcd-9ef7-9fbc68c206a2" + ], + "replaygain_album_peak":[ + "0.999969" + ], + "lyricist":[ + "Dusty Hill", + "Frank Beard", + "Billy Gibbons" + ], + "musicbrainz_releasegroupid":[ + "f51d56e4-0211-3533-a9a5-08c02d8bb04a" + ], + "compilation":[ + "1" + ], + "barcode":[ + "5099749138421" + ], + "releasestatus":[ + "official" + ], + "composersort":[ + "Hill, Dusty", + "Beard, Frank", + "Gibbons, Billy" + ], + "date":[ + "1998" + ], + "isrc":[ + "USWB10505222" + ], + "discnumber":[ + "1" + ], + "musicbrainz_recordingid":[ + "cd3f5efa-bc5e-4064-a765-960494ad4bb4" + ], + "tracktotal":[ + "14" + ], + "originaldate":[ + "1998-06-23" + ], + "language":[ + "eng" + ], + "artist":[ + "ZZ Top" + ], + "title":[ + "La Grange" + ], + "releasetype":[ + "album", + "soundtrack" + ], + "musicbrainz_albumid":[ + "cfc31187-aebd-309f-a92f-7138c17df7c2" + ], + "work":[ + "La Grange" + ], + "totaltracks":[ + "14" + ], + "replaygain_track_gain":[ + "-9.38 dB" + ], + "musicbrainz_releasetrackid":[ + "befe2741-462b-3568-ba06-c8cc8e4f6eaf" + ] + } + } +} diff -Nru beets-1.3.19/test/rsrc/convert_stub.py beets-1.4.6/test/rsrc/convert_stub.py --- beets-1.3.19/test/rsrc/convert_stub.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/rsrc/convert_stub.py 2017-06-14 23:13:49.000000000 +0000 @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- """A tiny tool used to test the `convert` plugin. It copies a file and appends a specified text tag. @@ -7,6 +8,17 @@ from __future__ import division, absolute_import, print_function import sys import platform +import locale + +PY2 = sys.version_info[0] == 2 + + +# From `beets.util`. +def arg_encoding(): + try: + return locale.getdefaultlocale()[1] or 'utf-8' + except ValueError: + return 'utf-8' def convert(in_file, out_file, tag): @@ -14,13 +26,13 @@ """ # On Python 3, encode the tag argument as bytes. if not isinstance(tag, bytes): - tag = tag.encode('utf8') + tag = tag.encode('utf-8') - # On Windows, use Unicode paths. (The test harness gives them to us - # as UTF-8 bytes.) - if platform.system() == 'Windows': - in_file = in_file.decode('utf8') - out_file = out_file.decode('utf8') + # On Windows, use Unicode paths. On Python 3, we get the actual, + # Unicode filenames. On Python 2, we get them as UTF-8 byes. + if platform.system() == 'Windows' and PY2: + in_file = in_file.decode('utf-8') + out_file = out_file.decode('utf-8') with open(out_file, 'wb') as out_f: with open(in_file, 'rb') as in_f: Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/test/rsrc/empty.dsf and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/test/rsrc/empty.dsf differ Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/test/rsrc/full.dsf and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/test/rsrc/full.dsf differ Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/test/rsrc/image-jpeg.mp3 and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/test/rsrc/image-jpeg.mp3 differ diff -Nru beets-1.3.19/test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt beets-1.4.6/test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt --- beets-1.3.19/test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt 2016-08-01 03:02:12.000000000 +0000 @@ -0,0 +1,409 @@ +<!DOCTYPE html> +<html> +<head> +<title>Lady Madonna Lyrics :: The Beatles - Absolute Lyrics + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+
+ + +
+ + + + + + + + + + + + + + +

Lady Madonna - The Beatles + +

+ + + + + + + +

+ The Beatles - Lady Madonna
+
+Lady Madonna, children at your feet.
+Wonder how you manage to make ends meet.
+Who finds the money? When you pay the rent?
+Did you think that money was heaven sent?
+Friday night arrives without a suitcase.
+Sunday morning creep in like a nun.
+Monday's child has learned to tie his bootlace.
+See how they run.
+Lady Madonna, baby at your breast.
+Wonder how you manage to feed the rest.
+See how they run.
+Lady Madonna, lying on the bed,
+Listen to the music playing in your head.
+Tuesday afternoon is never ending.
+Wednesday morning papers didn't come.
+Thursday night you stockings needed mending.
+See how they run.
+Lady Madonna, children at your feet.
+Wonder how you manage to make ends meet.

+ + + + + +
+ view 9,779 times, correct by Diesel
+ +
+ +
+ + + + + + + + + + + +
+

comments

+ +
+
+
+ + + +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff -Nru beets-1.3.19/test/rsrc/lyricstext.yaml beets-1.4.6/test/rsrc/lyricstext.yaml --- beets-1.3.19/test/rsrc/lyricstext.yaml 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/rsrc/lyricstext.yaml 2017-06-14 23:13:49.000000000 +0000 @@ -1,45 +1,56 @@ -Beets_song: - - geeks - - bouquet - - panacea - -Amsterdam: - - oriflammes - - fortune - - batave - - pissent - -Lady_Madonna: - - heaven - - tuesday - - thursday - -Jazz_n_blues: - - parkway - - balance - - impatient - - shoes - -Hey_it_s_ok: - - swear - - forgive - - drink - - found - -City_of_dreams: - - groves - - landmarks - - twilight - - freeways - -Black_magic_woman: - - devil - - magic - - spell - - heart +# Song used by LyricsGooglePluginMachineryTest + +Beets_song: | + beets is the media library management system for obsessive-compulsive music geeks the purpose of + beets is to get your music collection right once and for all it catalogs your collection + automatically improving its metadata as it goes it then provides a bouquet of tools for + manipulating and accessing your music here's an example of beets' brainy tag corrector doing its + because beets is designed as a library it can do almost anything you can imagine for your + music collection via plugins beets becomes a panacea missing_texts: | Lyricsmania staff is working hard for you to add $TITLE lyrics as soon as they'll be released by $ARTIST, check back soon! In case you have the lyrics to $TITLE and want to send them to us, fill out the following form. + +# Songs lyrics used to test the different sources present in the google custom search engine. +# Text is randomized for copyright infringement reason. + +Amsterdam: | + coup corps coeur invitent mains comme trop morue le hantent mais la dames joli revenir aux + mangent croquer pleine plantent rire de sortent pleins fortune d'amsterdam bruit ruisselants + large poissons braguette leur putains blanches jusque pissent dans soleils dansent et port + bien vertu nez sur chaleur femmes rotant dorment marins boivent bu les que d'un qui je + une cou hambourg plus ils dents ou tournent or berges d'ailleurs tout ciel haubans ce son lueurs + en lune ont mouchent leurs long frottant jusqu'en vous regard montrent langueurs chantent + tordent pleure donnent drames mornes des panse pour un sent encore referment nappes au meurent + geste quand puis alors frites grosses batave expire naissent reboivent oriflammes grave riant a + enfin rance fier y bouffer s'entendre se mieux + +Lady_Madonna: | + feed his money tuesday manage didn't head feet see arrives at in madonna rest morning children + wonder how make thursday your to sunday music papers come tie you has was is listen suitcase + ends friday run that needed breast they child baby mending on lady learned a nun like did wednesday + bed think without afternoon night meet the playing lying + +Jazz_n_blues: | + all shoes money through follow blow til father to his hit jazz kiss now cool bar cause 50 night + heading i'll says yeah cash forgot blues out what for ways away fingers waiting got ever bold + screen sixty throw wait on about last compton days o pick love wall had within jeans jd next + miss standing from it's two long fight extravagant tell today more buy shopping that didn't + what's but russian up can parkway balance my and gone am it as at in check if bags when cross + machine take you drinks coke june wrong coming fancy's i n' impatient so the main's spend + that's + +Hey_it_s_ok: | + and forget be when please it against fighting mama cause ! again what said + things papa hey to much lovers way wet was too do drink and i who forgive + hey fourteen please know not wanted had myself ok friends bed times looked + swear act found the my mean + +Black_magic_woman: | + blind heart sticks just don't into back alone see need yes your out devil make that to black got + you might me woman turning spell stop baby with 'round a on stone messin' magic i of + tricks up leave turn bad so pick she's my can't + Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/test/rsrc/unicode’d.mp3 and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/test/rsrc/unicode’d.mp3 differ Binary files /tmp/tmpkALRwi/k2gS07Sl4O/beets-1.3.19/test/rsrc/unparseable.dsf and /tmp/tmpkALRwi/c8pP2XYpCF/beets-1.4.6/test/rsrc/unparseable.dsf differ diff -Nru beets-1.3.19/test/test_acousticbrainz.py beets-1.4.6/test/test_acousticbrainz.py --- beets-1.3.19/test/test_acousticbrainz.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/test/test_acousticbrainz.py 2016-12-17 03:01:23.000000000 +0000 @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, Nathan Dwek. +# +# 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. + +"""Tests for the 'acousticbrainz' plugin. +""" + +from __future__ import division, absolute_import, print_function + +import json +import os.path +import unittest + +from test._common import RSRC + +from beetsplug.acousticbrainz import AcousticPlugin, ABSCHEME + + +class MapDataToSchemeTest(unittest.TestCase): + def test_basic(self): + ab = AcousticPlugin() + data = {'key 1': 'value 1', 'key 2': 'value 2'} + scheme = {'key 1': 'attribute 1', 'key 2': 'attribute 2'} + mapping = set(ab._map_data_to_scheme(data, scheme)) + self.assertEqual(mapping, {('attribute 1', 'value 1'), + ('attribute 2', 'value 2')}) + + def test_recurse(self): + ab = AcousticPlugin() + data = { + 'key': 'value', + 'group': { + 'subkey': 'subvalue', + 'subgroup': { + 'subsubkey': 'subsubvalue' + } + } + } + scheme = { + 'key': 'attribute 1', + 'group': { + 'subkey': 'attribute 2', + 'subgroup': { + 'subsubkey': 'attribute 3' + } + } + } + mapping = set(ab._map_data_to_scheme(data, scheme)) + self.assertEqual(mapping, {('attribute 1', 'value'), + ('attribute 2', 'subvalue'), + ('attribute 3', 'subsubvalue')}) + + def test_composite(self): + ab = AcousticPlugin() + data = {'key 1': 'part 1', 'key 2': 'part 2'} + scheme = {'key 1': ('attribute', 0), 'key 2': ('attribute', 1)} + mapping = set(ab._map_data_to_scheme(data, scheme)) + self.assertEqual(mapping, {('attribute', 'part 1 part 2')}) + + def test_realistic(self): + ab = AcousticPlugin() + data_path = os.path.join(RSRC, b'acousticbrainz/data.json') + with open(data_path) as res: + data = json.load(res) + mapping = set(ab._map_data_to_scheme(data, ABSCHEME)) + expected = { + ('chords_key', 'A'), + ('average_loudness', 0.815025985241), + ('mood_acoustic', 0.415711194277), + ('chords_changes_rate', 0.0445116683841), + ('tonal', 0.874250173569), + ('mood_sad', 0.299694597721), + ('bpm', 162.532119751), + ('gender', 'female'), + ('initial_key', 'A minor'), + ('chords_number_rate', 0.00194468453992), + ('mood_relaxed', 0.123632438481), + ('chords_scale', 'minor'), + ('voice_instrumental', 'instrumental'), + ('key_strength', 0.636936545372), + ('genre_rosamerica', 'roc'), + ('mood_party', 0.234383180737), + ('mood_aggressive', 0.0779221653938), + ('danceable', 0.143928021193), + ('rhythm', 'VienneseWaltz'), + ('mood_electronic', 0.339881360531), + ('mood_happy', 0.0894767045975) + } + self.assertEqual(mapping, expected) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/test/testall.py beets-1.4.6/test/testall.py --- beets-1.3.19/test/testall.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/testall.py 2016-12-17 03:01:23.000000000 +0000 @@ -20,8 +20,7 @@ import os import re import sys - -from test._common import unittest +import unittest pkgpath = os.path.dirname(__file__) or '.' sys.path.append(pkgpath) diff -Nru beets-1.3.19/test/test_art.py beets-1.4.6/test/test_art.py --- beets-1.3.19/test/test_art.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/test/test_art.py 2017-10-03 19:33:23.000000000 +0000 @@ -19,12 +19,12 @@ import os import shutil +import unittest import responses from mock import patch from test import _common -from test._common import unittest from beetsplug import fetchart from beets.autotag import AlbumInfo, AlbumMatch from beets import config @@ -39,6 +39,15 @@ logger = logging.getLogger('beets.test_art') +class Settings(): + """Used to pass settings to the ArtSources when the plugin isn't fully + instantiated. + """ + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + class UseThePlugin(_common.TestCase): def setUp(self): super(UseThePlugin, self).setUp() @@ -73,28 +82,28 @@ super(FetchImageTest, self).setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') self.source = fetchart.RemoteArtSource(logger, self.plugin.config) - self.extra = {'maxwidth': 0} + self.settings = Settings(maxwidth=0) self.candidate = fetchart.Candidate(logger, url=self.URL) def test_invalid_type_returns_none(self): self.mock_response(self.URL, 'image/watercolour') - self.source.fetch_image(self.candidate, self.extra) + self.source.fetch_image(self.candidate, self.settings) self.assertEqual(self.candidate.path, None) def test_jpeg_type_returns_path(self): self.mock_response(self.URL, 'image/jpeg') - self.source.fetch_image(self.candidate, self.extra) + self.source.fetch_image(self.candidate, self.settings) self.assertNotEqual(self.candidate.path, None) def test_extension_set_by_content_type(self): self.mock_response(self.URL, 'image/png') - self.source.fetch_image(self.candidate, self.extra) + self.source.fetch_image(self.candidate, self.settings) self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png') self.assertExists(self.candidate.path) def test_does_not_rely_on_server_content_type(self): self.mock_response(self.URL, 'image/jpeg', 'image/png') - self.source.fetch_image(self.candidate, self.extra) + self.source.fetch_image(self.candidate, self.settings) self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png') self.assertExists(self.candidate.path) @@ -106,44 +115,43 @@ os.mkdir(self.dpath) self.source = fetchart.FileSystem(logger, self.plugin.config) - self.extra = {'cautious': False, - 'cover_names': ('art',), - 'paths': [self.dpath]} + self.settings = Settings(cautious=False, + cover_names=('art',)) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) - candidate = next(self.source.get(None, self.extra)) + candidate = next(self.source.get(None, self.settings, [self.dpath])) self.assertEqual(candidate.path, os.path.join(self.dpath, b'a.jpg')) def test_appropriately_named_file_takes_precedence(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) _common.touch(os.path.join(self.dpath, b'art.jpg')) - candidate = next(self.source.get(None, self.extra)) + candidate = next(self.source.get(None, self.settings, [self.dpath])) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) def test_non_image_file_not_identified(self): _common.touch(os.path.join(self.dpath, b'a.txt')) with self.assertRaises(StopIteration): - next(self.source.get(None, self.extra)) + next(self.source.get(None, self.settings, [self.dpath])) def test_cautious_skips_fallback(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) - self.extra['cautious'] = True + self.settings.cautious = True with self.assertRaises(StopIteration): - next(self.source.get(None, self.extra)) + next(self.source.get(None, self.settings, [self.dpath])) def test_empty_dir(self): with self.assertRaises(StopIteration): - next(self.source.get(None, self.extra)) + next(self.source.get(None, self.settings, [self.dpath])) def test_precedence_amongst_correct_files(self): images = [b'front-cover.jpg', b'front.jpg', b'back.jpg'] paths = [os.path.join(self.dpath, i) for i in images] for p in paths: _common.touch(p) - self.extra['cover_names'] = ['cover', 'front', 'back'] + self.settings.cover_names = ['cover', 'front', 'back'] candidates = [candidate.path for candidate in - self.source.get(None, self.extra)] + self.source.get(None, self.settings, [self.dpath])] self.assertEqual(candidates, paths) @@ -154,7 +162,7 @@ .format(ASIN) AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}' \ .format(ASIN) - CAA_URL = 'http://coverartarchive.org/release/{0}/front' \ + CAA_URL = 'coverartarchive.org/release/{0}/front' \ .format(MBID) def setUp(self): @@ -202,12 +210,17 @@ self.assertEqual(responses.calls[-1].request.url, self.AAO_URL) def test_main_interface_uses_caa_when_mbid_available(self): - self.mock_response(self.CAA_URL) + self.mock_response("http://" + self.CAA_URL) + self.mock_response("https://" + self.CAA_URL) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) self.assertIsNotNone(candidate) self.assertEqual(len(responses.calls), 1) - self.assertEqual(responses.calls[0].request.url, self.CAA_URL) + if util.SNI_SUPPORTED: + url = "https://" + self.CAA_URL + else: + url = "http://" + self.CAA_URL + self.assertEqual(responses.calls[0].request.url, url) def test_local_only_does_not_access_network(self): album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) @@ -231,7 +244,7 @@ def setUp(self): super(AAOTest, self).setUp() self.source = fetchart.AlbumArtOrg(logger, self.plugin.config) - self.extra = dict() + self.settings = Settings() @responses.activate def run(self, *args, **kwargs): @@ -251,21 +264,21 @@ """ self.mock_response(self.AAO_URL, body) album = _common.Bag(asin=self.ASIN) - candidate = next(self.source.get(album, self.extra)) + candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'TARGET_URL') def test_aao_scraper_returns_no_result_when_no_image_present(self): self.mock_response(self.AAO_URL, 'blah blah') album = _common.Bag(asin=self.ASIN) with self.assertRaises(StopIteration): - next(self.source.get(album, self.extra)) + next(self.source.get(album, self.settings, [])) class GoogleImageTest(UseThePlugin): def setUp(self): super(GoogleImageTest, self).setUp() self.source = fetchart.GoogleImages(logger, self.plugin.config) - self.extra = dict() + self.settings = Settings() @responses.activate def run(self, *args, **kwargs): @@ -279,7 +292,7 @@ album = _common.Bag(albumartist="some artist", album="some album") json = '{"items": [{"link": "url_to_the_image"}]}' self.mock_response(fetchart.GoogleImages.URL, json) - candidate = next(self.source.get(album, self.extra)) + candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') def test_google_art_returns_no_result_when_error_received(self): @@ -287,14 +300,14 @@ json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.GoogleImages.URL, json) with self.assertRaises(StopIteration): - next(self.source.get(album, self.extra)) + next(self.source.get(album, self.settings, [])) def test_google_art_returns_no_result_with_malformed_response(self): album = _common.Bag(albumartist="some artist", album="some album") json = """bla blup""" self.mock_response(fetchart.GoogleImages.URL, json) with self.assertRaises(StopIteration): - next(self.source.get(album, self.extra)) + next(self.source.get(album, self.settings, [])) class FanartTVTest(UseThePlugin): @@ -358,7 +371,7 @@ def setUp(self): super(FanartTVTest, self).setUp() self.source = fetchart.FanartTV(logger, self.plugin.config) - self.extra = dict() + self.settings = Settings() @responses.activate def run(self, *args, **kwargs): @@ -372,7 +385,7 @@ album = _common.Bag(mb_releasegroupid=u'thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_MULTIPLE) - candidate = next(self.source.get(album, self.extra)) + candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'http://example.com/1.jpg') def test_fanarttv_returns_no_result_when_error_received(self): @@ -380,14 +393,14 @@ self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_ERROR) with self.assertRaises(StopIteration): - next(self.source.get(album, self.extra)) + next(self.source.get(album, self.settings, [])) def test_fanarttv_returns_no_result_with_malformed_response(self): album = _common.Bag(mb_releasegroupid=u'thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_MALFORMED) with self.assertRaises(StopIteration): - next(self.source.get(album, self.extra)) + next(self.source.get(album, self.settings, [])) def test_fanarttv_only_other_images(self): # The source used to fail when there were images present, but no cover @@ -395,7 +408,7 @@ self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_NO_ART) with self.assertRaises(StopIteration): - next(self.source.get(album, self.extra)) + next(self.source.get(album, self.settings, [])) @_common.slow_test() @@ -415,8 +428,8 @@ self.plugin.art_for_album = art_for_album # Test library. - self.libpath = os.path.join(self.temp_dir, 'tmplib.blb') - self.libdir = os.path.join(self.temp_dir, 'tmplib') + self.libpath = os.path.join(self.temp_dir, b'tmplib.blb') + self.libdir = os.path.join(self.temp_dir, b'tmplib') os.mkdir(self.libdir) os.mkdir(os.path.join(self.libdir, b'album')) itempath = os.path.join(self.libdir, b'album', b'test.mp3') @@ -505,7 +518,8 @@ # message " has album art". self._fetch_art(True) util.remove(self.album.artpath) - self.plugin.batch_fetch_art(self.lib, self.lib.albums(), force=False) + self.plugin.batch_fetch_art(self.lib, self.lib.albums(), force=False, + quiet=False) self.assertExists(self.album.artpath) @@ -523,8 +537,8 @@ self.old_fs_source_get = fetchart.FileSystem.get - def fs_source_get(_self, album, extra): - if extra['paths']: + def fs_source_get(_self, album, settings, paths): + if paths: yield fetchart.Candidate(logger, path=self.image_file) fetchart.FileSystem.get = fs_source_get @@ -652,5 +666,6 @@ def suite(): return unittest.TestLoader().loadTestsFromName(__name__) + if __name__ == '__main__': unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/test/test_autotag.py beets-1.4.6/test/test_autotag.py --- beets-1.3.19/test/test_autotag.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_autotag.py 2016-12-17 03:01:23.000000000 +0000 @@ -19,9 +19,9 @@ import re import copy +import unittest from test import _common -from test._common import unittest from beets import autotag from beets.autotag import match from beets.autotag.hooks import Distance, string_dist @@ -611,7 +611,7 @@ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) - for item, info in mapping.iteritems(): + for item, info in mapping.items(): self.assertEqual(items.index(item), trackinfo.index(info)) diff -Nru beets-1.3.19/test/test_bucket.py beets-1.4.6/test/test_bucket.py --- beets-1.3.19/test/test_bucket.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_bucket.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,7 +17,7 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from beetsplug import bucket from beets import config, ui diff -Nru beets-1.3.19/test/test_config_command.py beets-1.4.6/test/test_config_command.py --- beets-1.3.19/test/test_config_command.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_config_command.py 2016-12-17 03:01:23.000000000 +0000 @@ -7,18 +7,20 @@ from mock import patch from tempfile import mkdtemp from shutil import rmtree +import unittest from beets import ui from beets import config -from test._common import unittest -from test.helper import TestHelper, capture_stdout +from test.helper import TestHelper from beets.library import Library +import six class ConfigCommandTest(unittest.TestCase, TestHelper): def setUp(self): + self.lib = Library(':memory:') self.temp_dir = mkdtemp() if 'EDITOR' in os.environ: del os.environ['EDITOR'] @@ -41,55 +43,55 @@ def tearDown(self): rmtree(self.temp_dir) + def _run_with_yaml_output(self, *args): + output = self.run_with_output(*args) + return yaml.load(output) + def test_show_user_config(self): - with capture_stdout() as output: - self.run_command('config', '-c') - output = yaml.load(output.getvalue()) + output = self._run_with_yaml_output('config', '-c') + self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'password_value') def test_show_user_config_with_defaults(self): - with capture_stdout() as output: - self.run_command('config', '-dc') - output = yaml.load(output.getvalue()) + output = self._run_with_yaml_output('config', '-dc') + self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'password_value') self.assertEqual(output['library'], 'lib') self.assertEqual(output['import']['timid'], False) def test_show_user_config_with_cli(self): - with capture_stdout() as output: - self.run_command('--config', self.cli_config_path, 'config') - output = yaml.load(output.getvalue()) + output = self._run_with_yaml_output('--config', self.cli_config_path, + 'config') + self.assertEqual(output['library'], 'lib') self.assertEqual(output['option'], 'cli overwrite') def test_show_redacted_user_config(self): - with capture_stdout() as output: - self.run_command('config') - output = yaml.load(output.getvalue()) + output = self._run_with_yaml_output('config') + self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'REDACTED') def test_show_redacted_user_config_with_defaults(self): - with capture_stdout() as output: - self.run_command('config', '-d') - output = yaml.load(output.getvalue()) + output = self._run_with_yaml_output('config', '-d') + self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'REDACTED') self.assertEqual(output['import']['timid'], False) def test_config_paths(self): - with capture_stdout() as output: - self.run_command('config', '-p') - paths = output.getvalue().split('\n') + output = self.run_with_output('config', '-p') + + paths = output.split('\n') self.assertEqual(len(paths), 2) self.assertEqual(paths[0], self.config_path) def test_config_paths_with_cli(self): - with capture_stdout() as output: - self.run_command('--config', self.cli_config_path, 'config', '-p') - paths = output.getvalue().split('\n') + output = self.run_with_output('--config', self.cli_config_path, + 'config', '-p') + paths = output.split('\n') self.assertEqual(len(paths), 3) self.assertEqual(paths[0], self.cli_config_path) @@ -114,11 +116,10 @@ execlp.side_effect = OSError('here is problem') self.run_command('config', '-e') self.assertIn('Could not edit configuration', - unicode(user_error.exception)) - self.assertIn('here is problem', unicode(user_error.exception)) + six.text_type(user_error.exception)) + self.assertIn('here is problem', six.text_type(user_error.exception)) def test_edit_invalid_config_file(self): - self.lib = Library(':memory:') with open(self.config_path, 'w') as file: file.write('invalid: [') config.clear() diff -Nru beets-1.3.19/test/test_convert.py beets-1.4.6/test/test_convert.py --- beets-1.3.19/test/test_convert.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_convert.py 2017-06-14 23:13:49.000000000 +0000 @@ -15,16 +15,26 @@ from __future__ import division, absolute_import, print_function +import sys import re import os.path +import unittest + from test import _common -from test._common import unittest from test import helper -from test.helper import control_stdin +from test.helper import control_stdin, capture_log from beets.mediafile import MediaFile from beets import util -from beets import ui + + +def shell_quote(text): + if sys.version_info[0] < 3: + import pipes + return pipes.quote(text) + else: + import shlex + return shlex.quote(text) class TestHelper(helper.TestHelper): @@ -39,31 +49,41 @@ # A Python script that copies the file and appends a tag. stub = os.path.join(_common.RSRC, b'convert_stub.py').decode('utf-8') - return u"python '{}' $source $dest {}".format(stub, tag) + return u"{} {} $source $dest {}".format(shell_quote(sys.executable), + shell_quote(stub), tag) def assertFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content ends with `tag`. """ + display_tag = tag tag = tag.encode('utf-8') self.assertTrue(os.path.isfile(path), - u'{0} is not a file'.format(path)) + u'{0} is not a file'.format( + util.displayable_path(path))) with open(path, 'rb') as f: - f.seek(-len(tag), os.SEEK_END) + f.seek(-len(display_tag), os.SEEK_END) self.assertEqual(f.read(), tag, - u'{0} is not tagged with {1}'.format(path, tag)) + u'{0} is not tagged with {1}' + .format( + util.displayable_path(path), + display_tag)) def assertNoFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content does not end with `tag`. """ + display_tag = tag tag = tag.encode('utf-8') self.assertTrue(os.path.isfile(path), - u'{0} is not a file'.format(path)) + u'{0} is not a file'.format( + util.displayable_path(path))) with open(path, 'rb') as f: f.seek(-len(tag), os.SEEK_END) self.assertNotEqual(f.read(), tag, u'{0} is unexpectedly tagged with {1}' - .format(path, tag)) + .format( + util.displayable_path(path), + display_tag)) @_common.slow_test() @@ -110,7 +130,7 @@ """Run the `convert` command on a given path.""" # The path is currently a filesystem bytestring. Convert it to # an argument bytestring. - path = path.decode(util._fsencoding()).encode(ui._arg_encoding()) + path = path.decode(util._fsencoding()).encode(util.arg_encoding()) args = args + (b'path:' + path,) return self.run_command('convert', *args) @@ -160,7 +180,7 @@ converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') - def test_rejecet_confirmation(self): + def test_reject_confirmation(self): with control_stdin('n'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') @@ -207,6 +227,11 @@ converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFalse(os.path.exists(converted)) + def test_empty_query(self): + with capture_log('beets.convert') as logs: + self.run_convert('An impossible query') + self.assertEqual(logs[0], u'convert: Empty query result.') + @_common.slow_test() class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper, @@ -259,5 +284,6 @@ def suite(): return unittest.TestLoader().loadTestsFromName(__name__) + if __name__ == '__main__': unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/test/test_datequery.py beets-1.4.6/test/test_datequery.py --- beets-1.3.19/test/test_datequery.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_datequery.py 2017-06-20 19:15:08.000000000 +0000 @@ -18,16 +18,21 @@ from __future__ import division, absolute_import, print_function from test import _common -from test._common import unittest -from datetime import datetime +from datetime import datetime, timedelta +import unittest import time -from beets.dbcore.query import _parse_periods, DateInterval, DateQuery +from beets.dbcore.query import _parse_periods, DateInterval, DateQuery,\ + InvalidQueryArgumentValueError def _date(string): return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S') +def _datepattern(datetimedate): + return datetimedate.strftime('%Y-%m-%dT%H:%M:%S') + + class DateIntervalTest(unittest.TestCase): def test_year_precision_intervals(self): self.assertContains('2000..2001', '2000-01-01T00:00:00') @@ -43,6 +48,9 @@ self.assertContains('..2001', '2001-12-31T23:59:59') self.assertExcludes('..2001', '2002-01-01T00:00:00') + self.assertContains('-1d..1d', _datepattern(datetime.now())) + self.assertExcludes('-2d..-1d', _datepattern(datetime.now())) + def test_day_precision_intervals(self): self.assertContains('2000-06-20..2000-06-20', '2000-06-20T00:00:00') self.assertContains('2000-06-20..2000-06-20', '2000-06-20T10:20:30') @@ -57,6 +65,51 @@ self.assertExcludes('1999-12..2000-02', '1999-11-30T23:59:59') self.assertExcludes('1999-12..2000-02', '2000-03-01T00:00:00') + def test_hour_precision_intervals(self): + # test with 'T' separator + self.assertExcludes('2000-01-01T12..2000-01-01T13', + '2000-01-01T11:59:59') + self.assertContains('2000-01-01T12..2000-01-01T13', + '2000-01-01T12:00:00') + self.assertContains('2000-01-01T12..2000-01-01T13', + '2000-01-01T12:30:00') + self.assertContains('2000-01-01T12..2000-01-01T13', + '2000-01-01T13:30:00') + self.assertContains('2000-01-01T12..2000-01-01T13', + '2000-01-01T13:59:59') + self.assertExcludes('2000-01-01T12..2000-01-01T13', + '2000-01-01T14:00:00') + self.assertExcludes('2000-01-01T12..2000-01-01T13', + '2000-01-01T14:30:00') + + # test non-range query + self.assertContains('2008-12-01T22', + '2008-12-01T22:30:00') + self.assertExcludes('2008-12-01T22', + '2008-12-01T23:30:00') + + def test_minute_precision_intervals(self): + self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31', + '2000-01-01T12:29:59') + self.assertContains('2000-01-01T12:30..2000-01-01T12:31', + '2000-01-01T12:30:00') + self.assertContains('2000-01-01T12:30..2000-01-01T12:31', + '2000-01-01T12:30:30') + self.assertContains('2000-01-01T12:30..2000-01-01T12:31', + '2000-01-01T12:31:59') + self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31', + '2000-01-01T12:32:00') + + def test_second_precision_intervals(self): + self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55', + '2000-01-01T12:30:49') + self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55', + '2000-01-01T12:30:50') + self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55', + '2000-01-01T12:30:55') + self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55', + '2000-01-01T12:30:56') + def test_unbounded_endpoints(self): self.assertContains('..', date=datetime.max) self.assertContains('..', date=datetime.min) @@ -115,12 +168,129 @@ self.assertEqual(len(matched), 0) +class DateQueryTestRelative(_common.LibTestCase): + def setUp(self): + super(DateQueryTestRelative, self).setUp() + self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) + self.i.store() + + def test_single_month_match_fast(self): + query = DateQuery('added', datetime.now().strftime('%Y-%m')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_single_month_nonmatch_fast(self): + query = DateQuery('added', (datetime.now() + timedelta(days=30)) + .strftime('%Y-%m')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + def test_single_month_match_slow(self): + query = DateQuery('added', datetime.now().strftime('%Y-%m')) + self.assertTrue(query.match(self.i)) + + def test_single_month_nonmatch_slow(self): + query = DateQuery('added', (datetime.now() + timedelta(days=30)) + .strftime('%Y-%m')) + self.assertFalse(query.match(self.i)) + + def test_single_day_match_fast(self): + query = DateQuery('added', datetime.now().strftime('%Y-%m-%d')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_single_day_nonmatch_fast(self): + query = DateQuery('added', (datetime.now() + timedelta(days=1)) + .strftime('%Y-%m-%d')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + +class DateQueryTestRelativeMore(_common.LibTestCase): + def setUp(self): + super(DateQueryTestRelativeMore, self).setUp() + self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) + self.i.store() + + def test_relative(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '-4' + timespan + '..+4' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_relative_fail(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '-2' + timespan + '..-1' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + def test_start_relative(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '-4' + timespan + '..') + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_start_relative_fail(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '4' + timespan + '..') + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + def test_end_relative(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '..+4' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_end_relative_fail(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '..-4' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + class DateQueryConstructTest(unittest.TestCase): def test_long_numbers(self): - DateQuery('added', '1409830085..1412422089') + with self.assertRaises(InvalidQueryArgumentValueError): + DateQuery('added', '1409830085..1412422089') def test_too_many_components(self): - DateQuery('added', '12-34-56-78') + with self.assertRaises(InvalidQueryArgumentValueError): + DateQuery('added', '12-34-56-78') + + def test_invalid_date_query(self): + q_list = [ + '2001-01-0a', + '2001-0a', + '200a', + '2001-01-01..2001-01-0a', + '2001-0a..2001-01', + '200a..2002', + '20aa..', + '..2aa' + ] + for q in q_list: + with self.assertRaises(InvalidQueryArgumentValueError): + DateQuery('added', q) + + def test_datetime_uppercase_t_separator(self): + date_query = DateQuery('added', '2000-01-01T12') + self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) + self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) + + def test_datetime_lowercase_t_separator(self): + date_query = DateQuery('added', '2000-01-01t12') + self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) + self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) + + def test_datetime_space_separator(self): + date_query = DateQuery('added', '2000-01-01 12') + self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) + self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) + + def test_datetime_invalid_separator(self): + with self.assertRaises(InvalidQueryArgumentValueError): + DateQuery('added', '2000-01-01x12') def suite(): diff -Nru beets-1.3.19/test/test_dbcore.py beets-1.4.6/test/test_dbcore.py --- beets-1.3.19/test/test_dbcore.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_dbcore.py 2017-06-21 14:26:58.000000000 +0000 @@ -20,11 +20,13 @@ import os import shutil import sqlite3 +import unittest +from six import assertRaisesRegex from test import _common -from test._common import unittest from beets import dbcore from tempfile import mkstemp +import six # Fixture: concrete database and model classes. For migration tests, we @@ -298,9 +300,9 @@ self.assertNotIn('flex_field', model2) def test_check_db_fails(self): - with self.assertRaisesRegexp(ValueError, 'no database'): + with assertRaisesRegex(self, ValueError, 'no database'): dbcore.Model()._check_db() - with self.assertRaisesRegexp(ValueError, 'no id'): + with assertRaisesRegex(self, ValueError, 'no id'): TestModel1(self.db)._check_db() dbcore.Model(self.db)._check_db(need_id=False) @@ -312,13 +314,13 @@ def test_computed_field(self): model = TestModelWithGetters() self.assertEqual(model.aComputedField, 'thing') - with self.assertRaisesRegexp(KeyError, u'computed field .+ deleted'): + with assertRaisesRegex(self, KeyError, u'computed field .+ deleted'): del model.aComputedField def test_items(self): model = TestModel1(self.db) model.id = 5 - self.assertEqual({('id', 5), ('field_one', None)}, + self.assertEqual({('id', 5), ('field_one', 0)}, set(model.items())) def test_delete_internal_field(self): @@ -328,7 +330,7 @@ model._db def test_parse_nonstring(self): - with self.assertRaisesRegexp(TypeError, u"must be a string"): + with assertRaisesRegex(self, TypeError, u"must be a string"): dbcore.Model._parse(None, 42) @@ -347,9 +349,9 @@ def test_format_flex_field_bytes(self): model = TestModel1() - model.other_field = u'caf\xe9'.encode('utf8') + model.other_field = u'caf\xe9'.encode('utf-8') value = model.formatted().get('other_field') - self.assertTrue(isinstance(value, unicode)) + self.assertTrue(isinstance(value, six.text_type)) self.assertEqual(value, u'caf\xe9') def test_format_unset_field(self): diff -Nru beets-1.3.19/test/test_discogs.py beets-1.4.6/test/test_discogs.py --- beets-1.3.19/test/test_discogs.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/test/test_discogs.py 2017-01-03 01:53:12.000000000 +0000 @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, 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. + +"""Tests for discogs plugin. +""" +from __future__ import division, absolute_import, print_function + +import unittest +from test import _common +from test._common import Bag +from test.helper import capture_log + +from beetsplug.discogs import DiscogsPlugin + + +class DGAlbumInfoTest(_common.TestCase): + def _make_release(self, tracks=None): + """Returns a Bag that mimics a discogs_client.Release. The list + of elements on the returned Bag is incomplete, including just + those required for the tests on this class.""" + data = { + 'id': 'ALBUM ID', + 'uri': 'ALBUM URI', + 'title': 'ALBUM TITLE', + 'year': '3001', + 'artists': [{ + 'name': 'ARTIST NAME', + 'id': 'ARTIST ID', + 'join': ',' + }], + 'formats': [{ + 'descriptions': ['FORMAT DESC 1', 'FORMAT DESC 2'], + 'name': 'FORMAT', + 'qty': 1 + }], + 'labels': [{ + 'name': 'LABEL NAME', + 'catno': 'CATALOG NUMBER', + }], + 'tracklist': [] + } + + if tracks: + for recording in tracks: + data['tracklist'].append(recording) + + return Bag(data=data, + # Make some fields available as properties, as they are + # accessed by DiscogsPlugin methods. + title=data['title'], + artists=[Bag(data=d) for d in data['artists']]) + + def _make_track(self, title, position='', duration='', type_=None): + track = { + 'title': title, + 'position': position, + 'duration': duration + } + if type_ is not None: + # Test samples on discogs_client do not have a 'type_' field, but + # the API seems to return it. Values: 'track' for regular tracks, + # 'heading' for descriptive texts (ie. not real tracks - 12.13.2). + track['type_'] = type_ + + return track + + def _make_release_from_positions(self, positions): + """Return a Bag that mimics a discogs_client.Release with a + tracklist where tracks have the specified `positions`.""" + tracks = [self._make_track('TITLE%s' % i, position) for + (i, position) in enumerate(positions, start=1)] + return self._make_release(tracks) + + def test_parse_media_for_tracks(self): + tracks = [self._make_track('TITLE ONE', '1', '01:01'), + self._make_track('TITLE TWO', '2', '02:02')] + release = self._make_release(tracks=tracks) + + d = DiscogsPlugin().get_album_info(release) + t = d.tracks + self.assertEqual(d.media, 'FORMAT') + self.assertEqual(t[0].media, d.media) + self.assertEqual(t[1].media, d.media) + + def test_parse_medium_numbers_single_medium(self): + release = self._make_release_from_positions(['1', '2']) + d = DiscogsPlugin().get_album_info(release) + t = d.tracks + + self.assertEqual(d.mediums, 1) + self.assertEqual(t[0].medium, 1) + self.assertEqual(t[0].medium_total, 1) + self.assertEqual(t[1].medium, 1) + self.assertEqual(t[0].medium_total, 1) + + def test_parse_medium_numbers_two_mediums(self): + release = self._make_release_from_positions(['1-1', '2-1']) + d = DiscogsPlugin().get_album_info(release) + t = d.tracks + + self.assertEqual(d.mediums, 2) + self.assertEqual(t[0].medium, 1) + self.assertEqual(t[0].medium_total, 2) + self.assertEqual(t[1].medium, 2) + self.assertEqual(t[1].medium_total, 2) + + def test_parse_medium_numbers_two_mediums_two_sided(self): + release = self._make_release_from_positions(['A1', 'B1', 'C1']) + d = DiscogsPlugin().get_album_info(release) + t = d.tracks + + self.assertEqual(d.mediums, 2) + self.assertEqual(t[0].medium, 1) + self.assertEqual(t[0].medium_total, 2) + self.assertEqual(t[0].medium_index, 1) + self.assertEqual(t[1].medium, 1) + self.assertEqual(t[1].medium_total, 2) + self.assertEqual(t[1].medium_index, 2) + self.assertEqual(t[2].medium, 2) + self.assertEqual(t[2].medium_total, 2) + self.assertEqual(t[2].medium_index, 1) + + def test_parse_track_indices(self): + release = self._make_release_from_positions(['1', '2']) + d = DiscogsPlugin().get_album_info(release) + t = d.tracks + + self.assertEqual(t[0].medium_index, 1) + self.assertEqual(t[0].index, 1) + self.assertEqual(t[0].medium_total, 1) + self.assertEqual(t[1].medium_index, 2) + self.assertEqual(t[1].index, 2) + self.assertEqual(t[1].medium_total, 1) + + def test_parse_track_indices_several_media(self): + release = self._make_release_from_positions(['1-1', '1-2', '2-1', + '3-1']) + d = DiscogsPlugin().get_album_info(release) + t = d.tracks + + self.assertEqual(d.mediums, 3) + self.assertEqual(t[0].medium_index, 1) + self.assertEqual(t[0].index, 1) + self.assertEqual(t[0].medium_total, 3) + self.assertEqual(t[1].medium_index, 2) + self.assertEqual(t[1].index, 2) + self.assertEqual(t[1].medium_total, 3) + self.assertEqual(t[2].medium_index, 1) + self.assertEqual(t[2].index, 3) + self.assertEqual(t[2].medium_total, 3) + self.assertEqual(t[3].medium_index, 1) + self.assertEqual(t[3].index, 4) + self.assertEqual(t[3].medium_total, 3) + + def test_parse_position(self): + """Test the conversion of discogs `position` to medium, medium_index + and subtrack_index.""" + # List of tuples (discogs_position, (medium, medium_index, subindex) + positions = [('1', (None, '1', None)), + ('A12', ('A', '12', None)), + ('12-34', ('12-', '34', None)), + ('CD1-1', ('CD1-', '1', None)), + ('1.12', (None, '1', '12')), + ('12.a', (None, '12', 'A')), + ('12.34', (None, '12', '34')), + ('1ab', (None, '1', 'AB')), + # Non-standard + ('IV', ('IV', None, None)), + ] + + d = DiscogsPlugin() + for position, expected in positions: + self.assertEqual(d.get_track_index(position), expected) + + def test_parse_tracklist_without_sides(self): + """Test standard Discogs position 12.2.9#1: "without sides".""" + release = self._make_release_from_positions(['1', '2', '3']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 3) + + def test_parse_tracklist_with_sides(self): + """Test standard Discogs position 12.2.9#2: "with sides".""" + release = self._make_release_from_positions(['A1', 'A2', 'B1', 'B2']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) # 2 sides = 1 LP + self.assertEqual(len(d.tracks), 4) + + def test_parse_tracklist_multiple_lp(self): + """Test standard Discogs position 12.2.9#3: "multiple LP".""" + release = self._make_release_from_positions(['A1', 'A2', 'B1', 'C1']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 2) # 3 sides = 1 LP + 1 LP + self.assertEqual(len(d.tracks), 4) + + def test_parse_tracklist_multiple_cd(self): + """Test standard Discogs position 12.2.9#4: "multiple CDs".""" + release = self._make_release_from_positions(['1-1', '1-2', '2-1', + '3-1']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 3) + self.assertEqual(len(d.tracks), 4) + + def test_parse_tracklist_non_standard(self): + """Test non standard Discogs position.""" + release = self._make_release_from_positions(['I', 'II', 'III', 'IV']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 4) + + def test_parse_tracklist_subtracks_dot(self): + """Test standard Discogs position 12.2.9#5: "sub tracks, dots".""" + release = self._make_release_from_positions(['1', '2.1', '2.2', '3']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 3) + + release = self._make_release_from_positions(['A1', 'A2.1', 'A2.2', + 'A3']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 3) + + def test_parse_tracklist_subtracks_letter(self): + """Test standard Discogs position 12.2.9#5: "sub tracks, letter".""" + release = self._make_release_from_positions(['A1', 'A2a', 'A2b', 'A3']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 3) + + release = self._make_release_from_positions(['A1', 'A2.a', 'A2.b', + 'A3']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 3) + + def test_parse_tracklist_subtracks_extra_material(self): + """Test standard Discogs position 12.2.9#6: "extra material".""" + release = self._make_release_from_positions(['1', '2', 'Video 1']) + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d.mediums, 2) + self.assertEqual(len(d.tracks), 3) + + def test_parse_tracklist_subtracks_indices(self): + """Test parsing of subtracks that include index tracks.""" + release = self._make_release_from_positions(['', '', '1.1', '1.2']) + # Track 1: Index track with medium title + release.data['tracklist'][0]['title'] = 'MEDIUM TITLE' + # Track 2: Index track with track group title + release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' + + d = DiscogsPlugin().get_album_info(release) + self.assertEqual(d.mediums, 1) + self.assertEqual(d.tracks[0].disctitle, 'MEDIUM TITLE') + self.assertEqual(len(d.tracks), 1) + self.assertEqual(d.tracks[0].title, 'TRACK GROUP TITLE') + + def test_parse_tracklist_subtracks_nested_logical(self): + """Test parsing of subtracks defined inside a index track that are + logical subtracks (ie. should be grouped together into a single track). + """ + release = self._make_release_from_positions(['1', '', '3']) + # Track 2: Index track with track group title, and sub_tracks + release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' + release.data['tracklist'][1]['sub_tracks'] = [ + self._make_track('TITLE ONE', '2.1', '01:01'), + self._make_track('TITLE TWO', '2.2', '02:02') + ] + + d = DiscogsPlugin().get_album_info(release) + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 3) + self.assertEqual(d.tracks[1].title, 'TRACK GROUP TITLE') + + def test_parse_tracklist_subtracks_nested_physical(self): + """Test parsing of subtracks defined inside a index track that are + physical subtracks (ie. should not be grouped together). + """ + release = self._make_release_from_positions(['1', '', '4']) + # Track 2: Index track with track group title, and sub_tracks + release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' + release.data['tracklist'][1]['sub_tracks'] = [ + self._make_track('TITLE ONE', '2', '01:01'), + self._make_track('TITLE TWO', '3', '02:02') + ] + + d = DiscogsPlugin().get_album_info(release) + self.assertEqual(d.mediums, 1) + self.assertEqual(len(d.tracks), 4) + self.assertEqual(d.tracks[1].title, 'TITLE ONE') + self.assertEqual(d.tracks[2].title, 'TITLE TWO') + + def test_parse_tracklist_disctitles(self): + """Test parsing of index tracks that act as disc titles.""" + release = self._make_release_from_positions(['', '1-1', '1-2', '', + '2-1']) + # Track 1: Index track with medium title (Cd1) + release.data['tracklist'][0]['title'] = 'MEDIUM TITLE CD1' + # Track 4: Index track with medium title (Cd2) + release.data['tracklist'][3]['title'] = 'MEDIUM TITLE CD2' + + d = DiscogsPlugin().get_album_info(release) + self.assertEqual(d.mediums, 2) + self.assertEqual(d.tracks[0].disctitle, 'MEDIUM TITLE CD1') + self.assertEqual(d.tracks[1].disctitle, 'MEDIUM TITLE CD1') + self.assertEqual(d.tracks[2].disctitle, 'MEDIUM TITLE CD2') + self.assertEqual(len(d.tracks), 3) + + def test_parse_minimal_release(self): + """Test parsing of a release with the minimal amount of information.""" + data = {'id': 123, + 'tracklist': [self._make_track('A', '1', '01:01')], + 'artists': [{'name': 'ARTIST NAME', 'id': 321, 'join': ''}], + 'title': 'TITLE'} + release = Bag(data=data, + title=data['title'], + artists=[Bag(data=d) for d in data['artists']]) + d = DiscogsPlugin().get_album_info(release) + self.assertEqual(d.artist, 'ARTIST NAME') + self.assertEqual(d.album, 'TITLE') + self.assertEqual(len(d.tracks), 1) + + def test_parse_release_without_required_fields(self): + """Test parsing of a release that does not have the required fields.""" + release = Bag(data={}, refresh=lambda *args: None) + with capture_log() as logs: + d = DiscogsPlugin().get_album_info(release) + + self.assertEqual(d, None) + self.assertIn('Release does not contain the required fields', logs[0]) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/test/test_edit.py beets-1.4.6/test/test_edit.py --- beets-1.3.19/test/test_edit.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_edit.py 2017-08-27 14:19:06.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # @@ -14,13 +15,14 @@ from __future__ import division, absolute_import, print_function import codecs +import unittest from mock import patch from test import _common -from test._common import unittest from test.helper import TestHelper, control_stdin from test.test_ui_importer import TerminalImportSessionSetup from test.test_importer import ImportHelper, AutotagStub +from beets.dbcore.query import TrueQuery from beets.library import Item from beetsplug.edit import EditPlugin @@ -54,18 +56,18 @@ `self.contents` is empty, the file remains unchanged. """ if self.contents: - with codecs.open(filename, 'w', encoding='utf8') as f: + with codecs.open(filename, 'w', encoding='utf-8') as f: f.write(self.contents) def replace_contents(self, filename, log): """Modify `filename`, reading its contents and replacing the strings specified in `self.replacements`. """ - with codecs.open(filename, 'r', encoding='utf8') as f: + with codecs.open(filename, 'r', encoding='utf-8') as f: contents = f.read() - for old, new_ in self.replacements.iteritems(): + for old, new_ in self.replacements.items(): contents = contents.replace(old, new_) - with codecs.open(filename, 'w', encoding='utf8') as f: + with codecs.open(filename, 'w', encoding='utf-8') as f: f.write(contents) @@ -106,6 +108,7 @@ @_common.slow_test() +@patch('beets.library.Item.write') class EditCommandTest(unittest.TestCase, TestHelper, EditMixin): """Black box tests for `beetsplug.edit`. Command line interaction is simulated using `test.helper.control_stdin()`, and yaml editing via an @@ -123,26 +126,21 @@ self.items_orig = [{f: item[f] for f in item._fields} for item in self.album.items()] - # Keep track of write()s. - self.write_patcher = patch('beets.library.Item.write') - self.mock_write = self.write_patcher.start() - def tearDown(self): EditPlugin.listeners = None - self.write_patcher.stop() self.teardown_beets() self.unload_plugins() - def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, # noqa + def assertCounts(self, mock_write, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, # noqa write_call_count=TRACK_COUNT, title_starts_with=''): """Several common assertions on Album, Track and call counts.""" self.assertEqual(len(self.lib.albums()), album_count) self.assertEqual(len(self.lib.items()), track_count) - self.assertEqual(self.mock_write.call_count, write_call_count) + self.assertEqual(mock_write.call_count, write_call_count) self.assertTrue(all(i.title.startswith(title_starts_with) for i in self.lib.items())) - def test_title_edit_discard(self): + def test_title_edit_discard(self, mock_write): """Edit title for all items in the library, then discard changes.""" # Edit track titles. self.run_mocked_command({'replacements': {u't\u00eftle': @@ -150,11 +148,11 @@ # Cancel. ['c']) - self.assertCounts(write_call_count=0, + self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, []) - def test_title_edit_apply(self): + def test_title_edit_apply(self, mock_write): """Edit title for all items in the library, then apply changes.""" # Edit track titles. self.run_mocked_command({'replacements': {u't\u00eftle': @@ -162,12 +160,12 @@ # Apply changes. ['a']) - self.assertCounts(write_call_count=self.TRACK_COUNT, + self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT, title_starts_with=u'modified t\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, - ['title']) + ['title', 'mtime']) - def test_single_title_edit_apply(self): + def test_single_title_edit_apply(self, mock_write): """Edit title for one item in the library, then apply changes.""" # Edit one track title. self.run_mocked_command({'replacements': {u't\u00eftle 9': @@ -175,25 +173,25 @@ # Apply changes. ['a']) - self.assertCounts(write_call_count=1,) + self.assertCounts(mock_write, write_call_count=1,) # No changes except on last item. self.assertItemFieldsModified(list(self.album.items())[:-1], self.items_orig[:-1], []) self.assertEqual(list(self.album.items())[-1].title, u'modified t\u00eftle 9') - def test_noedit(self): + def test_noedit(self, mock_write): """Do not edit anything.""" # Do not edit anything. self.run_mocked_command({'contents': None}, # No stdin. []) - self.assertCounts(write_call_count=0, + self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, []) - def test_album_edit_apply(self): + def test_album_edit_apply(self, mock_write): """Edit the album field for all items in the library, apply changes. By design, the album should not be updated."" """ @@ -203,27 +201,31 @@ # Apply changes. ['a']) - self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, - ['album']) + ['album', 'mtime']) # Ensure album is *not* modified. self.album.load() self.assertEqual(self.album.album, u'\u00e4lbum') - def test_single_edit_add_field(self): + def test_single_edit_add_field(self, mock_write): """Edit the yaml file appending an extra field to the first item, then apply changes.""" - # Append "foo: bar" to item with id == 1. - self.run_mocked_command({'replacements': {u"id: 1": - u"id: 1\nfoo: bar"}}, + # Append "foo: bar" to item with id == 2. ("id: 1" would match both + # "id: 1" and "id: 10") + self.run_mocked_command({'replacements': {u"id: 2": + u"id: 2\nfoo: bar"}}, # Apply changes. ['a']) - self.assertEqual(self.lib.items(u'id:1')[0].foo, 'bar') - self.assertCounts(write_call_count=1, + self.assertEqual(self.lib.items(u'id:2')[0].foo, 'bar') + # Even though a flexible attribute was written (which is not directly + # written to the tags), write should still be called since templates + # might use it. + self.assertCounts(mock_write, write_call_count=1, title_starts_with=u't\u00eftle') - def test_a_album_edit_apply(self): + def test_a_album_edit_apply(self, mock_write): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, @@ -232,12 +234,12 @@ args=['-a']) self.album.load() - self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertEqual(self.album.album, u'modified \u00e4lbum') self.assertItemFieldsModified(self.album.items(), self.items_orig, - ['album']) + ['album', 'mtime']) - def test_a_albumartist_edit_apply(self): + def test_a_albumartist_edit_apply(self, mock_write): """Album query (-a), edit albumartist field, apply changes.""" self.run_mocked_command({'replacements': {u'album artist': u'modified album artist'}}, @@ -246,12 +248,12 @@ args=['-a']) self.album.load() - self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertEqual(self.album.albumartist, u'the modified album artist') self.assertItemFieldsModified(self.album.items(), self.items_orig, - ['albumartist']) + ['albumartist', 'mtime']) - def test_malformed_yaml(self): + def test_malformed_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a malformed yaml document).""" # Edit the yaml file to an invalid file. @@ -259,10 +261,10 @@ # Edit again to fix? No. ['n']) - self.assertCounts(write_call_count=0, + self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') - def test_invalid_yaml(self): + def test_invalid_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a well-formed but invalid yaml document).""" # Edit the yaml file to an invalid but parseable file. @@ -270,7 +272,7 @@ # No stdin. []) - self.assertCounts(write_call_count=0, + self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') @@ -354,6 +356,34 @@ # Check that 'title' field is modified, and other fields come from # the candidate. + self.assertTrue(all('Edited Title ' in i.title + for i in self.lib.items())) + self.assertTrue(all('match ' in i.mb_trackid + for i in self.lib.items())) + + # Ensure album is fetched from a candidate. + self.assertIn('albumid', self.lib.albums()[0].mb_albumid) + + def test_edit_retag_apply(self): + """Import the album using a candidate, then retag and edit and apply + changes. + """ + self._setup_import_session() + self.run_mocked_interpreter({}, + # 1, Apply changes. + ['1', 'a']) + + # Retag and edit track titles. On retag, the importer will reset items + # ids but not the db connections. + self.importer.paths = [] + self.importer.query = TrueQuery() + self.run_mocked_interpreter({'replacements': {u'Applied Title': + u'Edited Title'}}, + # eDit, Apply changes. + ['d', 'a']) + + # Check that 'title' field is modified, and other fields come from + # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid diff -Nru beets-1.3.19/test/test_embedart.py beets-1.4.6/test/test_embedart.py --- beets-1.3.19/test/test_embedart.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_embedart.py 2017-10-29 19:52:50.000000000 +0000 @@ -19,14 +19,14 @@ import shutil from mock import patch, MagicMock import tempfile +import unittest from test import _common -from test._common import unittest from test.helper import TestHelper from beets.mediafile import MediaFile from beets import config, logging, ui -from beets.util import syspath +from beets.util import syspath, displayable_path from beets.util.artresizer import ArtResizer from beets import art @@ -51,6 +51,8 @@ abbey_differentpath = os.path.join(_common.RSRC, b'abbey-different.jpg') def setUp(self): + super(EmbedartCliTest, self).setUp() + self.io.install() self.setup_beets() # Converter is threaded self.load_plugins('embedart') @@ -64,21 +66,40 @@ self.unload_plugins() self.teardown_beets() - def test_embed_art_from_file(self): + def test_embed_art_from_file_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] + self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) + def test_embed_art_from_file_with_no_input(self): + self._setup_data() + album = self.add_album_fixture() + item = album.items()[0] + self.io.addinput('n') + self.run_command('embedart', '-f', self.small_artpath) + mediafile = MediaFile(syspath(item.path)) + # make sure that images array is empty (nothing embedded) + self.assertEqual(len(mediafile.images), 0) + + def test_embed_art_from_file(self): + self._setup_data() + album = self.add_album_fixture() + item = album.items()[0] + self.run_command('embedart', '-y', '-f', self.small_artpath) + mediafile = MediaFile(syspath(item.path)) + self.assertEqual(mediafile.images[0].data, self.image_data) + def test_embed_art_from_album(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] album.artpath = self.small_artpath album.store() - self.run_command('embedart') + self.run_command('embedart', '-y') mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) @@ -96,7 +117,7 @@ album.store() config['embedart']['remove_art_file'] = True - self.run_command('embedart') + self.run_command('embedart', '-y') if os.path.isfile(tmp_path): os.remove(tmp_path) @@ -106,7 +127,7 @@ self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) with self.assertRaises(ui.UserError): - self.run_command('embedart', '-f', '/doesnotexist') + self.run_command('embedart', '-y', '-f', '/doesnotexist') def test_embed_non_image_file(self): album = self.add_album_fixture() @@ -117,7 +138,7 @@ os.close(handle) try: - self.run_command('embedart', '-f', tmp_path) + self.run_command('embedart', '-y', '-f', tmp_path) finally: os.remove(tmp_path) @@ -129,28 +150,28 @@ self._setup_data(self.abbey_artpath) album = self.add_album_fixture() item = album.items()[0] - self.run_command('embedart', '-f', self.abbey_artpath) + self.run_command('embedart', '-y', '-f', self.abbey_artpath) config['embedart']['compare_threshold'] = 20 - self.run_command('embedart', '-f', self.abbey_differentpath) + self.run_command('embedart', '-y', '-f', self.abbey_differentpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data, u'Image written is not {0}'.format( - self.abbey_artpath)) + displayable_path(self.abbey_artpath))) @require_artresizer_compare def test_accept_similar_art(self): self._setup_data(self.abbey_similarpath) album = self.add_album_fixture() item = album.items()[0] - self.run_command('embedart', '-f', self.abbey_artpath) + self.run_command('embedart', '-y', '-f', self.abbey_artpath) config['embedart']['compare_threshold'] = 20 - self.run_command('embedart', '-f', self.abbey_similarpath) + self.run_command('embedart', '-y', '-f', self.abbey_similarpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data, u'Image written is not {0}'.format( - self.abbey_similarpath)) + displayable_path(self.abbey_similarpath))) def test_non_ascii_album_path(self): resource_path = os.path.join(_common.RSRC, b'image.mp3') @@ -163,6 +184,39 @@ self.assertExists(os.path.join(albumpath, b'extracted.png')) + def test_extracted_extension(self): + resource_path = os.path.join(_common.RSRC, b'image-jpeg.mp3') + album = self.add_album_fixture() + trackpath = album.items()[0].path + albumpath = album.path + shutil.copy(syspath(resource_path), syspath(trackpath)) + + self.run_command('extractart', '-n', 'extracted') + + self.assertExists(os.path.join(albumpath, b'extracted.jpg')) + + def test_clear_art_with_yes_input(self): + self._setup_data() + album = self.add_album_fixture() + item = album.items()[0] + self.io.addinput('y') + self.run_command('embedart', '-f', self.small_artpath) + self.io.addinput('y') + self.run_command('clearart') + mediafile = MediaFile(syspath(item.path)) + self.assertEqual(len(mediafile.images), 0) + + def test_clear_art_with_no_input(self): + self._setup_data() + album = self.add_album_fixture() + item = album.items()[0] + self.io.addinput('y') + self.run_command('embedart', '-f', self.small_artpath) + self.io.addinput('n') + self.run_command('clearart') + mediafile = MediaFile(syspath(item.path)) + self.assertEqual(mediafile.images[0].data, self.image_data) + @patch('beets.art.subprocess') @patch('beets.art.extract') diff -Nru beets-1.3.19/test/test_embyupdate.py beets-1.4.6/test/test_embyupdate.py --- beets-1.3.19/test/test_embyupdate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_embyupdate.py 2016-12-17 03:01:23.000000000 +0000 @@ -2,9 +2,9 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest from test.helper import TestHelper from beetsplug import embyupdate +import unittest import responses @@ -24,7 +24,7 @@ self.teardown_beets() self.unload_plugins() - def test_api_url(self): + def test_api_url_only_name(self): self.assertEqual( embyupdate.api_url(self.config['emby']['host'].get(), self.config['emby']['port'].get(), @@ -32,6 +32,22 @@ 'http://localhost:8096/Library/Refresh?format=json' ) + def test_api_url_http(self): + self.assertEqual( + embyupdate.api_url(u'http://localhost', + self.config['emby']['port'].get(), + '/Library/Refresh'), + 'http://localhost:8096/Library/Refresh?format=json' + ) + + def test_api_url_https(self): + self.assertEqual( + embyupdate.api_url(u'https://localhost', + self.config['emby']['port'].get(), + '/Library/Refresh'), + 'https://localhost:8096/Library/Refresh?format=json' + ) + def test_password_data(self): self.assertEqual( embyupdate.password_data(self.config['emby']['username'].get(), @@ -47,12 +63,14 @@ self.assertEqual( embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721'), { - 'Authorization': 'MediaBrowser', - 'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721', - 'Client': 'other', - 'Device': 'empy', - 'DeviceId': 'beets', - 'Version': '0.0.0' + 'x-emby-authorization': ( + 'MediaBrowser ' + 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' + 'Client="other", ' + 'Device="beets", ' + 'DeviceId="beets", ' + 'Version="0.0.0"' + ) } ) @@ -61,13 +79,15 @@ embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721', token='abc123'), { - 'Authorization': 'MediaBrowser', - 'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721', - 'Client': 'other', - 'Device': 'empy', - 'DeviceId': 'beets', - 'Version': '0.0.0', - 'X-MediaBrowser-Token': 'abc123' + 'x-emby-authorization': ( + 'MediaBrowser ' + 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' + 'Client="other", ' + 'Device="beets", ' + 'DeviceId="beets", ' + 'Version="0.0.0"' + ), + 'x-mediabrowser-token': 'abc123' } ) @@ -132,12 +152,14 @@ content_type='application/json') headers = { - 'Authorization': 'MediaBrowser', - 'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721', - 'Client': 'other', - 'Device': 'empy', - 'DeviceId': 'beets', - 'Version': '0.0.0' + 'x-emby-authorization': ( + 'MediaBrowser ' + 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' + 'Client="other", ' + 'Device="beets", ' + 'DeviceId="beets", ' + 'Version="0.0.0"' + ) } auth_data = { @@ -147,7 +169,7 @@ } self.assertEqual( - embyupdate.get_token('localhost', 8096, headers, auth_data), + embyupdate.get_token('http://localhost', 8096, headers, auth_data), '4b19180cf02748f7b95c7e8e76562fc8') @responses.activate @@ -196,7 +218,7 @@ status=200, content_type='application/json') - response = embyupdate.get_user('localhost', 8096, 'username') + response = embyupdate.get_user('http://localhost', 8096, 'username') self.assertEqual(response[0]['Id'], '2ec276a2642e54a19b612b9418a8bd3b') diff -Nru beets-1.3.19/test/test_fetchart.py beets-1.4.6/test/test_fetchart.py --- beets-1.3.19/test/test_fetchart.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_fetchart.py 2016-12-17 03:01:23.000000000 +0000 @@ -16,7 +16,7 @@ from __future__ import division, absolute_import, print_function import os -from test._common import unittest +import unittest from test.helper import TestHelper from beets import util diff -Nru beets-1.3.19/test/test_filefilter.py beets-1.4.6/test/test_filefilter.py --- beets-1.3.19/test/test_filefilter.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_filefilter.py 2016-12-17 03:01:23.000000000 +0000 @@ -20,9 +20,9 @@ import os import shutil +import unittest from test import _common -from test._common import unittest from test.helper import capture_log from test.test_importer import ImportHelper from beets import config diff -Nru beets-1.3.19/test/test_files.py beets-1.4.6/test/test_files.py --- beets-1.3.19/test/test_files.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_files.py 2017-10-29 20:27:34.000000000 +0000 @@ -21,12 +21,13 @@ import os import stat from os.path import join +import unittest from test import _common -from test._common import unittest from test._common import item, touch import beets.library from beets import util +from beets.util import MoveOperation class MoveTest(_common.TestCase): @@ -78,11 +79,11 @@ self.assertNotExists(os.path.dirname(old_path)) def test_copy_arrives(self): - self.i.move(copy=True) + self.i.move(operation=MoveOperation.COPY) self.assertExists(self.dest) def test_copy_does_not_depart(self): - self.i.move(copy=True) + self.i.move(operation=MoveOperation.COPY) self.assertExists(self.path) def test_move_changes_path(self): @@ -92,13 +93,13 @@ def test_copy_already_at_destination(self): self.i.move() old_path = self.i.path - self.i.move(copy=True) + self.i.move(operation=MoveOperation.COPY) self.assertEqual(self.i.path, old_path) def test_move_already_at_destination(self): self.i.move() old_path = self.i.path - self.i.move(copy=False) + self.i.move() self.assertEqual(self.i.path, old_path) def test_read_only_file_copied_writable(self): @@ -106,7 +107,7 @@ os.chmod(self.path, 0o444) try: - self.i.move(copy=True) + self.i.move(operation=MoveOperation.COPY) self.assertTrue(os.access(self.i.path, os.W_OK)) finally: # Make everything writable so it can be cleaned up. @@ -126,19 +127,40 @@ @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_arrives(self): - self.i.move(link=True) + self.i.move(operation=MoveOperation.LINK) self.assertExists(self.dest) self.assertTrue(os.path.islink(self.dest)) self.assertEqual(os.readlink(self.dest), self.path) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_does_not_depart(self): - self.i.move(link=True) + self.i.move(operation=MoveOperation.LINK) self.assertExists(self.path) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_changes_path(self): - self.i.move(link=True) + self.i.move(operation=MoveOperation.LINK) + self.assertEqual(self.i.path, util.normpath(self.dest)) + + @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") + def test_hardlink_arrives(self): + self.i.move(operation=MoveOperation.HARDLINK) + self.assertExists(self.dest) + s1 = os.stat(self.path) + s2 = os.stat(self.dest) + self.assertTrue( + (s1[stat.ST_INO], s1[stat.ST_DEV]) == + (s2[stat.ST_INO], s2[stat.ST_DEV]) + ) + + @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") + def test_hardlink_does_not_depart(self): + self.i.move(operation=MoveOperation.HARDLINK) + self.assertExists(self.path) + + @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") + def test_hardlink_changes_path(self): + self.i.move(operation=MoveOperation.HARDLINK) self.assertEqual(self.i.path, util.normpath(self.dest)) @@ -215,7 +237,7 @@ def test_albuminfo_move_copies_file(self): oldpath = self.i.path self.ai.album = u'newAlbumName' - self.ai.move(True) + self.ai.move(operation=MoveOperation.COPY) self.ai.store() self.i.load() @@ -290,7 +312,7 @@ i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) - i2.move(True) + i2.move(operation=MoveOperation.COPY) self.assertEqual(ai.artpath, None) ai.set_art(newart) @@ -306,7 +328,7 @@ i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) - i2.move(True) + i2.move(operation=MoveOperation.COPY) ai.set_art(newart) # Set the art again. @@ -320,7 +342,7 @@ i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) - i2.move(True) + i2.move(operation=MoveOperation.COPY) # Copy the art to the destination. artdest = ai.art_destination(newart) @@ -337,7 +359,7 @@ i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) - i2.move(True) + i2.move(operation=MoveOperation.COPY) # Make a file at the destination. artdest = ai.art_destination(newart) @@ -361,7 +383,7 @@ i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) - i2.move(True) + i2.move(operation=MoveOperation.COPY) ai.set_art(newart) mode = stat.S_IMODE(os.stat(ai.artpath).st_mode) diff -Nru beets-1.3.19/test/test_ftintitle.py beets-1.4.6/test/test_ftintitle.py --- beets-1.3.19/test/test_ftintitle.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_ftintitle.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,7 +17,7 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from test.helper import TestHelper from beetsplug import ftintitle diff -Nru beets-1.3.19/test/test_hidden.py beets-1.4.6/test/test_hidden.py --- beets-1.3.19/test/test_hidden.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_hidden.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,10 +17,11 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest import sys import tempfile from beets.util import hidden +from beets import util import subprocess import errno import ctypes @@ -71,4 +72,12 @@ return with tempfile.NamedTemporaryFile(prefix='.tmp') as f: - self.assertTrue(hidden.is_hidden(f.name)) + fn = util.bytestring_path(f.name) + self.assertTrue(hidden.is_hidden(fn)) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/test/test_hook.py beets-1.4.6/test/test_hook.py --- beets-1.3.19/test/test_hook.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_hook.py 2016-12-17 03:01:23.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2015, Thomas Scholtes. # @@ -16,9 +17,9 @@ import os.path import tempfile +import unittest from test import _common -from test._common import unittest from test.helper import TestHelper from beets import config diff -Nru beets-1.3.19/test/test_ihate.py beets-1.4.6/test/test_ihate.py --- beets-1.3.19/test/test_ihate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_ihate.py 2016-12-17 03:01:23.000000000 +0000 @@ -4,7 +4,7 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from beets import importer from beets.library import Item from beetsplug.ihate import IHatePlugin diff -Nru beets-1.3.19/test/test_importadded.py beets-1.4.6/test/test_importadded.py --- beets-1.3.19/test/test_importadded.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_importadded.py 2017-06-14 23:13:49.000000000 +0000 @@ -18,8 +18,8 @@ """Tests for the `importadded` plugin.""" import os +import unittest -from test._common import unittest from test.test_importer import ImportHelper, AutotagStub from beets import importer from beets import util @@ -93,6 +93,7 @@ self.config['import']['copy'] = False self.config['import']['move'] = False self.config['import']['link'] = False + self.config['import']['hardlink'] = False self.assertAlbumImport() def test_import_album_with_preserved_mtimes(self): @@ -124,7 +125,7 @@ self.assertEqualTimes(album.added, album_added_before) items_added_after = dict((item.path, item.added) for item in album.items()) - for item_path, added_after in items_added_after.iteritems(): + for item_path, added_after in items_added_after.items(): self.assertEqualTimes(items_added_before[item_path], added_after, u"reimport modified Item.added for " + util.displayable_path(item_path)) @@ -162,7 +163,7 @@ # Verify the reimported items items_added_after = dict((item.path, item.added) for item in self.lib.items()) - for item_path, added_after in items_added_after.iteritems(): + for item_path, added_after in items_added_after.items(): self.assertEqualTimes(items_added_before[item_path], added_after, u"reimport modified Item.added for " + util.displayable_path(item_path)) diff -Nru beets-1.3.19/test/test_importer.py beets-1.4.6/test/test_importer.py --- beets-1.3.19/test/test_importer.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/test/test_importer.py 2017-12-16 20:00:33.000000000 +0000 @@ -20,17 +20,18 @@ import os import re import shutil -import StringIO import unicodedata import sys +import stat +from six import StringIO from tempfile import mkstemp from zipfile import ZipFile from tarfile import TarFile -from mock import patch +from mock import patch, Mock +import unittest from test import _common -from test._common import unittest -from beets.util import displayable_path, bytestring_path +from beets.util import displayable_path, bytestring_path, py3_path from test.helper import TestImportSession, TestHelper, has_program, capture_log from beets import importer from beets.importer import albums_in_dir @@ -209,7 +210,8 @@ def _setup_import_session(self, import_dir=None, delete=False, threaded=False, copy=True, singletons=False, - move=False, autotag=True, link=False): + move=False, autotag=True, link=False, + hardlink=False): config['import']['copy'] = copy config['import']['delete'] = delete config['import']['timid'] = True @@ -219,6 +221,7 @@ config['import']['autotag'] = autotag config['import']['resume'] = False config['import']['link'] = link + config['import']['hardlink'] = hardlink self.importer = TestImportSession( self.lib, loghandler=None, query=None, @@ -348,16 +351,38 @@ ) self.assertExists(filename) self.assertTrue(os.path.islink(filename)) - self.assert_equal_path(os.readlink(filename), mediafile.path) + self.assert_equal_path( + util.bytestring_path(os.readlink(filename)), + mediafile.path + ) + + @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") + def test_import_hardlink_arrives(self): + config['import']['hardlink'] = True + self.importer.run() + for mediafile in self.import_media: + filename = os.path.join( + self.libdir, + b'Tag Artist', b'Tag Album', + util.bytestring_path('{0}.mp3'.format(mediafile.title)) + ) + self.assertExists(filename) + s1 = os.stat(mediafile.path) + s2 = os.stat(filename) + self.assertTrue( + (s1[stat.ST_INO], s1[stat.ST_DEV]) == + (s2[stat.ST_INO], s2[stat.ST_DEV]) + ) def create_archive(session): - (handle, path) = mkstemp(dir=session.temp_dir) + (handle, path) = mkstemp(dir=py3_path(session.temp_dir)) os.close(handle) - archive = ZipFile(path, mode='w') - archive.write(os.path.join(_common.RSRC, 'full.mp3'), + archive = ZipFile(py3_path(path), mode='w') + archive.write(os.path.join(_common.RSRC, b'full.mp3'), 'full.mp3') archive.close() + path = bytestring_path(path) return path @@ -410,8 +435,8 @@ def create_archive(self): (handle, path) = mkstemp(dir=self.temp_dir) os.close(handle) - archive = TarFile(path, mode='w') - archive.add(os.path.join(_common.RSRC, 'full.mp3'), + archive = TarFile(py3_path(path), mode='w') + archive.add(os.path.join(_common.RSRC, b'full.mp3'), 'full.mp3') archive.close() return path @@ -421,14 +446,14 @@ class ImportRarTest(ImportZipTest): def create_archive(self): - return os.path.join(_common.RSRC, 'archive.rar') + return os.path.join(_common.RSRC, b'archive.rar') @unittest.skip('Implement me!') class ImportPasswordRarTest(ImportZipTest): def create_archive(self): - return os.path.join(_common.RSRC, 'password.rar') + return os.path.join(_common.RSRC, b'password.rar') class ImportSingletonTest(_common.TestCase, ImportHelper): @@ -518,6 +543,38 @@ self.assertEqual(len(self.lib.items()), 2) self.assertEqual(len(self.lib.albums()), 2) + def test_set_fields(self): + genre = u"\U0001F3B7 Jazz" + collection = u"To Listen" + + config['import']['set_fields'] = { + u'collection': collection, + u'genre': genre + } + + # As-is item import. + self.assertEqual(self.lib.albums().get(), None) + self.importer.add_choice(importer.action.ASIS) + self.importer.run() + + for item in self.lib.items(): + item.load() # TODO: Not sure this is necessary. + self.assertEqual(item.genre, genre) + self.assertEqual(item.collection, collection) + # Remove item from library to test again with APPLY choice. + item.remove() + + # Autotagged. + self.assertEqual(self.lib.albums().get(), None) + self.importer.clear_choices() + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + + for item in self.lib.items(): + item.load() + self.assertEqual(item.genre, genre) + self.assertEqual(item.collection, collection) + class ImportTest(_common.TestCase, ImportHelper): """Test APPLY, ASIS and SKIP choices. @@ -576,6 +633,17 @@ self.assert_file_in_lib( b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') + def test_apply_from_scratch_removes_other_metadata(self): + config['import']['from_scratch'] = True + + for mediafile in self.import_media: + mediafile.genre = u'Tag Genre' + mediafile.save() + + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + self.assertEqual(self.lib.items().get().genre, u'') + def test_apply_with_move_deletes_import(self): config['import']['move'] = True @@ -647,6 +715,38 @@ with self.assertRaises(AttributeError): self.lib.items().get().data_source + def test_set_fields(self): + genre = u"\U0001F3B7 Jazz" + collection = u"To Listen" + + config['import']['set_fields'] = { + u'collection': collection, + u'genre': genre + } + + # As-is album import. + self.assertEqual(self.lib.albums().get(), None) + self.importer.add_choice(importer.action.ASIS) + self.importer.run() + + for album in self.lib.albums(): + album.load() # TODO: Not sure this is necessary. + self.assertEqual(album.genre, genre) + self.assertEqual(album.collection, collection) + # Remove album from library to test again with APPLY choice. + album.remove() + + # Autotagged. + self.assertEqual(self.lib.albums().get(), None) + self.importer.clear_choices() + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + + for album in self.lib.albums(): + album.load() + self.assertEqual(album.genre, genre) + self.assertEqual(album.collection, collection) + class ImportTracksTest(_common.TestCase, ImportHelper): """Test TRACKS and APPLY choice. @@ -1068,7 +1168,7 @@ self.assertFalse(self.items[0].comp) -def test_album_info(): +def test_album_info(*args, **kwargs): """Create an AlbumInfo object for testing. """ track_info = TrackInfo( @@ -1083,9 +1183,10 @@ album_id=u'albumid', artist_id=u'artistid', ) - return album_info + return iter([album_info]) +@patch('beets.autotag.mb.match_album', Mock(side_effect=test_album_info)) class ImportDuplicateAlbumTest(unittest.TestCase, TestHelper, _common.Assertions): @@ -1095,17 +1196,11 @@ # Original album self.add_album_fixture(albumartist=u'artist', album=u'album') - # Create duplicate through autotagger - self.match_album_patcher = patch('beets.autotag.mb.match_album') - self.match_album = self.match_album_patcher.start() - self.match_album.return_value = iter([test_album_info()]) - # Create import session self.importer = self.create_importer() config['import']['autotag'] = True def tearDown(self): - self.match_album_patcher.stop() self.teardown_beets() def test_remove_duplicate_album(self): @@ -1164,6 +1259,12 @@ item = self.lib.items().get() self.assertEqual(item.title, u't\xeftle 0') + def test_merge_duplicate_album(self): + self.importer.default_resolution = self.importer.Resolution.MERGE + self.importer.run() + + self.assertEqual(len(self.lib.albums()), 1) + def test_twice_in_import_dir(self): self.skipTest('write me') @@ -1175,6 +1276,13 @@ return album +def test_track_info(*args, **kwargs): + return iter([TrackInfo( + artist=u'artist', title=u'title', + track_id=u'new trackid', index=0,)]) + + +@patch('beets.autotag.mb.match_track', Mock(side_effect=test_track_info)) class ImportDuplicateSingletonTest(unittest.TestCase, TestHelper, _common.Assertions): @@ -1185,24 +1293,12 @@ self.add_item_fixture(artist=u'artist', title=u'title', mb_trackid='old trackid') - # Create duplicate through autotagger - self.match_track_patcher = patch('beets.autotag.mb.match_track') - self.match_track = self.match_track_patcher.start() - track_info = TrackInfo( - artist=u'artist', - title=u'title', - track_id=u'new trackid', - index=0, - ) - self.match_track.return_value = iter([track_info]) - # Import session self.importer = self.create_importer() config['import']['autotag'] = True config['import']['singletons'] = True def tearDown(self): - self.match_track_patcher.stop() self.teardown_beets() def test_remove_duplicate(self): @@ -1250,14 +1346,14 @@ class TagLogTest(_common.TestCase): def test_tag_log_line(self): - sio = StringIO.StringIO() + sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log('status', 'path') self.assertIn('status path', sio.getvalue()) def test_tag_log_unicode(self): - sio = StringIO.StringIO() + sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log('status', u'caf\xe9') # send unicode @@ -1471,10 +1567,10 @@ """Normalize a path's Unicode combining form according to the platform. """ - path = path.decode('utf8') + path = path.decode('utf-8') norm_form = 'NFD' if sys.platform == 'darwin' else 'NFC' path = unicodedata.normalize(norm_form, path) - return path.encode('utf8') + return path.encode('utf-8') def test_coalesce_nested_album_multiple_subdirs(self): self.create_music() @@ -1702,7 +1798,81 @@ self.assertEqual(logs, [u'No files imported from {0}' .format(displayable_path(self.empty_path))]) +# Helpers for ImportMusicBrainzIdTest. + + +def mocked_get_release_by_id(id_, includes=[], release_status=[], + release_type=[]): + """Mimic musicbrainzngs.get_release_by_id, accepting only a restricted list + of MB ids (ID_RELEASE_0, ID_RELEASE_1). The returned dict differs only in + the release title and artist name, so that ID_RELEASE_0 is a closer match + to the items created by ImportHelper._create_import_dir().""" + # Map IDs to (release title, artist), so the distances are different. + releases = {ImportMusicBrainzIdTest.ID_RELEASE_0: ('VALID_RELEASE_0', + 'TAG ARTIST'), + ImportMusicBrainzIdTest.ID_RELEASE_1: ('VALID_RELEASE_1', + 'DISTANT_MATCH')} + + return { + 'release': { + 'title': releases[id_][0], + 'id': id_, + 'medium-list': [{ + 'track-list': [{ + 'recording': { + 'title': 'foo', + 'id': 'bar', + 'length': 59, + }, + 'position': 9, + 'number': 'A2' + }], + 'position': 5, + }], + 'artist-credit': [{ + 'artist': { + 'name': releases[id_][1], + 'id': 'some-id', + }, + }], + 'release-group': { + 'id': 'another-id', + } + } + } + +def mocked_get_recording_by_id(id_, includes=[], release_status=[], + release_type=[]): + """Mimic musicbrainzngs.get_recording_by_id, accepting only a restricted + list of MB ids (ID_RECORDING_0, ID_RECORDING_1). The returned dict differs + only in the recording title and artist name, so that ID_RECORDING_0 is a + closer match to the items created by ImportHelper._create_import_dir().""" + # Map IDs to (recording title, artist), so the distances are different. + releases = {ImportMusicBrainzIdTest.ID_RECORDING_0: ('VALID_RECORDING_0', + 'TAG ARTIST'), + ImportMusicBrainzIdTest.ID_RECORDING_1: ('VALID_RECORDING_1', + 'DISTANT_MATCH')} + + return { + 'recording': { + 'title': releases[id_][0], + 'id': id_, + 'length': 59, + 'artist-credit': [{ + 'artist': { + 'name': releases[id_][1], + 'id': 'some-id', + }, + }], + } + } + + +@patch('musicbrainzngs.get_recording_by_id', + Mock(side_effect=mocked_get_recording_by_id)) +@patch('musicbrainzngs.get_release_by_id', + Mock(side_effect=mocked_get_release_by_id)) class ImportMusicBrainzIdTest(_common.TestCase, ImportHelper): """Test the --musicbrainzid argument.""" @@ -1717,17 +1887,7 @@ self.setup_beets() self._create_import_dir(1) - # Patch calls to musicbrainzngs. - self.release_patcher = patch('musicbrainzngs.get_release_by_id', - side_effect=mocked_get_release_by_id) - self.recording_patcher = patch('musicbrainzngs.get_recording_by_id', - side_effect=mocked_get_recording_by_id) - self.release_patcher.start() - self.recording_patcher.start() - def tearDown(self): - self.recording_patcher.stop() - self.release_patcher.stop() self.teardown_beets() def test_one_mbid_one_album(self): @@ -1796,76 +1956,6 @@ set([c.info.title for c in task.candidates])) -# Helpers for ImportMusicBrainzIdTest. - - -def mocked_get_release_by_id(id_, includes=[], release_status=[], - release_type=[]): - """Mimic musicbrainzngs.get_release_by_id, accepting only a restricted list - of MB ids (ID_RELEASE_0, ID_RELEASE_1). The returned dict differs only in - the release title and artist name, so that ID_RELEASE_0 is a closer match - to the items created by ImportHelper._create_import_dir().""" - # Map IDs to (release title, artist), so the distances are different. - releases = {ImportMusicBrainzIdTest.ID_RELEASE_0: ('VALID_RELEASE_0', - 'TAG ARTIST'), - ImportMusicBrainzIdTest.ID_RELEASE_1: ('VALID_RELEASE_1', - 'DISTANT_MATCH')} - - return { - 'release': { - 'title': releases[id_][0], - 'id': id_, - 'medium-list': [{ - 'track-list': [{ - 'recording': { - 'title': 'foo', - 'id': 'bar', - 'length': 59, - }, - 'position': 9, - }], - 'position': 5, - }], - 'artist-credit': [{ - 'artist': { - 'name': releases[id_][1], - 'id': 'some-id', - }, - }], - 'release-group': { - 'id': 'another-id', - } - } - } - - -def mocked_get_recording_by_id(id_, includes=[], release_status=[], - release_type=[]): - """Mimic musicbrainzngs.get_recording_by_id, accepting only a restricted - list of MB ids (ID_RECORDING_0, ID_RECORDING_1). The returned dict differs - only in the recording title and artist name, so that ID_RECORDING_0 is a - closer match to the items created by ImportHelper._create_import_dir().""" - # Map IDs to (recording title, artist), so the distances are different. - releases = {ImportMusicBrainzIdTest.ID_RECORDING_0: ('VALID_RECORDING_0', - 'TAG ARTIST'), - ImportMusicBrainzIdTest.ID_RECORDING_1: ('VALID_RECORDING_1', - 'DISTANT_MATCH')} - - return { - 'recording': { - 'title': releases[id_][0], - 'id': id_, - 'length': 59, - 'artist-credit': [{ - 'artist': { - 'name': releases[id_][1], - 'id': 'some-id', - }, - }], - } - } - - def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff -Nru beets-1.3.19/test/test_importfeeds.py beets-1.4.6/test/test_importfeeds.py --- beets-1.3.19/test/test_importfeeds.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_importfeeds.py 2016-12-17 03:01:23.000000000 +0000 @@ -6,8 +6,8 @@ import os.path import tempfile import shutil +import unittest -from test._common import unittest from beets import config from beets.library import Item, Album, Library from beetsplug.importfeeds import ImportFeedsPlugin diff -Nru beets-1.3.19/test/test_info.py beets-1.4.6/test/test_info.py --- beets-1.3.19/test/test_info.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_info.py 2016-12-17 03:01:23.000000000 +0000 @@ -15,7 +15,7 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from test.helper import TestHelper from beets.mediafile import MediaFile @@ -32,9 +32,6 @@ self.unload_plugins() self.teardown_beets() - def run_command(self, *args): - super(InfoTest, self).run_command('info', *args) - def test_path(self): path = self.create_mediafile_fixture() @@ -45,7 +42,7 @@ mediafile.composer = None mediafile.save() - out = self.run_with_output(path) + out = self.run_with_output('info', path) self.assertIn(path, out) self.assertIn('albumartist: AAA', out) self.assertIn('disctitle: DDD', out) @@ -60,7 +57,7 @@ item1.album = 'yyyy' item1.store() - out = self.run_with_output('album:yyyy') + out = self.run_with_output('info', 'album:yyyy') self.assertIn(displayable_path(item1.path), out) self.assertIn(u'album: xxxx', out) @@ -71,7 +68,7 @@ item.album = 'xxxx' item.store() - out = self.run_with_output('--library', 'album:xxxx') + out = self.run_with_output('info', '--library', 'album:xxxx') self.assertIn(displayable_path(item.path), out) self.assertIn(u'album: xxxx', out) @@ -89,7 +86,7 @@ item.store() mediafile.save() - out = self.run_with_output('--summarize', 'album:AAA', path) + out = self.run_with_output('info', '--summarize', 'album:AAA', path) self.assertIn(u'album: AAA', out) self.assertIn(u'tracktotal: 5', out) self.assertIn(u'title: [various]', out) @@ -100,7 +97,7 @@ item.album = 'xxxx' item.store() - out = self.run_with_output('--library', 'album:xxxx', + out = self.run_with_output('info', '--library', 'album:xxxx', '--include-keys', '*lbu*') self.assertIn(displayable_path(item.path), out) self.assertNotIn(u'title:', out) @@ -108,7 +105,7 @@ def test_custom_format(self): self.add_item_fixtures() - out = self.run_with_output('--library', '--format', + out = self.run_with_output('info', '--library', '--format', '$track. $title - $artist ($length)') self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) diff -Nru beets-1.3.19/test/test_ipfs.py beets-1.4.6/test/test_ipfs.py --- beets-1.3.19/test/test_ipfs.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_ipfs.py 2017-06-21 14:26:58.000000000 +0000 @@ -14,31 +14,30 @@ from __future__ import division, absolute_import, print_function -from mock import patch +from mock import patch, Mock from beets import library -from beets.util import bytestring_path +from beets.util import bytestring_path, _fsencoding from beetsplug.ipfs import IPFSPlugin +import unittest +import os + from test import _common -from test._common import unittest from test.helper import TestHelper -import os +@patch('beets.util.command_output', Mock()) class IPFSPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('ipfs') - self.patcher = patch('beets.util.command_output') - self.command_output = self.patcher.start() self.lib = library.Library(":memory:") def tearDown(self): self.unload_plugins() self.teardown_beets() - self.patcher.stop() def test_stored_hashes(self): test_album = self.mk_test_album() @@ -51,9 +50,11 @@ for check_item in added_album.items(): try: if check_item.ipfs: + ipfs_item = os.path.basename(want_item.path).decode( + _fsencoding(), + ) want_path = '/ipfs/{0}/{1}'.format(test_album.ipfs, - os.path.basename( - want_item.path)) + ipfs_item) want_path = bytestring_path(want_path) self.assertEqual(check_item.path, want_path) self.assertEqual(check_item.ipfs, want_item.ipfs) @@ -66,17 +67,17 @@ def mk_test_album(self): items = [_common.item() for _ in range(3)] items[0].title = 'foo bar' - items[0].artist = 'one' + items[0].artist = '1one' items[0].album = 'baz' items[0].year = 2001 items[0].comp = True items[1].title = 'baz qux' - items[1].artist = 'two' + items[1].artist = '2two' items[1].album = 'baz' items[1].year = 2002 items[1].comp = True items[2].title = 'beets 4 eva' - items[2].artist = 'three' + items[2].artist = '3three' items[2].album = 'foo' items[2].year = 2003 items[2].comp = False diff -Nru beets-1.3.19/test/test_keyfinder.py beets-1.4.6/test/test_keyfinder.py --- beets-1.3.19/test/test_keyfinder.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_keyfinder.py 2016-12-17 03:01:23.000000000 +0000 @@ -16,63 +16,61 @@ from __future__ import division, absolute_import, print_function from mock import patch -from test._common import unittest +import unittest from test.helper import TestHelper from beets.library import Item from beets import util +@patch('beets.util.command_output') class KeyFinderTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('keyfinder') - self.patcher = patch('beets.util.command_output') - self.command_output = self.patcher.start() def tearDown(self): self.teardown_beets() self.unload_plugins() - self.patcher.stop() - def test_add_key(self): + def test_add_key(self, command_output): item = Item(path='/file') item.add(self.lib) - self.command_output.return_value = 'dbm' + command_output.return_value = 'dbm' self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'C#m') - self.command_output.assert_called_with( + command_output.assert_called_with( ['KeyFinder', '-f', util.syspath(item.path)]) - def test_add_key_on_import(self): - self.command_output.return_value = 'dbm' + def test_add_key_on_import(self, command_output): + command_output.return_value = 'dbm' importer = self.create_importer() importer.run() item = self.lib.items().get() self.assertEqual(item['initial_key'], 'C#m') - def test_force_overwrite(self): + def test_force_overwrite(self, command_output): self.config['keyfinder']['overwrite'] = True item = Item(path='/file', initial_key='F') item.add(self.lib) - self.command_output.return_value = 'C#m' + command_output.return_value = 'C#m' self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'C#m') - def test_do_not_overwrite(self): + def test_do_not_overwrite(self, command_output): item = Item(path='/file', initial_key='F') item.add(self.lib) - self.command_output.return_value = 'dbm' + command_output.return_value = 'dbm' self.run_command('keyfinder') item.load() diff -Nru beets-1.3.19/test/test_lastgenre.py beets-1.4.6/test/test_lastgenre.py --- beets-1.3.19/test/test_lastgenre.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_lastgenre.py 2017-06-20 19:15:08.000000000 +0000 @@ -17,14 +17,15 @@ from __future__ import division, absolute_import, print_function +import unittest from mock import Mock from test import _common -from test._common import unittest from beetsplug import lastgenre from beets import config from test.helper import TestHelper +import six class LastGenrePluginTest(unittest.TestCase, TestHelper): @@ -38,11 +39,11 @@ def _setup_config(self, whitelist=False, canonical=False, count=1): config['lastgenre']['canonical'] = canonical config['lastgenre']['count'] = count - if isinstance(whitelist, (bool, basestring)): + if isinstance(whitelist, (bool, six.string_types)): # Filename, default, or disabled. config['lastgenre']['whitelist'] = whitelist self.plugin.setup() - if not isinstance(whitelist, (bool, basestring)): + if not isinstance(whitelist, (bool, six.string_types)): # Explicit list of genres. self.plugin.whitelist = whitelist @@ -212,6 +213,18 @@ self.assertEqual(res, (config['lastgenre']['fallback'].get(), u'fallback')) + def test_sort_by_depth(self): + self._setup_config(canonical=True) + # Normal case. + tags = ('electronic', 'ambient', 'post-rock', 'downtempo') + res = self.plugin._sort_by_depth(tags) + self.assertEqual( + res, ['post-rock', 'downtempo', 'ambient', 'electronic']) + # Non-canonical tag ('chillout') present. + tags = ('electronic', 'ambient', 'chillout') + res = self.plugin._sort_by_depth(tags) + self.assertEqual(res, ['ambient', 'electronic']) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff -Nru beets-1.3.19/test/test_library.py beets-1.4.6/test/test_library.py --- beets-1.3.19/test/test_library.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_library.py 2017-01-21 04:54:30.000000000 +0000 @@ -25,9 +25,9 @@ import unicodedata import sys import time +import unittest from test import _common -from test._common import unittest from test._common import item import beets.library import beets.mediafile @@ -38,6 +38,7 @@ from beets.mediafile import MediaFile from beets.util import syspath, bytestring_path from test.helper import TestHelper +import six # Shortcut to path normalization. np = util.normpath @@ -391,7 +392,7 @@ def test_unicode_normalized_nfc_on_linux(self): instr = unicodedata.normalize('NFD', u'caf\xe9') self.lib.path_formats = [(u'default', instr)] - dest = self.i.destination(platform='linux2', fragment=True) + dest = self.i.destination(platform='linux', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFC', instr)) def test_non_mbcs_characters_on_windows(self): @@ -403,14 +404,14 @@ p = self.i.destination() self.assertFalse(b'?' in p) # We use UTF-8 to encode Windows paths now. - self.assertTrue(u'h\u0259d'.encode('utf8') in p) + self.assertTrue(u'h\u0259d'.encode('utf-8') in p) finally: sys.getfilesystemencoding = oldfunc def test_unicode_extension_in_fragment(self): self.lib.path_formats = [(u'default', u'foo')] self.i.path = util.bytestring_path(u'bar.caf\xe9') - dest = self.i.destination(platform='linux2', fragment=True) + dest = self.i.destination(platform='linux', fragment=True) self.assertEqual(dest, u'foo.caf\xe9') def test_asciify_and_replace(self): @@ -421,6 +422,13 @@ self.i.title = u'\u201c\u00f6\u2014\u00cf\u201d' self.assertEqual(self.i.destination(), np('lib/qo--Iq')) + def test_asciify_character_expanding_to_slash(self): + config['asciify_paths'] = True + self.lib.directory = b'lib' + self.lib.path_formats = [(u'default', u'$title')] + self.i.title = u'ab\xa2\xbdd' + self.assertEqual(self.i.destination(), np('lib/abC_1_2d')) + def test_destination_with_replacements(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'a'), u'e')] @@ -581,6 +589,10 @@ self._setf(u'%title{$title}') self._assert_dest(b'/base/The Title') + def test_asciify_variable(self): + self._setf(u'%asciify{ab\xa2\xbdd}') + self._assert_dest(b'/base/abC_1_2d') + def test_left_variable(self): self._setf(u'%left{$title, 3}') self._assert_dest(b'/base/the') @@ -701,8 +713,8 @@ album2.year = 2001 album2.store() - self._assert_dest(b'/base/foo 1/the title', self.i1) - self._assert_dest(b'/base/foo 2/the title', self.i2) + self._assert_dest(b'/base/foo [1]/the title', self.i1) + self._assert_dest(b'/base/foo [2]/the title', self.i2) def test_unique_falls_back_to_second_distinguishing_field(self): self._setf(u'foo%aunique{albumartist album,month year}/$title') @@ -718,6 +730,24 @@ self._setf(u'foo%aunique{albumartist album,albumtype}/$title') self._assert_dest(b'/base/foo [foo_bar]/the title', self.i1) + def test_drop_empty_disambig_string(self): + album1 = self.lib.get_album(self.i1) + album1.albumdisambig = None + album2 = self.lib.get_album(self.i2) + album2.albumdisambig = u'foo' + album1.store() + album2.store() + self._setf(u'foo%aunique{albumartist album,albumdisambig}/$title') + self._assert_dest(b'/base/foo/the title', self.i1) + + def test_change_brackets(self): + self._setf(u'foo%aunique{albumartist album,year,()}/$title') + self._assert_dest(b'/base/foo (2001)/the title', self.i1) + + def test_remove_brackets(self): + self._setf(u'foo%aunique{albumartist album,year,}/$title') + self._assert_dest(b'/base/foo 2001/the title', self.i1) + class PluginDestinationTest(_common.TestCase): def setUp(self): @@ -922,7 +952,7 @@ self.assertTrue(isinstance(i.path, bytes)) def test_special_chars_preserved_in_database(self): - path = u'b\xe1r'.encode('utf8') + path = u'b\xe1r'.encode('utf-8') self.i.path = path self.i.store() i = list(self.lib.items())[0] @@ -930,7 +960,7 @@ def test_special_char_path_added_to_database(self): self.i.remove() - path = u'b\xe1r'.encode('utf8') + path = u'b\xe1r'.encode('utf-8') i = item() i.path = path self.lib.add(i) @@ -964,7 +994,7 @@ def test_sanitize_path_returns_unicode(self): path = u'b\xe1r?' new_path = util.sanitize_path(path) - self.assertTrue(isinstance(new_path, unicode)) + self.assertTrue(isinstance(new_path, six.text_type)) def test_unicode_artpath_becomes_bytestring(self): alb = self.lib.add_album([self.i]) @@ -1051,20 +1081,21 @@ album.tagada = u'togodo' self.assertEqual(u"{0}".format(album), u"foö bar") self.assertEqual(u"{0:$tagada}".format(album), u"togodo") - self.assertEqual(unicode(album), u"foö bar") + self.assertEqual(six.text_type(album), u"foö bar") self.assertEqual(bytes(album), b"fo\xc3\xb6 bar") - config['format_item'] = 'bar $foo' + config['format_item'] = u'bar $foo' item = beets.library.Item() item.foo = u'bar' item.tagada = u'togodo' - self.assertEqual("{0}".format(item), u"bar bar") - self.assertEqual("{0:$tagada}".format(item), u"togodo") + self.assertEqual(u"{0}".format(item), u"bar bar") + self.assertEqual(u"{0:$tagada}".format(item), u"togodo") class UnicodePathTest(_common.LibTestCase): def test_unicode_path(self): - self.i.path = os.path.join(_common.RSRC, u'unicode\u2019d.mp3') + self.i.path = os.path.join(_common.RSRC, + u'unicode\u2019d.mp3'.encode('utf-8')) # If there are any problems with unicode paths, we will raise # here and fail. self.i.read() @@ -1102,18 +1133,20 @@ shutil.copy(syspath(item.path), syspath(custom_path)) item['artist'] = 'new artist' - self.assertNotEqual(MediaFile(custom_path).artist, 'new artist') - self.assertNotEqual(MediaFile(item.path).artist, 'new artist') + self.assertNotEqual(MediaFile(syspath(custom_path)).artist, + 'new artist') + self.assertNotEqual(MediaFile(syspath(item.path)).artist, + 'new artist') item.write(custom_path) - self.assertEqual(MediaFile(custom_path).artist, 'new artist') - self.assertNotEqual(MediaFile(item.path).artist, 'new artist') + self.assertEqual(MediaFile(syspath(custom_path)).artist, 'new artist') + self.assertNotEqual(MediaFile(syspath(item.path)).artist, 'new artist') def test_write_custom_tags(self): item = self.add_item_fixture(artist='old artist') item.write(tags={'artist': 'new artist'}) self.assertNotEqual(item.artist, 'new artist') - self.assertEqual(MediaFile(item.path).artist, 'new artist') + self.assertEqual(MediaFile(syspath(item.path)).artist, 'new artist') def test_write_date_field(self): # Since `date` is not a MediaField, this should do nothing. @@ -1121,7 +1154,7 @@ clean_year = item.year item.date = u'foo' item.write() - self.assertEqual(MediaFile(item.path).year, clean_year) + self.assertEqual(MediaFile(syspath(item.path)).year, clean_year) class ItemReadTest(unittest.TestCase): @@ -1174,7 +1207,8 @@ t = beets.library.DateType() # format - time_local = time.strftime(beets.config['time_format'].get(unicode), + time_format = beets.config['time_format'].as_str() + time_local = time.strftime(time_format, time.localtime(123456789)) self.assertEqual(time_local, t.format(123456789)) # parse diff -Nru beets-1.3.19/test/test_logging.py beets-1.4.6/test/test_logging.py --- beets-1.3.19/test/test_logging.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_logging.py 2017-06-14 23:13:49.000000000 +0000 @@ -6,14 +6,16 @@ import sys import threading import logging as log -from StringIO import StringIO +from six import StringIO +import unittest import beets.logging as blog from beets import plugins, ui import beetsplug from test import _common -from test._common import unittest, TestCase +from test._common import TestCase from test import helper +import six class LoggingTest(TestCase): @@ -218,7 +220,7 @@ def check_dp_exc(): if dp.exc_info: - raise dp.exc_info[1], None, dp.exc_info[2] + six.reraise(dp.exc_info[1], None, dp.exc_info[2]) try: dp.lock1.acquire() @@ -255,7 +257,7 @@ t2.join(.1) self.assertFalse(t2.is_alive()) - except: + except Exception: print(u"Alive threads:", threading.enumerate()) if dp.lock1.locked(): print(u"Releasing lock1 after exception in test") diff -Nru beets-1.3.19/test/test_lyrics.py beets-1.4.6/test/test_lyrics.py --- beets-1.3.19/test/test_lyrics.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_lyrics.py 2017-08-11 18:33:37.000000000 +0000 @@ -15,20 +15,25 @@ """Tests for the 'lyrics' plugin.""" -from __future__ import division, absolute_import, print_function +from __future__ import absolute_import, division, print_function import os -from test import _common -import sys import re +import six +import sys +import unittest -from mock import MagicMock +from mock import patch +from test import _common -from test._common import unittest -from beetsplug import lyrics -from beets.library import Item -from beets.util import confit, bytestring_path from beets import logging +from beets.library import Item +from beets.util import bytestring_path, confit + +from beetsplug import lyrics + +from mock import MagicMock + log = logging.getLogger('beets.test_lyrics') raw_backend = lyrics.Backend({}, log) @@ -36,8 +41,9 @@ class LyricsPluginTest(unittest.TestCase): + def setUp(self): - """Set up configuration""" + """Set up configuration.""" lyrics.LyricsPlugin() def test_search_artist(self): @@ -83,6 +89,10 @@ self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) + item = Item(artist='Alice and Bob', title='song') + self.assertEqual(('Alice and Bob', ['song']), + list(lyrics.search_pairs(item))[0]) + def test_search_pairs_multi_titles(self): item = Item(title='1 / 2', artist='A') self.assertIn(('A', ['1 / 2']), lyrics.search_pairs(item)) @@ -117,6 +127,10 @@ self.assertNotIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song and B']), lyrics.search_pairs(item)) + item = Item(title='Song: B', artist='A') + self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) + self.assertIn(('A', ['Song: B']), lyrics.search_pairs(item)) + def test_remove_credits(self): self.assertEqual( lyrics.remove_credits("""It's close to midnight @@ -185,16 +199,8 @@ return fn -def check_lyrics_fetched(): - """Return True if lyrics_download_samples.py has been runned and lyrics - pages are present in resources directory""" - lyrics_dirs = len([d for d in os.listdir(LYRICS_ROOT_DIR) if - os.path.isdir(os.path.join(LYRICS_ROOT_DIR, d))]) - # example.com is the only lyrics dir added to repo - return lyrics_dirs > 1 - - class MockFetchUrl(object): + def __init__(self, pathval='fetched_path'): self.pathval = pathval self.fetched = None @@ -208,174 +214,172 @@ def is_lyrics_content_ok(title, text): - """Compare lyrics text to expected lyrics for given title""" - - keywords = LYRICS_TEXTS[google.slugify(title)] - return all(x in text.lower() for x in keywords) + """Compare lyrics text to expected lyrics for given title.""" + if not text: + return + keywords = set(LYRICS_TEXTS[google.slugify(title)].split()) + words = set(x.strip(".?, ") for x in text.lower().split()) + return keywords <= words LYRICS_ROOT_DIR = os.path.join(_common.RSRC, b'lyrics') LYRICS_TEXTS = confit.load_yaml(os.path.join(_common.RSRC, b'lyricstext.yaml')) -DEFAULT_SONG = dict(artist=u'The Beatles', title=u'Lady Madonna') - -DEFAULT_SOURCES = [ - dict(DEFAULT_SONG, url=u'http://lyrics.wikia.com/', - path=u'The_Beatles:Lady_Madonna'), - dict(artist=u'Santana', title=u'Black magic woman', - url='http://www.lyrics.com/', - path=u'black-magic-woman-lyrics-santana.html'), - dict(DEFAULT_SONG, url='https://www.musixmatch.com/', - path=u'lyrics/The-Beatles/Lady-Madonna'), -] - -# Every source entered in default beets google custom search engine -# must be listed below. -# Use default query when possible, or override artist and title fields -# if website don't have lyrics for default query. -GOOGLE_SOURCES = [ - dict(DEFAULT_SONG, - url=u'http://www.absolutelyrics.com', - path=u'/lyrics/view/the_beatles/lady_madonna'), - dict(DEFAULT_SONG, - url=u'http://www.azlyrics.com', - path=u'/lyrics/beatles/ladymadonna.html'), - dict(DEFAULT_SONG, - url=u'http://www.chartlyrics.com', - path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'), - dict(DEFAULT_SONG, - url=u'http://www.elyricsworld.com', - path=u'/lady_madonna_lyrics_beatles.html'), - dict(url=u'http://www.lacoccinelle.net', - artist=u'Jacques Brel', title=u"Amsterdam", - path=u'/paroles-officielles/275679.html'), - dict(DEFAULT_SONG, - url=u'http://letras.mus.br/', path=u'the-beatles/275/'), - dict(DEFAULT_SONG, - url='http://www.lyricsmania.com/', - path='lady_madonna_lyrics_the_beatles.html'), - dict(artist=u'Santana', title=u'Black magic woman', - url='http://www.lyrics.com/', - path=u'black-magic-woman-lyrics-santana.html'), - dict(DEFAULT_SONG, url=u'http://lyrics.wikia.com/', - path=u'The_Beatles:Lady_Madonna'), - dict(DEFAULT_SONG, - url=u'http://www.lyrics.net', path=u'/lyric/19110224'), - dict(DEFAULT_SONG, - url=u'http://www.lyricsmode.com', - path=u'/lyrics/b/beatles/lady_madonna.html'), - dict(url=u'http://www.lyricsontop.com', - artist=u'Amy Winehouse', title=u"Jazz'n'blues", - path=u'/amy-winehouse-songs/jazz-n-blues-lyrics.html'), - dict(DEFAULT_SONG, - url='http://www.metrolyrics.com/', - path='lady-madonna-lyrics-beatles.html'), - dict(url='http://www.musica.com/', path='letras.asp?letra=2738', - artist=u'Santana', title=u'Black magic woman'), - dict(DEFAULT_SONG, - url=u'http://www.onelyrics.net/', - artist=u'Ben & Ellen Harper', title=u'City of dreams', - path='ben-ellen-harper-city-of-dreams-lyrics'), - dict(url=u'http://www.paroles.net/', - artist=u'Lilly Wood & the prick', title=u"Hey it's ok", - path=u'lilly-wood-the-prick/paroles-hey-it-s-ok'), - dict(DEFAULT_SONG, - url='http://www.releaselyrics.com', - path=u'/346e/the-beatles-lady-madonna-(love-version)/'), - dict(DEFAULT_SONG, - url=u'http://www.smartlyrics.com', - path=u'/Song18148-The-Beatles-Lady-Madonna-lyrics.aspx'), - dict(DEFAULT_SONG, - url='http://www.songlyrics.com', - path=u'/the-beatles/lady-madonna-lyrics'), - dict(DEFAULT_SONG, - url=u'http://www.stlyrics.com', - path=u'/songs/r/richiehavens48961/ladymadonna2069109.html'), - dict(DEFAULT_SONG, - url=u'http://www.sweetslyrics.com', - path=u'/761696.The%20Beatles%20-%20Lady%20Madonna.html') -] -class LyricsGooglePluginTest(unittest.TestCase): - """Test scraping heuristics on a fake html page. - Or run lyrics_download_samples.py first to check that beets google - custom search engine sources are correctly scraped. - """ - source = dict(url=u'http://www.example.com', artist=u'John Doe', - title=u'Beets song', path=u'/lyrics/beetssong') +class LyricsGoogleBaseTest(unittest.TestCase): def setUp(self): - """Set up configuration""" + """Set up configuration.""" try: __import__('bs4') except ImportError: self.skipTest('Beautiful Soup 4 not available') if sys.version_info[:3] < (2, 7, 3): self.skipTest("Python's built-in HTML parser is not good enough") - lyrics.LyricsPlugin() - raw_backend.fetch_url = MockFetchUrl() - def test_mocked_source_ok(self): - """Test that lyrics of the mocked page are correctly scraped""" - url = self.source['url'] + self.source['path'] - if os.path.isfile(url_to_filename(url)): - res = lyrics.scrape_lyrics_from_html(raw_backend.fetch_url(url)) - self.assertTrue(google.is_lyrics(res), url) - self.assertTrue(is_lyrics_content_ok(self.source['title'], res), - url) +class LyricsPluginSourcesTest(LyricsGoogleBaseTest): + """Check that beets google custom search engine sources are correctly + scraped. + """ + + DEFAULT_SONG = dict(artist=u'The Beatles', title=u'Lady Madonna') + + DEFAULT_SOURCES = [ + dict(DEFAULT_SONG, backend=lyrics.LyricsWiki), + dict(artist=u'Santana', title=u'Black magic woman', + backend=lyrics.MusiXmatch), + dict(DEFAULT_SONG, backend=lyrics.Genius), + ] + + GOOGLE_SOURCES = [ + dict(DEFAULT_SONG, + url=u'http://www.absolutelyrics.com', + path=u'/lyrics/view/the_beatles/lady_madonna'), + dict(DEFAULT_SONG, + url=u'http://www.azlyrics.com', + path=u'/lyrics/beatles/ladymadonna.html'), + dict(DEFAULT_SONG, + url=u'http://www.chartlyrics.com', + path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'), + dict(DEFAULT_SONG, + url=u'http://www.elyricsworld.com', + path=u'/lady_madonna_lyrics_beatles.html'), + dict(url=u'http://www.lacoccinelle.net', + artist=u'Jacques Brel', title=u"Amsterdam", + path=u'/paroles-officielles/275679.html'), + dict(DEFAULT_SONG, + url=u'http://letras.mus.br/', path=u'the-beatles/275/'), + dict(DEFAULT_SONG, + url='http://www.lyricsmania.com/', + path='lady_madonna_lyrics_the_beatles.html'), + dict(DEFAULT_SONG, url=u'http://lyrics.wikia.com/', + path=u'The_Beatles:Lady_Madonna'), + dict(DEFAULT_SONG, + url=u'http://www.lyricsmode.com', + path=u'/lyrics/b/beatles/lady_madonna.html'), + dict(url=u'http://www.lyricsontop.com', + artist=u'Amy Winehouse', title=u"Jazz'n'blues", + path=u'/amy-winehouse-songs/jazz-n-blues-lyrics.html'), + dict(DEFAULT_SONG, + url='http://www.metrolyrics.com/', + path='lady-madonna-lyrics-beatles.html'), + dict(url='http://www.musica.com/', path='letras.asp?letra=2738', + artist=u'Santana', title=u'Black magic woman'), + dict(url=u'http://www.paroles.net/', + artist=u'Lilly Wood & the prick', title=u"Hey it's ok", + path=u'lilly-wood-the-prick/paroles-hey-it-s-ok'), + dict(DEFAULT_SONG, + url='http://www.songlyrics.com', + path=u'/the-beatles/lady-madonna-lyrics'), + dict(DEFAULT_SONG, + url=u'http://www.sweetslyrics.com', + path=u'/761696.The%20Beatles%20-%20Lady%20Madonna.html') + ] + + def setUp(self): + LyricsGoogleBaseTest.setUp(self) + self.plugin = lyrics.LyricsPlugin() + + @unittest.skipUnless(os.environ.get( + 'BEETS_TEST_LYRICS_SOURCES', '0') == '1', + 'lyrics sources testing not enabled') + def test_backend_sources_ok(self): + """Test default backends with songs known to exist in respective databases. + """ + errors = [] + for s in self.DEFAULT_SOURCES: + res = s['backend'](self.plugin.config, self.plugin._log).fetch( + s['artist'], s['title']) + if not is_lyrics_content_ok(s['title'], res): + errors.append(s['backend'].__name__) + self.assertFalse(errors) + + @unittest.skipUnless(os.environ.get( + 'BEETS_TEST_LYRICS_SOURCES', '0') == '1', + 'lyrics sources testing not enabled') def test_google_sources_ok(self): """Test if lyrics present on websites registered in beets google custom - search engine are correctly scraped.""" - if not check_lyrics_fetched(): - self.skipTest("Run lyrics_download_samples.py script first.") - for s in GOOGLE_SOURCES: - url = s['url'] + s['path'] - if os.path.isfile(url_to_filename(url)): - res = lyrics.scrape_lyrics_from_html( - raw_backend.fetch_url(url)) - self.assertTrue(google.is_lyrics(res), url) - self.assertTrue(is_lyrics_content_ok(s['title'], res), url) - - def test_default_ok(self): - """Test default engines with the default query""" - if not check_lyrics_fetched(): - self.skipTest("Run lyrics_download_samples.py script first.") - for (source, s) in zip([lyrics.LyricsWiki, - lyrics.LyricsCom, - lyrics.MusiXmatch], DEFAULT_SOURCES): + search engine are correctly scraped. + """ + for s in self.GOOGLE_SOURCES: url = s['url'] + s['path'] - if os.path.isfile(url_to_filename(url)): - res = source({}, log).fetch(s['artist'], s['title']) - self.assertTrue(google.is_lyrics(res), url) - self.assertTrue(is_lyrics_content_ok(s['title'], res), url) + res = lyrics.scrape_lyrics_from_html( + raw_backend.fetch_url(url)) + self.assertTrue(google.is_lyrics(res), url) + self.assertTrue(is_lyrics_content_ok(s['title'], res), url) + + +class LyricsGooglePluginMachineryTest(LyricsGoogleBaseTest): + """Test scraping heuristics on a fake html page. + """ + source = dict(url=u'http://www.example.com', artist=u'John Doe', + title=u'Beets song', path=u'/lyrics/beetssong') + + def setUp(self): + """Set up configuration""" + LyricsGoogleBaseTest.setUp(self) + self.plugin = lyrics.LyricsPlugin() + + @patch.object(lyrics.Backend, 'fetch_url', MockFetchUrl()) + def test_mocked_source_ok(self): + """Test that lyrics of the mocked page are correctly scraped""" + url = self.source['url'] + self.source['path'] + res = lyrics.scrape_lyrics_from_html(raw_backend.fetch_url(url)) + self.assertTrue(google.is_lyrics(res), url) + self.assertTrue(is_lyrics_content_ok(self.source['title'], res), + url) + + @patch.object(lyrics.Backend, 'fetch_url', MockFetchUrl()) def test_is_page_candidate_exact_match(self): """Test matching html page title with song infos -- when song infos are - present in the title.""" + present in the title. + """ from bs4 import SoupStrainer, BeautifulSoup s = self.source - url = unicode(s['url'] + s['path']) + url = six.text_type(s['url'] + s['path']) html = raw_backend.fetch_url(url) soup = BeautifulSoup(html, "html.parser", parse_only=SoupStrainer('title')) - self.assertEqual(google.is_page_candidate(url, soup.title.string, - s['title'], s['artist']), - True, url) + self.assertEqual( + google.is_page_candidate(url, soup.title.string, + s['title'], s['artist']), True, url) def test_is_page_candidate_fuzzy_match(self): """Test matching html page title with song infos -- when song infos are - not present in the title.""" + not present in the title. + """ s = self.source url = s['url'] + s['path'] url_title = u'example.com | Beats song by John doe' # very small diffs (typo) are ok eg 'beats' vs 'beets' with same artist self.assertEqual(google.is_page_candidate(url, url_title, s['title'], - s['artist']), True, url) + s['artist']), True, url) # reject different title url_title = u'example.com | seets bong lyrics by John doe' self.assertEqual(google.is_page_candidate(url, url_title, s['title'], - s['artist']), False, url) + s['artist']), False, url) def test_is_page_candidate_special_chars(self): """Ensure that `is_page_candidate` doesn't crash when the artist @@ -389,6 +393,26 @@ google.is_page_candidate(url, url_title, s['title'], u'Sunn O)))') +class SlugTests(unittest.TestCase): + + def test_slug(self): + # plain ascii passthrough + text = u"test" + self.assertEqual(lyrics.slug(text), 'test') + # german unicode and capitals + text = u"Mørdag" + self.assertEqual(lyrics.slug(text), 'mordag') + # more accents and quotes + text = u"l'été c'est fait pour jouer" + self.assertEqual(lyrics.slug(text), 'l-ete-c-est-fait-pour-jouer') + # accents, parens and spaces + text = u"\xe7afe au lait (boisson)" + self.assertEqual(lyrics.slug(text), 'cafe-au-lait-boisson') + text = u"Multiple spaces -- and symbols! -- merged" + self.assertEqual(lyrics.slug(text), + 'multiple-spaces-and-symbols-merged') + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff -Nru beets-1.3.19/test/test_mb.py beets-1.4.6/test/test_mb.py --- beets-1.3.19/test/test_mb.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_mb.py 2017-01-11 19:15:59.000000000 +0000 @@ -18,9 +18,10 @@ from __future__ import division, absolute_import, print_function from test import _common -from test._common import unittest from beets.autotag import mb from beets import config + +import unittest import mock @@ -67,6 +68,7 @@ track = { 'recording': recording, 'position': i + 1, + 'number': 'A1', } if track_length: # Track lengths are distinct from recording lengths. @@ -181,6 +183,7 @@ second_track_list = [{ 'recording': tracks[1], 'position': '1', + 'number': 'A1', }] release['medium-list'].append({ 'position': '2', @@ -453,6 +456,7 @@ 'length': 42, }, 'position': 9, + 'number': 'A1', }], 'position': 5, }], diff -Nru beets-1.3.19/test/test_mbsubmit.py beets-1.4.6/test/test_mbsubmit.py --- beets-1.3.19/test/test_mbsubmit.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_mbsubmit.py 2016-12-17 03:01:23.000000000 +0000 @@ -15,7 +15,7 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from test.helper import capture_stdout, control_stdin, TestHelper from test.test_importer import ImportHelper, AutotagStub from test.test_ui_importer import TerminalImportSessionSetup diff -Nru beets-1.3.19/test/test_mbsync.py beets-1.4.6/test/test_mbsync.py --- beets-1.3.19/test/test_mbsync.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_mbsync.py 2016-12-17 03:01:23.000000000 +0000 @@ -15,9 +15,9 @@ from __future__ import division, absolute_import, print_function +import unittest from mock import patch -from test._common import unittest from test.helper import TestHelper,\ generate_album_info, \ generate_track_info, \ @@ -100,8 +100,8 @@ self.assertEqual(e, logs[0]) # restore the config - config['format_item'] = '$artist - $album - $title' - config['format_album'] = '$albumartist - $album' + config['format_item'] = u'$artist - $album - $title' + config['format_album'] = u'$albumartist - $album' # Test singleton with no mb_trackid. # The default singleton format includes $artist and $album diff -Nru beets-1.3.19/test/test_mediafile_edge.py beets-1.4.6/test/test_mediafile_edge.py --- beets-1.3.19/test/test_mediafile_edge.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_mediafile_edge.py 2017-11-25 22:56:53.000000000 +0000 @@ -19,16 +19,16 @@ import os import shutil +import unittest +import mutagen.id3 from test import _common -from test._common import unittest -from test.helper import TestHelper -from beets.util import bytestring_path -import beets.mediafile +from beets import mediafile +import six -_sc = beets.mediafile._safe_cast +_sc = mediafile._safe_cast class EdgeTest(unittest.TestCase): @@ -36,7 +36,7 @@ # Some files have an ID3 frame that has a list with no elements. # This is very hard to produce, so this is just the first 8192 # bytes of a file found "in the wild". - emptylist = beets.mediafile.MediaFile( + emptylist = mediafile.MediaFile( os.path.join(_common.RSRC, b'emptylist.mp3') ) genre = emptylist.genre @@ -45,7 +45,7 @@ def test_release_time_with_space(self): # Ensures that release times delimited by spaces are ignored. # Amie Street produces such files. - space_time = beets.mediafile.MediaFile( + space_time = mediafile.MediaFile( os.path.join(_common.RSRC, b'space_time.mp3') ) self.assertEqual(space_time.year, 2009) @@ -55,7 +55,7 @@ def test_release_time_with_t(self): # Ensures that release times delimited by Ts are ignored. # The iTunes Store produces such files. - t_time = beets.mediafile.MediaFile( + t_time = mediafile.MediaFile( os.path.join(_common.RSRC, b't_time.m4a') ) self.assertEqual(t_time.year, 1987) @@ -65,20 +65,20 @@ def test_tempo_with_bpm(self): # Some files have a string like "128 BPM" in the tempo field # rather than just a number. - f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, b'bpm.mp3')) + f = mediafile.MediaFile(os.path.join(_common.RSRC, b'bpm.mp3')) self.assertEqual(f.bpm, 128) def test_discc_alternate_field(self): # Different taggers use different vorbis comments to reflect # the disc and disc count fields: ensure that the alternative # style works. - f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, b'discc.ogg')) + f = mediafile.MediaFile(os.path.join(_common.RSRC, b'discc.ogg')) self.assertEqual(f.disc, 4) self.assertEqual(f.disctotal, 5) def test_old_ape_version_bitrate(self): media_file = os.path.join(_common.RSRC, b'oldape.ape') - f = beets.mediafile.MediaFile(media_file) + f = mediafile.MediaFile(media_file) self.assertEqual(f.bitrate, 0) def test_only_magic_bytes_jpeg(self): @@ -86,17 +86,17 @@ # such aren't recognized by imghdr. Ensure that this still works thanks # to our own follow up mimetype detection based on # https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12 - f = open(os.path.join(_common.RSRC, b'only-magic-bytes.jpg'), 'rb') - jpg_data = f.read() + magic_bytes_file = os.path.join(_common.RSRC, b'only-magic-bytes.jpg') + with open(magic_bytes_file, 'rb') as f: + jpg_data = f.read() self.assertEqual( - beets.mediafile._image_mime_type(jpg_data), - 'image/jpeg') + mediafile._imghdr_what_wrapper(jpg_data), 'jpeg') def test_soundcheck_non_ascii(self): # Make sure we don't crash when the iTunes SoundCheck field contains # non-ASCII binary data. - f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, - b'soundcheck-nonascii.m4a')) + f = mediafile.MediaFile(os.path.join(_common.RSRC, + b'soundcheck-nonascii.m4a')) self.assertEqual(f.rg_track_gain, 0.0) @@ -105,6 +105,9 @@ def test_safe_cast_string_to_int(self): self.assertEqual(_sc(int, u'something'), 0) + def test_safe_cast_string_to_int_with_no_numbers(self): + self.assertEqual(_sc(int, u'-'), 0) + def test_safe_cast_int_string_to_int(self): self.assertEqual(_sc(int, u'20'), 20) @@ -127,8 +130,8 @@ self.assertAlmostEqual(_sc(float, u'-1.234'), -1.234) def test_safe_cast_special_chars_to_unicode(self): - us = _sc(unicode, 'caf\xc3\xa9') - self.assertTrue(isinstance(us, unicode)) + us = _sc(six.text_type, 'caf\xc3\xa9') + self.assertTrue(isinstance(us, six.text_type)) self.assertTrue(us.startswith(u'caf')) def test_safe_cast_float_with_no_numbers(self): @@ -144,7 +147,7 @@ self.assertEqual(v, 1.0) -class SafetyTest(unittest.TestCase, TestHelper): +class SafetyTest(unittest.TestCase, _common.TempDirMixin): def setUp(self): self.create_temp_dir() @@ -156,44 +159,44 @@ with open(fn, 'w') as f: f.write(data) try: - self.assertRaises(exc, beets.mediafile.MediaFile, fn) + self.assertRaises(exc, mediafile.MediaFile, fn) finally: os.unlink(fn) # delete the temporary file def test_corrupt_mp3_raises_unreadablefileerror(self): # Make sure we catch Mutagen reading errors appropriately. - self._exccheck(b'corrupt.mp3', beets.mediafile.UnreadableFileError) + self._exccheck(b'corrupt.mp3', mediafile.UnreadableFileError) def test_corrupt_mp4_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.m4a', beets.mediafile.UnreadableFileError) + self._exccheck(b'corrupt.m4a', mediafile.UnreadableFileError) def test_corrupt_flac_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.flac', beets.mediafile.UnreadableFileError) + self._exccheck(b'corrupt.flac', mediafile.UnreadableFileError) def test_corrupt_ogg_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.ogg', beets.mediafile.UnreadableFileError) + self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError) def test_invalid_ogg_header_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.ogg', beets.mediafile.UnreadableFileError, + self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError, 'OggS\x01vorbis') def test_corrupt_monkeys_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.ape', beets.mediafile.UnreadableFileError) + self._exccheck(b'corrupt.ape', mediafile.UnreadableFileError) def test_invalid_extension_raises_filetypeerror(self): - self._exccheck(b'something.unknown', beets.mediafile.FileTypeError) + self._exccheck(b'something.unknown', mediafile.FileTypeError) def test_magic_xml_raises_unreadablefileerror(self): - self._exccheck(b'nothing.xml', beets.mediafile.UnreadableFileError, + self._exccheck(b'nothing.xml', mediafile.UnreadableFileError, "ftyp") - @unittest.skipIf(not hasattr(os, 'symlink'), u'platform lacks symlink') + @unittest.skipUnless(_common.HAVE_SYMLINK, u'platform lacks symlink') def test_broken_symlink(self): fn = os.path.join(_common.RSRC, b'brokenlink') os.symlink('does_not_exist', fn) try: - self.assertRaises(IOError, - beets.mediafile.MediaFile, fn) + self.assertRaises(mediafile.UnreadableFileError, + mediafile.MediaFile, fn) finally: os.unlink(fn) @@ -204,19 +207,19 @@ def test_opening_tagless_file_leaves_untouched(self): old_mtime = os.stat(self.empty).st_mtime - beets.mediafile.MediaFile(self.empty) + mediafile.MediaFile(self.empty) new_mtime = os.stat(self.empty).st_mtime self.assertEqual(old_mtime, new_mtime) -class MP4EncodingTest(unittest.TestCase, TestHelper): +class MP4EncodingTest(unittest.TestCase, _common.TempDirMixin): def setUp(self): self.create_temp_dir() src = os.path.join(_common.RSRC, b'full.m4a') self.path = os.path.join(self.temp_dir, b'test.m4a') shutil.copy(src, self.path) - self.mf = beets.mediafile.MediaFile(self.path) + self.mf = mediafile.MediaFile(self.path) def tearDown(self): self.remove_temp_dir() @@ -224,18 +227,18 @@ def test_unicode_label_in_m4a(self): self.mf.label = u'foo\xe8bar' self.mf.save() - new_mf = beets.mediafile.MediaFile(self.path) + new_mf = mediafile.MediaFile(self.path) self.assertEqual(new_mf.label, u'foo\xe8bar') -class MP3EncodingTest(unittest.TestCase, TestHelper): +class MP3EncodingTest(unittest.TestCase, _common.TempDirMixin): def setUp(self): self.create_temp_dir() src = os.path.join(_common.RSRC, b'full.mp3') self.path = os.path.join(self.temp_dir, b'test.mp3') shutil.copy(src, self.path) - self.mf = beets.mediafile.MediaFile(self.path) + self.mf = mediafile.MediaFile(self.path) def test_comment_with_latin1_encoding(self): # Set up the test file with a Latin1-encoded COMM frame. The encoding @@ -248,7 +251,7 @@ self.mf.save() -class ZeroLengthMediaFile(beets.mediafile.MediaFile): +class ZeroLengthMediaFile(mediafile.MediaFile): @property def length(self): return 0.0 @@ -269,7 +272,7 @@ def setUp(self): super(TypeTest, self).setUp() path = os.path.join(_common.RSRC, b'full.mp3') - self.mf = beets.mediafile.MediaFile(path) + self.mf = mediafile.MediaFile(path) def test_year_integer_in_string(self): self.mf.year = u'2009' @@ -301,45 +304,45 @@ class SoundCheckTest(unittest.TestCase): def test_round_trip(self): - data = beets.mediafile._sc_encode(1.0, 1.0) - gain, peak = beets.mediafile._sc_decode(data) + data = mediafile._sc_encode(1.0, 1.0) + gain, peak = mediafile._sc_decode(data) self.assertEqual(gain, 1.0) self.assertEqual(peak, 1.0) def test_decode_zero(self): data = b' 80000000 80000000 00000000 00000000 00000000 00000000 ' \ b'00000000 00000000 00000000 00000000' - gain, peak = beets.mediafile._sc_decode(data) + gain, peak = mediafile._sc_decode(data) self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_malformatted(self): - gain, peak = beets.mediafile._sc_decode(b'foo') + gain, peak = mediafile._sc_decode(b'foo') self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_special_characters(self): - gain, peak = beets.mediafile._sc_decode(u'caf\xe9'.encode('utf8')) + gain, peak = mediafile._sc_decode(u'caf\xe9'.encode('utf-8')) self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_decode_handles_unicode(self): # Most of the time, we expect to decode the raw bytes. But some formats # might give us text strings, which we need to handle. - gain, peak = beets.mediafile._sc_decode(u'caf\xe9') + gain, peak = mediafile._sc_decode(u'caf\xe9') self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) -class ID3v23Test(unittest.TestCase, TestHelper): - def _make_test(self, ext='mp3', id3v23=False): +class ID3v23Test(unittest.TestCase, _common.TempDirMixin): + def _make_test(self, ext=b'mp3', id3v23=False): self.create_temp_dir() src = os.path.join(_common.RSRC, - bytestring_path('full.{0}'.format(ext))) + b'full.' + ext) self.path = os.path.join(self.temp_dir, - bytestring_path('test.{0}'.format(ext))) + b'test.' + ext) shutil.copy(src, self.path) - return beets.mediafile.MediaFile(self.path, id3v23=id3v23) + return mediafile.MediaFile(self.path, id3v23=id3v23) def _delete_test(self): self.remove_temp_dir() @@ -350,7 +353,7 @@ mf.year = 2013 mf.save() frame = mf.mgfile['TDRC'] - self.assertTrue('2013' in unicode(frame)) + self.assertTrue('2013' in six.text_type(frame)) self.assertTrue('TYER' not in mf.mgfile) finally: self._delete_test() @@ -361,43 +364,43 @@ mf.year = 2013 mf.save() frame = mf.mgfile['TYER'] - self.assertTrue('2013' in unicode(frame)) + self.assertTrue('2013' in six.text_type(frame)) self.assertTrue('TDRC' not in mf.mgfile) finally: self._delete_test() def test_v23_on_non_mp3_is_noop(self): - mf = self._make_test('m4a', id3v23=True) + mf = self._make_test(b'm4a', id3v23=True) try: mf.year = 2013 mf.save() finally: self._delete_test() - def test_v24_image_encoding(self): - mf = self._make_test(id3v23=False) - try: - mf.images = [beets.mediafile.Image(b'test data')] - mf.save() - frame = mf.mgfile.tags.getall('APIC')[0] - self.assertEqual(frame.encoding, 3) - finally: - self._delete_test() + def test_image_encoding(self): + """For compatibility with OS X/iTunes. - @unittest.skip("a bug, see #899") - def test_v23_image_encoding(self): - """For compatibility with OS X/iTunes (and strict adherence to - the standard), ID3v2.3 tags need to use an inferior text - encoding: UTF-8 is not supported. + See https://github.com/beetbox/beets/issues/899#issuecomment-62437773 """ - mf = self._make_test(id3v23=True) - try: - mf.images = [beets.mediafile.Image(b'test data')] - mf.save() - frame = mf.mgfile.tags.getall('APIC')[0] - self.assertEqual(frame.encoding, 1) - finally: - self._delete_test() + + for v23 in [True, False]: + mf = self._make_test(id3v23=v23) + try: + mf.images = [ + mediafile.Image(b'data', desc=u""), + mediafile.Image(b'data', desc=u"foo"), + mediafile.Image(b'data', desc=u"\u0185"), + ] + mf.save() + apic_frames = mf.mgfile.tags.getall('APIC') + encodings = dict([(f.desc, f.encoding) for f in apic_frames]) + self.assertEqual(encodings, { + u"": mutagen.id3.Encoding.LATIN1, + u"foo": mutagen.id3.Encoding.LATIN1, + u"\u0185": mutagen.id3.Encoding.UTF16, + }) + finally: + self._delete_test() def suite(): diff -Nru beets-1.3.19/test/test_mediafile.py beets-1.4.6/test/test_mediafile.py --- beets-1.3.19/test/test_mediafile.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_mediafile.py 2017-06-20 19:15:08.000000000 +0000 @@ -20,18 +20,14 @@ import os import shutil -import tempfile import datetime import time +import unittest +from six import assertCountEqual from test import _common -from test._common import unittest -from beets.mediafile import MediaFile, MediaField, Image, \ - MP3DescStorageStyle, StorageStyle, MP4StorageStyle, \ - ASFStorageStyle, ImageType, CoverArtField -from beets.library import Item -from beets.plugins import BeetsPlugin -from beets.util import bytestring_path +from beets.mediafile import MediaFile, Image, \ + ImageType, CoverArtField, UnreadableFileError class ArtTestMixin(object): @@ -179,7 +175,11 @@ class ExtendedImageStructureTestMixin(ImageStructureTestMixin): - """Checks for additional attributes in the image structure.""" + """Checks for additional attributes in the image structure. + + Like the base `ImageStructureTestMixin`, per-format test classes + should include this mixin to add image-related tests. + """ def assertExtendedImageAttributes(self, image, desc=None, type=None): # noqa self.assertEqual(image.desc, desc) @@ -268,7 +268,7 @@ def test_read_genre_list(self): mediafile = self._mediafile_fixture('full') - self.assertItemsEqual(mediafile.genres, ['the genre']) + assertCountEqual(self, mediafile.genres, ['the genre']) def test_write_genre_list(self): mediafile = self._mediafile_fixture('empty') @@ -276,7 +276,7 @@ mediafile.save() mediafile = MediaFile(mediafile.path) - self.assertItemsEqual(mediafile.genres, [u'one', u'two']) + assertCountEqual(self, mediafile.genres, [u'one', u'two']) def test_write_genre_list_get_first(self): mediafile = self._mediafile_fixture('empty') @@ -293,78 +293,28 @@ mediafile.save() mediafile = MediaFile(mediafile.path) - self.assertItemsEqual(mediafile.genres, [u'the genre', u'another']) - - -field_extension = MediaField( - MP3DescStorageStyle('customtag'), - MP4StorageStyle('----:com.apple.iTunes:customtag'), - StorageStyle('customtag'), - ASFStorageStyle('customtag'), -) - - -class ExtendedFieldTestMixin(object): - - def test_extended_field_write(self): - plugin = BeetsPlugin() - plugin.add_media_field('customtag', field_extension) - - mediafile = self._mediafile_fixture('empty') - mediafile.customtag = u'F#' - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.customtag, u'F#') - delattr(MediaFile, 'customtag') - Item._media_fields.remove('customtag') - - def test_write_extended_tag_from_item(self): - plugin = BeetsPlugin() - plugin.add_media_field('customtag', field_extension) - - mediafile = self._mediafile_fixture('empty') - self.assertIsNone(mediafile.customtag) - - item = Item(path=mediafile.path, customtag=u'Gb') - item.write() - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.customtag, u'Gb') - - delattr(MediaFile, 'customtag') - Item._media_fields.remove('customtag') - - def test_read_flexible_attribute_from_file(self): - plugin = BeetsPlugin() - plugin.add_media_field('customtag', field_extension) - - mediafile = self._mediafile_fixture('empty') - mediafile.update({'customtag': u'F#'}) - mediafile.save() - - item = Item.from_path(mediafile.path) - self.assertEqual(item['customtag'], u'F#') - - delattr(MediaFile, 'customtag') - Item._media_fields.remove('customtag') - - def test_invalid_descriptor(self): - with self.assertRaises(ValueError) as cm: - MediaFile.add_field('somekey', True) - self.assertIn(u'must be an instance of MediaField', - unicode(cm.exception)) - - def test_overwrite_property(self): - with self.assertRaises(ValueError) as cm: - MediaFile.add_field('artist', MediaField()) - self.assertIn(u'property "artist" already exists', - unicode(cm.exception)) + assertCountEqual(self, mediafile.genres, [u'the genre', u'another']) class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, - ExtendedFieldTestMixin): - """Test writing and reading tags. Subclasses must set ``extension`` and - ``audio_properties``. + _common.TempDirMixin): + """Test writing and reading tags. Subclasses must set ``extension`` + and ``audio_properties``. + + The basic tests for all audio formats encompass three files provided + in our `rsrc` folder: `full.*`, `empty.*`, and `unparseable.*`. + Respectively, they should contain a full slate of common fields + listed in `full_initial_tags` below; no fields contents at all; and + an unparseable release date field. + + To add support for a new file format to MediaFile, add these three + files and then create a `ReadWriteTestBase` subclass by copying n' + pasting one of the existing subclasses below. You will want to + update the `format` field in that subclass, and you will probably + need to fiddle with the `bitrate` and other format-specific fields. + + You can also add image tests (using an additional `image.*` fixture + file) by including one of the image-related mixins. """ full_initial_tags = { @@ -398,7 +348,10 @@ 'artist', 'album', 'genre', + 'lyricist', 'composer', + 'composer_sort', + 'arranger', 'grouping', 'year', 'month', @@ -421,6 +374,8 @@ 'rg_track_gain', 'rg_album_peak', 'rg_album_gain', + 'r128_track_gain', + 'r128_album_gain', 'albumartist', 'mb_albumartistid', 'artist_sort', @@ -447,11 +402,31 @@ ] def setUp(self): - self.temp_dir = tempfile.mkdtemp() + self.create_temp_dir() def tearDown(self): - if os.path.isdir(self.temp_dir): - shutil.rmtree(self.temp_dir) + self.remove_temp_dir() + + def test_read_nonexisting(self): + mediafile = self._mediafile_fixture('full') + os.remove(mediafile.path) + self.assertRaises(UnreadableFileError, MediaFile, mediafile.path) + + def test_save_nonexisting(self): + mediafile = self._mediafile_fixture('full') + os.remove(mediafile.path) + try: + mediafile.save() + except UnreadableFileError: + pass + + def test_delete_nonexisting(self): + mediafile = self._mediafile_fixture('full') + os.remove(mediafile.path) + try: + mediafile.delete() + except UnreadableFileError: + pass def test_read_audio_properties(self): mediafile = self._mediafile_fixture('full') @@ -601,6 +576,9 @@ self.assertEqual(mediafile.disctotal, None) def test_unparseable_date(self): + """The `unparseable.*` fixture should not crash but should return None + for all parts of the release date. + """ mediafile = self._mediafile_fixture('unparseable') self.assertIsNone(mediafile.date) @@ -679,7 +657,9 @@ self.fail('\n '.join(errors)) def _mediafile_fixture(self, name): - name = bytestring_path(name + '.' + self.extension) + name = name + '.' + self.extension + if not isinstance(name, bytes): + name = name.encode('utf8') src = os.path.join(_common.RSRC, name) target = os.path.join(self.temp_dir, name) shutil.copy(src, target) @@ -694,6 +674,9 @@ if key.startswith('rg_'): # ReplayGain is float tags[key] = 1.0 + elif key.startswith('r128_'): + # R128 is int + tags[key] = -1 else: tags[key] = 'value\u2010%s' % key @@ -875,7 +858,7 @@ extension = 'flac' audio_properties = { 'length': 1.0, - 'bitrate': 175120, + 'bitrate': 108688, 'format': u'FLAC', 'samplerate': 44100, 'bitdepth': 16, @@ -932,6 +915,29 @@ } +# Check whether we have a Mutagen version with DSF support. We can +# remove this once we require a version that includes the feature. +try: + import mutagen.dsf # noqa +except ImportError: + HAVE_DSF = False +else: + HAVE_DSF = True + + +@unittest.skipIf(not HAVE_DSF, "Mutagen does not have DSF support") +class DSFTest(ReadWriteTestBase, unittest.TestCase): + extension = 'dsf' + audio_properties = { + 'length': 0.01, + 'bitrate': 11289600, + 'format': u'DSD Stream File', + 'samplerate': 5644800, + 'bitdepth': 1, + 'channels': 2, + } + + class MediaFieldTest(unittest.TestCase): def test_properties_from_fields(self): @@ -949,7 +955,7 @@ def test_known_fields(self): fields = list(ReadWriteTestBase.tag_fields) fields.extend(('encoder', 'images', 'genres', 'albumtype')) - self.assertItemsEqual(MediaFile.fields(), fields) + assertCountEqual(self, MediaFile.fields(), fields) def test_fields_in_readable_fields(self): readable = MediaFile.readable_fields() diff -Nru beets-1.3.19/test/test_metasync.py beets-1.4.6/test/test_metasync.py --- beets-1.3.19/test/test_metasync.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_metasync.py 2016-12-17 03:01:23.000000000 +0000 @@ -20,9 +20,10 @@ import time from datetime import datetime from beets.library import Item +from beets.util import py3_path +import unittest from test import _common -from test._common import unittest from test.helper import TestHelper @@ -48,10 +49,10 @@ if _is_windows(): self.config['metasync']['itunes']['library'] = \ - self.itunes_library_windows + py3_path(self.itunes_library_windows) else: self.config['metasync']['itunes']['library'] = \ - self.itunes_library_unix + py3_path(self.itunes_library_unix) self._set_up_data() diff -Nru beets-1.3.19/test/test_mpdstats.py beets-1.4.6/test/test_mpdstats.py --- beets-1.3.19/test/test_mpdstats.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_mpdstats.py 2016-12-17 03:01:23.000000000 +0000 @@ -15,8 +15,8 @@ from __future__ import division, absolute_import, print_function +import unittest from mock import Mock, patch, call, ANY -from test._common import unittest from test.helper import TestHelper from beets.library import Item diff -Nru beets-1.3.19/test/test_permissions.py beets-1.4.6/test/test_permissions.py --- beets-1.3.19/test/test_permissions.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_permissions.py 2016-12-17 03:01:23.000000000 +0000 @@ -6,10 +6,11 @@ import os import platform +import unittest from mock import patch, Mock -from test._common import unittest from test.helper import TestHelper +from beets.util import displayable_path from beetsplug.permissions import (check_permissions, convert_perm, dirs_in_library) @@ -67,13 +68,20 @@ def assertPerms(self, path, typ, expect_success): # noqa for x in [(True, self.exp_perms[expect_success][typ], '!='), (False, self.exp_perms[not expect_success][typ], '==')]: - self.assertEqual(x[0], check_permissions(path, x[1]), - msg=u'{} : {} {} {}'.format( - path, oct(os.stat(path).st_mode), x[2], oct(x[1]))) + msg = u'{} : {} {} {}'.format( + displayable_path(path), + oct(os.stat(path).st_mode), + x[2], + oct(x[1]) + ) + self.assertEqual(x[0], check_permissions(path, x[1]), msg=msg) def test_convert_perm_from_string(self): self.assertEqual(convert_perm('10'), 8) + def test_convert_perm_from_int(self): + self.assertEqual(convert_perm(10), 8) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff -Nru beets-1.3.19/test/test_pipeline.py beets-1.4.6/test/test_pipeline.py --- beets-1.3.19/test/test_pipeline.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_pipeline.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,7 +17,9 @@ """ from __future__ import division, absolute_import, print_function -from test._common import unittest +import six +import unittest + from beets.util import pipeline @@ -134,7 +136,10 @@ pull = pl.pull() for i in range(3): next(pull) - self.assertRaises(TestException, pull.next) + if six.PY2: + self.assertRaises(TestException, pull.next) + else: + self.assertRaises(TestException, pull.__next__) class ParallelExceptionTest(unittest.TestCase): diff -Nru beets-1.3.19/test/test_player.py beets-1.4.6/test/test_player.py --- beets-1.3.19/test/test_player.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_player.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,7 +17,7 @@ """ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from beetsplug import bpd diff -Nru beets-1.3.19/test/test_play.py beets-1.4.6/test/test_play.py --- beets-1.3.19/test/test_play.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_play.py 2017-06-14 23:13:49.000000000 +0000 @@ -19,105 +19,118 @@ import os +import unittest from mock import patch, ANY -from test._common import unittest from test.helper import TestHelper, control_stdin from beets.ui import UserError from beets.util import open_anything +@patch('beetsplug.play.util.interactive_open') class PlayPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('play') self.item = self.add_item(album=u'a nice älbum', title=u'aNiceTitle') self.lib.add_album([self.item]) - self.open_patcher = patch('beetsplug.play.util.interactive_open') - self.open_mock = self.open_patcher.start() self.config['play']['command'] = 'echo' def tearDown(self): - self.open_patcher.stop() self.teardown_beets() self.unload_plugins() - def do_test(self, args=('title:aNiceTitle',), expected_cmd='echo', - expected_playlist=None): + def run_and_assert(self, open_mock, args=('title:aNiceTitle',), + expected_cmd='echo', expected_playlist=None): self.run_command('play', *args) - self.open_mock.assert_called_once_with(ANY, expected_cmd) + open_mock.assert_called_once_with(ANY, expected_cmd) expected_playlist = expected_playlist or self.item.path.decode('utf-8') exp_playlist = expected_playlist + u'\n' - with open(self.open_mock.call_args[0][0][0], 'rb') as playlist: + with open(open_mock.call_args[0][0][0], 'rb') as playlist: self.assertEqual(exp_playlist, playlist.read().decode('utf-8')) - def test_basic(self): - self.do_test() + def test_basic(self, open_mock): + self.run_and_assert(open_mock) - def test_album_option(self): - self.do_test([u'-a', u'nice']) + def test_album_option(self, open_mock): + self.run_and_assert(open_mock, [u'-a', u'nice']) - def test_args_option(self): - self.do_test([u'-A', u'foo', u'title:aNiceTitle'], u'echo foo') + def test_args_option(self, open_mock): + self.run_and_assert( + open_mock, [u'-A', u'foo', u'title:aNiceTitle'], u'echo foo') - def test_args_option_in_middle(self): + def test_args_option_in_middle(self, open_mock): self.config['play']['command'] = 'echo $args other' - self.do_test([u'-A', u'foo', u'title:aNiceTitle'], u'echo foo other') + self.run_and_assert( + open_mock, [u'-A', u'foo', u'title:aNiceTitle'], u'echo foo other') - def test_relative_to(self): + def test_unset_args_option_in_middle(self, open_mock): + self.config['play']['command'] = 'echo $args other' + + self.run_and_assert( + open_mock, [u'title:aNiceTitle'], u'echo other') + + def test_relative_to(self, open_mock): self.config['play']['command'] = 'echo' self.config['play']['relative_to'] = '/something' path = os.path.relpath(self.item.path, b'/something') - playlist = path.decode('utf8') - self.do_test(expected_cmd='echo', expected_playlist=playlist) + playlist = path.decode('utf-8') + self.run_and_assert( + open_mock, expected_cmd='echo', expected_playlist=playlist) - def test_use_folders(self): + def test_use_folders(self, open_mock): self.config['play']['command'] = None self.config['play']['use_folders'] = True self.run_command('play', '-a', 'nice') - self.open_mock.assert_called_once_with(ANY, open_anything()) - playlist = open(self.open_mock.call_args[0][0][0], 'rb') + open_mock.assert_called_once_with(ANY, open_anything()) + with open(open_mock.call_args[0][0][0], 'rb') as f: + playlist = f.read().decode('utf-8') self.assertEqual(u'{}\n'.format( os.path.dirname(self.item.path.decode('utf-8'))), - playlist.read().decode('utf-8')) + playlist) - def test_raw(self): + def test_raw(self, open_mock): self.config['play']['raw'] = True self.run_command(u'play', u'nice') - self.open_mock.assert_called_once_with([self.item.path], 'echo') + open_mock.assert_called_once_with([self.item.path], 'echo') - def test_not_found(self): + def test_not_found(self, open_mock): self.run_command(u'play', u'not found') - self.open_mock.assert_not_called() + open_mock.assert_not_called() - def test_warning_threshold(self): + def test_warning_threshold(self, open_mock): self.config['play']['warning_threshold'] = 1 self.add_item(title='another NiceTitle') with control_stdin("a"): self.run_command(u'play', u'nice') - self.open_mock.assert_not_called() + open_mock.assert_not_called() - def test_warning_threshold_backwards_compat(self): - self.config['play']['warning_treshold'] = 1 - self.add_item(title=u'another NiceTitle') + def test_skip_warning_threshold_bypass(self, open_mock): + self.config['play']['warning_threshold'] = 1 + self.other_item = self.add_item(title='another NiceTitle') - with control_stdin("a"): - self.run_command(u'play', u'nice') + expected_playlist = u'{0}\n{1}'.format( + self.item.path.decode('utf-8'), + self.other_item.path.decode('utf-8')) - self.open_mock.assert_not_called() + with control_stdin("a"): + self.run_and_assert( + open_mock, + [u'-y', u'NiceTitle'], + expected_playlist=expected_playlist) - def test_command_failed(self): - self.open_mock.side_effect = OSError(u"some reason") + def test_command_failed(self, open_mock): + open_mock.side_effect = OSError(u"some reason") with self.assertRaises(UserError): self.run_command(u'play', u'title:aNiceTitle') diff -Nru beets-1.3.19/test/test_plexupdate.py beets-1.4.6/test/test_plexupdate.py --- beets-1.3.19/test/test_plexupdate.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_plexupdate.py 2016-12-17 03:01:23.000000000 +0000 @@ -2,9 +2,9 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest from test.helper import TestHelper from beetsplug.plexupdate import get_music_section, update_plex +import unittest import responses diff -Nru beets-1.3.19/test/test_plugin_mediafield.py beets-1.4.6/test/test_plugin_mediafield.py --- beets-1.3.19/test/test_plugin_mediafield.py 1970-01-01 00:00:00.000000000 +0000 +++ beets-1.4.6/test/test_plugin_mediafield.py 2017-01-15 01:33:19.000000000 +0000 @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, 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. + +"""Tests the facility that lets plugins add custom field to MediaFile. +""" +from __future__ import division, absolute_import, print_function + +import os +import six +import shutil +import unittest + +from test import _common +from beets.library import Item +from beets import mediafile +from beets.plugins import BeetsPlugin +from beets.util import bytestring_path + + +field_extension = mediafile.MediaField( + mediafile.MP3DescStorageStyle(u'customtag'), + mediafile.MP4StorageStyle('----:com.apple.iTunes:customtag'), + mediafile.StorageStyle('customtag'), + mediafile.ASFStorageStyle('customtag'), +) + + +class ExtendedFieldTestMixin(_common.TestCase): + + def _mediafile_fixture(self, name, extension='mp3'): + name = bytestring_path(name + '.' + extension) + src = os.path.join(_common.RSRC, name) + target = os.path.join(self.temp_dir, name) + shutil.copy(src, target) + return mediafile.MediaFile(target) + + def test_extended_field_write(self): + plugin = BeetsPlugin() + plugin.add_media_field('customtag', field_extension) + + try: + mf = self._mediafile_fixture('empty') + mf.customtag = u'F#' + mf.save() + + mf = mediafile.MediaFile(mf.path) + self.assertEqual(mf.customtag, u'F#') + + finally: + delattr(mediafile.MediaFile, 'customtag') + Item._media_fields.remove('customtag') + + def test_write_extended_tag_from_item(self): + plugin = BeetsPlugin() + plugin.add_media_field('customtag', field_extension) + + try: + mf = self._mediafile_fixture('empty') + self.assertIsNone(mf.customtag) + + item = Item(path=mf.path, customtag=u'Gb') + item.write() + mf = mediafile.MediaFile(mf.path) + self.assertEqual(mf.customtag, u'Gb') + + finally: + delattr(mediafile.MediaFile, 'customtag') + Item._media_fields.remove('customtag') + + def test_read_flexible_attribute_from_file(self): + plugin = BeetsPlugin() + plugin.add_media_field('customtag', field_extension) + + try: + mf = self._mediafile_fixture('empty') + mf.update({'customtag': u'F#'}) + mf.save() + + item = Item.from_path(mf.path) + self.assertEqual(item['customtag'], u'F#') + + finally: + delattr(mediafile.MediaFile, 'customtag') + Item._media_fields.remove('customtag') + + def test_invalid_descriptor(self): + with self.assertRaises(ValueError) as cm: + mediafile.MediaFile.add_field('somekey', True) + self.assertIn(u'must be an instance of MediaField', + six.text_type(cm.exception)) + + def test_overwrite_property(self): + with self.assertRaises(ValueError) as cm: + mediafile.MediaFile.add_field('artist', mediafile.MediaField()) + self.assertIn(u'property "artist" already exists', + six.text_type(cm.exception)) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff -Nru beets-1.3.19/test/test_plugins.py beets-1.4.6/test/test_plugins.py --- beets-1.3.19/test/test_plugins.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_plugins.py 2016-12-28 19:50:46.000000000 +0000 @@ -19,6 +19,7 @@ from mock import patch, Mock, ANY import shutil import itertools +import unittest from beets.importer import SingletonImportTask, SentinelImportTask, \ ArchiveImportTask, action @@ -26,11 +27,11 @@ from beets.library import Item from beets.dbcore import types from beets.mediafile import MediaFile -from beets.util import displayable_path, bytestring_path +from beets.util import displayable_path, bytestring_path, syspath from test.test_importer import ImportHelper, AutotagStub from test.test_ui_importer import TerminalImportSessionSetup -from test._common import unittest, RSRC +from test._common import RSRC from test import helper @@ -117,7 +118,7 @@ item = self.add_item_fixture(artist=u'XXX') item.write() - mediafile = MediaFile(item.path) + mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.artist, u'YYY') def register_listener(self, event, func): diff -Nru beets-1.3.19/test/test_query.py beets-1.4.6/test/test_query.py --- beets-1.3.19/test/test_query.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/test/test_query.py 2017-06-21 14:26:58.000000000 +0000 @@ -21,19 +21,20 @@ from mock import patch import os import sys +import unittest from test import _common -from test._common import unittest from test import helper import beets.library from beets import dbcore from beets.dbcore import types from beets.dbcore.query import (NoneQuery, ParsingError, - InvalidQueryArgumentTypeError) + InvalidQueryArgumentValueError) from beets.library import Library, Item from beets import util import platform +import six class TestHelper(helper.TestHelper): @@ -78,10 +79,10 @@ class AssertsMixin(object): def assert_items_matched(self, results, titles): - self.assertEqual([i.title for i in results], titles) + self.assertEqual(set([i.title for i in results]), set(titles)) def assert_albums_matched(self, results, albums): - self.assertEqual([a.album for a in results], albums) + self.assertEqual(set([a.album for a in results]), set(albums)) # A test case class providing a library with some dummy data and some @@ -300,13 +301,13 @@ self.assertFalse(results) def test_invalid_query(self): - with self.assertRaises(InvalidQueryArgumentTypeError) as raised: + with self.assertRaises(InvalidQueryArgumentValueError) as raised: dbcore.query.NumericQuery('year', u'199a') - self.assertIn(u'not an int', unicode(raised.exception)) + self.assertIn(u'not an int', six.text_type(raised.exception)) - with self.assertRaises(InvalidQueryArgumentTypeError) as raised: + with self.assertRaises(InvalidQueryArgumentValueError) as raised: dbcore.query.RegexpQuery('year', u'199(') - exception_text = unicode(raised.exception) + exception_text = six.text_type(raised.exception) self.assertIn(u'not a regular expression', exception_text) if sys.version_info >= (3, 5): self.assertIn(u'unterminated subpattern', exception_text) @@ -600,6 +601,7 @@ try: path = self.touch(os.path.join(b'foo', b'bar')) + path = path.decode('utf-8') # The file itself. self.assertTrue(is_path(path)) @@ -889,9 +891,12 @@ self.assertNegationProperties(q) def test_type_date(self): - q = dbcore.query.DateQuery(u'mtime', u'0.0') + q = dbcore.query.DateQuery(u'added', u'2000-01-01') not_results = self.lib.items(dbcore.query.NotQuery(q)) - self.assert_items_matched(not_results, []) + # query date is in the past, thus the 'not' results should contain all + # items + self.assert_items_matched(not_results, [u'foo bar', u'baz qux', + u'beets 4 eva']) self.assertNegationProperties(q) def test_type_false(self): @@ -990,7 +995,7 @@ AttributeError: type object 'NoneQuery' has no attribute 'field' at NoneQuery.match() (due to being @classmethod, and no self?) """ - classes = [(dbcore.query.DateQuery, [u'mtime', u'0.0']), + classes = [(dbcore.query.DateQuery, [u'added', u'2001-01-01']), (dbcore.query.MatchQuery, [u'artist', u'one']), # (dbcore.query.NoneQuery, ['rg_track_gain']), (dbcore.query.NumericQuery, [u'year', u'2002']), diff -Nru beets-1.3.19/test/test_replaygain.py beets-1.4.6/test/test_replaygain.py --- beets-1.3.19/test/test_replaygain.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_replaygain.py 2017-06-14 23:13:49.000000000 +0000 @@ -16,7 +16,9 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest +import six + from test.helper import TestHelper, has_program from beets import config @@ -50,19 +52,19 @@ try: self.load_plugins('replaygain') - except: + except Exception: import sys # store exception info so an error in teardown does not swallow it exc_info = sys.exc_info() try: self.teardown_beets() self.unload_plugins() - except: + except Exception: # if load_plugins() failed then setup is incomplete and # teardown operations may fail. In particular # {Item,Album} # may not have the _original_types attribute in unload_plugins pass - raise exc_info[1], None, exc_info[2] + six.reraise(exc_info[1], None, exc_info[2]) album = self.add_album_fixture(2) for item in album.items(): diff -Nru beets-1.3.19/test/test_smartplaylist.py beets-1.4.6/test/test_smartplaylist.py --- beets-1.3.19/test/test_smartplaylist.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_smartplaylist.py 2016-12-17 03:01:23.000000000 +0000 @@ -18,6 +18,7 @@ from os import path, remove from tempfile import mkdtemp from shutil import rmtree +import unittest from mock import Mock, MagicMock @@ -25,11 +26,10 @@ from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import NullSort, MultipleSort, FixedFieldSort -from beets.util import syspath, bytestring_path +from beets.util import syspath, bytestring_path, py3_path, CHAR_REPLACE from beets.ui import UserError from beets import config -from test._common import unittest from test.helper import TestHelper @@ -150,18 +150,22 @@ spl = SmartPlaylistPlugin() i = Mock(path=b'/tagada.mp3') - i.evaluate_template.side_effect = lambda x, _: x - q = Mock() - a_q = Mock() + i.evaluate_template.side_effect = \ + lambda pl, _: pl.replace(b'$title', b'ta:ga:da').decode() + lib = Mock() + lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] - pl = b'my_playlist.m3u', (q, None), (a_q, None) + + q = Mock() + a_q = Mock() + pl = b'$title-my.m3u', (q, None), (a_q, None) spl._matched_playlists = [pl] dir = bytestring_path(mkdtemp()) config['smartplaylist']['relative_to'] = False - config['smartplaylist']['playlist_dir'] = dir + config['smartplaylist']['playlist_dir'] = py3_path(dir) try: spl.update_playlists(lib) except Exception: @@ -171,7 +175,7 @@ lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) - m3u_filepath = path.join(dir, pl[0]) + m3u_filepath = path.join(dir, b'ta_ga_da-my_playlist_.m3u') self.assertTrue(path.exists(m3u_filepath)) with open(syspath(m3u_filepath), 'rb') as f: content = f.read() @@ -191,7 +195,7 @@ {'name': 'all.m3u', 'query': u''} ]) - config['smartplaylist']['playlist_dir'].set(self.temp_dir) + config['smartplaylist']['playlist_dir'].set(py3_path(self.temp_dir)) self.load_plugins('smartplaylist') def tearDown(self): diff -Nru beets-1.3.19/test/test_sort.py beets-1.4.6/test/test_sort.py --- beets-1.3.19/test/test_sort.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_sort.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,8 +17,8 @@ """ from __future__ import division, absolute_import, print_function +import unittest from test import _common -from test._common import unittest import beets.library from beets import dbcore from beets import config diff -Nru beets-1.3.19/test/test_spotify.py beets-1.4.6/test/test_spotify.py --- beets-1.3.19/test/test_spotify.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_spotify.py 2016-12-17 03:01:23.000000000 +0000 @@ -6,14 +6,14 @@ import os import responses +import unittest from test import _common -from test._common import unittest from beets import config from beets.library import Item from beetsplug import spotify from test.helper import TestHelper -import urlparse +from six.moves.urllib.parse import parse_qs, urlparse class ArgumentsMock(object): @@ -25,7 +25,7 @@ def _params(url): """Get the query parameters from a URL.""" - return urlparse.parse_qs(urlparse.urlparse(url).query) + return parse_qs(urlparse(url).query) class SpotifyPluginTest(_common.TestCase, TestHelper): diff -Nru beets-1.3.19/test/test_template.py beets-1.4.6/test/test_template.py --- beets-1.3.19/test/test_template.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_template.py 2017-06-20 19:15:08.000000000 +0000 @@ -17,9 +17,8 @@ """ from __future__ import division, absolute_import, print_function -import warnings - -from test._common import unittest +import unittest +import six from beets.util import functemplate @@ -30,7 +29,7 @@ """ textbuf = [] for part in expr.parts: - if isinstance(part, basestring): + if isinstance(part, six.string_types): textbuf.append(part) else: if textbuf: @@ -212,12 +211,26 @@ self._assert_call(arg_parts[0], u"bar", 1) self.assertEqual(list(_normexpr(arg_parts[0].args[0])), [u'baz']) - def test_fail_on_utf8(self): - parts = u'é'.encode('utf8') - warnings.simplefilter("ignore") - with self.assertRaises(UnicodeDecodeError): - functemplate._parse(parts) - warnings.simplefilter("default") + def test_sep_before_call_two_args(self): + parts = list(_normparse(u'hello, %foo{bar,baz}')) + self.assertEqual(len(parts), 2) + self.assertEqual(parts[0], u'hello, ') + self._assert_call(parts[1], u"foo", 2) + self.assertEqual(list(_normexpr(parts[1].args[0])), [u'bar']) + self.assertEqual(list(_normexpr(parts[1].args[1])), [u'baz']) + + def test_sep_with_symbols(self): + parts = list(_normparse(u'hello,$foo,$bar')) + self.assertEqual(len(parts), 4) + self.assertEqual(parts[0], u'hello,') + self._assert_symbol(parts[1], u"foo") + self.assertEqual(parts[2], u',') + self._assert_symbol(parts[3], u"bar") + + def test_newline_at_end(self): + parts = list(_normparse(u'foo\n')) + self.assertEqual(len(parts), 1) + self.assertEqual(parts[0], u'foo\n') class EvalTest(unittest.TestCase): @@ -227,7 +240,7 @@ u'baz': u'BaR', } functions = { - u'lower': unicode.lower, + u'lower': six.text_type.lower, u'len': len, } return functemplate.Template(template).substitute(values, functions) @@ -258,7 +271,7 @@ def test_function_call_exception(self): res = self._eval(u"%lower{a,b,c,d,e}") - self.assertTrue(isinstance(res, basestring)) + self.assertTrue(isinstance(res, six.string_types)) def test_function_returning_integer(self): self.assertEqual(self._eval(u"%len{foo}"), u"3") diff -Nru beets-1.3.19/test/test_the.py beets-1.4.6/test/test_the.py --- beets-1.3.19/test/test_the.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_the.py 2016-12-17 03:01:23.000000000 +0000 @@ -4,7 +4,7 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest +import unittest from test import _common from beets import config from beetsplug.the import ThePlugin, PATTERN_A, PATTERN_THE, FORMAT diff -Nru beets-1.3.19/test/test_thumbnails.py beets-1.4.6/test/test_thumbnails.py --- beets-1.3.19/test/test_thumbnails.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_thumbnails.py 2017-06-14 23:13:49.000000000 +0000 @@ -19,8 +19,8 @@ from mock import Mock, patch, call from tempfile import mkdtemp from shutil import rmtree +import unittest -from test._common import unittest from test.helper import TestHelper from beets.util import bytestring_path @@ -273,20 +273,15 @@ def test_uri(self): gio = GioURI() - plib = PathlibURI() if not gio.available: self.skipTest(u"GIO library not found") - self.assertEqual(gio.uri(u"/foo"), b"file:///") # silent fail - self.assertEqual(gio.uri(b"/foo"), b"file:///foo") - self.assertEqual(gio.uri(b"/foo!"), b"file:///foo!") - self.assertEqual(plib.uri(b"/foo!"), b"file:///foo%21") + self.assertEqual(gio.uri(u"/foo"), u"file:///") # silent fail + self.assertEqual(gio.uri(b"/foo"), u"file:///foo") + self.assertEqual(gio.uri(b"/foo!"), u"file:///foo!") self.assertEqual( gio.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'), - b'file:///music/%EC%8B%B8%EC%9D%B4') - self.assertEqual( - plib.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'), - b'file:///music/%EC%8B%B8%EC%9D%B4') + u'file:///music/%EC%8B%B8%EC%9D%B4') def suite(): diff -Nru beets-1.3.19/test/test_types_plugin.py beets-1.4.6/test/test_types_plugin.py --- beets-1.3.19/test/test_types_plugin.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_types_plugin.py 2016-12-17 03:01:23.000000000 +0000 @@ -17,8 +17,8 @@ import time from datetime import datetime +import unittest -from test._common import unittest from test.helper import TestHelper from beets.util.confit import ConfigValueError @@ -48,7 +48,7 @@ # Match in range out = self.list(u'myint:1..3') - self.assertIn(b'aaa', out) + self.assertIn('aaa', out) def test_album_integer_modify_and_query(self): self.config['types'] = {'myint': u'int'} @@ -64,19 +64,23 @@ # Match in range out = self.list_album(u'myint:1..3') - self.assertIn(b'aaa', out) + self.assertIn('aaa', out) def test_float_modify_and_query(self): self.config['types'] = {'myfloat': u'float'} item = self.add_item(artist=u'aaa') + # Do not match unset values + out = self.list(u'myfloat:10..0') + self.assertEqual(u'', out) + self.modify(u'myfloat=-9.1') item.load() self.assertEqual(item['myfloat'], -9.1) # Match in range out = self.list(u'myfloat:-10..0') - self.assertIn(b'aaa', out) + self.assertIn('aaa', out) def test_bool_modify_and_query(self): self.config['types'] = {'mybool': u'bool'} @@ -84,6 +88,10 @@ false = self.add_item(artist=u'false') self.add_item(artist=u'unset') + # Do not match unset values + out = self.list(u'mybool:true, mybool:false') + self.assertEqual(u'', out) + # Set true self.modify(u'mybool=1', u'artist:true') true.load() @@ -112,6 +120,10 @@ old = self.add_item(artist=u'prince') new = self.add_item(artist=u'britney') + # Do not match unset values + out = self.list(u'mydate:..2000') + self.assertEqual(u'', out) + self.modify(u'mydate=1999-01-01', u'artist:prince') old.load() self.assertEqual(old['mydate'], mktime(1999, 1, 1)) diff -Nru beets-1.3.19/test/test_ui_commands.py beets-1.4.6/test/test_ui_commands.py --- beets-1.3.19/test/test_ui_commands.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_ui_commands.py 2016-12-17 03:01:23.000000000 +0000 @@ -20,9 +20,9 @@ import os import shutil +import unittest from test import _common -from test._common import unittest from beets import library from beets import ui diff -Nru beets-1.3.19/test/test_ui_importer.py beets-1.4.6/test/test_ui_importer.py --- beets-1.3.19/test/test_ui_importer.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_ui_importer.py 2016-12-17 03:01:23.000000000 +0000 @@ -20,12 +20,14 @@ """ from __future__ import division, absolute_import, print_function +import unittest -from test._common import unittest, DummyIO +from test._common import DummyIO from test import test_importer from beets.ui.commands import TerminalImportSession from beets import importer from beets import config +import six class TestTerminalImportSession(TerminalImportSession): @@ -69,7 +71,7 @@ self.io.addinput(u'S') elif isinstance(choice, int): self.io.addinput(u'M') - self.io.addinput(unicode(choice)) + self.io.addinput(six.text_type(choice)) self._add_choice_input() else: raise Exception(u'Unknown choice %s' % choice) diff -Nru beets-1.3.19/test/test_ui_init.py beets-1.4.6/test/test_ui_init.py --- beets-1.3.19/test/test_ui_init.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_ui_init.py 2016-12-17 03:01:23.000000000 +0000 @@ -18,8 +18,8 @@ from __future__ import division, absolute_import, print_function +import unittest from test import _common -from test._common import unittest from beets import ui diff -Nru beets-1.3.19/test/test_ui.py beets-1.4.6/test/test_ui.py --- beets-1.3.19/test/test_ui.py 2016-06-26 00:42:09.000000000 +0000 +++ beets-1.4.6/test/test_ui.py 2017-10-03 19:33:23.000000000 +0000 @@ -23,10 +23,11 @@ import subprocess import platform from copy import deepcopy +import six +import unittest -from mock import patch +from mock import patch, Mock from test import _common -from test._common import unittest from test.helper import capture_stdout, has_program, TestHelper, control_stdin from beets import library @@ -39,6 +40,7 @@ from beets import plugins from beets.util.confit import ConfigError from beets import util +from beets.util import syspath, MoveOperation class ListTest(unittest.TestCase): @@ -49,12 +51,13 @@ self.lib.add(self.item) self.lib.add_album([self.item]) - def _run_list(self, query=u'', album=False, path=False, fmt=''): - commands.list_items(self.lib, query, album, fmt) + def _run_list(self, query=u'', album=False, path=False, fmt=u''): + with capture_stdout() as stdout: + commands.list_items(self.lib, query, album, fmt) + return stdout def test_list_outputs_item(self): - with capture_stdout() as stdout: - self._run_list() + stdout = self._run_list() self.assertIn(u'the title', stdout.getvalue()) def test_list_unicode_query(self): @@ -62,57 +65,49 @@ self.item.store() self.lib._connection().commit() - with capture_stdout() as stdout: - self._run_list([u'na\xefve']) + stdout = self._run_list([u'na\xefve']) out = stdout.getvalue() - self.assertTrue(u'na\xefve' in out.decode(stdout.encoding)) + if six.PY2: + out = out.decode(stdout.encoding) + self.assertTrue(u'na\xefve' in out) def test_list_item_path(self): - with capture_stdout() as stdout: - self._run_list(fmt='$path') + stdout = self._run_list(fmt=u'$path') self.assertEqual(stdout.getvalue().strip(), u'xxx/yyy') def test_list_album_outputs_something(self): - with capture_stdout() as stdout: - self._run_list(album=True) + stdout = self._run_list(album=True) self.assertGreater(len(stdout.getvalue()), 0) def test_list_album_path(self): - with capture_stdout() as stdout: - self._run_list(album=True, fmt='$path') + stdout = self._run_list(album=True, fmt=u'$path') self.assertEqual(stdout.getvalue().strip(), u'xxx') def test_list_album_omits_title(self): - with capture_stdout() as stdout: - self._run_list(album=True) + stdout = self._run_list(album=True) self.assertNotIn(u'the title', stdout.getvalue()) def test_list_uses_track_artist(self): - with capture_stdout() as stdout: - self._run_list() + stdout = self._run_list() self.assertIn(u'the artist', stdout.getvalue()) self.assertNotIn(u'the album artist', stdout.getvalue()) def test_list_album_uses_album_artist(self): - with capture_stdout() as stdout: - self._run_list(album=True) + stdout = self._run_list(album=True) self.assertNotIn(u'the artist', stdout.getvalue()) self.assertIn(u'the album artist', stdout.getvalue()) def test_list_item_format_artist(self): - with capture_stdout() as stdout: - self._run_list(fmt='$artist') + stdout = self._run_list(fmt=u'$artist') self.assertIn(u'the artist', stdout.getvalue()) def test_list_item_format_multiple(self): - with capture_stdout() as stdout: - self._run_list(fmt='$artist - $album - $year') + stdout = self._run_list(fmt=u'$artist - $album - $year') self.assertEqual(u'the artist - the album - 0001', stdout.getvalue().strip()) def test_list_album_format(self): - with capture_stdout() as stdout: - self._run_list(album=True, fmt='$genre') + stdout = self._run_list(album=True, fmt=u'$genre') self.assertIn(u'the genre', stdout.getvalue()) self.assertNotIn(u'the album', stdout.getvalue()) @@ -131,7 +126,7 @@ item_path = os.path.join(_common.RSRC, b'full.mp3') self.i = library.Item.from_path(item_path) self.lib.add(self.i) - self.i.move(True) + self.i.move(operation=MoveOperation.COPY) def test_remove_items_no_delete(self): self.io.addinput('y') @@ -172,7 +167,7 @@ def modify_inp(self, inp, *args): with control_stdin(inp): - ui._raw_main(['modify'] + list(args), self.lib) + self.run_command('modify', *args) def modify(self, *args): self.modify_inp('y', *args) @@ -295,7 +290,7 @@ def test_write_initial_key_tag(self): self.modify(u"initial_key=C#m") item = self.lib.items().get() - mediafile = MediaFile(item.path) + mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.initial_key, u'C#m') def test_set_flexattr(self): @@ -319,11 +314,11 @@ item.write() item.store() - mediafile = MediaFile(item.path) + mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.initial_key, u'C#m') self.modify(u"initial_key!") - mediafile = MediaFile(item.path) + mediafile = MediaFile(syspath(item.path)) self.assertIsNone(mediafile.initial_key) def test_arg_parsing_colon_query(self): @@ -360,7 +355,7 @@ self.teardown_beets() def write_cmd(self, *args): - ui._raw_main(['write'] + list(args), self.lib) + return self.run_with_output('write', *args) def test_update_mtime(self): item = self.add_item_fixture() @@ -386,10 +381,9 @@ item.bitrate = 123 item.store() - with capture_stdout() as stdout: - self.write_cmd() + output = self.write_cmd() - self.assertEqual(stdout.getvalue(), '') + self.assertEqual(output, '') def test_write_metadata_field(self): item = self.add_item_fixture() @@ -399,11 +393,10 @@ item.title = u'new title' item.store() - with capture_stdout() as stdout: - self.write_cmd() + output = self.write_cmd() self.assertTrue(u'{0} -> new title'.format(old_title) - in stdout.getvalue()) + in output) class MoveTest(_common.TestCase): @@ -428,8 +421,9 @@ self.otherdir = os.path.join(self.temp_dir, b'testotherdir') def _move(self, query=(), dest=None, copy=False, album=False, - pretend=False): - commands.move_items(self.lib, dest, query, copy, album, pretend) + pretend=False, export=False): + commands.move_items(self.lib, dest, query, copy, album, pretend, + export=export) def test_move_item(self): self._move() @@ -483,6 +477,24 @@ self.i.load() self.assertIn(b'srcfile', self.i.path) + def test_export_item_custom_dir(self): + self._move(dest=self.otherdir, export=True) + self.i.load() + self.assertEqual(self.i.path, self.itempath) + self.assertExists(self.otherdir) + + def test_export_album_custom_dir(self): + self._move(dest=self.otherdir, album=True, export=True) + self.i.load() + self.assertEqual(self.i.path, self.itempath) + self.assertExists(self.otherdir) + + def test_pretend_export_item(self): + self._move(dest=self.otherdir, pretend=True, export=True) + self.i.load() + self.assertIn(b'srcfile', self.i.path) + self.assertNotExists(self.otherdir) + class UpdateTest(_common.TestCase): def setUp(self): @@ -497,7 +509,7 @@ item_path = os.path.join(_common.RSRC, b'full.mp3') self.i = library.Item.from_path(item_path) self.lib.add(self.i) - self.i.move(True) + self.i.move(operation=MoveOperation.COPY) self.album = self.lib.add_album([self.i]) # Album art. @@ -507,12 +519,14 @@ self.album.store() os.remove(artfile) - def _update(self, query=(), album=False, move=False, reset_mtime=True): + def _update(self, query=(), album=False, move=False, reset_mtime=True, + fields=None): self.io.addinput('y') if reset_mtime: self.i.mtime = 0 self.i.store() - commands.update_items(self.lib, query, album, move, False) + commands.update_items(self.lib, query, album, move, False, + fields=fields) def test_delete_removes_item(self): self.assertTrue(list(self.lib.items())) @@ -534,7 +548,7 @@ self.assertNotExists(artpath) def test_modified_metadata_detected(self): - mf = MediaFile(self.i.path) + mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() self._update() @@ -542,7 +556,7 @@ self.assertEqual(item.title, u'differentTitle') def test_modified_metadata_moved(self): - mf = MediaFile(self.i.path) + mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() self._update(move=True) @@ -550,15 +564,35 @@ self.assertTrue(b'differentTitle' in item.path) def test_modified_metadata_not_moved(self): - mf = MediaFile(self.i.path) + mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() self._update(move=False) item = self.lib.items().get() self.assertTrue(b'differentTitle' not in item.path) + def test_selective_modified_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = u'differentTitle' + mf.genre = u'differentGenre' + mf.save() + self._update(move=True, fields=['title']) + item = self.lib.items().get() + self.assertTrue(b'differentTitle' in item.path) + self.assertNotEqual(item.genre, u'differentGenre') + + def test_selective_modified_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = u'differentTitle' + mf.genre = u'differentGenre' + mf.save() + self._update(move=False, fields=['title']) + item = self.lib.items().get() + self.assertTrue(b'differentTitle' not in item.path) + self.assertNotEqual(item.genre, u'differentGenre') + def test_modified_album_metadata_moved(self): - mf = MediaFile(self.i.path) + mf = MediaFile(syspath(self.i.path)) mf.album = u'differentAlbum' mf.save() self._update(move=True) @@ -567,15 +601,35 @@ def test_modified_album_metadata_art_moved(self): artpath = self.album.artpath - mf = MediaFile(self.i.path) + mf = MediaFile(syspath(self.i.path)) mf.album = u'differentAlbum' mf.save() self._update(move=True) album = self.lib.albums()[0] self.assertNotEqual(artpath, album.artpath) + def test_selective_modified_album_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = u'differentAlbum' + mf.genre = u'differentGenre' + mf.save() + self._update(move=True, fields=['album']) + item = self.lib.items().get() + self.assertTrue(b'differentAlbum' in item.path) + self.assertNotEqual(item.genre, u'differentGenre') + + def test_selective_modified_album_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = u'differentAlbum' + mf.genre = u'differentGenre' + mf.save() + self._update(move=True, fields=['genre']) + item = self.lib.items().get() + self.assertTrue(b'differentAlbum' not in item.path) + self.assertEqual(item.genre, u'differentGenre') + def test_mtime_match_skips_update(self): - mf = MediaFile(self.i.path) + mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() @@ -635,19 +689,6 @@ None) -class InputTest(_common.TestCase): - def setUp(self): - super(InputTest, self).setUp() - self.io.install() - - def test_manual_search_gets_unicode(self): - self.io.addinput(b'\xc3\x82me') - self.io.addinput(b'\xc3\x82me') - artist, album = commands.manual_search(False) - self.assertEqual(artist, u'\xc2me') - self.assertEqual(album, u'\xc2me') - - @_common.slow_test() class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): @@ -657,12 +698,12 @@ # directory there. Some tests will set `BEETSDIR` themselves. del os.environ['BEETSDIR'] self._old_home = os.environ.get('HOME') - os.environ['HOME'] = self.temp_dir + os.environ['HOME'] = util.py3_path(self.temp_dir) # Also set APPDATA, the Windows equivalent of setting $HOME. self._old_appdata = os.environ.get('APPDATA') os.environ['APPDATA'] = \ - os.path.join(self.temp_dir, 'AppData', 'Roaming') + util.py3_path(os.path.join(self.temp_dir, b'AppData', b'Roaming')) self._orig_cwd = os.getcwd() self.test_cmd = self._make_test_cmd() @@ -671,18 +712,18 @@ # Default user configuration if platform.system() == 'Windows': self.user_config_dir = os.path.join( - self.temp_dir, 'AppData', 'Roaming', 'beets' + self.temp_dir, b'AppData', b'Roaming', b'beets' ) else: self.user_config_dir = os.path.join( - self.temp_dir, '.config', 'beets' + self.temp_dir, b'.config', b'beets' ) os.makedirs(self.user_config_dir) self.user_config_path = os.path.join(self.user_config_dir, - 'config.yaml') + b'config.yaml') # Custom BEETSDIR - self.beetsdir = os.path.join(self.temp_dir, 'beetsdir') + self.beetsdir = os.path.join(self.temp_dir, b'beetsdir') os.makedirs(self.beetsdir) self._reset_config() @@ -721,7 +762,7 @@ with self.write_config_file() as config: config.write('paths: {x: y}') - ui._raw_main(['test']) + self.run_command('test', lib=None) key, template = self.test_cmd.lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') @@ -732,8 +773,7 @@ self._reset_config() with self.write_config_file() as config: config.write('paths: {x: y}') - - ui._raw_main(['test']) + self.run_command('test', lib=None) key, template = self.test_cmd.lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') @@ -745,28 +785,27 @@ config.write('library: /xxx/yyy/not/a/real/path') with self.assertRaises(ui.UserError): - ui._raw_main(['test']) + self.run_command('test', lib=None) def test_user_config_file(self): with self.write_config_file() as file: file.write('anoption: value') - ui._raw_main(['test']) + self.run_command('test', lib=None) self.assertEqual(config['anoption'].get(), 'value') def test_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z}") - ui._raw_main(['test']) + self.run_command('test', lib=None) replacements = self.test_cmd.lib.replacements self.assertEqual(replacements, [(re.compile(u'[xy]'), 'z')]) def test_multiple_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z, foo: bar}") - - ui._raw_main(['test']) + self.run_command('test', lib=None) replacements = self.test_cmd.lib.replacements self.assertEqual(replacements, [ (re.compile(u'[xy]'), u'z'), @@ -774,41 +813,38 @@ ]) def test_cli_config_option(self): - config_path = os.path.join(self.temp_dir, 'config.yaml') + config_path = os.path.join(self.temp_dir, b'config.yaml') with open(config_path, 'w') as file: file.write('anoption: value') - - ui._raw_main(['--config', config_path, 'test']) + self.run_command('--config', config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'value') def test_cli_config_file_overwrites_user_defaults(self): with open(self.user_config_path, 'w') as file: file.write('anoption: value') - cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') - - ui._raw_main(['--config', cli_config_path, 'test']) + self.run_command('--config', cli_config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'cli overwrite') def test_cli_config_file_overwrites_beetsdir_defaults(self): - os.environ['BEETSDIR'] = self.beetsdir - env_config_path = os.path.join(self.beetsdir, 'config.yaml') + os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) + env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('anoption: value') - cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') - - ui._raw_main(['--config', cli_config_path, 'test']) + self.run_command('--config', cli_config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'cli overwrite') # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_files(self): -# cli_config_path_1 = os.path.join(self.temp_dir, 'config.yaml') -# cli_config_path_2 = os.path.join(self.temp_dir, 'config_2.yaml') +# cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') +# cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') # # with open(cli_config_path_1, 'w') as file: # file.write('first: value') @@ -816,16 +852,16 @@ # with open(cli_config_path_2, 'w') as file: # file.write('second: value') # -# ui._raw_main(['--config', cli_config_path_1, -# '--config', cli_config_path_2, 'test']) +# self.run_command('--config', cli_config_path_1, +# '--config', cli_config_path_2, 'test', lib=None) # self.assertEqual(config['first'].get(), 'value') # self.assertEqual(config['second'].get(), 'value') # # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_overwrite(self): -# cli_config_path = os.path.join(self.temp_dir, 'config.yaml') +# cli_config_path = os.path.join(self.temp_dir, b'config.yaml') # cli_overwrite_config_path = os.path.join(self.temp_dir, -# 'overwrite_config.yaml') +# b'overwrite_config.yaml') # # with open(cli_config_path, 'w') as file: # file.write('anoption: value') @@ -833,59 +869,63 @@ # with open(cli_overwrite_config_path, 'w') as file: # file.write('anoption: overwrite') # -# ui._raw_main(['--config', cli_config_path, -# '--config', cli_overwrite_config_path, 'test']) +# self.run_command('--config', cli_config_path, +# '--config', cli_overwrite_config_path, 'test') # self.assertEqual(config['anoption'].get(), 'cli overwrite') def test_cli_config_paths_resolve_relative_to_user_dir(self): - cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') - ui._raw_main(['--config', cli_config_path, 'test']) + self.run_command('--config', cli_config_path, 'test', lib=None) self.assert_equal_path( - config['library'].as_filename(), - os.path.join(self.user_config_dir, 'beets.db') + util.bytestring_path(config['library'].as_filename()), + os.path.join(self.user_config_dir, b'beets.db') ) self.assert_equal_path( - config['statefile'].as_filename(), - os.path.join(self.user_config_dir, 'state') + util.bytestring_path(config['statefile'].as_filename()), + os.path.join(self.user_config_dir, b'state') ) def test_cli_config_paths_resolve_relative_to_beetsdir(self): - os.environ['BEETSDIR'] = self.beetsdir + os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) - cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') - ui._raw_main(['--config', cli_config_path, 'test']) - self.assert_equal_path(config['library'].as_filename(), - os.path.join(self.beetsdir, 'beets.db')) - self.assert_equal_path(config['statefile'].as_filename(), - os.path.join(self.beetsdir, 'state')) + self.run_command('--config', cli_config_path, 'test', lib=None) + self.assert_equal_path( + util.bytestring_path(config['library'].as_filename()), + os.path.join(self.beetsdir, b'beets.db') + ) + self.assert_equal_path( + util.bytestring_path(config['statefile'].as_filename()), + os.path.join(self.beetsdir, b'state') + ) def test_command_line_option_relative_to_working_dir(self): os.chdir(self.temp_dir) - ui._raw_main(['--library', 'foo.db', 'test']) + self.run_command('--library', 'foo.db', 'test', lib=None) self.assert_equal_path(config['library'].as_filename(), os.path.join(os.getcwd(), 'foo.db')) def test_cli_config_file_loads_plugin_commands(self): - cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('pluginpath: %s\n' % _common.PLUGINPATH) file.write('plugins: test') - ui._raw_main(['--config', cli_config_path, 'plugin']) + self.run_command('--config', cli_config_path, 'plugin', lib=None) self.assertTrue(plugins.find_plugins()[0].is_test_plugin) def test_beetsdir_config(self): - os.environ['BEETSDIR'] = self.beetsdir + os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) - env_config_path = os.path.join(self.beetsdir, 'config.yaml') + env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('anoption: overwrite') @@ -893,13 +933,13 @@ self.assertEqual(config['anoption'].get(), 'overwrite') def test_beetsdir_points_to_file_error(self): - beetsdir = os.path.join(self.temp_dir, 'beetsfile') + beetsdir = os.path.join(self.temp_dir, b'beetsfile') open(beetsdir, 'a').close() - os.environ['BEETSDIR'] = beetsdir - self.assertRaises(ConfigError, ui._raw_main, ['test']) + os.environ['BEETSDIR'] = util.py3_path(beetsdir) + self.assertRaises(ConfigError, self.run_command, 'test') def test_beetsdir_config_does_not_load_default_user_config(self): - os.environ['BEETSDIR'] = self.beetsdir + os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) with open(self.user_config_path, 'w') as file: file.write('anoption: value') @@ -908,27 +948,35 @@ self.assertFalse(config['anoption'].exists()) def test_default_config_paths_resolve_relative_to_beetsdir(self): - os.environ['BEETSDIR'] = self.beetsdir + os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) config.read() - self.assertEqual(config['library'].as_filename(), - os.path.join(self.beetsdir, 'library.db')) - self.assertEqual(config['statefile'].as_filename(), - os.path.join(self.beetsdir, 'state.pickle')) + self.assert_equal_path( + util.bytestring_path(config['library'].as_filename()), + os.path.join(self.beetsdir, b'library.db') + ) + self.assert_equal_path( + util.bytestring_path(config['statefile'].as_filename()), + os.path.join(self.beetsdir, b'state.pickle') + ) def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): - os.environ['BEETSDIR'] = self.beetsdir + os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) - env_config_path = os.path.join(self.beetsdir, 'config.yaml') + env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') config.read() - self.assertEqual(config['library'].as_filename(), - os.path.join(self.beetsdir, 'beets.db')) - self.assertEqual(config['statefile'].as_filename(), - os.path.join(self.beetsdir, 'state')) + self.assert_equal_path( + util.bytestring_path(config['library'].as_filename()), + os.path.join(self.beetsdir, b'beets.db') + ) + self.assert_equal_path( + util.bytestring_path(config['statefile'].as_filename()), + os.path.join(self.beetsdir, b'state') + ) class ShowModelChangeTest(_common.TestCase): @@ -1013,7 +1061,7 @@ autotag.AlbumMatch(album_dist, info, mapping, set(), set()), ) # FIXME decoding shouldn't be done here - return self.io.getoutput().lower().decode('utf8') + return util.text_string(self.io.getoutput().lower()) def test_null_change(self): msg = self._show_change() @@ -1047,12 +1095,13 @@ def test_item_data_change_title_missing_with_unicode_filename(self): self.items[0].title = u'' - self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8') + self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf-8') msg = re.sub(r' +', ' ', self._show_change()) self.assertTrue(u'caf\xe9.mp3 -> the title' in msg or u'caf.mp3 ->' in msg) +@patch('beets.library.Item.try_filesize', Mock(return_value=987)) class SummarizeItemsTest(_common.TestCase): def setUp(self): super(SummarizeItemsTest, self).setUp() @@ -1061,8 +1110,6 @@ item.length = 10 * 60 + 54 item.format = "F" self.item = item - fsize_mock = patch('beets.library.Item.try_filesize').start() - fsize_mock.return_value = 987 def test_summarize_item(self): summary = commands.summarize_items([], True) @@ -1103,26 +1150,32 @@ @_common.slow_test() -class PluginTest(_common.TestCase): +class PluginTest(_common.TestCase, TestHelper): def test_plugin_command_from_pluginpath(self): config['pluginpath'] = [_common.PLUGINPATH] config['plugins'] = ['test'] - ui._raw_main(['test']) + self.run_command('test', lib=None) @_common.slow_test() -class CompletionTest(_common.TestCase): +class CompletionTest(_common.TestCase, TestHelper): def test_completion(self): # Load plugin commands config['pluginpath'] = [_common.PLUGINPATH] config['plugins'] = ['test'] - # Tests run in bash + # Do not load any other bash completion scripts on the system. + env = dict(os.environ) + env['BASH_COMPLETION_DIR'] = os.devnull + env['BASH_COMPLETION_COMPAT_DIR'] = os.devnull + + # Open a `bash` process to run the tests in. We'll pipe in bash + # commands via stdin. cmd = os.environ.get('BEETS_TEST_SHELL', '/bin/bash --norc').split() if not has_program(cmd[0]): self.skipTest(u'bash not available') tester = subprocess.Popen(cmd, stdin=subprocess.PIPE, - stdout=subprocess.PIPE) + stdout=subprocess.PIPE, env=env) # Load bash_completion library. for path in commands.BASH_COMPLETION_PATHS: @@ -1132,25 +1185,25 @@ else: self.skipTest(u'bash-completion script not found') try: - with open(util.syspath(bash_completion), 'r') as f: + with open(util.syspath(bash_completion), 'rb') as f: tester.stdin.writelines(f) except IOError: self.skipTest(u'could not read bash-completion script') # Load completion script. self.io.install() - ui._raw_main(['completion']) - completion_script = self.io.getoutput() + self.run_command('completion', lib=None) + completion_script = self.io.getoutput().encode('utf-8') self.io.restore() - tester.stdin.writelines(completion_script) + tester.stdin.writelines(completion_script.splitlines(True)) # Load test suite. - test_script = os.path.join(_common.RSRC, b'test_completion.sh') - with open(test_script, 'r') as test_script: - tester.stdin.writelines(test_script) - (out, err) = tester.communicate() - if tester.returncode != 0 or out != u"completion tests passed\n": - print(out) + test_script_name = os.path.join(_common.RSRC, b'test_completion.sh') + with open(test_script_name, 'rb') as test_script_file: + tester.stdin.writelines(test_script_file) + out, err = tester.communicate() + if tester.returncode != 0 or out != b'completion tests passed\n': + print(out.decode('utf-8')) self.fail(u'test/test_completion.sh did not execute properly') @@ -1192,7 +1245,7 @@ def test_format_option_unicode(self): l = self.run_with_output(b'ls', b'-f', - u'caf\xe9'.encode(ui._arg_encoding())) + u'caf\xe9'.encode(util.arg_encoding())) self.assertEqual(l, u'caf\xe9\n') def test_root_format_option(self): @@ -1225,6 +1278,7 @@ def test_version(self): l = self.run_with_output(u'version') + self.assertIn(u'Python version', l) self.assertIn(u'no plugins loaded', l) # # Need to have plugin loaded @@ -1257,15 +1311,15 @@ config['format_item'].set('$foo') self.assertEqual(parser.parse_args([]), ({'path': None}, [])) - self.assertEqual(config['format_item'].get(unicode), u'$foo') + self.assertEqual(config['format_item'].as_str(), u'$foo') self.assertEqual(parser.parse_args([u'-p']), ({'path': True, 'format': u'$path'}, [])) self.assertEqual(parser.parse_args(['--path']), ({'path': True, 'format': u'$path'}, [])) - self.assertEqual(config['format_item'].get(unicode), u'$path') - self.assertEqual(config['format_album'].get(unicode), u'$path') + self.assertEqual(config['format_item'].as_str(), u'$path') + self.assertEqual(config['format_album'].as_str(), u'$path') def test_format_option(self): parser = ui.CommonOptionsParser() @@ -1274,15 +1328,15 @@ config['format_item'].set('$foo') self.assertEqual(parser.parse_args([]), ({'format': None}, [])) - self.assertEqual(config['format_item'].get(unicode), u'$foo') + self.assertEqual(config['format_item'].as_str(), u'$foo') self.assertEqual(parser.parse_args([u'-f', u'$bar']), ({'format': u'$bar'}, [])) self.assertEqual(parser.parse_args([u'--format', u'$baz']), ({'format': u'$baz'}, [])) - self.assertEqual(config['format_item'].get(unicode), u'$baz') - self.assertEqual(config['format_album'].get(unicode), u'$baz') + self.assertEqual(config['format_item'].as_str(), u'$baz') + self.assertEqual(config['format_album'].as_str(), u'$baz') def test_format_option_with_target(self): with self.assertRaises(KeyError): @@ -1297,8 +1351,8 @@ self.assertEqual(parser.parse_args([u'-f', u'$bar']), ({'format': u'$bar'}, [])) - self.assertEqual(config['format_item'].get(unicode), u'$bar') - self.assertEqual(config['format_album'].get(unicode), u'$album') + self.assertEqual(config['format_item'].as_str(), u'$bar') + self.assertEqual(config['format_album'].as_str(), u'$album') def test_format_option_with_album(self): parser = ui.CommonOptionsParser() @@ -1309,15 +1363,15 @@ config['format_album'].set('$album') parser.parse_args([u'-f', u'$bar']) - self.assertEqual(config['format_item'].get(unicode), u'$bar') - self.assertEqual(config['format_album'].get(unicode), u'$album') + self.assertEqual(config['format_item'].as_str(), u'$bar') + self.assertEqual(config['format_album'].as_str(), u'$album') parser.parse_args([u'-a', u'-f', u'$foo']) - self.assertEqual(config['format_item'].get(unicode), u'$bar') - self.assertEqual(config['format_album'].get(unicode), u'$foo') + self.assertEqual(config['format_item'].as_str(), u'$bar') + self.assertEqual(config['format_album'].as_str(), u'$foo') parser.parse_args([u'-f', u'$foo2', u'-a']) - self.assertEqual(config['format_album'].get(unicode), u'$foo2') + self.assertEqual(config['format_album'].as_str(), u'$foo2') def test_add_all_common_options(self): parser = ui.CommonOptionsParser() @@ -1342,12 +1396,12 @@ def out_encoding_default_utf8(self): with patch('sys.stdout') as stdout: stdout.encoding = None - self.assertEqual(ui._out_encoding(), 'utf8') + self.assertEqual(ui._out_encoding(), 'utf-8') def in_encoding_default_utf8(self): with patch('sys.stdin') as stdin: stdin.encoding = None - self.assertEqual(ui._in_encoding(), 'utf8') + self.assertEqual(ui._in_encoding(), 'utf-8') def suite(): diff -Nru beets-1.3.19/test/test_util.py beets-1.4.6/test/test_util.py --- beets-1.3.19/test/test_util.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_util.py 2016-12-17 03:01:23.000000000 +0000 @@ -20,12 +20,13 @@ import re import os import subprocess +import unittest from mock import patch, Mock -from test._common import unittest from test import _common from beets import util +import six class UtilTest(unittest.TestCase): @@ -103,6 +104,15 @@ ]) self.assertEqual(p, u'foo/_/bar') + @unittest.skipIf(six.PY2, 'surrogateescape error handler not available' + 'on Python 2') + def test_convert_command_args_keeps_undecodeable_bytes(self): + arg = b'\x82' # non-ascii bytes + cmd_args = util.convert_command_args([arg]) + + self.assertEqual(cmd_args[0], + arg.decode(util.arg_encoding(), 'surrogateescape')) + @patch('beets.util.subprocess.Popen') def test_command_output(self, mock_popen): def popen_fail(*args, **kwargs): @@ -112,9 +122,9 @@ mock_popen.side_effect = popen_fail with self.assertRaises(subprocess.CalledProcessError) as exc_context: - util.command_output([b"taga", b"\xc3\xa9"]) + util.command_output(['taga', '\xc3\xa9']) self.assertEqual(exc_context.exception.returncode, 1) - self.assertEqual(exc_context.exception.cmd, b"taga \xc3\xa9") + self.assertEqual(exc_context.exception.cmd, 'taga \xc3\xa9') class PathConversionTest(_common.TestCase): @@ -122,7 +132,7 @@ with _common.platform_windows(): path = os.path.join(u'a', u'b', u'c') outpath = util.syspath(path) - self.assertTrue(isinstance(outpath, unicode)) + self.assertTrue(isinstance(outpath, six.text_type)) self.assertTrue(outpath.startswith(u'\\\\?\\')) def test_syspath_windows_format_unc_path(self): @@ -131,7 +141,7 @@ path = '\\\\server\\share\\file.mp3' with _common.platform_windows(): outpath = util.syspath(path) - self.assertTrue(isinstance(outpath, unicode)) + self.assertTrue(isinstance(outpath, six.text_type)) self.assertEqual(outpath, u'\\\\?\\UNC\\server\\share\\file.mp3') def test_syspath_posix_unchanged(self): @@ -152,12 +162,12 @@ def test_bytestring_path_windows_encodes_utf8(self): path = u'caf\xe9' outpath = self._windows_bytestring_path(path) - self.assertEqual(path, outpath.decode('utf8')) + self.assertEqual(path, outpath.decode('utf-8')) def test_bytesting_path_windows_removes_magic_prefix(self): path = u'\\\\?\\C:\\caf\xe9' outpath = self._windows_bytestring_path(path) - self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf8')) + self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf-8')) class PathTruncationTest(_common.TestCase): diff -Nru beets-1.3.19/test/test_vfs.py beets-1.4.6/test/test_vfs.py --- beets-1.3.19/test/test_vfs.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_vfs.py 2016-12-17 03:01:23.000000000 +0000 @@ -16,8 +16,8 @@ """Tests for the virtual filesystem builder..""" from __future__ import division, absolute_import, print_function +import unittest from test import _common -from test._common import unittest from beets import library from beets import vfs diff -Nru beets-1.3.19/test/test_web.py beets-1.4.6/test/test_web.py --- beets-1.3.19/test/test_web.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_web.py 2017-06-20 17:24:04.000000000 +0000 @@ -4,12 +4,13 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest -from test import _common import json -import beetsplug +import unittest +import os.path +from six import assertCountEqual + +from test import _common from beets.library import Item, Album -beetsplug.__path__ = ['./beetsplug', '../beetsplug'] # noqa from beetsplug import web @@ -21,15 +22,32 @@ # Add fixtures for track in self.lib.items(): track.remove() - self.lib.add(Item(title=u'title', path='', id=1)) - self.lib.add(Item(title=u'another title', path='', id=2)) + self.lib.add(Item(title=u'title', path='/path_1', id=1)) + self.lib.add(Item(title=u'another title', path='/path_2', id=2)) self.lib.add(Album(album=u'album', id=3)) self.lib.add(Album(album=u'another album', id=4)) web.app.config['TESTING'] = True web.app.config['lib'] = self.lib + web.app.config['INCLUDE_PATHS'] = False self.client = web.app.test_client() + def test_config_include_paths_true(self): + web.app.config['INCLUDE_PATHS'] = True + response = self.client.get('/item/1') + response.json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json['path'], u'/path_1') + + def test_config_include_paths_false(self): + web.app.config['INCLUDE_PATHS'] = False + response = self.client.get('/item/1') + response.json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertNotIn('path', response.json) + def test_get_all_items(self): response = self.client.get('/item/') response.json = json.loads(response.data.decode('utf-8')) @@ -52,12 +70,29 @@ self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json['items']), 2) response_titles = [item['title'] for item in response.json['items']] - self.assertItemsEqual(response_titles, [u'title', u'another title']) + assertCountEqual(self, response_titles, [u'title', u'another title']) def test_get_single_item_not_found(self): response = self.client.get('/item/3') self.assertEqual(response.status_code, 404) + def test_get_single_item_by_path(self): + data_path = os.path.join(_common.RSRC, b'full.mp3') + self.lib.add(Item.from_path(data_path)) + response = self.client.get('/item/path/' + data_path.decode('utf-8')) + response.json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json['title'], u'full') + + def test_get_single_item_by_path_not_found_if_not_in_library(self): + data_path = os.path.join(_common.RSRC, b'full.mp3') + # data_path points to a valid file, but we have not added the file + # to the library. + response = self.client.get('/item/path/' + data_path.decode('utf-8')) + + self.assertEqual(response.status_code, 404) + def test_get_item_empty_query(self): response = self.client.get('/item/query/') response.json = json.loads(response.data.decode('utf-8')) @@ -80,7 +115,7 @@ self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in response.json['albums']] - self.assertItemsEqual(response_albums, [u'album', u'another album']) + assertCountEqual(self, response_albums, [u'album', u'another album']) def test_get_single_album_by_id(self): response = self.client.get('/album/2') @@ -96,7 +131,7 @@ self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in response.json['albums']] - self.assertItemsEqual(response_albums, [u'album', u'another album']) + assertCountEqual(self, response_albums, [u'album', u'another album']) def test_get_album_empty_query(self): response = self.client.get('/album/query/') diff -Nru beets-1.3.19/test/test_zero.py beets-1.4.6/test/test_zero.py --- beets-1.3.19/test/test_zero.py 2016-06-20 01:53:12.000000000 +0000 +++ beets-1.4.6/test/test_zero.py 2017-01-03 01:53:12.000000000 +0000 @@ -4,121 +4,312 @@ from __future__ import division, absolute_import, print_function -from test._common import unittest -from test.helper import TestHelper +import unittest +from test.helper import TestHelper, control_stdin from beets.library import Item -from beets import config from beetsplug.zero import ZeroPlugin from beets.mediafile import MediaFile +from beets.util import syspath class ZeroPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() + self.config['zero'] = { + 'fields': [], + 'keep_fields': [], + 'update_database': False, + } def tearDown(self): + ZeroPlugin.listeners = None self.teardown_beets() self.unload_plugins() def test_no_patterns(self): - tags = { - 'comments': u'test comment', - 'day': 13, - 'month': 3, - 'year': 2012, - } - z = ZeroPlugin() - z.debug = False - z.fields = ['comments', 'month', 'day'] - z.patterns = {'comments': [u'.'], - 'month': [u'.'], - 'day': [u'.']} - z.write_event(None, None, tags) - self.assertEqual(tags['comments'], None) - self.assertEqual(tags['day'], None) - self.assertEqual(tags['month'], None) - self.assertEqual(tags['year'], 2012) + self.config['zero']['fields'] = ['comments', 'month'] - def test_patterns(self): - z = ZeroPlugin() - z.debug = False - z.fields = ['comments', 'year'] - z.patterns = {'comments': u'eac lame'.split(), - 'year': u'2098 2099'.split()} - - tags = { - 'comments': u'from lame collection, ripped by eac', - 'year': 2012, - } - z.write_event(None, None, tags) - self.assertEqual(tags['comments'], None) - self.assertEqual(tags['year'], 2012) + item = self.add_item_fixture( + comments=u'test comment', + title=u'Title', + month=1, + year=2000, + ) + item.write() - def test_delete_replaygain_tag(self): - path = self.create_mediafile_fixture() - item = Item.from_path(path) - item.rg_track_peak = 0.0 + self.load_plugins('zero') item.write() - mediafile = MediaFile(item.path) - self.assertIsNotNone(mediafile.rg_track_peak) - self.assertIsNotNone(mediafile.rg_track_gain) + mf = MediaFile(syspath(item.path)) + self.assertIsNone(mf.comments) + self.assertIsNone(mf.month) + self.assertEqual(mf.title, u'Title') + self.assertEqual(mf.year, 2000) + + def test_pattern_match(self): + self.config['zero']['fields'] = ['comments'] + self.config['zero']['comments'] = [u'encoded by'] + + item = self.add_item_fixture(comments=u'encoded by encoder') + item.write() - config['zero'] = { - 'fields': ['rg_track_peak', 'rg_track_gain'], - } self.load_plugins('zero') + item.write() + + mf = MediaFile(syspath(item.path)) + self.assertIsNone(mf.comments) + + def test_pattern_nomatch(self): + self.config['zero']['fields'] = ['comments'] + self.config['zero']['comments'] = [u'encoded by'] + item = self.add_item_fixture(comments=u'recorded at place') item.write() - mediafile = MediaFile(item.path) - self.assertIsNone(mediafile.rg_track_peak) - self.assertIsNone(mediafile.rg_track_gain) + + self.load_plugins('zero') + item.write() + + mf = MediaFile(syspath(item.path)) + self.assertEqual(mf.comments, u'recorded at place') def test_do_not_change_database(self): + self.config['zero']['fields'] = ['year'] + item = self.add_item_fixture(year=2000) item.write() - mediafile = MediaFile(item.path) - self.assertEqual(2000, mediafile.year) - config['zero'] = {'fields': ['year']} self.load_plugins('zero') - item.write() - mediafile = MediaFile(item.path) + self.assertEqual(item['year'], 2000) - self.assertIsNone(mediafile.year) def test_change_database(self): + self.config['zero']['fields'] = ['year'] + self.config['zero']['update_database'] = True + item = self.add_item_fixture(year=2000) item.write() - mediafile = MediaFile(item.path) - self.assertEqual(2000, mediafile.year) - config['zero'] = { - 'fields': [u'year'], - 'update_database': True, - } self.load_plugins('zero') - item.write() - mediafile = MediaFile(item.path) + self.assertEqual(item['year'], 0) - self.assertIsNone(mediafile.year) def test_album_art(self): + self.config['zero']['fields'] = ['images'] + path = self.create_mediafile_fixture(images=['jpg']) item = Item.from_path(path) - mediafile = MediaFile(item.path) - self.assertNotEqual(0, len(mediafile.images)) + self.load_plugins('zero') + item.write() + + mf = MediaFile(syspath(path)) + self.assertEqual(0, len(mf.images)) + + def test_auto_false(self): + self.config['zero']['fields'] = ['year'] + self.config['zero']['update_database'] = True + self.config['zero']['auto'] = False + + item = self.add_item_fixture(year=2000) + item.write() + + self.load_plugins('zero') + item.write() + + self.assertEqual(item['year'], 2000) + + def test_subcommand_update_database_true(self): + item = self.add_item_fixture( + year=2016, + day=13, + month=3, + comments=u'test comment' + ) + item.write() + item_id = item.id + self.config['zero']['fields'] = ['comments'] + self.config['zero']['update_database'] = True + self.config['zero']['auto'] = False + + self.load_plugins('zero') + with control_stdin('y'): + self.run_command('zero') + + mf = MediaFile(syspath(item.path)) + item = self.lib.get_item(item_id) + + self.assertEqual(item['year'], 2016) + self.assertEqual(mf.year, 2016) + self.assertEqual(mf.comments, None) + self.assertEqual(item['comments'], u'') + + def test_subcommand_update_database_false(self): + item = self.add_item_fixture( + year=2016, + day=13, + month=3, + comments=u'test comment' + ) + item.write() + item_id = item.id + + self.config['zero']['fields'] = ['comments'] + self.config['zero']['update_database'] = False + self.config['zero']['auto'] = False - config['zero'] = {'fields': [u'images']} self.load_plugins('zero') + with control_stdin('y'): + self.run_command('zero') + mf = MediaFile(syspath(item.path)) + item = self.lib.get_item(item_id) + + self.assertEqual(item['year'], 2016) + self.assertEqual(mf.year, 2016) + self.assertEqual(item['comments'], u'test comment') + self.assertEqual(mf.comments, None) + + def test_subcommand_query_include(self): + item = self.add_item_fixture( + year=2016, + day=13, + month=3, + comments=u'test comment' + ) + + item.write() + + self.config['zero']['fields'] = ['comments'] + self.config['zero']['update_database'] = False + self.config['zero']['auto'] = False + + self.load_plugins('zero') + self.run_command('zero', 'year: 2016') + + mf = MediaFile(syspath(item.path)) + + self.assertEqual(mf.year, 2016) + self.assertEqual(mf.comments, None) + + def test_subcommand_query_exclude(self): + item = self.add_item_fixture( + year=2016, + day=13, + month=3, + comments=u'test comment' + ) + + item.write() + + self.config['zero']['fields'] = ['comments'] + self.config['zero']['update_database'] = False + self.config['zero']['auto'] = False + + self.load_plugins('zero') + self.run_command('zero', 'year: 0000') + + mf = MediaFile(syspath(item.path)) + + self.assertEqual(mf.year, 2016) + self.assertEqual(mf.comments, u'test comment') + + def test_no_fields(self): + item = self.add_item_fixture(year=2016) + item.write() + mediafile = MediaFile(syspath(item.path)) + self.assertEqual(mediafile.year, 2016) + + item_id = item.id + + self.load_plugins('zero') + with control_stdin('y'): + self.run_command('zero') + + item = self.lib.get_item(item_id) + + self.assertEqual(item['year'], 2016) + self.assertEqual(mediafile.year, 2016) + + def test_whitelist_and_blacklist(self): + item = self.add_item_fixture(year=2016) + item.write() + mf = MediaFile(syspath(item.path)) + self.assertEqual(mf.year, 2016) + + item_id = item.id + self.config['zero']['fields'] = [u'year'] + self.config['zero']['keep_fields'] = [u'comments'] + + self.load_plugins('zero') + with control_stdin('y'): + self.run_command('zero') + + item = self.lib.get_item(item_id) + + self.assertEqual(item['year'], 2016) + self.assertEqual(mf.year, 2016) + + def test_keep_fields(self): + item = self.add_item_fixture(year=2016, comments=u'test comment') + self.config['zero']['keep_fields'] = [u'year'] + self.config['zero']['fields'] = None + self.config['zero']['update_database'] = True + + tags = { + 'comments': u'test comment', + 'year': 2016, + } + self.load_plugins('zero') + + z = ZeroPlugin() + z.write_event(item, item.path, tags) + self.assertEqual(tags['comments'], None) + self.assertEqual(tags['year'], 2016) + + def test_keep_fields_removes_preserved_tags(self): + self.config['zero']['keep_fields'] = [u'year'] + self.config['zero']['fields'] = None + self.config['zero']['update_database'] = True + + z = ZeroPlugin() + + self.assertNotIn('id', z.fields_to_progs) + + def test_fields_removes_preserved_tags(self): + self.config['zero']['fields'] = [u'year id'] + self.config['zero']['update_database'] = True + + z = ZeroPlugin() + + self.assertNotIn('id', z.fields_to_progs) + + def test_empty_query_n_response_no_changes(self): + item = self.add_item_fixture( + year=2016, + day=13, + month=3, + comments=u'test comment' + ) item.write() - mediafile = MediaFile(item.path) - self.assertEqual(0, len(mediafile.images)) + item_id = item.id + self.config['zero']['fields'] = ['comments'] + self.config['zero']['update_database'] = True + self.config['zero']['auto'] = False + + self.load_plugins('zero') + with control_stdin('n'): + self.run_command('zero') + + mf = MediaFile(syspath(item.path)) + item = self.lib.get_item(item_id) + + self.assertEqual(item['year'], 2016) + self.assertEqual(mf.year, 2016) + self.assertEqual(mf.comments, u'test comment') + self.assertEqual(item['comments'], u'test comment') def suite():