This commit is contained in:
Paul Provost 2012-03-24 17:25:26 -04:00
commit 1e4f2d555f
27 changed files with 772 additions and 186 deletions

View file

@ -10,3 +10,4 @@ a256ec5b0b2de500305fd6656db0a195df273acc 1.0b9
88807657483a916200296165933529da9a682528 1.0b10 88807657483a916200296165933529da9a682528 1.0b10
4ca1475821742002962df439f71f51d67640b91e 1.0b11 4ca1475821742002962df439f71f51d67640b91e 1.0b11
284b58a9f9ce3a79f7d2bcc48819f2bb77773818 1.0b12 284b58a9f9ce3a79f7d2bcc48819f2bb77773818 1.0b12
b6c10981014a5b3a963460fca3b31cc62bf7ed2c 1.0b13

7
.travis.yml Normal file
View file

@ -0,0 +1,7 @@
language: python
python:
- "2.7"
install:
- pip install . --use-mirrors
- pip install pylast flask --use-mirrors
script: nosetests

View file

@ -12,7 +12,7 @@
# The above copyright notice and this permission notice shall be # The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
__version__ = '1.0b13' __version__ = '1.0b14'
__author__ = 'Adrian Sampson <adrian@radbox.org>' __author__ = 'Adrian Sampson <adrian@radbox.org>'
import beets.library import beets.library

View file

@ -59,14 +59,26 @@ def tag_log(logfile, status, path):
print >>logfile, '%s %s' % (status, path) print >>logfile, '%s %s' % (status, path)
logfile.flush() logfile.flush()
def log_choice(config, task): def log_choice(config, task, duplicate=False):
"""Logs the task's current choice if it should be logged. """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 path = task.path if task.is_album else task.item.path
if task.choice_flag is action.ASIS: if duplicate:
tag_log(config.logfile, 'asis', path) # Duplicate: log all three choices (skip, keep both, and trump).
elif task.choice_flag is action.SKIP: if task.remove_duplicates:
tag_log(config.logfile, 'skip', path) 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): def _reopen_lib(lib):
"""Because of limitations in SQLite, a given Library is bound to """Because of limitations in SQLite, a given Library is bound to
@ -85,32 +97,18 @@ def _reopen_lib(lib):
else: else:
return lib return lib
def _duplicate_check(lib, task, recent=None): def _duplicate_check(lib, task):
"""Check whether an album already exists in the library. `recent` """Check whether an album already exists in the library. Returns a
should be a set of (artist, album) pairs that will be built up list of Album objects (empty if no duplicates are found).
with every call to this function and checked along with the
library.
""" """
if task.choice_flag is action.ASIS: assert task.choice_flag in (action.ASIS, action.APPLY)
artist = task.cur_artist artist, album = task.chosen_ident()
album = task.cur_album
elif task.choice_flag is action.APPLY:
artist = task.info.artist
album = task.info.album
else:
return False
if artist is None: if artist is None:
# As-is import with no artist. Skip check. # As-is import with no artist. Skip check.
return False return []
# Try the recent albums. found_albums = []
if recent is not None:
if (artist, album) in recent:
return True
recent.add((artist, album))
# Look in the library.
cur_paths = set(i.path for i in task.items if i) cur_paths = set(i.path for i in task.items if i)
for album_cand in lib.albums(artist=artist): for album_cand in lib.albums(artist=artist):
if album_cand.album == album: 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()) other_paths = set(i.path for i in album_cand.items())
if other_paths == cur_paths: if other_paths == cur_paths:
continue 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): found_items = []
"""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.
for other_item in lib.items(artist=artist, title=title): for other_item in lib.items(artist=artist, title=title):
# Existing items not considered duplicates. # Existing items not considered duplicates.
if other_item.path == task.item.path: if other_item.path == task.item.path:
continue continue
return True found_items.append(other_item)
return False return found_items
def _infer_album_fields(task): def _infer_album_fields(task):
"""Given an album and an associated import task, massage the """Given an album and an associated import task, massage the
@ -275,7 +262,8 @@ class ImportConfig(object):
'quiet_fallback', 'copy', 'write', 'art', 'delete', 'quiet_fallback', 'copy', 'write', 'art', 'delete',
'choose_match_func', 'should_resume_func', 'threaded', 'choose_match_func', 'should_resume_func', 'threaded',
'autot', 'singletons', 'timid', 'choose_item_func', 'autot', 'singletons', 'timid', 'choose_item_func',
'query', 'incremental', 'ignore'] 'query', 'incremental', 'ignore',
'resolve_duplicate_func']
def __init__(self, **kwargs): def __init__(self, **kwargs):
for slot in self._fields: for slot in self._fields:
setattr(self, slot, kwargs[slot]) setattr(self, slot, kwargs[slot])
@ -307,6 +295,7 @@ class ImportTask(object):
self.path = path self.path = path
self.items = items self.items = items
self.sentinel = False self.sentinel = False
self.remove_duplicates = False
@classmethod @classmethod
def done_sentinel(cls, toppath): def done_sentinel(cls, toppath):
@ -422,6 +411,26 @@ class ImportTask(object):
""" """
return self.sentinel or self.choice_flag == action.SKIP 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. # Full-album pipeline stages.
@ -575,10 +584,15 @@ def user_query(config):
continue continue
# Check for duplicates if we have a match (or ASIS). # Check for duplicates if we have a match (or ASIS).
if _duplicate_check(lib, task, recent): if task.choice_flag in (action.ASIS, action.APPLY):
tag_log(config.logfile, 'duplicate', task.path) ident = task.chosen_ident()
log.warn("This album is already in the library!") # The "recent" set keeps track of identifiers for recently
task.set_choice(action.SKIP) # 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): def show_progress(config):
"""This stage replaces the initial_lookup and user_query stages """This stage replaces the initial_lookup and user_query stages
@ -625,9 +639,9 @@ def apply_choices(config):
if task.is_album: if task.is_album:
_infer_album_fields(task) _infer_album_fields(task)
# Find existing item entries that these are replacing. Old # Find existing item entries that these are replacing (for
# album structures are automatically cleaned up when the # re-imports). Old album structures are automatically cleaned up
# last item is removed. # when the last item is removed.
replaced_items = defaultdict(list) replaced_items = defaultdict(list)
for item in items: for item in items:
dup_items = lib.items(library.MatchQuery('path', item.path)) 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), log.debug('%i of %i items replaced' % (len(replaced_items),
len(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. # Move/copy files.
task.old_paths = [item.path for item in items] task.old_paths = [item.path for item in items]
for item in items: for item in items:
@ -661,6 +697,8 @@ def apply_choices(config):
for replaced in replaced_items.itervalues(): for replaced in replaced_items.itervalues():
for item in replaced: for item in replaced:
lib.remove(item) lib.remove(item)
for item in duplicate_items:
lib.remove(item)
# Add new ones. # Add new ones.
if task.is_album: if task.is_album:
@ -775,10 +813,12 @@ def item_query(config):
log_choice(config, task) log_choice(config, task)
# Duplicate check. # Duplicate check.
if _item_duplicate_check(lib, task, recent): if task.choice_flag in (action.ASIS, action.APPLY):
tag_log(config.logfile, 'duplicate', task.item.path) ident = task.chosen_ident()
log.warn("This item is already in the library!") if ident in recent or _item_duplicate_check(lib, task):
task.set_choice(action.SKIP) config.resolve_duplicate_func(task, config)
log_choice(config, task, True)
recent.add(ident)
def item_progress(config): def item_progress(config):
"""Skips the lookup and query stages in a non-autotagged singleton """Skips the lookup and query stages in a non-autotagged singleton

View file

@ -354,7 +354,7 @@ class CollectionQuery(Query):
"""An abstract query class that aggregates other queries. Can be """An abstract query class that aggregates other queries. Can be
indexed like a list to access the sub-queries. indexed like a list to access the sub-queries.
""" """
def __init__(self, subqueries = ()): def __init__(self, subqueries=()):
self.subqueries = subqueries self.subqueries = subqueries
# is there a better way to do this? # is there a better way to do this?
@ -790,10 +790,10 @@ class Library(BaseLibrary):
if table == 'albums' and 'artist' in current_fields and \ if table == 'albums' and 'artist' in current_fields and \
'albumartist' not in current_fields: 'albumartist' not in current_fields:
setup_sql += "UPDATE ALBUMS SET albumartist=artist;\n" setup_sql += "UPDATE ALBUMS SET albumartist=artist;\n"
self.conn.executescript(setup_sql) self.conn.executescript(setup_sql)
self.conn.commit() self.conn.commit()
def destination(self, item, pathmod=None, in_album=False, def destination(self, item, pathmod=None, in_album=False,
fragment=False, basedir=None): fragment=False, basedir=None):
"""Returns the path in the library directory designated for item """Returns the path in the library directory designated for item
@ -805,7 +805,7 @@ class Library(BaseLibrary):
directory for the destination. directory for the destination.
""" """
pathmod = pathmod or os.path pathmod = pathmod or os.path
# Use a path format based on a query, falling back on the # Use a path format based on a query, falling back on the
# default. # default.
for query, path_format in self.path_formats: for query, path_format in self.path_formats:
@ -832,10 +832,10 @@ class Library(BaseLibrary):
else: else:
assert False, "no default path format" assert False, "no default path format"
subpath_tmpl = Template(path_format) subpath_tmpl = Template(path_format)
# Get the item's Album if it has one. # Get the item's Album if it has one.
album = self.get_album(item) album = self.get_album(item)
# Build the mapping for substitution in the path template, # Build the mapping for substitution in the path template,
# beginning with the values from the database. # beginning with the values from the database.
mapping = {} mapping = {}
@ -848,7 +848,7 @@ class Library(BaseLibrary):
# From Item. # From Item.
value = getattr(item, key) value = getattr(item, key)
mapping[key] = util.sanitize_for_path(value, pathmod, key) mapping[key] = util.sanitize_for_path(value, pathmod, key)
# Use the album artist if the track artist is not set and # Use the album artist if the track artist is not set and
# vice-versa. # vice-versa.
if not mapping['artist']: if not mapping['artist']:
@ -859,24 +859,24 @@ class Library(BaseLibrary):
# Get values from plugins. # Get values from plugins.
for key, value in plugins.template_values(item).iteritems(): for key, value in plugins.template_values(item).iteritems():
mapping[key] = util.sanitize_for_path(value, pathmod, key) mapping[key] = util.sanitize_for_path(value, pathmod, key)
# Perform substitution. # Perform substitution.
funcs = dict(TEMPLATE_FUNCTIONS) funcs = DefaultTemplateFunctions(self, item).functions()
funcs.update(plugins.template_funcs()) funcs.update(plugins.template_funcs())
subpath = subpath_tmpl.substitute(mapping, funcs) subpath = subpath_tmpl.substitute(mapping, funcs)
# Encode for the filesystem, dropping unencodable characters. # Encode for the filesystem, dropping unencodable characters.
if isinstance(subpath, unicode) and not fragment: if isinstance(subpath, unicode) and not fragment:
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
subpath = subpath.encode(encoding, 'replace') subpath = subpath.encode(encoding, 'replace')
# Truncate components and remove forbidden characters. # Truncate components and remove forbidden characters.
subpath = util.sanitize_path(subpath, pathmod, self.replacements) subpath = util.sanitize_path(subpath, pathmod, self.replacements)
# Preserve extension. # Preserve extension.
_, extension = pathmod.splitext(item.path) _, extension = pathmod.splitext(item.path)
subpath += extension.lower() subpath += extension.lower()
if fragment: if fragment:
return subpath return subpath
else: else:
@ -887,7 +887,6 @@ class Library(BaseLibrary):
# Item manipulation. # Item manipulation.
def add(self, item, copy=False): def add(self, item, copy=False):
#FIXME make a deep copy of the item?
item.library = self item.library = self
if copy: if copy:
self.move(item, copy=True) self.move(item, copy=True)
@ -902,18 +901,18 @@ class Library(BaseLibrary):
if key == 'path' and isinstance(value, str): if key == 'path' and isinstance(value, str):
value = buffer(value) value = buffer(value)
subvars.append(value) subvars.append(value)
# issue query # issue query
c = self.conn.cursor() c = self.conn.cursor()
query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')' query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')'
c.execute(query, subvars) c.execute(query, subvars)
new_id = c.lastrowid new_id = c.lastrowid
c.close() c.close()
item._clear_dirty() item._clear_dirty()
item.id = new_id item.id = new_id
return new_id return new_id
def save(self, event=True): def save(self, event=True):
"""Writes the library to disk (completing an sqlite """Writes the library to disk (completing an sqlite
transaction). transaction).
@ -925,7 +924,7 @@ class Library(BaseLibrary):
def load(self, item, load_id=None): def load(self, item, load_id=None):
if load_id is None: if load_id is None:
load_id = item.id load_id = item.id
c = self.conn.execute( c = self.conn.execute(
'SELECT * FROM items WHERE id=?', (load_id,) ) 'SELECT * FROM items WHERE id=?', (load_id,) )
item._fill_record(c.fetchone()) item._fill_record(c.fetchone())
@ -935,7 +934,7 @@ class Library(BaseLibrary):
def store(self, item, store_id=None, store_all=False): def store(self, item, store_id=None, store_all=False):
if store_id is None: if store_id is None:
store_id = item.id store_id = item.id
# build assignments for query # build assignments for query
assignments = '' assignments = ''
subvars = [] subvars = []
@ -948,7 +947,7 @@ class Library(BaseLibrary):
if key == 'path' and isinstance(value, str): if key == 'path' and isinstance(value, str):
value = buffer(value) value = buffer(value)
subvars.append(value) subvars.append(value)
if not assignments: if not assignments:
# nothing to store (i.e., nothing was dirty) # nothing to store (i.e., nothing was dirty)
return return
@ -1316,44 +1315,128 @@ def _int_arg(s):
function. May raise a ValueError. function. May raise a ValueError.
""" """
return int(s.strip()) 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 = { class DefaultTemplateFunctions(object):
'lower': _tmpl_lower, """A container class for the default functions provided to path
'upper': _tmpl_upper, templates. These functions are contained in an object to provide
'title': _tmpl_title, additional context to the functions -- specifically, the Item being
'left': _tmpl_left, evaluated.
'right': _tmpl_right, """
'if': _tmpl_if, def __init__(self, lib, item):
'asciify': _tmpl_asciify, 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))

View file

@ -317,7 +317,13 @@ class MediaField(object):
# possibly index the list # possibly index the list
if style.list_elem: if style.list_elem:
if entry: # List must have at least one value. 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: else:
return None return None
else: else:

View file

@ -32,6 +32,10 @@ from beets import library
from beets import plugins from beets import plugins
from beets import util from beets import util
if sys.platform == 'win32':
import colorama
colorama.init()
# Constants. # Constants.
CONFIG_PATH_VAR = 'BEETSCONFIG' CONFIG_PATH_VAR = 'BEETSCONFIG'
DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig' DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig'

View file

@ -30,6 +30,7 @@ import beets.autotag.art
from beets import plugins from beets import plugins
from beets import importer from beets import importer
from beets.util import syspath, normpath, ancestry, displayable_path from beets.util import syspath, normpath, ancestry, displayable_path
from beets.util.functemplate import Template
from beets import library from beets import library
# Global logger. # Global logger.
@ -563,6 +564,35 @@ def choose_item(task, config):
assert not isinstance(choice, importer.action) assert not isinstance(choice, importer.action)
return choice 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. # The import command.
def import_files(lib, paths, copy, write, autot, logpath, art, threaded, 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, query = query,
incremental = incremental, incremental = incremental,
ignore = ignore, ignore = ignore,
resolve_duplicate_func = resolve_duplicate,
) )
finally: finally:
@ -743,31 +774,41 @@ default_commands.append(import_cmd)
# list: Query and show library contents. # 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 """Print out items in lib matching query. If album, then search for
albums instead of single items. If path, print the matched objects' albums instead of single items. If path, print the matched objects'
paths instead of human-readable information about them. 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: if album:
for album in lib.albums(query): for album in lib.albums(query):
if path: if path:
print_(album.item_dir()) print_(album.item_dir())
else: elif fmt is not None:
print_(album.albumartist + u' - ' + album.album) print_(template.substitute(album._record))
else: else:
for item in lib.items(query): for item in lib.items(query):
if path: if path:
print_(item.path) print_(item.path)
else: elif fmt is not None:
print_(item.artist + u' - ' + item.album + u' - ' + item.title) print_(template.substitute(item.record))
list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',)) list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',))
list_cmd.parser.add_option('-a', '--album', action='store_true', list_cmd.parser.add_option('-a', '--album', action='store_true',
help='show matching albums instead of tracks') help='show matching albums instead of tracks')
list_cmd.parser.add_option('-p', '--path', action='store_true', list_cmd.parser.add_option('-p', '--path', action='store_true',
help='print paths for matched items or albums') 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): 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 list_cmd.func = list_func
default_commands.append(list_cmd) default_commands.append(list_cmd)

View file

@ -109,7 +109,7 @@ class Expression(object):
out.append(part) out.append(part)
else: else:
out.append(part.evaluate(env)) out.append(part.evaluate(env))
return u''.join(out) return u''.join(map(unicode, out))
class ParseError(Exception): class ParseError(Exception):
pass pass

View file

@ -50,7 +50,7 @@ class GstPlayer(object):
# Set up the Gstreamer player. From the pygst tutorial: # Set up the Gstreamer player. From the pygst tutorial:
# http://pygstdocs.berlios.de/pygst-tutorial/playbin.html # 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") fakesink = gst.element_factory_make("fakesink", "fakesink")
self.player.set_property("video-sink", fakesink) self.player.set_property("video-sink", fakesink)
bus = self.player.get_bus() bus = self.player.get_bus()

71
beetsplug/m3uupdate.py Normal file
View file

@ -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])

62
beetsplug/mbcollection.py Normal file
View file

@ -0,0 +1,62 @@
#Copyright (c) 2011, Jeffrey Aylesworth <jeffrey@aylesworth.ca>
#
#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]

70
beetsplug/rdm.py Normal file
View file

@ -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]

View file

@ -1,21 +1,45 @@
Changelog 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 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 refinements. A lyrics plugin is now included with beets; new audio properties
are catalogged; the autotagger is more tolerant of different tagging styles; and are catalogged; the ``list`` command has been made more powerful; the autotagger
importing with original file deletion now cleans up after itself more is more tolerant of different tagging styles; and importing with original file
thoroughly. Many, many bugs—including several crashers—were fixed. This release deletion now cleans up after itself more thoroughly. Many, many bugs—including
lays the foundation for more features to come in the next couple of releases. 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 * The :doc:`/plugins/lyrics`, originally by `Peter Brunner`_, is revamped and
included with beets, making it easy to fetch **song lyrics**. included with beets, making it easy to fetch **song lyrics**.
* Items now expose their audio **sample rate**, number of **channels**, and * Items now expose their audio **sample rate**, number of **channels**, and
**bits per sample** (bitdepth). See :doc:`/reference/pathformat` for a list of **bits per sample** (bitdepth). See :doc:`/reference/pathformat` for a list of
all available audio properties. Thanks to Andrew Dunn. 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 * 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 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 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. albums.
* The autotagger now also tolerates tracks whose track artists tags are set * The autotagger now also tolerates tracks whose track artists tags are set
to "Various Artists". 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 * When previewing metadata differences, the importer now shows discrepancies in
track length. track length.
* Importing with ``import_delete`` enabled now cleans up empty directories that * 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. data.
* Fix the ``list`` command in BPD (thanks to Simon Chopin). * Fix the ``list`` command in BPD (thanks to Simon Chopin).
.. _Colorama: http://pypi.python.org/pypi/colorama
1.0b12 (January 16, 2012) 1.0b12 (January 16, 2012)
------------------------- -------------------------

View file

@ -12,8 +12,8 @@ master_doc = 'index'
project = u'beets' project = u'beets'
copyright = u'2011, Adrian Sampson' copyright = u'2011, Adrian Sampson'
version = '1.0b13' version = '1.0b14'
release = '1.0b13' release = '1.0b14'
pygments_style = 'sphinx' pygments_style = 'sphinx'

View file

@ -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 them, just like the earlier example. As the prompt suggests, you can just hit
return to select the first candidate. 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 Fingerprinting
-------------- --------------

View file

@ -30,10 +30,10 @@ Plugins Included With Beets
--------------------------- ---------------------------
There are a few plugins that are included with the beets distribution. They're 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:: .. toctree::
:maxdepth: 1 :hidden:
chroma chroma
lyrics lyrics
@ -46,6 +46,50 @@ disabled by default, but you can turn them on as described above:
inline inline
scrub scrub
rewrite 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: .. _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. * `beetFs`_ is a FUSE filesystem for browsing the music in your beets library.
(Might be out of date.) (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. * `A cmus plugin`_ integrates with the `cmus`_ console music player.
.. _beetFs: http://code.google.com/p/beetfs/ .. _beetFs: http://code.google.com/p/beetfs/
.. _Beet-MusicBrainz-Collection: .. _Beet-MusicBrainz-Collection:
https://github.com/jeffayle/Beet-MusicBrainz-Collection/ https://github.com/jeffayle/Beet-MusicBrainz-Collection/
.. _"music collection": http://musicbrainz.org/show/collection/
.. _A cmus plugin: .. _A cmus plugin:
https://github.com/coolkehon/beets/blob/master/beetsplug/cmus.py https://github.com/coolkehon/beets/blob/master/beetsplug/cmus.py
.. _cmus: http://cmus.sourceforge.net/ .. _cmus: http://cmus.sourceforge.net/

View file

@ -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

View file

@ -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.

21
docs/plugins/rdm.rst Normal file
View file

@ -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``.

View file

@ -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 gronlandic``. Maybe you want to see everything released in 2009 with
"vegetables" in the title? Try ``beet list year:2009 title:vegetables``. (Read "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 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 albums instead of individual items.
filenames of matched items, which might be useful for piping into other Unix
commands (such as `xargs`_). 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
<pathformat>`. 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 .. _xargs: http://en.wikipedia.org/wiki/Xargs

View file

@ -161,7 +161,7 @@ artist, and ``singleton`` for non-album tracks. The defaults look like this::
[paths] [paths]
default: $albumartist/$album/$track $title default: $albumartist/$album/$track $title
singleton: Non-Album/$artist/$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 Note the use of ``$albumartist`` instead of ``$artist``; this ensure that albums
will be well-organized. For more about these format strings, see 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:: This means that a config file like this::
[paths] [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 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 the order they appear in the configuration file, meaning that if an item matches

View file

@ -42,7 +42,7 @@ if 'sdist' in sys.argv:
shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir)
setup(name='beets', setup(name='beets',
version='1.0b13', version='1.0b14',
description='music tagger and library organizer', description='music tagger and library organizer',
author='Adrian Sampson', author='Adrian Sampson',
author_email='adrian@radbox.org', author_email='adrian@radbox.org',
@ -75,7 +75,7 @@ setup(name='beets',
'munkres', 'munkres',
'unidecode', 'unidecode',
'musicbrainzngs', 'musicbrainzngs',
], ] + (['colorama'] if (sys.platform == 'win32') else []),
classifiers=[ classifiers=[
'Topic :: Multimedia :: Sound/Audio', 'Topic :: Multimedia :: Sound/Audio',

View file

@ -95,6 +95,7 @@ def iconfig(lib, **kwargs):
query = None, query = None,
incremental = False, incremental = False,
ignore = [], ignore = [],
resolve_duplicate_func = lambda x, y: None,
) )
for k, v in kwargs.items(): for k, v in kwargs.items():
setattr(config, k, v) setattr(config, k, v)

View file

@ -404,7 +404,17 @@ class DestinationTest(unittest.TestCase):
]) ])
self.assertEqual(p, 'bar/bar') 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): def setUp(self):
self.lib = beets.library.Library(':memory:') self.lib = beets.library.Library(':memory:')
self.lib.directory = '/base' self.lib.directory = '/base'
@ -413,12 +423,6 @@ class DestinationFunctionTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.lib.conn.close() 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): def test_upper_case_literal(self):
self._setf(u'%upper{foo}') self._setf(u'%upper{foo}')
self._assert_dest('/base/FOO') self._assert_dest('/base/FOO')
@ -459,6 +463,43 @@ class DestinationFunctionTest(unittest.TestCase):
self._setf(u'%foo{bar}') self._setf(u'%foo{bar}')
self._assert_dest('/base/%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): class PluginDestinationTest(unittest.TestCase):
# Mock the plugins.template_values(item) function. # Mock the plugins.template_values(item) function.
def _template_values(self, item): def _template_values(self, item):

View file

@ -105,6 +105,7 @@ class NonAutotaggedImportTest(unittest.TestCase):
query = None, query = None,
incremental = False, incremental = False,
ignore = [], ignore = [],
resolve_duplicate_func = None,
) )
return paths return paths
@ -677,26 +678,6 @@ class DuplicateCheckTest(unittest.TestCase):
self._item_task(True, 'xxx', 'yyy')) self._item_task(True, 'xxx', 'yyy'))
self.assertFalse(res) 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): def test_duplicate_album_existing(self):
res = importer._duplicate_check(self.lib, res = importer._duplicate_check(self.lib,
self._album_task(False, existing=True)) self._album_task(False, existing=True))

View file

@ -47,7 +47,7 @@ class ListTest(unittest.TestCase):
self.io.restore() self.io.restore()
def test_list_outputs_item(self): 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() out = self.io.getoutput()
self.assertTrue(u'the title' in out) self.assertTrue(u'the title' in out)
@ -56,42 +56,66 @@ class ListTest(unittest.TestCase):
self.lib.store(self.item) self.lib.store(self.item)
self.lib.save() 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() out = self.io.getoutput()
self.assertTrue(u'na\xefve' in out.decode(self.io.stdout.encoding)) self.assertTrue(u'na\xefve' in out.decode(self.io.stdout.encoding))
def test_list_item_path(self): 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() out = self.io.getoutput()
self.assertEqual(out.strip(), u'xxx/yyy') self.assertEqual(out.strip(), u'xxx/yyy')
def test_list_album_outputs_something(self): 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() out = self.io.getoutput()
self.assertGreater(len(out), 0) self.assertGreater(len(out), 0)
def test_list_album_path(self): 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() out = self.io.getoutput()
self.assertEqual(out.strip(), u'xxx') self.assertEqual(out.strip(), u'xxx')
def test_list_album_omits_title(self): 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() out = self.io.getoutput()
self.assertTrue(u'the title' not in out) self.assertTrue(u'the title' not in out)
def test_list_uses_track_artist(self): 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() out = self.io.getoutput()
self.assertTrue(u'the artist' in out) self.assertTrue(u'the artist' in out)
self.assertTrue(u'the album artist' not in out) self.assertTrue(u'the album artist' not in out)
def test_list_album_uses_album_artist(self): 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() out = self.io.getoutput()
self.assertTrue(u'the artist' not in out) self.assertTrue(u'the artist' not in out)
self.assertTrue(u'the album artist' 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): class RemoveTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.io = _common.DummyIO() self.io = _common.DummyIO()