diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 5c4ce082e..b2b635ad2 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -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 diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 8589a62aa..78c5fb2f4 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -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') diff --git a/beets/config_default.yaml b/beets/config_default.yaml index f708702a8..ba58debe7 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -47,6 +47,7 @@ verbose: 0 terminal_encoding: original_date: no id3v23: no +va_name: "Various Artists" ui: terminal_width: 80 diff --git a/beets/importer.py b/beets/importer.py index 85d6e0824..5bdcaff8f 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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: diff --git a/beets/library.py b/beets/library.py index 186a674e7..cad39c232 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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) diff --git a/beets/mediafile.py b/beets/mediafile.py index 64ab49ac2..5fe1fa308 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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 diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 768eb76c7..4c99b937a 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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.) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 348d12c88..307945a03 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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() diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 61e2f4ac8..2a861ce88 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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 diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index f1ca233e0..3446661d3 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -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] diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index d8568bfd1..7ab2d217b 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -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)) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 2c8a35c69..312a8b620 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -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): diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 9c0efa51e..6dc235979 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -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), diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py new file mode 100644 index 000000000..ffca7c30c --- /dev/null +++ b/beetsplug/embyupdate.py @@ -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.') diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 8c435865c..d2369bf52 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -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) diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index e3e9a86ba..d76448a4c 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -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) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index eab6ab440..f276fe4f1 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -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)): diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 2f1e3529e..16f669f53 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -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, diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 974f7e894..6e7c208a7 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -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 diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index d57533e75..75f2ab947 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -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)): diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 8889a2534..c88fe38c5 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -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) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0050c659b..fd4dc1aed 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 798b6894c..885ef2222 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -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 diff --git a/docs/plugins/duplicates.rst b/docs/plugins/duplicates.rst index a0369a95e..4d8b35376 100644 --- a/docs/plugins/duplicates.rst +++ b/docs/plugins/duplicates.rst @@ -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``. diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst new file mode 100644 index 000000000..3e4c9687f --- /dev/null +++ b/docs/plugins/embyupdate.rst @@ -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. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index a4767cc22..5da5ee0be 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 `: file, like so:: +list), just use the `plugins` option in your :doc:`config.yaml ` 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 diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 2d91bac14..84e4368d6 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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 ~~~~ diff --git a/test/rsrc/soundcheck-nonascii.m4a b/test/rsrc/soundcheck-nonascii.m4a new file mode 100644 index 000000000..29f5de531 Binary files /dev/null and b/test/rsrc/soundcheck-nonascii.m4a differ diff --git a/test/test_embyupdate.py b/test/test_embyupdate.py new file mode 100644 index 000000000..d21c19987 --- /dev/null +++ b/test/test_embyupdate.py @@ -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') diff --git a/test/test_mediafile_edge.py b/test/test_mediafile_edge.py index 7a17fe86a..7886abdd2 100644 --- a/test/test_mediafile_edge.py +++ b/test/test_mediafile_edge.py @@ -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) diff --git a/test/test_util.py b/test/test_util.py index 324a4d589..b72410c9c 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -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()