diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
index 646243812..6fae156b1 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.md
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -35,6 +35,12 @@ Here's a link to the music files that trigger the bug (if relevant):
* beets version:
* Turning off plugins made problem go away (yes/no):
+
+
My configuration (output of `beet config`) is:
```yaml
diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml
index 386571e5a..633947fd3 100644
--- a/.github/workflows/integration_test.yaml
+++ b/.github/workflows/integration_test.yaml
@@ -27,6 +27,10 @@ jobs:
run: |
tox -e int
+ - name: Check external links in docs
+ run: |
+ tox -e links
+
- name: Notify on failure
if: ${{ failure() }}
env:
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index d86c490b9..9600ee966 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -28,7 +28,7 @@ Non-Programming
- Promote beets! Help get the word out by telling your friends, writing
a blog post, or discussing it on a forum you frequent.
-- Improve the `documentation `__. It’s
+- Improve the `documentation`_. It’s
incredibly easy to contribute here: just find a page you want to
modify and hit the “Edit on GitHub” button in the upper-right. You
can automatically send us a pull request for your changes.
@@ -62,7 +62,7 @@ Getting the Source
^^^^^^^^^^^^^^^^^^
The easiest way to get started with the latest beets source is to use
-`pip `__ to install an “editable” package. This
+`pip`_ to install an “editable” package. This
can be done with one command:
.. code-block:: bash
@@ -147,8 +147,7 @@ request and your code will ship in no time.
5. Add a changelog entry to ``docs/changelog.rst`` near the top of the
document.
6. Run the tests and style checker. The easiest way to run the tests is
- to use `tox `__. For more
- information on running tests, see :ref:`testing`.
+ to use `tox`_. For more information on running tests, see :ref:`testing`.
7. Push to your fork and open a pull request! We’ll be in touch shortly.
8. If you add commits to a pull request, please add a comment or
re-request a review after you push them since GitHub doesn’t
@@ -253,7 +252,7 @@ guidelines to follow:
Editor Settings
---------------
-Personally, I work on beets with `vim `__. Here are
+Personally, I work on beets with `vim`_. Here are
some ``.vimrc`` lines that might help with PEP 8-compliant Python
coding::
@@ -318,7 +317,7 @@ To install the test dependencies, run ``python -m pip install .[test]``.
Or, just run a test suite with ``tox`` which will install them
automatically.
-.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py#L99`
+.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py
Writing Tests
-------------
@@ -352,9 +351,9 @@ others. See `unittest.mock`_ for more info.
.. _Python unittest: https://docs.python.org/2/library/unittest.html
.. _Codecov: https://codecov.io/github/beetbox/beets
.. _pytest-random: https://github.com/klrmn/pytest-random
-.. _tox: http://tox.readthedocs.org
-.. _detox: https://pypi.python.org/pypi/detox/
-.. _pytest: http://pytest.org
+.. _tox: https://tox.readthedocs.io/en/latest/
+.. _detox: https://pypi.org/project/detox/
+.. _pytest: https://docs.pytest.org/en/stable/
.. _Linux: https://github.com/beetbox/beets/actions
.. _Windows: https://ci.appveyor.com/project/beetbox/beets/
.. _`https://github.com/beetbox/beets/blob/master/setup.py#L99`: https://github.com/beetbox/beets/blob/master/setup.py#L99
@@ -364,3 +363,6 @@ others. See `unittest.mock`_ for more info.
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
.. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html
.. _Python unittest: https://docs.python.org/2/library/unittest.html
+.. _documentation: https://beets.readthedocs.io/en/stable/
+.. _pip: https://pip.pypa.io/en/stable/
+.. _vim: https://www.vim.org/
diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py
index ea8ef24da..7952c5566 100644
--- a/beets/autotag/mb.py
+++ b/beets/autotag/mb.py
@@ -74,6 +74,8 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
TRACK_INCLUDES = ['artists', 'aliases']
if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']:
TRACK_INCLUDES += ['work-level-rels', 'artist-rels']
+if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']:
+ RELEASE_INCLUDES += ['genres']
def track_url(trackid):
@@ -415,6 +417,10 @@ def album_info(release):
first_medium = release['medium-list'][0]
info.media = first_medium.get('format')
+ genres = release.get('genre-list')
+ if config['musicbrainz']['genres'] and genres:
+ info.genre = ';'.join(g['name'] for g in genres)
+
info.decode()
return info
diff --git a/beets/config_default.yaml b/beets/config_default.yaml
index c75778b80..dd140675f 100644
--- a/beets/config_default.yaml
+++ b/beets/config_default.yaml
@@ -7,6 +7,7 @@ import:
move: no
link: no
hardlink: no
+ reflink: no
delete: no
resume: ask
incremental: no
@@ -105,6 +106,7 @@ musicbrainz:
ratelimit_interval: 1.0
searchlimit: 5
extra_tags: []
+ genres: no
match:
strong_rec_thresh: 0.04
diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py
index 5aa2b9812..c85eb1a50 100644
--- a/beets/dbcore/types.py
+++ b/beets/dbcore/types.py
@@ -207,6 +207,12 @@ class String(Type):
sql = u'TEXT'
query = query.SubstringQuery
+ def normalize(self, value):
+ if value is None:
+ return self.null
+ else:
+ return self.model_type(value)
+
class Boolean(Type):
"""A boolean type.
diff --git a/beets/importer.py b/beets/importer.py
index 68d5f3d5d..3220b260f 100644
--- a/beets/importer.py
+++ b/beets/importer.py
@@ -222,19 +222,31 @@ class ImportSession(object):
iconfig['resume'] = False
iconfig['incremental'] = False
- # Copy, move, link, and hardlink are mutually exclusive.
+ if iconfig['reflink']:
+ iconfig['reflink'] = iconfig['reflink'] \
+ .as_choice(['auto', True, False])
+
+ # Copy, move, reflink, link, and hardlink are mutually exclusive.
if iconfig['move']:
iconfig['copy'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
+ iconfig['reflink'] = False
elif iconfig['link']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['hardlink'] = False
+ iconfig['reflink'] = False
elif iconfig['hardlink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False
+ iconfig['reflink'] = False
+ elif iconfig['reflink']:
+ iconfig['copy'] = False
+ iconfig['move'] = False
+ iconfig['link'] = False
+ iconfig['hardlink'] = False
# Only delete when copying.
if not iconfig['copy']:
@@ -707,7 +719,7 @@ class ImportTask(BaseImportTask):
item.update(changes)
def manipulate_files(self, operation=None, write=False, session=None):
- """ Copy, move, link or hardlink (depending on `operation`) the files
+ """ Copy, move, link, hardlink or reflink (depending on `operation`) the files
as well as write metadata.
`operation` should be an instance of `util.MoveOperation`.
@@ -1536,6 +1548,8 @@ def manipulate_files(session, task):
operation = MoveOperation.LINK
elif session.config['hardlink']:
operation = MoveOperation.HARDLINK
+ elif session.config['reflink']:
+ operation = MoveOperation.REFLINK
else:
operation = None
diff --git a/beets/library.py b/beets/library.py
index e22d4edc0..a060e93d6 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -747,6 +747,16 @@ class Item(LibModel):
util.hardlink(self.path, dest)
plugins.send("item_hardlinked", item=self, source=self.path,
destination=dest)
+ elif operation == MoveOperation.REFLINK:
+ util.reflink(self.path, dest, fallback=False)
+ plugins.send("item_reflinked", item=self, source=self.path,
+ destination=dest)
+ elif operation == MoveOperation.REFLINK_AUTO:
+ util.reflink(self.path, dest, fallback=True)
+ plugins.send("item_reflinked", item=self, source=self.path,
+ destination=dest)
+ else:
+ assert False, 'unknown MoveOperation'
# Either copying or moving succeeded, so update the stored path.
self.path = dest
@@ -1087,6 +1097,12 @@ class Album(LibModel):
util.link(old_art, new_art)
elif operation == MoveOperation.HARDLINK:
util.hardlink(old_art, new_art)
+ elif operation == MoveOperation.REFLINK:
+ util.reflink(old_art, new_art, fallback=False)
+ elif operation == MoveOperation.REFLINK_AUTO:
+ util.reflink(old_art, new_art, fallback=True)
+ else:
+ assert False, 'unknown MoveOperation'
self.artpath = new_art
def move(self, operation=MoveOperation.MOVE, basedir=None, store=True):
diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py
index aec0e80a9..28879a731 100644
--- a/beets/ui/__init__.py
+++ b/beets/ui/__init__.py
@@ -389,17 +389,19 @@ def input_yn(prompt, require=False):
return sel == u'y'
-def input_select_objects(prompt, objs, rep):
+def input_select_objects(prompt, objs, rep, prompt_all=None):
"""Prompt to user to choose all, none, or some of the given objects.
Return the list of selected objects.
`prompt` is the prompt string to use for each question (it should be
- phrased as an imperative verb). `rep` is a function to call on each
- object to print it out when confirming objects individually.
+ phrased as an imperative verb). If `prompt_all` is given, it is used
+ instead of `prompt` for the first (yes(/no/select) question.
+ `rep` is a function to call on each object to print it out when confirming
+ objects individually.
"""
choice = input_options(
(u'y', u'n', u's'), False,
- u'%s? (Yes/no/select)' % prompt)
+ u'%s? (Yes/no/select)' % (prompt_all or prompt))
print() # Blank line.
if choice == u'y': # Yes.
@@ -1100,8 +1102,8 @@ optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
# The main entry point and bootstrapping.
-def _load_plugins(config):
- """Load the plugins specified in the configuration.
+def _load_plugins(options, config):
+ """Load the plugins specified on the command line or in the configuration.
"""
paths = config['pluginpath'].as_str_seq(split=False)
paths = [util.normpath(p) for p in paths]
@@ -1112,13 +1114,20 @@ def _load_plugins(config):
# Extend the `beetsplug` package to include the plugin paths.
import beetsplug
- beetsplug.__path__ = paths + beetsplug.__path__
+ beetsplug.__path__ = paths + list(beetsplug.__path__)
# For backwards compatibility, also support plugin paths that
# *contain* a `beetsplug` package.
sys.path += paths
- plugins.load_plugins(config['plugins'].as_str_seq())
+ # If we were given any plugins on the command line, use those.
+ if options.plugins is not None:
+ plugin_list = (options.plugins.split(',')
+ if len(options.plugins) > 0 else [])
+ else:
+ plugin_list = config['plugins'].as_str_seq()
+
+ plugins.load_plugins(plugin_list)
plugins.send("pluginload")
return plugins
@@ -1133,7 +1142,7 @@ def _setup(options, lib=None):
config = _configure(options)
- plugins = _load_plugins(config)
+ plugins = _load_plugins(options, config)
# Get the default subcommands.
from beets.ui.commands import default_commands
@@ -1231,6 +1240,8 @@ def _raw_main(args, lib=None):
help=u'log more details (use twice for even more)')
parser.add_option('-c', '--config', dest='config',
help=u'path to configuration file')
+ parser.add_option('-p', '--plugins', dest='plugins',
+ help=u'a comma-separated list of plugins to load')
parser.add_option('-h', '--help', dest='help', action='store_true',
help=u'show this help message and exit')
parser.add_option('--version', dest='version', action='store_true',
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
index 56f9ad1f5..4d010f4b1 100755
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -468,6 +468,10 @@ def summarize_items(items, singleton):
total_duration = sum([item.length for item in items])
total_filesize = sum([item.filesize for item in items])
summary_parts.append(u'{0}kbps'.format(int(average_bitrate / 1000)))
+ if items[0].format == "FLAC":
+ sample_bits = u'{}kHz/{} bit'.format(
+ round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth)
+ summary_parts.append(sample_bits)
summary_parts.append(ui.human_seconds_short(total_duration))
summary_parts.append(ui.human_bytes(total_filesize))
@@ -803,7 +807,7 @@ class TerminalImportSession(importer.ImportSession):
))
sel = ui.input_options(
- (u'Skip new', u'Keep both', u'Remove old', u'Merge all')
+ (u'Skip new', u'Keep all', u'Remove old', u'Merge all')
)
if sel == u's':
@@ -1228,31 +1232,53 @@ def remove_items(lib, query, album, delete, force):
"""
# Get the matching items.
items, albums = _do_query(lib, query, album)
+ objs = albums if album else items
# Confirm file removal if not forcing removal.
if not force:
# Prepare confirmation with user.
- print_()
+ album_str = u" in {} album{}".format(
+ len(albums), u's' if len(albums) > 1 else u''
+ ) if album else ""
+
if delete:
fmt = u'$path - $title'
- prompt = u'Really DELETE %i file%s (y/n)?' % \
- (len(items), 's' if len(items) > 1 else '')
+ prompt = u'Really DELETE'
+ prompt_all = u'Really DELETE {} file{}{}'.format(
+ len(items), u's' if len(items) > 1 else u'', album_str
+ )
else:
fmt = u''
- prompt = u'Really remove %i item%s from the library (y/n)?' % \
- (len(items), 's' if len(items) > 1 else '')
+ prompt = u'Really remove from the library?'
+ prompt_all = u'Really remove {} item{}{} from the library?'.format(
+ len(items), u's' if len(items) > 1 else u'', album_str
+ )
+
+ # Helpers for printing affected items
+ def fmt_track(t):
+ ui.print_(format(t, fmt))
+
+ def fmt_album(a):
+ ui.print_()
+ for i in a.items():
+ fmt_track(i)
+
+ fmt_obj = fmt_album if album else fmt_track
# Show all the items.
- for item in items:
- ui.print_(format(item, fmt))
+ for o in objs:
+ fmt_obj(o)
# Confirm with user.
- if not ui.input_yn(prompt, True):
- return
+ objs = ui.input_select_objects(prompt, objs, fmt_obj,
+ prompt_all=prompt_all)
+
+ if not objs:
+ return
# Remove (and possibly delete) items.
with lib.transaction():
- for obj in (albums if album else items):
+ for obj in objs:
obj.remove(delete)
@@ -1665,7 +1691,10 @@ def config_func(lib, opts, args):
# Dump configuration.
else:
config_out = config.dump(full=opts.defaults, redact=opts.redact)
- print_(util.text_string(config_out))
+ if config_out.strip() != '{}':
+ print_(util.text_string(config_out))
+ else:
+ print("Empty configuration")
def config_edit():
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
index bb84aedc7..248096730 100644
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -134,6 +134,8 @@ class MoveOperation(Enum):
COPY = 1
LINK = 2
HARDLINK = 3
+ REFLINK = 4
+ REFLINK_AUTO = 5
def normpath(path):
@@ -197,6 +199,10 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
skip = False
for pat in ignore:
if fnmatch.fnmatch(base, pat):
+ if logger:
+ logger.debug(u'ignoring {0} due to ignore rule {1}'.format(
+ base, pat
+ ))
skip = True
break
if skip:
@@ -545,6 +551,35 @@ def hardlink(path, dest, replace=False):
traceback.format_exc())
+def reflink(path, dest, replace=False, fallback=False):
+ """Create a reflink from `dest` to `path`.
+
+ Raise an `OSError` if `dest` already exists, unless `replace` is
+ True. If `path` == `dest`, then do nothing.
+
+ If reflinking fails and `fallback` is enabled, try copying the file
+ instead. Otherwise, raise an error without trying a plain copy.
+
+ May raise an `ImportError` if the `reflink` module is not available.
+ """
+ import reflink as pyreflink
+
+ if samefile(path, dest):
+ return
+
+ if os.path.exists(syspath(dest)) and not replace:
+ raise FilesystemError(u'file exists', 'rename', (path, dest))
+
+ try:
+ pyreflink.reflink(path, dest)
+ except (NotImplementedError, pyreflink.ReflinkImpossibleError):
+ if fallback:
+ copy(path, dest, replace)
+ else:
+ raise FilesystemError(u'OS/filesystem does not support reflinks.',
+ 'link', (path, dest), traceback.format_exc())
+
+
def unique_path(path):
"""Returns a version of ``path`` that does not exist on the
filesystem. Specifically, if ``path` itself already exists, then
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
index 8f14c8baf..c57918f16 100644
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -77,6 +77,11 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0):
im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
+
+ if quality == 0:
+ # Use PIL's default quality.
+ quality = -1
+
im.save(util.py3_path(path_out), quality=quality)
return path_out
except IOError:
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index 54ae90098..20d0f5479 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -279,7 +279,7 @@ def submit_items(log, userkey, items, chunksize=64):
del data[:]
for item in items:
- fp = fingerprint_item(log, item)
+ fp = fingerprint_item(log, item, write=ui.should_write())
# Construct a submission dictionary for this item.
item_data = {
diff --git a/beetsplug/export.py b/beetsplug/export.py
index 8d98d0ba2..957180db2 100644
--- a/beetsplug/export.py
+++ b/beetsplug/export.py
@@ -54,6 +54,14 @@ class ExportPlugin(BeetsPlugin):
'sort_keys': True
}
},
+ 'jsonlines': {
+ # JSON Lines formatting options.
+ 'formatting': {
+ 'ensure_ascii': False,
+ 'separators': (',', ': '),
+ 'sort_keys': True
+ }
+ },
'csv': {
# CSV module formatting options.
'formatting': {
@@ -95,7 +103,7 @@ class ExportPlugin(BeetsPlugin):
)
cmd.parser.add_option(
u'-f', u'--format', default='json',
- help=u"the output format: json (default), csv, or xml"
+ help=u"the output format: json (default), jsonlines, csv, or xml"
)
return [cmd]
@@ -103,6 +111,7 @@ class ExportPlugin(BeetsPlugin):
file_path = opts.output
file_mode = 'a' if opts.append else 'w'
file_format = opts.format or self.config['default_format'].get(str)
+ file_format_is_line_based = (file_format == 'jsonlines')
format_options = self.config[file_format]['formatting'].get(dict)
export_format = ExportFormat.factory(
@@ -130,9 +139,14 @@ class ExportPlugin(BeetsPlugin):
continue
data = key_filter(data)
- items += [data]
- export_format.export(items, **format_options)
+ if file_format_is_line_based:
+ export_format.export(data, **format_options)
+ else:
+ items += [data]
+
+ if not file_format_is_line_based:
+ export_format.export(items, **format_options)
class ExportFormat(object):
@@ -147,7 +161,7 @@ class ExportFormat(object):
@classmethod
def factory(cls, file_type, **kwargs):
- if file_type == "json":
+ if file_type in ["json", "jsonlines"]:
return JsonFormat(**kwargs)
elif file_type == "csv":
return CSVFormat(**kwargs)
@@ -167,6 +181,7 @@ class JsonFormat(ExportFormat):
def export(self, data, **kwargs):
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
+ self.out_stream.write('\n')
class CSVFormat(ExportFormat):
diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py
index 86c5b958f..1bf8ad428 100644
--- a/beetsplug/fetchart.py
+++ b/beetsplug/fetchart.py
@@ -308,16 +308,44 @@ class CoverArtArchive(RemoteArtSource):
VALID_THUMBNAIL_SIZES = [250, 500, 1200]
if util.SNI_SUPPORTED:
- URL = 'https://coverartarchive.org/release/{mbid}/front'
- GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front'
+ URL = 'https://coverartarchive.org/release/{mbid}'
+ GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}'
else:
- URL = 'http://coverartarchive.org/release/{mbid}/front'
- GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
+ URL = 'http://coverartarchive.org/release/{mbid}'
+ GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}'
def get(self, album, plugin, paths):
"""Return the Cover Art Archive and Cover Art Archive release group URLs
using album MusicBrainz release ID and release group ID.
"""
+
+ def get_image_urls(url, size_suffix=None):
+ try:
+ response = self.request(url)
+ except requests.RequestException:
+ self._log.debug(u'{0}: error receiving response'
+ .format(self.NAME))
+ return
+
+ try:
+ data = response.json()
+ except ValueError:
+ self._log.debug(u'{0}: error loading response: {1}'
+ .format(self.NAME, response.text))
+ return
+
+ for item in data.get('images', []):
+ try:
+ if 'Front' not in item['types']:
+ continue
+
+ if size_suffix:
+ yield item['thumbnails'][size_suffix]
+ else:
+ yield item['image']
+ except KeyError:
+ pass
+
release_url = self.URL.format(mbid=album.mb_albumid)
release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid)
@@ -330,19 +358,12 @@ class CoverArtArchive(RemoteArtSource):
size_suffix = "-" + str(plugin.maxwidth)
if 'release' in self.match_by and album.mb_albumid:
- if size_suffix:
- release_thumbnail_url = release_url + size_suffix
- yield self._candidate(url=release_thumbnail_url,
- match=Candidate.MATCH_EXACT)
- yield self._candidate(url=release_url,
- match=Candidate.MATCH_EXACT)
+ for url in get_image_urls(release_url, size_suffix):
+ yield self._candidate(url=url, match=Candidate.MATCH_EXACT)
+
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
- if size_suffix:
- release_group_thumbnail_url = release_group_url + size_suffix
- yield self._candidate(url=release_group_thumbnail_url,
- match=Candidate.MATCH_FALLBACK)
- yield self._candidate(url=release_group_url,
- match=Candidate.MATCH_FALLBACK)
+ for url in get_image_urls(release_group_url):
+ yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK)
class Amazon(RemoteArtSource):
diff --git a/beetsplug/fish.py b/beetsplug/fish.py
index b842ac70f..0f7fe1e2c 100644
--- a/beetsplug/fish.py
+++ b/beetsplug/fish.py
@@ -133,6 +133,12 @@ class FishPlugin(BeetsPlugin):
fish_file.write(totstring)
+def _escape(name):
+ # Escape ? in fish
+ if name == "?":
+ name = "\\" + name
+
+
def get_cmds_list(cmds_names):
# Make a list of all Beets core & plugin commands
substr = ''
@@ -201,6 +207,8 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
# Formatting for Fish to complete our fields/values
word = ""
for cmdname, cmdhelp in cmd_name_and_help:
+ cmdname = _escape(cmdname)
+
word += "\n" + "# ------ {} -------".format(
"fieldsetups for " + cmdname) + "\n"
word += (
@@ -232,6 +240,8 @@ def get_all_commands(beetcmds):
names = [alias for alias in cmd.aliases]
names.append(cmd.name)
for name in names:
+ name = _escape(name)
+
word += "\n"
word += ("\n" * 2) + "# ====== {} =====".format(
"completions for " + name) + "\n"
diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py
index a75b8d972..702003f0f 100644
--- a/beetsplug/keyfinder.py
+++ b/beetsplug/keyfinder.py
@@ -76,7 +76,14 @@ class KeyFinderPlugin(BeetsPlugin):
item.path)
continue
- key_raw = output.rsplit(None, 1)[-1]
+ try:
+ key_raw = output.rsplit(None, 1)[-1]
+ except IndexError:
+ # Sometimes keyfinder-cli returns 0 but with no key, usually
+ # when the file is silent or corrupt, so we log and skip.
+ self._log.error(u'no key returned for path: {0}', item.path)
+ continue
+
try:
key = util.text_string(key_raw)
except UnicodeDecodeError:
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py
index 16696d425..00d65b578 100644
--- a/beetsplug/lyrics.py
+++ b/beetsplug/lyrics.py
@@ -55,7 +55,6 @@ except ImportError:
from beets import plugins
from beets import ui
-from beets import util
import beets
DIV_RE = re.compile(r'<(/?)div>?', re.I)
@@ -145,39 +144,6 @@ def extract_text_between(html, start_marker, end_marker):
return html
-def extract_text_in(html, starttag):
- """Extract the text from a
tag in the HTML starting with
- ``starttag``. Returns None if parsing fails.
- """
- # Strip off the leading text before opening tag.
- try:
- _, html = html.split(starttag, 1)
- except ValueError:
- return
-
- # Walk through balanced DIV tags.
- level = 0
- parts = []
- pos = 0
- for match in DIV_RE.finditer(html):
- if match.group(1): # Closing tag.
- level -= 1
- if level == 0:
- pos = match.end()
- else: # Opening tag.
- if level == 0:
- parts.append(html[pos:match.start()])
- level += 1
-
- if level == -1:
- parts.append(html[pos:match.start()])
- break
- else:
- print(u'no closing tag found!')
- return
- return u''.join(parts)
-
-
def search_pairs(item):
"""Yield a pairs of artists and titles to search for.
@@ -296,9 +262,9 @@ class Backend(object):
raise NotImplementedError()
-class SymbolsReplaced(Backend):
+class MusiXmatch(Backend):
REPLACEMENTS = {
- r'\s+': '_',
+ r'\s+': '-',
'<': 'Less_Than',
'>': 'Greater_Than',
'#': 'Number_',
@@ -306,20 +272,14 @@ class SymbolsReplaced(Backend):
r'[\]\}]': ')',
}
+ URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
+
@classmethod
def _encode(cls, s):
for old, new in cls.REPLACEMENTS.items():
s = re.sub(old, new, s)
- return super(SymbolsReplaced, cls)._encode(s)
-
-
-class MusiXmatch(SymbolsReplaced):
- REPLACEMENTS = dict(SymbolsReplaced.REPLACEMENTS, **{
- r'\s+': '-'
- })
-
- URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
+ return super(MusiXmatch, cls)._encode(s)
def fetch(self, artist, title):
url = self.build_url(artist, title)
@@ -441,30 +401,6 @@ class Genius(Backend):
return lyrics_div.get_text()
-class LyricsWiki(SymbolsReplaced):
- """Fetch lyrics from LyricsWiki."""
-
- if util.SNI_SUPPORTED:
- URL_PATTERN = 'https://lyrics.wikia.com/%s:%s'
- else:
- URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
-
- def fetch(self, artist, title):
- url = self.build_url(artist, title)
- html = self.fetch_url(url)
- if not html:
- return
-
- # Get the HTML fragment inside the appropriate HTML element and then
- # extract the text from it.
- html_frag = extract_text_in(html, u"
")
- if html_frag:
- lyrics = _scrape_strip_cruft(html_frag, True)
-
- if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
- return lyrics
-
-
def remove_credits(text):
"""Remove first/last line of text if it contains the word 'lyrics'
eg 'Lyrics by songsdatabase.com'
@@ -488,6 +424,7 @@ def _scrape_strip_cruft(html, plain_text_out=False):
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
html = BREAK_RE.sub('\n', html) #
eats up surrounding '\n'.
html = re.sub(r'(?s)<(script).*?\1>', '', html) # Strip script tags.
+ html = re.sub(u'\u2005', " ", html) # replace unicode with regular space
if plain_text_out: # Strip remaining HTML tags
html = COMMENT_RE.sub('', html)
@@ -656,10 +593,9 @@ class Google(Backend):
class LyricsPlugin(plugins.BeetsPlugin):
- SOURCES = ['google', 'lyricwiki', 'musixmatch', 'genius']
+ SOURCES = ['google', 'musixmatch', 'genius']
SOURCE_BACKENDS = {
'google': Google,
- 'lyricwiki': LyricsWiki,
'musixmatch': MusiXmatch,
'genius': Genius,
}
diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py
index 45fc3a8cb..004439bac 100644
--- a/beetsplug/subsonicupdate.py
+++ b/beetsplug/subsonicupdate.py
@@ -100,16 +100,24 @@ class SubsonicUpdate(BeetsPlugin):
't': token,
's': salt,
'v': '1.15.0', # Subsonic 6.1 and newer.
- 'c': 'beets'
+ 'c': 'beets',
+ 'f': 'json'
}
- response = requests.post(url, params=payload)
+ try:
+ response = requests.get(url, params=payload)
+ json = response.json()
- if response.status_code == 403:
- self._log.error(u'Server authentication failed')
- elif response.status_code == 200:
- self._log.debug(u'Updating Subsonic')
- else:
- self._log.error(
- u'Generic error, please try again later [Status Code: {}]'
- .format(response.status_code))
+ if response.status_code == 200 and \
+ json['subsonic-response']['status'] == "ok":
+ count = json['subsonic-response']['scanStatus']['count']
+ self._log.info(
+ u'Updating Subsonic; scanning {0} tracks'.format(count))
+ elif response.status_code == 200 and \
+ json['subsonic-response']['status'] == "failed":
+ error_message = json['subsonic-response']['error']['message']
+ self._log.error(u'Error: {0}'.format(error_message))
+ else:
+ self._log.error(u'Error: {0}', json)
+ except Exception as error:
+ self._log.error(u'Error: {0}'.format(error))
diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py
index 49149772d..a982809c4 100644
--- a/beetsplug/web/__init__.py
+++ b/beetsplug/web/__init__.py
@@ -21,7 +21,7 @@ from beets import ui
from beets import util
import beets.library
import flask
-from flask import g
+from flask import g, jsonify
from werkzeug.routing import BaseConverter, PathConverter
import os
from unidecode import unidecode
@@ -91,7 +91,20 @@ def is_expand():
return flask.request.args.get('expand') is not None
-def resource(name):
+def is_delete():
+ """Returns whether the current delete request should remove the selected
+ files.
+ """
+
+ return flask.request.args.get('delete') is not None
+
+
+def get_method():
+ """Returns the HTTP method of the current request."""
+ return flask.request.method
+
+
+def resource(name, patchable=False):
"""Decorates a function to handle RESTful HTTP requests for a resource.
"""
def make_responder(retriever):
@@ -99,34 +112,84 @@ def resource(name):
entities = [retriever(id) for id in ids]
entities = [entity for entity in entities if entity]
- if len(entities) == 1:
- return flask.jsonify(_rep(entities[0], expand=is_expand()))
- elif entities:
- return app.response_class(
- json_generator(entities, root=name),
- mimetype='application/json'
- )
+ if get_method() == "DELETE":
+ for entity in entities:
+ entity.remove(delete=is_delete())
+
+ return flask.make_response(jsonify({'deleted': True}), 200)
+
+ elif get_method() == "PATCH" and patchable:
+ for entity in entities:
+ entity.update(flask.request.get_json())
+ entity.try_sync(True, False) # write, don't move
+
+ if len(entities) == 1:
+ return flask.jsonify(_rep(entities[0], expand=is_expand()))
+ elif entities:
+ return app.response_class(
+ json_generator(entities, root=name),
+ mimetype='application/json'
+ )
+
+ elif get_method() == "GET":
+ if len(entities) == 1:
+ return flask.jsonify(_rep(entities[0], expand=is_expand()))
+ elif entities:
+ return app.response_class(
+ json_generator(entities, root=name),
+ mimetype='application/json'
+ )
+ else:
+ return flask.abort(404)
+
else:
- return flask.abort(404)
+ return flask.abort(405)
+
responder.__name__ = 'get_{0}'.format(name)
+
return responder
return make_responder
-def resource_query(name):
+def resource_query(name, patchable=False):
"""Decorates a function to handle RESTful HTTP queries for resources.
"""
def make_responder(query_func):
def responder(queries):
- return app.response_class(
- json_generator(
- query_func(queries),
- root='results', expand=is_expand()
- ),
- mimetype='application/json'
- )
+ entities = query_func(queries)
+
+ if get_method() == "DELETE":
+ for entity in entities:
+ entity.remove(delete=is_delete())
+
+ return flask.make_response(jsonify({'deleted': True}), 200)
+
+ elif get_method() == "PATCH" and patchable:
+ for entity in entities:
+ entity.update(flask.request.get_json())
+ entity.try_sync(True, False) # write, don't move
+
+ return app.response_class(
+ json_generator(entities, root=name),
+ mimetype='application/json'
+ )
+
+ elif get_method() == "GET":
+ return app.response_class(
+ json_generator(
+ entities,
+ root='results', expand=is_expand()
+ ),
+ mimetype='application/json'
+ )
+
+ else:
+ return flask.abort(405)
+
responder.__name__ = 'query_{0}'.format(name)
+
return responder
+
return make_responder
@@ -203,8 +266,8 @@ def before_request():
# Items.
-@app.route('/item/')
-@resource('items')
+@app.route('/item/', methods=["GET", "DELETE", "PATCH"])
+@resource('items', patchable=True)
def get_item(id):
return g.lib.get_item(id)
@@ -250,8 +313,8 @@ def item_file(item_id):
return response
-@app.route('/item/query/')
-@resource_query('items')
+@app.route('/item/query/', methods=["GET", "DELETE", "PATCH"])
+@resource_query('items', patchable=True)
def item_query(queries):
return g.lib.items(queries)
@@ -279,7 +342,7 @@ def item_unique_field_values(key):
# Albums.
-@app.route('/album/')
+@app.route('/album/', methods=["GET", "DELETE"])
@resource('albums')
def get_album(id):
return g.lib.get_album(id)
@@ -292,7 +355,7 @@ def all_albums():
return g.lib.albums()
-@app.route('/album/query/')
+@app.route('/album/query/', methods=["GET", "DELETE"])
@resource_query('albums')
def album_query(queries):
return g.lib.albums(queries)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 3278c73a8..c1a4f9fa3 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,8 +6,16 @@ Changelog
New features:
+* When config is printed with no available configuration a new message is printed.
+ :bug:`3779`
+* When importing a duplicate album it ask if it should "Keep all" instead of "Keep both".
+ :bug:`3569`
+* :doc:`/plugins/chroma`: Update file metadata after generating fingerprints through the `submit` command.
* :doc:`/plugins/lastgenre`: Added more heavy metal genres: https://en.wikipedia.org/wiki/Heavy_metal_genres to genres.txt and genres-tree.yaml
* :doc:`/plugins/subsonicplaylist`: import playlist from a subsonic server.
+* A new :ref:`reflink` config option instructs the importer to create fast,
+ copy-on-write file clones on filesystems that support them. Thanks to
+ :user:`rubdos`.
* A new :ref:`extra_tags` configuration option allows more tagged metadata
to be included in MusicBrainz queries.
* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets
@@ -19,12 +27,12 @@ New features:
* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to
allow downloading of higher resolution iTunes artwork (at the expense of
file size).
- :bug: `3391`
+ :bug:`3391`
* :doc:`plugins/discogs` now adds two extra fields: `discogs_labelid` and
`discogs_artistid`
- :bug: `3413`
+ :bug:`3413`
* :doc:`/plugins/export`: Added new ``-f`` (``--format``) flag;
- which allows for the ability to export in json, csv and xml.
+ which allows for the ability to export in json, jsonlines, csv and xml.
Thanks to :user:`austinmm`.
:bug:`3402`
* :doc:`/plugins/unimported`: lets you find untracked files in your library directory.
@@ -146,12 +154,29 @@ New features:
be deleted after importing.
Thanks to :user:`logan-arens`.
:bug:`2947`
+* Added flac-specific reporting of samplerate and bitrate when importing duplicates.
+* :doc:`/plugins/fetchart`: Cover Art Archive source now iterates over
+ all front images instead of blindly selecting the first one.
+* ``beet remove`` now also allows interactive selection of items from the query
+ similar to ``beet modify``
+* :doc:`/plugins/web`: add DELETE and PATCH methods for modifying items
+* :doc:`/plugins/lyrics`: Removed LyricWiki source (shut down on 21/09/2020).
+* Added a ``--plugins`` (or ``-p``) flag to specify a list of plugins at startup.
+* Use the musicbrainz genre tag api to get genre information. This currently
+ depends on functionality that is currently unreleased in musicbrainzngs.
+ Once the functionality has been released, you can enable it with the
+ ``genres`` option inside the ``musicbrainz`` config. See
+ https://github.com/alastair/python-musicbrainzngs/pull/247 and
+ https://github.com/alastair/python-musicbrainzngs/pull/266 .
+ Thanks to :user:`aereaux`.
* :doc:`/plugins/replaygain` now does its analysis in parallel when using
the ``command``, ``ffmpeg`` or ``bs1770gain`` backends.
:bug:`3478`
Fixes:
+* :doc:`/plugins/subsonicupdate`: REST was using `POST` method rather `GET` method.
+ Also includes better exception handling, response parsing, and tests.
* :doc:`/plugins/the`: Fixed incorrect regex for 'the' that matched any
3-letter combination of the letters t, h, e.
:bug:`3701`
@@ -196,8 +221,10 @@ Fixes:
* ``beet update`` will now confirm that the user still wants to update if
their library folder cannot be found, preventing the user from accidentally
wiping out their beets database.
- Thanks to :user:`logan-arens`.
+ Thanks to user: `logan-arens`.
:bug:`1934`
+* ``beet import`` now logs which files are ignored when in debug mode.
+ :bug:`3764`
* :doc:`/plugins/bpd`: Fix the transition to next track when in consume mode.
Thanks to :user:`aereaux`.
:bug:`3437`
@@ -252,6 +279,14 @@ Fixes:
the current track in the queue.
Thanks to :user:`aereaux`.
:bug:`3722`
+* String-typed fields are now normalized to string values, avoiding an
+ occasional crash when using both the :doc:`/plugins/fetchart` and the
+ :doc:`/plugins/discogs` together.
+ :bug:`3773` :bug:`3774`
+* Fix a bug causing PIL to generate poor quality JPEGs when resizing artwork.
+ :bug:`3743`
+* :doc:`plugins/keyfinder`: Catch output from ``keyfinder-cli`` that is missing key.
+ :bug:`2242`
For plugin developers:
@@ -1273,7 +1308,7 @@ And there are a few bug fixes too:
The last release, 1.3.19, also erroneously reported its version as "1.3.18"
when you typed ``beet version``. This has been corrected.
-.. _six: https://pythonhosted.org/six/
+.. _six: https://pypi.org/project/six/
1.3.19 (June 25, 2016)
@@ -2119,7 +2154,7 @@ As usual, there are loads of little fixes and improvements:
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
arguments.
-.. _API changes: https://developer.echonest.com/forums/thread/3650
+.. _API changes: https://web.archive.org/web/20160814092627/https://developer.echonest.com/forums/thread/3650
.. _Plex: https://plex.tv/
.. _musixmatch: https://www.musixmatch.com/
@@ -2344,7 +2379,7 @@ The big new features are:
* A new :ref:`asciify-paths` configuration option replaces all non-ASCII
characters in paths.
-.. _Mutagen: https://bitbucket.org/lazka/mutagen
+.. _Mutagen: https://github.com/quodlibet/mutagen
.. _Spotify: https://www.spotify.com/
And the multitude of little improvements and fixes:
@@ -2599,7 +2634,7 @@ Fixes:
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
executable can't be found.
-.. _requests: https://www.python-requests.org/
+.. _requests: https://requests.readthedocs.io/en/master/
1.3.3 (February 26, 2014)
@@ -2780,7 +2815,7 @@ As usual, there are also innumerable little fixes and improvements:
Bezman.
-.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html
+.. _Acoustic Attributes: https://web.archive.org/web/20160701063109/http://developer.echonest.com/acoustic-attributes.html
.. _MPD: https://www.musicpd.org/
@@ -3130,7 +3165,7 @@ will automatically migrate your configuration to the new system.
header. Thanks to Uwe L. Korn.
* :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization.
-.. _Tomahawk: https://tomahawk-player.org/
+.. _Tomahawk: https://github.com/tomahawk-player/tomahawk
1.1b3 (March 16, 2013)
----------------------
@@ -3473,7 +3508,7 @@ begins today on features for version 1.1.
* Changed plugin loading so that modules can be imported without
unintentionally loading the plugins they contain.
-.. _The Echo Nest: http://the.echonest.com/
+.. _The Echo Nest: https://web.archive.org/web/20180329103558/http://the.echonest.com/
.. _Tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
.. _aacgain: https://aacgain.altosdesign.com
@@ -3911,7 +3946,7 @@ plugin.
* The :doc:`/plugins/web` encapsulates a simple **Web-based GUI for beets**. The
current iteration can browse the library and play music in browsers that
- support `HTML5 Audio`_.
+ support HTML5 Audio.
* When moving items that are part of an album, the album art implicitly moves
too.
@@ -3928,8 +3963,6 @@ plugin.
* Fix crash when "copying" an art file that's already in place.
-.. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html
-
1.0b9 (July 9, 2011)
--------------------
diff --git a/docs/conf.py b/docs/conf.py
index bb3e3d00f..f77838e81 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -28,6 +28,14 @@ extlinks = {
'stdlib': ('https://docs.python.org/3/library/%s.html', ''),
}
+linkcheck_ignore = [
+ r'https://github.com/beetbox/beets/issues/',
+ r'https://github.com/[^/]+$', # ignore user pages
+ r'.*localhost.*',
+ r'https://www.musixmatch.com/', # blocks requests
+ r'https://genius.com/', # blocks requests
+]
+
# Options for HTML output
htmlhelp_basename = 'beetsdoc'
diff --git a/docs/dev/index.rst b/docs/dev/index.rst
index f1465494d..63335160c 100644
--- a/docs/dev/index.rst
+++ b/docs/dev/index.rst
@@ -7,7 +7,7 @@ in hacking beets itself or creating plugins for it.
See also the documentation for `MediaFile`_, the library used by beets to read
and write metadata tags in media files.
-.. _MediaFile: https://mediafile.readthedocs.io/
+.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
.. toctree::
diff --git a/docs/dev/library.rst b/docs/dev/library.rst
index 77e218b93..071b780f3 100644
--- a/docs/dev/library.rst
+++ b/docs/dev/library.rst
@@ -45,7 +45,7 @@ responsible for handling queries to retrieve stored objects.
.. automethod:: transaction
-.. _SQLite: https://sqlite.org/
+.. _SQLite: https://sqlite.org/index.html
.. _ORM: https://en.wikipedia.org/wiki/Object-relational_mapping
@@ -118,7 +118,7 @@ To make changes to either the database or the tags on a file, you
update an item's fields (e.g., ``item.title = "Let It Be"``) and then call
``item.write()``.
-.. _MediaFile: https://mediafile.readthedocs.io/
+.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
Items also track their modification times (mtimes) to help detect when they
become out of sync with on-disk metadata, mainly to speed up the
diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst
index 3328654e0..a6aa3d6d7 100644
--- a/docs/dev/plugins.rst
+++ b/docs/dev/plugins.rst
@@ -164,6 +164,10 @@ The events currently available are:
created for a file.
Parameters: ``item``, ``source`` path, ``destination`` path
+* `item_reflinked`: called with an ``Item`` object whenever a reflink is
+ created for a file.
+ Parameters: ``item``, ``source`` path, ``destination`` path
+
* `item_removed`: called with an ``Item`` object every time an item (singleton
or album's part) is removed from the library (even when its file is not
deleted from disk).
@@ -301,7 +305,7 @@ To access this value, say ``self.config['foo'].get()`` at any point in your
plugin's code. The `self.config` object is a *view* as defined by the `Confuse`_
library.
-.. _Confuse: https://confuse.readthedocs.org/
+.. _Confuse: https://confuse.readthedocs.io/en/latest/
If you want to access configuration values *outside* of your plugin's section,
import the `config` object from the `beets` module. That is, just put ``from
@@ -379,7 +383,7 @@ access to file tags. If you have created a descriptor you can add it through
your plugins ``add_media_field()`` method.
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
-.. _MediaFile: https://mediafile.readthedocs.io/
+.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
Here's an example plugin that provides a meaningless new field "foo"::
diff --git a/docs/faq.rst b/docs/faq.rst
index 9732a4725..f47233430 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -2,10 +2,9 @@ FAQ
###
Here are some answers to frequently-asked questions from IRC and elsewhere.
-Got a question that isn't answered here? Try `IRC`_, the `discussion board`_, or
+Got a question that isn't answered here? Try the `discussion board`_, or
:ref:`filing an issue ` in the bug tracker.
-.. _IRC: irc://irc.freenode.net/beets
.. _mailing list: https://groups.google.com/group/beets-users
.. _discussion board: https://discourse.beets.io
@@ -119,7 +118,7 @@ Run a command like this::
pip install -U beets
-The ``-U`` flag tells `pip `__ to upgrade
+The ``-U`` flag tells `pip`_ to upgrade
beets to the latest version. If you want a specific version, you can
specify with using ``==`` like so::
@@ -136,13 +135,13 @@ it's helpful to run on the "bleeding edge". To run the latest source:
1. Uninstall beets. If you installed using ``pip``, you can just run
``pip uninstall beets``.
-2. Install from source. There are a few easy ways to do this:
+2. Install from source. Choose one of these methods:
- - Use ``pip`` to install the latest snapshot tarball: just type
- ``pip install https://github.com/beetbox/beets/tarball/master``.
- - Grab the source using Git:
- ``git clone https://github.com/beetbox/beets.git``. Then
- ``cd beets`` and type ``python setup.py install``.
+ - Use ``pip`` to install the latest snapshot tarball. Type:
+ ``pip install https://github.com/beetbox/beets/tarball/master``
+ - Grab the source using git. First, clone the repository:
+ ``git clone https://github.com/beetbox/beets.git``.
+ Then, ``cd beets`` and ``python setup.py install``.
- Use ``pip`` to install an "editable" version of beets based on an
automatic source checkout. For example, run
``pip install -e git+https://github.com/beetbox/beets#egg=beets``
@@ -188,7 +187,9 @@ there to report a bug. Please follow these guidelines when reporting an issue:
If you've never reported a bug before, Mozilla has some well-written
`general guidelines for good bug
-reports `__.
+reports`_.
+
+.. _general guidelines for good bug reports: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Bug_writing_guidelines
.. _find-config:
@@ -300,8 +301,7 @@ a flag. There is no simple way to remedy this.)
…not change my ID3 tags?
------------------------
-Beets writes `ID3v2.4 `__ tags by
-default.
+Beets writes `ID3v2.4`_ tags by default.
Some software, including Windows (i.e., Windows Explorer and Windows
Media Player) and `id3lib/id3v2 `__,
don't support v2.4 tags. When using 2.4-unaware software, it might look
@@ -311,6 +311,7 @@ To enable ID3v2.3 tags, enable the :ref:`id3v23` config option.
.. _invalid:
+.. _ID3v2.4: https://id3.org/id3v2.4.0-structure
…complain that a file is "unreadable"?
--------------------------------------
@@ -379,3 +380,4 @@ installed using pip, the command ``pip show -f beets`` can show you where
try `this Super User answer`_.
.. _this Super User answer: https://superuser.com/a/284361/4569
+.. _pip: https://pip.pypa.io/en/stable/
diff --git a/docs/guides/main.rst b/docs/guides/main.rst
index 2f05634d9..f1da16f50 100644
--- a/docs/guides/main.rst
+++ b/docs/guides/main.rst
@@ -64,7 +64,7 @@ beets`` if you run into permissions problems).
To install without pip, download beets from `its PyPI page`_ and run ``python
setup.py install`` in the directory therein.
-.. _its PyPI page: https://pypi.org/project/beets#downloads
+.. _its PyPI page: https://pypi.org/project/beets/#files
.. _pip: https://pip.pypa.io
The best way to upgrade beets to a new version is by running ``pip install -U
diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst
index 467d605a4..d890f5c08 100644
--- a/docs/guides/tagger.rst
+++ b/docs/guides/tagger.rst
@@ -234,7 +234,7 @@ If beets finds an album or item in your library that seems to be the same as the
one you're importing, you may see a prompt like this::
This album is already in the library!
- [S]kip new, Keep both, Remove old, Merge all?
+ [S]kip new, Keep all, Remove old, Merge all?
Beets wants to keep you safe from duplicates, which can be a real pain, so you
have four choices in this situation. You can skip importing the new music,
diff --git a/docs/plugins/absubmit.rst b/docs/plugins/absubmit.rst
index 64c77e077..953335a14 100644
--- a/docs/plugins/absubmit.rst
+++ b/docs/plugins/absubmit.rst
@@ -62,6 +62,6 @@ file. The available options are:
.. _streaming_extractor_music: https://acousticbrainz.org/download
.. _FAQ: https://acousticbrainz.org/faq
.. _pip: https://pip.pypa.io
-.. _requests: https://docs.python-requests.org/en/master/
+.. _requests: https://requests.readthedocs.io/en/master/
.. _github: https://github.com/MTG/essentia
.. _AcousticBrainz: https://acousticbrainz.org
diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst
index cbf5b4312..6117c4a1f 100644
--- a/docs/plugins/beatport.rst
+++ b/docs/plugins/beatport.rst
@@ -41,6 +41,6 @@ Configuration
This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`.
-.. _requests: https://docs.python-requests.org/en/latest/
+.. _requests: https://requests.readthedocs.io/en/master/
.. _requests_oauthlib: https://github.com/requests/requests-oauthlib
-.. _Beatport: https://beetport.com
+.. _Beatport: https://www.beatport.com/
diff --git a/docs/plugins/bpd.rst b/docs/plugins/bpd.rst
index 49563a73a..2330bea70 100644
--- a/docs/plugins/bpd.rst
+++ b/docs/plugins/bpd.rst
@@ -5,7 +5,7 @@ BPD is a music player using music from a beets library. It runs as a daemon and
implements the MPD protocol, so it's compatible with all the great MPD clients
out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
-.. _Theremin: https://theremin.sigterm.eu/
+.. _Theremin: https://github.com/TheStalwart/Theremin
.. _gmpc: https://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
.. _Sonata: http://sonata.berlios.de/
.. _Ario: http://ario-player.sourceforge.net/
@@ -13,7 +13,7 @@ out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
Dependencies
------------
-Before you can use BPD, you'll need the media library called GStreamer (along
+Before you can use BPD, you'll need the media library called `GStreamer`_ (along
with its Python bindings) on your system.
* On Mac OS X, you can use `Homebrew`_. Run ``brew install gstreamer
@@ -22,14 +22,11 @@ with its Python bindings) on your system.
* On Linux, you need to install GStreamer 1.0 and the GObject bindings for
python. Under Ubuntu, they are called ``python-gi`` and ``gstreamer1.0``.
-* On Windows, you may want to try `GStreamer WinBuilds`_ (caveat emptor: I
- haven't tried this).
-
You will also need the various GStreamer plugin packages to make everything
work. See the :doc:`/plugins/chroma` documentation for more information on
installing GStreamer plugins.
-.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/
+.. _GStreamer: https://gstreamer.freedesktop.org/download
.. _Homebrew: https://brew.sh
Usage
diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst
index a6b60e6d8..9315a1b8c 100644
--- a/docs/plugins/chroma.rst
+++ b/docs/plugins/chroma.rst
@@ -80,8 +80,8 @@ You will also need a mechanism for decoding audio files supported by the
.. _audioread: https://github.com/beetbox/audioread
.. _pyacoustid: https://github.com/beetbox/pyacoustid
.. _FFmpeg: https://ffmpeg.org/
-.. _MAD: https://spacepants.org/src/pymad/
-.. _pymad: https://www.underbit.com/products/mad/
+.. _pymad: https://spacepants.org/src/pymad/
+.. _MAD: https://www.underbit.com/products/mad/
.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html
.. _Gstreamer: https://gstreamer.freedesktop.org/
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject
diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst
index 9581e24a4..d53b8dc6d 100644
--- a/docs/plugins/convert.rst
+++ b/docs/plugins/convert.rst
@@ -191,7 +191,7 @@ can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME
`documentation`_ and the `HydrogenAudio wiki`_ for other LAME configuration
options and a thorough discussion of MP3 encoding.
-.. _documentation: http://lame.sourceforge.net/using.php
+.. _documentation: https://lame.sourceforge.io/index.php
.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME
.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback
-.. _LAME: https://lame.sourceforge.net/
+.. _LAME: https://lame.sourceforge.io/index.php
diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst
index 626fafa9d..1a8b7c7b1 100644
--- a/docs/plugins/embyupdate.rst
+++ b/docs/plugins/embyupdate.rst
@@ -18,7 +18,7 @@ To use the ``embyupdate`` plugin you need to install the `requests`_ library wit
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
.. _Emby: https://emby.media/
-.. _requests: https://docs.python-requests.org/en/latest/
+.. _requests: https://requests.readthedocs.io/en/master/
Configuration
-------------
diff --git a/docs/plugins/export.rst b/docs/plugins/export.rst
index f3756718c..284d2b8b6 100644
--- a/docs/plugins/export.rst
+++ b/docs/plugins/export.rst
@@ -39,14 +39,15 @@ The ``export`` command has these command-line options:
* ``--append``: Appends the data to the file instead of writing.
-* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json and xml.
+* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json, `jsonlines `_ and xml.
Configuration
-------------
To configure the plugin, make a ``export:`` section in your configuration
file.
-For JSON export, these options are available under the ``json`` key:
+For JSON export, these options are available under the ``json`` and
+``jsonlines`` keys:
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
- **indent**: The number of spaces for indentation.
diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst
index aab922fcd..2b16d96d8 100644
--- a/docs/plugins/index.rst
+++ b/docs/plugins/index.rst
@@ -275,7 +275,7 @@ Here are a few of the plugins written by the beets community:
* `beet-amazon`_ adds Amazon.com as a tagger data source.
-* `copyartifacts`_ helps bring non-music files along during import.
+* `beets-copyartifacts`_ helps bring non-music files along during import.
* `beets-check`_ automatically checksums your files to detect corruption.
@@ -283,6 +283,8 @@ Here are a few of the plugins written by the beets community:
* `beets-follow`_ lets you check for new albums from artists you like.
+* `beets-ibroadcast`_ uploads tracks to the `iBroadcast`_ cloud service.
+
* `beets-setlister`_ generate playlists from the setlists of a given artist.
* `beets-noimport`_ adds and removes directories from the incremental import skip list.
@@ -324,7 +326,7 @@ Here are a few of the plugins written by the beets community:
.. _beets-barcode: https://github.com/8h2a/beets-barcode
.. _beets-check: https://github.com/geigerzaehler/beets-check
-.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
+.. _beets-copyartifacts: https://github.com/adammillerio/beets-copyartifacts
.. _dsedivec: https://github.com/dsedivec/beets-plugins
.. _beets-artistcountry: https://github.com/agrausem/beets-artistcountry
.. _beetFs: https://github.com/jbaiter/beetfs
@@ -336,6 +338,8 @@ Here are a few of the plugins written by the beets community:
.. _beet-amazon: https://github.com/jmwatte/beet-amazon
.. _beets-alternatives: https://github.com/geigerzaehler/beets-alternatives
.. _beets-follow: https://github.com/nolsto/beets-follow
+.. _beets-ibroadcast: https://github.com/ctrueden/beets-ibroadcast
+.. _iBroadcast: https://ibroadcast.com/
.. _beets-setlister: https://github.com/tomjaspers/beets-setlister
.. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport
.. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets
diff --git a/docs/plugins/keyfinder.rst b/docs/plugins/keyfinder.rst
index 2ed2c1cec..a5c64d39c 100644
--- a/docs/plugins/keyfinder.rst
+++ b/docs/plugins/keyfinder.rst
@@ -31,5 +31,5 @@ configuration file. The available options are:
`initial_key` value.
Default: ``no``.
-.. _KeyFinder: https://www.ibrahimshaath.co.uk/keyfinder/
+.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
.. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli/
diff --git a/docs/plugins/kodiupdate.rst b/docs/plugins/kodiupdate.rst
index e60f503f2..f521a8000 100644
--- a/docs/plugins/kodiupdate.rst
+++ b/docs/plugins/kodiupdate.rst
@@ -27,7 +27,7 @@ With that all in place, you'll see beets send the "update" command to your Kodi
host every time you change your beets library.
.. _Kodi: https://kodi.tv/
-.. _requests: https://docs.python-requests.org/en/latest/
+.. _requests: https://requests.readthedocs.io/en/master/
Configuration
-------------
diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst
index 5fcdd2254..dee4260de 100644
--- a/docs/plugins/lastgenre.rst
+++ b/docs/plugins/lastgenre.rst
@@ -1,13 +1,10 @@
LastGenre Plugin
================
-The MusicBrainz database `does not contain genre information`_. Therefore, when
-importing and autotagging music, beets does not assign a genre. The
-``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres
+
+The ``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres
to your albums and items.
-.. _does not contain genre information:
- https://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
.. _Last.fm: https://last.fm/
Installation
@@ -72,7 +69,7 @@ nothing would ever be matched to a more generic node since all the specific
subgenres are in the whitelist to begin with.
-.. _YAML: https://www.yaml.org/
+.. _YAML: https://yaml.org/
.. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml
diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst
index fac07ad87..b71764042 100644
--- a/docs/plugins/lyrics.rst
+++ b/docs/plugins/lyrics.rst
@@ -2,10 +2,9 @@ Lyrics Plugin
=============
The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web.
-Namely, the current version of the plugin uses `Lyric Wiki`_,
-`Musixmatch`_, `Genius.com`_, and, optionally, the Google custom search API.
+Namely, the current version of the plugin uses `Musixmatch`_, `Genius.com`_,
+and, optionally, the Google custom search API.
-.. _Lyric Wiki: https://lyrics.wikia.com/
.. _Musixmatch: https://www.musixmatch.com/
.. _Genius.com: https://genius.com/
@@ -26,7 +25,7 @@ already have them. The lyrics will be stored in the beets database. If the
``import.write`` config option is on, then the lyrics will also be written to
the files' tags.
-.. _requests: https://docs.python-requests.org/en/latest/
+.. _requests: https://requests.readthedocs.io/en/master/
Configuration
@@ -180,8 +179,7 @@ You also need to register for a Microsoft Azure Marketplace free account and
to the `Microsoft Translator API`_. Follow the four steps process, specifically
at step 3 enter ``beets`` as *Client ID* and copy/paste the generated
*Client secret* into your ``bing_client_secret`` configuration, alongside
-``bing_lang_to`` target `language code`_.
+``bing_lang_to`` target `language code`.
.. _langdetect: https://pypi.python.org/pypi/langdetect
-.. _Microsoft Translator API: https://www.microsoft.com/en-us/translator/getstarted.aspx
-.. _language code: https://msdn.microsoft.com/en-us/library/hh456380.aspx
+.. _Microsoft Translator API: https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-how-to-signup
diff --git a/docs/plugins/plexupdate.rst b/docs/plugins/plexupdate.rst
index 92fc949d2..b6a2bf920 100644
--- a/docs/plugins/plexupdate.rst
+++ b/docs/plugins/plexupdate.rst
@@ -25,7 +25,7 @@ With that all in place, you'll see beets send the "update" command to your Plex
server every time you change your beets library.
.. _Plex: https://plex.tv/
-.. _requests: https://docs.python-requests.org/en/latest/
+.. _requests: https://requests.readthedocs.io/en/master/
.. _documentation about tokens: https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token
Configuration
diff --git a/docs/plugins/subsonicupdate.rst b/docs/plugins/subsonicupdate.rst
index 3549be091..710d21f2c 100644
--- a/docs/plugins/subsonicupdate.rst
+++ b/docs/plugins/subsonicupdate.rst
@@ -4,7 +4,7 @@ SubsonicUpdate Plugin
``subsonicupdate`` is a very simple plugin for beets that lets you automatically
update `Subsonic`_'s index whenever you change your beets library.
-.. _Subsonic: https://www.subsonic.org
+.. _Subsonic: http://www.subsonic.org/pages/index.jsp
To use ``subsonicupdate`` plugin, enable it in your configuration
(see :ref:`using-plugins`).
diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst
index 65d4743fb..4b069a944 100644
--- a/docs/plugins/web.rst
+++ b/docs/plugins/web.rst
@@ -19,8 +19,6 @@ The Web interface depends on `Flask`_. To get it, just run ``pip install
flask``. Then enable the ``web`` plugin in your configuration (see
:ref:`using-plugins`).
-.. _Flask: https://flask.pocoo.org/
-
If you need CORS (it's disabled by default---see :ref:`web-cors`, below), then
you also need `flask-cors`_. Just type ``pip install flask-cors``.
@@ -47,9 +45,7 @@ Usage
-----
Type queries into the little search box. Double-click a track to play it with
-`HTML5 Audio`_.
-
-.. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html
+HTML5 Audio.
Configuration
-------------
@@ -78,7 +74,7 @@ The Web backend is built using a simple REST+JSON API with the excellent
`Flask`_ library. The frontend is a single-page application written with
`Backbone.js`_. This allows future non-Web clients to use the same backend API.
-.. _Flask: https://flask.pocoo.org/
+
.. _Backbone.js: https://backbonejs.org
Eventually, to make the Web player really viable, we should use a Flash fallback
@@ -90,7 +86,7 @@ for unsupported formats/browsers. There are a number of options for this:
.. _audio.js: https://kolber.github.io/audiojs/
.. _html5media: https://html5media.info/
-.. _MediaElement.js: https://mediaelementjs.com/
+.. _MediaElement.js: https://www.mediaelementjs.com/
.. _web-cors:
@@ -187,6 +183,25 @@ representation. ::
If there is no item with that id responds with a *404* status
code.
+``DELETE /item/6``
+++++++++++++++++++
+
+Removes the item with id *6* from the beets library. If the *?delete* query string is included,
+the matching file will be deleted from disk.
+
+``PATCH /item/6``
+++++++++++++++++++
+
+Updates the item with id *6* and write the changes to the music file. The body should be a JSON object
+containing the changes to the object.
+
+Returns the updated JSON representation. ::
+
+ {
+ "id": 6,
+ "title": "A Song",
+ ...
+ }
``GET /item/6,12,13``
+++++++++++++++++++++
@@ -196,6 +211,8 @@ the response is the same as for `GET /item/`_. It is *not guaranteed* that the
response includes all the items requested. If a track is not found it is silently
dropped from the response.
+This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all
+items of the list.
``GET /item/path/...``
++++++++++++++++++++++
@@ -225,6 +242,8 @@ Path elements are joined as parts of a query. For example,
To specify literal path separators in a query, use a backslash instead of a
slash.
+This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all
+items returned by the query.
``GET /item/6/file``
++++++++++++++++++++
@@ -242,10 +261,16 @@ For albums, the following endpoints are provided:
* ``GET /album/5``
+* ``DELETE /album/5``
+
* ``GET /album/5,7``
+* ``DELETE /album/5,7``
+
* ``GET /album/query/querystring``
+* ``DELETE /album/query/querystring``
+
The interface and response format is similar to the item API, except replacing
the encapsulation key ``"items"`` with ``"albums"`` when requesting ``/album/``
or ``/album/5,7``. In addition we can request the cover art of an album with
@@ -262,3 +287,5 @@ Responds with the number of tracks and albums in the database. ::
"items": 5,
"albums": 3
}
+
+.. _Flask: https://flask.palletsprojects.com/en/1.1.x/
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index 724afc80a..5d2b834b7 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -230,10 +230,21 @@ remove
Remove music from your library.
This command uses the same :doc:`query ` syntax as the ``list`` command.
-You'll be shown a list of the files that will be removed and asked to confirm.
-By default, this just removes entries from the library database; it doesn't
-touch the files on disk. To actually delete the files, use ``beet remove -d``.
-If you do not want to be prompted to remove the files, use ``beet remove -f``.
+By default, it just removes entries from the library database; it doesn't
+touch the files on disk. To actually delete the files, use the ``-d`` flag.
+When the ``-a`` flag is given, the command operates on albums instead of
+individual tracks.
+
+When you run the ``remove`` command, it prints a list of all
+affected items in the library and asks for your permission before removing
+them. You can then choose to abort (type `n`), confirm (`y`), or interactively
+choose some of the items (`s`). In the latter case, the command will prompt you
+for every matching item or album and invite you to type `y` to remove the
+item/album, `n` to keep it or `q` to exit and only remove the items/albums
+selected up to this point.
+This option lets you choose precisely which tracks/albums to remove without
+spending too much time to carefully craft a query.
+If you do not want to be prompted at all, use the ``-f`` option.
.. _modify-cmd:
@@ -429,6 +440,10 @@ import ...``.
configuration options entirely, the two are merged. Any individual options set
in this config file will override the corresponding settings in your base
configuration.
+* ``-p plugins``: specify a comma-separated list of plugins to enable. If
+ specified, the plugin list in your configuration is ignored. The long form
+ of this argument also allows specifying no plugins, effectively disabling
+ all plugins: ``--plugins=``.
Beets also uses the ``BEETSDIR`` environment variable to look for
configuration and data.
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index 46f14f2c5..9dd7447a4 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -356,7 +356,6 @@ Sets the albumartist for various-artist compilations. Defaults to ``'Various
Artists'`` (the MusicBrainz standard). Affects other sources, such as
:doc:`/plugins/discogs`, too.
-
UI Options
----------
@@ -476,13 +475,35 @@ hardlink
~~~~~~~~
Either ``yes`` or ``no``, indicating whether to use hard links instead of
-moving or copying or symlinking files. (It conflicts with the ``move``,
+moving, copying, or symlinking files. (It conflicts with the ``move``,
``copy``, and ``link`` options.) Defaults to ``no``.
As with symbolic links (see :ref:`link`, above), this will not work on Windows
and you will want to set ``write`` to ``no``. Otherwise, metadata on the
original file will be modified.
+.. _reflink:
+
+reflink
+~~~~~~~
+
+Either ``yes``, ``no``, or ``auto``, indicating whether to use copy-on-write
+`file clones`_ (a.k.a. "reflinks") instead of copying or moving files.
+The ``auto`` option uses reflinks when possible and falls back to plain
+copying when necessary.
+Defaults to ``no``.
+
+This kind of clone is only available on certain filesystems: for example,
+btrfs and APFS. For more details on filesystem support, see the `pyreflink`_
+documentation. Note that you need to install ``pyreflink``, either through
+``python -m pip install beets[reflink]`` or ``python -m pip install reflink``.
+
+The option is ignored if ``move`` is enabled (i.e., beets can move or
+copy files but it doesn't make sense to do both).
+
+.. _file clones: https://blogs.oracle.com/otn/save-disk-space-on-linux-by-cloning-files-on-btrfs-and-ocfs2
+.. _pyreflink: https://reflink.readthedocs.io/en/latest/
+
resume
~~~~~~
@@ -689,7 +710,7 @@ to one request per second.
.. _your own MusicBrainz database: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
.. _main server: https://musicbrainz.org/
.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
-.. _Building search indexes: https://musicbrainz.org/doc/MusicBrainz_Server/Setup#Building_search_indexes
+.. _Building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup
.. _searchlimit:
@@ -721,6 +742,17 @@ above example.
Default: ``[]``
+.. _genres:
+
+genres
+~~~~~~
+
+Use MusicBrainz genre tags to populate the ``genre`` tag. This will make it a
+semicolon-separated list of all the genres tagged for the release on
+MusicBrainz.
+
+Default: ``no``
+
.. _match-config:
Autotagger Matching Options
diff --git a/setup.py b/setup.py
index 2c3cb2b55..41050307a 100755
--- a/setup.py
+++ b/setup.py
@@ -122,6 +122,7 @@ setup(
'pyxdg',
'responses>=0.3.0',
'requests_oauthlib',
+ 'reflink',
] + (
# Tests for the thumbnails plugin need pathlib on Python 2 too.
['pathlib'] if (sys.version_info < (3, 4, 0)) else []
@@ -163,6 +164,7 @@ setup(
'scrub': ['mutagen>=1.33'],
'bpd': ['PyGObject'],
'replaygain': ['PyGObject'],
+ 'reflink': ['reflink'],
},
# Non-Python/non-PyPI plugin dependencies:
# chroma: chromaprint or fpcalc
diff --git a/test/_common.py b/test/_common.py
index 8e3b1dd18..e44fac48b 100644
--- a/test/_common.py
+++ b/test/_common.py
@@ -25,6 +25,8 @@ import six
import unittest
from contextlib import contextmanager
+import reflink
+
# Mangle the search path to include the beets sources.
sys.path.insert(0, '..')
@@ -55,6 +57,7 @@ _item_ident = 0
# OS feature test.
HAVE_SYMLINK = sys.platform != 'win32'
HAVE_HARDLINK = sys.platform != 'win32'
+HAVE_REFLINK = reflink.supported_at(tempfile.gettempdir())
def item(lib=None):
diff --git a/test/test_art.py b/test/test_art.py
index f4b3a6e62..51e5a9fe8 100644
--- a/test/test_art.py
+++ b/test/test_art.py
@@ -76,6 +76,96 @@ class FetchImageHelper(_common.TestCase):
file_type, b'').ljust(32, b'\x00'))
+class CAAHelper():
+ """Helper mixin for mocking requests to the Cover Art Archive."""
+ MBID_RELASE = 'rid'
+ MBID_GROUP = 'rgid'
+
+ RELEASE_URL = 'coverartarchive.org/release/{0}' \
+ .format(MBID_RELASE)
+ GROUP_URL = 'coverartarchive.org/release-group/{0}' \
+ .format(MBID_GROUP)
+
+ if util.SNI_SUPPORTED:
+ RELEASE_URL = "https://" + RELEASE_URL
+ GROUP_URL = "https://" + GROUP_URL
+ else:
+ RELEASE_URL = "http://" + RELEASE_URL
+ GROUP_URL = "http://" + GROUP_URL
+
+ RESPONSE_RELEASE = """{
+ "images": [
+ {
+ "approved": false,
+ "back": false,
+ "comment": "GIF",
+ "edit": 12345,
+ "front": true,
+ "id": 12345,
+ "image": "http://coverartarchive.org/release/rid/12345.gif",
+ "thumbnails": {
+ "1200": "http://coverartarchive.org/release/rid/12345-1200.jpg",
+ "250": "http://coverartarchive.org/release/rid/12345-250.jpg",
+ "500": "http://coverartarchive.org/release/rid/12345-500.jpg",
+ "large": "http://coverartarchive.org/release/rid/12345-500.jpg",
+ "small": "http://coverartarchive.org/release/rid/12345-250.jpg"
+ },
+ "types": [
+ "Front"
+ ]
+ },
+ {
+ "approved": false,
+ "back": false,
+ "comment": "",
+ "edit": 12345,
+ "front": false,
+ "id": 12345,
+ "image": "http://coverartarchive.org/release/rid/12345.jpg",
+ "thumbnails": {
+ "1200": "http://coverartarchive.org/release/rid/12345-1200.jpg",
+ "250": "http://coverartarchive.org/release/rid/12345-250.jpg",
+ "500": "http://coverartarchive.org/release/rid/12345-500.jpg",
+ "large": "http://coverartarchive.org/release/rid/12345-500.jpg",
+ "small": "http://coverartarchive.org/release/rid/12345-250.jpg"
+ },
+ "types": [
+ "Front"
+ ]
+ }
+ ],
+ "release": "https://musicbrainz.org/release/releaseid"
+}"""
+ RESPONSE_GROUP = """{
+ "images": [
+ {
+ "approved": false,
+ "back": false,
+ "comment": "",
+ "edit": 12345,
+ "front": true,
+ "id": 12345,
+ "image": "http://coverartarchive.org/release/releaseid/12345.jpg",
+ "thumbnails": {
+ "1200": "http://coverartarchive.org/release/rgid/12345-1200.jpg",
+ "250": "http://coverartarchive.org/release/rgid/12345-250.jpg",
+ "500": "http://coverartarchive.org/release/rgid/12345-500.jpg",
+ "large": "http://coverartarchive.org/release/rgid/12345-500.jpg",
+ "small": "http://coverartarchive.org/release/rgid/12345-250.jpg"
+ },
+ "types": [
+ "Front"
+ ]
+ }
+ ],
+ "release": "https://musicbrainz.org/release/release-id"
+ }"""
+
+ def mock_caa_response(self, url, json):
+ responses.add(responses.GET, url, body=json,
+ content_type='application/json')
+
+
class FetchImageTest(FetchImageHelper, UseThePlugin):
URL = 'http://example.com/test.jpg'
@@ -156,15 +246,13 @@ class FSArtTest(UseThePlugin):
self.assertEqual(candidates, paths)
-class CombinedTest(FetchImageHelper, UseThePlugin):
+class CombinedTest(FetchImageHelper, UseThePlugin, CAAHelper):
ASIN = 'xxxx'
MBID = 'releaseid'
AMAZON_URL = 'https://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \
.format(ASIN)
AAO_URL = 'https://www.albumart.org/index_detail.php?asin={0}' \
.format(ASIN)
- CAA_URL = 'coverartarchive.org/release/{0}/front' \
- .format(MBID)
def setUp(self):
super(CombinedTest, self).setUp()
@@ -211,17 +299,19 @@ class CombinedTest(FetchImageHelper, UseThePlugin):
self.assertEqual(responses.calls[-1].request.url, self.AAO_URL)
def test_main_interface_uses_caa_when_mbid_available(self):
- self.mock_response("http://" + self.CAA_URL)
- self.mock_response("https://" + self.CAA_URL)
- album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
+ self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
+ self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
+ self.mock_response('http://coverartarchive.org/release/rid/12345.gif',
+ content_type='image/gif')
+ self.mock_response('http://coverartarchive.org/release/rid/12345.jpg',
+ content_type='image/jpeg')
+ album = _common.Bag(mb_albumid=self.MBID_RELASE,
+ mb_releasegroupid=self.MBID_GROUP,
+ asin=self.ASIN)
candidate = self.plugin.art_for_album(album, None)
self.assertIsNotNone(candidate)
- self.assertEqual(len(responses.calls), 1)
- if util.SNI_SUPPORTED:
- url = "https://" + self.CAA_URL
- else:
- url = "http://" + self.CAA_URL
- self.assertEqual(responses.calls[0].request.url, url)
+ self.assertEqual(len(responses.calls), 3)
+ self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL)
def test_local_only_does_not_access_network(self):
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
@@ -416,6 +506,28 @@ class GoogleImageTest(UseThePlugin):
next(self.source.get(album, self.settings, []))
+class CoverArtArchiveTest(UseThePlugin, CAAHelper):
+
+ def setUp(self):
+ super(CoverArtArchiveTest, self).setUp()
+ self.source = fetchart.CoverArtArchive(logger, self.plugin.config)
+ self.settings = Settings(maxwidth=0)
+
+ @responses.activate
+ def run(self, *args, **kwargs):
+ super(CoverArtArchiveTest, self).run(*args, **kwargs)
+
+ def test_caa_finds_image(self):
+ album = _common.Bag(mb_albumid=self.MBID_RELASE,
+ mb_releasegroupid=self.MBID_GROUP)
+ self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
+ self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
+ candidates = list(self.source.get(album, self.settings, []))
+ self.assertEqual(len(candidates), 3)
+ self.assertEqual(len(responses.calls), 2)
+ self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL)
+
+
class FanartTVTest(UseThePlugin):
RESPONSE_MULTIPLE = u"""{
"name": "artistname",
diff --git a/test/test_export.py b/test/test_export.py
index 779e74423..f0a8eb0f7 100644
--- a/test/test_export.py
+++ b/test/test_export.py
@@ -66,6 +66,17 @@ class ExportPluginTest(unittest.TestCase, TestHelper):
self.assertTrue(key in json_data)
self.assertEqual(val, json_data[key])
+ def test_jsonlines_output(self):
+ item1 = self.create_item()
+ out = self.execute_command(
+ format_type='jsonlines',
+ artist=item1.artist
+ )
+ json_data = json.loads(out)
+ for key, val in self.test_values.items():
+ self.assertTrue(key in json_data)
+ self.assertEqual(val, json_data[key])
+
def test_csv_output(self):
item1 = self.create_item()
out = self.execute_command(
diff --git a/test/test_files.py b/test/test_files.py
index 13a8b4407..ab82c192e 100644
--- a/test/test_files.py
+++ b/test/test_files.py
@@ -86,6 +86,24 @@ class MoveTest(_common.TestCase):
self.i.move(operation=MoveOperation.COPY)
self.assertExists(self.path)
+ def test_reflink_arrives(self):
+ self.i.move(operation=MoveOperation.REFLINK_AUTO)
+ self.assertExists(self.dest)
+
+ def test_reflink_does_not_depart(self):
+ self.i.move(operation=MoveOperation.REFLINK_AUTO)
+ self.assertExists(self.path)
+
+ @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
+ def test_force_reflink_arrives(self):
+ self.i.move(operation=MoveOperation.REFLINK)
+ self.assertExists(self.dest)
+
+ @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
+ def test_force_reflink_does_not_depart(self):
+ self.i.move(operation=MoveOperation.REFLINK)
+ self.assertExists(self.path)
+
def test_move_changes_path(self):
self.i.move()
self.assertEqual(self.i.path, util.normpath(self.dest))
@@ -268,6 +286,17 @@ class AlbumFileTest(_common.TestCase):
self.assertTrue(os.path.exists(oldpath))
self.assertTrue(os.path.exists(self.i.path))
+ @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
+ def test_albuminfo_move_reflinks_file(self):
+ oldpath = self.i.path
+ self.ai.album = u'newAlbumName'
+ self.ai.move(operation=MoveOperation.REFLINK)
+ self.ai.store()
+ self.i.load()
+
+ self.assertTrue(os.path.exists(oldpath))
+ self.assertTrue(os.path.exists(self.i.path))
+
def test_albuminfo_move_to_custom_dir(self):
self.ai.move(basedir=self.otherdir)
self.i.load()
@@ -549,6 +578,12 @@ class SafeMoveCopyTest(_common.TestCase):
self.assertExists(self.dest)
self.assertExists(self.path)
+ @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
+ def test_successful_reflink(self):
+ util.reflink(self.path, self.dest)
+ self.assertExists(self.dest)
+ self.assertExists(self.path)
+
def test_unsuccessful_move(self):
with self.assertRaises(util.FilesystemError):
util.move(self.path, self.otherpath)
@@ -557,6 +592,11 @@ class SafeMoveCopyTest(_common.TestCase):
with self.assertRaises(util.FilesystemError):
util.copy(self.path, self.otherpath)
+ @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
+ def test_unsuccessful_reflink(self):
+ with self.assertRaises(util.FilesystemError):
+ util.reflink(self.path, self.otherpath)
+
def test_self_move(self):
util.move(self.path, self.path)
self.assertExists(self.path)
diff --git a/test/test_keyfinder.py b/test/test_keyfinder.py
index a9ac43a27..c8735e47f 100644
--- a/test/test_keyfinder.py
+++ b/test/test_keyfinder.py
@@ -76,6 +76,16 @@ class KeyFinderTest(unittest.TestCase, TestHelper):
item.load()
self.assertEqual(item['initial_key'], 'F')
+ def test_no_key(self, command_output):
+ item = Item(path='/file')
+ item.add(self.lib)
+
+ command_output.return_value = util.CommandOutput(b"", b"")
+ self.run_command('keyfinder')
+
+ item.load()
+ self.assertEqual(item['initial_key'], None)
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_lyrics.py b/test/test_lyrics.py
index e0ec1e548..95b094e98 100644
--- a/test/test_lyrics.py
+++ b/test/test_lyrics.py
@@ -48,71 +48,72 @@ class LyricsPluginTest(unittest.TestCase):
lyrics.LyricsPlugin()
def test_search_artist(self):
- item = Item(artist='Alice ft. Bob', title='song')
- self.assertIn(('Alice ft. Bob', ['song']),
+ item = Item(artist=u'Alice ft. Bob', title=u'song')
+ self.assertIn((u'Alice ft. Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Alice', ['song']),
+ self.assertIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice feat Bob', title='song')
- self.assertIn(('Alice feat Bob', ['song']),
+ item = Item(artist=u'Alice feat Bob', title=u'song')
+ self.assertIn((u'Alice feat Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Alice', ['song']),
+ self.assertIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice feat. Bob', title='song')
- self.assertIn(('Alice feat. Bob', ['song']),
+ item = Item(artist=u'Alice feat. Bob', title=u'song')
+ self.assertIn((u'Alice feat. Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Alice', ['song']),
+ self.assertIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice feats Bob', title='song')
- self.assertIn(('Alice feats Bob', ['song']),
+ item = Item(artist=u'Alice feats Bob', title=u'song')
+ self.assertIn((u'Alice feats Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertNotIn(('Alice', ['song']),
+ self.assertNotIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice featuring Bob', title='song')
- self.assertIn(('Alice featuring Bob', ['song']),
+ item = Item(artist=u'Alice featuring Bob', title=u'song')
+ self.assertIn((u'Alice featuring Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Alice', ['song']),
+ self.assertIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice & Bob', title='song')
- self.assertIn(('Alice & Bob', ['song']),
+ item = Item(artist=u'Alice & Bob', title=u'song')
+ self.assertIn((u'Alice & Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Alice', ['song']),
+ self.assertIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice and Bob', title='song')
- self.assertIn(('Alice and Bob', ['song']),
+ item = Item(artist=u'Alice and Bob', title=u'song')
+ self.assertIn((u'Alice and Bob', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Alice', ['song']),
+ self.assertIn((u'Alice', [u'song']),
lyrics.search_pairs(item))
- item = Item(artist='Alice and Bob', title='song')
- self.assertEqual(('Alice and Bob', ['song']),
+ item = Item(artist=u'Alice and Bob', title=u'song')
+ self.assertEqual((u'Alice and Bob', [u'song']),
list(lyrics.search_pairs(item))[0])
def test_search_artist_sort(self):
- item = Item(artist='CHVRCHΞS', title='song', artist_sort='CHVRCHES')
- self.assertIn(('CHVRCHΞS', ['song']),
+ item = Item(artist=u'CHVRCHΞS', title=u'song', artist_sort=u'CHVRCHES')
+ self.assertIn((u'CHVRCHΞS', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('CHVRCHES', ['song']),
+ self.assertIn((u'CHVRCHES', [u'song']),
lyrics.search_pairs(item))
# Make sure that the original artist name is still the first entry
- self.assertEqual(('CHVRCHΞS', ['song']),
+ self.assertEqual((u'CHVRCHΞS', [u'song']),
list(lyrics.search_pairs(item))[0])
- item = Item(artist='横山克', title='song', artist_sort='Masaru Yokoyama')
- self.assertIn(('横山克', ['song']),
+ item = Item(artist=u'横山克', title=u'song',
+ artist_sort=u'Masaru Yokoyama')
+ self.assertIn((u'横山克', [u'song']),
lyrics.search_pairs(item))
- self.assertIn(('Masaru Yokoyama', ['song']),
+ self.assertIn((u'Masaru Yokoyama', [u'song']),
lyrics.search_pairs(item))
# Make sure that the original artist name is still the first entry
- self.assertEqual(('横山克', ['song']),
+ self.assertEqual((u'横山克', [u'song']),
list(lyrics.search_pairs(item))[0])
def test_search_pairs_multi_titles(self):
@@ -268,10 +269,11 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
DEFAULT_SONG = dict(artist=u'The Beatles', title=u'Lady Madonna')
DEFAULT_SOURCES = [
- dict(DEFAULT_SONG, backend=lyrics.LyricsWiki),
# dict(artist=u'Santana', title=u'Black magic woman',
# backend=lyrics.MusiXmatch),
- dict(DEFAULT_SONG, backend=lyrics.Genius),
+ dict(DEFAULT_SONG, backend=lyrics.Genius,
+ # GitHub actions is on some form of Cloudflare blacklist.
+ skip=os.environ.get('GITHUB_ACTIONS') == 'true'),
]
GOOGLE_SOURCES = [
@@ -280,7 +282,9 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
path=u'/lyrics/view/the_beatles/lady_madonna'),
dict(DEFAULT_SONG,
url=u'http://www.azlyrics.com',
- path=u'/lyrics/beatles/ladymadonna.html'),
+ path=u'/lyrics/beatles/ladymadonna.html',
+ # AZLyrics returns a 403 on GitHub actions.
+ skip=os.environ.get('GITHUB_ACTIONS') == 'true'),
dict(DEFAULT_SONG,
url=u'http://www.chartlyrics.com',
path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'),
@@ -295,8 +299,6 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
dict(DEFAULT_SONG,
url='http://www.lyricsmania.com/',
path='lady_madonna_lyrics_the_beatles.html'),
- dict(DEFAULT_SONG, url=u'http://lyrics.wikia.com/',
- path=u'The_Beatles:Lady_Madonna'),
dict(DEFAULT_SONG,
url=u'http://www.lyricsmode.com',
path=u'/lyrics/b/beatles/lady_madonna.html'),
@@ -330,11 +332,8 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
"""Test default backends with songs known to exist in respective databases.
"""
errors = []
- # GitHub actions seems to be on a Cloudflare blacklist, so we can't
- # contact genius.
- sources = [s for s in self.DEFAULT_SOURCES if
- s['backend'] != lyrics.Genius or
- os.environ.get('GITHUB_ACTIONS') != 'true']
+ # Don't test any sources marked as skipped.
+ sources = [s for s in self.DEFAULT_SOURCES if not s.get("skip", False)]
for s in sources:
res = s['backend'](self.plugin.config, self.plugin._log).fetch(
s['artist'], s['title'])
@@ -349,7 +348,9 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
"""Test if lyrics present on websites registered in beets google custom
search engine are correctly scraped.
"""
- for s in self.GOOGLE_SOURCES:
+ # Don't test any sources marked as skipped.
+ sources = [s for s in self.GOOGLE_SOURCES if not s.get("skip", False)]
+ for s in sources:
url = s['url'] + s['path']
res = lyrics.scrape_lyrics_from_html(
raw_backend.fetch_url(url))
diff --git a/test/test_subsonic.py b/test/test_subsonic.py
deleted file mode 100644
index 6d37cdf4f..000000000
--- a/test/test_subsonic.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""Tests for the 'subsonic' plugin"""
-
-from __future__ import division, absolute_import, print_function
-
-import requests
-import responses
-import unittest
-
-from test import _common
-from beets import config
-from beetsplug import subsonicupdate
-from test.helper import TestHelper
-from six.moves.urllib.parse import parse_qs, urlparse
-
-
-class ArgumentsMock(object):
- def __init__(self, mode, show_failures):
- self.mode = mode
- self.show_failures = show_failures
- self.verbose = 1
-
-
-def _params(url):
- """Get the query parameters from a URL."""
- return parse_qs(urlparse(url).query)
-
-
-class SubsonicPluginTest(_common.TestCase, TestHelper):
- @responses.activate
- def setUp(self):
- config.clear()
- self.setup_beets()
-
- config["subsonic"]["user"] = "admin"
- config["subsonic"]["pass"] = "admin"
- config["subsonic"]["url"] = "http://localhost:4040"
-
- self.subsonicupdate = subsonicupdate.SubsonicUpdate()
-
- def tearDown(self):
- self.teardown_beets()
-
- @responses.activate
- def test_start_scan(self):
- responses.add(
- responses.POST,
- 'http://localhost:4040/rest/startScan',
- status=200
- )
-
- self.subsonicupdate.start_scan()
-
- @responses.activate
- def test_url_with_extra_forward_slash_url(self):
- config["subsonic"]["url"] = "http://localhost:4040/contextPath"
-
- responses.add(
- responses.POST,
- 'http://localhost:4040/contextPath/rest/startScan',
- status=200
- )
-
- self.subsonicupdate.start_scan()
-
- @responses.activate
- def test_url_with_context_path(self):
- config["subsonic"]["url"] = "http://localhost:4040/"
-
- responses.add(
- responses.POST,
- 'http://localhost:4040/rest/startScan',
- status=200
- )
-
- self.subsonicupdate.start_scan()
-
- @responses.activate
- def test_url_with_missing_port(self):
- config["subsonic"]["url"] = "http://localhost/airsonic"
-
- responses.add(
- responses.POST,
- 'http://localhost:4040/rest/startScan',
- status=200
- )
-
- with self.assertRaises(requests.exceptions.ConnectionError):
- self.subsonicupdate.start_scan()
-
- @responses.activate
- def test_url_with_missing_schema(self):
- config["subsonic"]["url"] = "localhost:4040/airsonic"
-
- responses.add(
- responses.POST,
- 'http://localhost:4040/rest/startScan',
- status=200
- )
-
- with self.assertRaises(requests.exceptions.InvalidSchema):
- self.subsonicupdate.start_scan()
-
-
-def suite():
- return unittest.TestLoader().loadTestsFromName(__name__)
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/test/test_subsonicupdate.py b/test/test_subsonicupdate.py
new file mode 100644
index 000000000..c47208e65
--- /dev/null
+++ b/test/test_subsonicupdate.py
@@ -0,0 +1,188 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for the 'subsonic' plugin."""
+
+from __future__ import division, absolute_import, print_function
+
+import responses
+import unittest
+
+from test import _common
+from beets import config
+from beetsplug import subsonicupdate
+from test.helper import TestHelper
+from six.moves.urllib.parse import parse_qs, urlparse
+
+
+class ArgumentsMock(object):
+ """Argument mocks for tests."""
+ def __init__(self, mode, show_failures):
+ """Constructs ArgumentsMock."""
+ self.mode = mode
+ self.show_failures = show_failures
+ self.verbose = 1
+
+
+def _params(url):
+ """Get the query parameters from a URL."""
+ return parse_qs(urlparse(url).query)
+
+
+class SubsonicPluginTest(_common.TestCase, TestHelper):
+ """Test class for subsonicupdate."""
+ @responses.activate
+ def setUp(self):
+ """Sets up config and plugin for test."""
+ config.clear()
+ self.setup_beets()
+
+ config["subsonic"]["user"] = "admin"
+ config["subsonic"]["pass"] = "admin"
+ config["subsonic"]["url"] = "http://localhost:4040"
+
+ self.subsonicupdate = subsonicupdate.SubsonicUpdate()
+
+ SUCCESS_BODY = '''
+{
+ "subsonic-response": {
+ "status": "ok",
+ "version": "1.15.0",
+ "scanStatus": {
+ "scanning": true,
+ "count": 1000
+ }
+ }
+}
+'''
+
+ FAILED_BODY = '''
+{
+ "subsonic-response": {
+ "status": "failed",
+ "version": "1.15.0",
+ "error": {
+ "code": 40,
+ "message": "Wrong username or password."
+ }
+ }
+}
+'''
+
+ ERROR_BODY = '''
+{
+ "timestamp": 1599185854498,
+ "status": 404,
+ "error": "Not Found",
+ "message": "No message available",
+ "path": "/rest/startScn"
+}
+'''
+
+ def tearDown(self):
+ """Tears down tests."""
+ self.teardown_beets()
+
+ @responses.activate
+ def test_start_scan(self):
+ """Tests success path based on best case scenario."""
+ responses.add(
+ responses.GET,
+ 'http://localhost:4040/rest/startScan',
+ status=200,
+ body=self.SUCCESS_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+ @responses.activate
+ def test_start_scan_failed_bad_credentials(self):
+ """Tests failed path based on bad credentials."""
+ responses.add(
+ responses.GET,
+ 'http://localhost:4040/rest/startScan',
+ status=200,
+ body=self.FAILED_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+ @responses.activate
+ def test_start_scan_failed_not_found(self):
+ """Tests failed path based on resource not found."""
+ responses.add(
+ responses.GET,
+ 'http://localhost:4040/rest/startScan',
+ status=404,
+ body=self.ERROR_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+ def test_start_scan_failed_unreachable(self):
+ """Tests failed path based on service not available."""
+ self.subsonicupdate.start_scan()
+
+ @responses.activate
+ def test_url_with_context_path(self):
+ """Tests success for included with contextPath."""
+ config["subsonic"]["url"] = "http://localhost:4040/contextPath/"
+
+ responses.add(
+ responses.GET,
+ 'http://localhost:4040/contextPath/rest/startScan',
+ status=200,
+ body=self.SUCCESS_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+ @responses.activate
+ def test_url_with_trailing_forward_slash_url(self):
+ """Tests success path based on trailing forward slash."""
+ config["subsonic"]["url"] = "http://localhost:4040/"
+
+ responses.add(
+ responses.GET,
+ 'http://localhost:4040/rest/startScan',
+ status=200,
+ body=self.SUCCESS_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+ @responses.activate
+ def test_url_with_missing_port(self):
+ """Tests failed path based on missing port."""
+ config["subsonic"]["url"] = "http://localhost/airsonic"
+
+ responses.add(
+ responses.GET,
+ 'http://localhost/airsonic/rest/startScan',
+ status=200,
+ body=self.SUCCESS_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+ @responses.activate
+ def test_url_with_missing_schema(self):
+ """Tests failed path based on missing schema."""
+ config["subsonic"]["url"] = "localhost:4040/airsonic"
+
+ responses.add(
+ responses.GET,
+ 'http://localhost:4040/rest/startScan',
+ status=200,
+ body=self.SUCCESS_BODY
+ )
+
+ self.subsonicupdate.start_scan()
+
+
+def suite():
+ """Default test suite."""
+ return unittest.TestLoader().loadTestsFromName(__name__)
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/test/test_ui.py b/test/test_ui.py
index b1e7e8fad..5cfed1fda 100644
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -111,7 +111,7 @@ class ListTest(unittest.TestCase):
self.assertNotIn(u'the album', stdout.getvalue())
-class RemoveTest(_common.TestCase):
+class RemoveTest(_common.TestCase, TestHelper):
def setUp(self):
super(RemoveTest, self).setUp()
@@ -122,8 +122,8 @@ class RemoveTest(_common.TestCase):
# Copy a file into the library.
self.lib = library.Library(':memory:', self.libdir)
- item_path = os.path.join(_common.RSRC, b'full.mp3')
- self.i = library.Item.from_path(item_path)
+ self.item_path = os.path.join(_common.RSRC, b'full.mp3')
+ self.i = library.Item.from_path(self.item_path)
self.lib.add(self.i)
self.i.move(operation=MoveOperation.COPY)
@@ -153,6 +153,44 @@ class RemoveTest(_common.TestCase):
self.assertEqual(len(list(items)), 0)
self.assertFalse(os.path.exists(self.i.path))
+ def test_remove_items_select_with_delete(self):
+ i2 = library.Item.from_path(self.item_path)
+ self.lib.add(i2)
+ i2.move(operation=MoveOperation.COPY)
+
+ for s in ('s', 'y', 'n'):
+ self.io.addinput(s)
+ commands.remove_items(self.lib, u'', False, True, False)
+ items = self.lib.items()
+ self.assertEqual(len(list(items)), 1)
+ # There is probably no guarantee that the items are queried in any
+ # spcecific order, thus just ensure that exactly one was removed.
+ # To improve upon this, self.io would need to have the capability to
+ # generate input that depends on previous output.
+ num_existing = 0
+ num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0
+ num_existing += 1 if os.path.exists(syspath(i2.path)) else 0
+ self.assertEqual(num_existing, 1)
+
+ def test_remove_albums_select_with_delete(self):
+ a1 = self.add_album_fixture()
+ a2 = self.add_album_fixture()
+ path1 = a1.items()[0].path
+ path2 = a2.items()[0].path
+ items = self.lib.items()
+ self.assertEqual(len(list(items)), 3)
+
+ for s in ('s', 'y', 'n'):
+ self.io.addinput(s)
+ commands.remove_items(self.lib, u'', True, True, False)
+ items = self.lib.items()
+ self.assertEqual(len(list(items)), 2) # incl. the item from setUp()
+ # See test_remove_items_select_with_delete()
+ num_existing = 0
+ num_existing += 1 if os.path.exists(syspath(path1)) else 0
+ num_existing += 1 if os.path.exists(syspath(path2)) else 0
+ self.assertEqual(num_existing, 1)
+
class ModifyTest(unittest.TestCase, TestHelper):
diff --git a/tox.ini b/tox.ini
index cbf953033..69308235d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -27,6 +27,12 @@ basepython = python2.7
deps = sphinx
commands = sphinx-build -W -q -b html docs {envtmpdir}/html {posargs}
+# checks all links in the docs
+[testenv:links]
+deps = sphinx
+allowlist_externals = /bin/bash
+commands = /bin/bash -c '! sphinx-build -b linkcheck docs {envtmpdir}/linkcheck | grep "broken\s"'
+
[testenv:int]
deps = {[_test]deps}
setenv = INTEGRATION_TEST = 1