diff --git a/.travis.yml b/.travis.yml index 57051f833..fb2719187 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,8 +10,8 @@ matrix: env: {TOX_ENV: py26} - python: 2.7 env: {TOX_ENV: py27cov, COVERAGE: 1} - - python: pypy - env: {TOX_ENV: pypy} +# - python: pypy +# env: {TOX_ENV: pypy} - python: 2.7 env: {TOX_ENV: docs} - python: 2.7 diff --git a/beet b/beet index c5699a5ac..e30c8b6eb 100755 --- a/beet +++ b/beet @@ -1,7 +1,7 @@ #!/usr/bin/env python # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/__init__.py b/beets/__init__.py index 609acd6fc..be95bdcb4 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -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.3.10' +__version__ = '1.3.11' __author__ = 'Adrian Sampson ' import beets.library diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 5ac2380db..7ed7ce6bc 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,8 +14,8 @@ """Facilities for automatically determining files' correct metadata. """ -import logging +from beets import logging from beets import config # Parts of external interface. diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index beb3bd91b..6b592e1de 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -13,10 +13,10 @@ # included in all copies or substantial portions of the Software. """Glue between metadata sources and the matching logic.""" -import logging from collections import namedtuple import re +from beets import logging from beets import plugins from beets import config from beets.autotag import mb diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 2d1f20074..ea80ae111 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -18,10 +18,10 @@ releases and tracks. from __future__ import division import datetime -import logging import re from munkres import Munkres +from beets import logging from beets import plugins from beets import config from beets.util import plurality @@ -267,7 +267,7 @@ def match_by_id(items): # If all album IDs are equal, look up the album. if bool(reduce(lambda x, y: x if x == y else (), albumids)): albumid = albumids[0] - log.debug(u'Searching for discovered album ID: {0}'.format(albumid)) + log.debug(u'Searching for discovered album ID: {0}', albumid) return hooks.album_for_mbid(albumid) else: log.debug(u'No album ID consensus.') @@ -330,7 +330,7 @@ def _add_candidate(items, results, info): checking the track count, ordering the items, checking for duplicates, and calculating the distance. """ - log.debug(u'Candidate: {0} - {1}'.format(info.artist, info.album)) + log.debug(u'Candidate: {0} - {1}', info.artist, info.album) # Discard albums with zero tracks. if not info.tracks: @@ -345,7 +345,7 @@ def _add_candidate(items, results, info): # Discard matches without required tags. for req_tag in config['match']['required'].as_str_seq(): if getattr(info, req_tag) is None: - log.debug(u'Ignored. Missing required tag: {0}'.format(req_tag)) + log.debug(u'Ignored. Missing required tag: {0}', req_tag) return # Find mapping between the items and the track info. @@ -358,10 +358,10 @@ def _add_candidate(items, results, info): penalties = [key for _, key in dist] for penalty in config['match']['ignored'].as_str_seq(): if penalty in penalties: - log.debug(u'Ignored. Penalty: {0}'.format(penalty)) + log.debug(u'Ignored. Penalty: {0}', penalty) return - log.debug(u'Success. Distance: {0}'.format(dist)) + log.debug(u'Success. Distance: {0}', dist) results[info.album_id] = hooks.AlbumMatch(dist, info, mapping, extra_items, extra_tracks) @@ -387,7 +387,7 @@ def tag_album(items, search_artist=None, search_album=None, likelies, consensus = current_metadata(items) cur_artist = likelies['artist'] cur_album = likelies['album'] - log.debug(u'Tagging {0} - {1}'.format(cur_artist, cur_album)) + log.debug(u'Tagging {0} - {1}', cur_artist, cur_album) # The output result (distance, AlbumInfo) tuples (keyed by MB album # ID). @@ -395,7 +395,7 @@ def tag_album(items, search_artist=None, search_album=None, # Search by explicit ID. if search_id is not None: - log.debug(u'Searching for album ID: {0}'.format(search_id)) + log.debug(u'Searching for album ID: {0}', search_id) search_cands = hooks.albums_for_id(search_id) # Use existing metadata or text search. @@ -405,7 +405,7 @@ def tag_album(items, search_artist=None, search_album=None, if id_info: _add_candidate(items, candidates, id_info) rec = _recommendation(candidates.values()) - log.debug(u'Album ID match recommendation is {0}'.format(str(rec))) + log.debug(u'Album ID match recommendation is {0}', str(rec)) if candidates and not config['import']['timid']: # If we have a very good MBID match, return immediately. # Otherwise, this match will compete against metadata-based @@ -418,20 +418,19 @@ def tag_album(items, search_artist=None, search_album=None, if not (search_artist and search_album): # No explicit search terms -- use current metadata. search_artist, search_album = cur_artist, cur_album - log.debug(u'Search terms: {0} - {1}'.format(search_artist, - search_album)) + log.debug(u'Search terms: {0} - {1}', search_artist, search_album) # Is this album likely to be a "various artist" release? va_likely = ((not consensus['artist']) or (search_artist.lower() in VA_ARTISTS) or any(item.comp for item in items)) - log.debug(u'Album might be VA: {0}'.format(str(va_likely))) + log.debug(u'Album might be VA: {0}', str(va_likely)) # Get the results from the data sources. search_cands = hooks.album_candidates(items, search_artist, search_album, va_likely) - log.debug(u'Evaluating {0} candidates.'.format(len(search_cands))) + log.debug(u'Evaluating {0} candidates.', len(search_cands)) for info in search_cands: _add_candidate(items, candidates, info) @@ -456,7 +455,7 @@ def tag_item(item, search_artist=None, search_title=None, # First, try matching by MusicBrainz ID. trackid = search_id or item.mb_trackid if trackid: - log.debug(u'Searching for track ID: {0}'.format(trackid)) + log.debug(u'Searching for track ID: {0}', trackid) for track_info in hooks.tracks_for_id(trackid): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = \ @@ -477,8 +476,7 @@ def tag_item(item, search_artist=None, search_title=None, # Search terms. if not (search_artist and search_title): search_artist, search_title = item.artist, item.title - log.debug(u'Item search terms: {0} - {1}'.format(search_artist, - search_title)) + log.debug(u'Item search terms: {0} - {1}', search_artist, search_title) # Get and evaluate candidate metadata. for track_info in hooks.item_candidates(item, search_artist, search_title): @@ -486,7 +484,7 @@ def tag_item(item, search_artist=None, search_title=None, candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) # Sort by distance and return with recommendation. - log.debug(u'Found {0} candidates.'.format(len(candidates))) + log.debug(u'Found {0} candidates.', len(candidates)) candidates = sorted(candidates.itervalues()) rec = _recommendation(candidates) return candidates, rec diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index d063f6278..78f4cba7d 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,12 +14,12 @@ """Searches for albums in the MusicBrainz database. """ -import logging import musicbrainzngs import re import traceback from urlparse import urljoin +from beets import logging import beets.autotag.hooks import beets from beets import util @@ -374,7 +374,7 @@ def album_for_id(releaseid): """ albumid = _parse_id(releaseid) if not albumid: - log.debug(u'Invalid MBID ({0}).'.format(releaseid)) + log.debug(u'Invalid MBID ({0}).', releaseid) return try: res = musicbrainzngs.get_release_by_id(albumid, @@ -394,7 +394,7 @@ def track_for_id(releaseid): """ trackid = _parse_id(releaseid) if not trackid: - log.debug(u'Invalid MBID ({0}).'.format(releaseid)) + log.debug(u'Invalid MBID ({0}).', releaseid) return try: res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) diff --git a/beets/dbcore/__init__.py b/beets/dbcore/__init__.py index c364fdfc3..d08bd5013 100644 --- a/beets/dbcore/__init__.py +++ b/beets/dbcore/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 0c786daa5..7017e3e62 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 5a116eb2b..3ea37524a 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index 90963696b..89a6f5ca2 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 82346e704..c171a9310 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/importer.py b/beets/importer.py index 72b2a32fb..e90fd0eae 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -19,7 +19,6 @@ from __future__ import print_function import os import re -import logging import pickle import itertools from collections import defaultdict @@ -27,7 +26,9 @@ from tempfile import mkdtemp from bisect import insort, bisect_left from contextlib import contextmanager import shutil +import time +from beets import logging from beets import autotag from beets import library from beets import dbcore @@ -71,7 +72,7 @@ def _open_state(): # unpickling, including ImportError. We use a catch-all # exception to avoid enumerating them all (the docs don't even have a # full list!). - log.debug(u'state file could not be read: {0}'.format(exc)) + log.debug(u'state file could not be read: {0}', exc) return {} @@ -81,7 +82,7 @@ def _save_state(state): with open(config['statefile'].as_filename(), 'w') as f: pickle.dump(state, f) except IOError as exc: - log.error(u'state file could not be written: {0}'.format(exc)) + log.error(u'state file could not be written: {0}', exc) # Utilities for reading and writing the beets progress file, which @@ -174,14 +175,13 @@ class ImportSession(object): """Controls an import action. Subclasses should implement methods to communicate with the user or otherwise make decisions. """ - def __init__(self, lib, logfile, paths, query): - """Create a session. `lib` is a Library object. `logfile` is a - file-like object open for writing or None if no logging is to be - performed. Either `paths` or `query` is non-null and indicates + def __init__(self, lib, loghandler, paths, query): + """Create a session. `lib` is a Library object. `loghandler` is a + logging.Handler. Either `paths` or `query` is non-null and indicates the source of files to be imported. """ self.lib = lib - self.logfile = logfile + self.logger = self._setup_logging(loghandler) self.paths = paths self.query = query self.seen_idents = set() @@ -191,6 +191,14 @@ class ImportSession(object): if self.paths: self.paths = map(normpath, self.paths) + def _setup_logging(self, loghandler): + logger = logging.getLogger(__name__) + logger.propagate = False + if not loghandler: + loghandler = logging.NullHandler() + logger.handlers = [loghandler] + return logger + def set_config(self, config): """Set `config` property from global import config and make implied changes. @@ -225,13 +233,10 @@ class ImportSession(object): self.want_resume = config['resume'].as_choice([True, False, 'ask']) def tag_log(self, status, paths): - """Log a message about a given album to logfile. The status should - reflect the reason the album couldn't be tagged. + """Log a message about a given album to the importer log. The status + should reflect the reason the album couldn't be tagged. """ - if self.logfile: - print(u'{0} {1}'.format(status, displayable_path(paths)), - file=self.logfile) - self.logfile.flush() + self.logger.info(u'{0} {1}', status, displayable_path(paths)) def log_choice(self, task, duplicate=False): """Logs the task's current choice if it should be logged. If @@ -269,6 +274,7 @@ class ImportSession(object): def run(self): """Run the import task. """ + self.logger.info(u'import started {0}', time.asctime()) self.set_config(config['import']) # Set up the pipeline. @@ -347,8 +353,8 @@ class ImportSession(object): # Either accept immediately or prompt for input to decide. if self.want_resume is True or \ self.should_resume(toppath): - log.warn(u'Resuming interrupted import of {0}'.format( - util.displayable_path(toppath))) + log.warn(u'Resuming interrupted import of {0}', + util.displayable_path(toppath)) self._is_resuming[toppath] = True else: # Clear progress; we're starting from the top. @@ -481,13 +487,12 @@ class ImportTask(object): def remove_duplicates(self, lib): duplicate_items = self.duplicate_items(lib) - log.debug(u'removing {0} old duplicated items' - .format(len(duplicate_items))) + log.debug(u'removing {0} old duplicated items', len(duplicate_items)) for item in duplicate_items: item.remove() if lib.directory in util.ancestry(item.path): - log.debug(u'deleting duplicate {0}' - .format(util.displayable_path(item.path))) + log.debug(u'deleting duplicate {0}', + util.displayable_path(item.path)) util.remove(item.path) util.prune_dirs(os.path.dirname(item.path), lib.directory) @@ -686,12 +691,11 @@ class ImportTask(object): self.album.store() log.debug( u'Reimported album: added {0}, flexible ' - u'attributes {1} from album {2} for {3}'.format( - self.album.added, - replaced_album._values_flex.keys(), - replaced_album.id, - displayable_path(self.album.path), - ) + u'attributes {1} from album {2} for {3}', + self.album.added, + replaced_album._values_flex.keys(), + replaced_album.id, + displayable_path(self.album.path) ) for item in self.imported_items(): @@ -701,20 +705,18 @@ class ImportTask(object): item.added = dup_item.added log.debug( u'Reimported item added {0} ' - u'from item {1} for {2}'.format( - item.added, - dup_item.id, - displayable_path(item.path), - ) + u'from item {1} for {2}', + item.added, + dup_item.id, + displayable_path(item.path) ) item.update(dup_item._values_flex) log.debug( u'Reimported item flexible attributes {0} ' - u'from item {1} for {2}'.format( - dup_item._values_flex.keys(), - dup_item.id, - displayable_path(item.path), - ) + u'from item {1} for {2}', + dup_item._values_flex.keys(), + dup_item.id, + displayable_path(item.path) ) item.store() @@ -724,13 +726,12 @@ class ImportTask(object): """ for item in self.imported_items(): for dup_item in self.replaced_items[item]: - log.debug(u'Replacing item {0}: {1}' - .format(dup_item.id, - displayable_path(item.path))) + log.debug(u'Replacing item {0}: {1}', + dup_item.id, displayable_path(item.path)) dup_item.remove() - log.debug(u'{0} of {1} items replaced' - .format(sum(bool(l) for l in self.replaced_items.values()), - len(self.imported_items()))) + log.debug(u'{0} of {1} items replaced', + sum(bool(l) for l in self.replaced_items.values()), + len(self.imported_items())) def choose_match(self, session): """Ask the session which match should apply and apply it. @@ -1004,8 +1005,8 @@ class ImportTaskFactory(object): def singleton(self, path, item=None): if not item: if self.session.already_imported(self.toppath, [path]): - log.debug(u'Skipping previously-imported path: {0}' - .format(displayable_path(path))) + log.debug(u'Skipping previously-imported path: {0}', + displayable_path(path)) self.skipped += 1 return [] @@ -1031,8 +1032,8 @@ class ImportTaskFactory(object): dirs = list(set(os.path.dirname(p) for p in paths)) if self.session.already_imported(self.toppath, dirs): - log.debug(u'Skipping previously-imported path: {0}' - .format(displayable_path(dirs))) + log.debug(u'Skipping previously-imported path: {0}', + displayable_path(dirs)) self.skipped += 1 return [] @@ -1091,14 +1092,10 @@ class ImportTaskFactory(object): # Silently ignore non-music files. pass elif isinstance(exc.reason, mediafile.UnreadableFileError): - log.warn(u'unreadable file: {0}'.format( - displayable_path(path)) - ) + log.warn(u'unreadable file: {0}', displayable_path(path)) else: - log.error(u'error reading {0}: {1}'.format( - displayable_path(path), - exc, - )) + log.error(u'error reading {0}: {1}', + displayable_path(path), exc) # Full-album pipeline stages. @@ -1123,13 +1120,13 @@ def read_tasks(session): "'copy' or 'move' to be enabled.") continue - log.debug(u'extracting archive {0}' - .format(displayable_path(toppath))) + log.debug(u'extracting archive {0}', + displayable_path(toppath)) archive_task = task_factory.archive(toppath)[0] try: archive_task.extract() except Exception as exc: - log.error(u'extraction failed: {0}'.format(exc)) + log.error(u'extraction failed: {0}', exc) continue # Continue reading albums from the extracted directory. @@ -1150,12 +1147,12 @@ def read_tasks(session): yield archive_task if not imported: - log.warn(u'No files imported from {0}' - .format(displayable_path(user_toppath))) + log.warn(u'No files imported from {0}', + displayable_path(user_toppath)) # Show skipped directories. if skipped: - log.info(u'Skipped {0} directories.'.format(skipped)) + log.info(u'Skipped {0} directories.', skipped) def query_tasks(session): @@ -1174,8 +1171,8 @@ def query_tasks(session): else: # Search for albums. for album in session.lib.albums(session.query): - log.debug(u'yielding album {0}: {1} - {2}' - .format(album.id, album.albumartist, album.album)) + log.debug(u'yielding album {0}: {1} - {2}', + album.id, album.albumartist, album.album) items = list(album.items()) # Clear IDs from re-tagged items so they appear "fresh" when @@ -1202,7 +1199,7 @@ def lookup_candidates(session, task): return plugins.send('import_task_start', session=session, task=task) - log.debug(u'Looking up: {0}'.format(displayable_path(task.paths))) + log.debug(u'Looking up: {0}', displayable_path(task.paths)) task.lookup_candidates() @@ -1350,12 +1347,11 @@ def log_files(session, task): return if isinstance(task, SingletonImportTask): - log.info( - 'Singleton: {0}'.format(displayable_path(task.item['path']))) + log.info(u'Singleton: {0}', displayable_path(task.item['path'])) elif task.items: - log.info('Album {0}'.format(displayable_path(task.paths[0]))) + log.info(u'Album {0}', displayable_path(task.paths[0])) for item in task.items: - log.info(' {0}'.format(displayable_path(item['path']))) + log.info(u' {0}', displayable_path(item['path'])) def group_albums(session): diff --git a/beets/library.py b/beets/library.py index 1de1bba56..9b3e4a238 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -16,12 +16,13 @@ """ import os import sys -import logging import shlex import unicodedata import time import re from unidecode import unidecode + +from beets import logging from beets.mediafile import MediaFile, MutagenError, UnreadableFileError from beets import plugins from beets import util @@ -509,7 +510,7 @@ class Item(LibModel): self.write(path) return True except FileOperationError as exc: - log.error(exc) + log.error(str(exc)) return False def try_sync(self, write=None): @@ -837,9 +838,9 @@ class Album(LibModel): return new_art = util.unique_path(new_art) - log.debug(u'moving album art {0} to {1}' - .format(util.displayable_path(old_art), - util.displayable_path(new_art))) + log.debug(u'moving album art {0} to {1}', + util.displayable_path(old_art), + util.displayable_path(new_art)) if copy: util.copy(old_art, new_art) elif link: diff --git a/beets/logging.py b/beets/logging.py new file mode 100644 index 000000000..933fdb167 --- /dev/null +++ b/beets/logging.py @@ -0,0 +1,107 @@ +# This file is part of beets. +# Copyright 2015, 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. + +"""A drop-in replacement for the standard-library `logging` module that +allows {}-style log formatting on Python 2 and 3. + +Provides everything the "logging" module does. The only difference is +that when getLogger(name) instantiates a logger that logger uses +{}-style formatting. +""" + +from __future__ import absolute_import +from copy import copy +from logging import * # noqa +import sys + + +# We need special hacks for Python 2.6 due to logging.Logger being an +# old- style class and having no loggerClass attribute. +PY26 = sys.version_info[:2] == (2, 6) + + +class StrFormatLogger(Logger): + """A version of `Logger` that uses `str.format`-style formatting + instead of %-style formatting. + """ + + class _LogMessage(object): + def __init__(self, msg, args, kwargs): + self.msg = msg + self.args = args + self.kwargs = kwargs + + def __str__(self): + return self.msg.format(*self.args, **self.kwargs) + + def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs): + """Log msg.format(*args, **kwargs)""" + m = self._LogMessage(msg, args, kwargs) + return Logger._log(self, level, m, (), exc_info, extra) + # We cannot call super(StrFormatLogger, self) because it is not + # allowed on old-style classes (py2), which Logger is in python 2.6. + # Moreover, we cannot make StrFormatLogger a new-style class (by + # declaring 'class StrFormatLogger(Logger, object)' because the class- + # patching stmt 'logger.__class__ = StrFormatLogger' would not work: + # both prev & new __class__ values must be either old- or new- style; + # no mixing allowed. + + if PY26: + def getChild(self, suffix): + """Shameless copy from cpython's Lib/logging/__init__.py""" + if self.root is not self: + suffix = '.'.join((self.name, suffix)) + return self.manager.getLogger(suffix) + +my_manager = copy(Logger.manager) +my_manager.loggerClass = StrFormatLogger + + +def getLogger(name=None): + if name: + return my_manager.getLogger(name) + else: + return Logger.root + + +# On Python 2.6, there is no Manager.loggerClass so we dynamically +# change the logger class. We must be careful to do that on new loggers +# only to avoid side-effects. +if PY26: + # Wrap Manager.getLogger. + old_getLogger = my_manager.getLogger + + def new_getLogger(name): + change_its_type = not isinstance(my_manager.loggerDict.get(name), + Logger) + # it either does not exist or is a placeholder + logger = old_getLogger(name) + if change_its_type: + logger.__class__ = StrFormatLogger + return logger + + my_manager.getLogger = new_getLogger + + +# Offer NullHandler in Python 2.6 to reduce the difference with never versions +if PY26: + class NullHandler(Handler): + def handle(self, record): + pass + + def emit(self, record): + pass + + def createLock(self): + self.lock = None diff --git a/beets/mediafile.py b/beets/mediafile.py index 49ef10378..7522acf0a 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -48,10 +48,10 @@ import math import struct import imghdr import os -import logging import traceback import enum +from beets import logging from beets.util import displayable_path @@ -1313,7 +1313,7 @@ class MediaFile(object): try: self.mgfile = mutagen.File(path) except unreadable_exc as exc: - log.debug(u'header parsing failed: {0}'.format(unicode(exc))) + log.debug(u'header parsing failed: {0}', unicode(exc)) raise UnreadableFileError(path) except IOError as exc: if type(exc) == IOError: @@ -1326,7 +1326,7 @@ class MediaFile(object): except Exception as exc: # Isolate bugs in Mutagen. log.debug(traceback.format_exc()) - log.error(u'uncaught Mutagen exception in open: {0}'.format(exc)) + log.error(u'uncaught Mutagen exception in open: {0}', exc) raise MutagenError(path, exc) if self.mgfile is None: @@ -1399,7 +1399,7 @@ class MediaFile(object): raise except Exception as exc: log.debug(traceback.format_exc()) - log.error(u'uncaught Mutagen exception in save: {0}'.format(exc)) + log.error(u'uncaught Mutagen exception in save: {0}', exc) raise MutagenError(self.path, exc) def delete(self): diff --git a/beets/plugins.py b/beets/plugins.py index 3efe716a4..76804e579 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,14 +14,15 @@ """Support for beets plugins.""" -import logging import traceback import inspect import re from collections import defaultdict +from functools import wraps import beets +from beets import logging from beets import mediafile PLUGIN_NAMESPACE = 'beetsplug' @@ -41,6 +42,23 @@ class PluginConflictException(Exception): """ +class PluginLogFilter(logging.Filter): + """A logging filter that identifies the plugin that emitted a log + message. + """ + def __init__(self, plugin): + self.prefix = u'{0}: '.format(plugin.name) + + def filter(self, record): + if hasattr(record.msg, 'msg') and isinstance(record.msg.msg, + basestring): + # A _LogMessage from our hacked-up Logging replacement. + record.msg.msg = self.prefix + record.msg.msg + elif isinstance(record.msg, basestring): + record.msg = self.prefix + record.msg + return True + + # Managing the plugins themselves. class BeetsPlugin(object): @@ -51,7 +69,6 @@ class BeetsPlugin(object): def __init__(self, name=None): """Perform one-time plugin setup. """ - self.import_stages = [] self.name = name or self.__module__.split('.')[-1] self.config = beets.config[self.name] if not self.template_funcs: @@ -60,6 +77,12 @@ class BeetsPlugin(object): self.template_fields = {} if not self.album_template_fields: self.album_template_fields = {} + self.import_stages = [] + + self._log = log.getChild(self.name) + self._log.setLevel(logging.NOTSET) # Use `beets` logger level. + if beets.config['verbose']: + self._log.addFilter(PluginLogFilter(self)) def commands(self): """Should return a list of beets.ui.Subcommand objects for @@ -67,6 +90,36 @@ class BeetsPlugin(object): """ return () + def get_import_stages(self): + """Return a list of functions that should be called as importer + pipelines stages. + + The callables are wrapped versions of the functions in + `self.import_stages`. Wrapping provides some bookkeeping for the + plugin: specifically, the logging level is adjusted to WARNING. + """ + return [self._set_log_level(logging.WARNING, import_stage) + for import_stage in self.import_stages] + + def _set_log_level(self, log_level, func): + """Wrap `func` to temporarily set this plugin's logger level to + `log_level` (and restore it after the function returns). + + The level is *not* adjusted when beets is in verbose + mode---i.e., the plugin logger continues to delegate to the base + beets logger. + """ + @wraps(func) + def wrapper(*args, **kwargs): + if not beets.config['verbose']: + old_log_level = self._log.level + self._log.setLevel(log_level) + result = func(*args, **kwargs) + if not beets.config['verbose']: + self._log.setLevel(old_log_level) + return result + return wrapper + def queries(self): """Should return a dict mapping prefixes to Query subclasses. """ @@ -132,7 +185,8 @@ class BeetsPlugin(object): """ if cls.listeners is None: cls.listeners = defaultdict(list) - cls.listeners[event].append(func) + if func not in cls.listeners[event]: + cls.listeners[event].append(func) @classmethod def listen(cls, event): @@ -149,9 +203,7 @@ class BeetsPlugin(object): ... pass """ def helper(func): - if cls.listeners is None: - cls.listeners = defaultdict(list) - cls.listeners[event].append(func) + cls.register_listener(event, func) return func return helper @@ -204,7 +256,7 @@ def load_plugins(names=()): except ImportError as exc: # Again, this is hacky: if exc.args[0].endswith(' ' + name): - log.warn(u'** plugin {0} not found'.format(name)) + log.warn(u'** plugin {0} not found', name) else: raise else: @@ -214,7 +266,7 @@ def load_plugins(names=()): _classes.add(obj) except: - log.warn(u'** error loading plugin {0}'.format(name)) + log.warn(u'** error loading plugin {0}', name) log.warn(traceback.format_exc()) @@ -349,8 +401,7 @@ def import_stages(): """Get a list of import stage functions defined by plugins.""" stages = [] for plugin in find_plugins(): - if hasattr(plugin, 'import_stages'): - stages += plugin.import_stages + stages += plugin.get_import_stages() return stages @@ -398,7 +449,7 @@ def send(event, **arguments): Returns a list of return values from the handlers. """ - log.debug(u'Sending event: {0}'.format(event)) + log.debug(u'Sending event: {0}', event) return_values = [] for handler in event_handlers()[event]: # Don't break legacy plugins if we want to pass more arguments diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 8978ff547..291c768ec 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -23,7 +23,6 @@ import optparse import textwrap import sys from difflib import SequenceMatcher -import logging import sqlite3 import errno import re @@ -31,6 +30,7 @@ import struct import traceback import os.path +from beets import logging from beets import library from beets import plugins from beets import util @@ -866,14 +866,14 @@ def _configure(options): config_path = config.user_config_path() if os.path.isfile(config_path): - log.debug(u'user configuration: {0}'.format( - util.displayable_path(config_path))) + log.debug(u'user configuration: {0}', + util.displayable_path(config_path)) else: - log.debug(u'no user configuration found at {0}'.format( - util.displayable_path(config_path))) + log.debug(u'no user configuration found at {0}', + util.displayable_path(config_path)) - log.debug(u'data directory: {0}' - .format(util.displayable_path(config.config_dir()))) + log.debug(u'data directory: {0}', + util.displayable_path(config.config_dir())) return config @@ -895,9 +895,9 @@ def _open_library(config): util.displayable_path(dbpath) )) log.debug(u'library database: {0}\n' - u'library directory: {1}' - .format(util.displayable_path(lib.path), - util.displayable_path(lib.directory))) + u'library directory: {1}', + util.displayable_path(lib.path), + util.displayable_path(lib.directory)) return lib @@ -924,7 +924,8 @@ def _raw_main(args, lib=None): # Special case for the `config --edit` command: bypass _setup so # that an invalid configuration does not prevent the editor from # starting. - if subargs[0] == 'config' and ('-e' in subargs or '--edit' in subargs): + if subargs and subargs[0] == 'config' \ + and ('-e' in subargs or '--edit' in subargs): from beets.ui.commands import config_edit return config_edit() @@ -945,7 +946,7 @@ def main(args=None): _raw_main(args) except UserError as exc: message = exc.args[0] if exc.args else None - log.error(u'error: {0}'.format(message)) + log.error(u'error: {0}', message) sys.exit(1) except util.HumanReadableException as exc: exc.log(log) @@ -957,7 +958,7 @@ def main(args=None): log.error(exc) sys.exit(1) except confit.ConfigError as exc: - log.error(u'configuration error: {0}'.format(exc)) + log.error(u'configuration error: {0}', exc) sys.exit(1) except IOError as exc: if exc.errno == errno.EPIPE: diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 546fe87d9..2c0863b60 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -17,12 +17,10 @@ interface. """ from __future__ import print_function -import logging import os -import time -import codecs import platform import re +import shlex import beets from beets import ui @@ -37,6 +35,7 @@ from beets.util import syspath, normpath, ancestry, displayable_path from beets.util.functemplate import Template from beets import library from beets import config +from beets import logging from beets.util.confit import _package_path VARIOUS_ARTISTS = u'Various Artists' @@ -764,8 +763,8 @@ class TerminalImportSession(importer.ImportSession): """Decide what to do when a new album or item seems similar to one that's already in the library. """ - log.warn(u"This {0} is already in the library!" - .format("album" if task.is_album else "item")) + log.warn(u"This {0} is already in the library!", + ("album" if task.is_album else "item")) if config['import']['quiet']: # In quiet mode, don't prompt -- just skip. @@ -824,29 +823,22 @@ def import_files(lib, paths, query): # Open the log. if config['import']['log'].get() is not None: - logpath = config['import']['log'].as_filename() + logpath = syspath(config['import']['log'].as_filename()) try: - logfile = codecs.open(syspath(logpath), 'a', 'utf8') + loghandler = logging.FileHandler(logpath) except IOError: - raise ui.UserError(u"could not open log file for writing: %s" % - displayable_path(logpath)) - print(u'import started', time.asctime(), file=logfile) + raise ui.UserError(u"could not open log file for writing: " + u"{0}".format(displayable_path(loghandler))) else: - logfile = None + loghandler = None # Never ask for input in quiet mode. if config['import']['resume'].get() == 'ask' and \ config['import']['quiet']: config['import']['resume'] = False - session = TerminalImportSession(lib, logfile, paths, query) - try: - session.run() - finally: - # If we were logging, close the file. - if logfile: - print(u'', file=logfile) - logfile.close() + session = TerminalImportSession(lib, loghandler, paths, query) + session.run() # Emit event. plugins.send('import', lib=lib, paths=paths) @@ -1014,16 +1006,16 @@ def update_items(lib, query, album, move, pretend): # Did the item change since last checked? if item.current_mtime() <= item.mtime: - log.debug(u'skipping {0} because mtime is up to date ({1})' - .format(displayable_path(item.path), item.mtime)) + log.debug(u'skipping {0} because mtime is up to date ({1})', + displayable_path(item.path), item.mtime) continue # Read new data. try: item.read() except library.ReadError as exc: - log.error(u'error reading {0}: {1}'.format( - displayable_path(item.path), exc)) + log.error(u'error reading {0}: {1}', + displayable_path(item.path), exc) continue # Special-case album artist when it matches track artist. (Hacky @@ -1065,7 +1057,7 @@ def update_items(lib, query, album, move, pretend): continue album = lib.get_album(album_id) if not album: # Empty albums have already been removed. - log.debug(u'emptied album {0}'.format(album_id)) + log.debug(u'emptied album {0}', album_id) continue first_item = album.items().get() @@ -1076,7 +1068,7 @@ def update_items(lib, query, album, move, pretend): # Move album art (and any inconsistent items). if move and lib.directory in ancestry(first_item.path): - log.debug(u'moving album {0}'.format(album_id)) + log.debug(u'moving album {0}', album_id) album.move() @@ -1298,8 +1290,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm): if move: cur_path = obj.path if lib.directory in ancestry(cur_path): # In library? - log.debug(u'moving object {0}' - .format(displayable_path(cur_path))) + log.debug(u'moving object {0}', displayable_path(cur_path)) obj.move() obj.try_sync(write) @@ -1377,9 +1368,9 @@ def move_items(lib, dest, query, copy, album): action = 'Copying' if copy else 'Moving' entity = 'album' if album else 'item' - log.info(u'{0} {1} {2}s.'.format(action, len(objs), entity)) + log.info(u'{0} {1} {2}s.', action, len(objs), entity) for obj in objs: - log.debug(u'moving: {0}'.format(util.displayable_path(obj.path))) + log.debug(u'moving: {0}', util.displayable_path(obj.path)) obj.move(copy, basedir=dest) obj.store() @@ -1425,18 +1416,15 @@ def write_items(lib, query, pretend, force): for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): - log.info(u'missing file: {0}'.format( - util.displayable_path(item.path) - )) + log.info(u'missing file: {0}', util.displayable_path(item.path)) continue # Get an Item object reflecting the "clean" (on-disk) state. try: clean_item = library.Item.from_path(item.path) except library.ReadError as exc: - log.error(u'error reading {0}: {1}'.format( - displayable_path(item.path), exc - )) + log.error(u'error reading {0}: {1}', + displayable_path(item.path), exc) continue # Check for and display changes. @@ -1503,7 +1491,12 @@ def config_edit(): if 'EDITOR' in os.environ: editor = os.environ['EDITOR'] - args = [editor, editor, path] + try: + editor = shlex.split(editor) + except ValueError: # Malformed shell tokens. + editor = [editor] + args = editor + [path] + args.insert(1, args[0]) elif platform.system() == 'Darwin': args = ['open', 'open', '-n', path] elif platform.system() == 'Windows': @@ -1517,7 +1510,7 @@ def config_edit(): try: os.execlp(*args) except OSError: - raise ui.UserError("Could not edit configuration. Please" + raise ui.UserError("Could not edit configuration. Please " "set the EDITOR environment variable.") diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 38cecd703..f952a711a 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -544,10 +544,7 @@ def truncate_path(path, length=MAX_FILENAME_LENGTH): def str2bool(value): """Returns a boolean reflecting a human-entered string.""" - if value.lower() in ('yes', '1', 'true', 't', 'y'): - return True - else: - return False + return value.lower() in ('yes', '1', 'true', 't', 'y') def as_string(value): diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index f17fdc5b9..09092bbe8 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte +# Copyright 2015, Fabrice Laporte # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -20,7 +20,8 @@ import subprocess import os import re from tempfile import NamedTemporaryFile -import logging + +from beets import logging from beets import util # Resizing methods @@ -58,9 +59,8 @@ def pil_resize(maxwidth, path_in, path_out=None): """ path_out = path_out or temp_file_for(path_in) from PIL import Image - log.debug(u'artresizer: PIL resizing {0} to {1}'.format( - util.displayable_path(path_in), util.displayable_path(path_out) - )) + log.debug(u'artresizer: PIL resizing {0} to {1}', + util.displayable_path(path_in), util.displayable_path(path_out)) try: im = Image.open(util.syspath(path_in)) @@ -69,9 +69,8 @@ def pil_resize(maxwidth, path_in, path_out=None): im.save(path_out) return path_out except IOError: - log.error(u"PIL cannot create thumbnail for '{0}'".format( - util.displayable_path(path_in) - )) + log.error(u"PIL cannot create thumbnail for '{0}'", + util.displayable_path(path_in)) return path_in @@ -80,9 +79,8 @@ def im_resize(maxwidth, path_in, path_out=None): Return the output path of resized image. """ path_out = path_out or temp_file_for(path_in) - log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format( - util.displayable_path(path_in), util.displayable_path(path_out) - )) + log.debug(u'artresizer: ImageMagick resizing {0} to {1}', + util.displayable_path(path_in), util.displayable_path(path_out)) # "-resize widthxheight>" shrinks images with dimension(s) larger # than the corresponding width and/or height dimension(s). The > @@ -94,9 +92,8 @@ def im_resize(maxwidth, path_in, path_out=None): '-resize', '{0}x^>'.format(maxwidth), path_out ]) except subprocess.CalledProcessError: - log.warn(u'artresizer: IM convert failed for {0}'.format( - util.displayable_path(path_in) - )) + log.warn(u'artresizer: IM convert failed for {0}', + util.displayable_path(path_in)) return path_in return path_out @@ -134,7 +131,7 @@ class ArtResizer(object): specified, with an inferred method. """ self.method = self._check_method(method) - log.debug(u"artresizer: method is {0}".format(self.method)) + log.debug(u"artresizer: method is {0}", self.method) self.can_compare = self._can_compare() def resize(self, maxwidth, path_in, path_out=None): diff --git a/beets/util/confit.py b/beets/util/confit.py index de22e0adf..b157c35a2 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -1,5 +1,5 @@ # This file is part of Confit. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/enumeration.py b/beets/util/enumeration.py index e8cd0fe10..86e11874a 100644 --- a/beets/util/enumeration.py +++ b/beets/util/enumeration.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 03e57c618..6d236c8a2 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index d267789c8..9b4446d9f 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/vfs.py b/beets/vfs.py index e940e21fe..f0032d63b 100644 --- a/beets/vfs.py +++ b/beets/vfs.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/__init__.py b/beetsplug/__init__.py index 98a7ffd5c..337f84daa 100644 --- a/beetsplug/__init__.py +++ b/beetsplug/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index b83aef2f7..de71562a9 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,7 +14,6 @@ """Adds Beatport release and track search support to the autotagger """ -import logging import re from datetime import datetime, timedelta @@ -23,8 +22,6 @@ import requests from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin -log = logging.getLogger('beets') - class BeatportAPIError(Exception): pass @@ -194,7 +191,7 @@ class BeatportPlugin(BeetsPlugin): try: return self._get_releases(query) except BeatportAPIError as e: - log.debug(u'Beatport API Error: {0} (query: {1})'.format(e, query)) + self._log.debug(u'API Error: {0} (query: {1})', e, query) return [] def item_candidates(self, item, artist, title): @@ -205,14 +202,14 @@ class BeatportPlugin(BeetsPlugin): try: return self._get_tracks(query) except BeatportAPIError as e: - log.debug(u'Beatport API Error: {0} (query: {1})'.format(e, query)) + self._log.debug(u'API Error: {0} (query: {1})', e, query) return [] def album_for_id(self, release_id): """Fetches a release by its Beatport ID and returns an AlbumInfo object or None if the release is not found. """ - log.debug(u'Searching Beatport for release {0}'.format(release_id)) + self._log.debug(u'Searching for release {0}', release_id) match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id) if not match: return None @@ -224,7 +221,7 @@ class BeatportPlugin(BeetsPlugin): """Fetches a track by its Beatport ID and returns a TrackInfo object or None if the track is not found. """ - log.debug(u'Searching Beatport for track {0}'.format(str(track_id))) + self._log.debug(u'Searching for track {0}', track_id) match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id) if not match: return None diff --git a/beetsplug/bench.py b/beetsplug/bench.py index 56695e7c4..80c9a39ec 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 7b550487c..3380011f5 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -21,13 +21,13 @@ from __future__ import print_function import re from string import Template import traceback -import logging import random import time import beets from beets.plugins import BeetsPlugin import beets.ui +from beets import logging from beets import vfs from beets.util import bluelet from beets.library import Item @@ -1154,10 +1154,10 @@ class BPDPlugin(BeetsPlugin): def start_bpd(self, lib, host, port, password, volume, debug): """Starts a BPD server.""" - if debug: - log.setLevel(logging.DEBUG) + if debug: # FIXME this should be managed by BeetsPlugin + self._log.setLevel(logging.DEBUG) else: - log.setLevel(logging.WARNING) + self._log.setLevel(logging.WARNING) try: server = Server(lib, host, port, password) server.cmd_setvol(None, volume) diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 275f34b5c..2e7c05201 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/bpm.py b/beetsplug/bpm.py index d895ec5be..aa395719f 100644 --- a/beetsplug/bpm.py +++ b/beetsplug/bpm.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, aroquen +# Copyright 2015, aroquen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,13 +15,10 @@ """Determine BPM by pressing a key to the rhythm.""" import time -import logging from beets import ui from beets.plugins import BeetsPlugin -log = logging.getLogger('beets') - def bpm(max_strokes): """Returns average BPM (possibly of a playing song) @@ -73,15 +70,15 @@ class BPMPlugin(BeetsPlugin): item = items[0] if item['bpm']: - log.info(u'Found bpm {0}'.format(item['bpm'])) + self._log.info(u'Found bpm {0}', item['bpm']) if not overwrite: return - log.info(u'Press Enter {0} times to the rhythm or Ctrl-D ' - u'to exit'.format(self.config['max_strokes'].get(int))) + self._log.info(u'Press Enter {0} times to the rhythm or Ctrl-D ' + u'to exit', self.config['max_strokes'].get(int)) new_bpm = bpm(self.config['max_strokes'].get(int)) item['bpm'] = int(new_bpm) if write: item.try_write() item.store() - log.info(u'Added new bpm {0}'.format(item['bpm'])) + self._log.info(u'Added new bpm {0}', item['bpm']) diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index 64dcdf6ee..45ad163f9 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte. +# Copyright 2015, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -16,13 +16,11 @@ """ from datetime import datetime -import logging import re import string from itertools import tee, izip -from beets import plugins, ui -log = logging.getLogger('beets') +from beets import plugins, ui class BucketError(Exception): diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 106d6df76..5e17efe82 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -22,7 +22,6 @@ from beets import config from beets.util import confit from beets.autotag import hooks import acoustid -import logging from collections import defaultdict API_KEY = '1vOwZtEn' @@ -32,8 +31,6 @@ COMMON_REL_THRESH = 0.6 # How many tracks must have an album in common? MAX_RECORDINGS = 5 MAX_RELEASES = 5 -log = logging.getLogger('beets') - # Stores the Acoustid match information for each track. This is # populated when an import task begins and then used when searching for # candidates. It maps audio file paths to (recording_ids, release_ids) @@ -57,40 +54,40 @@ def prefix(it, count): yield v -def acoustid_match(path): +def acoustid_match(log, path): """Gets metadata for a file from Acoustid and populates the _matches, _fingerprints, and _acoustids dictionaries accordingly. """ try: duration, fp = acoustid.fingerprint_file(util.syspath(path)) except acoustid.FingerprintGenerationError as exc: - log.error(u'fingerprinting of {0} failed: {1}' - .format(util.displayable_path(repr(path)), str(exc))) + log.error(u'fingerprinting of {0} failed: {1}', + util.displayable_path(repr(path)), str(exc)) return None _fingerprints[path] = fp try: res = acoustid.lookup(API_KEY, fp, duration, meta='recordings releases') except acoustid.AcoustidError as exc: - log.debug(u'fingerprint matching {0} failed: {1}' - .format(util.displayable_path(repr(path)), str(exc))) + log.debug(u'fingerprint matching {0} failed: {1}', + util.displayable_path(repr(path)), exc) return None - log.debug(u'chroma: fingerprinted {0}' - .format(util.displayable_path(repr(path)))) + log.debug(u'chroma: fingerprinted {0}', + util.displayable_path(repr(path))) # Ensure the response is usable and parse it. if res['status'] != 'ok' or not res.get('results'): - log.debug(u'chroma: no match found') + log.debug(u'no match found') return None result = res['results'][0] # Best match. if result['score'] < SCORE_THRESH: - log.debug(u'chroma: no results above threshold') + log.debug(u'no results above threshold') return None _acoustids[path] = result['id'] # Get recording and releases from the result. if not result.get('recordings'): - log.debug(u'chroma: no recordings found') + log.debug(u'no recordings found') return None recording_ids = [] release_ids = [] @@ -99,9 +96,8 @@ def acoustid_match(path): if 'releases' in recording: release_ids += [rel['id'] for rel in recording['releases']] - log.debug(u'chroma: matched recordings {0} on releases {1}'.format( - recording_ids, release_ids, - )) + log.debug(u'matched recordings {0} on releases {1}', + recording_ids, release_ids) _matches[path] = recording_ids, release_ids @@ -136,7 +132,10 @@ class AcoustidPlugin(plugins.BeetsPlugin): }) if self.config['auto']: - self.register_listener('import_task_start', fingerprint_task) + self.register_listener('import_task_start', self.fingerprint_task) + + def fingerprint_task(self, task, session): + return fingerprint_task(self._log, task, session) def track_distance(self, item, info): dist = hooks.Distance() @@ -155,7 +154,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): if album: albums.append(album) - log.debug(u'acoustid album candidates: {0}'.format(len(albums))) + self._log.debug(u'acoustid album candidates: {0}', len(albums)) return albums def item_candidates(self, item, artist, title): @@ -168,7 +167,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): track = hooks.track_for_mbid(recording_id) if track: tracks.append(track) - log.debug(u'acoustid item candidates: {0}'.format(len(tracks))) + self._log.debug(u'acoustid item candidates: {0}', len(tracks)) return tracks def commands(self): @@ -180,7 +179,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): apikey = config['acoustid']['apikey'].get(unicode) except confit.NotFoundError: raise ui.UserError('no Acoustid user API key provided') - submit_items(apikey, lib.items(ui.decargs(args))) + submit_items(self._log, apikey, lib.items(ui.decargs(args))) submit_cmd.func = submit_cmd_func fingerprint_cmd = ui.Subcommand( @@ -190,7 +189,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): def fingerprint_cmd_func(lib, opts, args): for item in lib.items(ui.decargs(args)): - fingerprint_item(item, + fingerprint_item(self._log, item, write=config['import']['write'].get(bool)) fingerprint_cmd.func = fingerprint_cmd_func @@ -200,13 +199,13 @@ class AcoustidPlugin(plugins.BeetsPlugin): # Hooks into import process. -def fingerprint_task(task, session): +def fingerprint_task(log, task, session): """Fingerprint each item in the task for later use during the autotagging candidate search. """ items = task.items if task.is_album else [task.item] for item in items: - acoustid_match(item.path) + acoustid_match(log, item.path) @AcoustidPlugin.listen('import_task_apply') @@ -223,18 +222,18 @@ def apply_acoustid_metadata(task, session): # UI commands. -def submit_items(userkey, items, chunksize=64): +def submit_items(log, userkey, items, chunksize=64): """Submit fingerprints for the items to the Acoustid server. """ data = [] # The running list of dictionaries to submit. def submit_chunk(): """Submit the current accumulated fingerprint data.""" - log.info(u'submitting {0} fingerprints'.format(len(data))) + log.info(u'submitting {0} fingerprints', len(data)) try: acoustid.submit(API_KEY, userkey, data) except acoustid.AcoustidError as exc: - log.warn(u'acoustid submission error: {0}'.format(exc)) + log.warn(u'acoustid submission error: {0}', exc) del data[:] for item in items: @@ -270,7 +269,7 @@ def submit_items(userkey, items, chunksize=64): submit_chunk() -def fingerprint_item(item, write=False): +def fingerprint_item(log, item, write=False): """Get the fingerprint for an Item. If the item already has a fingerprint, it is not regenerated. If fingerprint generation fails, return None. If the items are associated with a library, they are @@ -279,34 +278,28 @@ def fingerprint_item(item, write=False): """ # Get a fingerprint and length for this track. if not item.length: - log.info(u'{0}: no duration available'.format( - util.displayable_path(item.path) - )) + log.info(u'{0}: no duration available', + util.displayable_path(item.path)) elif item.acoustid_fingerprint: if write: - log.info(u'{0}: fingerprint exists, skipping'.format( - util.displayable_path(item.path) - )) + log.info(u'{0}: fingerprint exists, skipping', + util.displayable_path(item.path)) else: - log.info(u'{0}: using existing fingerprint'.format( - util.displayable_path(item.path) - )) + log.info(u'{0}: using existing fingerprint', + util.displayable_path(item.path)) return item.acoustid_fingerprint else: - log.info(u'{0}: fingerprinting'.format( - util.displayable_path(item.path) - )) + log.info(u'{0}: fingerprinting', + util.displayable_path(item.path)) try: _, fp = acoustid.fingerprint_file(item.path) item.acoustid_fingerprint = fp if write: - log.info(u'{0}: writing fingerprint'.format( - util.displayable_path(item.path) - )) + log.info(u'{0}: writing fingerprint', + util.displayable_path(item.path)) item.try_write() if item._db: item.store() return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: - log.info(u'fingerprint generation failed: {0}' - .format(exc)) + log.info(u'fingerprint generation failed: {0}', exc) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 0b87fb71b..baf084423 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Jakob Schnitzer. +# Copyright 2015, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,7 +14,6 @@ """Converts tracks or albums to external directory """ -import logging import os import threading import subprocess @@ -24,10 +23,9 @@ from string import Template from beets import ui, util, plugins, config from beets.plugins import BeetsPlugin -from beetsplug.embedart import embed_item +from beetsplug.embedart import EmbedCoverArtPlugin from beets.util.confit import ConfigTypeError -log = logging.getLogger('beets') _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. @@ -83,55 +81,6 @@ def get_format(format=None): return (command.encode('utf8'), extension.encode('utf8')) -def encode(command, source, dest, pretend=False): - """Encode `source` to `dest` using command template `command`. - - Raises `subprocess.CalledProcessError` if the command exited with a - non-zero status code. - """ - quiet = config['convert']['quiet'].get() - - if not quiet and not pretend: - log.info(u'Encoding {0}'.format(util.displayable_path(source))) - - # Substitute $source and $dest in the argument list. - args = shlex.split(command) - for i, arg in enumerate(args): - args[i] = Template(arg).safe_substitute({ - 'source': source, - 'dest': dest, - }) - - if pretend: - log.info(' '.join(args)) - return - - try: - util.command_output(args) - except subprocess.CalledProcessError as exc: - # Something went wrong (probably Ctrl+C), remove temporary files - log.info(u'Encoding {0} failed. Cleaning up...' - .format(util.displayable_path(source))) - log.debug(u'Command {0} exited with status {1}'.format( - exc.cmd.decode('utf8', 'ignore'), - exc.returncode, - )) - util.remove(dest) - util.prune_dirs(os.path.dirname(dest)) - raise - except OSError as exc: - raise ui.UserError( - u"convert: could invoke '{0}': {1}".format( - ' '.join(args), exc - ) - ) - - if not quiet and not pretend: - log.info(u'Finished encoding {0}'.format( - util.displayable_path(source)) - ) - - def should_transcode(item, format): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). @@ -144,204 +93,6 @@ def should_transcode(item, format): item.bitrate >= 1000 * maxbr -def convert_item(dest_dir, keep_new, path_formats, format, pretend=False): - command, ext = get_format(format) - item, original, converted = None, None, None - while True: - item = yield (item, original, converted) - dest = item.destination(basedir=dest_dir, path_formats=path_formats) - - # When keeping the new file in the library, we first move the - # current (pristine) file to the destination. We'll then copy it - # back to its old path or transcode it to a new path. - if keep_new: - original = dest - converted = item.path - if should_transcode(item, format): - converted = replace_ext(converted, ext) - else: - original = item.path - if should_transcode(item, format): - dest = replace_ext(dest, ext) - converted = dest - - # Ensure that only one thread tries to create directories at a - # time. (The existence check is not atomic with the directory - # creation inside this function.) - if not pretend: - with _fs_lock: - util.mkdirall(dest) - - if os.path.exists(util.syspath(dest)): - log.info(u'Skipping {0} (target file exists)'.format( - util.displayable_path(item.path) - )) - continue - - if keep_new: - if pretend: - log.info(u'mv {0} {1}'.format( - util.displayable_path(item.path), - util.displayable_path(original), - )) - else: - log.info(u'Moving to {0}'.format( - util.displayable_path(original)) - ) - util.move(item.path, original) - - if should_transcode(item, format): - try: - encode(command, original, converted, pretend) - except subprocess.CalledProcessError: - continue - else: - if pretend: - log.info(u'cp {0} {1}'.format( - util.displayable_path(original), - util.displayable_path(converted), - )) - else: - # No transcoding necessary. - log.info(u'Copying {0}'.format( - util.displayable_path(item.path)) - ) - util.copy(original, converted) - - if pretend: - continue - - # Write tags from the database to the converted file. - item.try_write(path=converted) - - if keep_new: - # If we're keeping the transcoded file, read it again (after - # writing) to get new bitrate, duration, etc. - item.path = converted - item.read() - item.store() # Store new path and audio data. - - if config['convert']['embed']: - album = item.get_album() - if album and album.artpath: - embed_item(item, album.artpath, itempath=converted) - - if keep_new: - plugins.send('after_convert', item=item, - dest=dest, keepnew=True) - else: - plugins.send('after_convert', item=item, - dest=converted, keepnew=False) - - -def convert_on_import(lib, item): - """Transcode a file automatically after it is imported into the - library. - """ - format = config['convert']['format'].get(unicode).lower() - if should_transcode(item, format): - command, ext = get_format() - fd, dest = tempfile.mkstemp('.' + ext) - os.close(fd) - _temp_files.append(dest) # Delete the transcode later. - try: - encode(command, item.path, dest) - except subprocess.CalledProcessError: - return - item.path = dest - item.write() - item.read() # Load new audio information data. - item.store() - - -def copy_album_art(album, dest_dir, path_formats, pretend=False): - """Copies the associated cover art of the album. Album must have at least - one track. - """ - if not album or not album.artpath: - return - - album_item = album.items().get() - # Album shouldn't be empty. - if not album_item: - return - - # Get the destination of the first item (track) of the album, we use this - # function to format the path accordingly to path_formats. - dest = album_item.destination(basedir=dest_dir, path_formats=path_formats) - - # Remove item from the path. - dest = os.path.join(*util.components(dest)[:-1]) - - dest = album.art_destination(album.artpath, item_dir=dest) - if album.artpath == dest: - return - - if not pretend: - util.mkdirall(dest) - - if os.path.exists(util.syspath(dest)): - log.info(u'Skipping {0} (target file exists)'.format( - util.displayable_path(album.artpath) - )) - return - - if pretend: - log.info(u'cp {0} {1}'.format( - util.displayable_path(album.artpath), - util.displayable_path(dest), - )) - else: - log.info(u'Copying cover art to {0}'.format( - util.displayable_path(dest))) - util.copy(album.artpath, dest) - - -def convert_func(lib, opts, args): - if not opts.dest: - opts.dest = config['convert']['dest'].get() - if not opts.dest: - raise ui.UserError('no convert destination set') - opts.dest = util.bytestring_path(opts.dest) - - if not opts.threads: - opts.threads = config['convert']['threads'].get(int) - - if config['convert']['paths']: - path_formats = ui.get_path_formats(config['convert']['paths']) - else: - path_formats = ui.get_path_formats() - - if not opts.format: - opts.format = config['convert']['format'].get(unicode).lower() - - pretend = opts.pretend if opts.pretend is not None else \ - config['convert']['pretend'].get(bool) - - if not pretend: - ui.commands.list_items(lib, ui.decargs(args), opts.album, None) - - if not (opts.yes or ui.input_yn("Convert? (Y/n)")): - return - - if opts.album: - albums = lib.albums(ui.decargs(args)) - items = (i for a in albums for i in a.items()) - if config['convert']['copy_album_art']: - for album in albums: - copy_album_art(album, opts.dest, path_formats, pretend) - else: - items = iter(lib.items(ui.decargs(args))) - convert = [convert_item(opts.dest, - opts.keep_new, - path_formats, - opts.format, - pretend) - for _ in range(opts.threads)] - pipe = util.pipeline.Pipeline([items, convert]) - pipe.run_parallel() - - class ConvertPlugin(BeetsPlugin): def __init__(self): super(ConvertPlugin, self).__init__() @@ -379,6 +130,8 @@ class ConvertPlugin(BeetsPlugin): }) self.import_stages = [self.auto_convert] + self.register_listener('import_task_files', self._cleanup) + def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') cmd.parser.add_option('-p', '--pretend', action='store_true', @@ -397,19 +150,257 @@ class ConvertPlugin(BeetsPlugin): help='set the destination directory') cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes', help='do not ask for confirmation') - cmd.func = convert_func + cmd.func = self.convert_func return [cmd] def auto_convert(self, config, task): if self.config['auto']: for item in task.imported_items(): - convert_on_import(config.lib, item) + self.convert_on_import(config.lib, item) + # Utilities converted from functions to methods on logging overhaul -@ConvertPlugin.listen('import_task_files') -def _cleanup(task, session): - for path in task.old_paths: - if path in _temp_files: - if os.path.isfile(path): - util.remove(path) - _temp_files.remove(path) + def encode(self, command, source, dest, pretend=False): + """Encode `source` to `dest` using command template `command`. + + Raises `subprocess.CalledProcessError` if the command exited with a + non-zero status code. + """ + quiet = self.config['quiet'].get() + + if not quiet and not pretend: + self._log.info(u'Encoding {0}', util.displayable_path(source)) + + # Substitute $source and $dest in the argument list. + args = shlex.split(command) + for i, arg in enumerate(args): + args[i] = Template(arg).safe_substitute({ + 'source': source, + 'dest': dest, + }) + + if pretend: + self._log.info(' '.join(args)) + return + + try: + util.command_output(args) + except subprocess.CalledProcessError as exc: + # Something went wrong (probably Ctrl+C), remove temporary files + self._log.info(u'Encoding {0} failed. Cleaning up...', + util.displayable_path(source)) + self._log.debug(u'Command {0} exited with status {1}', + exc.cmd.decode('utf8', 'ignore'), + exc.returncode) + util.remove(dest) + util.prune_dirs(os.path.dirname(dest)) + raise + except OSError as exc: + raise ui.UserError( + u"convert: could invoke '{0}': {1}".format( + ' '.join(args), exc + ) + ) + + if not quiet and not pretend: + self._log.info(u'Finished encoding {0}', + util.displayable_path(source)) + + def convert_item(self, dest_dir, keep_new, path_formats, format, + pretend=False): + command, ext = get_format(format) + item, original, converted = None, None, None + while True: + item = yield (item, original, converted) + dest = item.destination(basedir=dest_dir, + path_formats=path_formats) + + # When keeping the new file in the library, we first move the + # current (pristine) file to the destination. We'll then copy it + # back to its old path or transcode it to a new path. + if keep_new: + original = dest + converted = item.path + if should_transcode(item, format): + converted = replace_ext(converted, ext) + else: + original = item.path + if should_transcode(item, format): + dest = replace_ext(dest, ext) + converted = dest + + # Ensure that only one thread tries to create directories at a + # time. (The existence check is not atomic with the directory + # creation inside this function.) + if not pretend: + with _fs_lock: + util.mkdirall(dest) + + if os.path.exists(util.syspath(dest)): + self._log.info(u'Skipping {0} (target file exists)', + util.displayable_path(item.path)) + continue + + if keep_new: + if pretend: + self._log.info(u'mv {0} {1}', + util.displayable_path(item.path), + util.displayable_path(original)) + else: + self._log.info(u'Moving to {0}', + util.displayable_path(original)) + util.move(item.path, original) + + if should_transcode(item, format): + try: + self.encode(command, original, converted, pretend) + except subprocess.CalledProcessError: + continue + else: + if pretend: + self._log.info(u'cp {0} {1}', + util.displayable_path(original), + util.displayable_path(converted)) + else: + # No transcoding necessary. + self._log.info(u'Copying {0}', + util.displayable_path(item.path)) + util.copy(original, converted) + + if pretend: + continue + + # Write tags from the database to the converted file. + item.try_write(path=converted) + + if keep_new: + # If we're keeping the transcoded file, read it again (after + # writing) to get new bitrate, duration, etc. + item.path = converted + item.read() + item.store() # Store new path and audio data. + + if self.config['embed']: + album = item.get_album() + if album and album.artpath: + EmbedCoverArtPlugin().embed_item(item, album.artpath, + itempath=converted) + + if keep_new: + plugins.send('after_convert', item=item, + dest=dest, keepnew=True) + else: + plugins.send('after_convert', item=item, + dest=converted, keepnew=False) + + def copy_album_art(self, album, dest_dir, path_formats, pretend=False): + """Copies the associated cover art of the album. Album must have at + least one track. + """ + if not album or not album.artpath: + return + + album_item = album.items().get() + # Album shouldn't be empty. + if not album_item: + return + + # Get the destination of the first item (track) of the album, we use + # this function to format the path accordingly to path_formats. + dest = album_item.destination(basedir=dest_dir, + path_formats=path_formats) + + # Remove item from the path. + dest = os.path.join(*util.components(dest)[:-1]) + + dest = album.art_destination(album.artpath, item_dir=dest) + if album.artpath == dest: + return + + if not pretend: + util.mkdirall(dest) + + if os.path.exists(util.syspath(dest)): + self._log.info(u'Skipping {0} (target file exists)', + util.displayable_path(album.artpath)) + return + + if pretend: + self._log.info(u'cp {0} {1}', + util.displayable_path(album.artpath), + util.displayable_path(dest)) + else: + self._log.info(u'Copying cover art to {0}', + util.displayable_path(dest)) + util.copy(album.artpath, dest) + + def convert_func(self, lib, opts, args): + if not opts.dest: + opts.dest = self.config['dest'].get() + if not opts.dest: + raise ui.UserError('no convert destination set') + opts.dest = util.bytestring_path(opts.dest) + + if not opts.threads: + opts.threads = self.config['threads'].get(int) + + if self.config['paths']: + path_formats = ui.get_path_formats(self.config['paths']) + else: + path_formats = ui.get_path_formats() + + if not opts.format: + opts.format = self.config['format'].get(unicode).lower() + + pretend = opts.pretend if opts.pretend is not None else \ + self.config['pretend'].get(bool) + + if not pretend: + ui.commands.list_items(lib, ui.decargs(args), opts.album, None) + + if not (opts.yes or ui.input_yn("Convert? (Y/n)")): + return + + if opts.album: + albums = lib.albums(ui.decargs(args)) + items = (i for a in albums for i in a.items()) + if self.config['copy_album_art']: + for album in albums: + self.copy_album_art(album, opts.dest, path_formats, + pretend) + else: + items = iter(lib.items(ui.decargs(args))) + convert = [self.convert_item(opts.dest, + opts.keep_new, + path_formats, + opts.format, + pretend) + for _ in range(opts.threads)] + pipe = util.pipeline.Pipeline([items, convert]) + pipe.run_parallel() + + def convert_on_import(self, lib, item): + """Transcode a file automatically after it is imported into the + library. + """ + format = self.config['format'].get(unicode).lower() + if should_transcode(item, format): + command, ext = get_format() + fd, dest = tempfile.mkstemp('.' + ext) + os.close(fd) + _temp_files.append(dest) # Delete the transcode later. + try: + self.encode(command, item.path, dest) + except subprocess.CalledProcessError: + return + item.path = dest + item.write() + item.read() # Load new audio information data. + item.store() + + def _cleanup(self, task, session): + for path in task.old_paths: + if path in _temp_files: + if os.path.isfile(path): + util.remove(path) + _temp_files.remove(path) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 234d9b6cd..0491bcb4c 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,6 +15,7 @@ """Adds Discogs album search support to the autotagger. Requires the discogs-client library. """ +from beets import logging from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin from beets.util import confit @@ -22,12 +23,10 @@ from discogs_client import Release, Client from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError import beets -import logging import re import time import json -log = logging.getLogger('beets') # Silence spurious INFO log lines generated by urllib3. urllib3_logger = logging.getLogger('requests.packages.urllib3') @@ -89,7 +88,7 @@ class DiscogsPlugin(BeetsPlugin): raise beets.ui.UserError('Discogs authorization failed') # Save the token for later use. - log.debug('Discogs token {0}, secret {1}'.format(token, secret)) + self._log.debug('Discogs token {0}, secret {1}', token, secret) with open(self._tokenfile(), 'w') as f: json.dump({'token': token, 'secret': secret}, f) @@ -117,10 +116,10 @@ class DiscogsPlugin(BeetsPlugin): try: return self.get_albums(query) except DiscogsAPIError as e: - log.debug(u'Discogs API Error: {0} (query: {1})'.format(e, query)) + self._log.debug(u'API Error: {0} (query: {1})', e, query) return [] except ConnectionError as e: - log.debug(u'HTTP Connection Error: {0}'.format(e)) + self._log.debug(u'HTTP Connection Error: {0}', e) return [] def album_for_id(self, album_id): @@ -130,7 +129,7 @@ class DiscogsPlugin(BeetsPlugin): if not self.discogs_client: return - log.debug(u'Searching Discogs for release {0}'.format(str(album_id))) + self._log.debug(u'Searching for release {0}', album_id) # Discogs-IDs are simple integers. We only look for those at the end # of an input string as to avoid confusion with other metadata plugins. # An optional bracket can follow the integer, as this is how discogs @@ -145,11 +144,10 @@ class DiscogsPlugin(BeetsPlugin): getattr(result, 'title') except DiscogsAPIError as e: if e.message != '404 Not Found': - log.debug(u'Discogs API Error: {0} (query: {1})' - .format(e, result._uri)) + self._log.debug(u'API Error: {0} (query: {1})', e, result._uri) return None except ConnectionError as e: - log.debug(u'HTTP Connection Error: {0}'.format(e)) + self._log.debug(u'HTTP Connection Error: {0}', e) return None return self.get_album_info(result) @@ -294,7 +292,7 @@ class DiscogsPlugin(BeetsPlugin): if match: medium, index = match.groups() else: - log.debug(u'Invalid Discogs position: {0}'.format(position)) + self._log.debug(u'Invalid position: {0}', position) medium = index = None return medium or None, index or None diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 8e0af4ab7..60a49d091 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Pedro Silva. +# Copyright 2015, Pedro Silva. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,14 +15,12 @@ """List duplicate tracks or albums. """ import shlex -import logging from beets.plugins import BeetsPlugin from beets.ui import decargs, print_obj, vararg_callback, Subcommand, UserError from beets.util import command_output, displayable_path, subprocess PLUGIN = 'duplicates' -log = logging.getLogger('beets') def _process_item(item, lib, copy=False, move=False, delete=False, @@ -47,7 +45,7 @@ def _process_item(item, lib, copy=False, move=False, delete=False, print_obj(item, lib, fmt=format) -def _checksum(item, prog): +def _checksum(item, prog, log): """Run external `prog` on file path associated with `item`, cache output as flexattr on a key that is the name of the program, and return the key, checksum tuple. @@ -56,24 +54,24 @@ def _checksum(item, prog): key = args[0] checksum = getattr(item, key, False) if not checksum: - log.debug(u'{0}: key {1} on item {2} not cached: computing checksum' - .format(PLUGIN, key, displayable_path(item.path))) + log.debug(u'{0}: key {1} on item {2} not cached: computing checksum', + PLUGIN, key, displayable_path(item.path)) try: checksum = command_output(args) setattr(item, key, checksum) item.store() - log.debug(u'{)}: computed checksum for {1} using {2}' - .format(PLUGIN, item.title, key)) + log.debug(u'{0}: computed checksum for {1} using {2}', + PLUGIN, item.title, key) except subprocess.CalledProcessError as e: - log.debug(u'{0}: failed to checksum {1}: {2}' - .format(PLUGIN, displayable_path(item.path), e)) + log.debug(u'{0}: failed to checksum {1}: {2}', + PLUGIN, displayable_path(item.path), e) else: - log.debug(u'{0}: key {1} on item {2} cached: not computing checksum' - .format(PLUGIN, key, displayable_path(item.path))) + log.debug(u'{0}: key {1} on item {2} cached: not computing checksum', + PLUGIN, key, displayable_path(item.path)) return key, checksum -def _group_by(objs, keys): +def _group_by(objs, keys, log): """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. """ @@ -86,17 +84,17 @@ def _group_by(objs, keys): key = '\001'.join(values) counts[key].append(obj) else: - log.debug(u'{0}: all keys {1} on item {2} are null: skipping' - .format(PLUGIN, str(keys), displayable_path(obj.path))) + log.debug(u'{0}: all keys {1} on item {2} are null: skipping', + PLUGIN, str(keys), displayable_path(obj.path)) return counts -def _duplicates(objs, keys, full): +def _duplicates(objs, keys, full, log): """Generate triples of keys, duplicate counts, and constituent objects. """ offset = 0 if full else 1 - for k, objs in _group_by(objs, keys).iteritems(): + for k, objs in _group_by(objs, keys, log).iteritems(): if len(objs) > 1: yield (k, len(objs) - offset, objs[offset:]) @@ -214,12 +212,13 @@ class DuplicatesPlugin(BeetsPlugin): 'duplicates: "checksum" option must be a command' ) for i in items: - k, _ = _checksum(i, checksum) + k, _ = self._checksum(i, checksum, self._log) keys = [k] for obj_id, obj_count, objs in _duplicates(items, keys=keys, - full=full): + full=full, + log=self._log): if obj_id: # Skip empty IDs. for o in objs: _process_item(o, lib, diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index db440a44a..a2b24bf20 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """Fetch a variety of acoustic metrics from The Echo Nest. """ import time -import logging import socket import os import tempfile @@ -28,8 +27,6 @@ import pyechonest import pyechonest.song import pyechonest.track -log = logging.getLogger('beets') - # If a request at the EchoNest fails, we want to retry the request RETRIES # times and wait between retries for RETRY_INTERVAL seconds. RETRIES = 10 @@ -137,7 +134,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): self.config.add(ATTRIBUTES) pyechonest.config.ECHO_NEST_API_KEY = \ - config['echonest']['apikey'].get(unicode) + self.config['apikey'].get(unicode) if self.config['auto']: self.import_stages = [self.imported] @@ -153,31 +150,30 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): except pyechonest.util.EchoNestAPIError as e: if e.code == 3: # reached access limit per minute - log.debug(u'echonest: rate-limited on try {0}; ' - u'waiting {1} seconds' - .format(i + 1, RETRY_INTERVAL)) + self._log.debug(u'rate-limited on try {0}; waiting {1} ' + u'seconds', i + 1, RETRY_INTERVAL) time.sleep(RETRY_INTERVAL) elif e.code == 5: # specified identifier does not exist # no use in trying again. - log.debug(u'echonest: {0}'.format(e)) + self._log.debug(u'{0}', e) return None else: - log.error(u'echonest: {0}'.format(e.args[0][0])) + self._log.error(u'{0}', e.args[0][0]) return None except (pyechonest.util.EchoNestIOError, socket.error) as e: - log.warn(u'echonest: IO error: {0}'.format(e)) + self._log.warn(u'IO error: {0}', e) time.sleep(RETRY_INTERVAL) except Exception as e: # there was an error analyzing the track, status: error - log.debug(u'echonest: {0}'.format(e)) + self._log.debug(u'{0}', e) return None else: break else: # If we exited the loop without breaking, then we used up all # our allotted retries. - log.error(u'echonest request failed repeatedly') + self._log.error(u'request failed repeatedly') return None return result @@ -188,7 +184,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): seconds, it's considered a match. """ if not songs: - log.debug(u'echonest: no songs found') + self._log.debug(u'no songs found') return pick = None @@ -226,13 +222,13 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): # Look up the Echo Nest ID based on the MBID. else: if not item.mb_trackid: - log.debug(u'echonest: no ID available') + self._log.debug(u'no ID available') return mbid = 'musicbrainz:track:{0}'.format(item.mb_trackid) track = self._echofun(pyechonest.track.track_from_id, identifier=mbid) if not track: - log.debug(u'echonest: lookup by MBID failed') + self._log.debug(u'lookup by MBID failed') return enid = track.song_id @@ -267,13 +263,13 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): source = item.path tmp = None if item.format not in ALLOWED_FORMATS: - if config['echonest']['convert']: + if self.config['convert']: tmp = source = self.convert(source) if not tmp: return if os.stat(source).st_size > UPLOAD_MAX_SIZE: - if config['echonest']['truncate']: + if self.config['truncate']: source = self.truncate(source) if tmp is not None: util.remove(tmp) @@ -292,10 +288,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): fd, dest = tempfile.mkstemp(u'.ogg') os.close(fd) - log.info(u'echonest: encoding {0} to {1}'.format( - util.displayable_path(source), - util.displayable_path(dest), - )) + self._log.info(u'encoding {0} to {1}', + util.displayable_path(source), + util.displayable_path(dest)) opts = [] for arg in CONVERT_COMMAND.split(): @@ -306,13 +301,11 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): try: util.command_output(opts) except (OSError, subprocess.CalledProcessError) as exc: - log.debug(u'echonest: encode failed: {0}'.format(exc)) + self._log.debug(u'encode failed: {0}', exc) util.remove(dest) return - log.info(u'echonest: finished encoding {0}'.format( - util.displayable_path(source)) - ) + self._log.info(u'finished encoding {0}', util.displayable_path(source)) return dest def truncate(self, source): @@ -320,10 +313,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): fd, dest = tempfile.mkstemp(u'.ogg') os.close(fd) - log.info(u'echonest: truncating {0} to {1}'.format( - util.displayable_path(source), - util.displayable_path(dest), - )) + self._log.info(u'truncating {0} to {1}', + util.displayable_path(source), + util.displayable_path(dest)) opts = [] for arg in TRUNCATE_COMMAND.split(): @@ -334,13 +326,11 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): try: util.command_output(opts) except (OSError, subprocess.CalledProcessError) as exc: - log.debug(u'echonest: truncate failed: {0}'.format(exc)) + self._log.debug(u'truncate failed: {0}', exc) util.remove(dest) return - log.info(u'echonest: truncate encoding {0}'.format( - util.displayable_path(source)) - ) + self._log.info(u'truncate encoding {0}', util.displayable_path(source)) return dest def analyze(self, item): @@ -349,18 +339,18 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): """ prepared = self.prepare_upload(item) if not prepared: - log.debug(u'echonest: could not prepare file for upload') + self._log.debug(u'could not prepare file for upload') return source, tmp = prepared - log.info(u'echonest: uploading file, please be patient') + self._log.info(u'uploading file, please be patient') track = self._echofun(pyechonest.track.track_from_filename, filename=source) if tmp is not None: util.remove(tmp) if not track: - log.debug(u'echonest: failed to upload file') + self._log.debug(u'failed to upload file') return # Sometimes we have a track but no song. I guess this happens for @@ -404,21 +394,19 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): # There are four different ways to get a song. Each method is a # callable that takes the Item as an argument. methods = [self.profile, self.search] - if config['echonest']['upload']: + if self.config['upload']: methods.append(self.analyze) # Try each method in turn. for method in methods: song = method(item) if song: - log.debug( - u'echonest: got song through {0}: {1} - {2} [{3}]'.format( - method.__name__, - item.artist, - item.title, - song.get('duration'), - ) - ) + self._log.debug(u'got song through {0}: {1} - {2} [{3}]', + method.__name__, + item.artist, + item.title, + song.get('duration'), + ) return song def apply_metadata(self, item, values, write=False): @@ -429,7 +417,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): for k, v in values.iteritems(): if k in ATTRIBUTES: field = ATTRIBUTES[k] - log.debug(u'echonest: metadata: {0} = {1}'.format(field, v)) + self._log.debug(u'metadata: {0} = {1}', field, v) if field == 'bpm': item[field] = int(v) else: @@ -441,7 +429,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): item['initial_key'] = key if 'id' in values: enid = values['id'] - log.debug(u'echonest: metadata: {0} = {1}'.format(ID_KEY, enid)) + self._log.debug(u'metadata: {0} = {1}', ID_KEY, enid) item[ID_KEY] = enid # Write and save. @@ -468,7 +456,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): for field in ATTRIBUTES.values(): if not item.get(field): return True - log.info(u'echonest: no update required') + self._log.info(u'no update required') return False def commands(self): @@ -483,8 +471,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): self.config.set_args(opts) write = config['import']['write'].get(bool) for item in lib.items(ui.decargs(args)): - log.info(u'echonest: {0} - {1}'.format(item.artist, - item.title)) + self._log.info(u'{0} - {1}', item.artist, item.title) if self.config['force'] or self.requires_update(item): song = self.fetch_song(item) if song: diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 49ed47928..fbed76c26 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,12 +14,12 @@ """Allows beets to embed album art into file metadata.""" import os.path -import logging import imghdr import subprocess import platform from tempfile import NamedTemporaryFile +from beets import logging from beets.plugins import BeetsPlugin from beets import mediafile from beets import ui @@ -29,9 +29,6 @@ from beets.util.artresizer import ArtResizer from beets import config -log = logging.getLogger('beets') - - class EmbedCoverArtPlugin(BeetsPlugin): """Allows albumart to be embedded into the actual files. """ @@ -46,13 +43,15 @@ class EmbedCoverArtPlugin(BeetsPlugin): if self.config['maxwidth'].get(int) and not ArtResizer.shared.local: self.config['maxwidth'] = 0 - log.warn(u"embedart: ImageMagick or PIL not found; " - u"'maxwidth' option ignored") + self._log.warn(u"ImageMagick or PIL not found; " + u"'maxwidth' option ignored") if self.config['compare_threshold'].get(int) and not \ ArtResizer.shared.can_compare: self.config['compare_threshold'] = 0 - log.warn(u"embedart: ImageMagick 6.8.7 or higher not installed; " - u"'compare_threshold' option ignored") + self._log.warn(u"ImageMagick 6.8.7 or higher not installed; " + u"'compare_threshold' option ignored") + + self.register_listener('album_imported', self.album_imported) def commands(self): # Embed command. @@ -62,19 +61,19 @@ class EmbedCoverArtPlugin(BeetsPlugin): embed_cmd.parser.add_option( '-f', '--file', metavar='PATH', help='the image file to embed' ) - maxwidth = config['embedart']['maxwidth'].get(int) - compare_threshold = config['embedart']['compare_threshold'].get(int) - ifempty = config['embedart']['ifempty'].get(bool) + maxwidth = self.config['maxwidth'].get(int) + compare_threshold = self.config['compare_threshold'].get(int) + ifempty = self.config['ifempty'].get(bool) def embed_func(lib, opts, args): if opts.file: imagepath = normpath(opts.file) for item in lib.items(decargs(args)): - embed_item(item, imagepath, maxwidth, None, - compare_threshold, ifempty) + self.embed_item(item, imagepath, maxwidth, None, + compare_threshold, ifempty) else: for album in lib.albums(decargs(args)): - embed_album(album, maxwidth) + self.embed_album(album, maxwidth) embed_cmd.func = embed_func @@ -87,7 +86,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): def extract_func(lib, opts, args): outpath = normpath(opts.outpath or 'cover') item = lib.items(decargs(args)).get() - extract(outpath, item) + self.extract(outpath, item) extract_cmd.func = extract_func # Clear command. @@ -95,186 +94,173 @@ class EmbedCoverArtPlugin(BeetsPlugin): help='remove images from file metadata') def clear_func(lib, opts, args): - clear(lib, decargs(args)) + self.clear(lib, decargs(args)) clear_cmd.func = clear_func return [embed_cmd, extract_cmd, clear_cmd] + def album_imported(self, lib, album): + """Automatically embed art into imported albums. + """ + if album.artpath and self.config['auto']: + max_width = self.config['maxwidth'].get(int) + self.embed_album(album, max_width, True) -@EmbedCoverArtPlugin.listen('album_imported') -def album_imported(lib, album): - """Automatically embed art into imported albums. - """ - if album.artpath and config['embedart']['auto']: - embed_album(album, config['embedart']['maxwidth'].get(int), True) - - -def embed_item(item, imagepath, maxwidth=None, itempath=None, - compare_threshold=0, ifempty=False, as_album=False): - """Embed an image into the item's media file. - """ - if compare_threshold: - if not check_art_similarity(item, imagepath, compare_threshold): - log.warn(u'Image not similar; skipping.') - return - if ifempty: - art = get_art(item) - if not art: - pass - else: - log.debug(u'embedart: media file contained art already {0}'.format( - displayable_path(imagepath) - )) - return - if maxwidth and not as_album: - imagepath = resize_image(imagepath, maxwidth) - - try: - log.debug(u'embedart: embedding {0}'.format( - displayable_path(imagepath) - )) - item['images'] = [_mediafile_image(imagepath, maxwidth)] - except IOError as exc: - log.error(u'embedart: could not read image file: {0}'.format(exc)) - else: - # We don't want to store the image in the database. - item.try_write(itempath) - del item['images'] - - -def embed_album(album, maxwidth=None, quiet=False): - """Embed album art into all of the album's items. - """ - imagepath = album.artpath - if not imagepath: - log.info(u'No album art present: {0} - {1}'. - format(album.albumartist, album.album)) - return - if not os.path.isfile(syspath(imagepath)): - log.error(u'Album art not found at {0}' - .format(displayable_path(imagepath))) - return - if maxwidth: - imagepath = resize_image(imagepath, maxwidth) - - log.log( - logging.DEBUG if quiet else logging.INFO, - u'Embedding album art into {0.albumartist} - {0.album}.'.format(album), - ) - - for item in album.items(): - embed_item(item, imagepath, maxwidth, None, - config['embedart']['compare_threshold'].get(int), - config['embedart']['ifempty'].get(bool), as_album=True) - - -def resize_image(imagepath, maxwidth): - """Returns path to an image resized to maxwidth. - """ - log.info(u'Resizing album art to {0} pixels wide' - .format(maxwidth)) - imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath)) - return imagepath - - -def check_art_similarity(item, imagepath, compare_threshold): - """A boolean indicating if an image is similar to embedded item art. - """ - with NamedTemporaryFile(delete=True) as f: - art = extract(f.name, item) - - if art: - # Converting images to grayscale tends to minimize the weight - # of colors in the diff score - cmd = 'convert {0} {1} -colorspace gray MIFF:- | ' \ - 'compare -metric PHASH - null:'.format(syspath(imagepath), - syspath(art)) - - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - close_fds=platform.system() != 'Windows', - shell=True) - stdout, stderr = proc.communicate() - if proc.returncode: - if proc.returncode != 1: - log.warn(u'embedart: IM phashes compare failed for {0}, \ - {1}'.format(displayable_path(imagepath), - displayable_path(art))) - return - phashDiff = float(stderr) + def embed_item(self, item, imagepath, maxwidth=None, itempath=None, + compare_threshold=0, ifempty=False, as_album=False): + """Embed an image into the item's media file. + """ + if compare_threshold: + if not self.check_art_similarity(item, imagepath, + compare_threshold): + self._log.warn(u'Image not similar; skipping.') + return + if ifempty: + art = self.get_art(item) + if not art: + pass else: - phashDiff = float(stdout) + self._log.debug(u'media file contained art already {0}', + displayable_path(imagepath)) + return + if maxwidth and not as_album: + imagepath = self.resize_image(imagepath, maxwidth) - log.info(u'embedart: compare PHASH score is {0}'.format(phashDiff)) - if phashDiff > compare_threshold: - return False - - return True - - -def _mediafile_image(image_path, maxwidth=None): - """Return a `mediafile.Image` object for the path. - """ - - with open(syspath(image_path), 'rb') as f: - data = f.read() - return mediafile.Image(data, type=mediafile.ImageType.front) - - -def get_art(item): - # Extract the art. - try: - mf = mediafile.MediaFile(syspath(item.path)) - except mediafile.UnreadableFileError as exc: - log.error(u'Could not extract art from {0}: {1}'.format( - displayable_path(item.path), exc - )) - return - - return mf.art - -# 'extractart' command. - - -def extract(outpath, item): - if not item: - log.error(u'No item matches query.') - return - - art = get_art(item) - - if not art: - log.error(u'No album art present in {0} - {1}.' - .format(item.artist, item.title)) - return - - # Add an extension to the filename. - ext = imghdr.what(None, h=art) - if not ext: - log.error(u'Unknown image type.') - return - outpath += '.' + ext - - log.info(u'Extracting album art from: {0.artist} - {0.title} ' - u'to: {1}'.format(item, displayable_path(outpath))) - with open(syspath(outpath), 'wb') as f: - f.write(art) - return outpath - - -# 'clearart' command. - -def clear(lib, query): - log.info(u'Clearing album art from items:') - for item in lib.items(query): - log.info(u'{0} - {1}'.format(item.artist, item.title)) try: - mf = mediafile.MediaFile(syspath(item.path), - config['id3v23'].get(bool)) + self._log.debug(u'embedding {0}', displayable_path(imagepath)) + item['images'] = [self._mediafile_image(imagepath, maxwidth)] + except IOError as exc: + self._log.error(u'could not read image file: {0}', exc) + else: + # We don't want to store the image in the database. + item.try_write(itempath) + del item['images'] + + def embed_album(self, album, maxwidth=None, quiet=False): + """Embed album art into all of the album's items. + """ + imagepath = album.artpath + if not imagepath: + self._log.info(u'No album art present: {0} - {1}', + album.albumartist, album.album) + return + if not os.path.isfile(syspath(imagepath)): + self._log.error(u'Album art not found at {0}', + displayable_path(imagepath)) + return + if maxwidth: + imagepath = self.resize_image(imagepath, maxwidth) + + self._log.log( + logging.DEBUG if quiet else logging.INFO, + u'Embedding album art into {0.albumartist} - {0.album}.', album + ) + + for item in album.items(): + thresh = self.config['compare_threshold'].get(int) + ifempty = self.config['ifempty'].get(bool) + self.embed_item(item, imagepath, maxwidth, None, + thresh, ifempty, as_album=True) + + def resize_image(self, imagepath, maxwidth): + """Returns path to an image resized to maxwidth. + """ + self._log.info(u'Resizing album art to {0} pixels wide', maxwidth) + imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath)) + return imagepath + + def check_art_similarity(self, item, imagepath, compare_threshold): + """A boolean indicating if an image is similar to embedded item art. + """ + with NamedTemporaryFile(delete=True) as f: + art = self.extract(f.name, item) + + if art: + # Converting images to grayscale tends to minimize the weight + # of colors in the diff score + cmd = 'convert {0} {1} -colorspace gray MIFF:- | ' \ + 'compare -metric PHASH - null:' \ + .format(syspath(imagepath), syspath(art)) + + is_windows = platform.system() != "Windows" + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + close_fds=is_windows, + shell=True) + stdout, stderr = proc.communicate() + if proc.returncode: + if proc.returncode != 1: + self._log.warn(u'IM phashes compare failed for {0}, ' + u'{1}', displayable_path(imagepath), + displayable_path(art)) + return + phashDiff = float(stderr) + else: + phashDiff = float(stdout) + + self._log.info(u'compare PHASH score is {0}', phashDiff) + if phashDiff > compare_threshold: + return False + + return True + + def _mediafile_image(self, image_path, maxwidth=None): + """Return a `mediafile.Image` object for the path. + """ + + with open(syspath(image_path), 'rb') as f: + data = f.read() + return mediafile.Image(data, type=mediafile.ImageType.front) + + def get_art(self, item): + # Extract the art. + try: + mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: - log.error(u'Could not clear art from {0}: {1}'.format( - displayable_path(item.path), exc - )) - continue - del mf.art - mf.save() + self._log.error(u'Could not extract art from {0}: {1}', + displayable_path(item.path), exc) + return + + return mf.art + + # 'extractart' command. + + def extract(self, outpath, item): + if not item: + self._log.error(u'No item matches query.') + return + + art = self.get_art(item) + + if not art: + self._log.error(u'No album art present in {0} - {1}.', + item.artist, item.title) + return + + # Add an extension to the filename. + ext = imghdr.what(None, h=art) + if not ext: + self._log.error(u'Unknown image type.') + return + outpath += '.' + ext + + self._log.info(u'Extracting album art from: {0.artist} - {0.title} ' + u'to: {1}', item, displayable_path(outpath)) + with open(syspath(outpath), 'wb') as f: + f.write(art) + return outpath + + # 'clearart' command. + def clear(self, lib, query): + self._log.info(u'Clearing album art from items:') + for item in lib.items(query): + self._log.info(u'{0} - {1}', item.artist, item.title) + try: + mf = mediafile.MediaFile(syspath(item.path), + config['id3v23'].get(bool)) + except mediafile.UnreadableFileError as exc: + self._log.error(u'Could not clear art from {0}: {1}', + displayable_path(item.path), exc) + continue + del mf.art + mf.save() diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index b2a4620b1..d86d942ee 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """Fetches album art. """ from contextlib import closing -import logging import os import re from tempfile import NamedTemporaryFile @@ -39,194 +38,167 @@ IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'] CONTENT_TYPES = ('image/jpeg',) DOWNLOAD_EXTENSION = '.jpg' -log = logging.getLogger('beets') - requests_session = requests.Session() requests_session.headers = {'User-Agent': 'beets'} -def _fetch_image(url): - """Downloads an image from a URL and checks whether it seems to - actually be an image. If so, returns a path to the downloaded image. - Otherwise, returns None. - """ - log.debug(u'fetchart: downloading art: {0}'.format(url)) - try: - with closing(requests_session.get(url, stream=True)) as resp: - if 'Content-Type' not in resp.headers \ - or resp.headers['Content-Type'] not in CONTENT_TYPES: - log.debug(u'fetchart: not an image') - return - - # Generate a temporary file with the correct extension. - with NamedTemporaryFile(suffix=DOWNLOAD_EXTENSION, delete=False) \ - as fh: - for chunk in resp.iter_content(): - fh.write(chunk) - log.debug(u'fetchart: downloaded art to: {0}'.format( - util.displayable_path(fh.name) - )) - return fh.name - except (IOError, requests.RequestException): - log.debug(u'fetchart: error fetching art') - - # ART SOURCES ################################################################ -# Cover Art Archive. +class ArtSource(object): + def __init__(self, log): + self._log = log -CAA_URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg' -CAA_GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front-500.jpg' + def get(self, album): + raise NotImplementedError() -def caa_art(album): - """Return the Cover Art Archive and Cover Art Archive release group URLs - using album MusicBrainz release ID and release group ID. - """ - if album.mb_albumid: - yield CAA_URL.format(mbid=album.mb_albumid) - if album.mb_releasegroupid: - yield CAA_GROUP_URL.format(mbid=album.mb_releasegroupid) +class CoverArtArchive(ArtSource): + """Cover Art Archive""" + URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg' + GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front-500.jpg' + + def get(self, album): + """Return the Cover Art Archive and Cover Art Archive release group URLs + using album MusicBrainz release ID and release group ID. + """ + if album.mb_albumid: + yield self.URL.format(mbid=album.mb_albumid) + if album.mb_releasegroupid: + yield self.GROUP_URL.format(mbid=album.mb_releasegroupid) -# Art from Amazon. +class Amazon(ArtSource): + URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' + INDICES = (1, 2) -AMAZON_URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' -AMAZON_INDICES = (1, 2) + def get(self, album): + """Generate URLs using Amazon ID (ASIN) string. + """ + if album.asin: + for index in self.INDICES: + yield self.URL % (album.asin, index) -def art_for_asin(album): - """Generate URLs using Amazon ID (ASIN) string. - """ - if album.asin: - for index in AMAZON_INDICES: - yield AMAZON_URL % (album.asin, index) +class AlbumArtOrg(ArtSource): + """AlbumArt.org scraper""" + URL = 'http://www.albumart.org/index_detail.php' + PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' - -# AlbumArt.org scraper. - -AAO_URL = 'http://www.albumart.org/index_detail.php' -AAO_PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' - - -def aao_art(album): - """Return art URL from AlbumArt.org using album ASIN. - """ - if not album.asin: - return - # Get the page from albumart.org. - try: - resp = requests_session.get(AAO_URL, params={'asin': album.asin}) - log.debug(u'fetchart: scraped art URL: {0}'.format(resp.url)) - except requests.RequestException: - log.debug(u'fetchart: error scraping art page') - return - - # Search the page for the image URL. - m = re.search(AAO_PAT, resp.text) - if m: - image_url = m.group(1) - yield image_url - else: - log.debug(u'fetchart: no image found on page') - - -# Google Images scraper. - -GOOGLE_URL = 'https://ajax.googleapis.com/ajax/services/search/images' - - -def google_art(album): - """Return art URL from google.org given an album title and - interpreter. - """ - if not (album.albumartist and album.album): - return - search_string = (album.albumartist + ',' + album.album).encode('utf-8') - response = requests_session.get(GOOGLE_URL, params={ - 'v': '1.0', - 'q': search_string, - 'start': '0', - }) - - # Get results using JSON. - try: - results = response.json() - data = results['responseData'] - dataInfo = data['results'] - for myUrl in dataInfo: - yield myUrl['unescapedUrl'] - except: - log.debug(u'fetchart: error scraping art page') - return - - -# Art from the iTunes Store. - -def itunes_art(album): - """Return art URL from iTunes Store given an album title. - """ - search_string = (album.albumartist + ' ' + album.album).encode('utf-8') - try: - # Isolate bugs in the iTunes library while searching. + def get(self, album): + """Return art URL from AlbumArt.org using album ASIN. + """ + if not album.asin: + return + # Get the page from albumart.org. try: - itunes_album = itunes.search_album(search_string)[0] - except Exception as exc: - log.debug('fetchart: iTunes search failed: {0}'.format(exc)) + resp = requests_session.get(self.URL, params={'asin': album.asin}) + self._log.debug(u'scraped art URL: {0}', resp.url) + except requests.RequestException: + self._log.debug(u'error scraping art page') return - if itunes_album.get_artwork()['100']: - small_url = itunes_album.get_artwork()['100'] - big_url = small_url.replace('100x100', '1200x1200') - yield big_url + # Search the page for the image URL. + m = re.search(self.PAT, resp.text) + if m: + image_url = m.group(1) + yield image_url else: - log.debug(u'fetchart: album has no artwork in iTunes Store') - except IndexError: - log.debug(u'fetchart: album not found in iTunes Store') + self._log.debug(u'no image found on page') -# Art from the filesystem. +class GoogleImages(ArtSource): + URL = 'https://ajax.googleapis.com/ajax/services/search/images' + + def get(self, album): + """Return art URL from google.org given an album title and + interpreter. + """ + if not (album.albumartist and album.album): + return + search_string = (album.albumartist + ',' + album.album).encode('utf-8') + response = requests_session.get(self.URL, params={ + 'v': '1.0', + 'q': search_string, + 'start': '0', + }) + + # Get results using JSON. + try: + results = response.json() + data = results['responseData'] + dataInfo = data['results'] + for myUrl in dataInfo: + yield myUrl['unescapedUrl'] + except: + self._log.debug(u'error scraping art page') + return -def filename_priority(filename, cover_names): - """Sort order for image names. +class ITunesStore(ArtSource): + # Art from the iTunes Store. + def get(self, album): + """Return art URL from iTunes Store given an album title. + """ + search_string = (album.albumartist + ' ' + album.album).encode('utf-8') + try: + # Isolate bugs in the iTunes library while searching. + try: + itunes_album = itunes.search_album(search_string)[0] + except Exception as exc: + self._log.debug('iTunes search failed: {0}', exc) + return - Return indexes of cover names found in the image filename. This - means that images with lower-numbered and more keywords will have higher - priority. - """ - return [idx for (idx, x) in enumerate(cover_names) if x in filename] + if itunes_album.get_artwork()['100']: + small_url = itunes_album.get_artwork()['100'] + big_url = small_url.replace('100x100', '1200x1200') + yield big_url + else: + self._log.debug(u'album has no artwork in iTunes Store') + except IndexError: + self._log.debug(u'album not found in iTunes Store') -def art_in_path(path, cover_names, cautious): - """Look for album art files in a specified directory. - """ - if not os.path.isdir(path): - return +class FileSystem(ArtSource): + """Art from the filesystem""" + @staticmethod + def filename_priority(filename, cover_names): + """Sort order for image names. - # Find all files that look like images in the directory. - images = [] - for fn in os.listdir(path): - for ext in IMAGE_EXTENSIONS: - if fn.lower().endswith('.' + ext): - images.append(fn) + Return indexes of cover names found in the image filename. This + means that images with lower-numbered and more keywords will have + higher priority. + """ + return [idx for (idx, x) in enumerate(cover_names) if x in filename] - # Look for "preferred" filenames. - images = sorted(images, key=lambda x: filename_priority(x, cover_names)) - cover_pat = r"(\b|_)({0})(\b|_)".format('|'.join(cover_names)) - for fn in images: - if re.search(cover_pat, os.path.splitext(fn)[0], re.I): - log.debug(u'fetchart: using well-named art file {0}'.format( - util.displayable_path(fn) - )) - return os.path.join(path, fn) + def get(self, path, cover_names, cautious): + """Look for album art files in a specified directory. + """ + if not os.path.isdir(path): + return - # Fall back to any image in the folder. - if images and not cautious: - log.debug(u'fetchart: using fallback art file {0}'.format( - util.displayable_path(images[0]) - )) - return os.path.join(path, images[0]) + # Find all files that look like images in the directory. + images = [] + for fn in os.listdir(path): + for ext in IMAGE_EXTENSIONS: + if fn.lower().endswith('.' + ext) and \ + os.path.isfile(os.path.join(path, fn)): + images.append(fn) + + # Look for "preferred" filenames. + images = sorted(images, + key=lambda x: self.filename_priority(x, cover_names)) + cover_pat = r"(\b|_)({0})(\b|_)".format('|'.join(cover_names)) + for fn in images: + if re.search(cover_pat, os.path.splitext(fn)[0], re.I): + self._log.debug(u'using well-named art file {0}', + util.displayable_path(fn)) + return os.path.join(path, fn) + + # Fall back to any image in the folder. + if images and not cautious: + self._log.debug(u'using fallback art file {0}', + util.displayable_path(images[0])) + return os.path.join(path, images[0]) # Try each source in turn. @@ -234,91 +206,16 @@ def art_in_path(path, cover_names, cautious): SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', u'google'] ART_FUNCS = { - u'coverart': caa_art, - u'itunes': itunes_art, - u'albumart': aao_art, - u'amazon': art_for_asin, - u'google': google_art, + u'coverart': CoverArtArchive, + u'itunes': ITunesStore, + u'albumart': AlbumArtOrg, + u'amazon': Amazon, + u'google': GoogleImages, } - -def _source_urls(album, sources=SOURCES_ALL): - """Generate possible source URLs for an album's art. The URLs are - not guaranteed to work so they each need to be attempted in turn. - This allows the main `art_for_album` function to abort iteration - through this sequence early to avoid the cost of scraping when not - necessary. - """ - for s in sources: - urls = ART_FUNCS[s](album) - for url in urls: - yield url - - -def art_for_album(album, paths, maxwidth=None, local_only=False): - """Given an Album object, returns a path to downloaded art for the - album (or None if no art is found). If `maxwidth`, then images are - resized to this maximum pixel size. If `local_only`, then only local - image files from the filesystem are returned; no network requests - are made. - """ - out = None - - # Local art. - cover_names = config['fetchart']['cover_names'].as_str_seq() - cover_names = map(util.bytestring_path, cover_names) - cautious = config['fetchart']['cautious'].get(bool) - if paths: - for path in paths: - out = art_in_path(path, cover_names, cautious) - if out: - break - - # Web art sources. - remote_priority = config['fetchart']['remote_priority'].get(bool) - if not local_only and (remote_priority or not out): - for url in _source_urls(album, - config['fetchart']['sources'].as_str_seq()): - if maxwidth: - url = ArtResizer.shared.proxy_url(maxwidth, url) - candidate = _fetch_image(url) - if candidate: - out = candidate - break - - if maxwidth and out: - out = ArtResizer.shared.resize(maxwidth, out) - return out - - # PLUGIN LOGIC ############################################################### -def batch_fetch_art(lib, albums, force, maxwidth=None): - """Fetch album art for each of the albums. This implements the manual - fetchart CLI command. - """ - for album in albums: - if album.artpath and not force: - message = 'has album art' - else: - # In ordinary invocations, look for images on the - # filesystem. When forcing, however, always go to the Web - # sources. - local_paths = None if force else [album.path] - - path = art_for_album(album, local_paths, maxwidth) - if path: - album.set_art(path, False) - album.store() - message = ui.colorize('green', 'found album art') - else: - message = ui.colorize('red', 'no art found') - - log.info(u'{0} - {1}: {2}'.format(album.albumartist, album.album, - message)) - - class FetchArtPlugin(plugins.BeetsPlugin): def __init__(self): super(FetchArtPlugin, self).__init__() @@ -346,8 +243,10 @@ class FetchArtPlugin(plugins.BeetsPlugin): available_sources = list(SOURCES_ALL) if not HAVE_ITUNES and u'itunes' in available_sources: available_sources.remove(u'itunes') - self.config['sources'] = plugins.sanitize_choices( + sources_name = plugins.sanitize_choices( self.config['sources'].as_str_seq(), available_sources) + self.sources = [ART_FUNCS[s](self._log) for s in sources_name] + self.fs_source = FileSystem(self._log) # Asynchronous; after music is added to the library. def fetch_art(self, session, task): @@ -363,7 +262,7 @@ class FetchArtPlugin(plugins.BeetsPlugin): # For any other choices (e.g., TRACKS), do nothing. return - path = art_for_album(task.album, task.paths, self.maxwidth, local) + path = self.art_for_album(task.album, task.paths, local) if path: self.art_paths[task] = path @@ -390,7 +289,102 @@ class FetchArtPlugin(plugins.BeetsPlugin): help='re-download art when already present') def func(lib, opts, args): - batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force, - self.maxwidth) + self.batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force) cmd.func = func return [cmd] + + # Utilities converted from functions to methods on logging overhaul + + def _fetch_image(self, url): + """Downloads an image from a URL and checks whether it seems to + actually be an image. If so, returns a path to the downloaded image. + Otherwise, returns None. + """ + self._log.debug(u'downloading art: {0}', url) + try: + with closing(requests_session.get(url, stream=True)) as resp: + if 'Content-Type' not in resp.headers \ + or resp.headers['Content-Type'] not in CONTENT_TYPES: + self._log.debug(u'not an image') + return + + # Generate a temporary file with the correct extension. + with NamedTemporaryFile(suffix=DOWNLOAD_EXTENSION, + delete=False) as fh: + for chunk in resp.iter_content(): + fh.write(chunk) + self._log.debug(u'downloaded art to: {0}', + util.displayable_path(fh.name)) + return fh.name + except (IOError, requests.RequestException): + self._log.debug(u'error fetching art') + + def art_for_album(self, album, paths, local_only=False): + """Given an Album object, returns a path to downloaded art for the + album (or None if no art is found). If `maxwidth`, then images are + resized to this maximum pixel size. If `local_only`, then only local + image files from the filesystem are returned; no network requests + are made. + """ + out = None + + # Local art. + cover_names = self.config['cover_names'].as_str_seq() + cover_names = map(util.bytestring_path, cover_names) + cautious = self.config['cautious'].get(bool) + if paths: + for path in paths: + # FIXME + out = self.fs_source.get(path, cover_names, cautious) + if out: + break + + # Web art sources. + remote_priority = self.config['remote_priority'].get(bool) + if not local_only and (remote_priority or not out): + for url in self._source_urls(album): + if self.maxwidth: + url = ArtResizer.shared.proxy_url(self.maxwidth, url) + candidate = self._fetch_image(url) + if candidate: + out = candidate + break + + if self.maxwidth and out: + out = ArtResizer.shared.resize(self.maxwidth, out) + return out + + def batch_fetch_art(self, lib, albums, force): + """Fetch album art for each of the albums. This implements the manual + fetchart CLI command. + """ + for album in albums: + if album.artpath and not force: + message = 'has album art' + else: + # In ordinary invocations, look for images on the + # filesystem. When forcing, however, always go to the Web + # sources. + local_paths = None if force else [album.path] + + path = self.art_for_album(album, local_paths) + if path: + album.set_art(path, False) + album.store() + message = ui.colorize('green', 'found album art') + else: + message = ui.colorize('red', 'no art found') + + self._log.info(u'{0.albumartist} - {0.album}: {1}', album, message) + + def _source_urls(self, album): + """Generate possible source URLs for an album's art. The URLs are + not guaranteed to work so they each need to be attempted in turn. + This allows the main `art_for_album` function to abort iteration + through this sequence early to avoid the cost of scraping when not + necessary. + """ + for source in self.sources: + urls = source.get(album) + for url in urls: + yield url diff --git a/beetsplug/freedesktop.py b/beetsplug/freedesktop.py index 0aea97c38..6411328ff 100644 --- a/beetsplug/freedesktop.py +++ b/beetsplug/freedesktop.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Matt Lichtenberg. +# Copyright 2015, Matt Lichtenberg. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -20,24 +20,6 @@ from beets.ui import Subcommand from beets.ui import decargs import os -import logging - -log = logging.getLogger('beets.freedesktop') - - -def process_query(lib, opts, args): - for album in lib.albums(decargs(args)): - process_album(album) - - -def process_album(album): - albumpath = album.item_dir() - if album.artpath: - fullartpath = album.artpath - artfile = os.path.split(fullartpath)[1] - create_file(albumpath, artfile) - else: - log.debug(u'freedesktop: album has no art') def create_file(albumpath, artfile): @@ -61,11 +43,24 @@ class FreedesktopPlugin(BeetsPlugin): def commands(self): freedesktop_command = Subcommand("freedesktop", help="Create .directory files") - freedesktop_command.func = process_query + freedesktop_command.func = self.process_query return [freedesktop_command] def imported(self, lib, album): automatic = self.config['auto'].get(bool) if not automatic: return - process_album(album) + self.process_album(album) + + def process_query(self, lib, opts, args): + for album in lib.albums(decargs(args)): + self.process_album(album) + + def process_album(self, album): + albumpath = album.item_dir() + if album.artpath: + fullartpath = album.artpath + artfile = os.path.split(fullartpath)[1] + create_file(albumpath, artfile) + else: + self._log.debug(u'album has no art') diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index f3884f556..4121c40e3 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Jan-Erik Dahlin +# Copyright 2015, Jan-Erik Dahlin # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index f28a1661c..839e4d16d 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Verrus, +# Copyright 2015, Verrus, # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,14 +14,12 @@ """Moves "featured" artists to the title from the artist field. """ +import re + from beets import plugins from beets import ui from beets.util import displayable_path from beets import config -import logging -import re - -log = logging.getLogger('beets') def split_on_feat(artist): @@ -45,70 +43,6 @@ def contains_feat(title): return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE)) -def update_metadata(item, feat_part, drop_feat, loglevel=logging.DEBUG): - """Choose how to add new artists to the title and set the new - metadata. Also, print out messages about any changes that are made. - If `drop_feat` is set, then do not add the artist to the title; just - remove it from the artist field. - """ - # In all cases, update the artist fields. - log.log(loglevel, u'artist: {0} -> {1}'.format( - item.artist, item.albumartist)) - item.artist = item.albumartist - if item.artist_sort: - # Just strip the featured artist from the sort name. - item.artist_sort, _ = split_on_feat(item.artist_sort) - - # Only update the title if it does not already contain a featured - # artist and if we do not drop featuring information. - if not drop_feat and not contains_feat(item.title): - new_title = u"{0} feat. {1}".format(item.title, feat_part) - log.log(loglevel, u'title: {0} -> {1}'.format(item.title, new_title)) - item.title = new_title - - -def ft_in_title(item, drop_feat, loglevel=logging.DEBUG): - """Look for featured artists in the item's artist fields and move - them to the title. - """ - artist = item.artist.strip() - albumartist = item.albumartist.strip() - - # Check whether there is a featured artist on this track and the - # artist field does not exactly match the album artist field. In - # that case, we attempt to move the featured artist to the title. - _, featured = split_on_feat(artist) - if featured and albumartist != artist and albumartist: - log.log(loglevel, displayable_path(item.path)) - feat_part = None - - # Look for the album artist in the artist field. If it's not - # present, give up. - albumartist_split = artist.split(albumartist, 1) - if len(albumartist_split) <= 1: - log.log(loglevel, 'album artist not present in artist') - - # If the last element of the split (the right-hand side of the - # album artist) is nonempty, then it probably contains the - # featured artist. - elif albumartist_split[-1] != '': - # Extract the featured artist from the right-hand side. - _, feat_part = split_on_feat(albumartist_split[-1]) - - # Otherwise, if there's nothing on the right-hand side, look for a - # featuring artist on the left-hand side. - else: - lhs, rhs = split_on_feat(albumartist_split[0]) - if rhs: - feat_part = lhs - - # If we have a featuring artist, move it to the title. - if feat_part: - update_metadata(item, feat_part, drop_feat, loglevel) - else: - log.log(loglevel, u'no featuring artists found') - - class FtInTitlePlugin(plugins.BeetsPlugin): def __init__(self): super(FtInTitlePlugin, self).__init__() @@ -138,7 +72,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): write = config['import']['write'].get(bool) for item in lib.items(ui.decargs(args)): - ft_in_title(item, drop_feat, logging.INFO) + self.ft_in_title(item, drop_feat) item.store() if write: item.try_write() @@ -152,5 +86,66 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat = self.config['drop'].get(bool) for item in task.imported_items(): - ft_in_title(item, drop_feat, logging.DEBUG) + self.ft_in_title(item, drop_feat) item.store() + + def update_metadata(self, item, feat_part, drop_feat): + """Choose how to add new artists to the title and set the new + metadata. Also, print out messages about any changes that are made. + If `drop_feat` is set, then do not add the artist to the title; just + remove it from the artist field. + """ + # In all cases, update the artist fields. + self._log.info(u'artist: {0} -> {1}', item.artist, item.albumartist) + item.artist = item.albumartist + if item.artist_sort: + # Just strip the featured artist from the sort name. + item.artist_sort, _ = split_on_feat(item.artist_sort) + + # Only update the title if it does not already contain a featured + # artist and if we do not drop featuring information. + if not drop_feat and not contains_feat(item.title): + new_title = u"{0} feat. {1}".format(item.title, feat_part) + self._log.info(u'title: {0} -> {1}', item.title, new_title) + item.title = new_title + + def ft_in_title(self, item, drop_feat): + """Look for featured artists in the item's artist fields and move + them to the title. + """ + artist = item.artist.strip() + albumartist = item.albumartist.strip() + + # Check whether there is a featured artist on this track and the + # artist field does not exactly match the album artist field. In + # that case, we attempt to move the featured artist to the title. + _, featured = split_on_feat(artist) + if featured and albumartist != artist and albumartist: + self._log.info(displayable_path(item.path)) + feat_part = None + + # Look for the album artist in the artist field. If it's not + # present, give up. + albumartist_split = artist.split(albumartist, 1) + if len(albumartist_split) <= 1: + self._log.info('album artist not present in artist') + + # If the last element of the split (the right-hand side of the + # album artist) is nonempty, then it probably contains the + # featured artist. + elif albumartist_split[-1] != '': + # Extract the featured artist from the right-hand side. + _, feat_part = split_on_feat(albumartist_split[-1]) + + # Otherwise, if there's nothing on the right-hand side, look for a + # featuring artist on the left-hand side. + else: + lhs, rhs = split_on_feat(albumartist_split[0]) + if rhs: + feat_part = lhs + + # If we have a featuring artist, move it to the title. + if feat_part: + self.update_metadata(item, feat_part, drop_feat) + else: + self._log.info(u'no featuring artists found') diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index c6fa36a26..789f862b8 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Philippe Mongeau. +# Copyright 2015, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -17,7 +17,6 @@ from beets.plugins import BeetsPlugin from beets.dbcore.query import StringFieldQuery -import beets import difflib @@ -28,7 +27,7 @@ class FuzzyQuery(StringFieldQuery): if pattern.islower(): val = val.lower() queryMatcher = difflib.SequenceMatcher(None, pattern, val) - threshold = beets.config['fuzzy']['threshold'].as_number() + threshold = self.config['threshold'].as_number() return queryMatcher.quick_ratio() >= threshold @@ -41,5 +40,5 @@ class FuzzyPlugin(BeetsPlugin): }) def queries(self): - prefix = beets.config['fuzzy']['prefix'].get(basestring) + prefix = self.config['prefix'].get(basestring) return {prefix: FuzzyQuery} diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py index 3fdcb26f7..7fdd661a0 100644 --- a/beetsplug/ihate.py +++ b/beetsplug/ihate.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Blemjhoo Tezoulbr . +# Copyright 2015, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,7 +14,6 @@ """Warns you about things you hate (or even blocks import).""" -import logging import re from beets import config from beets.plugins import BeetsPlugin @@ -39,8 +38,6 @@ def summary(task): class IHatePlugin(BeetsPlugin): - _log = logging.getLogger('beets') - def __init__(self): super(IHatePlugin, self).__init__() self.register_listener('import_task_choice', @@ -85,19 +82,17 @@ class IHatePlugin(BeetsPlugin): if task.choice_flag == action.APPLY: if skip_queries or warn_queries: - self._log.debug(u'[ihate] processing your hate') + self._log.debug(u'processing your hate') if self.do_i_hate_this(task, skip_queries): task.choice_flag = action.SKIP - self._log.info(u'[ihate] skipped: {0}' - .format(summary(task))) + self._log.info(u'skipped: {0}', summary(task)) return if self.do_i_hate_this(task, warn_queries): - self._log.info(u'[ihate] you maybe hate this: {0}' - .format(summary(task))) + self._log.info(u'you may hate this: {0}', summary(task)) else: - self._log.debug(u'[ihate] nothing to do') + self._log.debug(u'nothing to do') else: - self._log.debug(u'[ihate] user made a decision, nothing to do') + self._log.debug(u'user made a decision, nothing to do') def import_task_created_event(self, session, task): if task.items and len(task.items) > 0: diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index bf2de1300..b55d67171 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -6,15 +6,11 @@ Reimported albums and items are skipped. from __future__ import unicode_literals, absolute_import, print_function -import logging import os -from beets import config from beets import util from beets.plugins import BeetsPlugin -log = logging.getLogger('beets') - class ImportAddedPlugin(BeetsPlugin): def __init__(self): @@ -23,118 +19,104 @@ class ImportAddedPlugin(BeetsPlugin): 'preserve_mtimes': False, }) + # item.id for new items that were reimported + self.reimported_item_ids = None + # album.path for old albums that were replaced by a reimported album + self.replaced_album_paths = None + # item path in the library to the mtime of the source file + self.item_mtime = dict() -@ImportAddedPlugin.listen('import_task_start') -def check_config(task, session): - config['importadded']['preserve_mtimes'].get(bool) + register = self.register_listener + register('import_task_start', self.check_config) + register('import_task_start', self.record_if_inplace) + register('import_task_files', self.record_reimported) + register('before_item_moved', self.record_import_mtime) + register('item_copied', self.record_import_mtime) + register('item_linked', self.record_import_mtime) + register('album_imported', self.update_album_times) + register('item_imported', self.update_item_times) -# item.id for new items that were reimported -reimported_item_ids = None + def check_config(self, task, session): + self.config['preserve_mtimes'].get(bool) -# album.path for old albums that were replaced by a new reimported album -replaced_album_paths = None + def reimported_item(self, item): + return item.id in self.reimported_item_ids + def reimported_album(self, album): + return album.path in self.replaced_album_paths -def reimported_item(item): - return item.id in reimported_item_ids + def record_if_inplace(self, task, session): + if not (session.config['copy'] or session.config['move'] or + session.config['link']): + self._log.debug(u"In place import detected, recording mtimes from " + u"source paths") + for item in task.items: + self.record_import_mtime(item, item.path, item.path) + def record_reimported(self, task, session): + self.reimported_item_ids = set(item.id for item, replaced_items + in task.replaced_items.iteritems() + if replaced_items) + self.replaced_album_paths = set(task.replaced_albums.keys()) -def reimported_album(album): - return album.path in replaced_album_paths + def write_file_mtime(self, path, mtime): + """Write the given mtime to the destination path. + """ + stat = os.stat(util.syspath(path)) + os.utime(util.syspath(path), (stat.st_atime, mtime)) + def write_item_mtime(self, item, mtime): + """Write the given mtime to an item's `mtime` field and to the mtime + of the item's file. + """ + if mtime is None: + self._log.warn(u"No mtime to be preserved for item '{0}'", + util.displayable_path(item.path)) + return -@ImportAddedPlugin.listen('import_task_start') -def record_if_inplace(task, session): - if not (session.config['copy'] or session.config['move'] or - session.config['link']): - log.debug(u"In place import detected, recording mtimes from source" - u" paths") - for item in task.items: - record_import_mtime(item, item.path, item.path) + # The file's mtime on disk must be in sync with the item's mtime + self.write_file_mtime(util.syspath(item.path), mtime) + item.mtime = mtime + def record_import_mtime(self, item, source, destination): + """Record the file mtime of an item's path before its import. + """ + mtime = os.stat(util.syspath(source)).st_mtime + self.item_mtime[destination] = mtime + self._log.debug(u"Recorded mtime {0} for item '{1}' imported from " + u"'{2}'", mtime, util.displayable_path(destination), + util.displayable_path(source)) -@ImportAddedPlugin.listen('import_task_files') -def record_reimported(task, session): - global reimported_item_ids, replaced_album_paths - reimported_item_ids = set([item.id for item, replaced_items - in task.replaced_items.iteritems() - if replaced_items]) - replaced_album_paths = set(task.replaced_albums.keys()) + def update_album_times(self, lib, album): + if self.reimported_album(album): + self._log.debug(u"Album '{0}' is reimported, skipping import of " + u"added dates for the album and its items.", + util.displayable_path(album.path)) + return + album_mtimes = [] + for item in album.items(): + mtime = self.item_mtime.pop(item.path, None) + if mtime: + album_mtimes.append(mtime) + if self.config['preserve_mtimes'].get(bool): + self.write_item_mtime(item, mtime) + item.store() + album.added = min(album_mtimes) + self._log.debug(u"Import of album '{0}', selected album.added={1} " + u"from item file mtimes.", album.album, album.added) + album.store() -def write_file_mtime(path, mtime): - """Write the given mtime to the destination path. - """ - stat = os.stat(util.syspath(path)) - os.utime(util.syspath(path), - (stat.st_atime, mtime)) - - -def write_item_mtime(item, mtime): - """Write the given mtime to an item's `mtime` field and to the mtime of the - item's file. - """ - if mtime is None: - log.warn(u"No mtime to be preserved for item '{0}'" - .format(util.displayable_path(item.path))) - return - - # The file's mtime on disk must be in sync with the item's mtime - write_file_mtime(util.syspath(item.path), mtime) - item.mtime = mtime - - -# key: item path in the library -# value: the file mtime of the file the item was imported from -item_mtime = dict() - - -@ImportAddedPlugin.listen('before_item_moved') -@ImportAddedPlugin.listen('item_copied') -@ImportAddedPlugin.listen('item_linked') -def record_import_mtime(item, source, destination): - """Record the file mtime of an item's path before its import. - """ - mtime = os.stat(util.syspath(source)).st_mtime - item_mtime[destination] = mtime - log.debug(u"Recorded mtime {0} for item '{1}' imported from '{2}'".format( - mtime, util.displayable_path(destination), - util.displayable_path(source))) - - -@ImportAddedPlugin.listen('album_imported') -def update_album_times(lib, album): - if reimported_album(album): - log.debug(u"Album '{0}' is reimported, skipping import of added dates" - u" for the album and its items." - .format(util.displayable_path(album.path))) - return - - album_mtimes = [] - for item in album.items(): - mtime = item_mtime.pop(item.path, None) + def update_item_times(self, lib, item): + if self.reimported_item(item): + self._log.debug(u"Item '{0}' is reimported, skipping import of " + u"added date.", util.displayable_path(item.path)) + return + mtime = self.item_mtime.pop(item.path, None) if mtime: - album_mtimes.append(mtime) - if config['importadded']['preserve_mtimes'].get(bool): - write_item_mtime(item, mtime) - item.store() - album.added = min(album_mtimes) - log.debug(u"Import of album '{0}', selected album.added={1} from item" - u" file mtimes.".format(album.album, album.added)) - album.store() - - -@ImportAddedPlugin.listen('item_imported') -def update_item_times(lib, item): - if reimported_item(item): - log.debug(u"Item '{0}' is reimported, skipping import of added " - u"date.".format(util.displayable_path(item.path))) - return - mtime = item_mtime.pop(item.path, None) - if mtime: - item.added = mtime - if config['importadded']['preserve_mtimes'].get(bool): - write_item_mtime(item, mtime) - log.debug(u"Import of item '{0}', selected item.added={1}" - .format(util.displayable_path(item.path), item.added)) - item.store() + item.added = mtime + if self.config['preserve_mtimes'].get(bool): + self.write_item_mtime(item, mtime) + self._log.debug(u"Import of item '{0}', selected item.added={1}", + util.displayable_path(item.path), item.added) + item.store() diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index 0f1cd11c8..40d036832 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Fabrice Laporte. +# Copyright 2015, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -19,40 +19,12 @@ one wants to manually add music to a player by its path. import datetime import os import re -import logging from beets.plugins import BeetsPlugin from beets.util import normpath, syspath, bytestring_path from beets import config M3U_DEFAULT_NAME = 'imported.m3u' -log = logging.getLogger('beets') - - -class ImportFeedsPlugin(BeetsPlugin): - def __init__(self): - super(ImportFeedsPlugin, self).__init__() - - self.config.add({ - 'formats': [], - 'm3u_name': u'imported.m3u', - 'dir': None, - 'relative_to': None, - 'absolute_path': False, - }) - - feeds_dir = self.config['dir'].get() - if feeds_dir: - feeds_dir = os.path.expanduser(bytestring_path(feeds_dir)) - self.config['dir'] = feeds_dir - if not os.path.exists(syspath(feeds_dir)): - os.makedirs(syspath(feeds_dir)) - - relative_to = self.config['relative_to'].get() - if relative_to: - self.config['relative_to'] = normpath(relative_to) - else: - self.config['relative_to'] = feeds_dir def _get_feeds_dir(lib): @@ -90,62 +62,85 @@ def _write_m3u(m3u_path, items_paths): f.write(path + '\n') -def _record_items(lib, basename, items): - """Records relative paths to the given items for each feed format - """ - feedsdir = bytestring_path(config['importfeeds']['dir'].as_filename()) - formats = config['importfeeds']['formats'].as_str_seq() - relative_to = config['importfeeds']['relative_to'].get() \ - or config['importfeeds']['dir'].as_filename() - relative_to = bytestring_path(relative_to) +class ImportFeedsPlugin(BeetsPlugin): + def __init__(self): + super(ImportFeedsPlugin, self).__init__() - paths = [] - for item in items: - if config['importfeeds']['absolute_path']: - paths.append(item.path) + self.config.add({ + 'formats': [], + 'm3u_name': u'imported.m3u', + 'dir': None, + 'relative_to': None, + 'absolute_path': False, + }) + + feeds_dir = self.config['dir'].get() + if feeds_dir: + feeds_dir = os.path.expanduser(bytestring_path(feeds_dir)) + self.config['dir'] = feeds_dir + if not os.path.exists(syspath(feeds_dir)): + os.makedirs(syspath(feeds_dir)) + + relative_to = self.config['relative_to'].get() + if relative_to: + self.config['relative_to'] = normpath(relative_to) else: - try: - relpath = os.path.relpath(item.path, relative_to) - except ValueError: - # On Windows, it is sometimes not possible to construct a - # relative path (if the files are on different disks). - relpath = item.path - paths.append(relpath) + self.config['relative_to'] = feeds_dir - if 'm3u' in formats: - basename = bytestring_path( - config['importfeeds']['m3u_name'].get(unicode) - ) - m3u_path = os.path.join(feedsdir, basename) - _write_m3u(m3u_path, paths) + self.register_listener('library_opened', self.library_opened) + self.register_listener('album_imported', self.album_imported) + self.register_listener('item_imported', self.item_imported) - if 'm3u_multi' in formats: - m3u_path = _build_m3u_filename(basename) - _write_m3u(m3u_path, paths) + def _record_items(self, lib, basename, items): + """Records relative paths to the given items for each feed format + """ + feedsdir = bytestring_path(self.config['dir'].as_filename()) + formats = self.config['formats'].as_str_seq() + relative_to = self.config['relative_to'].get() \ + or self.config['dir'].as_filename() + relative_to = bytestring_path(relative_to) - if 'link' in formats: - for path in paths: - dest = os.path.join(feedsdir, os.path.basename(path)) - if not os.path.exists(syspath(dest)): - os.symlink(syspath(path), syspath(dest)) + paths = [] + for item in items: + if self.config['absolute_path']: + paths.append(item.path) + else: + try: + relpath = os.path.relpath(item.path, relative_to) + except ValueError: + # On Windows, it is sometimes not possible to construct a + # relative path (if the files are on different disks). + relpath = item.path + paths.append(relpath) - if 'echo' in formats: - log.info("Location of imported music:") - for path in paths: - log.info(" " + path) + if 'm3u' in formats: + basename = bytestring_path( + self.config['m3u_name'].get(unicode) + ) + m3u_path = os.path.join(feedsdir, basename) + _write_m3u(m3u_path, paths) + if 'm3u_multi' in formats: + m3u_path = _build_m3u_filename(basename) + _write_m3u(m3u_path, paths) -@ImportFeedsPlugin.listen('library_opened') -def library_opened(lib): - if config['importfeeds']['dir'].get() is None: - config['importfeeds']['dir'] = _get_feeds_dir(lib) + if 'link' in formats: + for path in paths: + dest = os.path.join(feedsdir, os.path.basename(path)) + if not os.path.exists(syspath(dest)): + os.symlink(syspath(path), syspath(dest)) + if 'echo' in formats: + self._log.info("Location of imported music:") + for path in paths: + self._log.info(" {0}", path) -@ImportFeedsPlugin.listen('album_imported') -def album_imported(lib, album): - _record_items(lib, album.album, album.items()) + def library_opened(self, lib): + if self.config['dir'].get() is None: + self.config['dir'] = _get_feeds_dir(lib) + def album_imported(self, lib, album): + self._record_items(lib, album.album, album.items()) -@ImportFeedsPlugin.listen('item_imported') -def item_imported(lib, item): - _record_items(lib, item.title, [item]) + def item_imported(self, lib, item): + self._record_items(lib, item.title, [item]) diff --git a/beetsplug/info.py b/beetsplug/info.py index 180f35747..27aeabf0f 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -16,7 +16,6 @@ """ import os -import logging from beets.plugins import BeetsPlugin from beets import ui @@ -24,49 +23,6 @@ from beets import mediafile from beets.util import displayable_path, normpath, syspath -log = logging.getLogger('beets') - - -def run(lib, opts, args): - """Print tag info or library data for each file referenced by args. - - Main entry point for the `beet info ARGS...` command. - - If an argument is a path pointing to an existing file, then the tags - of that file are printed. All other arguments are considered - queries, and for each item matching all those queries the tags from - the file are printed. - - If `opts.summarize` is true, the function merges all tags into one - dictionary and only prints that. If two files have different values - for the same tag, the value is set to '[various]' - """ - if opts.library: - data_collector = library_data - else: - data_collector = tag_data - - first = True - summary = {} - for data_emitter in data_collector(lib, ui.decargs(args)): - try: - data = data_emitter() - except mediafile.UnreadableFileError as ex: - log.error(u'cannot read file: {0}'.format(ex.message)) - continue - - if opts.summarize: - update_summary(summary, data) - else: - if not first: - ui.print_() - print_data(data) - first = False - - if opts.summarize: - print_data(summary) - - def tag_data(lib, args): query = [] for arg in args: @@ -143,9 +99,48 @@ class InfoPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('info', help='show file metadata') - cmd.func = run + cmd.func = self.run cmd.parser.add_option('-l', '--library', action='store_true', help='show library fields instead of tags') cmd.parser.add_option('-s', '--summarize', action='store_true', help='summarize the tags of all files') return [cmd] + + def run(self, lib, opts, args): + """Print tag info or library data for each file referenced by args. + + Main entry point for the `beet info ARGS...` command. + + If an argument is a path pointing to an existing file, then the tags + of that file are printed. All other arguments are considered + queries, and for each item matching all those queries the tags from + the file are printed. + + If `opts.summarize` is true, the function merges all tags into one + dictionary and only prints that. If two files have different values + for the same tag, the value is set to '[various]' + """ + if opts.library: + data_collector = library_data + else: + data_collector = tag_data + + first = True + summary = {} + for data_emitter in data_collector(lib, ui.decargs(args)): + try: + data = data_emitter() + except mediafile.UnreadableFileError as ex: + self._log.error(u'cannot read file: {0}', ex) + continue + + if opts.summarize: + update_summary(summary, data) + else: + if not first: + ui.print_() + print_data(data) + first = False + + if opts.summarize: + print_data(summary) diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 1ce4eb788..71a2e6e6b 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,15 +14,12 @@ """Allows inline path template customization code in the config file. """ -import logging import traceback import itertools from beets.plugins import BeetsPlugin from beets import config -log = logging.getLogger('beets') - FUNC_NAME = u'__INLINE_FUNC__' @@ -50,56 +47,6 @@ def _compile_func(body): return env[FUNC_NAME] -def compile_inline(python_code, album): - """Given a Python expression or function body, compile it as a path - field function. The returned function takes a single argument, an - Item, and returns a Unicode string. If the expression cannot be - compiled, then an error is logged and this function returns None. - """ - # First, try compiling as a single function. - try: - code = compile(u'({0})'.format(python_code), 'inline', 'eval') - except SyntaxError: - # Fall back to a function body. - try: - func = _compile_func(python_code) - except SyntaxError: - log.error(u'syntax error in inline field definition:\n{0}'.format( - traceback.format_exc() - )) - return - else: - is_expr = False - else: - is_expr = True - - def _dict_for(obj): - out = dict(obj) - if album: - out['items'] = list(obj.items()) - return out - - if is_expr: - # For expressions, just evaluate and return the result. - def _expr_func(obj): - values = _dict_for(obj) - try: - return eval(code, values) - except Exception as exc: - raise InlineError(python_code, exc) - return _expr_func - else: - # For function bodies, invoke the function with values as global - # variables. - def _func_func(obj): - func.__globals__.update(_dict_for(obj)) - try: - return func() - except Exception as exc: - raise InlineError(python_code, exc) - return _func_func - - class InlinePlugin(BeetsPlugin): def __init__(self): super(InlinePlugin, self).__init__() @@ -113,14 +60,62 @@ class InlinePlugin(BeetsPlugin): # Item fields. for key, view in itertools.chain(config['item_fields'].items(), config['pathfields'].items()): - log.debug(u'inline: adding item field {0}'.format(key)) - func = compile_inline(view.get(unicode), False) + self._log.debug(u'adding item field {0}', key) + func = self.compile_inline(view.get(unicode), False) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config['album_fields'].items(): - log.debug(u'inline: adding album field {0}'.format(key)) - func = compile_inline(view.get(unicode), True) + self._log.debug(u'adding album field {0}', key) + func = self.compile_inline(view.get(unicode), True) if func is not None: self.album_template_fields[key] = func + + def compile_inline(self, python_code, album): + """Given a Python expression or function body, compile it as a path + field function. The returned function takes a single argument, an + Item, and returns a Unicode string. If the expression cannot be + compiled, then an error is logged and this function returns None. + """ + # First, try compiling as a single function. + try: + code = compile(u'({0})'.format(python_code), 'inline', 'eval') + except SyntaxError: + # Fall back to a function body. + try: + func = _compile_func(python_code) + except SyntaxError: + self._log.error(u'syntax error in inline field definition:\n' + u'{0}', traceback.format_exc()) + return + else: + is_expr = False + else: + is_expr = True + + def _dict_for(obj): + out = dict(obj) + if album: + out['items'] = list(obj.items()) + return out + + if is_expr: + # For expressions, just evaluate and return the result. + def _expr_func(obj): + values = _dict_for(obj) + try: + return eval(code, values) + except Exception as exc: + raise InlineError(python_code, exc) + return _expr_func + else: + # For function bodies, invoke the function with values as global + # variables. + def _func_func(obj): + func.__globals__.update(_dict_for(obj)) + try: + return func() + except Exception as exc: + raise InlineError(python_code, exc) + return _func_func diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index 2abae381d..9ce81f36e 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """Uses the `KeyFinder` program to add the `initial_key` field. """ -import logging import subprocess from beets import ui @@ -23,9 +22,6 @@ from beets import util from beets.plugins import BeetsPlugin -log = logging.getLogger('beets') - - class KeyFinderPlugin(BeetsPlugin): def __init__(self): @@ -62,11 +58,11 @@ class KeyFinderPlugin(BeetsPlugin): try: key = util.command_output([bin, '-f', item.path]) except (subprocess.CalledProcessError, OSError) as exc: - log.error(u'KeyFinder execution failed: {0}'.format(exc)) + self._log.error(u'execution failed: {0}', exc) continue item['initial_key'] = key - log.debug(u'added computed initial key {0} for {1}' - .format(key, util.displayable_path(item.path))) + self._log.debug(u'added computed initial key {0} for {1}', + key, util.displayable_path(item.path)) item.try_write() item.store() diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 109aaae3c..54604ece6 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -20,7 +20,6 @@ and has been edited to remove some questionable entries. The scraper script used is available here: https://gist.github.com/1241307 """ -import logging import pylast import os import yaml @@ -31,7 +30,6 @@ from beets.util import normpath, plurality from beets import config from beets import library -log = logging.getLogger('beets') LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -53,40 +51,8 @@ def deduplicate(seq): return [x for x in seq if x not in seen and not seen.add(x)] -# Core genre identification routine. - -def _tags_for(obj, min_weight=None): - """Given a pylast entity (album or track), return a list of - tag names for that entity. Return an empty list if the entity is - not found or another error occurs. - - If `min_weight` is specified, tags are filtered by weight. - """ - try: - # Work around an inconsistency in pylast where - # Album.get_top_tags() does not return TopItem instances. - # https://code.google.com/p/pylast/issues/detail?id=85 - if isinstance(obj, pylast.Album): - res = super(pylast.Album, obj).get_top_tags() - else: - res = obj.get_top_tags() - except PYLAST_EXCEPTIONS as exc: - log.debug(u'last.fm error: {0}'.format(exc)) - return [] - - # Filter by weight (optionally). - if min_weight: - res = [el for el in res if (el.weight or 0) >= min_weight] - - # Get strings from tags. - res = [el.item.get_name().lower() for el in res] - - return res - - # Canonicalization tree processing. - def flatten_tree(elem, path, branches): """Flatten nested lists/dictionaries into lists of strings (branches). @@ -225,7 +191,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): can be found. Ex. 'Electronic, House, Dance' """ min_weight = self.config['min_weight'].get(int) - return self._resolve_genres(_tags_for(lastfm_obj, min_weight)) + return self._resolve_genres(self._tags_for(lastfm_obj, min_weight)) def _is_allowed(self, genre): """Determine whether the genre is present in the whitelist, @@ -371,9 +337,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): for album in lib.albums(ui.decargs(args)): album.genre, src = self._get_genre(album) - log.info(u'genre for album {0} - {1} ({2}): {3}'.format( - album.albumartist, album.album, src, album.genre - )) + self._log.info(u'genre for album {0.albumartist} - {0.album} ' + u'({1}): {0.genre}', album, src) album.store() for item in album.items(): @@ -382,9 +347,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): if 'track' in self.sources: item.genre, src = self._get_genre(item) item.store() - log.info(u'genre for track {0} - {1} ({2}): {3}' - .format(item.artist, item.title, src, - item.genre)) + self._log.info(u'genre for track {0.artist} - {0.tit' + u'le} ({1}): {0.genre}', item, src) if write: item.try_write() @@ -397,20 +361,50 @@ class LastGenrePlugin(plugins.BeetsPlugin): if task.is_album: album = task.album album.genre, src = self._get_genre(album) - log.debug(u'added last.fm album genre ({0}): {1}'.format( - src, album.genre)) + self._log.debug(u'added last.fm album genre ({0}): {1}', + src, album.genre) album.store() if 'track' in self.sources: for item in album.items(): item.genre, src = self._get_genre(item) - log.debug(u'added last.fm item genre ({0}): {1}'.format( - src, item.genre)) + self._log.debug(u'added last.fm item genre ({0}): {1}', + src, item.genre) item.store() else: item = task.item item.genre, src = self._get_genre(item) - log.debug(u'added last.fm item genre ({0}): {1}'.format( - src, item.genre)) + self._log.debug(u'added last.fm item genre ({0}): {1}', + src, item.genre) item.store() + + def _tags_for(self, obj, min_weight=None): + """Core genre identification routine. + + Given a pylast entity (album or track), return a list of + tag names for that entity. Return an empty list if the entity is + not found or another error occurs. + + If `min_weight` is specified, tags are filtered by weight. + """ + try: + # Work around an inconsistency in pylast where + # Album.get_top_tags() does not return TopItem instances. + # https://code.google.com/p/pylast/issues/detail?id=85 + if isinstance(obj, pylast.Album): + res = super(pylast.Album, obj).get_top_tags() + else: + res = obj.get_top_tags() + except PYLAST_EXCEPTIONS as exc: + self._log.debug(u'last.fm error: {0}', exc) + return [] + + # Filter by weight (optionally). + if min_weight: + res = [el for el in res if (el.weight or 0) >= min_weight] + + # Get strings from tags. + res = [el.item.get_name().lower() for el in res] + + return res diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml index 6f701f829..89675ce2f 100644 --- a/beetsplug/lastgenre/genres-tree.yaml +++ b/beetsplug/lastgenre/genres-tree.yaml @@ -159,7 +159,9 @@ - comedy: - comedy music - comedy rock + - humor - parody music + - stand-up - country: - alternative country: - cowpunk diff --git a/beetsplug/lastgenre/genres.txt b/beetsplug/lastgenre/genres.txt index ad344afec..d5e004af3 100644 --- a/beetsplug/lastgenre/genres.txt +++ b/beetsplug/lastgenre/genres.txt @@ -275,6 +275,7 @@ coimbra fado coladeira colombianas combined rhythm +comedy comedy rap comedy rock comic opera @@ -647,6 +648,7 @@ hua'er huasteco huayno hula +humor humppa hunguhungu hyangak @@ -1352,6 +1354,7 @@ sprechgesang square dance squee st. louis blues +stand-up steelband stoner metal stoner rock diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index 240aa90ca..5c3995d2a 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Rafael Bodill http://github.com/rafi +# Copyright 2015, Rafael Bodill http://github.com/rafi # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -12,7 +12,6 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -import logging import requests from beets import ui from beets import dbcore @@ -20,7 +19,6 @@ from beets import config from beets import plugins from beets.dbcore import types -log = logging.getLogger('beets') API_URL = 'http://ws.audioscrobbler.com/2.0/' @@ -43,20 +41,20 @@ class LastImportPlugin(plugins.BeetsPlugin): cmd = ui.Subcommand('lastimport', help='import last.fm play-count') def func(lib, opts, args): - import_lastfm(lib) + import_lastfm(lib, self._log) cmd.func = func return [cmd] -def import_lastfm(lib): +def import_lastfm(lib, log): user = config['lastfm']['user'] per_page = config['lastimport']['per_page'] if not user: raise ui.UserError('You must specify a user name for lastimport') - log.info('Fetching last.fm library for @{0}'.format(user)) + log.info('Fetching last.fm library for @{0}', user) page_total = 1 page_current = 0 @@ -65,10 +63,9 @@ def import_lastfm(lib): retry_limit = config['lastimport']['retry_limit'].get(int) # Iterate through a yet to be known page total count while page_current < page_total: - log.info('lastimport: Querying page #{0}{1}...'.format( - page_current + 1, - '/' + str(page_total) if page_total > 1 else '' - )) + log.info('Querying page #{0}{1}...', + page_current + 1, + '/{}'.format(page_total) if page_total > 1 else '') for retry in range(0, retry_limit): page = fetch_tracks(user, page_current + 1, per_page) @@ -79,32 +76,28 @@ def import_lastfm(lib): # It means nothing to us! raise ui.UserError('Last.fm reported no data.') - found, unknown = process_tracks(lib, page['tracks']['track']) + track = page['tracks']['track'] + found, unknown = process_tracks(lib, track, log) found_total += found unknown_total += unknown break else: - log.error('lastimport: ERROR: unable to read page #{0}'.format( - page_current + 1 - )) + log.error('ERROR: unable to read page #{0}', + page_current + 1) if retry < retry_limit: log.info( - 'lastimport: Retrying page #{0}... ({1}/{2} retry)' - .format(page_current + 1, retry + 1, retry_limit) + 'Retrying page #{0}... ({1}/{2} retry)', + page_current + 1, retry + 1, retry_limit ) else: - log.error( - 'lastimport: FAIL: unable to fetch page #{0}, ' - 'tried {1} times'.format(page_current, retry + 1) - ) + log.error('FAIL: unable to fetch page #{0}, ', + 'tried {1} times', page_current, retry + 1) page_current += 1 - log.info('lastimport: ... done!') - log.info('lastimport: finished processing {0} song pages'.format( - page_total - )) - log.info('lastimport: {0} unknown play-counts'.format(unknown_total)) - log.info('lastimport: {0} play-counts imported'.format(found_total)) + log.info('... done!') + log.info('finished processing {0} song pages', page_total) + log.info('{0} unknown play-counts', unknown_total) + log.info('{0} play-counts imported', found_total) def fetch_tracks(user, page, limit): @@ -118,14 +111,11 @@ def fetch_tracks(user, page, limit): }).json() -def process_tracks(lib, tracks): +def process_tracks(lib, tracks, log): total = len(tracks) total_found = 0 total_fails = 0 - log.info( - 'lastimport: Received {0} tracks in this page, processing...' - .format(total) - ) + log.info('Received {0} tracks in this page, processing...', total) for num in xrange(0, total): song = '' @@ -136,8 +126,7 @@ def process_tracks(lib, tracks): if 'album' in tracks[num]: album = tracks[num]['album'].get('name', '').strip() - log.debug(u'lastimport: query: {0} - {1} ({2})' - .format(artist, title, album)) + log.debug(u'query: {0} - {1} ({2})', artist, title, album) # First try to query by musicbrainz's trackid if trackid: @@ -147,8 +136,8 @@ def process_tracks(lib, tracks): # Otherwise try artist/title/album if not song: - log.debug(u'lastimport: no match for mb_trackid {0}, trying by ' - u'artist/title/album'.format(trackid)) + log.debug(u'no match for mb_trackid {0}, trying by ' + u'artist/title/album', trackid) query = dbcore.AndQuery([ dbcore.query.SubstringQuery('artist', artist), dbcore.query.SubstringQuery('title', title), @@ -158,7 +147,7 @@ def process_tracks(lib, tracks): # If not, try just artist/title if not song: - log.debug(u'lastimport: no album match, trying by artist/title') + log.debug(u'no album match, trying by artist/title') query = dbcore.AndQuery([ dbcore.query.SubstringQuery('artist', artist), dbcore.query.SubstringQuery('title', title) @@ -168,7 +157,7 @@ def process_tracks(lib, tracks): # Last resort, try just replacing to utf-8 quote if not song: title = title.replace("'", u'\u2019') - log.debug(u'lastimport: no title match, trying utf-8 single quote') + log.debug(u'no title match, trying utf-8 single quote') query = dbcore.AndQuery([ dbcore.query.SubstringQuery('artist', artist), dbcore.query.SubstringQuery('title', title) @@ -178,26 +167,19 @@ def process_tracks(lib, tracks): if song: count = int(song.get('play_count', 0)) new_count = int(tracks[num]['playcount']) - log.debug( - u'lastimport: match: {0} - {1} ({2}) ' - u'updating: play_count {3} => {4}'.format( - song.artist, song.title, song.album, count, new_count - ) - ) + log.debug(u'match: {0} - {1} ({2}) ' + u'updating: play_count {3} => {4}', + song.artist, song.title, song.album, count, new_count) song['play_count'] = new_count song.store() total_found += 1 else: total_fails += 1 - log.info( - u'lastimport: - No match: {0} - {1} ({2})' - .format(artist, title, album) - ) + log.info(u' - No match: {0} - {1} ({2})', + artist, title, album) if total_fails > 0: - log.info( - 'lastimport: Acquired {0}/{1} play-counts ({2} unknown)' - .format(total_found, total, total_fails) - ) + log.info('Acquired {0}/{1} play-counts ({2} unknown)', + total_found, total, total_fails) return total_found, total_fails diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index a2ebe7c36..625e8fff1 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -17,23 +17,19 @@ from __future__ import print_function import re -import logging import requests import json import unicodedata import urllib import difflib import itertools +import warnings from HTMLParser import HTMLParseError from beets import plugins from beets import config, ui -# Global logger. - -log = logging.getLogger('beets') - DIV_RE = re.compile(r'<(/?)div>?', re.I) COMMENT_RE = re.compile(r'', re.S) TAG_RE = re.compile(r'<[^>]*>') @@ -56,20 +52,6 @@ URL_CHARACTERS = { # Utilities. -def fetch_url(url): - """Retrieve the content at a given URL, or return None if the source - is unreachable. - """ - try: - r = requests.get(url, verify=False) - except requests.RequestException as exc: - log.debug(u'lyrics request failed: {0}'.format(exc)) - return - if r.status_code == requests.codes.ok: - return r.text - else: - log.debug(u'failed to fetch: {0} ({1})'.format(url, r.status_code)) - def unescape(text): """Resolves &#xxx; HTML entities (and some others).""" @@ -174,131 +156,116 @@ def search_pairs(item): return itertools.product(artists, multi_titles) -def _encode(s): - """Encode the string for inclusion in a URL (common to both - LyricsWiki and Lyrics.com). - """ - if isinstance(s, unicode): - for char, repl in URL_CHARACTERS.items(): - s = s.replace(char, repl) - s = s.encode('utf8', 'ignore') - return urllib.quote(s) +class Backend(object): + def __init__(self, log): + self._log = log -# Musixmatch + @staticmethod + def _encode(s): + """Encode the string for inclusion in a URL""" + if isinstance(s, unicode): + for char, repl in URL_CHARACTERS.items(): + s = s.replace(char, repl) + s = s.encode('utf8', 'ignore') + return urllib.quote(s) -MUSIXMATCH_URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s' + def build_url(self, artist, title): + return self.URL_PATTERN % (self._encode(artist.title()), + self._encode(title.title())) - -def fetch_musixmatch(artist, title): - url = MUSIXMATCH_URL_PATTERN % (_lw_encode(artist.title()), - _lw_encode(title.title())) - html = fetch_url(url) - if not html: - return - lyrics = extract_text_between(html, '"lyrics_body":', '"lyrics_language":') - return lyrics.strip(',"').replace('\\n', '\n') - -# LyricsWiki. - -LYRICSWIKI_URL_PATTERN = 'http://lyrics.wikia.com/%s:%s' - - -def _lw_encode(s): - s = re.sub(r'\s+', '_', s) - s = s.replace("<", "Less_Than") - s = s.replace(">", "Greater_Than") - s = s.replace("#", "Number_") - s = re.sub(r'[\[\{]', '(', s) - s = re.sub(r'[\]\}]', ')', s) - return _encode(s) - - -def fetch_lyricswiki(artist, title): - """Fetch lyrics from LyricsWiki.""" - url = LYRICSWIKI_URL_PATTERN % (_lw_encode(artist), _lw_encode(title)) - html = fetch_url(url) - if not html: - return - - lyrics = extract_text_in(html, u"
") - if lyrics and 'Unfortunately, we are not licensed' not in lyrics: - return lyrics - - -# Lyrics.com. - -LYRICSCOM_URL_PATTERN = 'http://www.lyrics.com/%s-lyrics-%s.html' -LYRICSCOM_NOT_FOUND = ( - 'Sorry, we do not have the lyric', - 'Submit Lyrics', -) - - -def _lc_encode(s): - s = re.sub(r'[^\w\s-]', '', s) - s = re.sub(r'\s+', '-', s) - return _encode(s).lower() - - -def fetch_lyricscom(artist, title): - """Fetch lyrics from Lyrics.com.""" - url = LYRICSCOM_URL_PATTERN % (_lc_encode(title), _lc_encode(artist)) - html = fetch_url(url) - if not html: - return - lyrics = extract_text_between(html, '
', '
') - if not lyrics: - return - for not_found_str in LYRICSCOM_NOT_FOUND: - if not_found_str in lyrics: + def fetch_url(self, url): + """Retrieve the content at a given URL, or return None if the source + is unreachable. + """ + try: + # Disable the InsecureRequestWarning that comes from using + # `verify=false`. + # https://github.com/kennethreitz/requests/issues/2214 + # We're not overly worried about the NSA MITMing our lyrics scraper + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + r = requests.get(url, verify=False) + except requests.RequestException as exc: + self._log.debug(u'lyrics request failed: {0}', exc) return + if r.status_code == requests.codes.ok: + return r.text + else: + self._log.debug(u'failed to fetch: {0} ({1})', url, r.status_code) - parts = lyrics.split('\n---\nLyrics powered by', 1) - if parts: - return parts[0] + def fetch(self, artist, title): + raise NotImplementedError() -# Optional Google custom search API backend. - -def slugify(text): - """Normalize a string and remove non-alphanumeric characters. - """ - text = re.sub(r"[-'_\s]", '_', text) - text = re.sub(r"_+", '_', text).strip('_') - pat = "([^,\(]*)\((.*?)\)" # Remove content within parentheses - text = re.sub(pat, '\g<1>', text).strip() - try: - text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore') - text = unicode(re.sub('[-\s]+', ' ', text)) - except UnicodeDecodeError: - log.exception(u"Failing to normalize '{0}'".format(text)) - return text +class SymbolsReplaced(Backend): + @classmethod + def _encode(cls, s): + s = re.sub(r'\s+', '_', s) + s = s.replace("<", "Less_Than") + s = s.replace(">", "Greater_Than") + s = s.replace("#", "Number_") + s = re.sub(r'[\[\{]', '(', s) + s = re.sub(r'[\]\}]', ')', s) + return super(SymbolsReplaced, cls)._encode(s) -BY_TRANS = ['by', 'par', 'de', 'von'] -LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte'] +class MusiXmatch(SymbolsReplaced): + URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s' + + def fetch(self, artist, title): + url = self.build_url(artist, title) + html = self.fetch_url(url) + if not html: + return + lyrics = extract_text_between(html, + '"lyrics_body":', '"lyrics_language":') + return lyrics.strip(',"').replace('\\n', '\n') -def is_page_candidate(urlLink, urlTitle, title, artist): - """Return True if the URL title makes it a good candidate to be a - page that contains lyrics of title by artist. - """ - title = slugify(title.lower()) - artist = slugify(artist.lower()) - sitename = re.search(u"//([^/]+)/.*", slugify(urlLink.lower())).group(1) - urlTitle = slugify(urlTitle.lower()) - # Check if URL title contains song title (exact match) - if urlTitle.find(title) != -1: - return True - # or try extracting song title from URL title and check if - # they are close enough - tokens = [by + '_' + artist for by in BY_TRANS] + \ - [artist, sitename, sitename.replace('www.', '')] + LYRICS_TRANS - songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle) - songTitle = songTitle.strip('_|') - typoRatio = .9 - return difflib.SequenceMatcher(None, songTitle, title).ratio() >= typoRatio +class LyricsWiki(SymbolsReplaced): + """Fetch lyrics from LyricsWiki.""" + URL_PATTERN = 'http://lyrics.wikia.com/%s:%s' + + def fetch(self, artist, title): + url = self.build_url(artist, title) + html = self.fetch_url(url) + if not html: + return + lyrics = extract_text_in(html, u"
") + if lyrics and 'Unfortunately, we are not licensed' not in lyrics: + return lyrics + + +class LyricsCom(Backend): + """Fetch lyrics from Lyrics.com.""" + URL_PATTERN = 'http://www.lyrics.com/%s-lyrics-%s.html' + NOT_FOUND = ( + 'Sorry, we do not have the lyric', + 'Submit Lyrics', + ) + + @classmethod + def _encode(cls, s): + s = re.sub(r'[^\w\s-]', '', s) + s = re.sub(r'\s+', '-', s) + return super(LyricsCom, cls)._encode(s).lower() + + def fetch(self, artist, title): + url = self.build_url(artist, title) + html = self.fetch_url(url) + if not html: + return + lyrics = extract_text_between(html, '
', '
') + if not lyrics: + return + for not_found_str in self.NOT_FOUND: + if not_found_str in lyrics: + return + + parts = lyrics.split('\n---\nLyrics powered by', 1) + if parts: + return parts[0] def remove_credits(text): @@ -315,36 +282,6 @@ def remove_credits(text): return text -def is_lyrics(text, artist=None): - """Determine whether the text seems to be valid lyrics. - """ - if not text: - return False - badTriggersOcc = [] - nbLines = text.count('\n') - if nbLines <= 1: - log.debug(u"Ignoring too short lyrics '{0}'".format(text)) - return False - elif nbLines < 5: - badTriggersOcc.append('too_short') - else: - # Lyrics look legit, remove credits to avoid being penalized further - # down - text = remove_credits(text) - - badTriggers = ['lyrics', 'copyright', 'property', 'links'] - if artist: - badTriggersOcc += [artist] - - for item in badTriggers: - badTriggersOcc += [item] * len(re.findall(r'\W%s\W' % item, - text, re.I)) - - if badTriggersOcc: - log.debug(u'Bad triggers detected: {0}'.format(badTriggersOcc)) - return len(badTriggersOcc) < 2 - - def _scrape_strip_cruft(html, plain_text_out=False): """Clean up HTML """ @@ -396,50 +333,119 @@ def scrape_lyrics_from_html(html): return soup -def fetch_google(artist, title): - """Fetch lyrics from Google search results. - """ - query = u"%s %s" % (artist, title) - api_key = config['lyrics']['google_API_key'].get(unicode) - engine_id = config['lyrics']['google_engine_ID'].get(unicode) - url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' % \ - (api_key, engine_id, urllib.quote(query.encode('utf8'))) +class Google(Backend): + """Fetch lyrics from Google search results.""" + def is_lyrics(self, text, artist=None): + """Determine whether the text seems to be valid lyrics. + """ + if not text: + return False + badTriggersOcc = [] + nbLines = text.count('\n') + if nbLines <= 1: + self._log.debug(u"Ignoring too short lyrics '{0}'", text) + return False + elif nbLines < 5: + badTriggersOcc.append('too_short') + else: + # Lyrics look legit, remove credits to avoid being penalized + # further down + text = remove_credits(text) - data = urllib.urlopen(url) - data = json.load(data) - if 'error' in data: - reason = data['error']['errors'][0]['reason'] - log.debug(u'google lyrics backend error: {0}'.format(reason)) - return + badTriggers = ['lyrics', 'copyright', 'property', 'links'] + if artist: + badTriggersOcc += [artist] - if 'items' in data.keys(): - for item in data['items']: - urlLink = item['link'] - urlTitle = item.get('title', u'') - if not is_page_candidate(urlLink, urlTitle, title, artist): - continue - html = fetch_url(urlLink) - lyrics = scrape_lyrics_from_html(html) - if not lyrics: - continue + for item in badTriggers: + badTriggersOcc += [item] * len(re.findall(r'\W%s\W' % item, + text, re.I)) - if is_lyrics(lyrics, artist): - log.debug(u'got lyrics from {0}'.format(item['displayLink'])) - return lyrics + if badTriggersOcc: + self._log.debug(u'Bad triggers detected: {0}', badTriggersOcc) + return len(badTriggersOcc) < 2 + def slugify(self, text): + """Normalize a string and remove non-alphanumeric characters. + """ + text = re.sub(r"[-'_\s]", '_', text) + text = re.sub(r"_+", '_', text).strip('_') + pat = "([^,\(]*)\((.*?)\)" # Remove content within parentheses + text = re.sub(pat, '\g<1>', text).strip() + try: + text = unicodedata.normalize('NFKD', text).encode('ascii', + 'ignore') + text = unicode(re.sub('[-\s]+', ' ', text)) + except UnicodeDecodeError: + self._log.exception(u"Failing to normalize '{0}'", text) + return text -# Plugin logic. + BY_TRANS = ['by', 'par', 'de', 'von'] + LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte'] -SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch'] -SOURCE_BACKENDS = { - 'google': fetch_google, - 'lyricwiki': fetch_lyricswiki, - 'lyrics.com': fetch_lyricscom, - 'musixmatch': fetch_musixmatch, -} + def is_page_candidate(self, urlLink, urlTitle, title, artist): + """Return True if the URL title makes it a good candidate to be a + page that contains lyrics of title by artist. + """ + title = self.slugify(title.lower()) + artist = self.slugify(artist.lower()) + sitename = re.search(u"//([^/]+)/.*", + self.slugify(urlLink.lower())).group(1) + urlTitle = self.slugify(urlTitle.lower()) + # Check if URL title contains song title (exact match) + if urlTitle.find(title) != -1: + return True + # or try extracting song title from URL title and check if + # they are close enough + tokens = [by + '_' + artist for by in self.BY_TRANS] + \ + [artist, sitename, sitename.replace('www.', '')] + \ + self.LYRICS_TRANS + songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle) + songTitle = songTitle.strip('_|') + typoRatio = .9 + ratio = difflib.SequenceMatcher(None, songTitle, title).ratio() + return ratio >= typoRatio + + def fetch(self, artist, title): + query = u"%s %s" % (artist, title) + api_key = self.config['google_API_key'].get(unicode) + engine_id = self.config['google_engine_ID'].get(unicode) + url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' % \ + (api_key, engine_id, urllib.quote(query.encode('utf8'))) + + data = urllib.urlopen(url) + data = json.load(data) + if 'error' in data: + reason = data['error']['errors'][0]['reason'] + self._log.debug(u'google lyrics backend error: {0}', reason) + return + + if 'items' in data.keys(): + for item in data['items']: + urlLink = item['link'] + urlTitle = item.get('title', u'') + if not self.is_page_candidate(urlLink, urlTitle, + title, artist): + continue + html = self.fetch_url(urlLink) + lyrics = scrape_lyrics_from_html(html) + if not lyrics: + continue + + if self.is_lyrics(lyrics, artist): + self._log.debug(u'got lyrics from {0}', + item['displayLink']) + return lyrics class LyricsPlugin(plugins.BeetsPlugin): + SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch'] + SOURCE_BACKENDS = { + 'google': Google, + 'lyricwiki': LyricsWiki, + 'lyrics.com': LyricsCom, + 'musixmatch': MusiXmatch, + } + def __init__(self): super(LyricsPlugin, self).__init__() self.import_stages = [self.imported] @@ -449,18 +455,18 @@ class LyricsPlugin(plugins.BeetsPlugin): 'google_engine_ID': u'009217259823014548361:lndtuqkycfu', 'fallback': None, 'force': False, - 'sources': SOURCES, + 'sources': self.SOURCES, }) - available_sources = list(SOURCES) + available_sources = list(self.SOURCES) if not self.config['google_API_key'].get() and \ - 'google' in SOURCES: + 'google' in self.SOURCES: available_sources.remove('google') self.config['sources'] = plugins.sanitize_choices( self.config['sources'].as_str_seq(), available_sources) self.backends = [] for key in self.config['sources'].as_str_seq(): - self.backends.append(SOURCE_BACKENDS[key]) + self.backends.append(self.SOURCE_BACKENDS[key](self._log)) def commands(self): cmd = ui.Subcommand('lyrics', help='fetch song lyrics') @@ -477,7 +483,7 @@ class LyricsPlugin(plugins.BeetsPlugin): write = config['import']['write'].get(bool) for item in lib.items(ui.decargs(args)): self.fetch_item_lyrics( - lib, logging.INFO, item, write, + lib, item, write, opts.force_refetch or self.config['force'], ) if opts.printlyr and item.lyrics: @@ -491,19 +497,16 @@ class LyricsPlugin(plugins.BeetsPlugin): """ if self.config['auto']: for item in task.imported_items(): - self.fetch_item_lyrics(session.lib, logging.DEBUG, item, + self.fetch_item_lyrics(session.lib, item, False, self.config['force']) - def fetch_item_lyrics(self, lib, loglevel, item, write, force): + def fetch_item_lyrics(self, lib, item, write, force): """Fetch and store lyrics for a single item. If ``write``, then the - lyrics will also be written to the file itself. The ``loglevel`` - parameter controls the visibility of the function's status log - messages. - """ + lyrics will also be written to the file itself.""" # Skip if the item already has lyrics. if not force and item.lyrics: - log.log(loglevel, u'lyrics already present: {0} - {1}' - .format(item.artist, item.title)) + self._log.info(u'lyrics already present: {0.artist} - {0.title}', + item) return lyrics = None @@ -515,11 +518,9 @@ class LyricsPlugin(plugins.BeetsPlugin): lyrics = u"\n\n---\n\n".join([l for l in lyrics if l]) if lyrics: - log.log(loglevel, u'fetched lyrics: {0} - {1}' - .format(item.artist, item.title)) + self._log.info(u'fetched lyrics: {0.artist} - {0.title}', item) else: - log.log(loglevel, u'lyrics not found: {0} - {1}' - .format(item.artist, item.title)) + self._log.info(u'lyrics not found: {0.artist} - {0.title}', item) fallback = self.config['fallback'].get() if fallback: lyrics = fallback @@ -537,8 +538,8 @@ class LyricsPlugin(plugins.BeetsPlugin): None if no lyrics were found. """ for backend in self.backends: - lyrics = backend(artist, title) + lyrics = backend.fetch(artist, title) if lyrics: - log.debug(u'got lyrics from backend: {0}' - .format(backend.__name__)) + self._log.debug(u'got lyrics from backend: {0}', + backend.__class__.__name__) return _scrape_strip_cruft(lyrics, True) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 610408232..b7cac8024 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -12,8 +12,6 @@ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -from __future__ import print_function - from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets import ui @@ -21,13 +19,10 @@ from beets import config import musicbrainzngs import re -import logging SUBMISSION_CHUNK_SIZE = 200 UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$' -log = logging.getLogger('beets.bpd') - def mb_call(func, *args, **kwargs): """Call a MusicBrainz API function and catch exceptions. @@ -54,48 +49,6 @@ def submit_albums(collection_id, release_ids): ) -def update_album_list(album_list): - """Update the MusicBrainz colleciton from a list of Beets albums - """ - # Get the available collections. - collections = mb_call(musicbrainzngs.get_collections) - if not collections['collection-list']: - raise ui.UserError('no collections exist for user') - - # Get the first release collection. MusicBrainz also has event - # collections, so we need to avoid adding to those. - for collection in collections['collection-list']: - if 'release-count' in collection: - collection_id = collection['id'] - break - else: - raise ui.UserError('No collection found.') - - # Get a list of all the album IDs. - album_ids = [] - for album in album_list: - aid = album.mb_albumid - if aid: - if re.match(UUID_REGEX, aid): - album_ids.append(aid) - else: - log.info(u'skipping invalid MBID: {0}'.format(aid)) - - # Submit to MusicBrainz. - print('Updating MusicBrainz collection {0}...'.format(collection_id)) - submit_albums(collection_id, album_ids) - print('...MusicBrainz collection updated.') - - -def update_collection(lib, opts, args): - update_album_list(lib.albums()) - - -update_mb_collection_cmd = Subcommand('mbupdate', - help='Update MusicBrainz collection') -update_mb_collection_cmd.func = update_collection - - class MusicBrainzCollectionPlugin(BeetsPlugin): def __init__(self): super(MusicBrainzCollectionPlugin, self).__init__() @@ -108,10 +61,47 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): self.import_stages = [self.imported] def commands(self): - return [update_mb_collection_cmd] + mbupdate = Subcommand('mbupdate', help='Update MusicBrainz collection') + mbupdate.func = self.update_collection + return [mbupdate] + + def update_collection(self, lib, opts, args): + self.update_album_list(lib.albums()) def imported(self, session, task): """Add each imported album to the collection. """ if task.is_album: - update_album_list([task.album]) + self.update_album_list([task.album]) + + def update_album_list(self, album_list): + """Update the MusicBrainz colleciton from a list of Beets albums + """ + # Get the available collections. + collections = mb_call(musicbrainzngs.get_collections) + if not collections['collection-list']: + raise ui.UserError('no collections exist for user') + + # Get the first release collection. MusicBrainz also has event + # collections, so we need to avoid adding to those. + for collection in collections['collection-list']: + if 'release-count' in collection: + collection_id = collection['id'] + break + else: + raise ui.UserError('No collection found.') + + # Get a list of all the album IDs. + album_ids = [] + for album in album_list: + aid = album.mb_albumid + if aid: + if re.match(UUID_REGEX, aid): + album_ids.append(aid) + else: + self._log.info(u'skipping invalid MBID: {0}', aid) + + # Submit to MusicBrainz. + self._log.info('Updating MusicBrainz collection {0}...', collection_id) + submit_albums(collection_id, album_ids) + self._log.info('...MusicBrainz collection updated.') diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 989caeb99..b1a74da28 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Jakob Schnitzer. +# Copyright 2015, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,104 +14,12 @@ """Update library's tags using MusicBrainz. """ -import logging - from beets.plugins import BeetsPlugin from beets import autotag, library, ui, util from beets.autotag import hooks from beets import config from collections import defaultdict -log = logging.getLogger('beets') - - -def mbsync_singletons(lib, query, move, pretend, write): - """Retrieve and apply info from the autotagger for items matched by - query. - """ - for item in lib.items(query + ['singleton:true']): - if not item.mb_trackid: - log.info(u'Skipping singleton {0}: has no mb_trackid' - .format(item.title)) - continue - - # Get the MusicBrainz recording info. - track_info = hooks.track_for_mbid(item.mb_trackid) - if not track_info: - log.info(u'Recording ID not found: {0}'.format(item.mb_trackid)) - continue - - # Apply. - with lib.transaction(): - autotag.apply_item_metadata(item, track_info) - apply_item_changes(lib, item, move, pretend, write) - - -def mbsync_albums(lib, query, move, pretend, write): - """Retrieve and apply info from the autotagger for albums matched by - query and their items. - """ - # Process matching albums. - for a in lib.albums(query): - if not a.mb_albumid: - log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) - continue - - items = list(a.items()) - - # Get the MusicBrainz album information. - album_info = hooks.album_for_mbid(a.mb_albumid) - if not album_info: - log.info(u'Release ID not found: {0}'.format(a.mb_albumid)) - continue - - # Map recording MBIDs to their information. Recordings can appear - # multiple times on a release, so each MBID maps to a list of TrackInfo - # objects. - track_index = defaultdict(list) - for track_info in album_info.tracks: - track_index[track_info.track_id].append(track_info) - - # Construct a track mapping according to MBIDs. This should work - # for albums that have missing or extra tracks. If there are multiple - # copies of a recording, they are disambiguated using their disc and - # track number. - mapping = {} - for item in items: - candidates = track_index[item.mb_trackid] - if len(candidates) == 1: - mapping[item] = candidates[0] - else: - for c in candidates: - if c.medium_index == item.track and c.medium == item.disc: - mapping[item] = c - break - - # Apply. - with lib.transaction(): - autotag.apply_metadata(album_info, mapping) - changed = False - for item in items: - item_changed = ui.show_model_changes(item) - changed |= item_changed - if item_changed: - apply_item_changes(lib, item, move, pretend, write) - - if not changed: - # No change to any item. - continue - - if not pretend: - # Update album structure to reflect an item in it. - for key in library.Album.item_keys: - a[key] = items[0][key] - a.store() - - # Move album art (and any inconsistent items). - if move and lib.directory in util.ancestry(items[0].path): - log.debug(u'moving album {0}'.format(a.id)) - a.move() - def apply_item_changes(lib, item, move, pretend, write): """Store, move and write the item according to the arguments. @@ -126,18 +34,6 @@ def apply_item_changes(lib, item, move, pretend, write): item.store() -def mbsync_func(lib, opts, args): - """Command handler for the mbsync function. - """ - move = opts.move - pretend = opts.pretend - write = opts.write - query = ui.decargs(args) - - mbsync_singletons(lib, query, move, pretend, write) - mbsync_albums(lib, query, move, pretend, write) - - class MBSyncPlugin(BeetsPlugin): def __init__(self): super(MBSyncPlugin, self).__init__() @@ -153,5 +49,103 @@ class MBSyncPlugin(BeetsPlugin): cmd.parser.add_option('-W', '--nowrite', action='store_false', default=config['import']['write'], dest='write', help="don't write updated metadata to files") - cmd.func = mbsync_func + cmd.func = self.func return [cmd] + + def func(self, lib, opts, args): + """Command handler for the mbsync function. + """ + move = opts.move + pretend = opts.pretend + write = opts.write + query = ui.decargs(args) + + self.singletons(lib, query, move, pretend, write) + self.albums(lib, query, move, pretend, write) + + def singletons(self, lib, query, move, pretend, write): + """Retrieve and apply info from the autotagger for items matched by + query. + """ + for item in lib.items(query + ['singleton:true']): + if not item.mb_trackid: + self._log.info(u'Skipping singleton {0}: has no mb_trackid', + item.title) + continue + + # Get the MusicBrainz recording info. + track_info = hooks.track_for_mbid(item.mb_trackid) + if not track_info: + self._log.info(u'Recording ID not found: {0}', item.mb_trackid) + continue + + # Apply. + with lib.transaction(): + autotag.apply_item_metadata(item, track_info) + apply_item_changes(lib, item, move, pretend, write) + + def albums(self, lib, query, move, pretend, write): + """Retrieve and apply info from the autotagger for albums matched by + query and their items. + """ + # Process matching albums. + for a in lib.albums(query): + if not a.mb_albumid: + self._log.info(u'Skipping album {0}: has no mb_albumid', a.id) + continue + + items = list(a.items()) + + # Get the MusicBrainz album information. + album_info = hooks.album_for_mbid(a.mb_albumid) + if not album_info: + self._log.info(u'Release ID not found: {0}', a.mb_albumid) + continue + + # Map recording MBIDs to their information. Recordings can appear + # multiple times on a release, so each MBID maps to a list of + # TrackInfo objects. + track_index = defaultdict(list) + for track_info in album_info.tracks: + track_index[track_info.track_id].append(track_info) + + # Construct a track mapping according to MBIDs. This should work + # for albums that have missing or extra tracks. If there are + # multiple copies of a recording, they are disambiguated using + # their disc and track number. + mapping = {} + for item in items: + candidates = track_index[item.mb_trackid] + if len(candidates) == 1: + mapping[item] = candidates[0] + else: + for c in candidates: + if (c.medium_index == item.track and + c.medium == item.disc): + mapping[item] = c + break + + # Apply. + with lib.transaction(): + autotag.apply_metadata(album_info, mapping) + changed = False + for item in items: + item_changed = ui.show_model_changes(item) + changed |= item_changed + if item_changed: + apply_item_changes(lib, item, move, pretend, write) + + if not changed: + # No change to any item. + continue + + if not pretend: + # Update album structure to reflect an item in it. + for key in library.Album.item_keys: + a[key] = items[0][key] + a.store() + + # Move album art (and any inconsistent items). + if move and lib.directory in util.ancestry(items[0].path): + self._log.debug(u'moving album {0}', a.id) + a.move() diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 74f4d4b6c..a27be65d1 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Pedro Silva. +# Copyright 2015, Pedro Silva. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,16 +14,11 @@ """List missing tracks. """ -import logging - from beets.autotag import hooks from beets.library import Item from beets.plugins import BeetsPlugin from beets.ui import decargs, print_obj, Subcommand -PLUGIN = 'missing' -log = logging.getLogger('beets') - def _missing_count(album): """Return number of missing items in `album`. @@ -31,25 +26,6 @@ def _missing_count(album): return (album.tracktotal or 0) - len(album.items()) -def _missing(album): - """Query MusicBrainz to determine items missing from `album`. - """ - item_mbids = map(lambda x: x.mb_trackid, album.items()) - - if len([i for i in album.items()]) < album.tracktotal: - # fetch missing items - # TODO: Implement caching that without breaking other stuff - album_info = hooks.album_for_mbid(album.mb_albumid) - for track_info in getattr(album_info, 'tracks', []): - if track_info.track_id not in item_mbids: - item = _item(track_info, album_info, album.id) - log.debug(u'{0}: track {1} in album {2}' - .format(PLUGIN, - track_info.track_id, - album_info.album_id)) - yield item - - def _item(track_info, album_info, album_id): """Build and return `item` from `track_info` and `album info` objects. `item` is missing what fields cannot be obtained from @@ -152,8 +128,24 @@ class MissingPlugin(BeetsPlugin): print_obj(album, lib, fmt=fmt) else: - for item in _missing(album): + for item in self._missing(album): print_obj(item, lib, fmt=fmt) self._command.func = _miss return [self._command] + + def _missing(self, album): + """Query MusicBrainz to determine items missing from `album`. + """ + item_mbids = map(lambda x: x.mb_trackid, album.items()) + + if len([i for i in album.items()]) < album.tracktotal: + # fetch missing items + # TODO: Implement caching that without breaking other stuff + album_info = hooks.album_for_mbid(album.mb_albumid) + for track_info in getattr(album_info, 'tracks', []): + if track_info.track_id not in item_mbids: + item = _item(track_info, album_info, album.id) + self._log.debug(u'track {1} in album {2}', + track_info.track_id, album_info.album_id) + yield item diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index c198445dc..0b2c8ba9b 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -1,6 +1,6 @@ # coding=utf-8 # This file is part of beets. -# Copyright 2013, Peter Schnebel and Johann Klähn. +# Copyright 2015, Peter Schnebel and Johann Klähn. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -13,7 +13,6 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -import logging import mpd import socket import select @@ -27,8 +26,6 @@ from beets import library from beets.util import displayable_path from beets.dbcore import types -log = logging.getLogger('beets') - # If we lose the connection, how many times do we want to retry and how # much time should we wait between retries? RETRIES = 10 @@ -56,9 +53,11 @@ class MPDClient(mpd.MPDClient): class MPDClientWrapper(object): - def __init__(self): + def __init__(self, log): + self._log = log + self.music_directory = ( - config['mpdstats']['music_directory'].get(unicode)) + self.config['music_directory'].get(unicode)) self.client = MPDClient() @@ -71,7 +70,7 @@ class MPDClientWrapper(object): if host[0] in ['/', '~']: host = os.path.expanduser(host) - log.info(u'mpdstats: connecting to {0}:{1}'.format(host, port)) + self._log.info(u'connecting to {0}:{1}', host, port) try: self.client.connect(host, port) except socket.error as e: @@ -99,7 +98,7 @@ class MPDClientWrapper(object): try: return getattr(self.client, command)() except (select.error, mpd.ConnectionError) as err: - log.error(u'mpdstats: {0}'.format(err)) + self._log.error(u'{0}', err) if retries <= 0: # if we exited without breaking, we couldn't reconnect in time :( @@ -141,15 +140,16 @@ class MPDClientWrapper(object): class MPDStats(object): - def __init__(self, lib): + def __init__(self, lib, log): self.lib = lib + self._log = log - self.do_rating = config['mpdstats']['rating'].get(bool) - self.rating_mix = config['mpdstats']['rating_mix'].get(float) + self.do_rating = self.config['rating'].get(bool) + self.rating_mix = self.config['rating_mix'].get(float) self.time_threshold = 10.0 # TODO: maybe add config option? self.now_playing = None - self.mpd = MPDClientWrapper() + self.mpd = MPDClientWrapper(log) def rating(self, play_count, skip_count, rating, skipped): """Calculate a new rating for a song based on play count, skip count, @@ -171,12 +171,9 @@ class MPDStats(object): if item: return item else: - log.info(u'mpdstats: item not found: {0}'.format( - displayable_path(path) - )) + self._log.info(u'item not found: {0}', displayable_path(path)) - @staticmethod - def update_item(item, attribute, value=None, increment=None): + def update_item(self, item, attribute, value=None, increment=None): """Update the beets item. Set attribute to value or increment the value of attribute. If the increment argument is used the value is cast to the corresponding type. @@ -192,11 +189,10 @@ class MPDStats(object): item[attribute] = value item.store() - log.debug(u'mpdstats: updated: {0} = {1} [{2}]'.format( - attribute, - item[attribute], - displayable_path(item.path), - )) + self._log.debug(u'updated: {0} = {1} [{2}]', + attribute, + item[attribute], + displayable_path(item.path)) def update_rating(self, item, skipped): """Update the rating for a beets item. @@ -215,6 +211,8 @@ class MPDStats(object): To this end the difference between the song's supposed end time and the current time is calculated. If it's greater than a threshold, the song is considered skipped. + + Returns whether the change was manual (skipped previous song or not) """ diff = abs(song['remaining'] - (time.time() - song['started'])) @@ -228,24 +226,22 @@ class MPDStats(object): if self.do_rating: self.update_rating(song['beets_item'], skipped) + return skipped + def handle_played(self, song): """Updates the play count of a song. """ self.update_item(song['beets_item'], 'play_count', increment=1) - log.info(u'mpdstats: played {0}'.format( - displayable_path(song['path']) - )) + self._log.info(u'played {0}', displayable_path(song['path'])) def handle_skipped(self, song): """Updates the skip count of a song. """ self.update_item(song['beets_item'], 'skip_count', increment=1) - log.info(u'mpdstats: skipped {0}'.format( - displayable_path(song['path']) - )) + self._log.info(u'skipped {0}', displayable_path(song['path'])) def on_stop(self, status): - log.info(u'mpdstats: stop') + self._log.info(u'stop') if self.now_playing: self.handle_song_change(self.now_playing) @@ -253,7 +249,7 @@ class MPDStats(object): self.now_playing = None def on_pause(self, status): - log.info(u'mpdstats: pause') + self._log.info(u'pause') self.now_playing = None def on_play(self, status): @@ -264,30 +260,31 @@ class MPDStats(object): return if is_url(path): - log.info(u'mpdstats: playing stream {0}'.format( - displayable_path(path) - )) + self._log.info(u'playing stream {0}', displayable_path(path)) return played, duration = map(int, status['time'].split(':', 1)) remaining = duration - played if self.now_playing and self.now_playing['path'] != path: - self.handle_song_change(self.now_playing) + skipped = self.handle_song_change(self.now_playing) + # mpd responds twice on a natural new song start + going_to_happen_twice = not skipped + else: + going_to_happen_twice = False - log.info(u'mpdstats: playing {0}'.format( - displayable_path(path) - )) + if not going_to_happen_twice: + self._log.info(u'playing {0}', displayable_path(path)) - self.now_playing = { - 'started': time.time(), - 'remaining': remaining, - 'path': path, - 'beets_item': self.get_item(path), - } + self.now_playing = { + 'started': time.time(), + 'remaining': remaining, + 'path': path, + 'beets_item': self.get_item(path), + } - self.update_item(self.now_playing['beets_item'], - 'last_played', value=int(time.time())) + self.update_item(self.now_playing['beets_item'], + 'last_played', value=int(time.time())) def run(self): self.mpd.connect() @@ -302,8 +299,7 @@ class MPDStats(object): if handler: handler(status) else: - log.debug(u'mpdstats: unhandled status "{0}"'. - format(status)) + self._log.debug(u'unhandled status "{0}"', status) events = self.mpd.events() @@ -356,7 +352,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin): config['mpd']['password'] = opts.password.decode('utf8') try: - MPDStats(lib).run() + MPDStats(lib, self._log).run() except KeyboardInterrupt: pass diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index b5137237a..42ef55fa8 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -20,17 +20,11 @@ Put something like the following in your config.yaml to configure: port: 6600 password: seekrit """ -from __future__ import print_function - from beets.plugins import BeetsPlugin import os import socket from beets import config -# Global variable so that mpdupdate can detect database changes and run only -# once before beets exits. -database_changed = False - # No need to introduce a dependency on an MPD library for such a # simple use case. Here's a simple socket abstraction to make things @@ -66,37 +60,6 @@ class BufferedSocket(object): self.sock.close() -def update_mpd(host='localhost', port=6600, password=None): - """Sends the "update" command to the MPD server indicated, - possibly authenticating with a password first. - """ - print('Updating MPD database...') - - s = BufferedSocket(host, port) - resp = s.readline() - if 'OK MPD' not in resp: - print('MPD connection failed:', repr(resp)) - return - - if password: - s.send('password "%s"\n' % password) - resp = s.readline() - if 'OK' not in resp: - print('Authentication failed:', repr(resp)) - s.send('close\n') - s.close() - return - - s.send('update\n') - resp = s.readline() - if 'updating_db' not in resp: - print('Update failed:', repr(resp)) - - s.send('close\n') - s.close() - print('... updated.') - - class MPDUpdatePlugin(BeetsPlugin): def __init__(self): super(MPDUpdatePlugin, self).__init__() @@ -112,18 +75,44 @@ class MPDUpdatePlugin(BeetsPlugin): if self.config[key].exists(): config['mpd'][key] = self.config[key].get() + self.register_listener('database_change', self.db_change) -@MPDUpdatePlugin.listen('database_change') -def handle_change(lib=None): - global database_changed - database_changed = True + def db_change(self, lib): + self.register_listener('cli_exit', self.update) - -@MPDUpdatePlugin.listen('cli_exit') -def update(lib=None): - if database_changed: - update_mpd( + def update(self, lib): + self.update_mpd( config['mpd']['host'].get(unicode), config['mpd']['port'].get(int), config['mpd']['password'].get(unicode), ) + + def update_mpd(self, host='localhost', port=6600, password=None): + """Sends the "update" command to the MPD server indicated, + possibly authenticating with a password first. + """ + self._log.info('Updating MPD database...') + + s = BufferedSocket(host, port) + resp = s.readline() + if 'OK MPD' not in resp: + self._log.warning(u'MPD connection failed: {0!r}', resp) + return + + if password: + s.send('password "%s"\n' % password) + resp = s.readline() + if 'OK' not in resp: + self._log.warning(u'Authentication failed: {0!r}', resp) + s.send('close\n') + s.close() + return + + s.send('update\n') + resp = s.readline() + if 'updating_db' not in resp: + self._log.warning(u'Update failed: {0!r}', resp) + + s.send('close\n') + s.close() + self._log.info('Database updated.') diff --git a/beetsplug/play.py b/beetsplug/play.py index 770b84284..5072234ca 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, David Hamp-Gonsalves +# Copyright 2015, David Hamp-Gonsalves # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,6 +14,8 @@ """Send the results of a query to the configured music player as a playlist. """ +from functools import partial + from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets import config @@ -21,14 +23,11 @@ from beets import ui from beets import util from os.path import relpath import platform -import logging import shlex from tempfile import NamedTemporaryFile -log = logging.getLogger('beets') - -def play_music(lib, opts, args): +def play_music(lib, opts, args, log): """Execute query, create temporary playlist and execute player command passing that playlist. """ @@ -101,12 +100,11 @@ def play_music(lib, opts, args): # Invoke the command and log the output. output = util.command_output(command) if output: - log.debug(u'Output of {0}: {1}'.format( - util.displayable_path(command[0]), - output.decode('utf8', 'ignore'), - )) + log.debug(u'Output of {0}: {1}', + util.displayable_path(command[0]), + output.decode('utf8', 'ignore')) else: - log.debug(u'play: no output') + log.debug(u'no output') ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type)) @@ -134,5 +132,5 @@ class PlayPlugin(BeetsPlugin): action='store_true', default=False, help='query and load albums rather than tracks' ) - play_command.func = play_music + play_command.func = partial(play_music, log=self._log) return [play_command] diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py index c61766c43..f132cd4b3 100644 --- a/beetsplug/plexupdate.py +++ b/beetsplug/plexupdate.py @@ -12,11 +12,6 @@ from beets import config from beets.plugins import BeetsPlugin -# Global variable to detect if database is changed that the update -# is only run once before beets exists. -database_changed = False - - def get_music_section(host, port): """Getting the section key for the music library in Plex. """ @@ -55,30 +50,23 @@ class PlexUpdate(BeetsPlugin): u'host': u'localhost', u'port': 32400}) + self.register_listener('database_change', self.listen_for_db_change) -@PlexUpdate.listen('database_change') -def listen_for_db_change(lib=None): - """Listens for beets db change and set global database_changed - variable to True. - """ - global database_changed - database_changed = True + def listen_for_db_change(self, lib): + """Listens for beets db change and register the update for the end""" + self.register_listener('cli_exit', self.update) - -@PlexUpdate.listen('cli_exit') -def update(lib=None): - """When the client exists and the database_changed variable is True - trying to send refresh request to Plex server. - """ - if database_changed: - print('Updating Plex library...') + def update(self, lib): + """When the client exists try to send refresh request to Plex server. + """ + self._log.info('Updating Plex library...') # Try to send update request. try: update_plex( config['plex']['host'].get(), config['plex']['port'].get()) - print('... started.') + self._log.info('... started.') except requests.exceptions.RequestException: - print('Update failed.') + self._log.warning('Update failed.') diff --git a/beetsplug/random.py b/beetsplug/random.py index f594bd73b..2c4d0c000 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Philippe Mongeau. +# Copyright 2015, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 40b3a3a85..f7ca68c5b 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. +# Copyright 2015, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -12,7 +12,6 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -import logging import subprocess import os import collections @@ -20,13 +19,12 @@ import itertools import sys import warnings +from beets import logging from beets import ui from beets.plugins import BeetsPlugin from beets.util import syspath, command_output, displayable_path from beets import config -log = logging.getLogger('beets.replaygain') - # Utilities. @@ -67,10 +65,11 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") class Backend(object): """An abstract class representing engine for calculating RG values. """ - def __init__(self, config): + def __init__(self, config, log): """Initialize the backend with the configuration view for the plugin. """ + self._log = log def compute_track_gain(self, items): raise NotImplementedError() @@ -85,7 +84,8 @@ class Backend(object): class CommandBackend(Backend): - def __init__(self, config): + def __init__(self, config, log): + super(CommandBackend, self).__init__(config, log) config.add({ 'command': u"", 'noclip': True, @@ -135,7 +135,7 @@ class CommandBackend(Backend): supported_items = filter(self.format_supported, album.items()) if len(supported_items) != len(album.items()): - log.debug(u'replaygain: tracks are of unsupported format') + self._log.debug(u'tracks are of unsupported format') return AlbumGain(None, []) output = self.compute_gain(supported_items, True) @@ -180,11 +180,10 @@ class CommandBackend(Backend): cmd = cmd + ['-d', str(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] - log.debug(u'replaygain: analyzing {0} files'.format(len(items))) - log.debug(u"replaygain: executing {0}" - .format(" ".join(map(displayable_path, cmd)))) + self._log.debug(u'analyzing {0} files', len(items)) + self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd))) output = call(cmd) - log.debug(u'replaygain: analysis finished') + self._log.debug(u'analysis finished') results = self.parse_tool_output(output, len(items) + (1 if is_album else 0)) @@ -199,7 +198,7 @@ class CommandBackend(Backend): for line in text.split('\n')[1:num_lines + 1]: parts = line.split('\t') if len(parts) != 6 or parts[0] == 'File': - log.debug(u'replaygain: bad tool output: {0}'.format(text)) + self._log.debug(u'bad tool output: {0}', text) raise ReplayGainError('mp3gain failed') d = { 'file': parts[0], @@ -468,7 +467,8 @@ class AudioToolsBackend(Backend): `_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. """ - def __init__(self, config): + def __init__(self, config, log): + super(CommandBackend, self).__init__(config, log) self._import_audiotools() def _import_audiotools(self): @@ -548,14 +548,8 @@ class AudioToolsBackend(Backend): # be obtained from an audiofile instance. rg_track_gain, rg_track_peak = rg.title_gain(audiofile.to_pcm()) - log.debug( - u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}'.format( - item.artist, - item.title, - rg_track_gain, - rg_track_peak - ) - ) + self._log.debug(u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}', + item.artist, item.title, rg_track_gain, rg_track_peak) return Gain(gain=rg_track_gain, peak=rg_track_peak) def compute_album_gain(self, album): @@ -563,12 +557,7 @@ class AudioToolsBackend(Backend): :rtype: :class:`AlbumGain` """ - log.debug( - u'Analysing album {0} - {1}'.format( - album.albumartist, - album.album - ) - ) + self._log.debug(u'Analysing album {0.albumartist} - {0.album}', album) # The first item is taken and opened to get the sample rate to # initialize the replaygain object. The object is used for all the @@ -584,26 +573,16 @@ class AudioToolsBackend(Backend): track_gains.append( Gain(gain=rg_track_gain, peak=rg_track_peak) ) - log.debug( - u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}'.format( - item.artist, - item.title, - rg_track_gain, - rg_track_peak - ) - ) + self._log.debug(u'ReplayGain for track {0.artist} - {0.title}: ' + u'{1:.2f}, {2:.2f}', + item, rg_track_gain, rg_track_peak) # After getting the values for all tracks, it's possible to get the # album values. rg_album_gain, rg_album_peak = rg.album_gain() - log.debug( - u'ReplayGain for Album {0} - {1}: {2:.2f}, {3:.2f}'.format( - album.albumartist, - album.album, - rg_album_gain, - rg_album_peak - ) - ) + self._log.debug(u'ReplayGain for album {0.albumartist} - {0.album}: ' + u'{1:.2f}, {2:.2f}', + album, rg_album_gain, rg_album_peak) return AlbumGain( Gain(gain=rg_album_gain, peak=rg_album_peak), @@ -649,7 +628,7 @@ class ReplayGainPlugin(BeetsPlugin): try: self.backend_instance = self.backends[backend_name]( - self.config + self.config, self._log ) except (ReplayGainError, FatalReplayGainError) as e: raise ui.UserError( @@ -674,19 +653,16 @@ class ReplayGainPlugin(BeetsPlugin): item.rg_track_peak = track_gain.peak item.store() - log.debug(u'replaygain: applied track gain {0}, peak {1}'.format( - item.rg_track_gain, - item.rg_track_peak - )) + self._log.debug(u'applied track gain {0}, peak {1}', + item.rg_track_gain, item.rg_track_peak) def store_album_gain(self, album, album_gain): album.rg_album_gain = album_gain.gain album.rg_album_peak = album_gain.peak album.store() - log.debug(u'replaygain: applied album gain {0}, peak {1}'.format( - album.rg_album_gain, - album.rg_album_peak)) + self._log.debug(u'applied album gain {0}, peak {1}', + album.rg_album_gain, album.rg_album_peak) def handle_album(self, album, write): """Compute album and track replay gain store it in all of the @@ -697,12 +673,11 @@ class ReplayGainPlugin(BeetsPlugin): items, nothing is done. """ if not self.album_requires_gain(album): - log.info(u'Skipping album {0} - {1}'.format(album.albumartist, - album.album)) + self._log.info(u'Skipping album {0} - {1}', + album.albumartist, album.album) return - log.info(u'analyzing {0} - {1}'.format(album.albumartist, - album.album)) + self._log.info(u'analyzing {0} - {1}', album.albumartist, album.album) try: album_gain = self.backend_instance.compute_album_gain(album) @@ -721,7 +696,7 @@ class ReplayGainPlugin(BeetsPlugin): if write: item.try_write() except ReplayGainError as e: - log.info(u"ReplayGain error: {0}".format(e)) + self._log.info(u"ReplayGain error: {0}", e) except FatalReplayGainError as e: raise ui.UserError( u"Fatal replay gain error: {0}".format(e) @@ -735,12 +710,10 @@ class ReplayGainPlugin(BeetsPlugin): in the item, nothing is done. """ if not self.track_requires_gain(item): - log.info(u'Skipping track {0} - {1}' - .format(item.artist, item.title)) + self._log.info(u'Skipping track {0.artist} - {0.title}', item) return - log.info(u'analyzing {0} - {1}' - .format(item.artist, item.title)) + self._log.info(u'analyzing {0} - {1}', item.artist, item.title) try: track_gains = self.backend_instance.compute_track_gain([item]) @@ -755,7 +728,7 @@ class ReplayGainPlugin(BeetsPlugin): if write: item.try_write() except ReplayGainError as e: - log.info(u"ReplayGain error: {0}".format(e)) + self._log.info(u"ReplayGain error: {0}", e) except FatalReplayGainError as e: raise ui.UserError( u"Fatal replay gain error: {0}".format(e) @@ -767,7 +740,7 @@ class ReplayGainPlugin(BeetsPlugin): if not self.automatic: return - log.setLevel(logging.WARN) + self._log.setLevel(logging.WARN) if task.is_album: self.handle_album(task.album, False) @@ -778,7 +751,7 @@ class ReplayGainPlugin(BeetsPlugin): """Return the "replaygain" ui subcommand. """ def func(lib, opts, args): - log.setLevel(logging.INFO) + self._log.setLevel(logging.INFO) write = config['import']['write'].get(bool) diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py index 55b705492..34c2e0ed4 100644 --- a/beetsplug/rewrite.py +++ b/beetsplug/rewrite.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -16,15 +16,12 @@ formats. """ import re -import logging from collections import defaultdict from beets.plugins import BeetsPlugin from beets import ui from beets import library -log = logging.getLogger('beets') - def rewriter(field, rules): """Create a template field function that rewrites the given field @@ -59,7 +56,7 @@ class RewritePlugin(BeetsPlugin): if fieldname not in library.Item._fields: raise ui.UserError("invalid field name (%s) in rewriter" % fieldname) - log.debug(u'adding template field {0}'.format(key)) + self._log.debug(u'adding template field {0}', key) pattern = re.compile(pattern.lower()) rules[fieldname].append((pattern, value)) if fieldname == 'artist': diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index c53c27590..64bbcc9b5 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """Cleans extraneous metadata from files' tags via a command or automatically whenever tags are written. """ -import logging from beets.plugins import BeetsPlugin from beets import ui @@ -23,8 +22,6 @@ from beets import util from beets import config from beets import mediafile -log = logging.getLogger('beets') - _MUTAGEN_FORMATS = { 'asf': 'ASF', 'apev2': 'APEv2File', @@ -54,6 +51,7 @@ class ScrubPlugin(BeetsPlugin): self.config.add({ 'auto': True, }) + self.register_listener("write", self.write_item) def commands(self): def scrub_func(lib, opts, args): @@ -64,8 +62,8 @@ class ScrubPlugin(BeetsPlugin): # Walk through matching files and remove tags. for item in lib.items(ui.decargs(args)): - log.info(u'scrubbing: {0}'.format( - util.displayable_path(item.path))) + self._log.info(u'scrubbing: {0}', + util.displayable_path(item.path)) # Get album art if we need to restore it. if opts.write: @@ -74,14 +72,14 @@ class ScrubPlugin(BeetsPlugin): art = mf.art # Remove all tags. - _scrub(item.path) + self._scrub(item.path) # Restore tags, if enabled. if opts.write: - log.debug(u'writing new tags after scrub') + self._log.debug(u'writing new tags after scrub') item.try_write() if art: - log.info(u'restoring art') + self._log.info(u'restoring art') mf = mediafile.MediaFile(item.path) mf.art = art mf.save() @@ -96,50 +94,46 @@ class ScrubPlugin(BeetsPlugin): return [scrub_cmd] + @staticmethod + def _mutagen_classes(): + """Get a list of file type classes from the Mutagen module. + """ + classes = [] + for modname, clsname in _MUTAGEN_FORMATS.items(): + mod = __import__('mutagen.{0}'.format(modname), + fromlist=[clsname]) + classes.append(getattr(mod, clsname)) + return classes -def _mutagen_classes(): - """Get a list of file type classes from the Mutagen module. - """ - classes = [] - for modname, clsname in _MUTAGEN_FORMATS.items(): - mod = __import__('mutagen.{0}'.format(modname), - fromlist=[clsname]) - classes.append(getattr(mod, clsname)) - return classes + def _scrub(self, path): + """Remove all tags from a file. + """ + for cls in self._mutagen_classes(): + # Try opening the file with this type, but just skip in the + # event of any error. + try: + f = cls(util.syspath(path)) + except Exception: + continue + if f.tags is None: + continue + # Remove the tag for this type. + try: + f.delete() + except NotImplementedError: + # Some Mutagen metadata subclasses (namely, ASFTag) do not + # support .delete(), presumably because it is impossible to + # remove them. In this case, we just remove all the tags. + for tag in f.keys(): + del f[tag] + f.save() + except IOError as exc: + self._log.error(u'could not scrub {0}: {1}', + util.displayable_path(path), exc) -def _scrub(path): - """Remove all tags from a file. - """ - for cls in _mutagen_classes(): - # Try opening the file with this type, but just skip in the - # event of any error. - try: - f = cls(util.syspath(path)) - except Exception: - continue - if f.tags is None: - continue - - # Remove the tag for this type. - try: - f.delete() - except NotImplementedError: - # Some Mutagen metadata subclasses (namely, ASFTag) do not - # support .delete(), presumably because it is impossible to - # remove them. In this case, we just remove all the tags. - for tag in f.keys(): - del f[tag] - f.save() - except IOError as exc: - log.error(u'could not scrub {0}: {1}'.format( - util.displayable_path(path), exc, - )) - - -# Automatically embed art into imported albums. -@ScrubPlugin.listen('write') -def write_item(path): - if not scrubbing and config['scrub']['auto']: - log.debug(u'auto-scrubbing {0}'.format(util.displayable_path(path))) - _scrub(path) + def write_item(self, path): + """Automatically embed art into imported albums.""" + if not scrubbing and self.config['auto']: + self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path)) + self._scrub(path) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 5cae4f385..15cb46057 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Dang Mai . +# Copyright 2015, Dang Mai . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,76 +15,23 @@ """Generates smart playlists based on beets queries. """ from __future__ import print_function +from itertools import chain from beets.plugins import BeetsPlugin -from beets import config, ui, library +from beets import ui from beets.util import normpath, syspath import os -# Global variable so that smartplaylist can detect database changes and run -# only once before beets exits. -database_changed = False - -def _items_for_query(lib, playlist, album=False): - """Get the matching items for a playlist's configured queries. - `album` indicates whether to process the item-level query or the - album-level query (if any). +def _items_for_query(lib, queries, album): + """Get the matching items for a query. + `album` indicates whether the queries are item-level or album-level. """ - key = 'album_query' if album else 'query' - if key not in playlist: - return [] - - # Parse quer(ies). If it's a list, perform the queries and manually - # concatenate the results - query_strings = playlist[key] - if not isinstance(query_strings, (list, tuple)): - query_strings = [query_strings] - model = library.Album if album else library.Item - results = [] - for q in query_strings: - query, sort = library.parse_query_string(q, model) - if album: - new = lib.albums(query, sort) - else: - new = lib.items(query, sort) - results.extend(new) - return results - - -def update_playlists(lib): - ui.print_("Updating smart playlists...") - playlists = config['smartplaylist']['playlists'].get(list) - playlist_dir = config['smartplaylist']['playlist_dir'].as_filename() - relative_to = config['smartplaylist']['relative_to'].get() - if relative_to: - relative_to = normpath(relative_to) - - for playlist in playlists: - items = [] - items.extend(_items_for_query(lib, playlist, True)) - items.extend(_items_for_query(lib, playlist, False)) - - m3us = {} - basename = playlist['name'].encode('utf8') - # As we allow tags in the m3u names, we'll need to iterate through - # the items and generate the correct m3u file names. - for item in items: - m3u_name = item.evaluate_template(basename, True) - if not (m3u_name in m3us): - m3us[m3u_name] = [] - item_path = item.path - if relative_to: - item_path = os.path.relpath(item.path, relative_to) - if item_path not in m3us[m3u_name]: - m3us[m3u_name].append(item_path) - # Now iterate through the m3us that we need to generate - for m3u in m3us: - m3u_path = normpath(os.path.join(playlist_dir, m3u)) - with open(syspath(m3u_path), 'w') as f: - for path in m3us[m3u]: - f.write(path + '\n') - ui.print_("... Done") + request = lib.albums if album else lib.items + if isinstance(queries, basestring): + return request(queries) + else: + return chain.from_iterable(map(request, queries)) class SmartPlaylistPlugin(BeetsPlugin): @@ -97,23 +44,54 @@ class SmartPlaylistPlugin(BeetsPlugin): 'playlists': [] }) + if self.config['auto']: + self.register_listener('database_change', self.db_change) + def commands(self): def update(lib, opts, args): - update_playlists(lib) + self.update_playlists(lib) spl_update = ui.Subcommand('splupdate', help='update the smart playlists') spl_update.func = update return [spl_update] + def db_change(self, lib): + self.register_listener('cli_exit', self.update_playlists) -@SmartPlaylistPlugin.listen('database_change') -def handle_change(lib): - global database_changed - database_changed = True + def update_playlists(self, lib): + self._log.info("Updating smart playlists...") + playlists = self.config['playlists'].get(list) + playlist_dir = self.config['playlist_dir'].as_filename() + relative_to = self.config['relative_to'].get() + if relative_to: + relative_to = normpath(relative_to) + for playlist in playlists: + self._log.debug(u"Creating playlist {0.name}", playlist) + items = [] + if 'album_query' in playlist: + items.extend(_items_for_query(lib, playlist['album_query'], + True)) + if 'query' in playlist: + items.extend(_items_for_query(lib, playlist['query'], False)) -@SmartPlaylistPlugin.listen('cli_exit') -def update(lib): - auto = config['smartplaylist']['auto'] - if database_changed and auto: - update_playlists(lib) + m3us = {} + basename = playlist['name'].encode('utf8') + # As we allow tags in the m3u names, we'll need to iterate through + # the items and generate the correct m3u file names. + for item in items: + m3u_name = item.evaluate_template(basename, True) + if m3u_name not in m3us: + m3us[m3u_name] = [] + item_path = item.path + if relative_to: + item_path = os.path.relpath(item.path, relative_to) + if item_path not in m3us[m3u_name]: + m3us[m3u_name].append(item_path) + # Now iterate through the m3us that we need to generate + for m3u in m3us: + m3u_path = normpath(os.path.join(playlist_dir, m3u)) + with open(syspath(m3u_path), 'w') as f: + for path in m3us[m3u]: + f.write(path + '\n') + self._log.info("{0} playlists updated", len(playlists)) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 7d424c828..5833efb58 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -2,14 +2,11 @@ from __future__ import print_function import re import webbrowser import requests -import logging from beets.plugins import BeetsPlugin from beets.ui import decargs from beets import ui from requests.exceptions import HTTPError -log = logging.getLogger('beets') - class SpotifyPlugin(BeetsPlugin): @@ -63,8 +60,8 @@ class SpotifyPlugin(BeetsPlugin): self.config['show_failures'].set(True) if self.config['mode'].get() not in ['list', 'open']: - log.warn(u'{0} is not a valid mode' - .format(self.config['mode'].get())) + self._log.warn(u'{0} is not a valid mode', + self.config['mode'].get()) return False self.opts = opts @@ -78,10 +75,11 @@ class SpotifyPlugin(BeetsPlugin): items = lib.items(query) if not items: - log.debug(u'Your beets query returned no items, skipping spotify') + self._log.debug(u'Your beets query returned no items, ' + u'skipping spotify') return - log.info(u'Processing {0} tracks...'.format(len(items))) + self._log.info(u'Processing {0} tracks...', len(items)) for item in items: @@ -109,12 +107,12 @@ class SpotifyPlugin(BeetsPlugin): r = requests.get(self.base_url, params={ "q": search_url, "type": "track" }) - log.debug(r.url) + self._log.debug(r.url) try: r.raise_for_status() except HTTPError as e: - log.debug(u'URL returned a {0} error' - .format(e.response.status_code)) + self._log.debug(u'URL returned a {0} error', + e.response.status_code) failures.append(search_url) continue @@ -130,33 +128,33 @@ class SpotifyPlugin(BeetsPlugin): # Simplest, take the first result chosen_result = None if len(r_data) == 1 or self.config['tiebreak'].get() == "first": - log.debug(u'Spotify track(s) found, count: {0}' - .format(len(r_data))) + self._log.debug(u'Spotify track(s) found, count: {0}', + len(r_data)) chosen_result = r_data[0] elif len(r_data) > 1: # Use the popularity filter - log.debug(u'Most popular track chosen, count: {0}' - .format(len(r_data))) + self._log.debug(u'Most popular track chosen, count: {0}', + len(r_data)) chosen_result = max(r_data, key=lambda x: x['popularity']) if chosen_result: results.append(chosen_result) else: - log.debug(u'No spotify track found: {0}'.format(search_url)) + self._log.debug(u'No spotify track found: {0}', search_url) failures.append(search_url) failure_count = len(failures) if failure_count > 0: if self.config['show_failures'].get(): - log.info(u'{0} track(s) did not match a Spotify ID:' - .format(failure_count)) + self._log.info(u'{0} track(s) did not match a Spotify ID:', + failure_count) for track in failures: - log.info(u'track: {0}'.format(track)) - log.info(u'') + self._log.info(u'track: {0}', track) + self._log.info(u'') else: - log.warn(u'{0} track(s) did not match a Spotify ID;\n' - u'use --show-failures to display' - .format(failure_count)) + self._log.warn(u'{0} track(s) did not match a Spotify ID;\n' + u'use --show-failures to display', + failure_count) return results @@ -164,7 +162,7 @@ class SpotifyPlugin(BeetsPlugin): if results: ids = map(lambda x: x['id'], results) if self.config['mode'].get() == "open": - log.info(u'Attempting to open Spotify with playlist') + self._log.info(u'Attempting to open Spotify with playlist') spotify_url = self.playlist_partial + ",".join(ids) webbrowser.open(spotify_url) @@ -172,4 +170,4 @@ class SpotifyPlugin(BeetsPlugin): for item in ids: print(unicode.encode(self.open_url + item)) else: - log.warn(u'No Spotify tracks found from beets query') + self._log.warn(u'No Spotify tracks found from beets query') diff --git a/beetsplug/the.py b/beetsplug/the.py index 5bc50415a..b3ff8b930 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Blemjhoo Tezoulbr . +# Copyright 2015, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """Moves patterns in path formats (suitable for moving articles).""" import re -import logging from beets.plugins import BeetsPlugin __author__ = 'baobab@heresiarch.info' @@ -29,7 +28,6 @@ FORMAT = u'{0}, {1}' class ThePlugin(BeetsPlugin): _instance = None - _log = logging.getLogger('beets') the = True a = True @@ -56,17 +54,17 @@ class ThePlugin(BeetsPlugin): try: re.compile(p) except re.error: - self._log.error(u'[the] invalid pattern: {0}'.format(p)) + self._log.error(u'invalid pattern: {0}', p) else: if not (p.startswith('^') or p.endswith('$')): - self._log.warn(u'[the] warning: \"{0}\" will not ' - 'match string start/end'.format(p)) + self._log.warn(u'warning: \"{0}\" will not ' + u'match string start/end', p) if self.config['a']: self.patterns = [PATTERN_A] + self.patterns if self.config['the']: self.patterns = [PATTERN_THE] + self.patterns if not self.patterns: - self._log.warn(u'[the] no patterns defined!') + self._log.warn(u'no patterns defined!') def unthe(self, text, pattern): """Moves pattern in the path format string or strips it @@ -99,7 +97,7 @@ class ThePlugin(BeetsPlugin): r = self.unthe(text, p) if r != text: break - self._log.debug(u'[the] \"{0}\" -> \"{1}\"'.format(text, r)) + self._log.debug(u'\"{0}\" -> \"{1}\"', text, r) return r else: return u'' diff --git a/beetsplug/types.py b/beetsplug/types.py index e351c8add..8a1e6c2d6 100644 --- a/beetsplug/types.py +++ b/beetsplug/types.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index cf45d8c48..a60461689 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/web/static/jquery.js b/beetsplug/web/static/jquery.js index a9d6fe8c1..5b43a3ece 100644 --- a/beetsplug/web/static/jquery.js +++ b/beetsplug/web/static/jquery.js @@ -2,13 +2,13 @@ * jQuery JavaScript Library v1.7.1 * http://jquery.com/ * - * Copyright 2013, John Resig + * Copyright 2015, John Resig * Dual licensed under the MIT or GPL Version 2 licenses. * http://jquery.org/license * * Includes Sizzle.js * http://sizzlejs.com/ - * Copyright 2013, The Dojo Foundation + * Copyright 2015, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * * Date: Mon Nov 21 21:11:03 2011 -0500 @@ -3851,7 +3851,7 @@ jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblcl /*! * Sizzle CSS Selector Engine - * Copyright 2013, The Dojo Foundation + * Copyright 2015, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * More information: http://sizzlejs.com/ */ diff --git a/beetsplug/zero.py b/beetsplug/zero.py index ed41511c8..6b4875c62 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Blemjhoo Tezoulbr . +# Copyright 2015, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """ Clears tag fields in media files.""" import re -import logging from beets.plugins import BeetsPlugin from beets.mediafile import MediaFile from beets.importer import action @@ -24,8 +23,6 @@ from beets.util import confit __author__ = 'baobab@heresiarch.info' __version__ = '0.10' -log = logging.getLogger('beets') - class ZeroPlugin(BeetsPlugin): @@ -48,11 +45,11 @@ class ZeroPlugin(BeetsPlugin): for field in self.config['fields'].as_str_seq(): if field in ('id', 'path', 'album_id'): - log.warn(u'[zero] field \'{0}\' ignored, zeroing ' - u'it would be dangerous'.format(field)) + self._log.warn(u'field \'{0}\' ignored, zeroing ' + u'it would be dangerous', field) continue if field not in MediaFile.fields(): - log.error(u'[zero] invalid field: {0}'.format(field)) + self._log.error(u'invalid field: {0}', field) continue try: @@ -64,7 +61,7 @@ class ZeroPlugin(BeetsPlugin): def import_task_choice_event(self, session, task): """Listen for import_task_choice event.""" if task.choice_flag == action.ASIS and not self.warned: - log.warn(u'[zero] cannot zero in \"as-is\" mode') + self._log.warn(u'cannot zero in \"as-is\" mode') self.warned = True # TODO request write in as-is mode @@ -85,7 +82,7 @@ class ZeroPlugin(BeetsPlugin): by `self.patterns`. """ if not self.patterns: - log.warn(u'[zero] no fields, nothing to do') + self._log.warn(u'no fields, nothing to do') return for field, patterns in self.patterns.items(): @@ -97,5 +94,5 @@ class ZeroPlugin(BeetsPlugin): match = patterns is True if match: - log.debug(u'[zero] {0}: {1} -> None'.format(field, value)) + self._log.debug(u'{0}: {1} -> None', field, value) tags[field] = None diff --git a/docs/changelog.rst b/docs/changelog.rst index 0446300cc..69aa4da8d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,38 +1,73 @@ Changelog ========= -1.3.10 (in development) +1.3.11 (in development) ----------------------- +Fixes: + +* :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new + MusixMatch backend. :bug:`1204` +* :doc:`/plugins/lastgenre`: Add *comedy*, *humor*, and *stand-up* to the + built-in whitelist/canonicalization tree. :bug:`1206` +* Fix a crash when ``beet`` is invoked without arguments. :bug:`1205` + :bug:`1207` +* :doc:`/plugins/fetchart`: Do not attempt to import directories as album art. + :bug:`1177` :bug:`1211` +* :doc:`/plugins/mpdstats`: Avoid double-counting some play events. :bug:`773` + :bug:`1212` +* Fix a crash when the importer deals with Unicode metadata in ``--pretend`` + mode. :bug:`1214` + +For developers: The logging system in beets has been overhauled. Plugins now +each have their own logger, which helps by automatically adjusting the +verbosity level in import mode and by prefixing the plugin's name. Also, +logging calls can (and should!) use modern ``{}``-style string formatting +lazily. See :ref:`plugin-logging` in the plugin API docs. + + +1.3.10 (January 5, 2015) +------------------------ + +This version adds a healthy helping of new features and fixes a critical +MPEG-4--related bug. There are more lyrics sources, there new plugins for +managing permissions and integrating with `Plex`_, and the importer has a new +``--pretend`` flag that shows which music *would* be imported. + One backwards-compatibility note: the :doc:`/plugins/lyrics` now requires the `requests`_ library. If you use this plugin, you will need to install the library by typing ``pip install requests`` or the equivalent for your OS. -New: +Also, as an advance warning, this will be one of the last releases to support +Python 2.6. If you have a system that cannot run Python 2.7, please consider +upgrading soon. + +The new features are: -* :doc:`/plugins/lyrics`: Add `Musixmatch`_ source and introduce a new - ``sources`` config option that lets you choose exactly where to look for - lyrics and in which order. -* :doc:`/plugins/lyrics`: Add brazilian and hispanic sources to Google custom - search engine. * A new :doc:`/plugins/permissions` makes it easy to fix permissions on music files as they are imported. Thanks to :user:`xsteadfastx`. :bug:`1098` * A new :doc:`/plugins/plexupdate` lets you notify a `Plex`_ server when the database changes. Thanks again to xsteadfastx. :bug:`1120` +* The :ref:`import-cmd` command now has a ``--pretend`` flag that lists the + files that will be imported. Thanks to :user:`mried`. :bug:`1162` +* :doc:`/plugins/lyrics`: Add `Musixmatch`_ source and introduce a new + ``sources`` config option that lets you choose exactly where to look for + lyrics and in which order. +* :doc:`/plugins/lyrics`: Add Brazilian and Spanish sources to Google custom + search engine. * Add a warning when importing a directory that contains no music. :bug:`1116` :bug:`1127` * :doc:`/plugins/zero`: Can now remove embedded images. :bug:`1129` :bug:`1100` * The :ref:`config-cmd` command can now be used to edit the configuration even when it has syntax errors. :bug:`1123` :bug:`1128` * :doc:`/plugins/lyrics`: Added a new ``force`` config option. :bug:`1150` -* The :ref:`import-cmd` command now has a ``--pretend`` flag that lists the - files that will be imported. Thanks to :user:`mried`. :bug:`1162` -Fixed: +As usual, there are loads of little fixes and improvements: -* :doc:`/plugins/lyrics`: Avoid fetching truncated lyrics from the Google - backed by merging text blocks separated by empty ``
`` before scraping. * Fix a new crash with the latest version of Mutagen (1.26). +* :doc:`/plugins/lyrics`: Avoid fetching truncated lyrics from the Google + backed by merging text blocks separated by empty ``
`` tags before + scraping. * We now print a better error message when the database file is corrupted. * :doc:`/plugins/discogs`: Only prompt for authentication when running the :ref:`import-cmd` command. :bug:`1123` @@ -68,6 +103,8 @@ Fixed: twice in the artist string. Thanks to Marc Addeo. :bug:`1179` :bug:`1181` * :doc:`/plugins/lastgenre`: Match songs more robustly when they contain dashes. Thanks to :user:`djl`. :bug:`1156` +* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with + arguments. .. _API changes: http://developer.echonest.com/forums/thread/3650 .. _Plex: https://plex.tv/ diff --git a/docs/conf.py b/docs/conf.py index 2ce36bc66..82fc15da8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,7 @@ project = u'beets' copyright = u'2012, Adrian Sampson' version = '1.3' -release = '1.3.10' +release = '1.3.11' pygments_style = 'sphinx' diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index bcb6c47d0..56db52673 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -112,8 +112,23 @@ an example:: def loaded(): print 'Plugin loaded!' -Pass the name of the event in question to the ``listen`` decorator. The events -currently available are: +Pass the name of the event in question to the ``listen`` decorator. + +Note that if you want to access an attribute of your plugin (e.g. ``config`` or +``log``) you'll have to define a method and not a function. Here is the usual +registration process in this case:: + + from beets.plugins import BeetsPlugin + + class SomePlugin(BeetsPlugin): + def __init__(self): + super(SomePlugin, self).__init__() + self.register_listener('pluginload', self.loaded) + + def loaded(self): + self._log.info('Plugin loaded!') + +The events currently available are: * *pluginload*: called after all the plugins have been loaded after the ``beet`` command starts @@ -334,11 +349,11 @@ method. Here's an example plugin that provides a meaningless new field "foo":: - class FooPlugin(BeetsPlugin): + class fooplugin(beetsplugin): def __init__(self): - field = mediafile.MediaField( - mediafile.MP3DescStorageStyle(u'foo') - mediafile.StorageStyle(u'foo') + field = mediafile.mediafield( + mediafile.mp3descstoragestyle(u'foo') + mediafile.storagestyle(u'foo') ) self.add_media_field('foo', field) @@ -448,3 +463,30 @@ Specifying types has several advantages: from the command line. * User input for flexible fields may be validated and converted. + + +.. _plugin-logging: + +Logging +^^^^^^^ + +Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the +`standard Python logging module`_. The logger is set up to `PEP 3101`_, +str.format-style string formatting. So you can write logging calls like this:: + + self._log.debug(u'Processing {0.title} by {0.artist}', item) + +.. _PEP 3101: https://www.python.org/dev/peps/pep-3101/ +.. _standard Python logging module: https://docs.python.org/2/library/logging.html + +The per-plugin loggers have two convenient features: + +* When beets is in verbose mode, messages are prefixed with the plugin name to + make them easier to see. +* Messages at the ``INFO`` logging level are hidden when the plugin is running + in an importer stage (see above). This addresses a common pattern where + plugins need to use the same code for a command and an import stage, but the + command needs to print more messages than the import stage. (For example, + you'll want to log "found lyrics for this song" when you're run explicitly + as a command, but you don't want to noisily interrupt the importer interface + when running automatically.) diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 270e34def..bc39e581e 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -53,9 +53,9 @@ to albums that have a ``for_travel`` extensible field set to 1:: album_query: 'for_travel:1' query: 'for_travel:1' -By default, all playlists are automatically regenerated after every beets -command that changes the library database. To force regeneration, you can invoke it manually from the -command line:: +By default, all playlists are automatically regenerated at the end of the +session if the library database was changed. To force regeneration, you can +invoke it manually from the command line:: $ beet splupdate diff --git a/setup.py b/setup.py index e1b94eefe..802809a92 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -45,7 +45,7 @@ if 'sdist' in sys.argv: setup( name='beets', - version='1.3.10', + version='1.3.11', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', diff --git a/test/_common.py b/test/_common.py index 64f2f7247..6107601c8 100644 --- a/test/_common.py +++ b/test/_common.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -16,7 +16,6 @@ import time import sys import os -import logging import tempfile import shutil from contextlib import contextmanager @@ -30,7 +29,7 @@ except ImportError: # Mangle the search path to include the beets sources. sys.path.insert(0, '..') import beets.library -from beets import importer +from beets import importer, logging from beets.ui import commands import beets @@ -116,9 +115,9 @@ def album(lib=None): # Dummy import session. -def import_session(lib=None, logfile=None, paths=[], query=[], cli=False): +def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): cls = commands.TerminalImportSession if cli else importer.ImportSession - return cls(lib, logfile, paths, query) + return cls(lib, loghandler, paths, query) # A test harness for all beets tests. diff --git a/test/helper.py b/test/helper.py index 459e643a0..af63c18a4 100644 --- a/test/helper.py +++ b/test/helper.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -35,13 +35,13 @@ import os import os.path import shutil import subprocess -import logging from tempfile import mkdtemp, mkstemp from contextlib import contextmanager from StringIO import StringIO from enum import Enum import beets +from beets import logging from beets import config import beets.plugins from beets.library import Library, Item, Album @@ -255,7 +255,7 @@ class TestHelper(object): config['import']['autotag'] = False config['import']['resume'] = False - return TestImportSession(self.lib, logfile=None, query=None, + return TestImportSession(self.lib, loghandler=None, query=None, paths=[import_dir]) # Library fixtures methods diff --git a/test/lyrics_download_samples.py b/test/lyrics_download_samples.py index 34f71c0d1..819d6f9f7 100644 --- a/test/lyrics_download_samples.py +++ b/test/lyrics_download_samples.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte +# Copyright 2015, Fabrice Laporte # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_art.py b/test/test_art.py index 22ead0c04..02e589a3a 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -26,9 +26,19 @@ from beets.autotag import AlbumInfo, AlbumMatch from beets import library from beets import importer from beets import config +from beets import logging -class FetchImageTest(_common.TestCase): +logger = logging.getLogger('beets.test_art') + + +class UseThePlugin(_common.TestCase): + def setUp(self): + super(UseThePlugin, self).setUp() + self.plugin = fetchart.FetchArtPlugin() + + +class FetchImageTest(UseThePlugin): @responses.activate def run(self, *args, **kwargs): super(FetchImageTest, self).run(*args, **kwargs) @@ -39,12 +49,12 @@ class FetchImageTest(_common.TestCase): def test_invalid_type_returns_none(self): self.mock_response('image/watercolour') - artpath = fetchart._fetch_image('http://example.com') + artpath = self.plugin._fetch_image('http://example.com') self.assertEqual(artpath, None) def test_jpeg_type_returns_path(self): self.mock_response('image/jpeg') - artpath = fetchart._fetch_image('http://example.com') + artpath = self.plugin._fetch_image('http://example.com') self.assertNotEqual(artpath, None) @@ -54,41 +64,42 @@ class FSArtTest(_common.TestCase): self.dpath = os.path.join(self.temp_dir, 'arttest') os.mkdir(self.dpath) + self.source = fetchart.FileSystem(logger) + def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) - fn = fetchart.art_in_path(self.dpath, ('art',), False) + fn = self.source.get(self.dpath, ('art',), False) self.assertEqual(fn, os.path.join(self.dpath, 'a.jpg')) def test_appropriately_named_file_takes_precedence(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) _common.touch(os.path.join(self.dpath, 'art.jpg')) - fn = fetchart.art_in_path(self.dpath, ('art',), False) + fn = self.source.get(self.dpath, ('art',), False) self.assertEqual(fn, os.path.join(self.dpath, 'art.jpg')) def test_non_image_file_not_identified(self): _common.touch(os.path.join(self.dpath, 'a.txt')) - fn = fetchart.art_in_path(self.dpath, ('art',), False) + fn = self.source.get(self.dpath, ('art',), False) self.assertEqual(fn, None) def test_cautious_skips_fallback(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) - fn = fetchart.art_in_path(self.dpath, ('art',), True) + fn = self.source.get(self.dpath, ('art',), True) self.assertEqual(fn, None) def test_empty_dir(self): - fn = fetchart.art_in_path(self.dpath, ('art',), True) + fn = self.source.get(self.dpath, ('art',), True) self.assertEqual(fn, None) def test_precedence_amongst_correct_files(self): _common.touch(os.path.join(self.dpath, 'back.jpg')) _common.touch(os.path.join(self.dpath, 'front.jpg')) _common.touch(os.path.join(self.dpath, 'front-cover.jpg')) - fn = fetchart.art_in_path(self.dpath, - ('cover', 'front', 'back'), False) + fn = self.source.get(self.dpath, ('cover', 'front', 'back'), False) self.assertEqual(fn, os.path.join(self.dpath, 'front-cover.jpg')) -class CombinedTest(_common.TestCase): +class CombinedTest(UseThePlugin): ASIN = 'xxxx' MBID = 'releaseid' AMAZON_URL = 'http://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \ @@ -103,9 +114,6 @@ class CombinedTest(_common.TestCase): self.dpath = os.path.join(self.temp_dir, 'arttest') os.mkdir(self.dpath) - # Set up configuration. - self.plugin = fetchart.FetchArtPlugin() - @responses.activate def run(self, *args, **kwargs): super(CombinedTest, self).run(*args, **kwargs) @@ -116,61 +124,61 @@ class CombinedTest(_common.TestCase): def test_main_interface_returns_amazon_art(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) - artpath = fetchart.art_for_album(album, None) + artpath = self.plugin.art_for_album(album, None) self.assertNotEqual(artpath, None) def test_main_interface_returns_none_for_missing_asin_and_path(self): album = _common.Bag() - artpath = fetchart.art_for_album(album, None) + artpath = self.plugin.art_for_album(album, None) self.assertEqual(artpath, None) def test_main_interface_gives_precedence_to_fs_art(self): _common.touch(os.path.join(self.dpath, 'art.jpg')) self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) - artpath = fetchart.art_for_album(album, [self.dpath]) + artpath = self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(artpath, os.path.join(self.dpath, 'art.jpg')) def test_main_interface_falls_back_to_amazon(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) - artpath = fetchart.art_for_album(album, [self.dpath]) + artpath = self.plugin.art_for_album(album, [self.dpath]) self.assertNotEqual(artpath, None) self.assertFalse(artpath.startswith(self.dpath)) def test_main_interface_tries_amazon_before_aao(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) - fetchart.art_for_album(album, [self.dpath]) + self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(len(responses.calls), 1) self.assertEqual(responses.calls[0].request.url, self.AMAZON_URL) def test_main_interface_falls_back_to_aao(self): self.mock_response(self.AMAZON_URL, content_type='text/html') album = _common.Bag(asin=self.ASIN) - fetchart.art_for_album(album, [self.dpath]) + self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(responses.calls[-1].request.url, self.AAO_URL) def test_main_interface_uses_caa_when_mbid_available(self): self.mock_response(self.CAA_URL) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) - artpath = fetchart.art_for_album(album, None) + artpath = self.plugin.art_for_album(album, None) self.assertNotEqual(artpath, None) self.assertEqual(len(responses.calls), 1) self.assertEqual(responses.calls[0].request.url, self.CAA_URL) def test_local_only_does_not_access_network(self): album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) - artpath = fetchart.art_for_album(album, [self.dpath], - local_only=True) + artpath = self.plugin.art_for_album(album, [self.dpath], + local_only=True) self.assertEqual(artpath, None) self.assertEqual(len(responses.calls), 0) def test_local_only_gets_fs_image(self): _common.touch(os.path.join(self.dpath, 'art.jpg')) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) - artpath = fetchart.art_for_album(album, [self.dpath], - None, local_only=True) + artpath = self.plugin.art_for_album(album, [self.dpath], + local_only=True) self.assertEqual(artpath, os.path.join(self.dpath, 'art.jpg')) self.assertEqual(len(responses.calls), 0) @@ -179,6 +187,10 @@ class AAOTest(_common.TestCase): ASIN = 'xxxx' AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}'.format(ASIN) + def setUp(self): + super(AAOTest, self).setUp() + self.source = fetchart.AlbumArtOrg(logger) + @responses.activate def run(self, *args, **kwargs): super(AAOTest, self).run(*args, **kwargs) @@ -197,13 +209,13 @@ class AAOTest(_common.TestCase): """ self.mock_response(self.AAO_URL, body) album = _common.Bag(asin=self.ASIN) - res = fetchart.aao_art(album) + res = self.source.get(album) self.assertEqual(list(res)[0], 'TARGET_URL') def test_aao_scraper_returns_no_result_when_no_image_present(self): self.mock_response(self.AAO_URL, 'blah blah') album = _common.Bag(asin=self.ASIN) - res = fetchart.aao_art(album) + res = self.source.get(album) self.assertEqual(list(res), []) @@ -211,6 +223,10 @@ class GoogleImageTest(_common.TestCase): _google_url = 'https://ajax.googleapis.com/ajax/services/search/images' + def setUp(self): + super(GoogleImageTest, self).setUp() + self.source = fetchart.GoogleImages(logger) + @responses.activate def run(self, *args, **kwargs): super(GoogleImageTest, self).run(*args, **kwargs) @@ -224,31 +240,31 @@ class GoogleImageTest(_common.TestCase): json = """{"responseData": {"results": [{"unescapedUrl": "url_to_the_image"}]}}""" self.mock_response(self._google_url, json) - result_url = fetchart.google_art(album) + result_url = self.source.get(album) self.assertEqual(list(result_url)[0], 'url_to_the_image') def test_google_art_dont_finds_image(self): album = _common.Bag(albumartist="some artist", album="some album") json = """bla blup""" self.mock_response(self._google_url, json) - result_url = fetchart.google_art(album) + result_url = self.source.get(album) self.assertEqual(list(result_url), []) -class ArtImporterTest(_common.TestCase): +class ArtImporterTest(UseThePlugin): def setUp(self): super(ArtImporterTest, self).setUp() # Mock the album art fetcher to always return our test file. self.art_file = os.path.join(self.temp_dir, 'tmpcover.jpg') _common.touch(self.art_file) - self.old_afa = fetchart.art_for_album + self.old_afa = self.plugin.art_for_album self.afa_response = self.art_file - def art_for_album(i, p, maxwidth=None, local_only=False): + def art_for_album(i, p, local_only=False): return self.afa_response - fetchart.art_for_album = art_for_album + self.plugin.art_for_album = art_for_album # Test library. self.libpath = os.path.join(self.temp_dir, 'tmplib.blb') @@ -263,8 +279,7 @@ class ArtImporterTest(_common.TestCase): self.album = self.lib.add_album([self.i]) self.lib._connection().commit() - # The plugin and import configuration. - self.plugin = fetchart.FetchArtPlugin() + # The import configuration. self.session = _common.import_session(self.lib) # Import task for the coroutine. @@ -283,7 +298,7 @@ class ArtImporterTest(_common.TestCase): def tearDown(self): self.lib._connection().close() super(ArtImporterTest, self).tearDown() - fetchart.art_for_album = self.old_afa + self.plugin.art_for_album = self.old_afa def _fetch_art(self, should_exist): """Execute the fetch_art coroutine for the task and return the diff --git a/test/test_autotag.py b/test/test_autotag.py index 4599d6df2..3405351e6 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_bucket.py b/test/test_bucket.py index 060c360ad..38846e324 100644 --- a/test/test_bucket.py +++ b/test/test_bucket.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2014, Fabrice Laporte. +# Copyright 2015, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -15,7 +15,6 @@ """Tests for the 'bucket' plugin.""" -from nose.tools import raises from _common import unittest from beetsplug import bucket from beets import config, ui @@ -129,26 +128,24 @@ class BucketPluginTest(unittest.TestCase, TestHelper): self.assertEqual(self.plugin._tmpl_bucket('…and Oceans'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('Eagles'), 'E - L') - @raises(ui.UserError) def test_bad_alpha_range_def(self): - """If bad alpha range definition, a UserError is raised""" - self._setup_config(bucket_alpha=['$%']) - self.assertEqual(self.plugin._tmpl_bucket('errol'), 'E') + """If bad alpha range definition, a UserError is raised.""" + with self.assertRaises(ui.UserError): + self._setup_config(bucket_alpha=['$%']) - @raises(ui.UserError) def test_bad_year_range_def_no4digits(self): """If bad year range definition, a UserError is raised. - Range origin must be expressed on 4 digits.""" - self._setup_config(bucket_year=['62-64']) - # from year must be expressed on 4 digits - self.assertEqual(self.plugin._tmpl_bucket('1963'), '62-64') + Range origin must be expressed on 4 digits. + """ + with self.assertRaises(ui.UserError): + self._setup_config(bucket_year=['62-64']) - @raises(ui.UserError) def test_bad_year_range_def_nodigits(self): """If bad year range definition, a UserError is raised. - At least the range origin must be declared.""" - self._setup_config(bucket_year=['nodigits']) - self.assertEqual(self.plugin._tmpl_bucket('1963'), '62-64') + At least the range origin must be declared. + """ + with self.assertRaises(ui.UserError): + self._setup_config(bucket_year=['nodigits']) def suite(): diff --git a/test/test_convert.py b/test/test_convert.py index 05d643e3e..f3f8cbeff 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_datequery.py b/test/test_datequery.py index 61f6abe2e..06d857c0b 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_dbcore.py b/test/test_dbcore.py index c05dc1c5d..4882c4da5 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_echonest.py b/test/test_echonest.py index d845b8a6b..a92bd4086 100644 --- a/test/test_echonest.py +++ b/test/test_echonest.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes +# Copyright 2015, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_embedart.py b/test/test_embedart.py index 818c0ab91..d0314dc02 100644 --- a/test/test_embedart.py +++ b/test/test_embedart.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -18,7 +18,7 @@ from _common import unittest from helper import TestHelper, capture_log from beets.mediafile import MediaFile -from beets import config +from beets import config, logging from beets.util import syspath from beets.util.artresizer import ArtResizer @@ -76,9 +76,10 @@ class EmbedartCliTest(unittest.TestCase, TestHelper): def test_art_file_missing(self): self.add_album_fixture() - with capture_log() as logs: + logging.getLogger('beets.embedart').setLevel(logging.DEBUG) + with capture_log('beets.embedart') as logs: self.run_command('embedart', '-f', '/doesnotexist') - self.assertIn(u'embedart: could not read image file:', ''.join(logs)) + self.assertIn(u'could not read image file:', ''.join(logs)) @require_artresizer_compare def test_reject_different_art(self): diff --git a/test/test_fetchart.py b/test/test_fetchart.py index 5e36f9145..e5a15f46c 100644 --- a/test/test_fetchart.py +++ b/test/test_fetchart.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -import os.path +import os from _common import unittest from helper import TestHelper @@ -22,25 +22,31 @@ class FetchartCliTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('fetchart') + self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg' + self.config['art_filename'] = 'mycover' + self.album = self.add_album() def tearDown(self): self.unload_plugins() self.teardown_beets() def test_set_art_from_folder(self): - self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg' - self.config['art_filename'] = 'mycover' - album = self.add_album() - self.touch('c\xc3\xb6ver.jpg', dir=album.path, content='IMAGE') + self.touch('c\xc3\xb6ver.jpg', dir=self.album.path, content='IMAGE') self.run_command('fetchart') - cover_path = os.path.join(album.path, 'mycover.jpg') + cover_path = os.path.join(self.album.path, 'mycover.jpg') - album.load() - self.assertEqual(album['artpath'], cover_path) + self.album.load() + self.assertEqual(self.album['artpath'], cover_path) with open(cover_path, 'r') as f: self.assertEqual(f.read(), 'IMAGE') + def test_filesystem_does_not_pick_up_folder(self): + os.makedirs(os.path.join(self.album.path, 'mycover.jpg')) + self.run_command('fetchart') + self.album.load() + self.assertEqual(self.album['artpath'], None) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_files.py b/test/test_files.py index 272a33fe7..03daf92e3 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ftintitle.py b/test/test_ftintitle.py index 77e416c5a..6b2e43b33 100644 --- a/test/test_ftintitle.py +++ b/test/test_ftintitle.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte. +# Copyright 2015, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_importadded.py b/test/test_importadded.py index b228ee24c..11c541ffa 100644 --- a/test/test_importadded.py +++ b/test/test_importadded.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Stig Inge Lea Bjornsen. +# Copyright 2015, Stig Inge Lea Bjornsen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_importer.py b/test/test_importer.py index 89a65b74f..7eab84e3e 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -1,5 +1,6 @@ +# -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -33,6 +34,7 @@ from beets.mediafile import MediaFile from beets import autotag from beets.autotag import AlbumInfo, TrackInfo, AlbumMatch from beets import config +from beets import logging class AutotagStub(object): @@ -209,7 +211,7 @@ class ImportHelper(TestHelper): config['import']['link'] = link self.importer = TestImportSession( - self.lib, logfile=None, query=None, + self.lib, loghandler=None, query=None, paths=[import_dir or self.import_dir] ) @@ -1219,15 +1221,17 @@ class ImportDuplicateSingletonTest(unittest.TestCase, TestHelper): class TagLogTest(_common.TestCase): def test_tag_log_line(self): sio = StringIO.StringIO() - session = _common.import_session(logfile=sio) + handler = logging.StreamHandler(sio) + session = _common.import_session(loghandler=handler) session.tag_log('status', 'path') - assert 'status path' in sio.getvalue() + self.assertIn('status path', sio.getvalue()) def test_tag_log_unicode(self): sio = StringIO.StringIO() - session = _common.import_session(logfile=sio) - session.tag_log('status', 'caf\xc3\xa9') - assert 'status caf' in sio.getvalue() + handler = logging.StreamHandler(sio) + session = _common.import_session(loghandler=handler) + session.tag_log('status', u'café') # send unicode + self.assertIn(u'status café', sio.getvalue()) class ResumeImportTest(unittest.TestCase, TestHelper): diff --git a/test/test_importfeeds.py b/test/test_importfeeds.py index bee7fa28a..f9646c175 100644 --- a/test/test_importfeeds.py +++ b/test/test_importfeeds.py @@ -6,7 +6,7 @@ import shutil from _common import unittest from beets import config from beets.library import Item, Album, Library -from beetsplug.importfeeds import album_imported, ImportFeedsPlugin +from beetsplug.importfeeds import ImportFeedsPlugin class ImportfeedsTestTest(unittest.TestCase): @@ -30,7 +30,7 @@ class ImportfeedsTestTest(unittest.TestCase): self.lib.add(album) self.lib.add(item) - album_imported(self.lib, album) + self.importfeeds.album_imported(self.lib, album) playlist_path = os.path.join(self.feeds_dir, os.listdir(self.feeds_dir)[0]) self.assertTrue(playlist_path.endswith('album_name.m3u')) diff --git a/test/test_info.py b/test/test_info.py index 9c641e501..15f5936ce 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_keyfinder.py b/test/test_keyfinder.py index 5795002dc..a8428cbb3 100644 --- a/test/test_keyfinder.py +++ b/test/test_keyfinder.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_lastgenre.py b/test/test_lastgenre.py index 9aaace237..3ac96b279 100644 --- a/test/test_lastgenre.py +++ b/test/test_lastgenre.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte. +# Copyright 2015, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -157,9 +157,10 @@ class LastGenrePluginTest(unittest.TestCase, TestHelper): tag2.item = MockPylastElem(u'Rap') return [tag1, tag2] - res = lastgenre._tags_for(MockPylastObj()) + plugin = lastgenre.LastGenrePlugin() + res = plugin._tags_for(MockPylastObj()) self.assertEqual(res, [u'pop', u'rap']) - res = lastgenre._tags_for(MockPylastObj(), min_weight=50) + res = plugin._tags_for(MockPylastObj(), min_weight=50) self.assertEqual(res, [u'pop']) def test_get_genre(self): diff --git a/test/test_library.py b/test/test_library.py index f4bdb15d6..717cc1aa1 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_logging.py b/test/test_logging.py new file mode 100644 index 000000000..0d2eb7291 --- /dev/null +++ b/test/test_logging.py @@ -0,0 +1,42 @@ +"""Stupid tests that ensure logging works as expected""" +import logging as log +from StringIO import StringIO + +import beets.logging as blog +from _common import unittest, TestCase + + +class LoggingTest(TestCase): + def test_logging_management(self): + l1 = log.getLogger("foo123") + l2 = blog.getLogger("foo123") + self.assertEqual(l1, l2) + self.assertEqual(l1.__class__, log.Logger) + + l3 = blog.getLogger("bar123") + l4 = log.getLogger("bar123") + self.assertEqual(l3, l4) + self.assertEqual(l3.__class__, blog.StrFormatLogger) + + l5 = l3.getChild("shalala") + self.assertEqual(l5.__class__, blog.StrFormatLogger) + + def test_str_format_logging(self): + l = blog.getLogger("baz123") + stream = StringIO() + handler = log.StreamHandler(stream) + + l.addHandler(handler) + l.propagate = False + + l.warning("foo {0} {bar}", "oof", bar="baz") + handler.flush() + self.assertTrue(stream.getvalue(), "foo oof baz") + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/test/test_lyrics.py b/test/test_lyrics.py index 492229630..21eb87256 100644 --- a/test/test_lyrics.py +++ b/test/test_lyrics.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Fabrice Laporte. +# Copyright 2015, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -22,6 +22,11 @@ from _common import unittest from beetsplug import lyrics from beets.library import Item from beets.util import confit +from beets import logging + +log = logging.getLogger('beets.test_lyrics') +raw_backend = lyrics.Backend(log) +google = lyrics.Google(log) class LyricsPluginTest(unittest.TestCase): @@ -128,11 +133,12 @@ class LyricsPluginTest(unittest.TestCase): texts += ["""All material found on this site is property\n of mywickedsongtext brand"""] for t in texts: - self.assertFalse(lyrics.is_lyrics(t)) + self.assertFalse(google.is_lyrics(t)) def test_slugify(self): text = u"http://site.com/\xe7afe-au_lait(boisson)" - self.assertEqual(lyrics.slugify(text), 'http://site.com/cafe_au_lait') + self.assertEqual(google.slugify(text), + 'http://site.com/cafe_au_lait') def test_scrape_strip_cruft(self): text = u""" @@ -160,7 +166,7 @@ class LyricsPluginTest(unittest.TestCase): "one\ntwo\nthree") def test_missing_lyrics(self): - self.assertFalse(lyrics.is_lyrics(LYRICS_TEXTS['missing_texts'])) + self.assertFalse(google.is_lyrics(LYRICS_TEXTS['missing_texts'])) def url_to_filename(url): @@ -196,7 +202,7 @@ class MockFetchUrl(object): def is_lyrics_content_ok(title, text): """Compare lyrics text to expected lyrics for given title""" - keywords = LYRICS_TEXTS[lyrics.slugify(title)] + keywords = LYRICS_TEXTS[google.slugify(title)] return all(x in text.lower() for x in keywords) LYRICS_ROOT_DIR = os.path.join(_common.RSRC, 'lyrics') @@ -298,14 +304,14 @@ class LyricsGooglePluginTest(unittest.TestCase): if sys.version_info[:3] < (2, 7, 3): self.skipTest("Python's built-in HTML parser is not good enough") lyrics.LyricsPlugin() - lyrics.fetch_url = MockFetchUrl() + raw_backend.fetch_url = MockFetchUrl() def test_mocked_source_ok(self): """Test that lyrics of the mocked page are correctly scraped""" url = self.source['url'] + self.source['path'] if os.path.isfile(url_to_filename(url)): - res = lyrics.scrape_lyrics_from_html(lyrics.fetch_url(url)) - self.assertTrue(lyrics.is_lyrics(res), url) + res = lyrics.scrape_lyrics_from_html(raw_backend.fetch_url(url)) + self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(self.source['title'], res), url) @@ -317,21 +323,22 @@ class LyricsGooglePluginTest(unittest.TestCase): for s in GOOGLE_SOURCES: url = s['url'] + s['path'] if os.path.isfile(url_to_filename(url)): - res = lyrics.scrape_lyrics_from_html(lyrics.fetch_url(url)) - self.assertTrue(lyrics.is_lyrics(res), url) + res = lyrics.scrape_lyrics_from_html( + raw_backend.fetch_url(url)) + self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(s['title'], res), url) def test_default_ok(self): """Test default engines with the default query""" if not check_lyrics_fetched(): self.skipTest("Run lyrics_download_samples.py script first.") - for (fun, s) in zip([lyrics.fetch_lyricswiki, - lyrics.fetch_lyricscom, - lyrics.fetch_musixmatch], DEFAULT_SOURCES): + for (source, s) in zip([lyrics.LyricsWiki, + lyrics.LyricsCom, + lyrics.MusiXmatch], DEFAULT_SOURCES): url = s['url'] + s['path'] if os.path.isfile(url_to_filename(url)): - res = fun(s['artist'], s['title']) - self.assertTrue(lyrics.is_lyrics(res), url) + res = source(log).fetch(s['artist'], s['title']) + self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(s['title'], res), url) def test_is_page_candidate_exact_match(self): @@ -340,10 +347,10 @@ class LyricsGooglePluginTest(unittest.TestCase): from bs4 import SoupStrainer, BeautifulSoup s = self.source url = unicode(s['url'] + s['path']) - html = lyrics.fetch_url(url) + html = raw_backend.fetch_url(url) soup = BeautifulSoup(html, "html.parser", parse_only=SoupStrainer('title')) - self.assertEqual(lyrics.is_page_candidate(url, soup.title.string, + self.assertEqual(google.is_page_candidate(url, soup.title.string, s['title'], s['artist']), True, url) @@ -355,11 +362,11 @@ class LyricsGooglePluginTest(unittest.TestCase): urlTitle = u'example.com | Beats song by John doe' # very small diffs (typo) are ok eg 'beats' vs 'beets' with same artist - self.assertEqual(lyrics.is_page_candidate(url, urlTitle, s['title'], + self.assertEqual(google.is_page_candidate(url, urlTitle, s['title'], s['artist']), True, url) # reject different title urlTitle = u'example.com | seets bong lyrics by John doe' - self.assertEqual(lyrics.is_page_candidate(url, urlTitle, s['title'], + self.assertEqual(google.is_page_candidate(url, urlTitle, s['title'], s['artist']), False, url) diff --git a/test/test_mb.py b/test/test_mb.py index f41ec5510..2e5cd1aa3 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mbsync.py b/test/test_mbsync.py index 2a064a311..8dddf60c0 100644 --- a/test/test_mbsync.py +++ b/test/test_mbsync.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 722b74484..c685cdd94 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mediafile_edge.py b/test/test_mediafile_edge.py index 81b689fbc..fa12d1dda 100644 --- a/test/test_mediafile_edge.py +++ b/test/test_mediafile_edge.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 0c4de6836..917983a91 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_player.py b/test/test_player.py index 966b98dac..147026963 100644 --- a/test/test_player.py +++ b/test/test_player.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_plugins.py b/test/test_plugins.py index 430f90bcd..3771828b7 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -256,6 +256,41 @@ class HelpersTest(unittest.TestCase): ('A', 'B', 'C', 'D')), ['D', 'B', 'C', 'A']) +class ListenersTest(unittest.TestCase, TestHelper): + def setUp(self): + self.setup_plugin_loader() + + def tearDown(self): + self.teardown_plugin_loader() + self.teardown_beets() + + def test_register(self): + + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + super(DummyPlugin, self).__init__() + self.register_listener('cli_exit', self.dummy) + self.register_listener('cli_exit', self.dummy) + + def dummy(self): + pass + + d = DummyPlugin() + self.assertEqual(DummyPlugin.listeners['cli_exit'], [d.dummy]) + + d2 = DummyPlugin() + DummyPlugin.register_listener('cli_exit', d.dummy) + self.assertEqual(DummyPlugin.listeners['cli_exit'], + [d.dummy, d2.dummy]) + + @DummyPlugin.listen('cli_exit') + def dummy(lib): + pass + + self.assertEqual(DummyPlugin.listeners['cli_exit'], + [d.dummy, d2.dummy, dummy]) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_query.py b/test/test_query.py index f2ad3cb6e..879e9ca7d 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_replaygain.py b/test/test_replaygain.py index bad779c69..aa0d19b23 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Thomas Scholtes +# Copyright 2015, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_sort.py b/test/test_sort.py index be5706bca..f0b56d3ca 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_template.py b/test/test_template.py index f14aed019..1bc0b2cd7 100644 --- a/test/test_template.py +++ b/test/test_template.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_types_plugin.py b/test/test_types_plugin.py index d175525be..697cda702 100644 --- a/test/test_types_plugin.py +++ b/test/test_types_plugin.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Thomas Scholtes. +# Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ui.py b/test/test_ui.py index 4ef5a9fcf..e32f9ed83 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2014, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ui_importer.py b/test/test_ui_importer.py index 3a8b2696c..0e3599301 100644 --- a/test/test_ui_importer.py +++ b/test/test_ui_importer.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -91,7 +91,7 @@ class TerminalImportSessionSetup(object): self.io = DummyIO() self.io.install() self.importer = TestTerminalImportSession( - self.lib, logfile=None, query=None, io=self.io, + self.lib, loghandler=None, query=None, io=self.io, paths=[import_dir or self.import_dir], ) diff --git a/test/test_vfs.py b/test/test_vfs.py index e31640aa2..ae8e7aef6 100644 --- a/test/test_vfs.py +++ b/test/test_vfs.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/testall.py b/test/testall.py index 70bf44f87..5fe8c5536 100755 --- a/test/testall.py +++ b/test/testall.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # This file is part of beets. -# Copyright 2013, Adrian Sampson. +# Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the