Merge pull request #2739 from wisp3rwind/fetchart_advanced_sources

Fetchart fine-grained source configuration (alternative to #2733)
This commit is contained in:
Adrian Sampson 2018-02-24 10:40:23 -05:00
commit 726311d25b
5 changed files with 164 additions and 32 deletions

View file

@ -502,9 +502,50 @@ def sanitize_choices(choices, choices_all):
others = [x for x in choices_all if x not in choices]
res = []
for s in choices:
if s in list(choices_all) + ['*']:
if not (s in seen or seen.add(s)):
res.extend(list(others) if s == '*' else [s])
if s not in seen:
if s in list(choices_all):
res.append(s)
elif s == '*':
res.extend(others)
seen.add(s)
return res
def sanitize_pairs(pairs, pairs_all):
"""Clean up a single-element mapping configuration attribute as returned
by `confit`'s `Pairs` template: keep only two-element tuples present in
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
wildcards while keeping the original order. Note that ('*', '*') and
('*', 'whatever') have the same effect.
For example,
>>> sanitize_pairs(
... [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
... ('key', 'value')]
... )
[('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
"""
pairs_all = list(pairs_all)
seen = set()
others = [x for x in pairs_all if x not in pairs]
res = []
for k, values in pairs:
for v in values.split():
x = (k, v)
if x in pairs_all:
if x not in seen:
seen.add(x)
res.append(x)
elif k == '*':
new = [o for o in others if o not in seen]
seen.update(new)
res.extend(new)
elif v == '*':
new = [o for o in others if o not in seen and o[0] == k]
seen.update(new)
res.extend(new)
return res

View file

@ -413,6 +413,12 @@ class ConfigView(object):
"""
return self.get(StrSeq(split=split))
def as_pairs(self, default_value=None):
"""Get the value as a sequence of pairs of two strings. Equivalent to
`get(Pairs())`.
"""
return self.get(Pairs(default_value=default_value))
def as_str(self):
"""Get the value as a (Unicode) string. Equivalent to
`get(unicode)` on Python 2 and `get(str)` on Python 3.
@ -1242,30 +1248,77 @@ class StrSeq(Template):
super(StrSeq, self).__init__()
self.split = split
def _convert_value(self, x, view):
if isinstance(x, STRING):
return x
elif isinstance(x, bytes):
return x.decode('utf-8', 'ignore')
else:
self.fail(u'must be a list of strings', view, True)
def convert(self, value, view):
if isinstance(value, bytes):
value = value.decode('utf-8', 'ignore')
if isinstance(value, STRING):
if self.split:
return value.split()
value = value.split()
else:
return [value]
value = [value]
else:
try:
value = list(value)
except TypeError:
self.fail(u'must be a whitespace-separated string or a list',
view, True)
return [self._convert_value(v, view) for v in value]
class Pairs(StrSeq):
"""A template for ordered key-value pairs.
This can either be given with the same syntax as for `StrSeq` (i.e. without
values), or as a list of strings and/or single-element mappings such as::
- key: value
- [key, value]
- key
The result is a list of two-element tuples. If no value is provided, the
`default_value` will be returned as the second element.
"""
def __init__(self, default_value=None):
"""Create a new template.
`default` is the dictionary value returned for items that are not
a mapping, but a single string.
"""
super(Pairs, self).__init__(split=True)
self.default_value = default_value
def _convert_value(self, x, view):
try:
value = list(value)
except TypeError:
self.fail(u'must be a whitespace-separated string or a list',
view, True)
def convert(x):
if isinstance(x, STRING):
return x
elif isinstance(x, bytes):
return x.decode('utf-8', 'ignore')
return (super(Pairs, self)._convert_value(x, view),
self.default_value)
except ConfigTypeError:
if isinstance(x, collections.Mapping):
if len(x) != 1:
self.fail(u'must be a single-element mapping', view, True)
k, v = iter_first(x.items())
elif isinstance(x, collections.Sequence):
if len(x) != 2:
self.fail(u'must be a two-element list', view, True)
k, v = x
else:
self.fail(u'must be a list of strings', view, True)
return list(map(convert, value))
# Is this even possible? -> Likely, if some !directive cause
# YAML to parse this to some custom type.
self.fail(u'must be a single string, mapping, or a list'
u'' + str(x),
view, True)
return (super(Pairs, self)._convert_value(k, view),
super(Pairs, self)._convert_value(v, view))
class Filename(Template):

View file

@ -192,9 +192,12 @@ class RequestMixin(object):
# ART SOURCES ################################################################
class ArtSource(RequestMixin):
def __init__(self, log, config):
VALID_MATCHING_CRITERIA = ['default']
def __init__(self, log, config, match_by=None):
self._log = log
self._config = config
self.match_by = match_by or self.VALID_MATCHING_CRITERIA
def get(self, album, plugin, paths):
raise NotImplementedError()
@ -289,6 +292,7 @@ class RemoteArtSource(ArtSource):
class CoverArtArchive(RemoteArtSource):
NAME = u"Cover Art Archive"
VALID_MATCHING_CRITERIA = ['release', 'releasegroup']
if util.SNI_SUPPORTED:
URL = 'https://coverartarchive.org/release/{mbid}/front'
@ -301,10 +305,10 @@ class CoverArtArchive(RemoteArtSource):
"""Return the Cover Art Archive and Cover Art Archive release group URLs
using album MusicBrainz release ID and release group ID.
"""
if album.mb_albumid:
if 'release' in self.match_by and album.mb_albumid:
yield self._candidate(url=self.URL.format(mbid=album.mb_albumid),
match=Candidate.MATCH_EXACT)
if album.mb_releasegroupid:
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
yield self._candidate(
url=self.GROUP_URL.format(mbid=album.mb_releasegroupid),
match=Candidate.MATCH_FALLBACK)
@ -757,21 +761,30 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
if not self.config['google_key'].get() and \
u'google' in available_sources:
available_sources.remove(u'google')
sources_name = plugins.sanitize_choices(
self.config['sources'].as_str_seq(), available_sources)
available_sources = [(s, c)
for s in available_sources
for c in ART_SOURCES[s].VALID_MATCHING_CRITERIA]
sources = plugins.sanitize_pairs(
self.config['sources'].as_pairs(default_value='*'),
available_sources)
if 'remote_priority' in self.config:
self._log.warning(
u'The `fetch_art.remote_priority` configuration option has '
u'been deprecated. Instead, place `filesystem` at the end of '
u'your `sources` list.')
if self.config['remote_priority'].get(bool):
try:
sources_name.remove(u'filesystem')
sources_name.append(u'filesystem')
except ValueError:
pass
self.sources = [ART_SOURCES[s](self._log, self.config)
for s in sources_name]
fs = []
others = []
for s, c in sources:
if s == 'filesystem':
fs.append((s, c))
else:
others.append((s, c))
sources = others + fs
self.sources = [ART_SOURCES[s](self._log, self.config, match_by=[c])
for s, c in sources]
# Asynchronous; after music is added to the library.
def fetch_art(self, session, task):

View file

@ -12,6 +12,9 @@ New features:
recording skipped directories to the incremental list, so you can revisit them
later. Thanks to :user:`sekjun9878`.
:bug:`2773`
* :doc:`/plugins/fetchart`: extended syntax for the ``sources`` option to give
fine-grained control over the search order for backends with several matching
strategies.
Fixes:

View file

@ -54,7 +54,8 @@ file. The available options are:
matches at the cost of some speed. They are searched in the given order,
thus in the default config, no remote (Web) art source are queried if
local art is found in the filesystem. To use a local image as fallback,
move it to the end of the list.
move it to the end of the list. For even more fine-grained control over
the search order, see the section on :ref:`album-art-sources` below.
- **google_key**: Your Google API key (to enable the Google Custom Search
backend).
Default: None.
@ -104,8 +105,6 @@ already have it; the ``-f`` or ``--force`` switch makes it search for art
in Web databases regardless. If you specify a query, only matching albums will
be processed; otherwise, the command processes every album in your library.
.. _image-resizing:
Display Only Missing Album Art
------------------------------
@ -117,6 +116,8 @@ art::
By default the command will display all results, the ``-q`` or ``--quiet``
switch will only display results for album arts that are still missing.
.. _image-resizing:
Image Resizing
--------------
@ -135,6 +136,8 @@ environment variable so that ImageMagick comes first or use Pillow instead.
.. _Pillow: https://github.com/python-pillow/Pillow
.. _ImageMagick: http://www.imagemagick.org/
.. _album-art-sources:
Album Art Sources
-----------------
@ -150,6 +153,25 @@ file whose name contains "cover", "front", "art", "album" or "folder", but in
the absence of well-known names, it will use any image file in the same folder
as your music files.
For some of the art sources, the backend service can match artwork by various
criteria. If you want finer control over the search order in such cases, the
following alternative syntax for the ``sources`` option can be used::
fetchart:
sources:
- filesystem
- coverart: release
- itunes
- coverart: releasegroup
- '*'
where listing a source without matching criteria will default to trying all
available strategies. Entries of the forms ``coverart: release releasegroup``
and ``coverart: *`` are also valid.
Currently, the ``coverart`` source is the only backend to support several
such values, namely ``release`` and ``releasegroup``, which refer to the
respective MusicBrainz IDs.
When you choose to apply changes during an import, beets will search for art as
described above. For "as-is" imports (and non-autotagged imports using the
``-A`` flag), beets only looks for art on the local filesystem.