mirror of
https://github.com/beetbox/beets.git
synced 2025-12-21 16:13:38 +01:00
Merge branch 'master' of https://github.com/sampsyo/beets
This commit is contained in:
commit
1e4f2d555f
27 changed files with 772 additions and 186 deletions
1
.hgtags
1
.hgtags
|
|
@ -10,3 +10,4 @@ a256ec5b0b2de500305fd6656db0a195df273acc 1.0b9
|
|||
88807657483a916200296165933529da9a682528 1.0b10
|
||||
4ca1475821742002962df439f71f51d67640b91e 1.0b11
|
||||
284b58a9f9ce3a79f7d2bcc48819f2bb77773818 1.0b12
|
||||
b6c10981014a5b3a963460fca3b31cc62bf7ed2c 1.0b13
|
||||
|
|
|
|||
7
.travis.yml
Normal file
7
.travis.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
install:
|
||||
- pip install . --use-mirrors
|
||||
- pip install pylast flask --use-mirrors
|
||||
script: nosetests
|
||||
|
|
@ -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 <adrian@radbox.org>'
|
||||
|
||||
import beets.library
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
203
beets/library.py
203
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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
71
beetsplug/m3uupdate.py
Normal file
71
beetsplug/m3uupdate.py
Normal 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
62
beetsplug/mbcollection.py
Normal 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
70
beetsplug/rdm.py
Normal 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]
|
||||
|
|
@ -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)
|
||||
-------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
--------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
20
docs/plugins/m3uupdate.rst
Normal file
20
docs/plugins/m3uupdate.rst
Normal 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
|
||||
20
docs/plugins/mbcollection.rst
Normal file
20
docs/plugins/mbcollection.rst
Normal 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
21
docs/plugins/rdm.rst
Normal 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``.
|
||||
|
|
@ -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
|
||||
<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
setup.py
4
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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue