Merge branch 'master' into import-filefilter

Conflicts:
	beets/importer.py
	beets/plugins.py
	beetsplug/ihate.py
This commit is contained in:
Malte Ried 2015-01-14 10:46:03 +01:00
commit ad65242ebd
117 changed files with 2558 additions and 2480 deletions

View file

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

2
beet
View file

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

View file

@ -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 <adrian@radbox.org>'
import beets.library

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

107
beets/logging.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2013, Verrus, <github.com/Verrus/beets-plugin-featInTitle>
# Copyright 2015, Verrus, <github.com/Verrus/beets-plugin-featInTitle>
#
# 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')

View file

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

View file

@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2014, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
# Copyright 2015, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
# 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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -159,7 +159,9 @@
- comedy:
- comedy music
- comedy rock
- humor
- parody music
- stand-up
- country:
- alternative country:
- cowpunk

View file

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

View file

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

View file

@ -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"<div class='lyricbox'>")
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, '<div id="lyrics" class="SCREENONLY" '
'itemprop="description">', '</div>')
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"<div class='lyricbox'>")
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, '<div id="lyrics" class="SCREENO'
'NLY" itemprop="description">', '</div>')
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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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):
<http://audiotools.sourceforge.net/>`_ 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)

View file

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

View file

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

View file

@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2013, Dang Mai <contact@dangmai.net>.
# Copyright 2015, Dang Mai <contact@dangmai.net>.
#
# 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))

View file

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

View file

@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2013, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
# Copyright 2015, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
# 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''

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2013, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
# Copyright 2015, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
# 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

42
test/test_logging.py Normal file
View file

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

Some files were not shown because too many files have changed in this diff Show more