Merge branch 'master' into sonos

This commit is contained in:
cgtobi 2018-04-20 07:44:21 +02:00 committed by GitHub
commit 7d45eabb25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 391 additions and 103 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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