diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ecb7e03dd..2a8b3a784 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,7 +6,7 @@ jobs: strategy: matrix: platform: [ubuntu-latest] - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9-dev] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] env: PY_COLORS: 1 @@ -25,17 +25,17 @@ jobs: python -m pip install tox sphinx - name: Test with tox - if: matrix.python-version != '3.8' + if: matrix.python-version != '3.9' run: | tox -e py-test - name: Test with tox and get coverage - if: matrix.python-version == '3.8' + if: matrix.python-version == '3.9' run: | tox -vv -e py-cov - + - name: Upload code coverage - if: matrix.python-version == '3.8' + if: matrix.python-version == '3.9' run: | pip install codecov || true codecov || true @@ -49,10 +49,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 2.7 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 2.7 + python-version: 3.9 - name: Install base dependencies run: | @@ -71,10 +71,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install base dependencies run: | diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 7952c5566..3ca5463c2 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -71,6 +71,12 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', 'labels', 'artist-credits', 'aliases', 'recording-level-rels', 'work-rels', 'work-level-rels', 'artist-rels'] +BROWSE_INCLUDES = ['artist-credits', 'work-rels', + 'artist-rels', 'recording-rels', 'release-rels'] +if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES['recording']: + BROWSE_INCLUDES.append("work-level-rels") +BROWSE_CHUNKSIZE = 100 +BROWSE_MAXTRACKS = 500 TRACK_INCLUDES = ['artists', 'aliases'] if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']: TRACK_INCLUDES += ['work-level-rels', 'artist-rels'] @@ -217,6 +223,8 @@ def track_info(recording, index=None, medium=None, medium_index=None, if recording.get('length'): info.length = int(recording['length']) / (1000.0) + info.trackdisambig = recording.get('disambiguation') + lyricist = [] composer = [] composer_sort = [] @@ -285,6 +293,26 @@ def album_info(release): artist_name, artist_sort_name, artist_credit_name = \ _flatten_artist_credit(release['artist-credit']) + ntracks = sum(len(m['track-list']) for m in release['medium-list']) + + # The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list' + # when the release has more than 500 tracks. So we use browse_recordings + # on chunks of tracks to recover the same information in this case. + if ntracks > BROWSE_MAXTRACKS: + log.debug(u'Album {} has too many tracks', release['id']) + recording_list = [] + for i in range(0, ntracks, BROWSE_CHUNKSIZE): + log.debug(u'Retrieving tracks starting at {}', i) + recording_list.extend(musicbrainzngs.browse_recordings( + release=release['id'], limit=BROWSE_CHUNKSIZE, + includes=BROWSE_INCLUDES, + offset=i)['recording-list']) + track_map = {r['id']: r for r in recording_list} + for medium in release['medium-list']: + for recording in medium['track-list']: + recording_info = track_map[recording['recording']['id']] + recording['recording'] = recording_info + # Basic info. track_infos = [] index = 0 diff --git a/beets/library.py b/beets/library.py index c5d25e5a9..3ba79d069 100644 --- a/beets/library.py +++ b/beets/library.py @@ -481,6 +481,7 @@ class Item(LibModel): 'mb_artistid': types.STRING, 'mb_albumartistid': types.STRING, 'mb_releasetrackid': types.STRING, + 'trackdisambig': types.STRING, 'albumtype': types.STRING, 'label': types.STRING, 'acoustid_fingerprint': types.STRING, diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index 39bc7152e..71ac8ffe6 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -247,9 +247,6 @@ class FirstPipelineThread(PipelineThread): self.out_queue = out_queue self.out_queue.acquire() - self.abort_lock = Lock() - self.abort_flag = False - def run(self): try: while True: diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 20d0f5479..91f2fe253 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -329,7 +329,7 @@ def fingerprint_item(log, item, write=False): else: log.info(u'{0}: using existing fingerprint', util.displayable_path(item.path)) - return item.acoustid_fingerprint + return item.acoustid_fingerprint else: log.info(u'{0}: fingerprinting', util.displayable_path(item.path)) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 4239e3d5f..45a571d38 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -16,6 +16,7 @@ """Converts tracks or albums to external directory """ from __future__ import division, absolute_import, print_function +from beets.util import par_map import os import threading @@ -183,8 +184,8 @@ class ConvertPlugin(BeetsPlugin): def auto_convert(self, config, task): if self.config['auto']: - for item in task.imported_items(): - self.convert_on_import(config.lib, item) + par_map(lambda item: self.convert_on_import(config.lib, item), + task.imported_items()) # Utilities converted from functions to methods on logging overhaul diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index b1b1593cd..05abb80c2 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -14,7 +14,7 @@ # included in all copies or substantial portions of the Software. """Adds Discogs album search support to the autotagger. Requires the -discogs-client library. +python3-discogs-client library. """ from __future__ import division, absolute_import, print_function diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 599aa7631..5c8402329 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -50,8 +50,15 @@ class MPDClientWrapper(object): def __init__(self, log): self._log = log - self.music_directory = ( - mpd_config['music_directory'].as_str()) + self.music_directory = mpd_config['music_directory'].as_str() + self.strip_path = mpd_config['strip_path'].as_str() + + # Ensure strip_path end with '/' + if not self.strip_path.endswith('/'): + self.strip_path += '/' + + self._log.debug('music_directory: {0}', self.music_directory) + self._log.debug('strip_path: {0}', self.strip_path) if sys.version_info < (3, 0): # On Python 2, use_unicode will enable the utf-8 mode for @@ -118,14 +125,21 @@ class MPDClientWrapper(object): """Return the path to the currently playing song, along with its songid. Prefixes paths with the music_directory, to get the absolute path. + In some cases, we need to remove the local path from MPD server, + we replace 'strip_path' with ''. + `strip_path` defaults to ''. """ result = None entry = self.get('currentsong') if 'file' in entry: if not is_url(entry['file']): - result = os.path.join(self.music_directory, entry['file']) + file = entry['file'] + if file.startswith(self.strip_path): + file = file[len(self.strip_path):] + result = os.path.join(self.music_directory, file) else: result = entry['file'] + self._log.debug('returning: {0}', result) return result, entry.get('id') def status(self): @@ -334,6 +348,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin): super(MPDStatsPlugin, self).__init__() mpd_config.add({ 'music_directory': config['directory'].as_filename(), + 'strip_path': u'', 'rating': True, 'rating_mix': 0.75, 'host': os.environ.get('MPD_HOST', u'localhost'), diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 9d6fa23c4..5060c8efe 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -15,26 +15,23 @@ from __future__ import division, absolute_import, print_function -import subprocess -import os import collections +import enum import math +import os +import signal +import six +import subprocess import sys import warnings -import enum -import re -import xml.parsers.expat -from six.moves import zip, queue -import six - from multiprocessing.pool import ThreadPool, RUN +from six.moves import zip, queue from threading import Thread, Event -import signal from beets import ui from beets.plugins import BeetsPlugin -from beets.util import (syspath, command_output, bytestring_path, - displayable_path, py3_path, cpu_count) +from beets.util import (syspath, command_output, displayable_path, + py3_path, cpu_count) # Utilities. @@ -136,252 +133,6 @@ class Backend(object): raise NotImplementedError() -# bsg1770gain backend -class Bs1770gainBackend(Backend): - """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and - its flavors EBU R128, ATSC A/85 and Replaygain 2.0. - """ - - methods = { - -24: "atsc", - -23: "ebu", - -18: "replaygain", - } - - do_parallel = True - - def __init__(self, config, log): - super(Bs1770gainBackend, self).__init__(config, log) - config.add({ - 'chunk_at': 5000, - 'method': '', - }) - self.chunk_at = config['chunk_at'].as_number() - # backward compatibility to `method` config option - self.__method = config['method'].as_str() - - cmd = 'bs1770gain' - try: - version_out = call([cmd, '--version']) - self.command = cmd - self.version = re.search( - 'bs1770gain ([0-9]+.[0-9]+.[0-9]+), ', - version_out.stdout.decode('utf-8') - ).group(1) - except OSError: - raise FatalReplayGainError( - u'Is bs1770gain installed?' - ) - if not self.command: - raise FatalReplayGainError( - u'no replaygain command found: install bs1770gain' - ) - - def compute_track_gain(self, items, target_level, peak): - """Computes the track gain of the given tracks, returns a list - of TrackGain objects. - """ - - output = self.compute_gain(items, target_level, False) - return output - - def compute_album_gain(self, items, target_level, peak): - """Computes the album gain of the given album, returns an - AlbumGain object. - """ - # TODO: What should be done when not all tracks in the album are - # supported? - - output = self.compute_gain(items, target_level, True) - - if not output: - raise ReplayGainError(u'no output from bs1770gain') - return AlbumGain(output[-1], output[:-1]) - - def isplitter(self, items, chunk_at): - """Break an iterable into chunks of at most size `chunk_at`, - generating lists for each chunk. - """ - iterable = iter(items) - while True: - result = [] - for i in range(chunk_at): - try: - a = next(iterable) - except StopIteration: - break - else: - result.append(a) - if result: - yield result - else: - break - - def compute_gain(self, items, target_level, is_album): - """Computes the track or album gain of a list of items, returns - a list of TrackGain objects. - When computing album gain, the last TrackGain object returned is - the album gain - """ - - if len(items) == 0: - return [] - - albumgaintot = 0.0 - albumpeaktot = 0.0 - returnchunks = [] - - # In the case of very large sets of music, we break the tracks - # into smaller chunks and process them one at a time. This - # avoids running out of memory. - if len(items) > self.chunk_at: - i = 0 - for chunk in self.isplitter(items, self.chunk_at): - i += 1 - returnchunk = self.compute_chunk_gain( - chunk, - is_album, - target_level - ) - albumgaintot += returnchunk[-1].gain - albumpeaktot = max(albumpeaktot, returnchunk[-1].peak) - returnchunks = returnchunks + returnchunk[0:-1] - returnchunks.append(Gain(albumgaintot / i, albumpeaktot)) - return returnchunks - else: - return self.compute_chunk_gain(items, is_album, target_level) - - def compute_chunk_gain(self, items, is_album, target_level): - """Compute ReplayGain values and return a list of results - dictionaries as given by `parse_tool_output`. - """ - # choose method - target_level = db_to_lufs(target_level) - if self.__method != "": - # backward compatibility to `method` option - method = self.__method - gain_adjustment = target_level \ - - [k for k, v in self.methods.items() if v == method][0] - elif target_level in self.methods: - method = self.methods[target_level] - gain_adjustment = 0 - else: - lufs_target = -23 - method = self.methods[lufs_target] - gain_adjustment = target_level - lufs_target - - # Construct shell command. - cmd = [self.command] - cmd += ["--" + method] - cmd += ['--xml', '-p'] - if after_version(self.version, '0.6.0'): - cmd += ['--unit=ebu'] # set units to LU - cmd += ['--suppress-progress'] # don't print % to XML output - - # Workaround for Windows: the underlying tool fails on paths - # with the \\?\ prefix, so we don't use it here. This - # prevents the backend from working with long paths. - args = cmd + [syspath(i.path, prefix=False) for i in items] - path_list = [i.path for i in items] - - # Invoke the command. - self._log.debug( - u'executing {0}', u' '.join(map(displayable_path, args)) - ) - output = call(args).stdout - - self._log.debug(u'analysis finished: {0}', output) - results = self.parse_tool_output(output, path_list, is_album) - - if gain_adjustment: - results = [ - Gain(res.gain + gain_adjustment, res.peak) - for res in results - ] - - self._log.debug(u'{0} items, {1} results', len(items), len(results)) - return results - - def parse_tool_output(self, text, path_list, is_album): - """Given the output from bs1770gain, parse the text and - return a list of dictionaries - containing information about each analyzed file. - """ - per_file_gain = {} - album_gain = {} # mutable variable so it can be set from handlers - parser = xml.parsers.expat.ParserCreate(encoding='utf-8') - state = {'file': None, 'gain': None, 'peak': None} - album_state = {'gain': None, 'peak': None} - - def start_element_handler(name, attrs): - if name == u'track': - state['file'] = bytestring_path(attrs[u'file']) - if state['file'] in per_file_gain: - raise ReplayGainError( - u'duplicate filename in bs1770gain output') - elif name == u'integrated': - if 'lu' in attrs: - state['gain'] = float(attrs[u'lu']) - elif name == u'sample-peak': - if 'factor' in attrs: - state['peak'] = float(attrs[u'factor']) - elif 'amplitude' in attrs: - state['peak'] = float(attrs[u'amplitude']) - - def end_element_handler(name): - if name == u'track': - if state['gain'] is None or state['peak'] is None: - raise ReplayGainError(u'could not parse gain or peak from ' - 'the output of bs1770gain') - per_file_gain[state['file']] = Gain(state['gain'], - state['peak']) - state['gain'] = state['peak'] = None - elif name == u'summary': - if state['gain'] is None or state['peak'] is None: - raise ReplayGainError(u'could not parse gain or peak from ' - 'the output of bs1770gain') - album_gain["album"] = Gain(state['gain'], state['peak']) - state['gain'] = state['peak'] = None - elif len(per_file_gain) == len(path_list): - if state['gain'] is not None: - album_state['gain'] = state['gain'] - if state['peak'] is not None: - album_state['peak'] = state['peak'] - if album_state['gain'] is not None \ - and album_state['peak'] is not None: - album_gain["album"] = Gain( - album_state['gain'], album_state['peak']) - state['gain'] = state['peak'] = None - - parser.StartElementHandler = start_element_handler - parser.EndElementHandler = end_element_handler - - try: - parser.Parse(text, True) - except xml.parsers.expat.ExpatError: - raise ReplayGainError( - u'The bs1770gain tool produced malformed XML. ' - u'Using version >=0.4.10 may solve this problem.') - - if len(per_file_gain) != len(path_list): - raise ReplayGainError( - u'the number of results returned by bs1770gain does not match ' - 'the number of files passed to it') - - # bs1770gain does not return the analysis results in the order that - # files are passed on the command line, because it is sorting the files - # internally. We must recover the order from the filenames themselves. - try: - out = [per_file_gain[os.path.basename(p)] for p in path_list] - except KeyError: - raise ReplayGainError( - u'unrecognized filename in bs1770gain output ' - '(bs1770gain can only deal with utf-8 file names)') - if is_album: - out.append(album_gain["album"]) - return out - - # ffmpeg backend class FfmpegBackend(Backend): """A replaygain backend using ffmpeg's ebur128 filter. @@ -1216,7 +967,6 @@ class ReplayGainPlugin(BeetsPlugin): "command": CommandBackend, "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, - "bs1770gain": Bs1770gainBackend, "ffmpeg": FfmpegBackend, } diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index 004439bac..04e903512 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -29,26 +29,46 @@ import string import requests +from binascii import hexlify from beets import config from beets.plugins import BeetsPlugin __author__ = 'https://github.com/maffo999' +AUTH_TOKEN_VERSION = (1, 12) class SubsonicUpdate(BeetsPlugin): def __init__(self): super(SubsonicUpdate, self).__init__() - # Set default configuration values config['subsonic'].add({ 'user': 'admin', 'pass': 'admin', 'url': 'http://localhost:4040', }) - config['subsonic']['pass'].redact = True + self._version = None + self._auth = None self.register_listener('import', self.start_scan) + @property + def version(self): + if self._version is None: + self._version = self.__get_version() + return self._version + + @property + def auth(self): + if self._auth is None: + if self.version is not None: + if self.version > AUTH_TOKEN_VERSION: + self._auth = "token" + else: + self._auth = "password" + self._log.info( + u"using '{}' authentication method".format(self._auth)) + return self._auth + @staticmethod def __create_token(): """Create salt and token from given password. @@ -67,10 +87,10 @@ class SubsonicUpdate(BeetsPlugin): return salt, token @staticmethod - def __format_url(): - """Get the Subsonic URL to trigger a scan. Uses either the url - config option or the deprecated host, port, and context_path config - options together. + def __format_url(endpoint): + """Get the Subsonic URL to trigger the given endpoint. + Uses either the url config option or the deprecated host, port, + and context_path config options together. :return: Endpoint for updating Subsonic """ @@ -88,22 +108,55 @@ class SubsonicUpdate(BeetsPlugin): context_path = '' url = "http://{}:{}{}".format(host, port, context_path) - return url + '/rest/startScan' - - def start_scan(self): - user = config['subsonic']['user'].as_str() - url = self.__format_url() - salt, token = self.__create_token() + return url + '/rest/{}'.format(endpoint) + def __get_version(self): + url = self.__format_url("ping.view") payload = { - 'u': user, - 't': token, - 's': salt, - 'v': '1.15.0', # Subsonic 6.1 and newer. 'c': 'beets', 'f': 'json' } + try: + response = requests.get(url, params=payload) + if response.status_code == 200: + json = response.json() + version = json['subsonic-response']['version'] + self._log.info( + u'subsonic version:{0} '.format(version)) + return tuple(int(s) for s in version.split('.')) + else: + self._log.error(u'Error: {0}', json) + return None + except Exception as error: + self._log.error(u'Error: {0}'.format(error)) + return None + def start_scan(self): + user = config['subsonic']['user'].as_str() + url = self.__format_url("startScan.view") + + if self.auth == 'token': + salt, token = self.__create_token() + payload = { + 'u': user, + 't': token, + 's': salt, + 'v': self.version, # Subsonic 6.1 and newer. + 'c': 'beets', + 'f': 'json' + } + elif self.auth == 'password': + password = config['subsonic']['pass'].as_str() + encpass = hexlify(password.encode()).decode() + payload = { + 'u': user, + 'p': 'enc:{}'.format(encpass), + 'v': self.version, + 'c': 'beets', + 'f': 'json' + } + else: + return try: response = requests.get(url, params=payload) json = response.json() diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index a982809c4..e80c8c29e 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -59,7 +59,10 @@ def _rep(obj, expand=False): return out elif isinstance(obj, beets.library.Album): - del out['artpath'] + if app.config.get('INCLUDE_PATHS', False): + out['artpath'] = util.displayable_path(out['artpath']) + else: + del out['artpath'] if expand: out['items'] = [_rep(item) for item in obj.items()] return out diff --git a/docs/changelog.rst b/docs/changelog.rst index e6d33d9f1..df286a10a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,11 @@ Changelog New features: +* :doc:`/plugins/mpdstats`: Add strip_path option to help build the right local path + from MPD information +* Submitting acoustID information on tracks which already have a fingerprint + :bug:`3834` +* conversion uses par_map to parallelize conversion jobs in python3 * Add ``title_case`` config option to lastgenre to make TitleCasing optional. * When config is printed with no available configuration a new message is printed. :bug:`3779` @@ -14,6 +19,8 @@ New features: * :doc:`/plugins/chroma`: Update file metadata after generating fingerprints through the `submit` command. * :doc:`/plugins/lastgenre`: Added more heavy metal genres: https://en.wikipedia.org/wiki/Heavy_metal_genres to genres.txt and genres-tree.yaml * :doc:`/plugins/subsonicplaylist`: import playlist from a subsonic server. +* :doc:`/plugins/subsonicupdate`: Automatically choose between token and + password-based authentication based on server version * A new :ref:`reflink` config option instructs the importer to create fast, copy-on-write file clones on filesystems that support them. Thanks to :user:`rubdos`. @@ -59,14 +66,12 @@ New features: Thanks to :user:`samuelnilsson` :bug:`293` * :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports - ``R128_`` tags, just like the ``bs1770gain`` backend. + ``R128_`` tags. :bug:`3056` * :doc:`plugins/replaygain`: ``r128_targetlevel`` is a new configuration option for the ReplayGain plugin: It defines the reference volume for files using ``R128_`` tags. ``targetlevel`` only configures the reference volume for ``REPLAYGAIN_`` files. - This also deprecates the ``bs1770gain`` ReplayGain backend's ``method`` - option. Use ``targetlevel`` and ``r128_targetlevel`` instead. :bug:`3065` * A new :doc:`/plugins/parentwork` gets information about the original work, which is useful for classical music. @@ -171,16 +176,24 @@ New features: https://github.com/alastair/python-musicbrainzngs/pull/266 . Thanks to :user:`aereaux`. * :doc:`/plugins/replaygain` now does its analysis in parallel when using - the ``command``, ``ffmpeg`` or ``bs1770gain`` backends. + the ``command`` or ``ffmpeg`` backends. :bug:`3478` * Fields in queries now fall back to an item's album and check its fields too. Notably, this allows querying items by an album flex attribute, also in path configuration. Thanks to :user:`FichteFoll`. :bug:`2797` :bug:`2988` +* Removes usage of the bs1770gain replaygain backend. + Thanks to :user:`SamuelCook`. +* Added ``trackdisambig`` which stores the recording disambiguation from + MusicBrainz for each track. + :bug:`1904` Fixes: +* :bug:`/plugins/web`: Fixed a small bug which caused album artpath to be + redacted even when ``include_paths`` option is set. + :bug:`3866` * :bug:`/plugins/discogs`: Fixed a bug with ``index_tracks`` options that sometimes caused the index to be discarded. Also remove the extra semicolon that was added when there is no index track. @@ -239,8 +252,6 @@ Fixes: :bug:`3437` * :doc:`/plugins/lyrics`: Fix a corner-case with Genius lowercase artist names :bug:`3446` -* :doc:`/plugins/replaygain`: Support ``bs1770gain`` v0.6.0 and up - :bug:`3480` * :doc:`/plugins/parentwork`: Don't save tracks when nothing has changed. :bug:`3492` * Added a warning when configuration files defined in the `include` directive @@ -300,6 +311,10 @@ Fixes: :bug:`3819` * :doc:`/plugins/mpdstats`: Fix Python 2/3 compatibility :bug:`3798` +* Fix :bug:`3308` by using browsing for big releases to retrieve additional + information. Thanks to :user:`dosoe`. +* :doc:`/plugins/discogs`: Replace deprecated discogs-client library with community + supported python3-discogs-client library. :bug:`3608` For plugin developers: @@ -356,6 +371,7 @@ For packagers: or `repair `_ the test may no longer be necessary. * This version drops support for Python 3.4. +* Removes the optional dependency on bs1770gain. .. _Fish shell: https://fishshell.com/ .. _MediaFile: https://github.com/beetbox/mediafile diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index c199ccf49..40875b022 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -10,9 +10,9 @@ Installation ------------ To use the ``discogs`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install the `discogs-client`_ library by typing:: +:ref:`using-plugins`). Then, install the `python3-discogs-client`_ library by typing:: - pip install discogs-client + pip install python3-discogs-client You will also need to register for a `Discogs`_ account, and provide authentication credentials via a personal access token or an OAuth2 @@ -36,7 +36,7 @@ Authentication via Personal Access Token As an alternative to OAuth, you can get a token from Discogs and add it to your configuration. -To get a personal access token (called a "user token" in the `discogs-client`_ +To get a personal access token (called a "user token" in the `python3-discogs-client`_ documentation), login to `Discogs`_, and visit the `Developer settings page `_. Press the ``Generate new @@ -89,4 +89,4 @@ Here are two things you can try: * Make sure that your system clock is accurate. The Discogs servers can reject your request if your clock is too out of sync. -.. _discogs-client: https://github.com/discogs/discogs_client +.. _python3-discogs-client: https://github.com/joalla/discogs_client diff --git a/docs/plugins/mpdstats.rst b/docs/plugins/mpdstats.rst index de9b2ca59..c5adbc64b 100644 --- a/docs/plugins/mpdstats.rst +++ b/docs/plugins/mpdstats.rst @@ -53,6 +53,9 @@ configuration file. The available options are: - **music_directory**: If your MPD library is at a different location from the beets library (e.g., because one is mounted on a NFS share), specify the path here. +- **strip_path**: If your MPD library contains local path, specify the part to remove + here. Combining this with **music_directory** you can mangle MPD path to match the + beets library one. Default: The beets library directory. - **rating**: Enable rating updates. Default: ``yes``. diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst index 4b069a944..16dd43174 100644 --- a/docs/plugins/web.rst +++ b/docs/plugins/web.rst @@ -261,6 +261,8 @@ For albums, the following endpoints are provided: * ``GET /album/5`` +* ``GET /album/5/art`` + * ``DELETE /album/5`` * ``GET /album/5,7`` diff --git a/setup.py b/setup.py index a6cfc9310..ec1d2c819 100755 --- a/setup.py +++ b/setup.py @@ -113,7 +113,6 @@ setup( 'test': [ 'beautifulsoup4', 'coverage', - 'discogs-client', 'flask', 'mock', 'pylast', @@ -128,6 +127,9 @@ setup( ['pathlib'] if (sys.version_info < (3, 4, 0)) else [] ) + [ 'rarfile<4' if sys.version_info < (3, 6, 0) else 'rarfile', + ] + [ + 'discogs-client' if (sys.version_info < (3, 0, 0)) + else 'python3-discogs-client' ], 'lint': [ 'flake8', @@ -144,7 +146,10 @@ setup( 'embyupdate': ['requests'], 'chroma': ['pyacoustid'], 'gmusic': ['gmusicapi'], - 'discogs': ['discogs-client>=2.2.1'], + 'discogs': ( + ['discogs-client' if (sys.version_info < (3, 0, 0)) + else 'python3-discogs-client'] + ), 'beatport': ['requests-oauthlib>=0.6.1'], 'kodiupdate': ['requests'], 'lastgenre': ['pylast'], diff --git a/test/test_mb.py b/test/test_mb.py index de1ffd9a7..9eca57c80 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -111,7 +111,8 @@ class MBAlbumInfoTest(_common.TestCase): }) return release - def _make_track(self, title, tr_id, duration, artist=False, video=False): + def _make_track(self, title, tr_id, duration, artist=False, video=False, + disambiguation=None): track = { 'title': title, 'id': tr_id, @@ -131,6 +132,8 @@ class MBAlbumInfoTest(_common.TestCase): ] if video: track['video'] = 'true' + if disambiguation: + track['disambiguation'] = disambiguation return track def test_parse_release_with_year(self): @@ -445,6 +448,18 @@ class MBAlbumInfoTest(_common.TestCase): self.assertEqual(d.tracks[1].title, 'TITLE TWO') self.assertEqual(d.tracks[2].title, 'TITLE VIDEO') + def test_track_disambiguation(self): + tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), + self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0, + disambiguation="SECOND TRACK")] + release = self._make_release(tracks=tracks) + + d = mb.album_info(release) + t = d.tracks + self.assertEqual(len(t), 2) + self.assertEqual(t[0].trackdisambig, None) + self.assertEqual(t[1].trackdisambig, "SECOND TRACK") + class ParseIDTest(_common.TestCase): def test_parse_id_correct(self): diff --git a/test/test_replaygain.py b/test/test_replaygain.py index 0100b520e..53daafbb9 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -16,18 +16,14 @@ from __future__ import division, absolute_import, print_function -import unittest import six - -from mock import patch -from test.helper import TestHelper, capture_log, has_program +import unittest +from mediafile import MediaFile from beets import config -from beets.util import CommandOutput -from mediafile import MediaFile from beetsplug.replaygain import (FatalGstreamerPluginReplayGainError, GStreamerBackend) - +from test.helper import TestHelper, has_program try: import gi @@ -41,11 +37,6 @@ if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']): else: GAIN_PROG_AVAILABLE = False -if has_program('bs1770gain'): - LOUDNESS_PROG_AVAILABLE = True -else: - LOUDNESS_PROG_AVAILABLE = False - FFMPEG_AVAILABLE = has_program('ffmpeg', ['-version']) @@ -153,9 +144,7 @@ class ReplayGainCliTestBase(TestHelper): self.assertEqual(max(gains), min(gains)) self.assertNotEqual(max(gains), 0.0) - if not self.backend == "bs1770gain": - # Actually produces peaks == 0.0 ~ self.add_album_fixture - self.assertNotEqual(max(peaks), 0.0) + self.assertNotEqual(max(peaks), 0.0) def test_cli_writes_only_r128_tags(self): if self.backend == "command": @@ -219,62 +208,6 @@ class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'command' -@unittest.skipIf(not LOUDNESS_PROG_AVAILABLE, u'bs1770gain cannot be found') -class ReplayGainLdnsCliTest(ReplayGainCliTestBase, unittest.TestCase): - backend = u'bs1770gain' - - -class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase): - @patch('beetsplug.replaygain.call') - def setUp(self, call_patch): - self.setup_beets() - self.config['replaygain']['backend'] = 'bs1770gain' - - # Patch call to return nothing, bypassing the bs1770gain installation - # check. - call_patch.return_value = CommandOutput( - stdout=b'bs1770gain 0.0.0, ', stderr=b'' - ) - try: - self.load_plugins('replaygain') - except Exception: - import sys - exc_info = sys.exc_info() - try: - self.tearDown() - except Exception: - pass - six.reraise(exc_info[1], None, exc_info[2]) - - for item in self.add_album_fixture(2).items(): - reset_replaygain(item) - - def tearDown(self): - self.teardown_beets() - self.unload_plugins() - - @patch('beetsplug.replaygain.call') - def test_malformed_output(self, call_patch): - # Return malformed XML (the ampersand should be &) - call_patch.return_value = CommandOutput(stdout=b""" - - - - - - - """, stderr="") - - with capture_log('beets.replaygain') as logs: - self.run_command('replaygain') - - # Count how many lines match the expected error. - matching = [line for line in logs if - 'malformed XML' in line] - - self.assertEqual(len(matching), 2) - - @unittest.skipIf(not FFMPEG_AVAILABLE, u'ffmpeg cannot be found') class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'ffmpeg' diff --git a/test/test_subsonicupdate.py b/test/test_subsonicupdate.py index c47208e65..dd254d593 100644 --- a/test/test_subsonicupdate.py +++ b/test/test_subsonicupdate.py @@ -39,9 +39,21 @@ class SubsonicPluginTest(_common.TestCase, TestHelper): config["subsonic"]["user"] = "admin" config["subsonic"]["pass"] = "admin" config["subsonic"]["url"] = "http://localhost:4040" - + responses.add( + responses.GET, + 'http://localhost:4040/rest/ping.view', + status=200, + body=self.PING_BODY + ) self.subsonicupdate = subsonicupdate.SubsonicUpdate() - + PING_BODY = ''' +{ + "subsonic-response": { + "status": "failed", + "version": "1.15.0" + } +} +''' SUCCESS_BODY = ''' { "subsonic-response": { diff --git a/test/test_web.py b/test/test_web.py index e7d1f334f..88be31365 100644 --- a/test/test_web.py +++ b/test/test_web.py @@ -22,10 +22,16 @@ class WebPluginTest(_common.LibTestCase): # Add fixtures for track in self.lib.items(): track.remove() - self.lib.add(Item(title=u'title', path='/path_1', id=1)) - self.lib.add(Item(title=u'another title', path='/path_2', id=2)) - self.lib.add(Album(album=u'album', id=3)) - self.lib.add(Album(album=u'another album', id=4)) + + # Add library elements. Note that self.lib.add overrides any "id=" + # and assigns the next free id number. + # The following adds will create items #1, #2 and #3 + self.lib.add(Item(title=u'title', path='/path_1', album_id=2)) + self.lib.add(Item(title=u'another title', path='/path_2')) + self.lib.add(Item(title=u'and a third')) + # The following adds will create albums #1 and #2 + self.lib.add(Album(album=u'album')) + self.lib.add(Album(album=u'other album', artpath='/art_path_2')) web.app.config['TESTING'] = True web.app.config['lib'] = self.lib @@ -40,6 +46,14 @@ class WebPluginTest(_common.LibTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(res_json['path'], u'/path_1') + def test_config_include_artpaths_true(self): + web.app.config['INCLUDE_PATHS'] = True + response = self.client.get('/album/2') + res_json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(res_json['artpath'], u'/art_path_2') + def test_config_include_paths_false(self): web.app.config['INCLUDE_PATHS'] = False response = self.client.get('/item/1') @@ -48,12 +62,20 @@ class WebPluginTest(_common.LibTestCase): self.assertEqual(response.status_code, 200) self.assertNotIn('path', res_json) + def test_config_include_artpaths_false(self): + web.app.config['INCLUDE_PATHS'] = False + response = self.client.get('/album/2') + res_json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertNotIn('artpath', res_json) + def test_get_all_items(self): response = self.client.get('/item/') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) - self.assertEqual(len(res_json['items']), 2) + self.assertEqual(len(res_json['items']), 3) def test_get_single_item_by_id(self): response = self.client.get('/item/1') @@ -73,7 +95,7 @@ class WebPluginTest(_common.LibTestCase): assertCountEqual(self, response_titles, [u'title', u'another title']) def test_get_single_item_not_found(self): - response = self.client.get('/item/3') + response = self.client.get('/item/4') self.assertEqual(response.status_code, 404) def test_get_single_item_by_path(self): @@ -98,7 +120,7 @@ class WebPluginTest(_common.LibTestCase): res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) - self.assertEqual(len(res_json['items']), 2) + self.assertEqual(len(res_json['items']), 3) def test_get_simple_item_query(self): response = self.client.get('/item/query/another') @@ -115,7 +137,7 @@ class WebPluginTest(_common.LibTestCase): self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in res_json['albums']] - assertCountEqual(self, response_albums, [u'album', u'another album']) + assertCountEqual(self, response_albums, [u'album', u'other album']) def test_get_single_album_by_id(self): response = self.client.get('/album/2') @@ -123,7 +145,7 @@ class WebPluginTest(_common.LibTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], 2) - self.assertEqual(res_json['album'], u'another album') + self.assertEqual(res_json['album'], u'other album') def test_get_multiple_albums_by_id(self): response = self.client.get('/album/1,2') @@ -131,7 +153,7 @@ class WebPluginTest(_common.LibTestCase): self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in res_json['albums']] - assertCountEqual(self, response_albums, [u'album', u'another album']) + assertCountEqual(self, response_albums, [u'album', u'other album']) def test_get_album_empty_query(self): response = self.client.get('/album/query/') @@ -140,6 +162,34 @@ class WebPluginTest(_common.LibTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['albums']), 2) + def test_get_simple_album_query(self): + response = self.client.get('/album/query/other') + res_json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(res_json['results']), 1) + self.assertEqual(res_json['results'][0]['album'], + u'other album') + self.assertEqual(res_json['results'][0]['id'], 2) + + def test_get_album_details(self): + response = self.client.get('/album/2?expand') + res_json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(res_json['items']), 1) + self.assertEqual(res_json['items'][0]['album'], + u'other album') + self.assertEqual(res_json['items'][0]['id'], 1) + + def test_get_stats(self): + response = self.client.get('/stats') + res_json = json.loads(response.data.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(res_json['items'], 3) + self.assertEqual(res_json['albums'], 2) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/tox.ini b/tox.ini index 69308235d..64acf1402 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,7 @@ commands = lint: python -m flake8 {posargs} {[_lint]files} [testenv:docs] -basepython = python2.7 +basepython = python3.9 deps = sphinx commands = sphinx-build -W -q -b html docs {envtmpdir}/html {posargs}