Updated beets library to 1.0b11

This commit is contained in:
Remy Varma
2011-11-03 22:03:03 +00:00
parent 0ba1e578ab
commit 5cf995b3dc
27 changed files with 8689 additions and 1059 deletions
+225 -65
View File
@@ -19,13 +19,14 @@ from __future__ import with_statement # Python 2.5
import os
import logging
import pickle
from collections import defaultdict
from lib.beets import autotag
from lib.beets import library
import lib.beets.autotag.art as beets.autotag.art
import lib.beets.autotag.art
from lib.beets import plugins
from lib.beets.util import pipeline
from lib.beets.util import syspath, normpath
from lib.beets.util import syspath, normpath, plurality
from lib.beets.util.enumeration import enum
action = enum(
@@ -35,6 +36,8 @@ action = enum(
QUEUE_SIZE = 128
STATE_FILE = os.path.expanduser('~/.beetsstate')
SINGLE_ARTIST_THRESH = 0.25
VARIOUS_ARTISTS = u'Various Artists'
# Global logger.
log = logging.getLogger('beets')
@@ -78,12 +81,21 @@ def _reopen_lib(lib):
else:
return lib
def _duplicate_check(lib, artist, album, recent=None):
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.
"""
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
if artist is None:
# As-is import with no artist. Skip check.
return False
@@ -95,14 +107,29 @@ def _duplicate_check(lib, artist, album, recent=None):
recent.add((artist, album))
# Look in the library.
cur_paths = set(i.path for i in task.items)
for album_cand in lib.albums(artist=artist):
if album_cand.album == album:
# Check whether the album is identical in contents, in which
# case it is not a duplicate (will be replaced).
other_paths = set(i.path for i in album_cand.items())
if other_paths == cur_paths:
continue
return True
return False
def _item_duplicate_check(lib, artist, title, recent=None):
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:
@@ -112,13 +139,66 @@ def _item_duplicate_check(lib, artist, title, recent=None):
# Check the library.
item_iter = lib.items(artist=artist, title=title)
try:
item_iter.next()
except StopIteration:
return False
for other_item in item_iter:
# Existing items not considered duplicates.
if other_item.path == task.item.path:
continue
return True
finally:
item_iter.close()
return False
def _infer_album_fields(task):
"""Given an album and an associated import task, massage the
album-level metadata. This ensures that the album artist is set
and that the "compilation" flag is set automatically.
"""
assert task.is_album
assert task.items
changes = {}
if task.choice_flag == action.ASIS:
# Taking metadata "as-is". Guess whether this album is VA.
plur_artist, freq = plurality([i.artist for i in task.items])
if freq == len(task.items) or (freq > 1 and
float(freq) / len(task.items) >= SINGLE_ARTIST_THRESH):
# Single-artist album.
changes['albumartist'] = plur_artist
changes['comp'] = False
else:
# VA.
changes['albumartist'] = VARIOUS_ARTISTS
changes['comp'] = True
elif task.choice_flag == action.APPLY:
# Applying autotagged metadata. Just get AA from the first
# item.
if not task.items[0].albumartist:
changes['albumartist'] = task.items[0].artist
if not task.items[0].mb_albumartistid:
changes['mb_albumartistid'] = task.items[0].mb_artistid
else:
assert False
# Apply new metadata.
for item in task.items:
for k, v in changes.iteritems():
setattr(item, k, v)
def _open_state():
"""Reads the state file, returning a dictionary."""
try:
with open(STATE_FILE) as f:
return pickle.load(f)
except IOError:
return {}
def _save_state(state):
"""Writes the state dictionary out to disk."""
with open(STATE_FILE, 'w') as f:
pickle.dump(state, f)
return True
# Utilities for reading and writing the beets progress file, which
# allows long tagging tasks to be resumed when they pause (or crash).
@@ -128,11 +208,9 @@ def progress_set(toppath, path):
`path`. If path is None, then clear the progress value (indicating
that the tagging completed).
"""
try:
with open(STATE_FILE) as f:
state = pickle.load(f)
except IOError:
state = {PROGRESS_KEY: {}}
state = _open_state()
if PROGRESS_KEY not in state:
state[PROGRESS_KEY] = {}
if path is None:
# Remove progress from file.
@@ -141,20 +219,41 @@ def progress_set(toppath, path):
else:
state[PROGRESS_KEY][toppath] = path
with open(STATE_FILE, 'w') as f:
pickle.dump(state, f)
_save_state(state)
def progress_get(toppath):
"""Get the last successfully tagged subpath of toppath. If toppath
has no progress information, returns None.
"""
try:
with open(STATE_FILE) as f:
state = pickle.load(f)
except IOError:
state = _open_state()
if PROGRESS_KEY not in state:
return None
return state[PROGRESS_KEY].get(toppath)
# Similarly, utilities for manipulating the "incremental" import log.
# This keeps track of all directories that were ever imported, which
# allows the importer to only import new stuff.
HISTORY_KEY = 'taghistory'
def history_add(path):
"""Indicate that the import of `path` is completed and should not
be repeated in incremental imports.
"""
state = _open_state()
if HISTORY_KEY not in state:
state[HISTORY_KEY] = set()
state[HISTORY_KEY].add(path)
_save_state(state)
def history_get():
"""Get the set of completed paths in incremental imports.
"""
state = _open_state()
if HISTORY_KEY not in state:
return set()
return state[HISTORY_KEY]
# The configuration structure.
class ImportConfig(object):
@@ -165,7 +264,8 @@ class ImportConfig(object):
_fields = ['lib', 'paths', 'resume', 'logfile', 'color', 'quiet',
'quiet_fallback', 'copy', 'write', 'art', 'delete',
'choose_match_func', 'should_resume_func', 'threaded',
'autot', 'singletons', 'timid', 'choose_item_func']
'autot', 'singletons', 'timid', 'choose_item_func',
'query', 'incremental']
def __init__(self, **kwargs):
for slot in self._fields:
setattr(self, slot, kwargs[slot])
@@ -174,6 +274,17 @@ class ImportConfig(object):
if self.paths:
self.paths = map(normpath, self.paths)
# Incremental and progress are mutually exclusive.
if self.incremental:
self.resume = False
# When based on a query instead of directories, never
# save progress or try to resume.
if self.query is not None:
self.paths = None
self.resume = False
self.incremental = False
# The importer task class.
@@ -277,6 +388,12 @@ class ImportTask(object):
# album task, which implies the same.
progress_set(self.toppath, self.path)
def save_history(self):
"""Save the directory in the history for incremental imports.
"""
if self.sentinel or self.is_album:
history_add(self.path)
# Logical decisions.
def should_write_tags(self):
"""Should new info be written to the files' metadata?"""
@@ -289,19 +406,6 @@ class ImportTask(object):
def should_fetch_art(self):
"""Should album art be downloaded for this album?"""
return self.should_write_tags() and self.is_album
def should_infer_aa(self):
"""When creating an album structure, should the album artist
field be inferred from the plurality of track artists?
"""
assert self.is_album
if self.choice_flag == action.APPLY:
# Album artist comes from the info dictionary.
return False
elif self.choice_flag == action.ASIS:
# As-is imports likely don't have an album artist.
return True
else:
assert False
def should_skip(self):
"""After a choice has been made, returns True if this is a
sentinel or it has been marked for skipping.
@@ -336,6 +440,10 @@ def read_tasks(config):
else:
# Clear progress; we're starting from the top.
progress_set(path, None)
# Look for saved incremental directories.
if config.incremental:
history_dirs = history_get()
for toppath in config.paths:
# Check whether the path is to a file.
@@ -348,6 +456,7 @@ def read_tasks(config):
if progress:
resume_dir = resume_dirs.get(toppath)
for path, items in autotag.albums_in_dir(toppath):
# Skip according to progress.
if progress and resume_dir:
# We're fast-forwarding to resume a previous tagging.
if path == resume_dir:
@@ -356,6 +465,10 @@ def read_tasks(config):
resume_dir = None
continue
# When incremental, skip paths in the history.
if config.incremental and path in history_dirs:
continue
# Yield all the necessary tasks.
if config.singletons:
for item in items:
@@ -367,6 +480,28 @@ def read_tasks(config):
# Indicate the directory is finished.
yield ImportTask.done_sentinel(toppath)
def query_tasks(config):
"""A generator that works as a drop-in-replacement for read_tasks.
Instead of finding files from the filesystem, a query is used to
match items from the library.
"""
lib = _reopen_lib(config.lib)
if config.singletons:
# Search for items.
items = list(lib.items(config.query))
for item in items:
yield ImportTask.item_task(item)
else:
# Search for albums.
albums = lib.albums(config.query)
for album in albums:
log.debug('yielding album %i: %s - %s' %
(album.id, album.albumartist, album.album))
items = list(album.items())
yield ImportTask(None, album.item_dir(), items)
def initial_lookup(config):
"""A coroutine for performing the initial MusicBrainz lookup for an
album. It accepts lists of Items and yields
@@ -420,19 +555,13 @@ def user_query(config):
item_query(config), collector()))
ipl.run_sequential()
task = pipeline.multiple(item_tasks)
continue
# Check for duplicates if we have a match (or ASIS).
if choice is action.ASIS or isinstance(choice, tuple):
if choice is action.ASIS:
artist = task.cur_artist
album = task.cur_album
else:
artist = task.info['artist']
album = task.info['album']
if _duplicate_check(lib, artist, album, recent):
tag_log(config.logfile, 'duplicate', task.path)
log.warn("This album is already in the library!")
task.set_choice(action.SKIP)
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)
def show_progress(config):
"""This stage replaces the initial_lookup and user_query stages
@@ -462,29 +591,60 @@ def apply_choices(config):
if task.should_skip():
continue
# Change metadata, move, and copy.
items = task.items if task.is_album else [task.item]
# Clear IDs in case the items are being re-tagged.
for item in items:
item.id = None
item.album_id = None
# Change metadata.
if task.should_write_tags():
if task.is_album:
autotag.apply_metadata(task.items, task.info)
else:
autotag.apply_item_metadata(task.item, task.info)
items = task.items if task.is_album else [task.item]
if config.copy and config.delete:
task.old_paths = [os.path.realpath(syspath(item.path))
for item in items]
# Infer album-level fields.
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.
replaced_items = defaultdict(list)
for item in items:
dup_items = list(lib.items(
library.MatchQuery('path', item.path)
))
for dup_item in dup_items:
replaced_items[item].append(dup_item)
log.debug('replacing item %i: %s' % (dup_item.id, item.path))
log.debug('%i of %i items replaced' % (len(replaced_items),
len(items)))
# Move/copy files.
task.old_paths = [item.path for item in items]
for item in items:
if config.copy:
item.move(lib, True, task.is_album)
# If we're replacing an item, then move rather than
# copying.
do_copy = not bool(replaced_items[item])
lib.move(item, do_copy, task.is_album)
if config.write and task.should_write_tags():
item.write()
# Add items to library. We consolidate this at the end to avoid
# locking while we do the copying and tag updates.
try:
# Remove old items.
for replaced in replaced_items.itervalues():
for item in replaced:
lib.remove(item)
# Add new ones.
if task.is_album:
# Add an album.
album = lib.add_album(task.items,
infer_aa = task.should_infer_aa())
album = lib.add_album(task.items)
task.album_id = album.id
else:
# Add tracks.
@@ -505,7 +665,7 @@ def fetch_art(config):
continue
if task.should_fetch_art():
artpath = beets.autotag.art.art_for_album(task.info)
artpath = lib.beets.autotag.art.art_for_album(task.info, task.path)
# Save the art if any was found.
if artpath:
@@ -526,6 +686,8 @@ def finalize(config):
if task.should_skip():
if config.resume is not False:
task.save_progress()
if config.incremental:
task.save_history()
continue
items = task.items if task.is_album else [task.item]
@@ -549,6 +711,8 @@ def finalize(config):
# Update progress.
if config.resume is not False:
task.save_progress()
if config.incremental:
task.save_history()
# Singleton pipeline stages.
@@ -582,17 +746,10 @@ def item_query(config):
log_choice(config, task)
# Duplicate check.
if task.choice_flag in (action.ASIS, action.APPLY):
if choice is action.ASIS:
artist = task.item.artist
title = task.item.title
else:
artist = task.info['artist']
title = task.info['title']
if _item_duplicate_check(lib, artist, title, recent):
tag_log(config.logfile, 'duplicate', task.item.path)
log.warn("This item is already in the library!")
task.set_choice(action.SKIP)
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)
def item_progress(config):
"""Skips the lookup and query stages in a non-autotagged singleton
@@ -619,7 +776,10 @@ def run_import(**kwargs):
config = ImportConfig(**kwargs)
# Set up the pipeline.
stages = [read_tasks(config)]
if config.query is None:
stages = [read_tasks(config)]
else:
stages = [query_tasks(config)]
if config.singletons:
# Singleton importer.
if config.autot: