diff --git a/.hgtags b/.hgtags index 0eda06449..57da60f91 100644 --- a/.hgtags +++ b/.hgtags @@ -10,3 +10,4 @@ a256ec5b0b2de500305fd6656db0a195df273acc 1.0b9 88807657483a916200296165933529da9a682528 1.0b10 4ca1475821742002962df439f71f51d67640b91e 1.0b11 284b58a9f9ce3a79f7d2bcc48819f2bb77773818 1.0b12 +b6c10981014a5b3a963460fca3b31cc62bf7ed2c 1.0b13 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..5193a680d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - "2.7" +install: + - pip install . --use-mirrors + - pip install pylast flask --use-mirrors +script: nosetests diff --git a/beets/__init__.py b/beets/__init__.py index 06e92b361..a0a74f3a8 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -__version__ = '1.0b13' +__version__ = '1.0b14' __author__ = 'Adrian Sampson ' import beets.library diff --git a/beets/importer.py b/beets/importer.py index f8d4794c4..3d7f9b1ac 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -59,14 +59,26 @@ def tag_log(logfile, status, path): print >>logfile, '%s %s' % (status, path) logfile.flush() -def log_choice(config, task): - """Logs the task's current choice if it should be logged. +def log_choice(config, task, duplicate=False): + """Logs the task's current choice if it should be logged. If + ``duplicate``, then this is a secondary choice after a duplicate was + detected and a decision was made. """ path = task.path if task.is_album else task.item.path - if task.choice_flag is action.ASIS: - tag_log(config.logfile, 'asis', path) - elif task.choice_flag is action.SKIP: - tag_log(config.logfile, 'skip', path) + if duplicate: + # Duplicate: log all three choices (skip, keep both, and trump). + if task.remove_duplicates: + tag_log(config.logfile, 'duplicate-replace', path) + elif task.choice_flag in (action.ASIS, action.APPLY): + tag_log(config.logfile, 'duplicate-keep', path) + elif task.choice_flag is (action.SKIP): + tag_log(config.logfile, 'duplicate-skip', path) + else: + # Non-duplicate: log "skip" and "asis" choices. + if task.choice_flag is action.ASIS: + tag_log(config.logfile, 'asis', path) + elif task.choice_flag is action.SKIP: + tag_log(config.logfile, 'skip', path) def _reopen_lib(lib): """Because of limitations in SQLite, a given Library is bound to @@ -85,32 +97,18 @@ def _reopen_lib(lib): else: return lib -def _duplicate_check(lib, task, recent=None): - """Check whether an album already exists in the library. `recent` - should be a set of (artist, album) pairs that will be built up - with every call to this function and checked along with the - library. +def _duplicate_check(lib, task): + """Check whether an album already exists in the library. Returns a + list of Album objects (empty if no duplicates are found). """ - if task.choice_flag is action.ASIS: - artist = task.cur_artist - album = task.cur_album - elif task.choice_flag is action.APPLY: - artist = task.info.artist - album = task.info.album - else: - return False + assert task.choice_flag in (action.ASIS, action.APPLY) + artist, album = task.chosen_ident() if artist is None: # As-is import with no artist. Skip check. - return False + return [] - # Try the recent albums. - if recent is not None: - if (artist, album) in recent: - return True - recent.add((artist, album)) - - # Look in the library. + found_albums = [] cur_paths = set(i.path for i in task.items if i) for album_cand in lib.albums(artist=artist): if album_cand.album == album: @@ -119,34 +117,23 @@ def _duplicate_check(lib, task, recent=None): other_paths = set(i.path for i in album_cand.items()) if other_paths == cur_paths: continue - return True + found_albums.append(album_cand) + return found_albums - return False +def _item_duplicate_check(lib, task): + """Check whether an item already exists in the library. Returns a + list of Item objects. + """ + assert task.choice_flag in (action.ASIS, action.APPLY) + artist, title = task.chosen_ident() -def _item_duplicate_check(lib, task, recent=None): - """Check whether an item already exists in the library.""" - if task.choice_flag is action.ASIS: - artist = task.item.artist - title = task.item.title - elif task.choice_flag is action.APPLY: - artist = task.info.artist - title = task.info.title - else: - return False - - # Try recent items. - if recent is not None: - if (artist, title) in recent: - return True - recent.add((artist, title)) - - # Check the library. + found_items = [] for other_item in lib.items(artist=artist, title=title): # Existing items not considered duplicates. if other_item.path == task.item.path: continue - return True - return False + found_items.append(other_item) + return found_items def _infer_album_fields(task): """Given an album and an associated import task, massage the @@ -275,7 +262,8 @@ class ImportConfig(object): 'quiet_fallback', 'copy', 'write', 'art', 'delete', 'choose_match_func', 'should_resume_func', 'threaded', 'autot', 'singletons', 'timid', 'choose_item_func', - 'query', 'incremental', 'ignore'] + 'query', 'incremental', 'ignore', + 'resolve_duplicate_func'] def __init__(self, **kwargs): for slot in self._fields: setattr(self, slot, kwargs[slot]) @@ -307,6 +295,7 @@ class ImportTask(object): self.path = path self.items = items self.sentinel = False + self.remove_duplicates = False @classmethod def done_sentinel(cls, toppath): @@ -422,6 +411,26 @@ class ImportTask(object): """ return self.sentinel or self.choice_flag == action.SKIP + # Useful data. + def chosen_ident(self): + """Returns identifying metadata about the current choice. For + albums, this is an (artist, album) pair. For items, this is + (artist, title). May only be called when the choice flag is ASIS + (in which case the data comes from the files' current metadata) + or APPLY (data comes from the choice). + """ + assert self.choice_flag in (action.ASIS, action.APPLY) + if self.is_album: + if self.choice_flag is action.ASIS: + return (self.cur_artist, self.cur_album) + elif self.choice_flag is action.APPLY: + return (self.info.artist, self.info.album) + else: + if self.choice_flag is action.ASIS: + return (self.item.artist, self.item.title) + elif self.choice_flag is action.APPLY: + return (self.info.artist, self.info.title) + # Full-album pipeline stages. @@ -575,10 +584,15 @@ def user_query(config): continue # Check for duplicates if we have a match (or ASIS). - if _duplicate_check(lib, task, recent): - tag_log(config.logfile, 'duplicate', task.path) - log.warn("This album is already in the library!") - task.set_choice(action.SKIP) + if task.choice_flag in (action.ASIS, action.APPLY): + ident = task.chosen_ident() + # The "recent" set keeps track of identifiers for recently + # imported albums -- those that haven't reached the database + # yet. + if ident in recent or _duplicate_check(lib, task): + config.resolve_duplicate_func(task, config) + log_choice(config, task, True) + recent.add(ident) def show_progress(config): """This stage replaces the initial_lookup and user_query stages @@ -625,9 +639,9 @@ def apply_choices(config): if task.is_album: _infer_album_fields(task) - # Find existing item entries that these are replacing. Old - # album structures are automatically cleaned up when the - # last item is removed. + # Find existing item entries that these are replacing (for + # re-imports). Old album structures are automatically cleaned up + # when the last item is removed. replaced_items = defaultdict(list) for item in items: dup_items = lib.items(library.MatchQuery('path', item.path)) @@ -638,6 +652,28 @@ def apply_choices(config): log.debug('%i of %i items replaced' % (len(replaced_items), len(items))) + # Find old items that should be replaced as part of a duplicate + # resolution. + duplicate_items = [] + if task.remove_duplicates: + if task.is_album: + for album in _duplicate_check(lib, task): + duplicate_items += album.items() + else: + duplicate_items = _item_duplicate_check(lib, task) + log.debug('removing %i old duplicated items' % + len(duplicate_items)) + + # Delete duplicate files that are located inside the library + # directory. + for duplicate_path in [i.path for i in duplicate_items]: + if lib.directory in util.ancestry(duplicate_path): + log.debug(u'deleting replaced duplicate %s' % + util.displayable_path(duplicate_path)) + util.soft_remove(duplicate_path) + util.prune_dirs(os.path.dirname(duplicate_path), + lib.directory) + # Move/copy files. task.old_paths = [item.path for item in items] for item in items: @@ -661,6 +697,8 @@ def apply_choices(config): for replaced in replaced_items.itervalues(): for item in replaced: lib.remove(item) + for item in duplicate_items: + lib.remove(item) # Add new ones. if task.is_album: @@ -775,10 +813,12 @@ def item_query(config): log_choice(config, task) # Duplicate check. - if _item_duplicate_check(lib, task, recent): - tag_log(config.logfile, 'duplicate', task.item.path) - log.warn("This item is already in the library!") - task.set_choice(action.SKIP) + if task.choice_flag in (action.ASIS, action.APPLY): + ident = task.chosen_ident() + if ident in recent or _item_duplicate_check(lib, task): + config.resolve_duplicate_func(task, config) + log_choice(config, task, True) + recent.add(ident) def item_progress(config): """Skips the lookup and query stages in a non-autotagged singleton diff --git a/beets/library.py b/beets/library.py index a9941d48a..0b80a440a 100644 --- a/beets/library.py +++ b/beets/library.py @@ -354,7 +354,7 @@ class CollectionQuery(Query): """An abstract query class that aggregates other queries. Can be indexed like a list to access the sub-queries. """ - def __init__(self, subqueries = ()): + def __init__(self, subqueries=()): self.subqueries = subqueries # is there a better way to do this? @@ -790,10 +790,10 @@ class Library(BaseLibrary): if table == 'albums' and 'artist' in current_fields and \ 'albumartist' not in current_fields: setup_sql += "UPDATE ALBUMS SET albumartist=artist;\n" - + self.conn.executescript(setup_sql) self.conn.commit() - + def destination(self, item, pathmod=None, in_album=False, fragment=False, basedir=None): """Returns the path in the library directory designated for item @@ -805,7 +805,7 @@ class Library(BaseLibrary): directory for the destination. """ pathmod = pathmod or os.path - + # Use a path format based on a query, falling back on the # default. for query, path_format in self.path_formats: @@ -832,10 +832,10 @@ class Library(BaseLibrary): else: assert False, "no default path format" subpath_tmpl = Template(path_format) - + # Get the item's Album if it has one. album = self.get_album(item) - + # Build the mapping for substitution in the path template, # beginning with the values from the database. mapping = {} @@ -848,7 +848,7 @@ class Library(BaseLibrary): # From Item. value = getattr(item, key) mapping[key] = util.sanitize_for_path(value, pathmod, key) - + # Use the album artist if the track artist is not set and # vice-versa. if not mapping['artist']: @@ -859,24 +859,24 @@ class Library(BaseLibrary): # Get values from plugins. for key, value in plugins.template_values(item).iteritems(): mapping[key] = util.sanitize_for_path(value, pathmod, key) - + # Perform substitution. - funcs = dict(TEMPLATE_FUNCTIONS) + funcs = DefaultTemplateFunctions(self, item).functions() funcs.update(plugins.template_funcs()) subpath = subpath_tmpl.substitute(mapping, funcs) - + # Encode for the filesystem, dropping unencodable characters. if isinstance(subpath, unicode) and not fragment: encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() subpath = subpath.encode(encoding, 'replace') - + # Truncate components and remove forbidden characters. subpath = util.sanitize_path(subpath, pathmod, self.replacements) - + # Preserve extension. _, extension = pathmod.splitext(item.path) subpath += extension.lower() - + if fragment: return subpath else: @@ -887,7 +887,6 @@ class Library(BaseLibrary): # Item manipulation. def add(self, item, copy=False): - #FIXME make a deep copy of the item? item.library = self if copy: self.move(item, copy=True) @@ -902,18 +901,18 @@ class Library(BaseLibrary): if key == 'path' and isinstance(value, str): value = buffer(value) subvars.append(value) - + # issue query c = self.conn.cursor() query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')' c.execute(query, subvars) new_id = c.lastrowid c.close() - + item._clear_dirty() item.id = new_id return new_id - + def save(self, event=True): """Writes the library to disk (completing an sqlite transaction). @@ -925,7 +924,7 @@ class Library(BaseLibrary): def load(self, item, load_id=None): if load_id is None: load_id = item.id - + c = self.conn.execute( 'SELECT * FROM items WHERE id=?', (load_id,) ) item._fill_record(c.fetchone()) @@ -935,7 +934,7 @@ class Library(BaseLibrary): def store(self, item, store_id=None, store_all=False): if store_id is None: store_id = item.id - + # build assignments for query assignments = '' subvars = [] @@ -948,7 +947,7 @@ class Library(BaseLibrary): if key == 'path' and isinstance(value, str): value = buffer(value) subvars.append(value) - + if not assignments: # nothing to store (i.e., nothing was dirty) return @@ -1316,44 +1315,128 @@ def _int_arg(s): function. May raise a ValueError. """ return int(s.strip()) -def _tmpl_lower(s): - """Convert a string to lower case.""" - return s.lower() -def _tmpl_upper(s): - """Covert a string to upper case.""" - return s.upper() -def _tmpl_title(s): - """Convert a string to title case.""" - return s.title() -def _tmpl_left(s, chars): - """Get the leftmost characters of a string.""" - return s[0:_int_arg(chars)] -def _tmpl_right(s, chars): - """Get the rightmost characters of a string.""" - return s[-_int_arg(chars):] -def _tmpl_if(condition, trueval, falseval=u''): - """If ``condition`` is nonempty and nonzero, emit ``trueval``; - otherwise, emit ``falseval`` (if provided). - """ - try: - condition = _int_arg(condition) - except ValueError: - condition = condition.strip() - if condition: - return trueval - else: - return falseval -def _tmpl_asciify(s): - """Translate non-ASCII characters to their ASCII equivalents. - """ - return unidecode(s) -TEMPLATE_FUNCTIONS = { - 'lower': _tmpl_lower, - 'upper': _tmpl_upper, - 'title': _tmpl_title, - 'left': _tmpl_left, - 'right': _tmpl_right, - 'if': _tmpl_if, - 'asciify': _tmpl_asciify, -} +class DefaultTemplateFunctions(object): + """A container class for the default functions provided to path + templates. These functions are contained in an object to provide + additional context to the functions -- specifically, the Item being + evaluated. + """ + def __init__(self, lib, item): + self.lib = lib + self.item = item + + _prefix = 'tmpl_' + + def functions(self): + """Returns a dictionary containing the functions defined in this + object. The keys are function names (as exposed in templates) + and the values are Python functions. + """ + out = {} + for key in dir(self): + if key.startswith(self._prefix): + out[key[len(self._prefix):]] = getattr(self, key) + return out + + @staticmethod + def tmpl_lower(s): + """Convert a string to lower case.""" + return s.lower() + + @staticmethod + def tmpl_upper(s): + """Covert a string to upper case.""" + return s.upper() + + @staticmethod + def tmpl_title(s): + """Convert a string to title case.""" + return s.title() + + @staticmethod + def tmpl_left(s, chars): + """Get the leftmost characters of a string.""" + return s[0:_int_arg(chars)] + + @staticmethod + def tmpl_right(s, chars): + """Get the rightmost characters of a string.""" + return s[-_int_arg(chars):] + + @staticmethod + def tmpl_if(condition, trueval, falseval=u''): + """If ``condition`` is nonempty and nonzero, emit ``trueval``; + otherwise, emit ``falseval`` (if provided). + """ + try: + condition = _int_arg(condition) + except ValueError: + condition = condition.strip() + if condition: + return trueval + else: + return falseval + + @staticmethod + def tmpl_asciify(s): + """Translate non-ASCII characters to their ASCII equivalents. + """ + return unidecode(s) + + def tmpl_unique(self, keys, disam): + """Generate a string that is guaranteed to be unique among all + albums in the library who share the same set of keys. Fields + from "disam" are used in the string if they are 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. + """ + keys = keys.split() + disam = disam.split() + + album = self.lib.get_album(self.item) + if not album: + # Do nothing for singletons. + return u'' + + # Find matching albums to disambiguate with. + subqueries = [] + for key in keys: + value = getattr(album, key) + subqueries.append(MatchQuery(key, value)) + albums = self.lib.albums(query=AndQuery(subqueries)) + + # If there's only one album to matching these details, then do + # nothing. + if len(albums) == 1: + return u'' + + # Find the minimum number of fields necessary to disambiguate + # the set of albums. + disambiguators = [] + for field in disam: + disambiguators.append(field) + + # Get the value tuple for each album for these + # disambiguators. + disam_values = set() + for a in albums: + values = [getattr(a, f) for f in disambiguators] + disam_values.add(tuple(values)) + + # If the set of unique tuples is equal to the number of + # albums in the disambiguation set, we're done -- this is + # sufficient disambiguation. + if len(disam_values) == len(albums): + break + + else: + # Even when using all of the disambiguating fields, we + # could not separate all the albums. Fall back to the unique + # album ID. + return u' {}'.format(album.id) + + # Flatten disambiguation values into a string. + values = [unicode(getattr(album, f)) for f in disambiguators] + return u' [{}]'.format(u' '.join(values)) diff --git a/beets/mediafile.py b/beets/mediafile.py index e7f6e948d..312f5971a 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -317,7 +317,13 @@ class MediaField(object): # possibly index the list if style.list_elem: if entry: # List must have at least one value. - return entry[0] + # Handle Mutagen bugs when reading values (#356). + try: + return entry[0] + except: + log.error('Mutagen exception when reading field: %s' % + traceback.format_exc) + return None else: return None else: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 3503842cb..1cf4bf9ec 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -32,6 +32,10 @@ from beets import library from beets import plugins from beets import util +if sys.platform == 'win32': + import colorama + colorama.init() + # Constants. CONFIG_PATH_VAR = 'BEETSCONFIG' DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig' diff --git a/beets/ui/commands.py b/beets/ui/commands.py index c4b55bfed..201d414cf 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -30,6 +30,7 @@ import beets.autotag.art from beets import plugins from beets import importer from beets.util import syspath, normpath, ancestry, displayable_path +from beets.util.functemplate import Template from beets import library # Global logger. @@ -563,6 +564,35 @@ def choose_item(task, config): assert not isinstance(choice, importer.action) return choice +def resolve_duplicate(task, config): + """Decide what to do when a new album or item seems similar to one + that's already in the library. + """ + log.warn("This %s is already in the library!" % + ("album" if task.is_album else "item")) + + if config.quiet: + # In quiet mode, don't prompt -- just skip. + log.info('Skipping.') + sel = 's' + else: + sel = ui.input_options( + ('Skip new', 'Keep both', 'Remove old'), + color=config.color + ) + + if sel == 's': + # Skip new. + task.set_choice(importer.action.SKIP) + elif sel == 'k': + # Keep both. Do nothing; leave the choice intact. + pass + elif sel == 'r': + # Remove old. + task.remove_duplicates = True + else: + assert False + # The import command. def import_files(lib, paths, copy, write, autot, logpath, art, threaded, @@ -635,6 +665,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded, query = query, incremental = incremental, ignore = ignore, + resolve_duplicate_func = resolve_duplicate, ) finally: @@ -743,31 +774,41 @@ default_commands.append(import_cmd) # list: Query and show library contents. -def list_items(lib, query, album, path): +def list_items(lib, query, album, path, fmt): """Print out items in lib matching query. If album, then search for albums instead of single items. If path, print the matched objects' paths instead of human-readable information about them. """ + if fmt is None: + # If no specific template is supplied, use a default. + if album: + fmt = u'$albumartist - $album' + else: + fmt = u'$artist - $album - $title' + template = Template(fmt) + if album: for album in lib.albums(query): if path: print_(album.item_dir()) - else: - print_(album.albumartist + u' - ' + album.album) + elif fmt is not None: + print_(template.substitute(album._record)) else: for item in lib.items(query): if path: print_(item.path) - else: - print_(item.artist + u' - ' + item.album + u' - ' + item.title) + elif fmt is not None: + print_(template.substitute(item.record)) list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',)) list_cmd.parser.add_option('-a', '--album', action='store_true', help='show matching albums instead of tracks') list_cmd.parser.add_option('-p', '--path', action='store_true', help='print paths for matched items or albums') +list_cmd.parser.add_option('-f', '--format', action='store', + help='print with custom format', default=None) def list_func(lib, config, opts, args): - list_items(lib, decargs(args), opts.album, opts.path) + list_items(lib, decargs(args), opts.album, opts.path, opts.format) list_cmd.func = list_func default_commands.append(list_cmd) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 1c2384a4c..5d6921799 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -109,7 +109,7 @@ class Expression(object): out.append(part) else: out.append(part.evaluate(env)) - return u''.join(out) + return u''.join(map(unicode, out)) class ParseError(Exception): pass diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 2c77087b2..6094f2c5e 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -50,7 +50,7 @@ class GstPlayer(object): # Set up the Gstreamer player. From the pygst tutorial: # http://pygstdocs.berlios.de/pygst-tutorial/playbin.html - self.player = gst.element_factory_make("playbin", "player") + self.player = gst.element_factory_make("playbin2", "player") fakesink = gst.element_factory_make("fakesink", "fakesink") self.player.set_property("video-sink", fakesink) bus = self.player.get_bus() diff --git a/beetsplug/m3uupdate.py b/beetsplug/m3uupdate.py new file mode 100644 index 000000000..32740ef84 --- /dev/null +++ b/beetsplug/m3uupdate.py @@ -0,0 +1,71 @@ +# This file is part of beets. +# Copyright 2012, Fabrice Laporte. +# +# 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. + +"""Write paths of imported files in a m3u file to ease later import in a +music player. +""" + +from __future__ import with_statement +import os + +from beets import ui +from beets.plugins import BeetsPlugin +from beets.util import normpath + +DEFAULT_FILENAME = 'imported.m3u' +_m3u_path = None # If unspecified, use file in library directory. + +class m3uPlugin(BeetsPlugin): + def configure(self, config): + global _m3u_path + _m3u_path = ui.config_val(config, 'm3uupdate', 'm3u', None) + if _m3u_path: + _m3u_path = normpath(_m3u_path) + +def _get_m3u_path(lib): + """Given a Library object, return the path to the M3U file to be + used (either in the library directory or an explicitly configured + path. Ensures that the containing directory exists. + """ + if _m3u_path: + # Explicitly specified. + path = _m3u_path + else: + # Inside library directory. + path = os.path.join(lib.directory, DEFAULT_FILENAME) + + # Ensure containing directory exists. + m3u_dir = os.path.dirname(path) + if not os.path.exists(m3u_dir): + os.makedirs(m3u_dir) + + return path + +def _record_items(lib, items): + """Records relative paths to the given items in the appropriate M3U + file. + """ + m3u_path = _get_m3u_path(lib) + with open(m3u_path, 'a') as f: + for item in items: + path = os.path.relpath(item.path, os.path.dirname(m3u_path)) + f.write(path + '\n') + +@m3uPlugin.listen('album_imported') +def album_imported(lib, album, config): + _record_items(lib, album.items()) + +@m3uPlugin.listen('item_imported') +def item_imported(lib, item, config): + _record_items(lib, [item]) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py new file mode 100644 index 000000000..2e4e1a713 --- /dev/null +++ b/beetsplug/mbcollection.py @@ -0,0 +1,62 @@ +#Copyright (c) 2011, Jeffrey Aylesworth +# +#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. +# +#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. + +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand +from beets import ui +import musicbrainzngs +from musicbrainzngs import musicbrainz + +SUBMISSION_CHUNK_SIZE = 350 + +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. + +def update_collection(lib, config, opts, args): + # Get the collection to modify. + collections = musicbrainz._mb_request('collection', 'GET', True, True) + if not collections['collection-list']: + raise ui.UserError('no collections exist for user') + collection_id = collections['collection-list'][0]['id'] + + # Get a list of all the albums. + albums = [a.mb_albumid for a in lib.albums() if a.mb_albumid] + + # Submit to MusicBrainz. + print 'Updating MusicBrainz collection {}...'.format(collection_id) + submit_albums(collection_id, albums) + print '...MusicBrainz collection updated.' + +update_mb_collection_cmd = Subcommand('mbupdate', + help='Update MusicBrainz collection') +update_mb_collection_cmd.func = update_collection + +class MusicBrainzCollectionPlugin(BeetsPlugin): + def configure(self, config): + username = ui.config_val(config, 'musicbrainz', 'user', '') + password = ui.config_val(config, 'musicbrainz', 'pass', '') + musicbrainzngs.auth(username, password) + + def commands(self): + return [update_mb_collection_cmd] diff --git a/beetsplug/rdm.py b/beetsplug/rdm.py new file mode 100644 index 000000000..d081c2a73 --- /dev/null +++ b/beetsplug/rdm.py @@ -0,0 +1,70 @@ +# This file is part of beets. +# Copyright 2011, Philippe Mongeau. +# +# 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. + +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, decargs, print_ +from beets.util.functemplate import Template +import random + +"""Get a random song or album from the library. +""" + +def random_item(lib, config, opts, args): + query = decargs(args) + path = opts.path + fmt = opts.format + + if fmt is None: + # If no specific template is supplied, use a default + if opts.album: + fmt = u'$albumartist - $album' + else: + fmt = u'$artist - $album - $title' + template = Template(fmt) + + if opts.album: + objs = list(lib.albums(query=query)) + else: + objs = list(lib.items(query=query)) + number = min(len(objs), opts.number) + objs = random.sample(objs, number) + + if opts.album: + for album in objs: + if path: + print_(album.item_dir()) + else: + print_(template.substitute(album._record)) + else: + for item in objs: + if path: + print_(item.path) + else: + print_(template.substitute(item.record)) + +random_cmd = Subcommand('random', + help='chose a random track or album') +random_cmd.parser.add_option('-a', '--album', action='store_true', + help='choose an album instead of track') +random_cmd.parser.add_option('-p', '--path', action='store_true', + help='print the path of the matched item') +random_cmd.parser.add_option('-f', '--format', action='store', + help='print with custom format', default=None) +random_cmd.parser.add_option('-n', '--number', action='store', type="int", + help='number of objects to choose', default=1) +random_cmd.func = random_item + +class Random(BeetsPlugin): + def commands(self): + return [random_cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index 5263c0d85..0db9df4bb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,21 +1,45 @@ Changelog ========= -1.0b13 (in development) +1.0b14 (in development) +----------------------- +* The importer now gives you **choices when duplicates are detected**. + Previously, when beets found an existing album or item in your library + matching the metadata on a newly-imported one, it would just skip the new + music to avoid introducing duplicates into your library. Now, you have three + choices: skip the new music (the previous behavior), keep both, or remove the + old music. See the :ref:`guide-duplicates` section in the autotagging guide + for details. +* New :doc:`/plugins/rdm`: Randomly select albums and tracks from your library. + Thanks to Philippe Mongeau. +* The :doc:`/plugins/mbcollection` by Jeffrey Aylesworth was added to the core + beets distribution. +* New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist + file for easy importing to other systems. Thanks to Fabrice Laporte. +* :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the + deprecated ``playbin``. + + +1.0b13 (March 16, 2012) ----------------------- Beets 1.0b13 consists of a plethora of small but important fixes and refinements. A lyrics plugin is now included with beets; new audio properties -are catalogged; the autotagger is more tolerant of different tagging styles; and -importing with original file deletion now cleans up after itself more -thoroughly. Many, many bugs—including several crashers—were fixed. This release -lays the foundation for more features to come in the next couple of releases. +are catalogged; the ``list`` command has been made more powerful; the autotagger +is more tolerant of different tagging styles; and importing with original file +deletion now cleans up after itself more thoroughly. Many, many bugs—including +several crashers—were fixed. This release lays the foundation for more features +to come in the next couple of releases. * The :doc:`/plugins/lyrics`, originally by `Peter Brunner`_, is revamped and included with beets, making it easy to fetch **song lyrics**. * Items now expose their audio **sample rate**, number of **channels**, and **bits per sample** (bitdepth). See :doc:`/reference/pathformat` for a list of all available audio properties. Thanks to Andrew Dunn. +* The ``beet list`` command now accepts a "format" argument that lets you **show + specific information about each album or track**. For example, run ``beet ls + -af '$album: $tracktotal' beatles`` to see how long each Beatles album is. + Thanks to Philippe Mongeau. * The autotagger now tolerates tracks on multi-disc albums that are numbered per-disc. For example, if track 24 on a release is the first track on the second disc, then it is not penalized for having its track number set to 1 @@ -24,6 +48,7 @@ lays the foundation for more features to come in the next couple of releases. albums. * The autotagger now also tolerates tracks whose track artists tags are set to "Various Artists". +* Terminal colors are now supported on Windows via `Colorama`_ (thanks to Karl). * When previewing metadata differences, the importer now shows discrepancies in track length. * Importing with ``import_delete`` enabled now cleans up empty directories that @@ -64,6 +89,8 @@ lays the foundation for more features to come in the next couple of releases. data. * Fix the ``list`` command in BPD (thanks to Simon Chopin). +.. _Colorama: http://pypi.python.org/pypi/colorama + 1.0b12 (January 16, 2012) ------------------------- diff --git a/docs/conf.py b/docs/conf.py index 19380812e..65d81f4e5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,8 +12,8 @@ master_doc = 'index' project = u'beets' copyright = u'2011, Adrian Sampson' -version = '1.0b13' -release = '1.0b13' +version = '1.0b14' +release = '1.0b14' pygments_style = 'sphinx' diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index d9155d979..985805196 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -195,6 +195,26 @@ guessing---beets will show you the proposed changes and ask you to confirm them, just like the earlier example. As the prompt suggests, you can just hit return to select the first candidate. +.. _guide-duplicates: + +Duplicates +---------- + +If beets finds an album or item in your library that seems to be the same as the +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? + +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, +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 +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. + Fingerprinting -------------- diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index ecd5c5eff..dd7b09112 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -30,10 +30,10 @@ Plugins Included With Beets --------------------------- There are a few plugins that are included with the beets distribution. They're -disabled by default, but you can turn them on as described above: +disabled by default, but you can turn them on as described above. .. toctree:: - :maxdepth: 1 + :hidden: chroma lyrics @@ -46,6 +46,50 @@ disabled by default, but you can turn them on as described above: inline scrub rewrite + m3uupdate + rdm + mbcollection + +Autotagger Extensions +'''''''''''''''''''''' + +* :doc:`chroma`: Use acoustic fingerprinting to identify audio files with + missing or incorrect metadata. + +Metadata +'''''''' + +* :doc:`lyrics`: Automatically fetch song lyrics. +* :doc:`lastgenre`: Fetch genres based on Last.fm tags. +* :doc:`embedart`: Embed album art images into files' metadata. (By default, + beets uses image files "on the side" instead of embedding images.) +* :doc:`replaygain`: Calculate volume normalization for players that support it. +* :doc:`scrub`: Clean extraneous metadata from music files. + +Path Formats +'''''''''''' + +* :doc:`inline`: Use Python snippets to customize path format strings. +* :doc:`rewrite`: Substitute values in path formats. + +Interoperability +'''''''''''''''' + +* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library + changes. +* :doc:`m3uupdate`: Catalog imported files in an ``.m3u`` playlist file. + +Miscellaneous +''''''''''''' + +* :doc:`web`: An experimental Web-based GUI for beets. +* :doc:`rdm`: Randomly choose albums and tracks from your library. +* :doc:`mbcollection`: Maintain your MusicBrainz collection list. +* :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is + compatible with `MPD clients`_. + +.. _MPD: http://mpd.wikia.com/ +.. _MPD clients: http://mpd.wikia.com/wiki/Clients .. _other-plugins: @@ -57,15 +101,11 @@ Here are a few of the plugins written by the beets community: * `beetFs`_ is a FUSE filesystem for browsing the music in your beets library. (Might be out of date.) -* `Beet-MusicBrainz-Collection`_ lets you add albums from your library to your - MusicBrainz `"music collection"`_. - * `A cmus plugin`_ integrates with the `cmus`_ console music player. .. _beetFs: http://code.google.com/p/beetfs/ .. _Beet-MusicBrainz-Collection: https://github.com/jeffayle/Beet-MusicBrainz-Collection/ -.. _"music collection": http://musicbrainz.org/show/collection/ .. _A cmus plugin: https://github.com/coolkehon/beets/blob/master/beetsplug/cmus.py .. _cmus: http://cmus.sourceforge.net/ diff --git a/docs/plugins/m3uupdate.rst b/docs/plugins/m3uupdate.rst new file mode 100644 index 000000000..6d5730ec2 --- /dev/null +++ b/docs/plugins/m3uupdate.rst @@ -0,0 +1,20 @@ +m3uUpdate Plugin +================ + +The ``m3uupdate`` plugin keeps track of newly imported music in a central +``.m3u`` playlist file. This file can be used to add new music to other players, +such as iTunes. + +To use the plugin, just put ``m3uupdate`` on the ``plugins`` line in your +:doc:`/reference/config`:: + + [beets] + plugins: m3uupdate + +Every time an album or singleton item is imported, new paths will be written to +the playlist file. By default, the plugin uses a file called ``imported.m3u`` +inside your beets library directory. To use a different file, just set the +``m3u`` parameter inside the ``m3uupdate`` config section, like so:: + + [m3uupdate] + m3u: ~/music.m3u diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst new file mode 100644 index 000000000..579cb2d2a --- /dev/null +++ b/docs/plugins/mbcollection.rst @@ -0,0 +1,20 @@ +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/ + +To begin, just enable the ``mbcollection`` plugin (see :doc:`/plugins/index`). +Then, add your MusicBrainz username and password to your +:doc:`/reference/config` in a ``musicbrainz`` section:: + + [musicbrainz] + user: USERNAME + pass: PASSWORD + +Then, use the ``beet mbupdate`` command to send your albums to MusicBrainz. The +command automatically adds all of your albums to the first collection it finds. +If you don't have a MusicBrainz collection yet, you may need to add one to your +profile first. diff --git a/docs/plugins/rdm.rst b/docs/plugins/rdm.rst new file mode 100644 index 000000000..4d8eb279e --- /dev/null +++ b/docs/plugins/rdm.rst @@ -0,0 +1,21 @@ +Random Plugin +============= + +The ``rdm`` plugin provides a command that randomly selects tracks or albums +from your library. This can be helpful if you need some help deciding what to +listen to. + +First, enable the plugin named ``rdm`` (see :doc:`/plugins/index`). You'll then +be able to use the ``beet random`` command:: + + $ beet random + Aesop Rock - None Shall Pass - The Harbor Is Yours + +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 ``-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``. diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 890a8b793..c9a7b7d71 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -131,9 +131,15 @@ Want to search for "Gronlandic Edit" by of Montreal? Try ``beet list gronlandic``. Maybe you want to see everything released in 2009 with "vegetables" in the title? Try ``beet list year:2009 title:vegetables``. (Read more in :doc:`query`.) You can use the ``-a`` switch to search for -albums instead of individual items. The ``-p`` option makes beets print out -filenames of matched items, which might be useful for piping into other Unix -commands (such as `xargs`_). +albums instead of individual items. + +The ``-p`` option makes beets print out filenames of matched items, which might +be useful for piping into other Unix commands (such as `xargs`_). Similarly, the +``-f`` option lets you specify a specific format with which to print every album +or track. This uses the same template syntax as beets' :doc:`path formats +`. For example, the command ``beet ls -af '$album: $tracktotal' +beatles`` prints out the number of tracks on each Beatles album. Remember to +enclose the template argument in single quotes to avoid shell expansion. .. _xargs: http://en.wikipedia.org/wiki/Xargs diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 1ce5c443e..73feda105 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -161,7 +161,7 @@ artist, and ``singleton`` for non-album tracks. The defaults look like this:: [paths] default: $albumartist/$album/$track $title singleton: Non-Album/$artist/$title - comp: Compilations/$album/$track title + comp: Compilations/$album/$track $title Note the use of ``$albumartist`` instead of ``$artist``; this ensure that albums will be well-organized. For more about these format strings, see @@ -174,7 +174,7 @@ template string, the ``_`` character is substituted for ``:`` in these queries. This means that a config file like this:: [paths] - albumtype_soundtrack: Soundtracks/$album/$track title + albumtype_soundtrack: Soundtracks/$album/$track $title will place soundtrack albums in a separate directory. The queries are tested in the order they appear in the configuration file, meaning that if an item matches diff --git a/setup.py b/setup.py index e3f7a51a9..a4e3dcfb7 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ if 'sdist' in sys.argv: shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', - version='1.0b13', + version='1.0b14', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', @@ -75,7 +75,7 @@ setup(name='beets', 'munkres', 'unidecode', 'musicbrainzngs', - ], + ] + (['colorama'] if (sys.platform == 'win32') else []), classifiers=[ 'Topic :: Multimedia :: Sound/Audio', diff --git a/test/_common.py b/test/_common.py index aaccccb2f..d632df041 100644 --- a/test/_common.py +++ b/test/_common.py @@ -95,6 +95,7 @@ def iconfig(lib, **kwargs): query = None, incremental = False, ignore = [], + resolve_duplicate_func = lambda x, y: None, ) for k, v in kwargs.items(): setattr(config, k, v) diff --git a/test/test_db.py b/test/test_db.py index b8d824d0d..06cca6b48 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -404,7 +404,17 @@ class DestinationTest(unittest.TestCase): ]) self.assertEqual(p, 'bar/bar') -class DestinationFunctionTest(unittest.TestCase): +class PathFormattingMixin(object): + """Utilities for testing path formatting.""" + def _setf(self, fmt): + self.lib.path_formats.insert(0, ('default', fmt)) + def _assert_dest(self, dest, i=None): + if i is None: + i = self.i + self.assertEqual(self.lib.destination(i, pathmod=posixpath), + dest) + +class DestinationFunctionTest(unittest.TestCase, PathFormattingMixin): def setUp(self): self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' @@ -413,12 +423,6 @@ class DestinationFunctionTest(unittest.TestCase): def tearDown(self): self.lib.conn.close() - def _setf(self, fmt): - self.lib.path_formats.insert(0, ('default', fmt)) - def _assert_dest(self, dest): - self.assertEqual(self.lib.destination(self.i, pathmod=posixpath), - dest) - def test_upper_case_literal(self): self._setf(u'%upper{foo}') self._assert_dest('/base/FOO') @@ -459,6 +463,43 @@ class DestinationFunctionTest(unittest.TestCase): self._setf(u'%foo{bar}') self._assert_dest('/base/%foo{bar}') +class DisambiguationTest(unittest.TestCase, PathFormattingMixin): + def setUp(self): + self.lib = beets.library.Library(':memory:') + self.lib.directory = '/base' + self.lib.path_formats = [('default', u'path')] + + self.i1 = item() + self.i1.year = 2001 + self.lib.add_album([self.i1]) + self.i2 = item() + self.i2.year = 2002 + self.lib.add_album([self.i2]) + self.lib.save() + + self._setf(u'foo%unique{albumartist album,year}/$title') + + def tearDown(self): + self.lib.conn.close() + + def test_unique_expands_to_disambiguating_year(self): + self._assert_dest('/base/foo [2001]/the title', self.i1) + + def test_unique_expands_to_nothing_for_distinct_albums(self): + album2 = self.lib.get_album(self.i2) + album2.album = 'different album' + self.lib.save() + + self._assert_dest('/base/foo/the title', self.i1) + + def test_use_fallback_numbers_when_identical(self): + album2 = self.lib.get_album(self.i2) + album2.year = 2001 + self.lib.save() + + self._assert_dest('/base/foo 1/the title', self.i1) + self._assert_dest('/base/foo 2/the title', self.i2) + class PluginDestinationTest(unittest.TestCase): # Mock the plugins.template_values(item) function. def _template_values(self, item): diff --git a/test/test_importer.py b/test/test_importer.py index c04a12296..935ba65df 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -105,6 +105,7 @@ class NonAutotaggedImportTest(unittest.TestCase): query = None, incremental = False, ignore = [], + resolve_duplicate_func = None, ) return paths @@ -677,26 +678,6 @@ class DuplicateCheckTest(unittest.TestCase): self._item_task(True, 'xxx', 'yyy')) self.assertFalse(res) - def test_recent_item(self): - recent = set() - importer._item_duplicate_check(self.lib, - self._item_task(False, 'xxx', 'yyy'), - recent) - res = importer._item_duplicate_check(self.lib, - self._item_task(False, 'xxx', 'yyy'), - recent) - self.assertTrue(res) - - def test_recent_album(self): - recent = set() - importer._duplicate_check(self.lib, - self._album_task(False, 'xxx', 'yyy'), - recent) - res = importer._duplicate_check(self.lib, - self._album_task(False, 'xxx', 'yyy'), - recent) - self.assertTrue(res) - def test_duplicate_album_existing(self): res = importer._duplicate_check(self.lib, self._album_task(False, existing=True)) diff --git a/test/test_ui.py b/test/test_ui.py index 6130fbd9b..a14145330 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -47,7 +47,7 @@ class ListTest(unittest.TestCase): self.io.restore() def test_list_outputs_item(self): - commands.list_items(self.lib, '', False, False) + commands.list_items(self.lib, '', False, False, None) out = self.io.getoutput() self.assertTrue(u'the title' in out) @@ -56,42 +56,66 @@ class ListTest(unittest.TestCase): self.lib.store(self.item) self.lib.save() - commands.list_items(self.lib, [u'na\xefve'], False, False) + commands.list_items(self.lib, [u'na\xefve'], False, False, None) out = self.io.getoutput() self.assertTrue(u'na\xefve' in out.decode(self.io.stdout.encoding)) def test_list_item_path(self): - commands.list_items(self.lib, '', False, True) + commands.list_items(self.lib, '', False, True, None) out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx/yyy') def test_list_album_outputs_something(self): - commands.list_items(self.lib, '', True, False) + commands.list_items(self.lib, '', True, False, None) out = self.io.getoutput() self.assertGreater(len(out), 0) def test_list_album_path(self): - commands.list_items(self.lib, '', True, True) + commands.list_items(self.lib, '', True, True, None) out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx') def test_list_album_omits_title(self): - commands.list_items(self.lib, '', True, False) + commands.list_items(self.lib, '', True, False, None) out = self.io.getoutput() self.assertTrue(u'the title' not in out) def test_list_uses_track_artist(self): - commands.list_items(self.lib, '', False, False) + commands.list_items(self.lib, '', False, False, None) out = self.io.getoutput() self.assertTrue(u'the artist' in out) self.assertTrue(u'the album artist' not in out) def test_list_album_uses_album_artist(self): - commands.list_items(self.lib, '', True, False) + commands.list_items(self.lib, '', True, False, None) out = self.io.getoutput() self.assertTrue(u'the artist' not in out) self.assertTrue(u'the album artist' in out) + def test_list_item_format_artist(self): + commands.list_items(self.lib, '', False, False, '$artist') + out = self.io.getoutput() + self.assertTrue(u'the artist' in out) + + def test_list_item_format_multiple(self): + commands.list_items(self.lib, '', False, False, '$artist - $album - $year') + out = self.io.getoutput() + self.assertTrue(u'1' in out) + self.assertTrue(u'the album' in out) + self.assertTrue(u'the artist' in out) + self.assertEqual(u'the artist - the album - 1', out.strip()) + + def test_list_album_format(self): + commands.list_items(self.lib, '', True, False, '$genre') + out = self.io.getoutput() + self.assertTrue(u'the genre' in out) + self.assertTrue(u'the album' not in out) + + def test_list_item_path_ignores_format(self): + commands.list_items(self.lib, '', False, True, '$year - $artist') + out = self.io.getoutput() + self.assertEqual(out.strip(), u'xxx/yyy') + class RemoveTest(unittest.TestCase): def setUp(self): self.io = _common.DummyIO()