Update multiple plugins: pass the logger around

This commit is contained in:
Bruno Cauet 2015-01-06 11:25:51 +01:00
parent 7d58a38428
commit 32673b87e7
10 changed files with 289 additions and 322 deletions

View file

@ -16,13 +16,11 @@
""" """
import shlex import shlex
from beets import logging
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_obj, vararg_callback, Subcommand, UserError from beets.ui import decargs, print_obj, vararg_callback, Subcommand, UserError
from beets.util import command_output, displayable_path, subprocess from beets.util import command_output, displayable_path, subprocess
PLUGIN = 'duplicates' PLUGIN = 'duplicates'
log = logging.getLogger(__name__)
def _process_item(item, lib, copy=False, move=False, delete=False, 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) 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 """Run external `prog` on file path associated with `item`, cache
output as flexattr on a key that is the name of the program, and output as flexattr on a key that is the name of the program, and
return the key, checksum tuple. return the key, checksum tuple.
@ -73,7 +71,7 @@ def _checksum(item, prog):
return key, checksum return key, checksum
def _group_by(objs, keys): def _group_by(objs, keys, log):
"""Return a dictionary with keys arbitrary concatenations of attributes and """Return a dictionary with keys arbitrary concatenations of attributes and
values lists of objects (Albums or Items) with those keys. values lists of objects (Albums or Items) with those keys.
""" """
@ -92,11 +90,11 @@ def _group_by(objs, keys):
return counts return counts
def _duplicates(objs, keys, full): def _duplicates(objs, keys, full, log):
"""Generate triples of keys, duplicate counts, and constituent objects. """Generate triples of keys, duplicate counts, and constituent objects.
""" """
offset = 0 if full else 1 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: if len(objs) > 1:
yield (k, len(objs) - offset, objs[offset:]) yield (k, len(objs) - offset, objs[offset:])
@ -214,12 +212,13 @@ class DuplicatesPlugin(BeetsPlugin):
'duplicates: "checksum" option must be a command' 'duplicates: "checksum" option must be a command'
) )
for i in items: for i in items:
k, _ = _checksum(i, checksum) k, _ = self._checksum(i, checksum, self._log)
keys = [k] keys = [k]
for obj_id, obj_count, objs in _duplicates(items, for obj_id, obj_count, objs in _duplicates(items,
keys=keys, keys=keys,
full=full): full=full,
log=self._log):
if obj_id: # Skip empty IDs. if obj_id: # Skip empty IDs.
for o in objs: for o in objs:
_process_item(o, lib, _process_item(o, lib,

View file

@ -20,9 +20,6 @@ from beets import plugins
from beets import ui from beets import ui
from beets.util import displayable_path from beets.util import displayable_path
from beets import config from beets import config
from beets import logging
log = logging.getLogger(__name__)
def split_on_feat(artist): def split_on_feat(artist):
@ -46,69 +43,6 @@ def contains_feat(title):
return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE)) return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE))
def update_metadata(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.
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)
log.info(u'title: {0} -> {1}', item.title, new_title)
item.title = new_title
def ft_in_title(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:
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:
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:
update_metadata(item, feat_part, drop_feat)
else:
log.info(u'no featuring artists found')
class FtInTitlePlugin(plugins.BeetsPlugin): class FtInTitlePlugin(plugins.BeetsPlugin):
def __init__(self): def __init__(self):
super(FtInTitlePlugin, self).__init__() super(FtInTitlePlugin, self).__init__()
@ -138,7 +72,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
write = config['import']['write'].get(bool) write = config['import']['write'].get(bool)
for item in lib.items(ui.decargs(args)): for item in lib.items(ui.decargs(args)):
ft_in_title(item, drop_feat) self.ft_in_title(item, drop_feat)
item.store() item.store()
if write: if write:
item.try_write() item.try_write()
@ -152,5 +86,66 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
drop_feat = self.config['drop'].get(bool) drop_feat = self.config['drop'].get(bool)
for item in task.imported_items(): for item in task.imported_items():
ft_in_title(item, drop_feat) self.ft_in_title(item, drop_feat)
item.store() 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

@ -17,56 +17,12 @@
import os import os
from beets import logging
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets import ui from beets import ui
from beets import mediafile from beets import mediafile
from beets.util import displayable_path, normpath, syspath from beets.util import displayable_path, normpath, syspath
log = logging.getLogger(__name__)
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}', 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): def tag_data(lib, args):
query = [] query = []
for arg in args: for arg in args:
@ -143,9 +99,48 @@ class InfoPlugin(BeetsPlugin):
def commands(self): def commands(self):
cmd = ui.Subcommand('info', help='show file metadata') cmd = ui.Subcommand('info', help='show file metadata')
cmd.func = run cmd.func = self.run
cmd.parser.add_option('-l', '--library', action='store_true', cmd.parser.add_option('-l', '--library', action='store_true',
help='show library fields instead of tags') help='show library fields instead of tags')
cmd.parser.add_option('-s', '--summarize', action='store_true', cmd.parser.add_option('-s', '--summarize', action='store_true',
help='summarize the tags of all files') help='summarize the tags of all files')
return [cmd] 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.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)

View file

@ -18,9 +18,7 @@ import traceback
import itertools import itertools
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets import config, logging from beets import config
log = logging.getLogger(__name__)
FUNC_NAME = u'__INLINE_FUNC__' FUNC_NAME = u'__INLINE_FUNC__'
@ -49,55 +47,6 @@ def _compile_func(body):
return env[FUNC_NAME] 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}',
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): class InlinePlugin(BeetsPlugin):
def __init__(self): def __init__(self):
super(InlinePlugin, self).__init__() super(InlinePlugin, self).__init__()
@ -112,13 +61,61 @@ class InlinePlugin(BeetsPlugin):
for key, view in itertools.chain(config['item_fields'].items(), for key, view in itertools.chain(config['item_fields'].items(),
config['pathfields'].items()): config['pathfields'].items()):
self._log.debug(u'adding item field {0}', key) self._log.debug(u'adding item field {0}', key)
func = compile_inline(view.get(unicode), False) func = self.compile_inline(view.get(unicode), False)
if func is not None: if func is not None:
self.template_fields[key] = func self.template_fields[key] = func
# Album fields. # Album fields.
for key, view in config['album_fields'].items(): for key, view in config['album_fields'].items():
self._log.debug(u'adding album field {0}', key) self._log.debug(u'adding album field {0}', key)
func = compile_inline(view.get(unicode), True) func = self.compile_inline(view.get(unicode), True)
if func is not None: if func is not None:
self.album_template_fields[key] = func 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

@ -24,14 +24,12 @@ import pylast
import os import os
import yaml import yaml
from beets import logging
from beets import plugins from beets import plugins
from beets import ui from beets import ui
from beets.util import normpath, plurality from beets.util import normpath, plurality
from beets import config from beets import config
from beets import library from beets import library
log = logging.getLogger(__name__)
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) 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)] 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}', 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. # Canonicalization tree processing.
def flatten_tree(elem, path, branches): def flatten_tree(elem, path, branches):
"""Flatten nested lists/dictionaries into lists of strings """Flatten nested lists/dictionaries into lists of strings
(branches). (branches).
@ -225,7 +191,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
can be found. Ex. 'Electronic, House, Dance' can be found. Ex. 'Electronic, House, Dance'
""" """
min_weight = self.config['min_weight'].get(int) 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): def _is_allowed(self, genre):
"""Determine whether the genre is present in the whitelist, """Determine whether the genre is present in the whitelist,
@ -371,8 +337,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
for album in lib.albums(ui.decargs(args)): for album in lib.albums(ui.decargs(args)):
album.genre, src = self._get_genre(album) album.genre, src = self._get_genre(album)
log.info(u'genre for album {0} - {1} ({2}): {3}', self._log.info(u'genre for album {0.albumartist} - {0.album} '
album.albumartist, album.album, src, album.genre) u'({1}): {0.genre}', album, src)
album.store() album.store()
for item in album.items(): for item in album.items():
@ -381,8 +347,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if 'track' in self.sources: if 'track' in self.sources:
item.genre, src = self._get_genre(item) item.genre, src = self._get_genre(item)
item.store() item.store()
log.info(u'genre for track {0} - {1} ({2}): {3}', self._log.info(u'genre for track {0.artist} - {0.tit'
item.artist, item.title, src, item.genre) u'le} ({1}): {0.genre}', item, src)
if write: if write:
item.try_write() item.try_write()
@ -395,20 +361,50 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if task.is_album: if task.is_album:
album = task.album album = task.album
album.genre, src = self._get_genre(album) album.genre, src = self._get_genre(album)
log.debug(u'added last.fm album genre ({0}): {1}', self._log.debug(u'added last.fm album genre ({0}): {1}',
src, album.genre) src, album.genre)
album.store() album.store()
if 'track' in self.sources: if 'track' in self.sources:
for item in album.items(): for item in album.items():
item.genre, src = self._get_genre(item) item.genre, src = self._get_genre(item)
log.debug(u'added last.fm item genre ({0}): {1}', self._log.debug(u'added last.fm item genre ({0}): {1}',
src, item.genre) src, item.genre)
item.store() item.store()
else: else:
item = task.item item = task.item
item.genre, src = self._get_genre(item) item.genre, src = self._get_genre(item)
log.debug(u'added last.fm item genre ({0}): {1}', self._log.debug(u'added last.fm item genre ({0}): {1}',
src, item.genre) src, item.genre)
item.store() 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

@ -17,10 +17,8 @@ from beets import ui
from beets import dbcore from beets import dbcore
from beets import config from beets import config
from beets import plugins from beets import plugins
from beets import logging
from beets.dbcore import types from beets.dbcore import types
log = logging.getLogger(__name__)
API_URL = 'http://ws.audioscrobbler.com/2.0/' API_URL = 'http://ws.audioscrobbler.com/2.0/'
@ -43,13 +41,13 @@ class LastImportPlugin(plugins.BeetsPlugin):
cmd = ui.Subcommand('lastimport', help='import last.fm play-count') cmd = ui.Subcommand('lastimport', help='import last.fm play-count')
def func(lib, opts, args): def func(lib, opts, args):
import_lastfm(lib) import_lastfm(lib, self._log)
cmd.func = func cmd.func = func
return [cmd] return [cmd]
def import_lastfm(lib): def import_lastfm(lib, log):
user = config['lastfm']['user'] user = config['lastfm']['user']
per_page = config['lastimport']['per_page'] per_page = config['lastimport']['per_page']
@ -78,7 +76,8 @@ def import_lastfm(lib):
# It means nothing to us! # It means nothing to us!
raise ui.UserError('Last.fm reported no data.') 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 found_total += found
unknown_total += unknown unknown_total += unknown
break break
@ -112,7 +111,7 @@ def fetch_tracks(user, page, limit):
}).json() }).json()
def process_tracks(lib, tracks): def process_tracks(lib, tracks, log):
total = len(tracks) total = len(tracks)
total_found = 0 total_found = 0
total_fails = 0 total_fails = 0

View file

@ -16,7 +16,6 @@ from __future__ import print_function
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.ui import Subcommand from beets.ui import Subcommand
from beets import logging
from beets import ui from beets import ui
from beets import config from beets import config
import musicbrainzngs import musicbrainzngs
@ -26,8 +25,6 @@ import re
SUBMISSION_CHUNK_SIZE = 200 SUBMISSION_CHUNK_SIZE = 200
UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$' UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$'
log = logging.getLogger(__name__)
def mb_call(func, *args, **kwargs): def mb_call(func, *args, **kwargs):
"""Call a MusicBrainz API function and catch exceptions. """Call a MusicBrainz API function and catch exceptions.
@ -54,48 +51,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}', 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): class MusicBrainzCollectionPlugin(BeetsPlugin):
def __init__(self): def __init__(self):
super(MusicBrainzCollectionPlugin, self).__init__() super(MusicBrainzCollectionPlugin, self).__init__()
@ -108,10 +63,47 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
self._import_stages = [self.imported] self._import_stages = [self.imported]
def commands(self): 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): def imported(self, session, task):
"""Add each imported album to the collection. """Add each imported album to the collection.
""" """
if task.is_album: 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.
print('Updating MusicBrainz collection {0}...'.format(collection_id))
submit_albums(collection_id, album_ids)
print('...MusicBrainz collection updated.')

View file

@ -14,14 +14,11 @@
"""List missing tracks. """List missing tracks.
""" """
from beets import logging
from beets.autotag import hooks from beets.autotag import hooks
from beets.library import Item from beets.library import Item
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_obj, Subcommand from beets.ui import decargs, print_obj, Subcommand
log = logging.getLogger(__name__)
def _missing_count(album): def _missing_count(album):
"""Return number of missing items in `album`. """Return number of missing items in `album`.
@ -29,23 +26,6 @@ def _missing_count(album):
return (album.tracktotal or 0) - len(album.items()) 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'track {1} in album {2}',
track_info.track_id, album_info.album_id)
yield item
def _item(track_info, album_info, album_id): def _item(track_info, album_info, album_id):
"""Build and return `item` from `track_info` and `album info` """Build and return `item` from `track_info` and `album info`
objects. `item` is missing what fields cannot be obtained from objects. `item` is missing what fields cannot be obtained from
@ -148,8 +128,24 @@ class MissingPlugin(BeetsPlugin):
print_obj(album, lib, fmt=fmt) print_obj(album, lib, fmt=fmt)
else: else:
for item in _missing(album): for item in self._missing(album):
print_obj(item, lib, fmt=fmt) print_obj(item, lib, fmt=fmt)
self._command.func = _miss self._command.func = _miss
return [self._command] 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

@ -19,7 +19,6 @@ import select
import time import time
import os import os
from beets import logging
from beets import ui from beets import ui
from beets import config from beets import config
from beets import plugins from beets import plugins
@ -27,8 +26,6 @@ from beets import library
from beets.util import displayable_path from beets.util import displayable_path
from beets.dbcore import types from beets.dbcore import types
log = logging.getLogger(__name__)
# If we lose the connection, how many times do we want to retry and how # If we lose the connection, how many times do we want to retry and how
# much time should we wait between retries? # much time should we wait between retries?
RETRIES = 10 RETRIES = 10
@ -56,7 +53,9 @@ class MPDClient(mpd.MPDClient):
class MPDClientWrapper(object): class MPDClientWrapper(object):
def __init__(self): def __init__(self, log):
self._log = log
self.music_directory = ( self.music_directory = (
config['mpdstats']['music_directory'].get(unicode)) config['mpdstats']['music_directory'].get(unicode))
@ -71,7 +70,7 @@ class MPDClientWrapper(object):
if host[0] in ['/', '~']: if host[0] in ['/', '~']:
host = os.path.expanduser(host) host = os.path.expanduser(host)
log.info(u'connecting to {0}:{1}', host, port) self._log.info(u'connecting to {0}:{1}', host, port)
try: try:
self.client.connect(host, port) self.client.connect(host, port)
except socket.error as e: except socket.error as e:
@ -99,7 +98,7 @@ class MPDClientWrapper(object):
try: try:
return getattr(self.client, command)() return getattr(self.client, command)()
except (select.error, mpd.ConnectionError) as err: except (select.error, mpd.ConnectionError) as err:
log.error(u'{0}', err) self._log.error(u'{0}', err)
if retries <= 0: if retries <= 0:
# if we exited without breaking, we couldn't reconnect in time :( # if we exited without breaking, we couldn't reconnect in time :(
@ -141,15 +140,16 @@ class MPDClientWrapper(object):
class MPDStats(object): class MPDStats(object):
def __init__(self, lib): def __init__(self, lib, log):
self.lib = lib self.lib = lib
self._log = log
self.do_rating = config['mpdstats']['rating'].get(bool) self.do_rating = config['mpdstats']['rating'].get(bool)
self.rating_mix = config['mpdstats']['rating_mix'].get(float) self.rating_mix = config['mpdstats']['rating_mix'].get(float)
self.time_threshold = 10.0 # TODO: maybe add config option? self.time_threshold = 10.0 # TODO: maybe add config option?
self.now_playing = None self.now_playing = None
self.mpd = MPDClientWrapper() self.mpd = MPDClientWrapper(log)
def rating(self, play_count, skip_count, rating, skipped): def rating(self, play_count, skip_count, rating, skipped):
"""Calculate a new rating for a song based on play count, skip count, """Calculate a new rating for a song based on play count, skip count,
@ -171,10 +171,9 @@ class MPDStats(object):
if item: if item:
return item return item
else: else:
log.info(u'item not found: {0}', displayable_path(path)) self._log.info(u'item not found: {0}', displayable_path(path))
@staticmethod def update_item(self, item, attribute, value=None, increment=None):
def update_item(item, attribute, value=None, increment=None):
"""Update the beets item. Set attribute to value or increment the value """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 of attribute. If the increment argument is used the value is cast to
the corresponding type. the corresponding type.
@ -190,10 +189,10 @@ class MPDStats(object):
item[attribute] = value item[attribute] = value
item.store() item.store()
log.debug(u'updated: {0} = {1} [{2}]', self._log.debug(u'updated: {0} = {1} [{2}]',
attribute, attribute,
item[attribute], item[attribute],
displayable_path(item.path)) displayable_path(item.path))
def update_rating(self, item, skipped): def update_rating(self, item, skipped):
"""Update the rating for a beets item. """Update the rating for a beets item.
@ -229,16 +228,16 @@ class MPDStats(object):
"""Updates the play count of a song. """Updates the play count of a song.
""" """
self.update_item(song['beets_item'], 'play_count', increment=1) self.update_item(song['beets_item'], 'play_count', increment=1)
log.info(u'played {0}', displayable_path(song['path'])) self._log.info(u'played {0}', displayable_path(song['path']))
def handle_skipped(self, song): def handle_skipped(self, song):
"""Updates the skip count of a song. """Updates the skip count of a song.
""" """
self.update_item(song['beets_item'], 'skip_count', increment=1) self.update_item(song['beets_item'], 'skip_count', increment=1)
log.info(u'skipped {0}', displayable_path(song['path'])) self._log.info(u'skipped {0}', displayable_path(song['path']))
def on_stop(self, status): def on_stop(self, status):
log.info(u'stop') self._log.info(u'stop')
if self.now_playing: if self.now_playing:
self.handle_song_change(self.now_playing) self.handle_song_change(self.now_playing)
@ -246,7 +245,7 @@ class MPDStats(object):
self.now_playing = None self.now_playing = None
def on_pause(self, status): def on_pause(self, status):
log.info(u'pause') self._log.info(u'pause')
self.now_playing = None self.now_playing = None
def on_play(self, status): def on_play(self, status):
@ -257,7 +256,7 @@ class MPDStats(object):
return return
if is_url(path): if is_url(path):
log.info(u'playing stream {0}', displayable_path(path)) self._log.info(u'playing stream {0}', displayable_path(path))
return return
played, duration = map(int, status['time'].split(':', 1)) played, duration = map(int, status['time'].split(':', 1))
@ -266,7 +265,7 @@ class MPDStats(object):
if self.now_playing and self.now_playing['path'] != path: if self.now_playing and self.now_playing['path'] != path:
self.handle_song_change(self.now_playing) self.handle_song_change(self.now_playing)
log.info(u'playing {0}', displayable_path(path)) self._log.info(u'playing {0}', displayable_path(path))
self.now_playing = { self.now_playing = {
'started': time.time(), 'started': time.time(),
@ -291,7 +290,7 @@ class MPDStats(object):
if handler: if handler:
handler(status) handler(status)
else: else:
log.debug(u'unhandled status "{0}"', status) self._log.debug(u'unhandled status "{0}"', status)
events = self.mpd.events() events = self.mpd.events()
@ -344,7 +343,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
config['mpd']['password'] = opts.password.decode('utf8') config['mpd']['password'] = opts.password.decode('utf8')
try: try:
MPDStats(lib).run() MPDStats(lib, self._log).run()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass

View file

@ -14,9 +14,10 @@
"""Send the results of a query to the configured music player as a playlist. """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.plugins import BeetsPlugin
from beets.ui import Subcommand from beets.ui import Subcommand
from beets import logging
from beets import config from beets import config
from beets import ui from beets import ui
from beets import util from beets import util
@ -25,10 +26,8 @@ import platform
import shlex import shlex
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
log = logging.getLogger(__name__)
def play_music(lib, opts, args, log):
def play_music(lib, opts, args):
"""Execute query, create temporary playlist and execute player """Execute query, create temporary playlist and execute player
command passing that playlist. command passing that playlist.
""" """
@ -133,5 +132,5 @@ class PlayPlugin(BeetsPlugin):
action='store_true', default=False, action='store_true', default=False,
help='query and load albums rather than tracks' 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] return [play_command]