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
4ca1475821742002962df439f71f51d67640b91e 1.0b11
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
# 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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