mirror of
https://github.com/beetbox/beets.git
synced 2026-02-21 14:56:02 +01:00
Merge branch 'master' into pr/item-album-fallback
This commit is contained in:
commit
09a6ec4f74
21 changed files with 275 additions and 391 deletions
18
.github/workflows/ci.yaml
vendored
18
.github/workflows/ci.yaml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
9
setup.py
9
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'],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<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'
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
2
tox.ini
2
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}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue