mirror of
https://github.com/rembo10/headphones.git
synced 2026-03-21 20:29:27 +00:00
652 lines
21 KiB
Python
652 lines
21 KiB
Python
# This file is part of beets.
|
|
# Copyright 2011, Adrian Sampson.
|
|
#
|
|
# 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.
|
|
|
|
"""Provides the basic, interface-agnostic workflow for importing and
|
|
autotagging music files.
|
|
"""
|
|
from __future__ import with_statement # Python 2.5
|
|
import os
|
|
import logging
|
|
import pickle
|
|
|
|
from lib.beets import autotag
|
|
from lib.beets import library
|
|
import lib.beets.autotag.art as 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.enumeration import enum
|
|
|
|
action = enum(
|
|
'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID',
|
|
name='action'
|
|
)
|
|
|
|
QUEUE_SIZE = 128
|
|
STATE_FILE = os.path.expanduser('~/.beetsstate')
|
|
|
|
# Global logger.
|
|
log = logging.getLogger('beets')
|
|
|
|
class ImportAbort(Exception):
|
|
"""Raised when the user aborts the tagging operation.
|
|
"""
|
|
pass
|
|
|
|
|
|
# Utilities.
|
|
|
|
def tag_log(logfile, status, path):
|
|
"""Log a message about a given album to logfile. The status should
|
|
reflect the reason the album couldn't be tagged.
|
|
"""
|
|
if logfile:
|
|
print >>logfile, '%s %s' % (status, path)
|
|
|
|
def log_choice(config, task):
|
|
"""Logs the task's current choice if it should be logged.
|
|
"""
|
|
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)
|
|
|
|
def _reopen_lib(lib):
|
|
"""Because of limitations in SQLite, a given Library is bound to
|
|
the thread in which it was created. This function reopens Library
|
|
objects so that they can be used from separate threads.
|
|
"""
|
|
if isinstance(lib, library.Library):
|
|
return library.Library(
|
|
lib.path,
|
|
lib.directory,
|
|
lib.path_formats,
|
|
lib.art_filename,
|
|
)
|
|
else:
|
|
return lib
|
|
|
|
def _duplicate_check(lib, artist, album, 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 artist is None:
|
|
# As-is import with no artist. Skip check.
|
|
return False
|
|
|
|
# Try the recent albums.
|
|
if recent is not None:
|
|
if (artist, album) in recent:
|
|
return True
|
|
recent.add((artist, album))
|
|
|
|
# Look in the library.
|
|
for album_cand in lib.albums(artist=artist):
|
|
if album_cand.album == album:
|
|
return True
|
|
|
|
return False
|
|
|
|
def _item_duplicate_check(lib, artist, title, recent=None):
|
|
"""Check whether an item already exists in the library."""
|
|
# Try recent items.
|
|
if recent is not None:
|
|
if (artist, title) in recent:
|
|
return True
|
|
recent.add((artist, title))
|
|
|
|
# Check the library.
|
|
item_iter = lib.items(artist=artist, title=title)
|
|
try:
|
|
item_iter.next()
|
|
except StopIteration:
|
|
return False
|
|
finally:
|
|
item_iter.close()
|
|
|
|
return True
|
|
|
|
# Utilities for reading and writing the beets progress file, which
|
|
# allows long tagging tasks to be resumed when they pause (or crash).
|
|
PROGRESS_KEY = 'tagprogress'
|
|
def progress_set(toppath, path):
|
|
"""Record that tagging for the given `toppath` was successful up to
|
|
`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: {}}
|
|
|
|
if path is None:
|
|
# Remove progress from file.
|
|
if toppath in state[PROGRESS_KEY]:
|
|
del state[PROGRESS_KEY][toppath]
|
|
else:
|
|
state[PROGRESS_KEY][toppath] = path
|
|
|
|
with open(STATE_FILE, 'w') as f:
|
|
pickle.dump(state, f)
|
|
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:
|
|
return None
|
|
return state[PROGRESS_KEY].get(toppath)
|
|
|
|
|
|
# The configuration structure.
|
|
|
|
class ImportConfig(object):
|
|
"""Contains all the settings used during an import session. Should
|
|
be used in a "write-once" way -- everything is set up initially and
|
|
then never touched again.
|
|
"""
|
|
_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']
|
|
def __init__(self, **kwargs):
|
|
for slot in self._fields:
|
|
setattr(self, slot, kwargs[slot])
|
|
|
|
# Normalize the paths.
|
|
if self.paths:
|
|
self.paths = map(normpath, self.paths)
|
|
|
|
|
|
# The importer task class.
|
|
|
|
class ImportTask(object):
|
|
"""Represents a single set of items to be imported along with its
|
|
intermediate state. May represent an album or a single item.
|
|
"""
|
|
def __init__(self, toppath=None, path=None, items=None):
|
|
self.toppath = toppath
|
|
self.path = path
|
|
self.items = items
|
|
self.sentinel = False
|
|
|
|
@classmethod
|
|
def done_sentinel(cls, toppath):
|
|
"""Create an ImportTask that indicates the end of a top-level
|
|
directory import.
|
|
"""
|
|
obj = cls(toppath)
|
|
obj.sentinel = True
|
|
return obj
|
|
|
|
@classmethod
|
|
def progress_sentinel(cls, toppath, path):
|
|
"""Create a task indicating that a single directory in a larger
|
|
import has finished. This is only required for singleton
|
|
imports; progress is implied for album imports.
|
|
"""
|
|
obj = cls(toppath, path)
|
|
obj.sentinel = True
|
|
return obj
|
|
|
|
@classmethod
|
|
def item_task(cls, item):
|
|
"""Creates an ImportTask for a single item."""
|
|
obj = cls()
|
|
obj.item = item
|
|
obj.is_album = False
|
|
return obj
|
|
|
|
def set_match(self, cur_artist, cur_album, candidates, rec):
|
|
"""Sets the candidates for this album matched by the
|
|
`autotag.tag_album` method.
|
|
"""
|
|
assert not self.sentinel
|
|
self.cur_artist = cur_artist
|
|
self.cur_album = cur_album
|
|
self.candidates = candidates
|
|
self.rec = rec
|
|
self.is_album = True
|
|
|
|
def set_null_match(self):
|
|
"""Set the candidates to indicate no album match was found.
|
|
"""
|
|
self.set_match(None, None, None, None)
|
|
|
|
def set_item_match(self, candidates, rec):
|
|
"""Set the match for a single-item task."""
|
|
assert not self.is_album
|
|
assert self.item is not None
|
|
self.item_match = (candidates, rec)
|
|
|
|
def set_null_item_match(self):
|
|
"""For single-item tasks, mark the item as having no matches.
|
|
"""
|
|
assert not self.is_album
|
|
assert self.item is not None
|
|
self.item_match = None
|
|
|
|
def set_choice(self, choice):
|
|
"""Given either an (info, items) tuple or an action constant,
|
|
indicates that an action has been selected by the user (or
|
|
automatically).
|
|
"""
|
|
assert not self.sentinel
|
|
# Not part of the task structure:
|
|
assert choice not in (action.MANUAL, action.MANUAL_ID)
|
|
assert choice != action.APPLY # Only used internally.
|
|
if choice in (action.SKIP, action.ASIS, action.TRACKS):
|
|
self.choice_flag = choice
|
|
self.info = None
|
|
else:
|
|
assert not isinstance(choice, action)
|
|
if self.is_album:
|
|
info, items = choice
|
|
self.items = items # Reordered items list.
|
|
else:
|
|
info = choice
|
|
self.info = info
|
|
self.choice_flag = action.APPLY # Implicit choice.
|
|
|
|
def save_progress(self):
|
|
"""Updates the progress state to indicate that this album has
|
|
finished.
|
|
"""
|
|
if self.sentinel and self.path is None:
|
|
# "Done" sentinel.
|
|
progress_set(self.toppath, None)
|
|
elif self.sentinel or self.is_album:
|
|
# "Directory progress" sentinel for singletons or a real
|
|
# album task, which implies the same.
|
|
progress_set(self.toppath, self.path)
|
|
|
|
# Logical decisions.
|
|
def should_write_tags(self):
|
|
"""Should new info be written to the files' metadata?"""
|
|
if self.choice_flag == action.APPLY:
|
|
return True
|
|
elif self.choice_flag in (action.ASIS, action.TRACKS, action.SKIP):
|
|
return False
|
|
else:
|
|
assert False
|
|
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.
|
|
"""
|
|
return self.sentinel or self.choice_flag == action.SKIP
|
|
|
|
|
|
# Full-album pipeline stages.
|
|
|
|
def read_tasks(config):
|
|
"""A generator yielding all the albums (as ImportTask objects) found
|
|
in the user-specified list of paths. In the case of a singleton
|
|
import, yields single-item tasks instead.
|
|
"""
|
|
# Look for saved progress.
|
|
progress = config.resume is not False
|
|
if progress:
|
|
resume_dirs = {}
|
|
for path in config.paths:
|
|
resume_dir = progress_get(path)
|
|
if resume_dir:
|
|
|
|
# Either accept immediately or prompt for input to decide.
|
|
if config.resume:
|
|
do_resume = True
|
|
log.warn('Resuming interrupted import of %s' % path)
|
|
else:
|
|
do_resume = config.should_resume_func(config, path)
|
|
|
|
if do_resume:
|
|
resume_dirs[path] = resume_dir
|
|
else:
|
|
# Clear progress; we're starting from the top.
|
|
progress_set(path, None)
|
|
|
|
for toppath in config.paths:
|
|
# Check whether the path is to a file.
|
|
if config.singletons and not os.path.isdir(syspath(toppath)):
|
|
item = library.Item.from_path(toppath)
|
|
yield ImportTask.item_task(item)
|
|
continue
|
|
|
|
# Produce paths under this directory.
|
|
if progress:
|
|
resume_dir = resume_dirs.get(toppath)
|
|
for path, items in autotag.albums_in_dir(toppath):
|
|
if progress and resume_dir:
|
|
# We're fast-forwarding to resume a previous tagging.
|
|
if path == resume_dir:
|
|
# We've hit the last good path! Turn off the
|
|
# fast-forwarding.
|
|
resume_dir = None
|
|
continue
|
|
|
|
# Yield all the necessary tasks.
|
|
if config.singletons:
|
|
for item in items:
|
|
yield ImportTask.item_task(item)
|
|
yield ImportTask.progress_sentinel(toppath, path)
|
|
else:
|
|
yield ImportTask(toppath, path, items)
|
|
|
|
# Indicate the directory is finished.
|
|
yield ImportTask.done_sentinel(toppath)
|
|
|
|
def initial_lookup(config):
|
|
"""A coroutine for performing the initial MusicBrainz lookup for an
|
|
album. It accepts lists of Items and yields
|
|
(items, cur_artist, cur_album, candidates, rec) tuples. If no match
|
|
is found, all of the yielded parameters (except items) are None.
|
|
"""
|
|
task = None
|
|
while True:
|
|
task = yield task
|
|
if task.sentinel:
|
|
continue
|
|
|
|
log.debug('Looking up: %s' % task.path)
|
|
try:
|
|
task.set_match(*autotag.tag_album(task.items, config.timid))
|
|
except autotag.AutotagError:
|
|
task.set_null_match()
|
|
|
|
def user_query(config):
|
|
"""A coroutine for interfacing with the user about the tagging
|
|
process. lib is the Library to import into and logfile may be
|
|
a file-like object for logging the import process. The coroutine
|
|
accepts and yields ImportTask objects.
|
|
"""
|
|
lib = _reopen_lib(config.lib)
|
|
recent = set()
|
|
task = None
|
|
while True:
|
|
task = yield task
|
|
if task.sentinel:
|
|
continue
|
|
|
|
# Ask the user for a choice.
|
|
choice = config.choose_match_func(task, config)
|
|
task.set_choice(choice)
|
|
log_choice(config, task)
|
|
|
|
# As-tracks: transition to singleton workflow.
|
|
if choice is action.TRACKS:
|
|
# Set up a little pipeline for dealing with the singletons.
|
|
item_tasks = []
|
|
def emitter():
|
|
for item in task.items:
|
|
yield ImportTask.item_task(item)
|
|
yield ImportTask.progress_sentinel(task.toppath, task.path)
|
|
def collector():
|
|
while True:
|
|
item_task = yield
|
|
item_tasks.append(item_task)
|
|
ipl = pipeline.Pipeline((emitter(), item_lookup(config),
|
|
item_query(config), collector()))
|
|
ipl.run_sequential()
|
|
task = pipeline.multiple(item_tasks)
|
|
|
|
# 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)
|
|
|
|
def show_progress(config):
|
|
"""This stage replaces the initial_lookup and user_query stages
|
|
when the importer is run without autotagging. It displays the album
|
|
name and artist as the files are added.
|
|
"""
|
|
task = None
|
|
while True:
|
|
task = yield task
|
|
if task.sentinel:
|
|
continue
|
|
|
|
log.info(task.path)
|
|
|
|
# Behave as if ASIS were selected.
|
|
task.set_null_match()
|
|
task.set_choice(action.ASIS)
|
|
|
|
def apply_choices(config):
|
|
"""A coroutine for applying changes to albums during the autotag
|
|
process.
|
|
"""
|
|
lib = _reopen_lib(config.lib)
|
|
task = None
|
|
while True:
|
|
task = yield task
|
|
if task.should_skip():
|
|
continue
|
|
|
|
# Change metadata, move, and copy.
|
|
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]
|
|
for item in items:
|
|
if config.copy:
|
|
item.move(lib, True, 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:
|
|
if task.is_album:
|
|
# Add an album.
|
|
album = lib.add_album(task.items,
|
|
infer_aa = task.should_infer_aa())
|
|
task.album_id = album.id
|
|
else:
|
|
# Add tracks.
|
|
for item in items:
|
|
lib.add(item)
|
|
finally:
|
|
lib.save()
|
|
|
|
def fetch_art(config):
|
|
"""A coroutine that fetches and applies album art for albums where
|
|
appropriate.
|
|
"""
|
|
lib = _reopen_lib(config.lib)
|
|
task = None
|
|
while True:
|
|
task = yield task
|
|
if task.should_skip():
|
|
continue
|
|
|
|
if task.should_fetch_art():
|
|
artpath = beets.autotag.art.art_for_album(task.info)
|
|
|
|
# Save the art if any was found.
|
|
if artpath:
|
|
try:
|
|
album = lib.get_album(task.album_id)
|
|
album.set_art(artpath)
|
|
finally:
|
|
lib.save(False)
|
|
|
|
def finalize(config):
|
|
"""A coroutine that finishes up importer tasks. In particular, the
|
|
coroutine sends plugin events, deletes old files, and saves
|
|
progress. This is a "terminal" coroutine (it yields None).
|
|
"""
|
|
lib = _reopen_lib(config.lib)
|
|
while True:
|
|
task = yield
|
|
if task.should_skip():
|
|
if config.resume is not False:
|
|
task.save_progress()
|
|
continue
|
|
|
|
items = task.items if task.is_album else [task.item]
|
|
|
|
# Announce that we've added an album.
|
|
if task.is_album:
|
|
album = lib.get_album(task.album_id)
|
|
plugins.send('album_imported', lib=lib, album=album)
|
|
else:
|
|
for item in items:
|
|
plugins.send('item_imported', lib=lib, item=item)
|
|
|
|
# Finally, delete old files.
|
|
if config.copy and config.delete:
|
|
new_paths = [os.path.realpath(item.path) for item in items]
|
|
for old_path in task.old_paths:
|
|
# Only delete files that were actually moved.
|
|
if old_path not in new_paths:
|
|
os.remove(syspath(old_path))
|
|
|
|
# Update progress.
|
|
if config.resume is not False:
|
|
task.save_progress()
|
|
|
|
|
|
# Singleton pipeline stages.
|
|
|
|
def item_lookup(config):
|
|
"""A coroutine used to perform the initial MusicBrainz lookup for
|
|
an item task.
|
|
"""
|
|
task = None
|
|
while True:
|
|
task = yield task
|
|
if task.sentinel:
|
|
continue
|
|
|
|
task.set_item_match(*autotag.tag_item(task.item, config.timid))
|
|
|
|
def item_query(config):
|
|
"""A coroutine that queries the user for input on single-item
|
|
lookups.
|
|
"""
|
|
lib = _reopen_lib(config.lib)
|
|
task = None
|
|
recent = set()
|
|
while True:
|
|
task = yield task
|
|
if task.sentinel:
|
|
continue
|
|
|
|
choice = config.choose_item_func(task, config)
|
|
task.set_choice(choice)
|
|
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)
|
|
|
|
def item_progress(config):
|
|
"""Skips the lookup and query stages in a non-autotagged singleton
|
|
import. Just shows progress.
|
|
"""
|
|
task = None
|
|
log.info('Importing items:')
|
|
while True:
|
|
task = yield task
|
|
if task.sentinel:
|
|
continue
|
|
|
|
log.info(task.item.path)
|
|
task.set_null_item_match()
|
|
task.set_choice(action.ASIS)
|
|
|
|
|
|
# Main driver.
|
|
|
|
def run_import(**kwargs):
|
|
"""Run an import. The keyword arguments are the same as those to
|
|
ImportConfig.
|
|
"""
|
|
config = ImportConfig(**kwargs)
|
|
|
|
# Set up the pipeline.
|
|
stages = [read_tasks(config)]
|
|
if config.singletons:
|
|
# Singleton importer.
|
|
if config.autot:
|
|
stages += [item_lookup(config), item_query(config)]
|
|
else:
|
|
stages += [item_progress(config)]
|
|
else:
|
|
# Whole-album importer.
|
|
if config.autot:
|
|
# Only look up and query the user when autotagging.
|
|
stages += [initial_lookup(config), user_query(config)]
|
|
else:
|
|
# When not autotagging, just display progress.
|
|
stages += [show_progress(config)]
|
|
stages += [apply_choices(config)]
|
|
if config.art:
|
|
stages += [fetch_art(config)]
|
|
stages += [finalize(config)]
|
|
pl = pipeline.Pipeline(stages)
|
|
|
|
# Run the pipeline.
|
|
try:
|
|
if config.threaded:
|
|
pl.run_parallel(QUEUE_SIZE)
|
|
else:
|
|
pl.run_sequential()
|
|
except ImportAbort:
|
|
# User aborted operation. Silently stop.
|
|
pass
|