diff --git a/.hgtags b/.hgtags index 2cf45afe0..914d3b40b 100644 --- a/.hgtags +++ b/.hgtags @@ -17,3 +17,4 @@ c84744f4519be7416dc1653142f1763f406d6896 1.0rc1 f3cd4c138c6f40dc324a23bf01c4c7d97766477e 1.0rc2 6f29c0f4dc7025e8d8216ea960000c353886c4f4 v1.1.0-beta.1 f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2 +8f070ce28a7b33d8509b29a8dbe937109bbdbd21 v1.1.0-beta.3 diff --git a/README.rst b/README.rst index dc28d6d7e..ad2512c78 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ shockingly simple if you know a little Python. .. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://mpd.wikia.com/ -.. _MusicBrainz music collection: http://musicbrainz.org/show/collection/ +.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ .. _writing your own plugin: http://beets.readthedocs.org/page/plugins/#writing-plugins .. _HTML5 Audio: diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 85d64c319..839c2b56d 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -257,8 +257,8 @@ def distance(items, album_info, mapping): `album_info.tracks`. """ cur_artist, cur_album, _ = current_metadata(items) - cur_artist = cur_artist or '' - cur_album = cur_album or '' + cur_artist = cur_artist or u'' + cur_album = cur_album or u'' # These accumulate the possible distance components. The final # distance will be dist/dist_max. diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 071561a15..e67a78a09 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -45,8 +45,8 @@ class MusicBrainzAPIError(util.HumanReadableException): log = logging.getLogger('beets') RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', - 'labels', 'artist-credits'] -TRACK_INCLUDES = ['artists'] + 'labels', 'artist-credits', 'aliases'] +TRACK_INCLUDES = ['artists', 'aliases'] def configure(): """Set up the python-musicbrainz-ngs module according to settings @@ -58,6 +58,34 @@ def configure(): config['musicbrainz']['ratelimit'].get(int), ) +def _preferred_alias(aliases): + """Given an list of alias structures for an artist credit, select + and return the user's preferred alias alias or None if no matching + alias is found. + """ + if not aliases: + return + + # Only consider aliases that have locales set. + aliases = [a for a in aliases if 'locale' in a] + + # Search configured locales in order. + for locale in config['import']['languages'].as_str_seq(): + # Find matching aliases for this locale. + matches = [a for a in aliases if a['locale'] == locale] + # Skip to the next locale if we have no matches + if not matches: + continue + + # Find the aliases that have the primary flag set. + primaries = [a for a in matches if 'primary' in a] + # Take the primary if we have it, otherwise take the first + # match with the correct locale. + if primaries: + return primaries[0] + else: + return matches[0] + 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 @@ -74,12 +102,19 @@ def _flatten_artist_credit(credit): artist_sort_parts.append(el) else: + alias = _preferred_alias(el['artist'].get('alias-list', ())) + # An artist. - cur_artist_name = el['artist']['name'] + if alias: + cur_artist_name = alias['alias'] + else: + cur_artist_name = el['artist']['name'] artist_parts.append(cur_artist_name) # Artist sort name. - if 'sort-name' in el['artist']: + if alias: + artist_sort_parts.append(alias['sort-name']) + elif 'sort-name' in el['artist']: artist_sort_parts.append(el['artist']['sort-name']) else: artist_sort_parts.append(cur_artist_name) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 2ed50127c..210d7f1db 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -16,6 +16,9 @@ import: quiet: no singletons: no default_action: apply + languages: [] + detail: no + flat: no clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information"] diff --git a/beets/importer.py b/beets/importer.py index a6ff10fbc..a5371fabf 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -564,6 +564,15 @@ def read_tasks(session): yield ImportTask.item_task(item) continue + # A flat album import merges all items into one album. + if config['import']['flat'] and not config['import']['singletons']: + all_items = [] + for _, items in autotag.albums_in_dir(toppath): + all_items += items + yield ImportTask(toppath, toppath, all_items) + yield ImportTask.done_sentinel(toppath) + continue + # Produce paths under this directory. if _resume(): resume_dir = resume_dirs.get(toppath) @@ -634,7 +643,7 @@ def initial_lookup(session): log.debug('Looking up: %s' % displayable_path(task.paths)) task.set_candidates( - *autotag.tag_album(task.items, config['import']['timid'].get(bool)) + *autotag.tag_album(task.items) ) def user_query(session): diff --git a/beets/library.py b/beets/library.py index 2eb8c6bf1..81af5ce46 100644 --- a/beets/library.py +++ b/beets/library.py @@ -175,21 +175,6 @@ def _orelse(exp1, exp2): 'WHEN "" THEN {1} ' 'ELSE {0} END)').format(exp1, exp2) -# An SQLite function for regular expression matching. -def _regexp(expr, val): - """Return a boolean indicating whether the regular expression `expr` - matches `val`. - """ - if expr is None: - return False - val = util.as_string(val) - try: - res = re.search(expr, val) - except re.error: - # Invalid regular expression. - return False - return res is not None - # Path element formatting for templating. def format_for_path(value, key=None, pathmod=None): """Sanitize the value for inclusion in a path: replace separators @@ -218,6 +203,8 @@ def format_for_path(value, key=None, pathmod=None): elif key == 'samplerate': # Sample rate formatted as kHz. value = u'%ikHz' % ((value or 0) // 1000) + elif value is None: + value = u'' else: value = unicode(value) @@ -497,12 +484,51 @@ class Query(object): class FieldQuery(Query): """An abstract query that searches in a specific field for a - pattern. + pattern. Subclasses must provide a `value_match` class method, which + determines whether a certain pattern string matches a certain value + string. Subclasses also need to provide `clause` to implement the + same matching functionality in SQLite. """ def __init__(self, field, pattern): self.field = field self.pattern = pattern + @classmethod + def value_match(cls, pattern, value): + """Determine whether the value matches the pattern. Both + arguments are strings. + """ + raise NotImplementedError() + + @classmethod + def _raw_value_match(cls, pattern, value): + """Determine whether the value matches the pattern. The value + may have any type. + """ + return cls.value_match(pattern, util.as_string(value)) + + def match(self, item): + return self._raw_value_match(self.pattern, getattr(item, self.field)) + +class RegisteredFieldQuery(FieldQuery): + """A FieldQuery that uses a registered SQLite callback function. + Before it can be used to execute queries, the `register` method must + be called. + """ + def clause(self): + # Invoke the registered SQLite function. + clause = "{name}(?, {field})".format(name=self.__class__.__name__, + field=self.field) + return clause, [self.pattern] + + @classmethod + def register(cls, conn): + """Register this query's matching function with the SQLite + connection. This method should only be invoked when the query + type chooses not to override `clause`. + """ + conn.create_function(cls.__name__, 2, cls._raw_value_match) + class MatchQuery(FieldQuery): """A query that looks for exact matches in an item field.""" def clause(self): @@ -511,8 +537,11 @@ class MatchQuery(FieldQuery): pattern = buffer(pattern) return self.field + " = ?", [pattern] - def match(self, item): - return self.pattern == getattr(item, self.field) + # We override the "raw" version here as a special case because we + # want to compare objects before conversion. + @classmethod + def _raw_value_match(cls, pattern, value): + return pattern == value class SubstringQuery(FieldQuery): """A query that matches a substring in a specific item field.""" @@ -523,24 +552,22 @@ class SubstringQuery(FieldQuery): subvals = [search] return clause, subvals - def match(self, item): - value = util.as_string(getattr(item, self.field)) - return self.pattern.lower() in value.lower() + @classmethod + def value_match(cls, pattern, value): + return pattern.lower() in value.lower() -class RegexpQuery(FieldQuery): - """A query that matches a regular expression in a specific item field.""" - def __init__(self, field, pattern): - super(RegexpQuery, self).__init__(field, pattern) - self.regexp = re.compile(pattern) - - def clause(self): - clause = self.field + " REGEXP ?" - subvals = [self.pattern] - return clause, subvals - - def match(self, item): - value = util.as_string(getattr(item, self.field)) - return self.regexp.search(value) is not None +class RegexpQuery(RegisteredFieldQuery): + """A query that matches a regular expression in a specific item + field. + """ + @classmethod + def value_match(cls, pattern, value): + try: + res = re.search(pattern, value) + except re.error: + # Invalid regular expression. + return False + return res is not None class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a @@ -592,103 +619,25 @@ class CollectionQuery(Query): clause = (' ' + joiner + ' ').join(clause_parts) return clause, subvals - # Regular expression for _parse_query_part, below. - _pq_regex = re.compile( - # Non-capturing optional segment for the keyword. - r'(?:' - r'(\S+?)' # The field key. - r'(? ']))) // 2 if lines: max_width = max(w for _, _, w in lines) for lhs, rhs, lhs_width in lines: - if max_width > col_width: + if not rhs: + print_(u' * {0}'.format(lhs)) + elif max_width > col_width: print_(u' * %s ->\n %s' % (lhs, rhs)) else: pad = max_width - lhs_width @@ -639,9 +643,11 @@ def import_files(lib, paths, query): for path in paths: fullpath = syspath(normpath(path)) if not config['import']['singletons'] and not os.path.isdir(fullpath): - raise ui.UserError('not a directory: ' + path) + raise ui.UserError(u'not a directory: {0}'.format( + displayable_path(path))) elif config['import']['singletons'] and not os.path.exists(fullpath): - raise ui.UserError('no such file: ' + path) + raise ui.UserError(u'no such file: {0}'.format( + displayable_path(path))) # Check parameter consistency. if config['import']['quiet'] and config['import']['timid']: @@ -709,6 +715,8 @@ import_cmd.parser.add_option('-i', '--incremental', dest='incremental', action='store_true', help='skip already-imported directories') import_cmd.parser.add_option('-I', '--noincremental', dest='incremental', action='store_false', help='do not skip already-imported directories') +import_cmd.parser.add_option('--flat', dest='flat', + action='store_true', help='import an entire tree as a single album') def import_func(lib, opts, args): config['import'].set_args(opts) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 2e2ce52b5..2dac5c89f 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -157,7 +157,16 @@ class AcoustidPlugin(plugins.BeetsPlugin): raise ui.UserError('no Acoustid user API key provided') submit_items(apikey, lib.items(ui.decargs(args))) submit_cmd.func = submit_cmd_func - return [submit_cmd] + + fingerprint_cmd = ui.Subcommand('fingerprint', + help='generate fingerprints for items without them') + def fingerprint_cmd_func(lib, opts, args): + for item in lib.items(ui.decargs(args)): + fingerprint_item(item, lib=lib, + write=config['import']['write'].get(bool)) + fingerprint_cmd.func = fingerprint_cmd_func + + return [submit_cmd, fingerprint_cmd] # Hooks into import process. @@ -191,32 +200,14 @@ def submit_items(userkey, items, chunksize=64): def submit_chunk(): """Submit the current accumulated fingerprint data.""" log.info('submitting {0} fingerprints'.format(len(data))) - acoustid.submit(API_KEY, userkey, data) + try: + acoustid.submit(API_KEY, userkey, data) + except acoustid.AcoustidError as exc: + log.warn(u'acoustid submission error: {}'.format(exc)) del data[:] for item in items: - # Get a fingerprint and length for this track. - if not item.length: - log.info(u'{0}: no duration available'.format( - util.displayable_path(item.path) - )) - continue - elif item.acoustid_fingerprint: - log.info(u'{0}: using existing fingerprint'.format( - util.displayable_path(item.path) - )) - fp = item.acoustid_fingerprint - else: - log.info(u'{0}: fingerprinting'.format( - util.displayable_path(item.path) - )) - try: - _, fp = acoustid.fingerprint_file(item.path) - except acoustid.FingerprintGenerationError as exc: - log.info( - 'fingerprint generation failed: {0}'.format(exc) - ) - continue + fp = fingerprint_item(item) # Construct a submission dictionary for this item. item_data = { @@ -246,3 +237,46 @@ def submit_items(userkey, items, chunksize=64): # Submit remaining data in a final chunk. if data: submit_chunk() + + +def fingerprint_item(item, lib=None, write=False): + """Get the fingerprint for an Item. If the item already has a + fingerprint, it is not regenerated. If fingerprint generation fails, + return None. If `lib` is provided, then new fingerprints are saved + to the database. If `write` is set, then the new fingerprints are + also written to files' metadata. + """ + # Get a fingerprint and length for this track. + if not item.length: + log.info(u'{0}: no duration available'.format( + util.displayable_path(item.path) + )) + elif item.acoustid_fingerprint: + if write: + log.info(u'{0}: fingerprint exists, skipping'.format( + util.displayable_path(item.path) + )) + else: + log.info(u'{0}: using existing fingerprint'.format( + util.displayable_path(item.path) + )) + return item.acoustid_fingerprint + else: + log.info(u'{0}: fingerprinting'.format( + util.displayable_path(item.path) + )) + try: + _, fp = acoustid.fingerprint_file(item.path) + item.acoustid_fingerprint = fp + if write: + log.info(u'{0}: writing fingerprint'.format( + util.displayable_path(item.path) + )) + item.write() + if lib: + lib.store(item) + return item.acoustid_fingerprint + except acoustid.FingerprintGenerationError as exc: + log.info( + 'fingerprint generation failed: {0}'.format(exc) + ) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 8ff078bed..8105dbbc1 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -18,6 +18,7 @@ import logging import os import threading from subprocess import Popen +import tempfile from beets.plugins import BeetsPlugin from beets import ui, util @@ -27,33 +28,55 @@ from beets import config log = logging.getLogger('beets') DEVNULL = open(os.devnull, 'wb') _fs_lock = threading.Lock() +_temp_files = [] # Keep track of temporary transcoded files for deletion. + + +def _destination(lib, dest_dir, item, keep_new): + """Return the path under `dest_dir` where the file should be placed + (possibly after conversion). + """ + dest = lib.destination(item, basedir=dest_dir) + if keep_new: + # When we're keeping the converted file, no extension munging + # occurs. + return dest + else: + # Otherwise, replace the extension with .mp3. + return os.path.splitext(dest)[0] + '.mp3' def encode(source, dest): log.info(u'Started encoding {0}'.format(util.displayable_path(source))) opts = config['convert']['opts'].get(unicode).split(u' ') - encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i', source] + - opts + [dest], + encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i', + source, '-y'] + opts + [dest], close_fds=True, stderr=DEVNULL) encode.wait() if encode.returncode != 0: # Something went wrong (probably Ctrl+C), remove temporary files - log.info(u'Encoding {0} failed. Cleaning up...'.format(source)) + log.info(u'Encoding {0} failed. Cleaning up...' + .format(util.displayable_path(source))) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) return log.info(u'Finished encoding {0}'.format(util.displayable_path(source))) -def convert_item(lib, dest_dir): +def should_transcode(item): + """Determine whether the item should be transcoded as part of + conversion (i.e., its bitrate is high or it has the wrong format). + """ + maxbr = config['convert']['max_bitrate'].get(int) + return item.format != 'MP3' or item.bitrate >= 1000 * maxbr + + +def convert_item(lib, dest_dir, keep_new): while True: item = yield + dest = _destination(lib, dest_dir, item, keep_new) - dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) - dest = os.path.splitext(dest)[0] + '.mp3' - - if os.path.exists(dest): + if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( util.displayable_path(item.path) )) @@ -65,16 +88,40 @@ def convert_item(lib, dest_dir): with _fs_lock: util.mkdirall(dest) - maxbr = config['convert']['max_bitrate'].get(int) - if item.format == 'MP3' and item.bitrate < 1000 * maxbr: - log.info(u'Copying {0}'.format(util.displayable_path(item.path))) - util.copy(item.path, dest) - else: - encode(item.path, dest) + # When keeping the new file in the library, we first move the + # current (pristine) file to the destination. We'll then copy it + # back to its old path or transcode it to a new path. + if keep_new: + log.info(u'Moving to {0}'. + format(util.displayable_path(dest))) + util.move(item.path, dest) - item.path = dest + if not should_transcode(item): + # No transcoding necessary. + log.info(u'Copying {0}'.format(util.displayable_path(item.path))) + if keep_new: + util.copy(dest, item.path) + else: + util.copy(item.path, dest) + + else: + if keep_new: + item.path = os.path.splitext(item.path)[0] + '.mp3' + encode(dest, item.path) + else: + encode(item.path, dest) + + # Write tags from the database to the converted file. + if not keep_new: + item.path = dest item.write() + # If we're keeping the transcoded file, read it again (after + # writing) to get new bitrate, duration, etc. + if keep_new: + item.read() + lib.store(item) # Store new path and audio data. + if config['convert']['embed']: album = lib.get_album(item) if album: @@ -83,13 +130,30 @@ def convert_item(lib, dest_dir): _embed(artpath, [item]) +def convert_on_import(lib, item): + """Transcode a file automatically after it is imported into the + library. + """ + if should_transcode(item): + fd, dest = tempfile.mkstemp('.mp3') + os.close(fd) + _temp_files.append(dest) # Delete the transcode later. + encode(item.path, dest) + item.path = dest + item.write() + item.read() # Load new audio information data. + lib.store(item) + + def convert_func(lib, opts, args): dest = opts.dest if opts.dest is not None else \ config['convert']['dest'].get() if not dest: raise ui.UserError('no convert destination set') + dest = util.bytestring_path(dest) threads = opts.threads if opts.threads is not None else \ config['convert']['threads'].get(int) + keep_new = opts.keep_new ui.commands.list_items(lib, ui.decargs(args), opts.album, None) @@ -100,7 +164,7 @@ def convert_func(lib, opts, args): items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) else: items = lib.items(ui.decargs(args)) - convert = [convert_item(lib, dest) for i in range(threads)] + convert = [convert_item(lib, dest, keep_new) for i in range(threads)] pipe = util.pipeline.Pipeline([items, convert]) pipe.run_parallel() @@ -115,7 +179,9 @@ class ConvertPlugin(BeetsPlugin): u'opts': u'-aq 2', u'max_bitrate': 500, u'embed': True, + u'auto': False }) + self.import_stages = [self.auto_convert] def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') @@ -124,7 +190,27 @@ class ConvertPlugin(BeetsPlugin): cmd.parser.add_option('-t', '--threads', action='store', type='int', help='change the number of threads, \ defaults to maximum availble processors ') + cmd.parser.add_option('-k', '--keep-new', action='store_true', + dest='keep_new', help='keep only the converted \ + and move the old files') cmd.parser.add_option('-d', '--dest', action='store', help='set the destination directory') cmd.func = convert_func return [cmd] + + def auto_convert(self, config, task): + if self.config['auto'].get(): + if not task.is_album: + convert_on_import(config.lib, task.item) + else: + for item in task.items: + convert_on_import(config.lib, item) + + +@ConvertPlugin.listen('import_task_files') +def _cleanup(task, session): + for path in task.old_paths: + if path in _temp_files: + if os.path.isfile(path): + util.remove(path) + _temp_files.remove(path) diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py index 8e0b119bb..d3a55d213 100644 --- a/beetsplug/echonest_tempo.py +++ b/beetsplug/echonest_tempo.py @@ -22,6 +22,7 @@ from beets import ui from beets import config import pyechonest.config import pyechonest.song +import socket # Global logger. log = logging.getLogger('beets') @@ -79,7 +80,7 @@ def get_tempo(artist, title): else: log.warn(u'echonest_tempo: {0}'.format(e.args[0][0])) return None - except pyechonest.util.EchoNestIOError as e: + except (pyechonest.util.EchoNestIOError, socket.error) as e: log.debug(u'echonest_tempo: IO error: {0}'.format(e)) time.sleep(RETRY_INTERVAL) else: diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 74693fd4b..b6ad90d87 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -12,86 +12,34 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Like beet list, but with fuzzy matching +"""Provides a fuzzy matching query. """ + from beets.plugins import BeetsPlugin -from beets.ui import Subcommand, decargs, print_obj -from beets.util.functemplate import Template -from beets import config +from beets.library import RegisteredFieldQuery +import beets import difflib -def fuzzy_score(queryMatcher, item): - queryMatcher.set_seq1(item) - return queryMatcher.quick_ratio() +class FuzzyQuery(RegisteredFieldQuery): + @classmethod + def value_match(self, pattern, val): + # smartcase + if pattern.islower(): + val = val.lower() + queryMatcher = difflib.SequenceMatcher(None, pattern, val) + threshold = beets.config['fuzzy']['threshold'].as_number() + return queryMatcher.quick_ratio() >= threshold -def is_match(queryMatcher, item, album=False, verbose=False, threshold=0.7): - if album: - values = [item.albumartist, item.album] - else: - values = [item.artist, item.album, item.title] - - s = max(fuzzy_score(queryMatcher, i.lower()) for i in values) - if verbose: - return (s >= threshold, s) - else: - return s >= threshold - - -def fuzzy_list(lib, opts, args): - query = decargs(args) - query = ' '.join(query).lower() - queryMatcher = difflib.SequenceMatcher(b=query) - - if opts.threshold is not None: - threshold = float(opts.threshold) - else: - threshold = config['fuzzy']['threshold'].as_number() - - if opts.path: - fmt = '$path' - else: - fmt = opts.format - template = Template(fmt) if fmt else None - - if opts.album: - objs = lib.albums() - else: - objs = lib.items() - - items = filter(lambda i: is_match(queryMatcher, i, album=opts.album, - threshold=threshold), objs) - - for item in items: - print_obj(item, lib, template) - if opts.verbose: - print(is_match(queryMatcher, item, - album=opts.album, verbose=True)[1]) - - -fuzzy_cmd = Subcommand('fuzzy', - help='list items using fuzzy matching') -fuzzy_cmd.parser.add_option('-a', '--album', action='store_true', - help='choose an album instead of track') -fuzzy_cmd.parser.add_option('-p', '--path', action='store_true', - help='print the path of the matched item') -fuzzy_cmd.parser.add_option('-f', '--format', action='store', - help='print with custom format', default=None) -fuzzy_cmd.parser.add_option('-v', '--verbose', action='store_true', - help='output scores for matches') -fuzzy_cmd.parser.add_option('-t', '--threshold', action='store', - help='return result with a fuzzy score above threshold. \ - (default is 0.7)', default=None) -fuzzy_cmd.func = fuzzy_list - - -class Fuzzy(BeetsPlugin): +class FuzzyPlugin(BeetsPlugin): def __init__(self): - super(Fuzzy, self).__init__() + super(FuzzyPlugin, self).__init__() self.config.add({ + 'prefix': '~', 'threshold': 0.7, }) - def commands(self): - return [fuzzy_cmd] + def queries(self): + prefix = beets.config['fuzzy']['prefix'].get(basestring) + return {prefix: FuzzyQuery} diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index a45e6013d..f160bb9a7 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -34,10 +34,11 @@ class ImportFeedsPlugin(BeetsPlugin): 'm3u_name': u'imported.m3u', 'dir': None, 'relative_to': None, + 'absolute_path': False, }) - + feeds_dir = self.config['dir'].get() - if feeds_dir: + 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)): @@ -92,9 +93,12 @@ def _record_items(lib, basename, items): paths = [] for item in items: - paths.append(os.path.relpath( - item.path, relative_to - )) + if config['importfeeds']['absolute_path']: + paths.append(item.path) + else: + paths.append(os.path.relpath( + item.path, relative_to + )) if 'm3u' in formats: basename = bytestring_path( diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 90e2d3578..4f9133fa9 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -19,26 +19,35 @@ from beets.ui import Subcommand from beets import ui from beets import config import musicbrainzngs -from musicbrainzngs import musicbrainz SUBMISSION_CHUNK_SIZE = 200 +def mb_call(func, *args, **kwargs): + """Call a MusicBrainz API function and catch exceptions. + """ + try: + return func(*args, **kwargs) + except musicbrainzngs.AuthenticationError: + raise ui.UserError('authentication with MusicBrainz failed') + except musicbrainzngs.ResponseError as exc: + raise ui.UserError('MusicBrainz API error: {0}'.format(exc)) + except musicbrainzngs.UsageError: + raise ui.UserError('MusicBrainz credentials missing') + def submit_albums(collection_id, release_ids): """Add all of the release IDs to the indicated collection. Multiple requests are made if there are many release IDs to submit. """ for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE] - releaselist = ";".join(chunk) - musicbrainz._mb_request( - "collection/%s/releases/%s" % (collection_id, releaselist), - 'PUT', True, True, body='foo') - # A non-empty request body is required to avoid a 411 "Length - # Required" error from the MB server. + mb_call( + musicbrainzngs.add_releases_to_collection, + collection_id, chunk + ) def update_collection(lib, opts, args): # Get the collection to modify. - collections = musicbrainz._mb_request('collection', 'GET', True, True) + collections = mb_call(musicbrainzngs.get_collections) if not collections['collection-list']: raise ui.UserError('no collections exist for user') collection_id = collections['collection-list'][0]['id'] @@ -60,7 +69,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): super(MusicBrainzCollectionPlugin, self).__init__() musicbrainzngs.auth( config['musicbrainz']['user'].get(unicode), - config['musicbrainz']['pass'].get(unicode) + config['musicbrainz']['pass'].get(unicode), ) def commands(self): diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py new file mode 100644 index 000000000..97ecee3ae --- /dev/null +++ b/beetsplug/mbsync.py @@ -0,0 +1,159 @@ +# This file is part of beets. +# Copyright 2013, Jakob Schnitzer. +# +# 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. + +"""Update library's tags using MusicBrainz. +""" +import logging + +from beets.plugins import BeetsPlugin +from beets import autotag, library, ui, util +from beets.autotag import hooks +from beets import config + +log = logging.getLogger('beets') + + +def _print_and_apply_changes(lib, item, move, pretend, write): + """Apply changes to an Item and preview them in the console. Return + a boolean indicating whether any changes were made. + """ + changes = {} + for key in library.ITEM_KEYS_META: + if item.dirty[key]: + changes[key] = item.old_data[key], getattr(item, key) + if not changes: + return False + + # Something changed. + ui.print_obj(item, lib) + for key, (oldval, newval) in changes.iteritems(): + ui.commands._showdiff(key, oldval, newval) + + # If we're just pretending, then don't move or save. + if not pretend: + # Move the item if it's in the library. + if move and lib.directory in util.ancestry(item.path): + lib.move(item, with_album=False) + + if write: + item.write() + lib.store(item) + + return True + + +def mbsync_singletons(lib, query, move, pretend, write): + """Synchronize matching singleton items. + """ + singletons_query = library.get_query(query, False) + singletons_query.subqueries.append(library.SingletonQuery(True)) + for s in lib.items(singletons_query): + if not s.mb_trackid: + log.info(u'Skipping singleton {0}: has no mb_trackid' + .format(s.title)) + continue + + s.old_data = dict(s.record) + + # Get the MusicBrainz recording info. + track_info = hooks._track_for_id(s.mb_trackid) + if not track_info: + log.info(u'Recording ID not found: {0}'.format(s.mb_trackid)) + continue + + # Apply. + with lib.transaction(): + autotag.apply_item_metadata(s, track_info) + _print_and_apply_changes(lib, s, move, pretend, write) + + +def mbsync_albums(lib, query, move, pretend, write): + """Synchronize matching albums. + """ + # Process matching albums. + for a in lib.albums(query): + if not a.mb_albumid: + log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) + continue + + items = list(a.items()) + for item in items: + item.old_data = dict(item.record) + + # Get the MusicBrainz album information. + album_info = hooks._album_for_id(a.mb_albumid) + if not album_info: + log.info(u'Release ID not found: {0}'.format(a.mb_albumid)) + continue + + # Construct a track mapping according to MBIDs. This should work + # for albums that have missing or extra tracks. + mapping = {} + for item in items: + for track_info in album_info.tracks: + if item.mb_trackid == track_info.track_id: + mapping[item] = track_info + break + + # Apply. + with lib.transaction(): + autotag.apply_metadata(album_info, mapping) + changed = False + for item in items: + changed = changed or \ + _print_and_apply_changes(lib, item, move, pretend, write) + if not changed: + # No change to any item. + continue + + if not pretend: + # Update album structure to reflect an item in it. + for key in library.ALBUM_KEYS_ITEM: + setattr(a, key, getattr(items[0], key)) + + # Move album art (and any inconsistent items). + if move and lib.directory in util.ancestry(items[0].path): + log.debug(u'moving album {0}'.format(a.id)) + a.move() + + +def mbsync_func(lib, opts, args): + """Command handler for the mbsync function. + """ + move = opts.move + pretend = opts.pretend + write = opts.write + query = ui.decargs(args) + + mbsync_singletons(lib, query, move, pretend, write) + mbsync_albums(lib, query, move, pretend, write) + + +class MBSyncPlugin(BeetsPlugin): + def __init__(self): + super(MBSyncPlugin, self).__init__() + + def commands(self): + cmd = ui.Subcommand('mbsync', + help='update metadata from musicbrainz') + cmd.parser.add_option('-p', '--pretend', action='store_true', + help='show all changes but do nothing') + cmd.parser.add_option('-M', '--nomove', action='store_false', + default=True, dest='move', + help="don't move files in library") + cmd.parser.add_option('-W', '--nowrite', action='store_false', + default=config['import']['write'], dest='write', + help="don't write updated metadata to files") + cmd.func = mbsync_func + return [cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index 086cc4b23..b75c3728e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,21 +1,77 @@ Changelog ========= -1.1b3 (in development) +1.1b3 (March 16, 2013) ---------------------- +This third beta of beets 1.1 brings a hodgepodge of little new features (and +internal overhauls that will make improvements easier in the future). There +are new options for getting metadata in a particular language and seeing more +detail during the import process. There's also a new plugin for synchronizing +your metadata with MusicBrainz. Under the hood, plugins can now extend the +query syntax. + +New configuration options: + +* :ref:`languages` controls the preferred languages when selecting an alias + from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 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. +* The ``--flat`` option to the ``beet import`` command treats an entire + directory tree of music files as a single album. This can help in situations + where a multi-disc album is split across multiple directories. +* :doc:`/plugins/importfeeds`: An option was added to use absolute, rather + than relative, paths. Thanks to Lucas Duailibe. + +Other stuff: + +* A new :doc:`/plugins/mbsync` provides a command that looks up each item and + track in MusicBrainz and updates your library to reflect it. This can help + you easily correct errors that have been fixed in the MB database. Thanks to + Jakob Schnitzer. +* :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a + new query type. To perform fuzzy searches, use the ``~`` prefix with + :ref:`list-cmd` or other commands. Thanks to Philippe Mongeau. +* As part of the above, plugins can now extend the query syntax and new kinds + of matching capabilities to beets. See :ref:`extend-query`. Thanks again to + Philippe Mongeau. +* :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store + transcoded files in your library while backing up the originals (instead of + vice-versa). Thanks to Lucas Duailibe. +* :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode + audio files automatically during import. Thanks again to Lucas Duailibe. +* :doc:`/plugins/chroma`: A new ``fingerprint`` command lets you generate and + store fingerprints for items that don't yet have them. One more round of + applause for Lucas Duailibe. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. * When the importer encounters an error (insufficient permissions, for example) when walking a directory tree, it now logs an error instead of crashing. +* In path formats, null database values now expand to the empty string instead + of the string "None". * Add "System Volume Information" (an internal directory found on some Windows filesystems) to the default ignore list. * Fix a crash when ReplayGain values were set to null. * Fix a crash when iTunes Sound Check tags contained invalid data. * Fix an error when the configuration file (``config.yaml``) is completely empty. +* Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to + Sam Doshi. +* :doc:`/plugins/convert`: Fix a bug when creating files with Unicode + pathnames. +* Fix a spurious warning from the Unidecode module when matching albums that + are missing all metadata. +* Fix Unicode errors when a directory or file doesn't exist when invoking the + import command. Thanks to Lucas Duailibe. +* :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when + MusicBrainz exceptions occur. +* :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by + the Echo Nest library. +* :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting + fingerprints. 1.1b2 (February 16, 2013) ------------------------- diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index c962f40e6..af9f3d04d 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -255,7 +255,7 @@ MusicBrainz---so consider adding the data yourself. If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. -.. _file a bug report: http://code.google.com/p/beets/issues/entry +.. _file a bug report: https://github.com/sampsyo/beets/issues I Hope That Makes Sense ----------------------- diff --git a/docs/index.rst b/docs/index.rst index 4ee7995d4..b7e274d98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,7 +18,7 @@ where you think this documentation can be improved. .. _beets: http://beets.radbox.org/ .. _email the author: mailto:adrian@radbox.org -.. _file a bug: http://code.google.com/p/beets/issues/entry +.. _file a bug: https://github.com/sampsyo/beets/issues Contents -------- diff --git a/docs/plugins/beetsweb.png b/docs/plugins/beetsweb.png index 7b04d9586..c335104eb 100644 Binary files a/docs/plugins/beetsweb.png and b/docs/plugins/beetsweb.png differ diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index b24a4816a..9fea8cc96 100644 --- a/docs/plugins/chroma.rst +++ b/docs/plugins/chroma.rst @@ -45,7 +45,7 @@ Next, you will need a mechanism for decoding audio files supported by the * On Linux, you can install `GStreamer for Python`_, `FFmpeg`_, or `MAD`_ and `pymad`_. How you install these will depend on your distribution. For example, on Ubuntu, run ``apt-get install python-gst0.10-dev``. On Arch Linux, you want - ``pacman -S gstreamer0.10-python``. + ``pacman -S gstreamer0.10-python``. * On Windows, try the Gstreamer "WinBuilds" from the `OSSBuild`_ project. @@ -78,6 +78,12 @@ editing your :doc:`configuration file `. Put ``chroma`` on your ``plugins:`` line. With that, beets will use fingerprinting the next time you run ``beet import``. +You can also use the ``beet fingerprint`` command to generate fingerprints for +items already in your library. (Provide a query to fingerprint a subset of your +library.) The generated fingerprints will be stored in the library database. +If you have the ``import.write`` config option enabled, they will also be +written to files' metadata. + .. _submitfp: Submitting Fingerprints diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 1295b6a04..7486f38dc 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -31,6 +31,12 @@ to match albums instead of tracks. The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify or overwrite the respective configuration options. +By default, the command places converted files into the destination directory +and leaves your library pristine. To instead back up your original files into +the destination directory and keep converted files in your library, use the +``-k`` (or ``--keep-new``) option. + + Configuration ------------- @@ -51,6 +57,10 @@ The plugin offers several configuration options, all of which live under the "-aq 2". (Note that "-aq " is equivalent to the LAME option "-V ".) If you want to specify a bitrate, use "-ab ". Refer to the `FFmpeg`_ documentation for more details. +* ``auto`` gives you the option to import transcoded versions of your files + automatically during the ``import`` command. With this option enabled, the + importer will transcode all non-MP3 files over the maximum bitrate before + adding them to your library. * Finally, ``threads`` determines the number of threads to use for parallel encoding. By default, the plugin will detect the number of processors available and use them all. diff --git a/docs/plugins/fuzzy.rst b/docs/plugins/fuzzy.rst index be659b386..3f4115168 100644 --- a/docs/plugins/fuzzy.rst +++ b/docs/plugins/fuzzy.rst @@ -1,25 +1,25 @@ Fuzzy Search Plugin =================== -The ``fuzzy`` plugin provides a command that search your library using -fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title. +The ``fuzzy`` plugin provides a prefixed query that search you library using +fuzzy pattern matching. This can be useful if you want to find a track with +complicated characters in the title. First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`). -You'll then be able to use the ``beet fuzzy`` command:: +You'll then be able to use the ``~`` prefix to use fuzzy matching:: - $ beet fuzzy Vareoldur + $ beet ls '~Vareoldur' Sigur Rós - Valtari - Varðeldur -The command has several options that resemble those for the ``beet list`` -command (see :doc:`/reference/cli`). To choose an album instead of a single -track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and -to use a custom format for printing, use ``-f FORMAT``. - -The ``-t NUMBER`` option lets you specify how precise the fuzzy match has to be -(default is 0.7). To make a fuzzier search, try ``beet fuzzy -t 0.5 Varoeldur``. -A value of ``1`` will show only perfect matches and a value of ``0`` will match everything. - -The default threshold can also be set in the config file:: +The plugin provides config options that let you choose the prefix and the +threshold.:: fuzzy: threshold: 0.8 + prefix: '@' + +A threshold value of 1.0 will show only perfect matches and a value of 0.0 +will match everything. + +The default prefix ``~`` needs to be escaped or quoted in most shells. If this +bothers you, you can change the prefix in your config file. diff --git a/docs/plugins/importfeeds.rst b/docs/plugins/importfeeds.rst index 09b831246..41c53cd84 100644 --- a/docs/plugins/importfeeds.rst +++ b/docs/plugins/importfeeds.rst @@ -15,11 +15,14 @@ relative to another folder than where the playlist is being written. If you're using importfeeds to generate a playlist for MPD, you should set this to the root of your music library. +The ``absolute_path`` configuration option can be set to use absolute paths +instead of relative paths. Some applications may need this to work properly. + Three different types of outputs coexist, specify the ones you want to use by -setting the ``formats`` parameter: +setting the ``formats`` parameter: - ``m3u``: catalog the imports in a centralized playlist. By default, the playlist is named ``imported.m3u``. To use a different file, just set the ``m3u_name`` parameter inside the ``importfeeds`` config section. -- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). +- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). - ``link``: create a symlink for each imported item. This is the recommended setting to propagate beets imports to your iTunes library: just drag and drop the ``dir`` folder on the iTunes dock icon. Here's an example configuration for this plugin:: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 2217f4d91..3c9eb4d9f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -60,6 +60,7 @@ disabled by default, but you can turn them on as described above. convert info smartplaylist + mbsync Autotagger Extensions '''''''''''''''''''''' @@ -73,6 +74,7 @@ Metadata * :doc:`lyrics`: Automatically fetch song lyrics. * :doc:`echonest_tempo`: Automatically fetch song tempos (bpm). * :doc:`lastgenre`: Fetch genres based on Last.fm tags. +* :doc:`mbsync`: Fetch updated metadata from MusicBrainz * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`replaygain`: Calculate volume normalization for players that support it. diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index f5a6df130..dc59ad88c 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -4,7 +4,7 @@ MusicBrainz Collection Plugin The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to maintain your `music collection`_ list there. -.. _music collection: http://musicbrainz.org/show/collection/ +.. _music collection: http://musicbrainz.org/doc/Collections To begin, just enable the ``mbcollection`` plugin (see :doc:`/plugins/index`). Then, add your MusicBrainz username and password to your diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst new file mode 100644 index 000000000..4bb3da32f --- /dev/null +++ b/docs/plugins/mbsync.rst @@ -0,0 +1,35 @@ +MBSync Plugin +============= + +This plugin provides the ``mbsync`` command, which lets you fetch metadata +from MusicBrainz for albums and tracks that already have MusicBrainz IDs. This +is useful for updating tags as they are fixed in the MusicBrainz database, or +when you change your mind about some config options that change how tags are +written to files. If you have a music library that is already nicely tagged by +a program that also uses MusicBrainz like Picard, this can speed up the +initial import if you just import "as-is" and then use ``mbsync`` to get +up-to-date tags that are written to the files according to your beets +configuration. + + +Usage +----- + +Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata +for a part of your collection (or omit the query to run over your whole +library). + +This plugin treats albums and singletons (non-album tracks) separately. It +first processes all matching singletons and then proceeds on to full albums. +The same query is used to search for both kinds of entities. + +The command has a few command-line options: + +* To preview the changes that would be made without applying them, use the + ``-p`` (``--pretend``) flag. +* By default, files will be moved (renamed) according to their metadata if + they are inside your beets library directory. To disable this, use the + ``-M`` (``--nomove``) command-line option. +* If you have the `import.write` configuration option enabled, then this + plugin will write new metadata to files' tags. To disable this, use the + ``-W`` (``--nowrite``) option. diff --git a/docs/plugins/the.rst b/docs/plugins/the.rst index 32f9ece33..4d807e113 100644 --- a/docs/plugins/the.rst +++ b/docs/plugins/the.rst @@ -22,18 +22,18 @@ The default configuration moves all English articles to the end of the string, but you can override these defaults to make more complex changes:: the: - # handle The, default is on - the=yes - # handle A/An, default is on - a=yes + # handle "The" (on by default) + the: yes + # handle "A/An" (on by default) + a: yes # format string, {0} - part w/o article, {1} - article # spaces already trimmed from ends of both parts # default is '{0}, {1}' - format={0}, {1} + format: '{0}, {1}' # strip instead of moving to the end, default is off - strip=no - # custom regexp patterns, separated by space - patterns= + strip: no + # custom regexp patterns, space-separated + patterns: ... Custom patterns are case-insensitive regular expressions. Patterns can be matched anywhere in the string (not just the beginning), so use ``^`` if you diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index aee4f93bc..d92ea8374 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -114,7 +114,7 @@ currently available are: * *pluginload*: called after all the plugins have been loaded after the ``beet`` command starts -* *import*: called after a ``beet import`` command fishes (the ``lib`` keyword +* *import*: called after a ``beet import`` command finishes (the ``lib`` keyword argument is a Library object; ``paths`` is a list of paths (strings) that were imported) @@ -323,3 +323,39 @@ to register it:: self.import_stages = [self.stage] def stage(self, config, task): print('Importing something!') + +.. _extend-query: + +Extend the Query Syntax +^^^^^^^^^^^^^^^^^^^^^^^ + +You can add new kinds of queries to beets' :doc:`query syntax +` indicated by a prefix. As an example, beets already +supports regular expression queries, which are indicated by a colon +prefix---plugins can do the same. + +To do so, define a subclass of the ``Query`` type from the ``beets.library`` +module. Then, in the ``queries`` method of your plugin class, return a +dictionary mapping prefix strings to query classes. + +One simple kind of query you can extend is the ``RegisteredFieldQuery``, which +implements string comparisons. To use it, create a subclass inheriting from +that class and override the ``value_match`` class method. (Remember the +``@classmethod`` decorator!) The following example plugin declares a query +using the ``@`` prefix to delimit exact string matches. The plugin will be +used if we issue a command like ``beet ls @something`` or ``beet ls +artist:@something``:: + + from beets.plugins import BeetsPlugin + from beets.library import PluginQuery + + class ExactMatchQuery(PluginQuery): + @classmethod + def value_match(self, pattern, val): + return pattern == val + + class ExactMatchPlugin(BeetsPlugin): + def queries(): + return { + '@': ExactMatchQuery + } diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index ca718cea5..575c92968 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -93,16 +93,21 @@ right now; this is something we need to work on. Read the instead want to import individual, non-album tracks, use the *singleton* mode by supplying the ``-s`` option. +* If you have an album that's split across several directories under a common + top directory, use the ``--flat`` 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 + multi-disc albums. + .. only:: html Reimporting ^^^^^^^^^^^ The ``import`` command can also be used to "reimport" music that you've - already added to your library. This is useful for updating tags as they are - fixed in the MusicBrainz database, for when you change your mind about some - selections you made during the initial import, or if you prefer to import - everything "as-is" and then correct tags later. + already added to your library. This is useful when you change your mind + about some selections you made during the initial import, or if you prefer + to import everything "as-is" and then correct tags later. Just point the ``beet import`` command at a directory of files that are already catalogged in your library. Beets will automatically detect this @@ -121,6 +126,11 @@ right now; this is something we need to work on. Read the or full albums. If you want to retag your whole library, just supply a null query, which matches everything: ``beet import -L`` + Note that, if you just want to update your files' tags according to + changes in the MusicBrainz database, the :doc:`/plugins/mbsync` is a + better choice. Reimporting uses the full matching machinery to guess + metadata matches; ``mbsync`` just relies on MusicBrainz IDs. + .. _list-cmd: list diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 655179637..a539a3a9d 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -317,6 +317,26 @@ should be the *default* when selecting an action for a given match. This is the action that will be taken when you type return without an option letter. The default is ``apply``. +.. _languages: + +languages +~~~~~~~~~ + +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's name when tagging from +MusicBrainz. Defaults to an empty list, meaning that no language is preferred. + +.. _detail: + +detail +~~~~~~ + +Whether the importer UI should show detailed information about each match it +finds. When enabled, this mode prints out the title of every track, regardless +of whether it matches the original metadata. (The default behavior only shows +changes.) Default: ``no``. + .. _musicbrainz-config: MusicBrainz Options diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index a58dc976a..0e5c4ebdd 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -195,7 +195,7 @@ Ordinary metadata: * encoder .. _artist credit: http://wiki.musicbrainz.org/Artist_Credit -.. _list of type names: http://wiki.musicbrainz.org/XMLWebService#Release_Type_and_Status +.. _list of type names: http://wiki.musicbrainz.org/Development/XML_Web_Service/Version_2#Release_Type_and_Status Audio information: diff --git a/setup.py b/setup.py index 9097d170c..295759b94 100755 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ setup(name='beets', 'mutagen>=1.20', 'munkres', 'unidecode', - 'musicbrainzngs>=0.2', + 'musicbrainzngs>=0.3', 'pyyaml', ] + (['colorama'] if (sys.platform == 'win32') else []) diff --git a/test/test_db.py b/test/test_db.py index d4dd30092..4ee6ea419 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -362,6 +362,10 @@ class DestinationTest(unittest.TestCase): val = beets.library.format_for_path(12345, 'samplerate', posixpath) self.assertEqual(val, u'12kHz') + def test_component_sanitize_none(self): + val = beets.library.format_for_path(None, 'foo', posixpath) + self.assertEqual(val, u'') + def test_artist_falls_back_to_albumartist(self): self.i.artist = '' self.i.albumartist = 'something' diff --git a/test/test_mb.py b/test/test_mb.py index ebecc5e7d..e23201706 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -16,6 +16,7 @@ """ from _common import unittest from beets.autotag import mb +from beets import config class MBAlbumInfoTest(unittest.TestCase): def _make_release(self, date_str='2009', tracks=None): @@ -286,6 +287,18 @@ class ArtistFlatteningTest(unittest.TestCase): 'name': 'CREDIT' + suffix, } + def _add_alias(self, credit_dict, suffix='', locale='', primary=False): + alias = { + 'alias': 'ALIAS' + suffix, + 'locale': locale, + 'sort-name': 'ALIASSORT' + suffix + } + if primary: + alias['primary'] = 'primary' + if 'alias-list' not in credit_dict['artist']: + credit_dict['artist']['alias-list'] = [] + credit_dict['artist']['alias-list'].append(alias) + def test_single_artist(self): a, s, c = mb._flatten_artist_credit([self._credit_dict()]) self.assertEqual(a, 'NAME') @@ -300,6 +313,38 @@ class ArtistFlatteningTest(unittest.TestCase): self.assertEqual(s, 'SORTa AND SORTb') self.assertEqual(c, 'CREDITa AND CREDITb') + def test_alias(self): + credit_dict = self._credit_dict() + self._add_alias(credit_dict, suffix='en', locale='en') + self._add_alias(credit_dict, suffix='en_GB', locale='en_GB') + self._add_alias(credit_dict, suffix='fr', locale='fr') + self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True) + + # test no alias + config['import']['languages'] = [''] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT')) + + # test en + config['import']['languages'] = ['en'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) + + # test en_GB en + config['import']['languages'] = ['en_GB', 'en'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASen_GB', 'ALIASSORTen_GB', 'CREDIT')) + + # test en en_GB + config['import']['languages'] = ['en', 'en_GB'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) + + # test fr primary + config['import']['languages'] = ['fr'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT')) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_query.py b/test/test_query.py index 8c7378518..54b30cf1e 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -20,82 +20,64 @@ import _common from _common import unittest import beets.library -pqp = beets.library.CollectionQuery._parse_query_part +pqp = beets.library.parse_query_part some_item = _common.item() class QueryParseTest(unittest.TestCase): def test_one_basic_term(self): q = 'test' - r = (None, 'test', False) + r = (None, 'test', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_keyed_term(self): q = 'test:val' - r = ('test', 'val', False) + r = ('test', 'val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_colon_at_end(self): q = 'test:' - r = (None, 'test:', False) + r = (None, 'test:', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_basic_regexp(self): q = r':regexp' - r = (None, 'regexp', True) + r = (None, 'regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_keyed_regexp(self): q = r'test::regexp' - r = ('test', 'regexp', True) + r = ('test', 'regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_escaped_colon(self): q = r'test\:val' - r = (None, 'test:val', False) + r = (None, 'test:val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_escaped_colon_in_regexp(self): q = r':test\:regexp' - r = (None, 'test:regexp', True) + r = (None, 'test:regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) -class AnySubstringQueryTest(unittest.TestCase): +class AnyFieldQueryTest(unittest.TestCase): def setUp(self): self.lib = beets.library.Library(':memory:') self.lib.add(some_item) def test_no_restriction(self): - q = beets.library.AnySubstringQuery('title') + q = beets.library.AnyFieldQuery('title', beets.library.ITEM_KEYS, + beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).next().title, 'the title') def test_restriction_completeness(self): - q = beets.library.AnySubstringQuery('title', ['title']) + q = beets.library.AnyFieldQuery('title', ['title'], + beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).next().title, 'the title') def test_restriction_soundness(self): - q = beets.library.AnySubstringQuery('title', ['artist']) - self.assertRaises(StopIteration, self.lib.items(q).next) - -class AnyRegexpQueryTest(unittest.TestCase): - def setUp(self): - self.lib = beets.library.Library(':memory:') - self.lib.add(some_item) - - def test_no_restriction(self): - q = beets.library.AnyRegexpQuery(r'^the ti') - self.assertEqual(self.lib.items(q).next().title, 'the title') - - def test_restriction_completeness(self): - q = beets.library.AnyRegexpQuery(r'^the ti', ['title']) - self.assertEqual(self.lib.items(q).next().title, 'the title') - - def test_restriction_soundness(self): - q = beets.library.AnyRegexpQuery(r'^the ti', ['artist']) - self.assertRaises(StopIteration, self.lib.items(q).next) - - def test_restriction_soundness_2(self): - q = beets.library.AnyRegexpQuery(r'the ti$', ['title']) + q = beets.library.AnyFieldQuery('title', ['artist'], + beets.library.SubstringQuery) self.assertRaises(StopIteration, self.lib.items(q).next) # Convenient asserts for matching items.