diff --git a/README.rst b/README.rst index 8172a1b9b..a3ea6302f 100644 --- a/README.rst +++ b/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/ diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 4c5f09eb4..09564f49d 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -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. diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 385dc64fb..9ce449a8b 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -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'] diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 69c22da28..273f94235 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -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 diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index fbe6626c7..8fb64e206 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -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} ?" diff --git a/beets/importer.py b/beets/importer.py index aac21d77f..4e4084eec 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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)) diff --git a/beets/plugins.py b/beets/plugins.py index d62f3c011..1bd2cacd5 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -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 diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 338bceb83..610ae551e 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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' diff --git a/beets/ui/completion_base.sh b/beets/ui/completion_base.sh index ce3fb6e27..1eaa4db3d 100644 --- a/beets/ui/completion_base.sh +++ b/beets/ui/completion_base.sh @@ -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 diff --git a/beets/util/__init__.py b/beets/util/__init__.py index db341a646..69870edf2 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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)) diff --git a/beets/util/confit.py b/beets/util/confit.py index 73ae97abc..b5513f48e 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -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): diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 380061248..d1223596f 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -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) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 8bee6e804..0e106694d 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -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): diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 1060a2dd8..9303f9cfc 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -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']: diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index c01d544a4..d99c386c9 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -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( diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index a7064451a..a7eb81b5c 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -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 diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 635c2f5a8..3cf43ed56 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -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//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']: diff --git a/beetsplug/web/static/beets.js b/beetsplug/web/static/beets.js index ec9aae9b3..51985c183 100644 --- a/beetsplug/web/static/beets.js +++ b/beetsplug/web/static/beets.js @@ -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; diff --git a/docs/changelog.rst b/docs/changelog.rst index 3113b925b..b1c0d4c29 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 4d41c8971..bab0e604d 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -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 diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index e375f9f57..d6d9adeff 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -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. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index e08a1767f..d76136d52 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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. diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst index 73a2b9147..35287acc8 100644 --- a/docs/plugins/web.rst +++ b/docs/plugins/web.rst @@ -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:: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 8c1279b5c..94dab4345 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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: diff --git a/test/test_autotag.py b/test/test_autotag.py index 6f107afa4..932616be1 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -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, diff --git a/test/test_datequery.py b/test/test_datequery.py index 7b7776711..b8348ca53 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -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)