mirror of
https://github.com/beetbox/beets.git
synced 2025-12-27 02:52:33 +01:00
Merge branch 'master' into sonos
This commit is contained in:
commit
7d45eabb25
26 changed files with 391 additions and 103 deletions
30
README.rst
30
README.rst
|
|
@ -8,6 +8,9 @@
|
|||
:target: https://travis-ci.org/beetbox/beets
|
||||
|
||||
|
||||
beets
|
||||
=====
|
||||
|
||||
Beets is the media library management system for obsessive-compulsive music
|
||||
geeks.
|
||||
|
||||
|
|
@ -72,24 +75,37 @@ shockingly simple if you know a little Python.
|
|||
.. _MusicBrainz: http://musicbrainz.org/
|
||||
.. _Beatport: https://www.beatport.com
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
You can install beets by typing ``pip install beets``. Then check out the
|
||||
`Getting Started`_ guide.
|
||||
|
||||
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
||||
|
||||
Contribute
|
||||
----------
|
||||
|
||||
Check out the `Hacking`_ page on the wiki for tips on how to help out.
|
||||
You might also be interested in the `For Developers`_ section in the docs.
|
||||
|
||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
||||
.. _For Developers: http://docs.beets.io/page/dev/
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
||||
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
|
||||
news and updates.
|
||||
|
||||
You can install beets by typing ``pip install beets``. Then check out the
|
||||
`Getting Started`_ guide.
|
||||
|
||||
.. _its Web site: http://beets.io/
|
||||
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
||||
.. _@b33ts: http://twitter.com/b33ts/
|
||||
|
||||
Authors
|
||||
-------
|
||||
|
||||
Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
|
||||
please contact the `mailing list`_.
|
||||
please visit our `forum`_.
|
||||
|
||||
.. _mailing list: https://groups.google.com/forum/#!forum/beets-users
|
||||
.. _Adrian Sampson: http://homes.cs.washington.edu/~asampson/
|
||||
.. _forum: https://discourse.beets.io
|
||||
.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/
|
||||
|
|
|
|||
|
|
@ -63,12 +63,19 @@ def apply_metadata(album_info, mapping):
|
|||
mapping from Items to TrackInfo objects.
|
||||
"""
|
||||
for item, track_info in mapping.items():
|
||||
# Album, artist, track count.
|
||||
if track_info.artist:
|
||||
item.artist = track_info.artist
|
||||
# Artist or artist credit.
|
||||
if config['artist_credit']:
|
||||
item.artist = (track_info.artist_credit or
|
||||
track_info.artist or
|
||||
album_info.artist_credit or
|
||||
album_info.artist)
|
||||
item.albumartist = (album_info.artist_credit or
|
||||
album_info.artist)
|
||||
else:
|
||||
item.artist = album_info.artist
|
||||
item.albumartist = album_info.artist
|
||||
item.artist = (track_info.artist or album_info.artist)
|
||||
item.albumartist = album_info.artist
|
||||
|
||||
# Album.
|
||||
item.album = album_info.album
|
||||
|
||||
# Artist sort and credit names.
|
||||
|
|
|
|||
|
|
@ -118,8 +118,8 @@ def _preferred_release_event(release):
|
|||
"""
|
||||
countries = config['match']['preferred']['countries'].as_str_seq()
|
||||
|
||||
for event in release.get('release-event-list', {}):
|
||||
for country in countries:
|
||||
for country in countries:
|
||||
for event in release.get('release-event-list', {}):
|
||||
try:
|
||||
if country in event['area']['iso-3166-1-code-list']:
|
||||
return country, event['date']
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ per_disc_numbering: no
|
|||
verbose: 0
|
||||
terminal_encoding:
|
||||
original_date: no
|
||||
artist_credit: no
|
||||
id3v23: no
|
||||
va_name: "Various Artists"
|
||||
|
||||
|
|
@ -127,7 +128,7 @@ match:
|
|||
original_year: no
|
||||
ignored: []
|
||||
required: []
|
||||
ignored_media: ['Data CD', 'DVD', 'DVD-Video', 'Blu-ray', 'HD-DVD', 'VCD', 'SVCD', 'UMD', 'VHS']
|
||||
ignored_media: []
|
||||
ignore_video_tracks: yes
|
||||
track_length_grace: 10
|
||||
track_length_max: 30
|
||||
|
|
|
|||
|
|
@ -708,7 +708,7 @@ class DateQuery(FieldQuery):
|
|||
if self.field not in item:
|
||||
return False
|
||||
timestamp = float(item[self.field])
|
||||
date = datetime.utcfromtimestamp(timestamp)
|
||||
date = datetime.fromtimestamp(timestamp)
|
||||
return self.interval.contains(date)
|
||||
|
||||
_clause_tmpl = "{0} {1} ?"
|
||||
|
|
|
|||
|
|
@ -313,6 +313,8 @@ class ImportSession(object):
|
|||
stages += [import_asis(self)]
|
||||
|
||||
# Plugin stages.
|
||||
for stage_func in plugins.early_import_stages():
|
||||
stages.append(plugin_stage(self, stage_func))
|
||||
for stage_func in plugins.import_stages():
|
||||
stages.append(plugin_stage(self, stage_func))
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class BeetsPlugin(object):
|
|||
self.template_fields = {}
|
||||
if not self.album_template_fields:
|
||||
self.album_template_fields = {}
|
||||
self.early_import_stages = []
|
||||
self.import_stages = []
|
||||
|
||||
self._log = log.getChild(self.name)
|
||||
|
|
@ -94,6 +95,22 @@ class BeetsPlugin(object):
|
|||
"""
|
||||
return ()
|
||||
|
||||
def _set_stage_log_level(self, stages):
|
||||
"""Adjust all the stages in `stages` to WARNING logging level.
|
||||
"""
|
||||
return [self._set_log_level_and_params(logging.WARNING, stage)
|
||||
for stage in stages]
|
||||
|
||||
def get_early_import_stages(self):
|
||||
"""Return a list of functions that should be called as importer
|
||||
pipelines stages early in the pipeline.
|
||||
|
||||
The callables are wrapped versions of the functions in
|
||||
`self.early_import_stages`. Wrapping provides some bookkeeping for the
|
||||
plugin: specifically, the logging level is adjusted to WARNING.
|
||||
"""
|
||||
return self._set_stage_log_level(self.early_import_stages)
|
||||
|
||||
def get_import_stages(self):
|
||||
"""Return a list of functions that should be called as importer
|
||||
pipelines stages.
|
||||
|
|
@ -102,8 +119,7 @@ class BeetsPlugin(object):
|
|||
`self.import_stages`. Wrapping provides some bookkeeping for the
|
||||
plugin: specifically, the logging level is adjusted to WARNING.
|
||||
"""
|
||||
return [self._set_log_level_and_params(logging.WARNING, import_stage)
|
||||
for import_stage in self.import_stages]
|
||||
return self._set_stage_log_level(self.import_stages)
|
||||
|
||||
def _set_log_level_and_params(self, base_log_level, func):
|
||||
"""Wrap `func` to temporarily set this plugin's logger level to
|
||||
|
|
@ -393,6 +409,14 @@ def template_funcs():
|
|||
return funcs
|
||||
|
||||
|
||||
def early_import_stages():
|
||||
"""Get a list of early import stage functions defined by plugins."""
|
||||
stages = []
|
||||
for plugin in find_plugins():
|
||||
stages += plugin.get_early_import_stages()
|
||||
return stages
|
||||
|
||||
|
||||
def import_stages():
|
||||
"""Get a list of import stage functions defined by plugins."""
|
||||
stages = []
|
||||
|
|
@ -478,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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1758,7 +1758,7 @@ def completion_script(commands):
|
|||
# Command aliases
|
||||
yield u" local aliases='%s'\n" % ' '.join(aliases.keys())
|
||||
for alias, cmd in aliases.items():
|
||||
yield u" local alias__%s=%s\n" % (alias, cmd)
|
||||
yield u" local alias__%s=%s\n" % (alias.replace('-', '_'), cmd)
|
||||
yield u'\n'
|
||||
|
||||
# Fields
|
||||
|
|
@ -1775,7 +1775,7 @@ def completion_script(commands):
|
|||
if option_list:
|
||||
option_list = u' '.join(option_list)
|
||||
yield u" local %s__%s='%s'\n" % (
|
||||
option_type, cmd, option_list)
|
||||
option_type, cmd.replace('-', '_'), option_list)
|
||||
|
||||
yield u' _beet_dispatch\n'
|
||||
yield u'}\n'
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ _beet_dispatch() {
|
|||
|
||||
# Replace command shortcuts
|
||||
if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then
|
||||
eval "cmd=\$alias__$cmd"
|
||||
eval "cmd=\$alias__${cmd//-/_}"
|
||||
fi
|
||||
|
||||
case $cmd in
|
||||
|
|
@ -94,8 +94,8 @@ _beet_dispatch() {
|
|||
_beet_complete() {
|
||||
if [[ $cur == -* ]]; then
|
||||
local opts flags completions
|
||||
eval "opts=\$opts__$cmd"
|
||||
eval "flags=\$flags__$cmd"
|
||||
eval "opts=\$opts__${cmd//-/_}"
|
||||
eval "flags=\$flags__${cmd//-/_}"
|
||||
completions="${flags___common} ${opts} ${flags}"
|
||||
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
|
||||
else
|
||||
|
|
@ -129,7 +129,7 @@ _beet_complete_global() {
|
|||
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
|
||||
elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then
|
||||
local cmd
|
||||
eval "cmd=\$alias__$cur"
|
||||
eval "cmd=\$alias__${cur//-/_}"
|
||||
COMPREPLY+=( "$cmd" )
|
||||
else
|
||||
COMPREPLY+=( $(compgen -W "$commands" -- $cur) )
|
||||
|
|
@ -138,7 +138,7 @@ _beet_complete_global() {
|
|||
|
||||
_beet_complete_query() {
|
||||
local opts
|
||||
eval "opts=\$opts__$cmd"
|
||||
eval "opts=\$opts__${cmd//-/_}"
|
||||
|
||||
if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then
|
||||
_beet_complete
|
||||
|
|
|
|||
|
|
@ -422,6 +422,8 @@ def syspath(path, prefix=True):
|
|||
|
||||
def samefile(p1, p2):
|
||||
"""Safer equality for paths."""
|
||||
if p1 == p2:
|
||||
return True
|
||||
return shutil._samefile(syspath(p1), syspath(p2))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
u'copy_album_art': False,
|
||||
u'album_art_maxwidth': 0,
|
||||
})
|
||||
self.import_stages = [self.auto_convert]
|
||||
self.early_import_stages = [self.auto_convert]
|
||||
|
||||
self.register_listener('import_task_files', self._cleanup)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
|
||||
self._command.parser.add_option(
|
||||
u'-d', u'--drop', dest='drop',
|
||||
action='store_true', default=False,
|
||||
action='store_true', default=None,
|
||||
help=u'drop featuring from artists and ignore title update')
|
||||
|
||||
if self.config['auto']:
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
|
|||
offset = 0
|
||||
albums_in_collection, release_count = _fetch(offset)
|
||||
for i in range(0, release_count, FETCH_CHUNK_SIZE):
|
||||
offset += FETCH_CHUNK_SIZE
|
||||
albums_in_collection += _fetch(offset)[0]
|
||||
offset += FETCH_CHUNK_SIZE
|
||||
|
||||
return albums_in_collection
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
|
|||
def remove_missing(self, collection_id, lib_albums):
|
||||
lib_ids = set([x.mb_albumid for x in lib_albums])
|
||||
albums_in_collection = self._get_albums_in_collection(collection_id)
|
||||
remove_me = list(lib_ids - set(albums_in_collection))
|
||||
remove_me = list(set(albums_in_collection) - lib_ids)
|
||||
for i in range(0, len(remove_me), FETCH_CHUNK_SIZE):
|
||||
chunk = remove_me[i:i + FETCH_CHUNK_SIZE]
|
||||
mb_call(
|
||||
|
|
|
|||
|
|
@ -613,16 +613,6 @@ class GStreamerBackend(Backend):
|
|||
|
||||
self._file = self._files.pop(0)
|
||||
|
||||
# Disconnect the decodebin element from the pipeline, set its
|
||||
# state to READY to to clear it.
|
||||
self._decbin.unlink(self._conv)
|
||||
self._decbin.set_state(self.Gst.State.READY)
|
||||
|
||||
# Set a new file on the filesrc element, can only be done in the
|
||||
# READY state
|
||||
self._src.set_state(self.Gst.State.READY)
|
||||
self._src.set_property("location", py3_path(syspath(self._file.path)))
|
||||
|
||||
# Ensure the filesrc element received the paused state of the
|
||||
# pipeline in a blocking manner
|
||||
self._src.sync_state_with_parent()
|
||||
|
|
@ -633,9 +623,18 @@ class GStreamerBackend(Backend):
|
|||
self._decbin.sync_state_with_parent()
|
||||
self._decbin.get_state(self.Gst.CLOCK_TIME_NONE)
|
||||
|
||||
# Disconnect the decodebin element from the pipeline, set its
|
||||
# state to READY to to clear it.
|
||||
self._decbin.unlink(self._conv)
|
||||
self._decbin.set_state(self.Gst.State.READY)
|
||||
|
||||
# Set a new file on the filesrc element, can only be done in the
|
||||
# READY state
|
||||
self._src.set_state(self.Gst.State.READY)
|
||||
self._src.set_property("location", py3_path(syspath(self._file.path)))
|
||||
|
||||
self._decbin.link(self._conv)
|
||||
self._pipe.set_state(self.Gst.State.READY)
|
||||
self._pipe.set_state(self.Gst.State.PLAYING)
|
||||
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import flask
|
|||
from flask import g
|
||||
from werkzeug.routing import BaseConverter, PathConverter
|
||||
import os
|
||||
from unidecode import unidecode
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
|
@ -225,10 +226,24 @@ def item_file(item_id):
|
|||
else:
|
||||
item_path = util.py3_path(item.path)
|
||||
|
||||
try:
|
||||
unicode_item_path = util.text_string(item.path)
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
unicode_item_path = util.displayable_path(item.path)
|
||||
|
||||
base_filename = os.path.basename(unicode_item_path)
|
||||
try:
|
||||
# Imitate http.server behaviour
|
||||
base_filename.encode("latin-1", "strict")
|
||||
except UnicodeEncodeError:
|
||||
safe_filename = unidecode(base_filename)
|
||||
else:
|
||||
safe_filename = base_filename
|
||||
|
||||
response = flask.send_file(
|
||||
item_path,
|
||||
as_attachment=True,
|
||||
attachment_filename=os.path.basename(util.py3_path(item.path)),
|
||||
attachment_filename=safe_filename
|
||||
)
|
||||
response.headers['Content-Length'] = os.path.getsize(item_path)
|
||||
return response
|
||||
|
|
@ -285,8 +300,8 @@ def album_query(queries):
|
|||
@app.route('/album/<int:album_id>/art')
|
||||
def album_art(album_id):
|
||||
album = g.lib.get_album(album_id)
|
||||
if album.artpath:
|
||||
return flask.send_file(album.artpath)
|
||||
if album and album.artpath:
|
||||
return flask.send_file(album.artpath.decode())
|
||||
else:
|
||||
return flask.abort(404)
|
||||
|
||||
|
|
@ -341,6 +356,7 @@ class WebPlugin(BeetsPlugin):
|
|||
'host': u'127.0.0.1',
|
||||
'port': 8337,
|
||||
'cors': '',
|
||||
'cors_supports_credentials': False,
|
||||
'reverse_proxy': False,
|
||||
'include_paths': False,
|
||||
})
|
||||
|
|
@ -372,7 +388,12 @@ class WebPlugin(BeetsPlugin):
|
|||
app.config['CORS_RESOURCES'] = {
|
||||
r"/*": {"origins": self.config['cors'].get(str)}
|
||||
}
|
||||
CORS(app)
|
||||
CORS(
|
||||
app,
|
||||
supports_credentials=self.config[
|
||||
'cors_supports_credentials'
|
||||
].get(bool)
|
||||
)
|
||||
|
||||
# Allow serving behind a reverse proxy
|
||||
if self.config['reverse_proxy']:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ var timeFormat = function(secs) {
|
|||
return '0:00';
|
||||
}
|
||||
secs = Math.round(secs);
|
||||
var mins = '' + Math.round(secs / 60);
|
||||
var mins = '' + Math.floor(secs / 60);
|
||||
secs = '' + (secs % 60);
|
||||
if (secs.length < 2) {
|
||||
secs = '0' + secs;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,46 @@ Fixes:
|
|||
* Avoid a crash when importing a non-ASCII filename when using an ASCII locale
|
||||
on Unix under Python 3.
|
||||
:bug:`2793` :bug:`2803`
|
||||
* Fix a problem caused by time zone misalignment that could make date queries
|
||||
fail to match certain dates that are near the edges of a range. For example,
|
||||
querying for dates within a certain month would fail to match dates within
|
||||
hours of the end of that month.
|
||||
:bug:`2652`
|
||||
* :doc:`/plugins/convert`: The plugin now runs before other plugin-provided
|
||||
import stages, which addresses an issue with generating ReplayGain data
|
||||
incompatible between the source and target file formats.
|
||||
:bug:`2814`
|
||||
Thanks to :user:`autrimpo`.
|
||||
* :doc:`/plugins/ftintitle`: The ``drop`` config option had no effect; it now
|
||||
does what it says it should do.
|
||||
:bug:`2817`
|
||||
* Importing a release with multiple release events now selects the
|
||||
event based on the order of your :ref:`preferred` countries rather than
|
||||
the order of release events in MusicBrainz. :bug:`2816`
|
||||
* :doc:`/plugins/web`: The time display in the web interface would incorrectly jump
|
||||
at the 30-second mark of every minute. Now, it correctly changes over at zero
|
||||
seconds. :bug:`2822`
|
||||
* :doc:`/plugins/web`: In a python 3 enviroment, the function to fetch the
|
||||
album art would not work and throw an exception. It now works as expected.
|
||||
Additionally, the server will now return a 404 response when the album id
|
||||
is unknown, instead of a 500 response and a thrown exception. :bug:`2823`
|
||||
* :doc:`/plugins/web`: In a python 3 enviroment, the server would throw an
|
||||
exception if non latin-1 characters where in the File name.
|
||||
It now checks if non latin-1 characters are in the filename and changes
|
||||
them to ascii-characters in that case :bug:`2815`
|
||||
* Partially fix bash completion for subcommand names that contain hyphens.
|
||||
:bug:`2836` :bug:`2837`
|
||||
Thanks to :user:`jhermann`.
|
||||
* Really fix album replaygain calculation with gstreamer backend. :bug:`2846`
|
||||
* Avoid an error when doing a "no-op" move on non-existent files (i.e., moving
|
||||
a file onto itself). :bug:`2863`
|
||||
|
||||
For developers:
|
||||
|
||||
* Plugins can now run their import stages *early*, before other plugins. Use
|
||||
the ``early_import_stages`` list instead of plain ``import_stages`` to
|
||||
request this behavior.
|
||||
:bug:`2814`
|
||||
|
||||
|
||||
1.4.6 (December 21, 2017)
|
||||
|
|
|
|||
|
|
@ -432,6 +432,11 @@ to register it::
|
|||
def stage(self, session, task):
|
||||
print('Importing something!')
|
||||
|
||||
It is also possible to request your function to run early in the pipeline by
|
||||
adding the function to the plugin's ``early_import_stages`` field instead::
|
||||
|
||||
self.early_import_stages = [self.stage]
|
||||
|
||||
.. _extend-query:
|
||||
|
||||
Extend the Query Syntax
|
||||
|
|
|
|||
|
|
@ -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, you
|
||||
can use this alternative syntax for the ``sources`` option::
|
||||
|
||||
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, only the ``coverart`` source supports multiple criteria:
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -54,10 +54,11 @@ like this::
|
|||
embyupdate
|
||||
export
|
||||
fetchart
|
||||
filefilter
|
||||
freedesktop
|
||||
fromfilename
|
||||
ftintitle
|
||||
fuzzy
|
||||
freedesktop
|
||||
gmusic
|
||||
hook
|
||||
ihate
|
||||
|
|
@ -82,7 +83,6 @@ like this::
|
|||
play
|
||||
plexupdate
|
||||
random
|
||||
filefilter
|
||||
replaygain
|
||||
rewrite
|
||||
scrub
|
||||
|
|
@ -147,6 +147,7 @@ Path Formats
|
|||
Interoperability
|
||||
----------------
|
||||
|
||||
* :doc:`badfiles`: Check audio file integrity.
|
||||
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
|
||||
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
|
||||
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
|
||||
|
|
@ -161,7 +162,6 @@ Interoperability
|
|||
* :doc:`sonosupdate`: Automatically notifies `Sonos`_ whenever the beets library
|
||||
changes.
|
||||
* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders.
|
||||
* :doc:`badfiles`: Check audio file integrity.
|
||||
|
||||
|
||||
.. _Emby: http://emby.media
|
||||
|
|
@ -178,6 +178,8 @@ Miscellaneous
|
|||
a different directory.
|
||||
* :doc:`duplicates`: List duplicate tracks or albums.
|
||||
* :doc:`export`: Export data from queries to a format.
|
||||
* :doc:`filefilter`: Automatically skip files during the import process based
|
||||
on regular expressions.
|
||||
* :doc:`fuzzy`: Search albums and tracks with fuzzy string matching.
|
||||
* :doc:`gmusic`: Search and upload files to Google Play Music.
|
||||
* :doc:`hook`: Run a command when an event is emitted by beets.
|
||||
|
|
@ -187,8 +189,6 @@ Miscellaneous
|
|||
* :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format.
|
||||
* :doc:`missing`: List missing tracks.
|
||||
* :doc:`random`: Randomly choose albums and tracks from your library.
|
||||
* :doc:`filefilter`: Automatically skip files during the import process based
|
||||
on regular expressions.
|
||||
* :doc:`spotify`: Create Spotify playlists from the Beets library.
|
||||
* :doc:`types`: Declare types for flexible attributes.
|
||||
* :doc:`web`: An experimental Web-based GUI for beets.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ configuration file. The available options are:
|
|||
Default: 8337.
|
||||
- **cors**: The CORS allowed origin (see :ref:`web-cors`, below).
|
||||
Default: CORS is disabled.
|
||||
- **cors_supports_credentials**: Support credentials when using CORS (see :ref:`web-cors`, below).
|
||||
Default: CORS_SUPPORTS_CREDENTIALS is disabled.
|
||||
- **reverse_proxy**: If true, enable reverse proxy support (see
|
||||
:ref:`reverse-proxy`, below).
|
||||
Default: false.
|
||||
|
|
@ -100,13 +102,17 @@ default, browsers will only allow access from clients running on the same
|
|||
server as the API. (You will get an arcane error about ``XMLHttpRequest``
|
||||
otherwise.) A technology called `CORS`_ lets you relax this restriction.
|
||||
|
||||
If you want to use an in-browser client hosted elsewhere (or running from
|
||||
a different server on your machine), first install the `flask-cors`_ plugin by
|
||||
typing ``pip install flask-cors``. Then set the ``cors`` configuration option
|
||||
to the "origin" (protocol, host, and optional port number) where the client is
|
||||
served. Or set it to ``'*'`` to enable access from all origins. Note that
|
||||
there are security implications if you set the origin to ``'*'``, so please
|
||||
research this before using it.
|
||||
If you want to use an in-browser client hosted elsewhere (or running from a
|
||||
different server on your machine), first install the `flask-cors`_ plugin by
|
||||
typing ``pip install flask-cors``. Then set the ``cors`` configuration option to
|
||||
the "origin" (protocol, host, and optional port number) where the client is
|
||||
served. Or set it to ``'*'`` to enable access from all origins. Note that there
|
||||
are security implications if you set the origin to ``'*'``, so please research
|
||||
this before using it.
|
||||
|
||||
If the ``web`` server is behind a proxy that uses credentials, you might want
|
||||
to set the ``cors_supports_credentials`` configuration option to true to let
|
||||
in-browser clients log in.
|
||||
|
||||
For example::
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,15 @@ Either ``yes`` or ``no``, indicating whether matched albums should have their
|
|||
That is, if this option is turned on, then ``year`` will always equal
|
||||
``original_year`` and so on. Default: ``no``.
|
||||
|
||||
.. _artist_credit:
|
||||
|
||||
artist_credit
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Either ``yes`` or ``no``, indicating whether matched tracks and albums should
|
||||
use the artist credit, rather than the artist. That is, if this option is turned
|
||||
on, then ``artist`` will contain the artist as credited on the release.
|
||||
|
||||
.. _per_disc_numbering:
|
||||
|
||||
per_disc_numbering
|
||||
|
|
@ -789,13 +798,16 @@ No tags are required by default.
|
|||
ignored_media
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
By default a list of release media formats considered not containing audio will
|
||||
be ignored. If you want them to be included (for example if you would like to
|
||||
consider the audio portion of DVD-Video tracks) you can alter the list
|
||||
accordingly.
|
||||
A list of media (i.e., formats) in metadata databases to ignore when matching
|
||||
music. You can use this to ignore all media that usually contain video instead
|
||||
of audio, for example::
|
||||
|
||||
match:
|
||||
ignored_media: ['Data CD', 'DVD', 'DVD-Video', 'Blu-ray', 'HD-DVD',
|
||||
'VCD', 'SVCD', 'UMD', 'VHS']
|
||||
|
||||
No formats are ignored by default.
|
||||
|
||||
Default: ``['Data CD', 'DVD', 'DVD-Video', 'Blu-ray', 'HD-DVD', 'VCD', 'SVCD',
|
||||
'UMD', 'VHS']``.
|
||||
|
||||
.. _ignore_video_tracks:
|
||||
|
||||
|
|
|
|||
|
|
@ -616,12 +616,13 @@ class AssignmentTest(unittest.TestCase):
|
|||
|
||||
|
||||
class ApplyTestUtil(object):
|
||||
def _apply(self, info=None, per_disc_numbering=False):
|
||||
def _apply(self, info=None, per_disc_numbering=False, artist_credit=False):
|
||||
info = info or self.info
|
||||
mapping = {}
|
||||
for i, t in zip(self.items, info.tracks):
|
||||
mapping[i] = t
|
||||
config['per_disc_numbering'] = per_disc_numbering
|
||||
config['artist_credit'] = artist_credit
|
||||
autotag.apply_metadata(info, mapping)
|
||||
|
||||
|
||||
|
|
@ -706,6 +707,24 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
self.assertEqual(self.items[0].tracktotal, 1)
|
||||
self.assertEqual(self.items[1].tracktotal, 1)
|
||||
|
||||
def test_artist_credit(self):
|
||||
self._apply(artist_credit=True)
|
||||
self.assertEqual(self.items[0].artist, 'trackArtistCredit')
|
||||
self.assertEqual(self.items[1].artist, 'albumArtistCredit')
|
||||
self.assertEqual(self.items[0].albumartist, 'albumArtistCredit')
|
||||
self.assertEqual(self.items[1].albumartist, 'albumArtistCredit')
|
||||
|
||||
def test_artist_credit_prefers_artist_over_albumartist_credit(self):
|
||||
self.info.tracks[0].artist = 'oldArtist'
|
||||
self.info.tracks[0].artist_credit = None
|
||||
self._apply(artist_credit=True)
|
||||
self.assertEqual(self.items[0].artist, 'oldArtist')
|
||||
|
||||
def test_artist_credit_falls_back_to_albumartist(self):
|
||||
self.info.artist_credit = None
|
||||
self._apply(artist_credit=True)
|
||||
self.assertEqual(self.items[1].artist, 'artistNew')
|
||||
|
||||
def test_mb_trackid_applied(self):
|
||||
self._apply()
|
||||
self.assertEqual(self.items[0].mb_trackid,
|
||||
|
|
|
|||
|
|
@ -171,36 +171,41 @@ class DateQueryTest(_common.LibTestCase):
|
|||
class DateQueryTestRelative(_common.LibTestCase):
|
||||
def setUp(self):
|
||||
super(DateQueryTestRelative, self).setUp()
|
||||
self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M'))
|
||||
|
||||
# We pick a date near a month changeover, which can reveal some time
|
||||
# zone bugs.
|
||||
self._now = datetime(2017, 12, 31, 22, 55, 4, 101332)
|
||||
|
||||
self.i.added = _parsetime(self._now.strftime('%Y-%m-%d %H:%M'))
|
||||
self.i.store()
|
||||
|
||||
def test_single_month_match_fast(self):
|
||||
query = DateQuery('added', datetime.now().strftime('%Y-%m'))
|
||||
query = DateQuery('added', self._now.strftime('%Y-%m'))
|
||||
matched = self.lib.items(query)
|
||||
self.assertEqual(len(matched), 1)
|
||||
|
||||
def test_single_month_nonmatch_fast(self):
|
||||
query = DateQuery('added', (datetime.now() + timedelta(days=30))
|
||||
query = DateQuery('added', (self._now + timedelta(days=30))
|
||||
.strftime('%Y-%m'))
|
||||
matched = self.lib.items(query)
|
||||
self.assertEqual(len(matched), 0)
|
||||
|
||||
def test_single_month_match_slow(self):
|
||||
query = DateQuery('added', datetime.now().strftime('%Y-%m'))
|
||||
query = DateQuery('added', self._now.strftime('%Y-%m'))
|
||||
self.assertTrue(query.match(self.i))
|
||||
|
||||
def test_single_month_nonmatch_slow(self):
|
||||
query = DateQuery('added', (datetime.now() + timedelta(days=30))
|
||||
query = DateQuery('added', (self._now + timedelta(days=30))
|
||||
.strftime('%Y-%m'))
|
||||
self.assertFalse(query.match(self.i))
|
||||
|
||||
def test_single_day_match_fast(self):
|
||||
query = DateQuery('added', datetime.now().strftime('%Y-%m-%d'))
|
||||
query = DateQuery('added', self._now.strftime('%Y-%m-%d'))
|
||||
matched = self.lib.items(query)
|
||||
self.assertEqual(len(matched), 1)
|
||||
|
||||
def test_single_day_nonmatch_fast(self):
|
||||
query = DateQuery('added', (datetime.now() + timedelta(days=1))
|
||||
query = DateQuery('added', (self._now + timedelta(days=1))
|
||||
.strftime('%Y-%m-%d'))
|
||||
matched = self.lib.items(query)
|
||||
self.assertEqual(len(matched), 0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue