Merge branch 'master' into pr/item-album-fallback

This commit is contained in:
Adrian Sampson 2021-03-07 09:20:50 -05:00 committed by GitHub
commit 09a6ec4f74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 275 additions and 391 deletions

View file

@ -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: |

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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'),

View file

@ -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,
}

View file

@ -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()

View file

@ -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

View file

@ -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 <https://build.opensuse.org/package/view_file/openSUSE:Factory/beets/fix_test_command_line_option_relative_to_working_dir.diff?expand=1>`_
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

View file

@ -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
<https://www.discogs.com/settings/developers>`_. 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

View file

@ -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``.

View file

@ -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``

View file

@ -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'],

View file

@ -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):

View file

@ -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 &amp;)
call_patch.return_value = CommandOutput(stdout=b"""
<album>
<track total="1" number="1" file="&">
<integrated lufs="0" lu="0" />
<sample-peak spfs="0" factor="0" />
</track>
</album>
""", 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'

View file

@ -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": {

View file

@ -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=<n>"
# 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__)

View file

@ -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}