diff --git a/appveyor.yml b/appveyor.yml index 4f350b938..938d3a5a4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,10 +12,10 @@ environment: matrix: - PYTHON: C:\Python27 TOX_ENV: py27-test - - PYTHON: C:\Python34 - TOX_ENV: py34-test - PYTHON: C:\Python35 TOX_ENV: py35-test + - PYTHON: C:\Python36 + TOX_ENV: py36-test # Install Tox for running tests. install: diff --git a/beets/__init__.py b/beets/__init__.py index 5d82b05f7..964d2592c 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -19,7 +19,7 @@ import os from beets.util import confit -__version__ = u'1.4.3' +__version__ = u'1.4.4' __author__ = u'Adrian Sampson ' diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index aad342e98..fc3b1926c 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -30,7 +30,11 @@ from beets import config import six VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377' -BASE_URL = 'http://musicbrainz.org/' + +if util.SNI_SUPPORTED: + BASE_URL = 'https://musicbrainz.org/' +else: + BASE_URL = 'http://musicbrainz.org/' musicbrainzngs.set_useragent('beets', beets.__version__, 'http://beets.io/') diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index d27897e69..470ca2ac6 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -503,9 +503,13 @@ def _to_epoch_time(date): """Convert a `datetime` object to an integer number of seconds since the (local) Unix epoch. """ - epoch = datetime.fromtimestamp(0) - delta = date - epoch - return int(delta.total_seconds()) + if hasattr(date, 'timestamp'): + # The `timestamp` method exists on Python 3.3+. + return int(date.timestamp()) + else: + epoch = datetime.fromtimestamp(0) + delta = date - epoch + return int(delta.total_seconds()) def _parse_periods(pattern): diff --git a/beets/mediafile.py b/beets/mediafile.py index 0f595a36a..c910c9a86 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -36,15 +36,11 @@ data from the tags. In turn ``MediaField`` uses a number of from __future__ import division, absolute_import, print_function import mutagen -import mutagen.mp3 import mutagen.id3 -import mutagen.oggopus -import mutagen.oggvorbis import mutagen.mp4 import mutagen.flac -import mutagen.monkeysaudio import mutagen.asf -import mutagen.aiff + import codecs import datetime import re @@ -77,6 +73,7 @@ TYPES = { 'mpc': 'Musepack', 'asf': 'Windows Media', 'aiff': 'AIFF', + 'dsf': 'DSD Stream File', } PREFERRED_IMAGE_EXTENSIONS = {'jpeg': 'jpg'} @@ -732,7 +729,7 @@ class MP4ImageStorageStyle(MP4ListStorageStyle): class MP3StorageStyle(StorageStyle): """Store data in ID3 frames. """ - formats = ['MP3', 'AIFF'] + formats = ['MP3', 'AIFF', 'DSF'] def __init__(self, key, id3_lang=None, **kwargs): """Create a new ID3 storage style. `id3_lang` is the value for @@ -1479,6 +1476,8 @@ class MediaFile(object): self.type = 'asf' elif type(self.mgfile).__name__ == 'AIFF': self.type = 'aiff' + elif type(self.mgfile).__name__ == 'DSF': + self.type = 'dsf' else: raise FileTypeError(path, type(self.mgfile).__name__) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index a33d0e02e..18d89ddd9 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -34,6 +34,7 @@ from unidecode import unidecode MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = u'\\\\?\\' +SNI_SUPPORTED = sys.version_info >= (2, 7, 9) class HumanReadableException(Exception): diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 4c2e92532..e84b775dc 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -32,7 +32,10 @@ PIL = 1 IMAGEMAGICK = 2 WEBPROXY = 3 -PROXY_URL = 'http://images.weserv.nl/' +if util.SNI_SUPPORTED: + PROXY_URL = 'https://images.weserv.nl/' +else: + PROXY_URL = 'http://images.weserv.nl/' log = logging.getLogger('beets') diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index ae3717470..e96d4f033 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -24,7 +24,7 @@ import os import subprocess import tempfile -import distutils +from distutils.spawn import find_executable import requests from beets import plugins @@ -79,9 +79,10 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): # Extractor found, will exit with an error if not called with # the correct amount of arguments. pass - # Get the executable location on the system, - # needed to calculate the sha1 hash. - self.extractor = distutils.spawn.find_executable(self.extractor) + + # Get the executable location on the system, which we need + # to calculate the SHA-1 hash. + self.extractor = find_executable(self.extractor) # Calculate extractor hash. self.extractor_sha = hashlib.sha1() diff --git a/beetsplug/bpm.py b/beetsplug/bpm.py index 89424f30e..20218bd33 100644 --- a/beetsplug/bpm.py +++ b/beetsplug/bpm.py @@ -65,7 +65,9 @@ class BPMPlugin(BeetsPlugin): return [cmd] def command(self, lib, opts, args): - self.get_bpm(lib.items(ui.decargs(args))) + items = lib.items(ui.decargs(args)) + write = ui.should_write() + self.get_bpm(items, write) def get_bpm(self, items, write=False): overwrite = self.config['overwrite'].get(bool) diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py index 3af285973..5c731954b 100644 --- a/beetsplug/embyupdate.py +++ b/beetsplug/embyupdate.py @@ -6,6 +6,7 @@ host: localhost port: 8096 username: user + apikey: apikey password: password """ from __future__ import division, absolute_import, print_function @@ -150,7 +151,9 @@ class EmbyUpdate(BeetsPlugin): # Adding defaults. config['emby'].add({ u'host': u'http://localhost', - u'port': 8096 + u'port': 8096, + u'apikey': None, + u'password': None, }) self.register_listener('database_change', self.listen_for_db_change) @@ -171,6 +174,11 @@ class EmbyUpdate(BeetsPlugin): password = config['emby']['password'].get() token = config['emby']['apikey'].get() + # Check if at least a apikey or password is given. + if not any([password, token]): + self._log.warning(u'Provide at least Emby password or apikey.') + return + # Get user information from the Emby API. user = get_user(host, port, username) if not user: diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 27ffa49cb..d87a5dc48 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -292,8 +292,12 @@ class RemoteArtSource(ArtSource): class CoverArtArchive(RemoteArtSource): NAME = u"Cover Art Archive" - URL = 'http://coverartarchive.org/release/{mbid}/front' - GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front' + if util.SNI_SUPPORTED: + URL = 'https://coverartarchive.org/release/{mbid}/front' + GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front' + else: + URL = 'http://coverartarchive.org/release/{mbid}/front' + GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front' def get(self, album, extra): """Return the Cover Art Archive and Cover Art Archive release group URLs @@ -394,8 +398,7 @@ class GoogleImages(RemoteArtSource): class FanartTV(RemoteArtSource): """Art from fanart.tv requested using their API""" NAME = u"fanart.tv" - - API_URL = 'http://webservice.fanart.tv/v3/' + API_URL = 'https://webservice.fanart.tv/v3/' API_ALBUMS = API_URL + 'music/albums/' PROJECT_KEY = '61a7d0ab4e67162b7a0c7c35915cd48e' @@ -488,8 +491,8 @@ class ITunesStore(RemoteArtSource): class Wikipedia(RemoteArtSource): NAME = u"Wikipedia (queried through DBpedia)" - DBPEDIA_URL = 'http://dbpedia.org/sparql' - WIKIPEDIA_URL = 'http://en.wikipedia.org/w/api.php' + DBPEDIA_URL = 'https://dbpedia.org/sparql' + WIKIPEDIA_URL = 'https://en.wikipedia.org/w/api.php' SPARQL_QUERY = u'''PREFIX rdf: PREFIX dbpprop: PREFIX owl: diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index 0ed9daf3c..d7b84b0aa 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -23,7 +23,7 @@ from beets import config from beets import plugins from beets.dbcore import types -API_URL = 'http://ws.audioscrobbler.com/2.0/' +API_URL = 'https://ws.audioscrobbler.com/2.0/' class LastImportPlugin(plugins.BeetsPlugin): diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index a642159c2..92abe377e 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -655,7 +655,7 @@ class LyricsPlugin(plugins.BeetsPlugin): params = { 'client_id': 'beets', 'client_secret': self.config['bing_client_secret'], - 'scope': 'http://api.microsofttranslator.com', + 'scope': "https://api.microsofttranslator.com", 'grant_type': 'client_credentials', } @@ -762,7 +762,7 @@ class LyricsPlugin(plugins.BeetsPlugin): if self.bing_auth_token: # Extract unique lines to limit API request size per song text_lines = set(text.split('\n')) - url = ('http://api.microsofttranslator.com/v2/Http.svc/' + url = ('https://api.microsofttranslator.com/v2/Http.svc/' 'Translate?text=%s&to=%s' % ('|'.join(text_lines), to_lang)) r = requests.get(url, headers={"Authorization ": self.bing_auth_token}) diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 6c39375be..6ecc92131 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -35,14 +35,14 @@ import six # easier. class BufferedSocket(object): """Socket abstraction that allows reading by line.""" - def __init__(self, host, port, sep='\n'): + def __init__(self, host, port, sep=b'\n'): if host[0] in ['/', '~']: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(os.path.expanduser(host)) else: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((host, port)) - self.buf = '' + self.buf = b'' self.sep = sep def readline(self): @@ -51,11 +51,11 @@ class BufferedSocket(object): if not data: break self.buf += data - if '\n' in self.buf: + if self.sep in self.buf: res, self.buf = self.buf.split(self.sep, 1) return res + self.sep else: - return '' + return b'' def send(self, data): self.sock.send(data) @@ -106,24 +106,24 @@ class MPDUpdatePlugin(BeetsPlugin): return resp = s.readline() - if 'OK MPD' not in resp: + if b'OK MPD' not in resp: self._log.warning(u'MPD connection failed: {0!r}', resp) return if password: - s.send('password "%s"\n' % password) + s.send(b'password "%s"\n' % password.encode('utf8')) resp = s.readline() - if 'OK' not in resp: + if b'OK' not in resp: self._log.warning(u'Authentication failed: {0!r}', resp) - s.send('close\n') + s.send(b'close\n') s.close() return - s.send('update\n') + s.send(b'update\n') resp = s.readline() - if 'updating_db' not in resp: + if b'updating_db' not in resp: self._log.warning(u'Update failed: {0!r}', resp) - s.send('close\n') + s.send(b'close\n') s.close() self._log.info(u'Database updated.') diff --git a/beetsplug/play.py b/beetsplug/play.py index 4a2174909..636d98d46 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -19,17 +19,41 @@ from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets.ui import Subcommand +from beets.ui.commands import PromptChoice from beets import config from beets import ui from beets import util from os.path import relpath from tempfile import NamedTemporaryFile +import subprocess # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. ARGS_MARKER = '$args' +def play(command_str, selection, paths, open_args, log, item_type='track', + keep_open=False): + """Play items in paths with command_str and optional arguments. If + keep_open, return to beets, otherwise exit once command runs. + """ + # Print number of tracks or albums to be played, log command to be run. + item_type += 's' if len(selection) > 1 else '' + ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type)) + log.debug(u'executing command: {} {!r}', command_str, open_args) + + try: + if keep_open: + command = util.shlex_split(command_str) + command = command + open_args + subprocess.call(command) + else: + util.interactive_open(open_args, command_str) + except OSError as exc: + raise ui.UserError( + "Could not play the query: {0}".format(exc)) + + class PlayPlugin(BeetsPlugin): def __init__(self): @@ -40,11 +64,12 @@ class PlayPlugin(BeetsPlugin): 'use_folders': False, 'relative_to': None, 'raw': False, - # Backwards compatibility. See #1803 and line 74 - 'warning_threshold': -2, - 'warning_treshold': 100, + 'warning_threshold': 100, }) + self.register_listener('before_choose_candidate', + self.before_choose_candidate_listener) + def commands(self): play_command = Subcommand( 'play', @@ -56,44 +81,17 @@ class PlayPlugin(BeetsPlugin): action='store', help=u'add additional arguments to the command', ) - play_command.func = self.play_music + play_command.func = self._play_command return [play_command] - def play_music(self, lib, opts, args): - """Execute query, create temporary playlist and execute player - command passing that playlist, at request insert optional arguments. + def _play_command(self, lib, opts, args): + """The CLI command function for `beet play`. Create a list of paths + from query, determine if tracks or albums are to be played. """ - command_str = config['play']['command'].get() - if not command_str: - command_str = util.open_anything() use_folders = config['play']['use_folders'].get(bool) relative_to = config['play']['relative_to'].get() - raw = config['play']['raw'].get(bool) - warning_threshold = config['play']['warning_threshold'].get(int) - # We use -2 as a default value for warning_threshold to detect if it is - # set or not. We can't use a falsey value because it would have an - # actual meaning in the configuration of this plugin, and we do not use - # -1 because some people might use it as a value to obtain no warning, - # which wouldn't be that bad of a practice. - if warning_threshold == -2: - # if warning_threshold has not been set by user, look for - # warning_treshold, to preserve backwards compatibility. See #1803. - # warning_treshold has the correct default value of 100. - warning_threshold = config['play']['warning_treshold'].get(int) - if relative_to: relative_to = util.normpath(relative_to) - - # Add optional arguments to the player command. - if opts.args: - if ARGS_MARKER in command_str: - command_str = command_str.replace(ARGS_MARKER, opts.args) - else: - command_str = u"{} {}".format(command_str, opts.args) - else: - # Don't include the marker in the command. - command_str = command_str.replace(" " + ARGS_MARKER, "") - # Perform search by album and add folders rather than tracks to # playlist. if opts.album: @@ -117,13 +115,52 @@ class PlayPlugin(BeetsPlugin): paths = [relpath(path, relative_to) for path in paths] item_type = 'track' - item_type += 's' if len(selection) > 1 else '' - if not selection: ui.print_(ui.colorize('text_warning', u'No {0} to play.'.format(item_type))) return + open_args = self._playlist_or_paths(paths) + command_str = self._command_str(opts.args) + + # Check if the selection exceeds configured threshold. If True, + # cancel, otherwise proceed with play command. + if not self._exceeds_threshold(selection, command_str, open_args, + item_type): + play(command_str, selection, paths, open_args, self._log, + item_type) + + def _command_str(self, args=None): + """Create a command string from the config command and optional args. + """ + command_str = config['play']['command'].get() + if not command_str: + return util.open_anything() + # Add optional arguments to the player command. + if args: + if ARGS_MARKER in command_str: + return command_str.replace(ARGS_MARKER, args) + else: + return u"{} {}".format(command_str, args) + else: + # Don't include the marker in the command. + return command_str.replace(" " + ARGS_MARKER, "") + + def _playlist_or_paths(self, paths): + """Return either the raw paths of items or a playlist of the items. + """ + if config['play']['raw']: + return paths + else: + return [self._create_tmp_playlist(paths)] + + def _exceeds_threshold(self, selection, command_str, open_args, + item_type='track'): + """Prompt user whether to abort if playlist exceeds threshold. If + True, cancel playback. If False, execute play command. + """ + warning_threshold = config['play']['warning_threshold'].get(int) + # Warn user before playing any huge playlists. if warning_threshold and len(selection) > warning_threshold: ui.print_(ui.colorize( @@ -132,20 +169,9 @@ class PlayPlugin(BeetsPlugin): len(selection), item_type))) if ui.input_options((u'Continue', u'Abort')) == 'a': - return + return True - ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type)) - if raw: - open_args = paths - else: - open_args = [self._create_tmp_playlist(paths)] - - self._log.debug(u'executing command: {} {!r}', command_str, open_args) - try: - util.interactive_open(open_args, command_str) - except OSError as exc: - raise ui.UserError( - "Could not play the query: {0}".format(exc)) + return False def _create_tmp_playlist(self, paths_list): """Create a temporary .m3u file. Return the filename. @@ -155,3 +181,21 @@ class PlayPlugin(BeetsPlugin): m3u.write(item + b'\n') m3u.close() return m3u.name + + def before_choose_candidate_listener(self, session, task): + """Append a "Play" choice to the interactive importer prompt. + """ + return [PromptChoice('y', 'plaY', self.importer_play)] + + def importer_play(self, session, task): + """Get items from current import task and send to play function. + """ + selection = task.items + paths = [item.path for item in selection] + + open_args = self._playlist_or_paths(paths) + command_str = self._command_str() + + if not self._exceeds_threshold(selection, command_str, open_args): + play(command_str, selection, paths, open_args, self._log, + keep_open=True) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 2d3af58a6..4cf7da7c5 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -23,7 +23,6 @@ import warnings import re from six.moves import zip -from beets import logging from beets import ui from beets.plugins import BeetsPlugin from beets.util import syspath, command_output, displayable_path, py3_path @@ -194,8 +193,8 @@ class Bs1770gainBackend(Backend): """ # Construct shell command. cmd = [self.command] - cmd = cmd + [self.method] - cmd = cmd + ['-p'] + cmd += [self.method] + cmd += ['-p'] # Workaround for Windows: the underlying tool fails on paths # with the \\?\ prefix, so we don't use it here. This @@ -227,7 +226,7 @@ class Bs1770gainBackend(Backend): ':|done\\.\\s)', re.DOTALL | re.UNICODE) results = re.findall(regex, data) for parts in results[0:num_lines]: - part = parts.split(b'\n') + part = parts.split(u'\n') if len(part) == 0: self._log.debug(u'bad tool output: {0!r}', text) raise ReplayGainError(u'bs1770gain failed') @@ -794,7 +793,7 @@ class ReplayGainPlugin(BeetsPlugin): "command": CommandBackend, "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, - "bs1770gain": Bs1770gainBackend + "bs1770gain": Bs1770gainBackend, } def __init__(self): @@ -934,8 +933,6 @@ class ReplayGainPlugin(BeetsPlugin): """Return the "replaygain" ui subcommand. """ def func(lib, opts, args): - self._log.setLevel(logging.INFO) - write = ui.should_write() if opts.album: diff --git a/docs/changelog.rst b/docs/changelog.rst index 909315ff3..4e0d7405f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,10 +1,33 @@ Changelog ========= -1.4.3 (in development) +1.4.4 (in development) ---------------------- -Features: +New features: + +* Added support for DSF files, once a future version of Mutagen is released + that supports them. Thanks to :user:`docbobo`. :bug:`459` :bug:`2379` + +Fixes: + +* :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381` +* :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain`` + backend. :bug:`2382` + + +1.4.3 (January 9, 2017) +----------------------- + +Happy new year! This new version includes a cornucopia of new features from +contributors, including new tags related to classical music and a new +:doc:`/plugins/absubmit` for performing acoustic analysis on your music. The +:doc:`/plugins/random` has a new mode that lets you generate time-limited +music---for example, you might generate a random playlist that lasts the +perfect length for your walk to work. We also access as many Web services as +possible over secure connections now---HTTPS everywhere! + +The most visible new features are: * We now support the composer, lyricist, and arranger tags. The MusicBrainz data source will fetch data for these fields when the next version of @@ -13,17 +36,28 @@ Features: * A new :doc:`/plugins/absubmit` lets you run acoustic analysis software and upload the results for others to use. Thanks to :user:`inytar`. :bug:`2253` :bug:`2342` +* :doc:`/plugins/play`: The plugin now provides an importer prompt choice to + play the music you're about to import. Thanks to :user:`diomekes`. + :bug:`2008` :bug:`2360` +* We now use SSL to access Web services whenever possible. That includes + MusicBrainz itself, several album art sources, some lyrics sources, and + other servers. Thanks to :user:`tigranl`. :bug:`2307` * :doc:`/plugins/random`: A new ``--time`` option lets you generate a random playlist that takes a given amount of time. Thanks to :user:`diomekes`. :bug:`2305` :bug:`2322` -* :doc:`/plugins/zero`: Added ``zero`` command to manually trigger the zero + +Some smaller new features: + +* :doc:`/plugins/zero`: A new ``zero`` command manually triggers the zero plugin. Thanks to :user:`SJoshBrown`. :bug:`2274` :bug:`2329` * :doc:`/plugins/acousticbrainz`: The plugin will avoid re-downloading data for files that already have it by default. You can override this behavior using a new ``force`` option. Thanks to :user:`SusannaMaria`. :bug:`2347` :bug:`2349` +* :doc:`/plugins/bpm`: The ``import.write`` configuration option now + decides whether or not to write tracks after updating their BPM. :bug:`1992` -Fixes: +And the fixes: * :doc:`/plugins/bpd`: Fix a crash on non-ASCII MPD commands. :bug:`2332` * :doc:`/plugins/scrub`: Avoid a crash when files cannot be read or written. @@ -34,13 +68,21 @@ Fixes: filesystem. :bug:`2353` * :doc:`/plugins/discogs`: Improve the handling of releases that contain subtracks. :bug:`2318` -* :doc:`/plugins/discogs`: Fix a crash when a release did not contain Format - information, and increased robustness when other fields are missing. +* :doc:`/plugins/discogs`: Fix a crash when a release does not contain format + information, and increase robustness when other fields are missing. :bug:`2302` * :doc:`/plugins/lyrics`: The plugin now reports a beets-specific User-Agent header when requesting lyrics. :bug:`2357` +* :doc:`/plugins/embyupdate`: The plugin now checks whether an API key or a + password is provided in the configuration. +* :doc:`/plugins/play`: The misspelled configuration option + ``warning_treshold`` is no longer supported. -For plugin developers: new importer prompt choices (see :ref:`append_prompt_choices`), you can now provide new candidates for the user to consider. +For plugin developers: when providing new importer prompt choices (see +:ref:`append_prompt_choices`), you can now provide new candidates for the user +to consider. For example, you might provide an alternative strategy for +picking between the available alternatives or for looking up a release on +MusicBrainz. 1.4.2 (December 16, 2016) diff --git a/docs/conf.py b/docs/conf.py index 99771e7b1..9573b2fba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ project = u'beets' copyright = u'2016, Adrian Sampson' version = '1.4' -release = '1.4.3' +release = '1.4.4' pygments_style = 'sphinx' diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 4ee2debed..7feacb6bf 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -137,7 +137,7 @@ favorite text editor. The file will start out empty, but here's good place to start:: directory: ~/music - library: ~/data/musiclibrary.blb + library: ~/data/musiclibrary.db Change that first path to a directory where you'd like to keep your music. Then, for ``library``, choose a good place to keep a database file that keeps an index diff --git a/docs/plugins/absubmit.rst b/docs/plugins/absubmit.rst index a22ec8860..665133c64 100644 --- a/docs/plugins/absubmit.rst +++ b/docs/plugins/absubmit.rst @@ -1,19 +1,36 @@ AcousticBrainz Submit Plugin ============================ -The `absubmit` plugin uses the `streaming_extractor_music`_ program to analyze an audio file and calculate different acoustic properties of the audio. The plugin then uploads this metadata to the AcousticBrainz server. The plugin does this when calling the ``beet absumbit [QUERY]`` command or on importing if the `auto` configuration option is set to ``yes``. +The `absubmit` plugin lets you submit acoustic analysis results to the +`AcousticBrainz`_ server. Installation ------------ The `absubmit` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_). -The `absubmit` also plugin requires `requests`_, which you can install using `pip_` by typing: +The `absubmit` also plugin requires `requests`_, which you can install using `pip_` by typing:: pip install requests After installing both the extractor binary and requests you can enable the plugin ``absubmit`` in your configuration (see :ref:`using-plugins`). +Submitting Data +--------------- + +Type:: + + beet absubmit [QUERY] + +to run the analysis program and upload its results. + +The plugin works on music with a MusicBrainz track ID attached. The plugin +will also skip music that the analysis tool doesn't support. +`streaming_extractor_music`_ currently supports files with the extensions +``mp3``, ``ogg``, ``oga``, ``flac``, ``mp4``, ``m4a``, ``m4r``, ``m4b``, +``m4p``, ``aac``, ``wma``, ``asf``, ``mpc``, ``wv``, ``spx``, ``tta``, +``3g2``, ``aif``, ``aiff`` and ``ape``. + Configuration ------------- @@ -21,7 +38,7 @@ To configure the plugin, make a ``absubmit:`` section in your configuration file - **auto**: Analyze every file on import. Otherwise, you need to use the ``beet absubmit`` command explicitly. Default: ``no`` -- **extractor**: The path to the `streaming_extractor_music`_ binary. +- **extractor**: The absolute path to the `streaming_extractor_music`_ binary. Default: search for the program in your ``$PATH`` .. _streaming_extractor_music: http://acousticbrainz.org/download @@ -29,3 +46,4 @@ To configure the plugin, make a ``absubmit:`` section in your configuration file .. _pip: http://www.pip-installer.org/ .. _requests: http://docs.python-requests.org/en/master/ .. _github: https://github.com/MTG/essentia +.. _AcousticBrainz: https://acousticbrainz.org diff --git a/docs/plugins/bpm.rst b/docs/plugins/bpm.rst index ce1f62298..012c3903c 100644 --- a/docs/plugins/bpm.rst +++ b/docs/plugins/bpm.rst @@ -22,6 +22,21 @@ for instance, with ``mpc`` you can do something like:: beet bpm $(mpc |head -1|tr -d "-") +If :ref:`import.write ` is ``yes``, the song's tags are +written to disk. + +Configuration +------------- + +To configure the plugin, make a ``bpm:`` section in your configuration file. +The available options are: + +- **max_strokes**: The maximum number of strokes to accept when tapping out the + BPM. + Default: 3. +- **overwrite**: Overwrite the track's existing BPM. + Default: ``yes``. + Credit ------ diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst index 8dbb7c1db..00373b98c 100644 --- a/docs/plugins/embyupdate.rst +++ b/docs/plugins/embyupdate.rst @@ -3,7 +3,7 @@ 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:: +To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll 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 diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 1ac51a1f3..9b9110bde 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -4,8 +4,8 @@ Play Plugin The ``play`` plugin allows you to pass the results of a query to a music player in the form of an m3u playlist or paths on the command line. -Usage ------ +Command Line Usage +------------------ To use the ``play`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then use it by invoking the ``beet play`` command with @@ -29,6 +29,18 @@ would on the command-line):: While playing you'll be able to interact with the player if it is a command-line oriented, and you'll get its output in real time. +Interactive Usage +----------------- + +The `play` plugin can also be invoked during an import. If enabled, the plugin +adds a `plaY` option to the prompt, so pressing `y` will execute the configured +command and play the items currently being imported. + +Once the configured command exits, you will be returned to the import +decision prompt. If your player is configured to run in the background (in a +client/server setup), the music will play until you choose to stop it, and the +import operation continues immediately. + Configuration ------------- diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 264cd6413..8e43e2dd7 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -229,7 +229,7 @@ Default sort order to use when fetching items from the database. Defaults to sort_album ~~~~~~~~~~ -Default sort order to use when fetching items from the database. Defaults to +Default sort order to use when fetching albums from the database. Defaults to ``albumartist+ album+``. Explicit sort orders override this default. .. _sort_case_insensitive: @@ -387,6 +387,8 @@ file that looks like this:: These options are available in this section: +.. _config-import-write: + write ~~~~~ @@ -814,21 +816,14 @@ Example Here's an example file:: - library: /var/music.blb directory: /var/mp3 import: copy: yes write: yes - resume: ask - quiet_fallback: skip - timid: no log: beetslog.txt - ignore: .AppleDouble ._* *~ .DS_Store - ignore_hidden: yes art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins - threaded: yes ui: color: yes diff --git a/setup.py b/setup.py index f88fe28b6..35131cb55 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ if 'sdist' in sys.argv: setup( name='beets', - version='1.4.3', + version='1.4.4', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', diff --git a/test/rsrc/empty.dsf b/test/rsrc/empty.dsf new file mode 100644 index 000000000..4cbceb3c9 Binary files /dev/null and b/test/rsrc/empty.dsf differ diff --git a/test/rsrc/full.dsf b/test/rsrc/full.dsf new file mode 100644 index 000000000..a90e6946f Binary files /dev/null and b/test/rsrc/full.dsf differ diff --git a/test/rsrc/unparseable.dsf b/test/rsrc/unparseable.dsf new file mode 100644 index 000000000..3b6292e32 Binary files /dev/null and b/test/rsrc/unparseable.dsf differ diff --git a/test/test_art.py b/test/test_art.py index aba180780..50ff26b00 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -154,7 +154,7 @@ class CombinedTest(FetchImageHelper, UseThePlugin): .format(ASIN) AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}' \ .format(ASIN) - CAA_URL = 'http://coverartarchive.org/release/{0}/front' \ + CAA_URL = 'coverartarchive.org/release/{0}/front' \ .format(MBID) def setUp(self): @@ -202,12 +202,17 @@ class CombinedTest(FetchImageHelper, UseThePlugin): self.assertEqual(responses.calls[-1].request.url, self.AAO_URL) def test_main_interface_uses_caa_when_mbid_available(self): - self.mock_response(self.CAA_URL) + self.mock_response("http://" + self.CAA_URL) + self.mock_response("https://" + self.CAA_URL) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) self.assertIsNotNone(candidate) self.assertEqual(len(responses.calls), 1) - self.assertEqual(responses.calls[0].request.url, self.CAA_URL) + if util.SNI_SUPPORTED: + url = "https://" + self.CAA_URL + else: + url = "http://" + self.CAA_URL + self.assertEqual(responses.calls[0].request.url, url) def test_local_only_does_not_access_network(self): album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 5fcb14187..63df38b8e 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -175,7 +175,11 @@ class ImageStructureTestMixin(ArtTestMixin): class ExtendedImageStructureTestMixin(ImageStructureTestMixin): - """Checks for additional attributes in the image structure.""" + """Checks for additional attributes in the image structure. + + Like the base `ImageStructureTestMixin`, per-format test classes + should include this mixin to add image-related tests. + """ def assertExtendedImageAttributes(self, image, desc=None, type=None): # noqa self.assertEqual(image.desc, desc) @@ -294,8 +298,23 @@ class GenreListTestMixin(object): class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, _common.TempDirMixin): - """Test writing and reading tags. Subclasses must set ``extension`` and - ``audio_properties``. + """Test writing and reading tags. Subclasses must set ``extension`` + and ``audio_properties``. + + The basic tests for all audio formats encompass three files provided + in our `rsrc` folder: `full.*`, `empty.*`, and `unparseable.*`. + Respectively, they should contain a full slate of common fields + listed in `full_initial_tags` below; no fields contents at all; and + an unparseable release date field. + + To add support for a new file format to MediaFile, add these three + files and then create a `ReadWriteTestBase` subclass by copying n' + pasting one of the existing subclasses below. You will want to + update the `format` field in that subclass, and you will probably + need to fiddle with the `bitrate` and other format-specific fields. + + You can also add image tests (using an additional `image.*` fixture + file) by including one of the image-related mixins. """ full_initial_tags = { @@ -554,6 +573,9 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, self.assertEqual(mediafile.disctotal, None) def test_unparseable_date(self): + """The `unparseable.*` fixture should not crash but should return None + for all parts of the release date. + """ mediafile = self._mediafile_fixture('unparseable') self.assertIsNone(mediafile.date) @@ -887,6 +909,29 @@ class AIFFTest(ReadWriteTestBase, unittest.TestCase): } +# Check whether we have a Mutagen version with DSF support. We can +# remove this once we require a version that includes the feature. +try: + import mutagen.dsf # noqa +except: + HAVE_DSF = False +else: + HAVE_DSF = True + + +@unittest.skipIf(not HAVE_DSF, "Mutagen does not have DSF support") +class DSFTest(ReadWriteTestBase, unittest.TestCase): + extension = 'dsf' + audio_properties = { + 'length': 0.01, + 'bitrate': 11289600, + 'format': u'DSD Stream File', + 'samplerate': 5644800, + 'bitdepth': 1, + 'channels': 2, + } + + class MediaFieldTest(unittest.TestCase): def test_properties_from_fields(self): diff --git a/test/test_play.py b/test/test_play.py index 9d66d7466..86fef99a9 100644 --- a/test/test_play.py +++ b/test/test_play.py @@ -115,15 +115,6 @@ class PlayPluginTest(unittest.TestCase, TestHelper): open_mock.assert_not_called() - def test_warning_threshold_backwards_compat(self, open_mock): - self.config['play']['warning_treshold'] = 1 - self.add_item(title=u'another NiceTitle') - - with control_stdin("a"): - self.run_command(u'play', u'nice') - - open_mock.assert_not_called() - def test_command_failed(self, open_mock): open_mock.side_effect = OSError(u"some reason")