Merge branch 'master' into editor

This commit is contained in:
Adrian Sampson 2015-11-14 13:28:04 -08:00
commit 72b26235ad
31 changed files with 670 additions and 122 deletions

View file

@ -511,7 +511,10 @@ def album_for_mbid(release_id):
if the ID is not found.
"""
try:
return mb.album_for_id(release_id)
album = mb.album_for_id(release_id)
if album:
plugins.send('albuminfo_received', info=album)
return album
except mb.MusicBrainzAPIError as exc:
exc.log(log)
@ -521,7 +524,10 @@ def track_for_mbid(recording_id):
if the ID is not found.
"""
try:
return mb.track_for_id(recording_id)
track = mb.track_for_id(recording_id)
if track:
plugins.send('trackinfo_received', info=track)
return track
except mb.MusicBrainzAPIError as exc:
exc.log(log)
@ -529,14 +535,20 @@ def track_for_mbid(recording_id):
def albums_for_id(album_id):
"""Get a list of albums for an ID."""
candidates = [album_for_mbid(album_id)]
candidates.extend(plugins.album_for_id(album_id))
plugin_albums = plugins.album_for_id(album_id)
for a in plugin_albums:
plugins.send('albuminfo_received', info=a)
candidates.extend(plugin_albums)
return filter(None, candidates)
def tracks_for_id(track_id):
"""Get a list of tracks for an ID."""
candidates = [track_for_mbid(track_id)]
candidates.extend(plugins.track_for_id(track_id))
plugin_tracks = plugins.track_for_id(track_id)
for t in plugin_tracks:
plugins.send('trackinfo_received', info=t)
candidates.extend(plugin_tracks)
return filter(None, candidates)
@ -566,6 +578,10 @@ def album_candidates(items, artist, album, va_likely):
# Candidates from plugins.
out.extend(plugins.candidates(items, artist, album, va_likely))
# Notify subscribed plugins about fetched album info
for a in out:
plugins.send('albuminfo_received', info=a)
return out
@ -586,4 +602,8 @@ def item_candidates(item, artist, title):
# Plugin candidates.
out.extend(plugins.item_candidates(item, artist, title))
# Notify subscribed plugins about fetched track info
for i in out:
plugins.send('trackinfo_received', info=i)
return out

View file

@ -259,6 +259,8 @@ def album_info(release):
data_url=album_url(release['id']),
)
info.va = info.artist_id == VARIOUS_ARTISTS_ID
if info.va:
info.artist = config['va_name'].get(unicode)
info.asin = release.get('asin')
info.releasegroup_id = release['release-group']['id']
info.country = release.get('country')

View file

@ -47,6 +47,7 @@ verbose: 0
terminal_encoding:
original_date: no
id3v23: no
va_name: "Various Artists"
ui:
terminal_width: 80

View file

@ -48,7 +48,6 @@ action = Enum('action',
QUEUE_SIZE = 128
SINGLE_ARTIST_THRESH = 0.25
VARIOUS_ARTISTS = u'Various Artists'
PROGRESS_KEY = 'tagprogress'
HISTORY_KEY = 'taghistory'
@ -631,7 +630,7 @@ class ImportTask(BaseImportTask):
changes['comp'] = False
else:
# VA.
changes['albumartist'] = VARIOUS_ARTISTS
changes['albumartist'] = config['va_name'].get(unicode)
changes['comp'] = True
elif self.choice_flag == action.APPLY:

View file

@ -19,7 +19,6 @@ from __future__ import (division, absolute_import, print_function,
import os
import sys
import shlex
import unicodedata
import time
import re
@ -1139,13 +1138,8 @@ def parse_query_string(s, model_cls):
The string is split into components using shell-like syntax.
"""
assert isinstance(s, unicode), "Query is not unicode: {0!r}".format(s)
# A bug in Python < 2.7.3 prevents correct shlex splitting of
# Unicode strings.
# http://bugs.python.org/issue6988
s = s.encode('utf8')
try:
parts = [p.decode('utf8') for p in shlex.split(s)]
parts = util.shlex_split(s)
except ValueError as exc:
raise dbcore.InvalidQueryError(s, exc)
return parse_query_parts(parts, model_cls)

View file

@ -215,9 +215,9 @@ def _sc_decode(soundcheck):
# SoundCheck tags consist of 10 numbers, each represented by 8
# characters of ASCII hex preceded by a space.
try:
soundcheck = soundcheck.replace(' ', '').decode('hex')
soundcheck = soundcheck.replace(b' ', b'').decode('hex')
soundcheck = struct.unpack(b'!iiiiiiiiii', soundcheck)
except (struct.error, TypeError, UnicodeEncodeError):
except (struct.error, TypeError):
# SoundCheck isn't in the format we expect, so return default
# values.
return 0.0, 0.0

View file

@ -70,7 +70,7 @@ class UserError(Exception):
"""
# Utilities.
# Encoding utilities.
def _out_encoding():
"""Get the encoding to use for *outputting* strings to the console.
@ -137,6 +137,45 @@ def print_(*strings, **kwargs):
sys.stdout.write(txt)
# Configuration wrappers.
def _bool_fallback(a, b):
"""Given a boolean or None, return the original value or a fallback.
"""
if a is None:
assert isinstance(b, bool)
return b
else:
assert isinstance(a, bool)
return a
def should_write(write_opt=None):
"""Decide whether a command that updates metadata should also write
tags, using the importer configuration as the default.
"""
return _bool_fallback(write_opt, config['import']['write'].get(bool))
def should_move(move_opt=None):
"""Decide whether a command that updates metadata should also move
files when they're inside the library, using the importer
configuration as the default.
Specifically, commands should move files after metadata updates only
when the importer is configured *either* to move *or* to copy files.
They should avoid moving files when the importer is configured not
to touch any filenames.
"""
return _bool_fallback(
move_opt,
config['import']['move'].get(bool) or
config['import']['copy'].get(bool)
)
# Input prompts.
def input_(prompt=None):
"""Like `raw_input`, but decodes the result to a Unicode string.
Raises a UserError if stdin is not available. The prompt is sent to
@ -327,6 +366,8 @@ def input_yn(prompt, require=False):
return sel == 'y'
# Human output formatting.
def human_bytes(size):
"""Formats size, a number of bytes, in a human-readable way."""
powers = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'H']
@ -374,6 +415,8 @@ def human_seconds_short(interval):
return u'%i:%02i' % (interval // 60, interval % 60)
# Colorization.
# ANSI terminal colorization code heavily inspired by pygments:
# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.)

View file

@ -1055,7 +1055,8 @@ def update_items(lib, query, album, move, pretend):
def update_func(lib, opts, args):
update_items(lib, decargs(args), opts.album, opts.move, opts.pretend)
update_items(lib, decargs(args), opts.album, ui.should_move(opts.move),
opts.pretend)
update_cmd = ui.Subcommand(
@ -1064,7 +1065,11 @@ update_cmd = ui.Subcommand(
update_cmd.parser.add_album_option()
update_cmd.parser.add_format_option()
update_cmd.parser.add_option(
'-M', '--nomove', action='store_false', default=True, dest='move',
'-m', '--move', action='store_true', dest='move',
help="move files in the library directory"
)
update_cmd.parser.add_option(
'-M', '--nomove', action='store_false', dest='move',
help="don't move files in library"
)
update_cmd.parser.add_option(
@ -1293,17 +1298,19 @@ def modify_func(lib, opts, args):
query, mods, dels = modify_parse_args(decargs(args))
if not mods and not dels:
raise ui.UserError('no modifications specified')
write = opts.write if opts.write is not None else \
config['import']['write'].get(bool)
modify_items(lib, mods, dels, query, write, opts.move, opts.album,
not opts.yes)
modify_items(lib, mods, dels, query, ui.should_write(opts.write),
ui.should_move(opts.move), opts.album, not opts.yes)
modify_cmd = ui.Subcommand(
'modify', help='change metadata fields', aliases=('mod',)
)
modify_cmd.parser.add_option(
'-M', '--nomove', action='store_false', default=True, dest='move',
'-m', '--move', action='store_true', dest='move',
help="move files in the library directory"
)
modify_cmd.parser.add_option(
'-M', '--nomove', action='store_false', dest='move',
help="don't move files in library"
)
modify_cmd.parser.add_option(
@ -1466,7 +1473,7 @@ def config_edit():
An empty config file is created if no existing config file exists.
"""
path = config.user_config_path()
editor = os.environ.get('EDITOR')
editor = util.editor_command()
try:
if not os.path.isfile(path):
open(path, 'w+').close()

View file

@ -736,27 +736,52 @@ def open_anything():
return base_cmd
def interactive_open(targets, command=None):
"""Open the files in `targets` by `exec`ing a new command. (The new
program takes over, and Python execution ends: this does not fork a
subprocess.)
def editor_command():
"""Get a command for opening a text file.
If `command` is provided, use it. Otherwise, use an OS-specific
command (from `open_anything`) to open the file.
Use the `EDITOR` environment variable by default. If it is not
present, fall back to `open_anything()`, the platform-specific tool
for opening files in general.
"""
editor = os.environ.get('EDITOR')
if editor:
return editor
return open_anything()
def shlex_split(s):
"""Split a Unicode or bytes string according to shell lexing rules.
Raise `ValueError` if the string is not a well-formed shell string.
This is a workaround for a bug in some versions of Python.
"""
if isinstance(s, bytes):
# Shlex works fine.
return shlex.split(s)
elif isinstance(s, unicode):
# Work around a Python bug.
# http://bugs.python.org/issue6988
bs = s.encode('utf8')
return [c.decode('utf8') for c in shlex.split(bs)]
else:
raise TypeError('shlex_split called with non-string')
def interactive_open(targets, command):
"""Open the files in `targets` by `exec`ing a new `command`, given
as a Unicode string. (The new program takes over, and Python
execution ends: this does not fork a subprocess.)
Can raise `OSError`.
"""
if command:
command = command.encode('utf8')
try:
command = [c.decode('utf8')
for c in shlex.split(command)]
except ValueError: # Malformed shell tokens.
command = [command]
command.insert(0, command[0]) # for argv[0]
else:
base_cmd = open_anything()
command = [base_cmd, base_cmd]
# Split the command string into its arguments.
try:
command = shlex_split(command)
except ValueError: # Malformed shell tokens.
command = [command]
command.insert(0, command[0]) # for argv[0]
command += targets

View file

@ -194,8 +194,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
def fingerprint_cmd_func(lib, opts, args):
for item in lib.items(ui.decargs(args)):
fingerprint_item(self._log, item,
write=config['import']['write'].get(bool))
fingerprint_item(self._log, item, write=ui.should_write())
fingerprint_cmd.func = fingerprint_cmd_func
return [submit_cmd, fingerprint_cmd]

View file

@ -20,6 +20,7 @@ from __future__ import (division, absolute_import, print_function,
import beets.ui
from beets import logging
from beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
from beets.plugins import BeetsPlugin
from beets.util import confit
@ -223,6 +224,8 @@ class DiscogsPlugin(BeetsPlugin):
albumtype = ', '.join(
result.data['formats'][0].get('descriptions', [])) or None
va = result.data['artists'][0]['name'].lower() == 'various'
if va:
artist = config['va_name'].get(unicode)
year = result.data['year']
label = result.data['labels'][0]['name']
mediums = len(set(t.medium for t in tracks))

View file

@ -24,7 +24,7 @@ import tempfile
from string import Template
import subprocess
from beets import util, config, plugins, ui
from beets import util, plugins, ui
from beets.dbcore import types
import pyechonest
import pyechonest.song
@ -472,7 +472,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
def fetch_func(lib, opts, args):
self.config.set_args(opts)
write = config['import']['write'].get(bool)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
self._log.info(u'{0}', item)
if self.config['force'] or self.requires_update(item):

View file

@ -128,7 +128,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
def process_album(self, album):
"""Automatically embed art after art has been set
"""
if self.config['auto'] and config['import']['write']:
if self.config['auto'] and ui.should_write():
max_width = self.config['maxwidth'].get(int)
art.embed_album(self._log, album, max_width, True,
self.config['compare_threshold'].get(int),

133
beetsplug/embyupdate.py Normal file
View file

@ -0,0 +1,133 @@
"""Updates the Emby Library whenever the beets library is changed.
emby:
host: localhost
port: 8096
username: user
password: password
"""
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from beets import config
from beets.plugins import BeetsPlugin
from urllib import urlencode
from urlparse import urljoin, parse_qs, urlsplit, urlunsplit
import hashlib
import requests
def api_url(host, port, endpoint):
"""Returns a joined url.
"""
joined = urljoin('http://{0}:{1}'.format(host, port), endpoint)
scheme, netloc, path, query_string, fragment = urlsplit(joined)
query_params = parse_qs(query_string)
query_params['format'] = ['json']
new_query_string = urlencode(query_params, doseq=True)
return urlunsplit((scheme, netloc, path, new_query_string, fragment))
def password_data(username, password):
"""Returns a dict with username and its encoded password.
"""
return {
'username': username,
'password': hashlib.sha1(password).hexdigest(),
'passwordMd5': hashlib.md5(password).hexdigest()
}
def create_headers(user_id, token=None):
"""Return header dict that is needed to talk to the Emby API.
"""
headers = {
'Authorization': 'MediaBrowser',
'UserId': user_id,
'Client': 'other',
'Device': 'empy',
'DeviceId': 'beets',
'Version': '0.0.0'
}
if token:
headers['X-MediaBrowser-Token'] = token
return headers
def get_token(host, port, headers, auth_data):
"""Return token for a user.
"""
url = api_url(host, port, '/Users/AuthenticateByName')
r = requests.post(url, headers=headers, data=auth_data)
return r.json().get('AccessToken')
def get_user(host, port, username):
"""Return user dict from server or None if there is no user.
"""
url = api_url(host, port, '/Users/Public')
r = requests.get(url)
user = [i for i in r.json() if i['Name'] == username]
return user
class EmbyUpdate(BeetsPlugin):
def __init__(self):
super(EmbyUpdate, self).__init__()
# Adding defaults.
config['emby'].add({
u'host': u'localhost',
u'port': 8096
})
self.register_listener('database_change', self.listen_for_db_change)
def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update for the end.
"""
self.register_listener('cli_exit', self.update)
def update(self, lib):
"""When the client exists try to send refresh request to Emby.
"""
self._log.info(u'Updating Emby library...')
host = config['emby']['host'].get()
port = config['emby']['port'].get()
username = config['emby']['username'].get()
password = config['emby']['password'].get()
# Get user information from the Emby API.
user = get_user(host, port, username)
if not user:
self._log.warning(u'User {0} could not be found.'.format(username))
return
# Create Authentication data and headers.
auth_data = password_data(username, password)
headers = create_headers(user[0]['Id'])
# Get authentication token.
token = get_token(host, port, headers, auth_data)
if not token:
self._log.warning(
u'Couldnt not get token for user {0}'.format(username))
return
# Recreate headers with a token.
headers = create_headers(user[0]['Id'], token=token)
# Trigger the Update.
url = api_url(host, port, '/Library/Refresh')
r = requests.post(url, headers=headers)
if r.status_code != 204:
self._log.warning(u'Update could not be triggered')
else:
self._log.info(u'Update triggered.')

View file

@ -22,7 +22,6 @@ import re
from beets import plugins
from beets import ui
from beets.util import displayable_path
from beets import config
def split_on_feat(artist):
@ -102,7 +101,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
def func(lib, opts, args):
self.config.set_args(opts)
drop_feat = self.config['drop'].get(bool)
write = config['import']['write'].get(bool)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
self.ft_in_title(item, drop_feat)

View file

@ -23,7 +23,6 @@ import subprocess
from beets import ui
from beets import util
from beets.plugins import BeetsPlugin
from beets import config
class KeyFinderPlugin(BeetsPlugin):
@ -46,8 +45,7 @@ class KeyFinderPlugin(BeetsPlugin):
return [cmd]
def command(self, lib, opts, args):
self.find_key(lib.items(ui.decargs(args)),
write=config['import']['write'].get(bool))
self.find_key(lib.items(ui.decargs(args)), write=ui.should_write())
def imported(self, session, task):
self.find_key(task.items)

View file

@ -30,8 +30,8 @@ import traceback
from beets import plugins
from beets import ui
from beets.util import normpath, plurality
from beets import config
from beets.util import normpath, plurality
from beets import library
@ -292,7 +292,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
result = None
if isinstance(obj, library.Item):
result = self.fetch_artist_genre(obj)
elif obj.albumartist != 'Various Artists':
elif obj.albumartist != config['va_name'].get(unicode):
result = self.fetch_album_artist_genre(obj)
else:
# For "Various Artists", pick the most popular track genre.
@ -336,7 +336,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
)
def lastgenre_func(lib, opts, args):
write = config['import']['write'].get(bool)
write = ui.should_write()
self.config.set_args(opts)
for album in lib.albums(ui.decargs(args)):

View file

@ -29,7 +29,7 @@ import warnings
from HTMLParser import HTMLParseError
from beets import plugins
from beets import config, ui
from beets import ui
DIV_RE = re.compile(r'<(/?)div>?', re.I)
@ -557,7 +557,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
def func(lib, opts, args):
# The "write to files" option corresponds to the
# import_write config value.
write = config['import']['write'].get(bool)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
self.fetch_item_lyrics(
lib, item, write,

View file

@ -20,7 +20,6 @@ from __future__ import (division, absolute_import, print_function,
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
@ -46,11 +45,14 @@ class MBSyncPlugin(BeetsPlugin):
help='update metadata from musicbrainz')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show all changes but do nothing')
cmd.parser.add_option('-m', '--move', action='store_true',
dest='move',
help="move files in the library directory")
cmd.parser.add_option('-M', '--nomove', action='store_false',
default=True, dest='move',
dest='move',
help="don't move files in library")
cmd.parser.add_option('-W', '--nowrite', action='store_false',
default=config['import']['write'], dest='write',
default=None, dest='write',
help="don't write updated metadata to files")
cmd.parser.add_format_option()
cmd.func = self.func
@ -59,9 +61,9 @@ class MBSyncPlugin(BeetsPlugin):
def func(self, lib, opts, args):
"""Command handler for the mbsync function.
"""
move = opts.move
move = ui.should_move(opts.move)
pretend = opts.pretend
write = opts.write
write = ui.should_write(opts.write)
query = ui.decargs(args)
self.singletons(lib, query, move, pretend, write)
@ -137,6 +139,7 @@ class MBSyncPlugin(BeetsPlugin):
break
# Apply.
self._log.debug('applying changes to {}', album_formatted)
with lib.transaction():
autotag.apply_metadata(album_info, mapping)
changed = False

View file

@ -27,7 +27,6 @@ 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
# Utilities.
@ -926,7 +925,7 @@ class ReplayGainPlugin(BeetsPlugin):
def func(lib, opts, args):
self._log.setLevel(logging.INFO)
write = config['import']['write'].get(bool)
write = ui.should_write()
if opts.album:
for album in lib.albums(ui.decargs(args)):

View file

@ -23,7 +23,7 @@ from beets import ui
from beets.util import mkdirall, normpath, syspath
from beets.library import Item, Album, parse_query_string
from beets.dbcore import OrQuery
from beets.dbcore.query import MultipleSort
from beets.dbcore.query import MultipleSort, ParsingError
import os
@ -93,36 +93,46 @@ class SmartPlaylistPlugin(BeetsPlugin):
self._matched_playlists = set()
for playlist in self.config['playlists'].get(list):
playlist_data = (playlist['name'],)
for key, Model in (('query', Item), ('album_query', Album)):
qs = playlist.get(key)
if qs is None:
query_and_sort = None, None
elif isinstance(qs, basestring):
query_and_sort = parse_query_string(qs, Model)
elif len(qs) == 1:
query_and_sort = parse_query_string(qs[0], Model)
else:
# multiple queries and sorts
queries, sorts = zip(*(parse_query_string(q, Model)
for q in qs))
query = OrQuery(queries)
final_sorts = []
for s in sorts:
if s:
if isinstance(s, MultipleSort):
final_sorts += s.sorts
else:
final_sorts.append(s)
if not final_sorts:
sort = None
elif len(final_sorts) == 1:
sort, = final_sorts
else:
sort = MultipleSort(final_sorts)
query_and_sort = query, sort
if 'name' not in playlist:
self._log.warn("playlist configuration is missing name")
continue
playlist_data += (query_and_sort,)
playlist_data = (playlist['name'],)
try:
for key, Model in (('query', Item), ('album_query', Album)):
qs = playlist.get(key)
if qs is None:
query_and_sort = None, None
elif isinstance(qs, basestring):
query_and_sort = parse_query_string(qs, Model)
elif len(qs) == 1:
query_and_sort = parse_query_string(qs[0], Model)
else:
# multiple queries and sorts
queries, sorts = zip(*(parse_query_string(q, Model)
for q in qs))
query = OrQuery(queries)
final_sorts = []
for s in sorts:
if s:
if isinstance(s, MultipleSort):
final_sorts += s.sorts
else:
final_sorts.append(s)
if not final_sorts:
sort = None
elif len(final_sorts) == 1:
sort, = final_sorts
else:
sort = MultipleSort(final_sorts)
query_and_sort = query, sort
playlist_data += (query_and_sort,)
except ParsingError as exc:
self._log.warn("invalid query in playlist {}: {}",
playlist['name'], exc)
continue
self._unmatched_playlists.add(playlist_data)

View file

@ -8,6 +8,29 @@ Changelog
with the items and the fields of the items you want to edit. Afterwards you can
review your changes save them back into the items.
New:
* Three commands, ``modify``, ``update``, and ``mbsync``, would previously
move files by default after changing their metadata. Now, these commands
will only move files if you have the :ref:`config-import-copy` or
:ref:`config-import-move` options enabled in your importer configuration.
This way, if you configure the importer not to touch your filenames, other
commands will respect that decision by default too. Each command also
sprouted a ``--move`` command-line option to override this default (in
addition to the ``--nomove`` flag they already had). :bug:`1697`
* A new configuration option, ``va_name``, controls the album artist name for
various-artists albums. The setting defaults to "Various Artists," the
MusicBrainz standard. In order to match MusicBrainz, the
:doc:`/plugins/discogs` also adopts the same setting.
* :doc:`/plugins/embyupdate`: A plugin to trigger a library refresh on a
`Emby Server`_ if database changed.
For developers:
* :doc:`/dev/plugins`: Two new hooks, ``albuminfo_received`` and
``trackinfo_received``, let plugins intercept metadata as soon as it is
received, before it is applied to music in the database. :bug:`872`
Fixes:
* :doc:`/plugins/plexupdate`: Fix a crash when Plex libraries use non-ASCII
@ -35,6 +58,12 @@ Fixes:
* :doc:`/plugins/metasync`: Fix a crash when syncing with recent versions of
iTunes. :bug:`1700`
* :doc:`/plugins/duplicates`: Fix a crash when merging items. :bug:`1699`
* :doc:`/plugins/smartplaylist`: More gracefully handle malformed queries and
missing configuration.
* Fix a crash with some files with unreadable iTunes SoundCheck metadata.
:bug:`1666`
.. _Emby Server: http://emby.media
1.3.15 (October 17, 2015)

View file

@ -133,39 +133,39 @@ registration process in this case::
The events currently available are:
* *pluginload*: called after all the plugins have been loaded after the ``beet``
* `pluginload`: called after all the plugins have been loaded after the ``beet``
command starts
* *import*: called after a ``beet import`` command finishes (the ``lib`` keyword
* `import`: called after a ``beet import`` command finishes (the ``lib`` keyword
argument is a Library object; ``paths`` is a list of paths (strings) that were
imported)
* *album_imported*: called with an ``Album`` object every time the ``import``
* `album_imported`: called with an ``Album`` object every time the ``import``
command finishes adding an album to the library. Parameters: ``lib``,
``album``
* *item_copied*: called with an ``Item`` object whenever its file is copied.
* `item_copied`: called with an ``Item`` object whenever its file is copied.
Parameters: ``item``, ``source`` path, ``destination`` path
* *item_imported*: called with an ``Item`` object every time the importer adds a
* `item_imported`: called with an ``Item`` object every time the importer adds a
singleton to the library (not called for full-album imports). Parameters:
``lib``, ``item``
* *before_item_moved*: called with an ``Item`` object immediately before its
* `before_item_moved`: called with an ``Item`` object immediately before its
file is moved. Parameters: ``item``, ``source`` path, ``destination`` path
* *item_moved*: called with an ``Item`` object whenever its file is moved.
* `item_moved`: called with an ``Item`` object whenever its file is moved.
Parameters: ``item``, ``source`` path, ``destination`` path
* *item_linked*: called with an ``Item`` object whenever a symlink is created
* `item_linked`: called with an ``Item`` object whenever a symlink is created
for a file.
Parameters: ``item``, ``source`` path, ``destination`` path
* *item_removed*: called with an ``Item`` object every time an item (singleton
* `item_removed`: called with an ``Item`` object every time an item (singleton
or album's part) is removed from the library (even when its file is not
deleted from disk).
* *write*: called with an ``Item`` object, a ``path``, and a ``tags``
* `write`: called with an ``Item`` object, a ``path``, and a ``tags``
dictionary just before a file's metadata is written to disk (i.e.,
just before the file on disk is opened). Event handlers may change
the ``tags`` dictionary to customize the tags that are written to the
@ -174,46 +174,59 @@ The events currently available are:
operation. Beets will catch that exception, print an error message
and continue.
* *after_write*: called with an ``Item`` object after a file's metadata is
* `after_write`: called with an ``Item`` object after a file's metadata is
written to disk (i.e., just after the file on disk is closed).
* *import_task_created*: called immediately after an import task is
* `import_task_created`: called immediately after an import task is
initialized. Plugins can use this to, for example, change imported files of a
task before anything else happens. It's also possible to replace the task
with another task by returning a list of tasks. This list can contain zero
or more `ImportTask`s. Returning an empty list will stop the task.
Parameters: ``task`` (an `ImportTask`) and ``session`` (an `ImportSession`).
* *import_task_start*: called when before an import task begins processing.
* `import_task_start`: called when before an import task begins processing.
Parameters: ``task`` and ``session``.
* *import_task_apply*: called after metadata changes have been applied in an
* `import_task_apply`: called after metadata changes have been applied in an
import task. This is called on the same thread as the UI, so use this
sparingly and only for tasks that can be done quickly. For most plugins, an
import pipeline stage is a better choice (see :ref:`plugin-stage`).
Parameters: ``task`` and ``session``.
* *import_task_choice*: called after a decision has been made about an import
* `import_task_choice`: called after a decision has been made about an import
task. This event can be used to initiate further interaction with the user.
Use ``task.choice_flag`` to determine or change the action to be
taken. Parameters: ``task`` and ``session``.
* *import_task_files*: called after an import task finishes manipulating the
* `import_task_files`: called after an import task finishes manipulating the
filesystem (copying and moving files, writing metadata tags). Parameters:
``task`` and ``session``.
* *library_opened*: called after beets starts up and initializes the main
* `library_opened`: called after beets starts up and initializes the main
Library object. Parameter: ``lib``.
* *database_change*: a modification has been made to the library database. The
* `database_change`: a modification has been made to the library database. The
change might not be committed yet. Parameters: ``lib`` and ``model``.
* *cli_exit*: called just before the ``beet`` command-line program exits.
* `cli_exit`: called just before the ``beet`` command-line program exits.
Parameter: ``lib``.
* *import_begin*: called just before a ``beet import`` session starts up.
* `import_begin`: called just before a ``beet import`` session starts up.
Parameter: ``session``.
* `trackinfo_received`: called after metadata for a track item has been
fetched from a data source, such as MusicBrainz. You can modify the tags
that the rest of the pipeline sees on a ``beet import`` operation or during
later adjustments, such as ``mbsync``. Slow handlers of the event can impact
the operation, since the event is fired for any fetched possible match
`before` the user (or the autotagger machinery) gets to see the match.
Parameter: ``info``.
* `albuminfo_received`: like `trackinfo_received`, the event indicates new
metadata for album items. The parameter is an ``AlbumInfo`` object instead
of a ``TrackInfo``.
Parameter: ``info``.
The included ``mpdupdate`` plugin provides an example use case for event listeners.
Extend the Autotagger

View file

@ -67,9 +67,6 @@ file. The available options mirror the command-line options:
- **full**: List every track or album that has duplicates, not just the
duplicates themselves.
Default: ``no``
- **strict**: Do not report duplicate matches if some of the
attributes are not defined (ie. null or empty).
Default: ``no``
- **keys**: Define in which track or album fields duplicates are to be
searched. By default, the plugin uses the musicbrainz track and album IDs for
this purpose. Using the ``keys`` option (as a YAML list in the configuration
@ -83,6 +80,9 @@ file. The available options mirror the command-line options:
Default: none (disabled).
- **path**: Output the path instead of metadata when listing duplicates.
Default: ``no``.
- **strict**: Do not report duplicate matches if some of the
attributes are not defined (ie. null or empty).
Default: ``no``
- **tag**: A ``key=value`` pair. The plugin will add a new ``key`` attribute
with ``value`` value as a flexattr to the database for duplicate items.
Default: ``no``.

View file

@ -0,0 +1,33 @@
EmbyUpdate Plugin
=================
``embyupdate`` is a plugin that lets you automatically update `Emby`_'s library whenever you change your beets library.
To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your Emby server. You can do that using an ``emby:`` section in your ``config.yaml``, which looks like this::
emby:
host: localhost
port: 8096
username: user
password: password
To use the ``embyupdate`` plugin you need to install the `requests`_ library with::
pip install requests
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
.. _Emby: http://emby.media/
.. _requests: http://docs.python-requests.org/en/latest/
Configuration
-------------
The available options under the ``emby:`` section are:
- **host**: The Emby server name.
Default: ``localhost``
- **port**: The Emby server port.
Default: 8096
- **username**: A username of a Emby user that is allowed to refresh the library.
- **password**: That user's password.

View file

@ -13,7 +13,7 @@ Using Plugins
-------------
To use one of the plugins included with beets (see the rest of this page for a
list), just use the `plugins` option in your :doc:`config.yaml </reference/config>`: file, like so::
list), just use the `plugins` option in your :doc:`config.yaml </reference/config>` file, like so::
plugins: inline convert web
@ -42,6 +42,7 @@ Each plugin has its own set of options that can be defined in a section bearing
echonest
edit
embedart
embyupdate
fetchart
fromfilename
ftintitle
@ -133,6 +134,7 @@ Path Formats
Interoperability
----------------
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
@ -145,6 +147,7 @@ Interoperability
* :doc:`badfiles`: Check audio file integrity.
.. _Emby: http://emby.media
.. _Plex: http://plex.tv
Miscellaneous
@ -217,6 +220,8 @@ Here are a few of the plugins written by the beets community:
* `whatlastgenre`_ fetches genres from various music sites.
* `beets-usertag`_ lets you use keywords to tag and organize your music.
.. _beets-check: https://github.com/geigerzaehler/beets-check
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
.. _dsedivec: https://github.com/dsedivec/beets-plugins
@ -233,3 +238,4 @@ Here are a few of the plugins written by the beets community:
.. _beets-setlister: https://github.com/tomjaspers/beets-setlister
.. _beets-noimport: https://github.com/ttsda/beets-noimport
.. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets
.. _beets-usertag: https://github.com/igordertigor/beets-usertag

View file

@ -304,6 +304,15 @@ By default, beets writes MP3 tags using the ID3v2.4 standard, the latest
version of ID3. Enable this option to instead use the older ID3v2.3 standard,
which is preferred by certain older software such as Windows Media Player.
.. _va_name:
va_name
~~~~~~~
Sets the albumartist for various-artist compilations. Defaults to ``'Various
Artists'`` (the MusicBrainz standard). Affects other sources, such as
:doc:`/plugins/discogs`, too.
UI Options
----------
@ -370,6 +379,8 @@ Either ``yes`` or ``no``, controlling whether metadata (e.g., ID3) tags are
written to files when using ``beet import``. Defaults to ``yes``. The ``-w``
and ``-W`` command-line options override this setting.
.. _config-import-copy:
copy
~~~~
@ -380,6 +391,8 @@ overridden with the ``-c`` and ``-C`` command-line options.
The option is ignored if ``move`` is enabled (i.e., beets can move or
copy files but it doesn't make sense to do both).
.. _config-import-move:
move
~~~~

Binary file not shown.

212
test/test_embyupdate.py Normal file
View file

@ -0,0 +1,212 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from test._common import unittest
from test.helper import TestHelper
from beetsplug import embyupdate
import responses
class EmbyUpdateTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
self.load_plugins('embyupdate')
self.config['emby'] = {
u'host': u'localhost',
u'port': 8096,
u'username': u'username',
u'password': u'password'
}
def tearDown(self):
self.teardown_beets()
self.unload_plugins()
def test_api_url(self):
self.assertEqual(
embyupdate.api_url(self.config['emby']['host'].get(),
self.config['emby']['port'].get(),
'/Library/Refresh'),
'http://localhost:8096/Library/Refresh?format=json'
)
def test_password_data(self):
self.assertEqual(
embyupdate.password_data(self.config['emby']['username'].get(),
self.config['emby']['password'].get()),
{
'username': 'username',
'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8',
'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99'
}
)
def test_create_header_no_token(self):
self.assertEqual(
embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721'),
{
'Authorization': 'MediaBrowser',
'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721',
'Client': 'other',
'Device': 'empy',
'DeviceId': 'beets',
'Version': '0.0.0'
}
)
def test_create_header_with_token(self):
self.assertEqual(
embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721',
token='abc123'),
{
'Authorization': 'MediaBrowser',
'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721',
'Client': 'other',
'Device': 'empy',
'DeviceId': 'beets',
'Version': '0.0.0',
'X-MediaBrowser-Token': 'abc123'
}
)
@responses.activate
def test_get_token(self):
body = ('{"User":{"Name":"username", '
'"ServerId":"1efa5077976bfa92bc71652404f646ec",'
'"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,'
'"HasConfiguredPassword":true,'
'"HasConfiguredEasyPassword":false,'
'"LastLoginDate":"2015-11-09T08:35:03.6357440Z",'
'"LastActivityDate":"2015-11-09T08:35:03.6665060Z",'
'"Configuration":{"AudioLanguagePreference":"",'
'"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",'
'"DisplayMissingEpisodes":false,'
'"DisplayUnairedEpisodes":false,'
'"GroupMoviesIntoBoxSets":false,'
'"DisplayChannelsWithinViews":[],'
'"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],'
'"SubtitleMode":"Default","DisplayCollectionsView":true,'
'"DisplayFoldersView":false,"EnableLocalPassword":false,'
'"OrderedViews":[],"IncludeTrailersInSuggestions":true,'
'"EnableCinemaMode":true,"LatestItemsExcludes":[],'
'"PlainFolderViews":[],"HidePlayedInLatest":true,'
'"DisplayChannelsInline":false},'
'"Policy":{"IsAdministrator":true,"IsHidden":false,'
'"IsDisabled":false,"BlockedTags":[],'
'"EnableUserPreferenceAccess":true,"AccessSchedules":[],'
'"BlockUnratedItems":[],'
'"EnableRemoteControlOfOtherUsers":false,'
'"EnableSharedDeviceControl":true,'
'"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,'
'"EnableMediaPlayback":true,'
'"EnableAudioPlaybackTranscoding":true,'
'"EnableVideoPlaybackTranscoding":true,'
'"EnableContentDeletion":false,'
'"EnableContentDownloading":true,"EnableSync":true,'
'"EnableSyncTranscoding":true,"EnabledDevices":[],'
'"EnableAllDevices":true,"EnabledChannels":[],'
'"EnableAllChannels":true,"EnabledFolders":[],'
'"EnableAllFolders":true,"InvalidLoginAttemptCount":0,'
'"EnablePublicSharing":true}},'
'"SessionInfo":{"SupportedCommands":[],'
'"QueueableMediaTypes":[],"PlayableMediaTypes":[],'
'"Id":"89f3b33f8b3a56af22088733ad1d76b3",'
'"UserId":"2ec276a2642e54a19b612b9418a8bd3b",'
'"UserName":"username","AdditionalUsers":[],'
'"ApplicationVersion":"Unknown version",'
'"Client":"Unknown app",'
'"LastActivityDate":"2015-11-09T08:35:03.6665060Z",'
'"DeviceName":"Unknown device","DeviceId":"Unknown device id",'
'"SupportsRemoteControl":false,"PlayState":{"CanSeek":false,'
'"IsPaused":false,"IsMuted":false,"RepeatMode":"RepeatNone"}},'
'"AccessToken":"4b19180cf02748f7b95c7e8e76562fc8",'
'"ServerId":"1efa5077976bfa92bc71652404f646ec"}')
responses.add(responses.POST,
('http://localhost:8096'
'/Users/AuthenticateByName'),
body=body,
status=200,
content_type='application/json')
headers = {
'Authorization': 'MediaBrowser',
'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721',
'Client': 'other',
'Device': 'empy',
'DeviceId': 'beets',
'Version': '0.0.0'
}
auth_data = {
'username': 'username',
'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8',
'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99'
}
self.assertEqual(
embyupdate.get_token('localhost', 8096, headers, auth_data),
'4b19180cf02748f7b95c7e8e76562fc8')
@responses.activate
def test_get_user(self):
body = ('[{"Name":"username",'
'"ServerId":"1efa5077976bfa92bc71652404f646ec",'
'"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,'
'"HasConfiguredPassword":true,'
'"HasConfiguredEasyPassword":false,'
'"LastLoginDate":"2015-11-09T08:35:03.6357440Z",'
'"LastActivityDate":"2015-11-09T08:42:39.3693220Z",'
'"Configuration":{"AudioLanguagePreference":"",'
'"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",'
'"DisplayMissingEpisodes":false,'
'"DisplayUnairedEpisodes":false,'
'"GroupMoviesIntoBoxSets":false,'
'"DisplayChannelsWithinViews":[],'
'"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],'
'"SubtitleMode":"Default","DisplayCollectionsView":true,'
'"DisplayFoldersView":false,"EnableLocalPassword":false,'
'"OrderedViews":[],"IncludeTrailersInSuggestions":true,'
'"EnableCinemaMode":true,"LatestItemsExcludes":[],'
'"PlainFolderViews":[],"HidePlayedInLatest":true,'
'"DisplayChannelsInline":false},'
'"Policy":{"IsAdministrator":true,"IsHidden":false,'
'"IsDisabled":false,"BlockedTags":[],'
'"EnableUserPreferenceAccess":true,"AccessSchedules":[],'
'"BlockUnratedItems":[],'
'"EnableRemoteControlOfOtherUsers":false,'
'"EnableSharedDeviceControl":true,'
'"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,'
'"EnableMediaPlayback":true,'
'"EnableAudioPlaybackTranscoding":true,'
'"EnableVideoPlaybackTranscoding":true,'
'"EnableContentDeletion":false,'
'"EnableContentDownloading":true,'
'"EnableSync":true,"EnableSyncTranscoding":true,'
'"EnabledDevices":[],"EnableAllDevices":true,'
'"EnabledChannels":[],"EnableAllChannels":true,'
'"EnabledFolders":[],"EnableAllFolders":true,'
'"InvalidLoginAttemptCount":0,"EnablePublicSharing":true}}]')
responses.add(responses.GET,
'http://localhost:8096/Users/Public',
body=body,
status=200,
content_type='application/json')
response = embyupdate.get_user('localhost', 8096, 'username')
self.assertEqual(response[0]['Id'],
'2ec276a2642e54a19b612b9418a8bd3b')
self.assertEqual(response[0]['Name'],
'username')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == b'__main__':
unittest.main(defaultTest='suite')

View file

@ -89,6 +89,13 @@ class EdgeTest(unittest.TestCase):
beets.mediafile._image_mime_type(jpg_data),
'image/jpeg')
def test_soundcheck_non_ascii(self):
# Make sure we don't crash when the iTunes SoundCheck field contains
# non-ASCII binary data.
f = beets.mediafile.MediaFile(os.path.join(_common.RSRC,
'soundcheck-nonascii.m4a'))
self.assertEqual(f.rg_track_gain, 0.0)
class InvalidValueToleranceTest(unittest.TestCase):
@ -269,19 +276,19 @@ class SoundCheckTest(unittest.TestCase):
self.assertEqual(peak, 1.0)
def test_decode_zero(self):
data = u' 80000000 80000000 00000000 00000000 00000000 00000000 ' \
u'00000000 00000000 00000000 00000000'
data = b' 80000000 80000000 00000000 00000000 00000000 00000000 ' \
b'00000000 00000000 00000000 00000000'
gain, peak = beets.mediafile._sc_decode(data)
self.assertEqual(gain, 0.0)
self.assertEqual(peak, 0.0)
def test_malformatted(self):
gain, peak = beets.mediafile._sc_decode(u'foo')
gain, peak = beets.mediafile._sc_decode(b'foo')
self.assertEqual(gain, 0.0)
self.assertEqual(peak, 0.0)
def test_special_characters(self):
gain, peak = beets.mediafile._sc_decode(u'caf\xe9')
gain, peak = beets.mediafile._sc_decode(u'caf\xe9'.encode('utf8'))
self.assertEqual(gain, 0.0)
self.assertEqual(peak, 0.0)

View file

@ -43,7 +43,7 @@ class UtilTest(unittest.TestCase):
@patch('beets.util.open_anything')
def test_interactive_open(self, mock_open, mock_execlp):
mock_open.return_value = 'tagada'
util.interactive_open(['foo'])
util.interactive_open(['foo'], util.open_anything())
mock_execlp.assert_called_once_with('tagada', 'tagada', 'foo')
mock_execlp.reset_mock()