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).*?', '', 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