From b5216a06f489b5da8c1e66b86603eedfc9d15856 Mon Sep 17 00:00:00 2001 From: Vrihub Date: Sat, 15 Jun 2024 20:52:55 +0200 Subject: [PATCH 001/301] Proposed fix for issue #5218 Check for existence of "title" matching group before using it --- beetsplug/fromfilename.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 103e82901..70b9d41fb 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -98,6 +98,7 @@ def apply_matches(d, log): # Given both an "artist" and "title" field, assume that one is # *actually* the artist, which must be uniform, and use the other # for the title. This, of course, won't work for VA albums. + # Only check for "artist": patterns containing it, also contain "title" if "artist" in keys: if equal_fields(d, "artist"): artist = some_map["artist"] @@ -113,14 +114,15 @@ def apply_matches(d, log): if not item.artist: item.artist = artist log.info("Artist replaced with: {}".format(item.artist)) - - # No artist field: remaining field is the title. - else: + # otherwise, if the pattern contains "title", use that for title_field + elif "title" in keys: title_field = "title" + else: + title_field = None - # Apply the title and track. + # Apply the title and track, if any. for item in d: - if bad_title(item.title): + if title_field and bad_title(item.title): item.title = str(d[item][title_field]) log.info("Title replaced with: {}".format(item.title)) From 09660426a887c69fc6269c20a4548cd65a308df3 Mon Sep 17 00:00:00 2001 From: Vrihub Date: Sat, 15 Jun 2024 20:56:40 +0200 Subject: [PATCH 002/301] Logging: add message about the pattern being tried --- beetsplug/fromfilename.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 70b9d41fb..598cbeda9 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -162,6 +162,7 @@ class FromFilenamePlugin(plugins.BeetsPlugin): # Look for useful information in the filenames. for pattern in PATTERNS: + self._log.debug("Trying pattern: {}".format(pattern)) d = all_matches(names, pattern) if d: apply_matches(d, self._log) From e6b773561b1cb588e6e3204030092af62859bd1a Mon Sep 17 00:00:00 2001 From: Vrihub Date: Sat, 15 Jun 2024 20:58:41 +0200 Subject: [PATCH 003/301] Refactor regexps in PATTERNS --- beetsplug/fromfilename.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 598cbeda9..0f34fd09c 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -25,12 +25,10 @@ from beets.util import displayable_path # Filename field extraction patterns. PATTERNS = [ # Useful patterns. - r"^(?P.+)[\-_](?P.+)[\-_](?P<tag>.*)$", - r"^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$", - r"^(?P<artist>.+)[\-_](?P<title>.+)$", - r"^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)$", - r"^(?P<track>\d+)[\s.\-_]+(?P<title>.+)$", - r"^(?P<track>\d+)\s+(?P<title>.+)$", + (r"^(?P<track>\d+)\.?\s*-\s*(?P<artist>.+?)\s*-\s*(?P<title>.+?)" + r"(\s*-\s*(?P<tag>.*))?$"), + r"^(?P<artist>.+?)\s*-\s*(?P<title>.+?)(\s*-\s*(?P<tag>.*))?$", + r"^(?P<track>\d+)\.?[\s\-_]+(?P<title>.+)$", r"^(?P<title>.+) by (?P<artist>.+)$", r"^(?P<track>\d+).*$", r"^(?P<title>.+)$", From 2f2680fa8d9431e3f2bebef82c2feb7aad9b1248 Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:02:33 +0200 Subject: [PATCH 004/301] Added tests for the fromfilename plugin --- test/plugins/test_fromfilename.py | 143 ++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 test/plugins/test_fromfilename.py diff --git a/test/plugins/test_fromfilename.py b/test/plugins/test_fromfilename.py new file mode 100644 index 000000000..3dc600ced --- /dev/null +++ b/test/plugins/test_fromfilename.py @@ -0,0 +1,143 @@ +# This file is part of beets. +# Copyright 2016, Jan-Erik Dahlin. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Tests for the fromfilename plugin. +""" + +import unittest +from unittest.mock import Mock +from beetsplug import fromfilename + + +class FromfilenamePluginTest(unittest.TestCase): + + def setUp(self): + """Create mock objects for import session and task.""" + self.session = Mock() + + item1config = {'path': '', 'track': 0, 'artist': '', 'title': ''} + self.item1 = Mock(**item1config) + + item2config = {'path': '', 'track': 0, 'artist': '', 'title': ''} + self.item2 = Mock(**item2config) + + taskconfig = {'is_album': True, 'items': [self.item1, self.item2]} + self.task = Mock(**taskconfig) + + def tearDown(self): + del self.session, self.task, self.item1, self.item2 + + def test_sep_sds(self): + """Test filenames that use " - " as separator.""" + + self.item1.path = "/music/files/01 - Artist Name - Song One.m4a" + self.item2.path = "/music/files/02. - Artist Name - Song Two.m4a" + + f = fromfilename.FromFilenamePlugin() + f.filename_task(self.task, self.session) + + self.assertEqual(self.task.items[0].track, 1) + self.assertEqual(self.task.items[1].track, 2) + self.assertEqual(self.task.items[0].artist, "Artist Name") + self.assertEqual(self.task.items[1].artist, "Artist Name") + self.assertEqual(self.task.items[0].title, "Song One") + self.assertEqual(self.task.items[1].title, "Song Two") + + def test_sep_dash(self): + """Test filenames that use "-" as separator.""" + + self.item1.path = "/music/files/01-Artist_Name-Song_One.m4a" + self.item2.path = "/music/files/02.-Artist_Name-Song_Two.m4a" + + f = fromfilename.FromFilenamePlugin() + f.filename_task(self.task, self.session) + + self.assertEqual(self.task.items[0].track, 1) + self.assertEqual(self.task.items[1].track, 2) + self.assertEqual(self.task.items[0].artist, "Artist_Name") + self.assertEqual(self.task.items[1].artist, "Artist_Name") + self.assertEqual(self.task.items[0].title, "Song_One") + self.assertEqual(self.task.items[1].title, "Song_Two") + + def test_track_title(self): + """Test filenames including track and title.""" + + self.item1.path = "/music/files/01 - Song_One.m4a" + self.item2.path = "/music/files/02. Song_Two.m4a" + + f = fromfilename.FromFilenamePlugin() + f.filename_task(self.task, self.session) + + self.assertEqual(self.task.items[0].track, 1) + self.assertEqual(self.task.items[1].track, 2) + self.assertEqual(self.task.items[0].artist, "") + self.assertEqual(self.task.items[1].artist, "") + self.assertEqual(self.task.items[0].title, "Song_One") + self.assertEqual(self.task.items[1].title, "Song_Two") + + def test_title_by_artist(self): + """Test filenames including title by artist.""" + + self.item1.path = "/music/files/Song One by The Artist.m4a" + self.item2.path = "/music/files/Song Two by The Artist.m4a" + + f = fromfilename.FromFilenamePlugin() + f.filename_task(self.task, self.session) + + self.assertEqual(self.task.items[0].track, 0) + self.assertEqual(self.task.items[1].track, 0) + self.assertEqual(self.task.items[0].artist, "The Artist") + self.assertEqual(self.task.items[1].artist, "The Artist") + self.assertEqual(self.task.items[0].title, "Song One") + self.assertEqual(self.task.items[1].title, "Song Two") + + def test_track_only(self): + """Test filenames including only track.""" + + self.item1.path = "/music/files/01.m4a" + self.item2.path = "/music/files/02.m4a" + + f = fromfilename.FromFilenamePlugin() + f.filename_task(self.task, self.session) + + self.assertEqual(self.task.items[0].track, 1) + self.assertEqual(self.task.items[1].track, 2) + self.assertEqual(self.task.items[0].artist, "") + self.assertEqual(self.task.items[1].artist, "") + self.assertEqual(self.task.items[0].title, "01") + self.assertEqual(self.task.items[1].title, "02") + + def test_title_only(self): + """Test filenames including only title.""" + + self.item1.path = "/music/files/Song One.m4a" + self.item2.path = "/music/files/Song Two.m4a" + + f = fromfilename.FromFilenamePlugin() + f.filename_task(self.task, self.session) + + self.assertEqual(self.task.items[0].track, 0) + self.assertEqual(self.task.items[1].track, 0) + self.assertEqual(self.task.items[0].artist, "") + self.assertEqual(self.task.items[1].artist, "") + self.assertEqual(self.task.items[0].title, "Song One") + self.assertEqual(self.task.items[1].title, "Song Two") + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == "__main__": + unittest.main(defaultTest="suite") From 1786adfff125c4e1f66b6658beef0b0cbfe4c847 Mon Sep 17 00:00:00 2001 From: Rebecca Turner <rbt@sent.as> Date: Sun, 27 Jul 2025 22:37:33 -0700 Subject: [PATCH 005/301] chroma: set a default timeout of 10 seconds TODO: Configurable timeouts :) --- beetsplug/chroma.py | 6 ++++-- docs/changelog.rst | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index f90877113..3b31382b7 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -98,7 +98,9 @@ def acoustid_match(log, path): fp = fp.decode() _fingerprints[path] = fp try: - res = acoustid.lookup(API_KEY, fp, duration, meta="recordings releases") + res = acoustid.lookup( + API_KEY, fp, duration, meta="recordings releases", timeout=10 + ) except acoustid.AcoustidError as exc: log.debug( "fingerprint matching {0} failed: {1}", @@ -292,7 +294,7 @@ def submit_items(log, userkey, items, chunksize=64): """Submit the current accumulated fingerprint data.""" log.info("submitting {0} fingerprints", len(data)) try: - acoustid.submit(API_KEY, userkey, data) + acoustid.submit(API_KEY, userkey, data, timeout=10) except acoustid.AcoustidError as exc: log.warning("acoustid submission error: {0}", exc) del data[:] diff --git a/docs/changelog.rst b/docs/changelog.rst index 75a11956b..b180be34f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,8 @@ Bug fixes: :bug:`5699` * :doc:`plugins/discogs`: Beets will no longer crash if a release has been deleted, and returns a 404. +* :doc:`plugins/chroma`: AcoustID lookup HTTP requests will now time out after + 10 seconds, rather than hanging the entire import process. For packagers: From 6dfde732b09bc054b7ed3b19d96bdcdb0e42ba56 Mon Sep 17 00:00:00 2001 From: Hendrik Boll <fanyx@posteo.net> Date: Fri, 8 Aug 2025 12:37:38 +0200 Subject: [PATCH 006/301] readme: add void linux --- docs/changelog.rst | 1 + docs/guides/main.rst | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab896a7ff..2c5147017 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -117,6 +117,7 @@ Other changes: - Refactored library.py file by splitting it into multiple modules within the beets/library directory. - Added a test to check that all plugins can be imported without errors. +- :doc:`/guides/main`: Add instructions to install beets on Void Linux. 2.3.1 (May 14, 2025) -------------------- diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 3e9c880ff..93f3d62cf 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -26,6 +26,8 @@ You will need Python. Beets works on Python 3.8 or later. which will probably set your computer on fire.) - On **Alpine Linux**, `beets is in the community repository <alpine package_>`_ and can be installed with ``apk add beets``. +- On **Void Linux**, `beets is in the official repository <void package_>`_ + and can be installed with ``xbps-install -S beets``. - For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run ``emerge beets`` to install. There are several USE flags available for optional plugin dependencies. @@ -53,6 +55,8 @@ You will need Python. Beets works on Python 3.8 or later. .. _macports: https://www.macports.org +.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets + .. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets .. _openbsd: http://openports.se/audio/beets From 5c7d49e24e4a6e4b6f89c68df2dce3c574cbfb0f Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Fri, 22 Aug 2025 11:30:47 +0200 Subject: [PATCH 007/301] Quick docfmt. --- docs/guides/main.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 93f3d62cf..bbb0ea858 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -26,8 +26,8 @@ You will need Python. Beets works on Python 3.8 or later. which will probably set your computer on fire.) - On **Alpine Linux**, `beets is in the community repository <alpine package_>`_ and can be installed with ``apk add beets``. -- On **Void Linux**, `beets is in the official repository <void package_>`_ - and can be installed with ``xbps-install -S beets``. +- On **Void Linux**, `beets is in the official repository <void package_>`_ and + can be installed with ``xbps-install -S beets``. - For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run ``emerge beets`` to install. There are several USE flags available for optional plugin dependencies. @@ -55,8 +55,6 @@ You will need Python. Beets works on Python 3.8 or later. .. _macports: https://www.macports.org -.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets - .. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets .. _openbsd: http://openports.se/audio/beets @@ -65,6 +63,8 @@ You will need Python. Beets works on Python 3.8 or later. .. _ubuntu details: https://launchpad.net/ubuntu/+source/beets +.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets + If you have pip_, just say ``pip install beets`` (or ``pip install --user beets`` if you run into permissions problems). From d87b80bd51db48de9c6cc0a8a69d4aa2b5489e1a Mon Sep 17 00:00:00 2001 From: David Logie <djl@mksh.org> Date: Sat, 23 Aug 2025 13:11:58 +0100 Subject: [PATCH 008/301] Log loaded plugins at the debug level. --- beets/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/plugins.py b/beets/plugins.py index c5c5b2c53..2daede655 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -384,7 +384,7 @@ def load_plugins() -> None: """ if not _instances: names = get_plugin_names() - log.info("Loading plugins: {}", ", ".join(sorted(names))) + log.debug("Loading plugins: {}", ", ".join(sorted(names))) _instances.extend(filter(None, map(_get_plugin, names))) send("pluginload") From 7f7b900f1b25690cf5a2e616c7235cdac2fb4aac Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Tue, 19 Aug 2025 07:02:58 +0200 Subject: [PATCH 009/301] lastgenre: Test canonicalization - Test non-whitelisted genres resolving "up" in the tree. - Test whitelisted original and whitelisted new genre resolving "up" - Test non-whitelisted original genre resolving "up" (and deduplication works) --- test/plugins/test_lastgenre.py | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index be145d811..81bfdd5ab 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -441,6 +441,77 @@ class LastGenrePluginTest(BeetsTestCase): }, ("Jazz", "keep + artist, whitelist"), ), + # 13 - canonicalization transforms non-whitelisted genres to canonical forms + # + # "Acid Techno" is not in the default whitelist, thus gets resolved "up" in the + # tree to "Techno" and "Electronic". + ( + { + "force": True, + "keep_existing": False, + "source": "album", + "whitelist": True, + "canonical": True, + "prefer_specific": False, + "count": 10, + }, + "", + { + "album": ["acid techno"], + }, + ("Techno, Electronic", "album, whitelist"), + ), + # 14 - canonicalization transforms whitelisted genres to canonical forms and + # includes originals + # + # "Detroit Techno" is in the default whitelist, thus it stays and and also gets + # resolved "up" in the tree to "Techno" and "Electronic". The same happens for + # newly fetched genre "Acid House". + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "canonical": True, + "prefer_specific": False, + "count": 10, + "extended_debug": True, + }, + "detroit techno", + { + "album": ["acid house"], + }, + ( + "Detroit Techno, Techno, Electronic, Acid House, House", + "keep + album, whitelist", + ), + ), + # 15 - canonicalization transforms non-whitelisted original genres to canonical + # forms and deduplication works. + # + # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the + # tree to "Disco" and "Electronic". New genre "Detroit Techno" resolves to + # "Techno". Both resolve to "Electronic" which gets deduplicated. + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "canonical": True, + "prefer_specific": False, + "count": 10, + }, + "Cosmic Disco", + { + "album": ["Detroit Techno"], + }, + ( + "Disco, Electronic, Detroit Techno, Techno", + "keep + album, whitelist", + ), + ), ], ) def test_get_genre(config_values, item_genre, mock_genres, expected_result): From fa8b5d7495add9bf264462c85846e9c73a53890d Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Mon, 18 Aug 2025 09:32:46 +0200 Subject: [PATCH 010/301] lastgenre: Fix canonicalization of non-valid genres - Remove "early whitelist check", since it breaks canonicalization of actually unwanted genres (not whitelisted) resolving "up" to parent genres. - Remove the filter_valid_genres method entirely and get back to inline list comprehensions. The caveat is that None genres are not catched that way (see below, should be one of the last functions that finally returns lists only) - Along the way, fix _last_lookup's rearly return to empty list instead of None. --- beetsplug/lastgenre/__init__.py | 63 +++++++++++++-------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index dbab96cf8..e3b5012ca 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -184,31 +184,28 @@ class LastGenrePlugin(plugins.BeetsPlugin): return [p[1] for p in depth_tag_pairs] def _resolve_genres(self, tags: list[str]) -> list[str]: - """Filter, deduplicate, sort, canonicalize provided genres list. + """Canonicalize, sort and filter a list of genres. - Returns an empty list if the input tags list is empty. - If canonicalization is enabled, it extends the list by incorporating parent genres from the canonicalization tree. When a whitelist is set, only parent tags that pass a validity check (_is_valid) are included; - otherwise, it adds the oldest ancestor. - - During canonicalization, it stops adding parent tags if the count of - tags reaches the configured limit (count). + otherwise, it adds the oldest ancestor. Adding parent tags is stopped + when the count of tags reaches the configured limit (count). - The tags list is then deduplicated to ensure only unique genres are retained. - - Optionally, if the 'prefer_specific' configuration is enabled, the - list is sorted by the specificity (depth in the canonicalization tree) - of the genres. - - The method then filters the tag list, ensuring that only valid - genres (those that pass the _is_valid method) are kept. If a - whitelist is set, only genres in the whitelist are considered valid - (which may even result in no genres at all being retained). - - Finally, the filtered list of genres, limited to - the configured count is returned. + - If the 'prefer_specific' configuration is enabled, the list is sorted + by the specificity (depth in the canonicalization tree) of the genres. + - Finally applies whitelist filtering to ensure that only valid + genres are kept. (This may result in no genres at all being retained). + - Returns the filtered list of genres, limited to the configured count. """ if not tags: return [] count = self.config["count"].get(int) + + # Canonicalization (if enabled) if self.canonicalize: # Extend the list to consider tags parents in the c14n tree tags_all = [] @@ -242,8 +239,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): # c14n only adds allowed genres but we may have had forbidden genres in # the original tags list - valid_tags = self._filter_valid_genres(tags) - return valid_tags[: self.config["count"].get(int)] + valid_tags = [t for t in tags if self._is_valid(t)] + return valid_tags[:count] def fetch_genre(self, lastfm_obj): """Return the genre for a pylast entity or None if no suitable genre @@ -252,12 +249,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): min_weight = self.config["min_weight"].get(int) return self._tags_for(lastfm_obj, min_weight) - def _filter_valid_genres(self, genres: list[str]) -> list[str]: - """Filter list of genres, only keep valid.""" - if not genres: - return [] - return [x for x in genres if self._is_valid(x)] - def _is_valid(self, genre: str) -> bool: """Check if the genre is valid. @@ -281,7 +272,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): """ # Shortcut if we're missing metadata. if any(not s for s in args): - return None + return [] key = f"{entity}.{'-'.join(str(a) for a in args)}" if key not in self._genre_cache: @@ -294,29 +285,23 @@ class LastGenrePlugin(plugins.BeetsPlugin): return genre def fetch_album_genre(self, obj): - """Return the album genre for this Item or Album.""" - return self._filter_valid_genres( - self._last_lookup( - "album", LASTFM.get_album, obj.albumartist, obj.album - ) + """Return raw album genres from Last.fm for this Item or Album.""" + return self._last_lookup( + "album", LASTFM.get_album, obj.albumartist, obj.album ) def fetch_album_artist_genre(self, obj): - """Return the album artist genre for this Item or Album.""" - return self._filter_valid_genres( - self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) - ) + """Return raw album artist genres from Last.fm for this Item or Album.""" + return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) def fetch_artist_genre(self, item): - """Returns the track artist genre for this Item.""" - return self._filter_valid_genres( - self._last_lookup("artist", LASTFM.get_artist, item.artist) - ) + """Returns raw track artist genres from Last.fm for this Item.""" + return self._last_lookup("artist", LASTFM.get_artist, item.artist) def fetch_track_genre(self, obj): - """Returns the track genre for this Item.""" - return self._filter_valid_genres( - self._last_lookup("track", LASTFM.get_track, obj.artist, obj.title) + """Returns raw track genres from Last.fm for this Item.""" + return self._last_lookup( + "track", LASTFM.get_track, obj.artist, obj.title ) # Main processing: _get_genre() and helpers. @@ -346,7 +331,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): self, old: list[str], new: list[str] ) -> list[str]: """Combine old and new genres and process via _resolve_genres.""" - self._log.debug(f"valid last.fm tags: {new}") + self._log.debug(f"raw last.fm tags: {new}") self._log.debug(f"existing genres taken into account: {old}") combined = old + new return self._resolve_genres(combined) From f85ba7ab3b7769e437b2bce4e6a667a0ef969264 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Tue, 19 Aug 2025 07:56:36 +0200 Subject: [PATCH 011/301] lastgenre: Fix test_get_genre loading whitelist - The default whitelist files were not loaded properly (at least in local test environments, not sure about CI yet...anyway...) --- test/plugins/test_lastgenre.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 81bfdd5ab..72b0d4f00 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -537,6 +537,7 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): plugin = lastgenre.LastGenrePlugin() # Configure plugin.config.set(config_values) + plugin.setup() # Loads default whitelist and canonicalization tree item = _common.item() item.genre = item_genre From d8e90d8e5493aa44062be7db155f36622bc6ed5a Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Wed, 20 Aug 2025 07:35:47 +0200 Subject: [PATCH 012/301] lastgenre: Resolve combined genres in each stage To ensure proper fallback to the next stage, in each stage we do a full combine/resolve/log. Also we directly return if have satisfied results. As a bonus this improves readability. Some duplicate code on the label magic though... --- beetsplug/lastgenre/__init__.py | 46 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index e3b5012ca..44c443a18 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -380,12 +380,32 @@ class LastGenrePlugin(plugins.BeetsPlugin): if isinstance(obj, library.Item) and "track" in self.sources: if new_genres := self.fetch_track_genre(obj): label = "track" + resolved_genres = self._combine_resolve_and_log( + keep_genres, new_genres + ) + if resolved_genres: + suffix = "whitelist" if self.whitelist else "any" + label += f", {suffix}" + if keep_genres: + label = f"keep + {label}" + return self._format_and_stringify(resolved_genres), label + new_genres = [] - if not new_genres and "album" in self.sources: + if "album" in self.sources: if new_genres := self.fetch_album_genre(obj): label = "album" + resolved_genres = self._combine_resolve_and_log( + keep_genres, new_genres + ) + if resolved_genres: + suffix = "whitelist" if self.whitelist else "any" + label += f", {suffix}" + if keep_genres: + label = f"keep + {label}" + return self._format_and_stringify(resolved_genres), label + new_genres = [] - if not new_genres and "artist" in self.sources: + if "artist" in self.sources: new_genres = [] if isinstance(obj, library.Item): new_genres = self.fetch_artist_genre(obj) @@ -414,17 +434,17 @@ class LastGenrePlugin(plugins.BeetsPlugin): rank, ) - # Return with a combined or freshly fetched genre list. - if new_genres: - resolved_genres = self._combine_resolve_and_log( - keep_genres, new_genres - ) - if resolved_genres: - suffix = "whitelist" if self.whitelist else "any" - label += f", {suffix}" - if keep_genres: - label = f"keep + {label}" - return self._format_and_stringify(resolved_genres), label + if new_genres: + resolved_genres = self._combine_resolve_and_log( + keep_genres, new_genres + ) + if resolved_genres: + suffix = "whitelist" if self.whitelist else "any" + label += f", {suffix}" + if keep_genres: + label = f"keep + {label}" + return self._format_and_stringify(resolved_genres), label + new_genres = [] # Nothing found, leave original if configured and valid. if obj.genre and self.config["keep_existing"]: From 05a1a95ee91481f0ec095f4aa351ed4ba2be96e5 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Wed, 20 Aug 2025 07:59:10 +0200 Subject: [PATCH 013/301] lastgenre: Dedup combine/resolve/label/format code --- beetsplug/lastgenre/__init__.py | 65 +++++++++++++++------------------ 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 44c443a18..2a67fa9da 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -357,9 +357,22 @@ class LastGenrePlugin(plugins.BeetsPlugin): applied, while "artist, any" means only new last.fm genres are included and the whitelist feature was disabled. """ + + def _try_resolve_stage(stage_label: str, keep_genres, new_genres): + """Try to resolve genres for a given stage and log the result.""" + resolved_genres = self._combine_resolve_and_log( + keep_genres, new_genres + ) + if resolved_genres: + suffix = "whitelist" if self.whitelist else "any" + label = stage_label + f", {suffix}" + if keep_genres: + label = f"keep + {label}" + return self._format_and_stringify(resolved_genres), label + return None + keep_genres = [] new_genres = [] - label = "" genres = self._get_existing_genres(obj) if genres and not self.config["force"]: @@ -379,40 +392,26 @@ class LastGenrePlugin(plugins.BeetsPlugin): # album artist, or most popular track genre. if isinstance(obj, library.Item) and "track" in self.sources: if new_genres := self.fetch_track_genre(obj): - label = "track" - resolved_genres = self._combine_resolve_and_log( - keep_genres, new_genres - ) - if resolved_genres: - suffix = "whitelist" if self.whitelist else "any" - label += f", {suffix}" - if keep_genres: - label = f"keep + {label}" - return self._format_and_stringify(resolved_genres), label - new_genres = [] + if result := _try_resolve_stage( + "track", keep_genres, new_genres + ): + return result if "album" in self.sources: if new_genres := self.fetch_album_genre(obj): - label = "album" - resolved_genres = self._combine_resolve_and_log( - keep_genres, new_genres - ) - if resolved_genres: - suffix = "whitelist" if self.whitelist else "any" - label += f", {suffix}" - if keep_genres: - label = f"keep + {label}" - return self._format_and_stringify(resolved_genres), label - new_genres = [] + if result := _try_resolve_stage( + "album", keep_genres, new_genres + ): + return result if "artist" in self.sources: new_genres = [] if isinstance(obj, library.Item): new_genres = self.fetch_artist_genre(obj) - label = "artist" + stage_label = "artist" elif obj.albumartist != config["va_name"].as_str(): new_genres = self.fetch_album_artist_genre(obj) - label = "album artist" + stage_label = "album artist" else: # For "Various Artists", pick the most popular track genre. item_genres = [] @@ -427,7 +426,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if item_genres: most_popular, rank = plurality(item_genres) new_genres = [most_popular] - label = "most popular track" + stage_label = "most popular track" self._log.debug( 'Most popular track genre "{}" ({}) for VA album.', most_popular, @@ -435,16 +434,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) if new_genres: - resolved_genres = self._combine_resolve_and_log( - keep_genres, new_genres - ) - if resolved_genres: - suffix = "whitelist" if self.whitelist else "any" - label += f", {suffix}" - if keep_genres: - label = f"keep + {label}" - return self._format_and_stringify(resolved_genres), label - new_genres = [] + if result := _try_resolve_stage( + stage_label, keep_genres, new_genres + ): + return result # Nothing found, leave original if configured and valid. if obj.genre and self.config["keep_existing"]: From a1efd2836a2d3b7892be0f473b37397d746f8e2e Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Wed, 20 Aug 2025 19:09:13 +0200 Subject: [PATCH 014/301] lastgenre: Clarify keep-existing precedence in docs --- docs/plugins/lastgenre.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 68d4a60a7..5ebe2d721 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -147,8 +147,9 @@ Add new last.fm genres when **empty**. Any present tags stay **untouched**. **Setup 3** **Combine** genres in present tags with new ones (be aware of that with an -enabled ``whitelist`` setting, of course some genres might get cleaned up. To -make sure any existing genres remain, set ``whitelist: no``). +enabled ``whitelist`` setting, of course some genres might get cleaned up - +existing genres take precedence over new ones though. To make sure any existing +genres remain, set ``whitelist: no``). .. code-block:: yaml From efa968175ba52a72113103d6dea45c9cb95a546e Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 21 Aug 2025 07:51:37 +0200 Subject: [PATCH 015/301] Changelog for #5946 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2c5147017..00e9a9e74 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,9 @@ Bug fixes: the config option ``spotify.search_query_ascii: yes``. :bug:`5699` - :doc:`plugins/discogs`: Beets will no longer crash if a release has been deleted, and returns a 404. +- :doc:`plugins/lastgenre`: Fix the issue introduced in Beets 2.3.0 where + non-whitelisted last.fm genres were not canonicalized to parent genres. + :bug:`5930` For packagers: From 0dcf7fdc234dd68f43dae479a6c99b3bf7d4fb95 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 21 Aug 2025 08:32:47 +0200 Subject: [PATCH 016/301] lastgenre: Remove leftover/unused REPLACE constant --- beetsplug/lastgenre/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 2a67fa9da..b0808e4b9 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -42,10 +42,6 @@ PYLAST_EXCEPTIONS = ( pylast.NetworkError, ) -REPLACE = { - "\u2010": "-", -} - # Canonicalization tree processing. From 6408b5e28080e4ad303c74a68025aea4371fcdba Mon Sep 17 00:00:00 2001 From: Ryan Waskiewicz <ryanwaskiewicz@gmail.com> Date: Sun, 24 Aug 2025 09:04:21 -0400 Subject: [PATCH 017/301] Add 'pretend' flag to update command example Add `p` as an acceptable flag for the `update` command to match the long form section of the command's docs --- docs/reference/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index f1ef041b6..c0274553a 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -323,7 +323,7 @@ update :: - beet update [-F] FIELD [-e] EXCLUDE_FIELD [-aM] QUERY + beet update [-F] FIELD [-e] EXCLUDE_FIELD [-aMp] QUERY Update the library (and, by default, move files) to reflect out-of-band metadata changes and file deletions. From 535fcfdbe79c6c75f41da72511ede1eb99f4d60f Mon Sep 17 00:00:00 2001 From: Luccoli <97095003+Luccoli@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:47:52 +0200 Subject: [PATCH 018/301] docs: update discogs.rst - add default of index_tracks configuration --- docs/plugins/discogs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index e6b93961a..44c0c0e22 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -76,7 +76,7 @@ between distinct works on the same release or within works. When index_tracks: yes beets will incorporate the names of the divisions containing each track into the -imported track's title. +imported track's title. Default: ``no``. For example, importing `divisions album`_ would result in track names like: From f81684e188c856283d06602b777a4ccc4818752d Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Thu, 17 Jul 2025 17:08:34 +0200 Subject: [PATCH 019/301] Moved construct search into SearchApiMetadataSource to dedupe some deezer and spotify functionalities. --- beets/metadata_plugins.py | 37 +++++++++++++++++++++++++++++++++++++ beetsplug/deezer.py | 22 ---------------------- beetsplug/spotify.py | 27 --------------------------- docs/changelog.rst | 5 +++++ docs/plugins/deezer.rst | 15 +++++++++++---- 5 files changed, 53 insertions(+), 53 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 9d69633d6..7e333a783 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -14,6 +14,7 @@ import warnings from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar from typing_extensions import NotRequired +import unidecode from beets.util import cached_classproperty from beets.util.id_extractors import extract_release_id @@ -334,6 +335,14 @@ class SearchApiMetadataSourcePlugin( of identifiers for the requested type (album or track). """ + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.config.add( + { + "search_query_ascii": False, + } + ) + @abc.abstractmethod def _search_api( self, @@ -382,6 +391,34 @@ class SearchApiMetadataSourcePlugin( self.tracks_for_ids([result["id"] for result in results if result]), ) + def _construct_search_query( + self, filters: SearchFilter, keywords: str = "" + ) -> str: + """Construct a query string with the specified filters and keywords to + be provided to the Spotify (or similar) Search API. + + At the moment, this is used to construct a query string for: + - Spotify (https://developer.spotify.com/documentation/web-api/reference/search). + - Deezer (https://developers.deezer.com/api/search). + + :param filters: Field filters to apply. + :param keywords: Query keywords to use. + :return: Query string to be provided to the Search API. + """ + + query_components = [ + keywords, + " ".join(f'{k}:"{v}"' for k, v in filters.items()), + ] + query = " ".join([q for q in query_components if q]) + if not isinstance(query, str): + query = query.decode("utf8") + + if self.config["search_query_ascii"].get(): + query = unidecode.unidecode(query) + + return query + # Dynamically copy methods to BeetsPlugin for legacy support # TODO: Remove this in the future major release, v3.0.0 diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 8815e3d59..abb7d80c4 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -21,7 +21,6 @@ import time from typing import TYPE_CHECKING, Literal, Sequence import requests -import unidecode from beets import ui from beets.autotag import AlbumInfo, TrackInfo @@ -216,27 +215,6 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): deezer_updated=time.time(), ) - @staticmethod - def _construct_search_query( - filters: SearchFilter, keywords: str = "" - ) -> str: - """Construct a query string with the specified filters and keywords to - be provided to the Deezer Search API - (https://developers.deezer.com/api/search). - - :param filters: Field filters to apply. - :param keywords: (Optional) Query keywords to use. - :return: Query string to be provided to the Search API. - """ - query_components = [ - keywords, - " ".join(f'{k}:"{v}"' for k, v in filters.items()), - ] - query = " ".join([q for q in query_components if q]) - if not isinstance(query, str): - query = query.decode("utf8") - return unidecode.unidecode(query) - def _search_api( self, query_type: Literal[ diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index fa5dc5c52..cdbed655e 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -29,7 +29,6 @@ from typing import TYPE_CHECKING, Any, Literal, Sequence, Union import confuse import requests -import unidecode from beets import ui from beets.autotag.hooks import AlbumInfo, TrackInfo @@ -139,7 +138,6 @@ class SpotifyPlugin( "client_id": "4e414367a1d14c75a5c5129a627fcab8", "client_secret": "f82bdc09b2254f1a8286815d02fd46dc", "tokenfile": "spotify_token.json", - "search_query_ascii": False, } ) self.config["client_id"].redact = True @@ -422,31 +420,6 @@ class SpotifyPlugin( track.medium_total = medium_total return track - def _construct_search_query( - self, filters: SearchFilter, keywords: str = "" - ) -> str: - """Construct a query string with the specified filters and keywords to - be provided to the Spotify Search API - (https://developer.spotify.com/documentation/web-api/reference/search). - - :param filters: (Optional) Field filters to apply. - :param keywords: (Optional) Query keywords to use. - :return: Query string to be provided to the Search API. - """ - - query_components = [ - keywords, - " ".join(f"{k}:{v}" for k, v in filters.items()), - ] - query = " ".join([q for q in query_components if q]) - if not isinstance(query, str): - query = query.decode("utf8") - - if self.config["search_query_ascii"].get(): - query = unidecode.unidecode(query) - - return query - def _search_api( self, query_type: Literal["album", "track"], diff --git a/docs/changelog.rst b/docs/changelog.rst index 52d9cd89e..a9e85f9e0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,11 @@ Bug fixes: :bug:`5930` - :doc:`plugins/chroma`: AcoustID lookup HTTP requests will now time out after 10 seconds, rather than hanging the entire import process. +- :doc:`/plugins/deezer`: Fix the issue with that every query to deezer was + ascii encoded. This resulted in bad matches for queries that contained special + e.g. non latin characters as 盗作. If you want to keep the legacy behavior + set the config option ``deezer.search_query_ascii: yes``. + :bug:`5860` For packagers: diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index e58252e84..ec44a530f 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -29,7 +29,14 @@ Configuration This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. -The ``deezer`` plugin provides an additional command ``deezerupdate`` to update -the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a -global indicator of a song's popularity on Deezer that is updated daily based on -streams. The higher the ``rank``, the more popular the track is. +The default options should work as-is, but there are some options you can put +in config.yaml under the ``deezer:`` section: + +- **search_query_ascii**: If set to ``yes``, the search query will be converted to + ASCII before being sent to Deezer. Converting searches to ASCII can + enhance search results in some cases, but in general, it is not recommended. + For instance `artist:deadmau5 album:4×4` will be converted to + `artist:deadmau5 album:4x4` (notice `×!=x`). + Default: ``no``. + +The ``deezer`` plugin provides an additional command ``deezerupdate`` to update the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a global indicator of a song's popularity on Deezer that is updated daily based on streams. The higher the ``rank``, the more popular the track is. From abffa7900b7d9bb4071a5532e51d91ce2a3a018e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Thu, 17 Jul 2025 17:32:32 +0200 Subject: [PATCH 020/301] Fixed tests as query formatting changed slightly. --- test/plugins/test_spotify.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index a2fb26f4b..67deca36f 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -82,8 +82,8 @@ class SpotifyPluginTest(PluginTestCase): params = _params(responses.calls[0].request.url) query = params["q"][0] assert "duifhjslkef" in query - assert "artist:ujydfsuihse" in query - assert "album:lkajsdflakjsd" in query + assert 'artist:"ujydfsuihse"' in query + assert 'album:"lkajsdflakjsd"' in query assert params["type"] == ["track"] @responses.activate @@ -117,8 +117,8 @@ class SpotifyPluginTest(PluginTestCase): params = _params(responses.calls[0].request.url) query = params["q"][0] assert "Happy" in query - assert "artist:Pharrell Williams" in query - assert "album:Despicable Me 2" in query + assert 'artist:"Pharrell Williams"' in query + assert 'album:"Despicable Me 2"' in query assert params["type"] == ["track"] @responses.activate @@ -233,8 +233,8 @@ class SpotifyPluginTest(PluginTestCase): params = _params(responses.calls[0].request.url) query = params["q"][0] assert item.title in query - assert f"artist:{item.albumartist}" in query - assert f"album:{item.album}" in query + assert f'artist:"{item.albumartist}"' in query + assert f'album:"{item.album}"' in query assert not query.isascii() # Is not found in the library if ascii encoding is enabled From 1c0484c4ba7d56a271199889f0e14ea27fb46c11 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sat, 19 Jul 2025 00:02:13 +0200 Subject: [PATCH 021/301] renamed keywords to query_string, shortened query construct expression, removed legacy isinstance check --- beets/metadata_plugins.py | 21 +++++++++------------ beetsplug/deezer.py | 10 ++++++---- beetsplug/spotify.py | 20 ++++++++++++-------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 7e333a783..be858bdef 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -348,13 +348,13 @@ class SearchApiMetadataSourcePlugin( self, query_type: Literal["album", "track"], filters: SearchFilter, - keywords: str = "", + query_string: str = "", ) -> Sequence[R]: """Perform a search on the API. :param query_type: The type of query to perform. :param filters: A dictionary of filters to apply to the search. - :param keywords: Additional keywords to include in the search. + :param query_string: Additional query to include in the search. Should return a list of identifiers for the requested type (album or track). """ @@ -382,7 +382,9 @@ class SearchApiMetadataSourcePlugin( def item_candidates( self, item: Item, artist: str, title: str ) -> Iterable[TrackInfo]: - results = self._search_api("track", {"artist": artist}, keywords=title) + results = self._search_api( + "track", {"artist": artist}, query_string=title + ) if not results: return [] @@ -392,7 +394,7 @@ class SearchApiMetadataSourcePlugin( ) def _construct_search_query( - self, filters: SearchFilter, keywords: str = "" + self, filters: SearchFilter, query_string: str ) -> str: """Construct a query string with the specified filters and keywords to be provided to the Spotify (or similar) Search API. @@ -402,17 +404,12 @@ class SearchApiMetadataSourcePlugin( - Deezer (https://developers.deezer.com/api/search). :param filters: Field filters to apply. - :param keywords: Query keywords to use. + :param query_string: Query keywords to use. :return: Query string to be provided to the Search API. """ - query_components = [ - keywords, - " ".join(f'{k}:"{v}"' for k, v in filters.items()), - ] - query = " ".join([q for q in query_components if q]) - if not isinstance(query, str): - query = query.decode("utf8") + components = [query_string, *(f'{k}:"{v}"' for k, v in filters.items())] + query = " ".join(filter(None, components)) if self.config["search_query_ascii"].get(): query = unidecode.unidecode(query) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index abb7d80c4..c8602b5e8 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -228,17 +228,19 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): "user", ], filters: SearchFilter, - keywords="", + query_string: str = "", ) -> Sequence[IDResponse]: - """Query the Deezer Search API for the specified ``keywords``, applying + """Query the Deezer Search API for the specified ``query_string``, applying the provided ``filters``. :param filters: Field filters to apply. - :param keywords: Query keywords to use. + :param query_string: Additional query to include in the search. :return: JSON data for the class:`Response <Response>` object or None if no search results are returned. """ - query = self._construct_search_query(keywords=keywords, filters=filters) + query = self._construct_search_query( + query_string=query_string, filters=filters + ) self._log.debug(f"Searching {self.data_source} for '{query}'") try: response = requests.get( diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index cdbed655e..f45ed158b 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -424,17 +424,19 @@ class SpotifyPlugin( self, query_type: Literal["album", "track"], filters: SearchFilter, - keywords: str = "", + query_string: str = "", ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: - """Query the Spotify Search API for the specified ``keywords``, + """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. :param query_type: Item type to search across. Valid types are: 'album', 'artist', 'playlist', and 'track'. - :param filters: (Optional) Field filters to apply. - :param keywords: (Optional) Query keywords to use. + :param filters: Field filters to apply. + :param query_string: Additional query to include in the search. """ - query = self._construct_search_query(keywords=keywords, filters=filters) + query = self._construct_search_query( + filters=filters, query_string=query_string + ) self._log.debug(f"Searching {self.data_source} for '{query}'") try: @@ -561,16 +563,18 @@ class SpotifyPlugin( # Custom values can be passed in the config (just in case) artist = item[self.config["artist_field"].get()] album = item[self.config["album_field"].get()] - keywords = item[self.config["track_field"].get()] + query_string = item[self.config["track_field"].get()] # Query the Web API for each track, look for the items' JSON data query_filters: SearchFilter = {"artist": artist, "album": album} response_data_tracks = self._search_api( - query_type="track", keywords=keywords, filters=query_filters + query_type="track", + query_string=query_string, + filters=query_filters, ) if not response_data_tracks: query = self._construct_search_query( - keywords=keywords, filters=query_filters + query_string=query_string, filters=query_filters ) failures.append(query) From 014831b5880beff64c174399a7db5ed65c2209df Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Fri, 22 Aug 2025 12:11:20 +0200 Subject: [PATCH 022/301] Reformulated docs to make the reference less strong. --- beets/metadata_plugins.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index be858bdef..455828191 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -397,15 +397,16 @@ class SearchApiMetadataSourcePlugin( self, filters: SearchFilter, query_string: str ) -> str: """Construct a query string with the specified filters and keywords to - be provided to the Spotify (or similar) Search API. + be provided to the spotify (or similar) search API. - At the moment, this is used to construct a query string for: - - Spotify (https://developer.spotify.com/documentation/web-api/reference/search). - - Deezer (https://developers.deezer.com/api/search). + The returned format was initially designed for spotify's search API but + we found is also useful with other APIs that support similar query structures. + see `spotify <https://developer.spotify.com/documentation/web-api/reference/search>`_ + and `deezer <https://developers.deezer.com/api/search>`_. :param filters: Field filters to apply. :param query_string: Query keywords to use. - :return: Query string to be provided to the Search API. + :return: Query string to be provided to the search API. """ components = [query_string, *(f'{k}:"{v}"' for k, v in filters.items())] From 279c828369c7f351c9185d301646ba5f5c42d8ed Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Fri, 22 Aug 2025 12:13:42 +0200 Subject: [PATCH 023/301] run docstrfmt --- beets/metadata_plugins.py | 2 +- docs/changelog.rst | 5 ++--- docs/plugins/deezer.rst | 20 +++++++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 455828191..1cdba5fe2 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -13,8 +13,8 @@ import re import warnings from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar -from typing_extensions import NotRequired import unidecode +from typing_extensions import NotRequired from beets.util import cached_classproperty from beets.util.id_extractors import extract_release_id diff --git a/docs/changelog.rst b/docs/changelog.rst index a9e85f9e0..e12050fdc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -59,9 +59,8 @@ Bug fixes: 10 seconds, rather than hanging the entire import process. - :doc:`/plugins/deezer`: Fix the issue with that every query to deezer was ascii encoded. This resulted in bad matches for queries that contained special - e.g. non latin characters as 盗作. If you want to keep the legacy behavior - set the config option ``deezer.search_query_ascii: yes``. - :bug:`5860` + e.g. non latin characters as 盗作. If you want to keep the legacy behavior set + the config option ``deezer.search_query_ascii: yes``. :bug:`5860` For packagers: diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index ec44a530f..2d0bd7009 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -29,14 +29,16 @@ Configuration This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. -The default options should work as-is, but there are some options you can put -in config.yaml under the ``deezer:`` section: +The default options should work as-is, but there are some options you can put in +config.yaml under the ``deezer:`` section: -- **search_query_ascii**: If set to ``yes``, the search query will be converted to - ASCII before being sent to Deezer. Converting searches to ASCII can - enhance search results in some cases, but in general, it is not recommended. - For instance `artist:deadmau5 album:4×4` will be converted to - `artist:deadmau5 album:4x4` (notice `×!=x`). - Default: ``no``. +- **search_query_ascii**: If set to ``yes``, the search query will be converted + to ASCII before being sent to Deezer. Converting searches to ASCII can enhance + search results in some cases, but in general, it is not recommended. For + instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 + album:4x4`` (notice ``×!=x``). Default: ``no``. -The ``deezer`` plugin provides an additional command ``deezerupdate`` to update the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a global indicator of a song's popularity on Deezer that is updated daily based on streams. The higher the ``rank``, the more popular the track is. +The ``deezer`` plugin provides an additional command ``deezerupdate`` to update +the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a +global indicator of a song's popularity on Deezer that is updated daily based on +streams. The higher the ``rank``, the more popular the track is. From 4a361bd501e85de12c91c2474c423559ca672852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 30 Aug 2025 18:41:40 +0100 Subject: [PATCH 024/301] Replace format calls with f-strings --- CONTRIBUTING.rst | 6 +- beets/__init__.py | 2 +- beets/autotag/distance.py | 6 +- beets/dbcore/db.py | 38 ++++----- beets/dbcore/query.py | 12 ++- beets/dbcore/types.py | 6 +- beets/importer/stages.py | 4 +- beets/library/models.py | 9 +- beets/plugins.py | 8 +- beets/ui/__init__.py | 35 +++----- beets/ui/commands.py | 130 ++++++++++++---------------- beets/util/__init__.py | 23 +++-- beets/util/artresizer.py | 2 +- beets/util/functemplate.py | 4 +- beetsplug/absubmit.py | 8 +- beetsplug/aura.py | 43 +++------- beetsplug/badfiles.py | 23 ++--- beetsplug/beatport.py | 23 ++--- beetsplug/bpd/__init__.py | 31 ++----- beetsplug/bucket.py | 11 +-- beetsplug/convert.py | 27 +++--- beetsplug/discogs.py | 6 +- beetsplug/edit.py | 8 +- beetsplug/embedart.py | 13 ++- beetsplug/embyupdate.py | 8 +- beetsplug/fish.py | 27 ++---- beetsplug/fromfilename.py | 6 +- beetsplug/lyrics.py | 2 +- beetsplug/mbcollection.py | 4 +- beetsplug/metasync/__init__.py | 2 +- beetsplug/musicbrainz.py | 4 +- beetsplug/play.py | 6 +- beetsplug/playlist.py | 20 ++--- beetsplug/plexupdate.py | 8 +- beetsplug/replaygain.py | 56 +++++------- beetsplug/smartplaylist.py | 10 +-- beetsplug/spotify.py | 27 +++--- beetsplug/subsonicplaylist.py | 4 +- beetsplug/thumbnails.py | 6 +- beetsplug/types.py | 2 +- beetsplug/web/__init__.py | 4 +- pyproject.toml | 1 + test/plugins/test_art.py | 10 +-- test/plugins/test_convert.py | 6 +- test/plugins/test_embedart.py | 4 +- test/plugins/test_ipfs.py | 2 +- test/plugins/test_play.py | 8 +- test/plugins/test_playlist.py | 147 +++++++++++++------------------- test/plugins/test_random.py | 2 +- test/plugins/test_replaygain.py | 12 +-- 50 files changed, 335 insertions(+), 531 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 92375b465..a49a0443f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -238,11 +238,7 @@ There are a few coding conventions we use in beets: .. code-block:: python with g.lib.transaction() as tx: - rows = tx.query( - "SELECT DISTINCT '{0}' FROM '{1}' ORDER BY '{2}'".format( - field, model._table, sort_field - ) - ) + rows = tx.query("SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}") To fetch Item objects from the database, use lib.items(…) and supply a query as an argument. Resist the urge to write raw SQL for your query. If you must diff --git a/beets/__init__.py b/beets/__init__.py index 8be305202..c5b93230f 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -35,7 +35,7 @@ class IncludeLazyConfig(confuse.LazyConfig): except confuse.NotFoundError: pass except confuse.ConfigReadError as err: - stderr.write("configuration `import` failed: {}".format(err.reason)) + stderr.write(f"configuration `import` failed: {err.reason}") config = IncludeLazyConfig("beets", __name__) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 39d16858f..db5da585e 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -79,9 +79,9 @@ def string_dist(str1: str | None, str2: str | None) -> float: # "something, the". for word in SD_END_WORDS: if str1.endswith(", %s" % word): - str1 = "{} {}".format(word, str1[: -len(word) - 2]) + str1 = f"{word} {str1[: -len(word) - 2]}" if str2.endswith(", %s" % word): - str2 = "{} {}".format(word, str2[: -len(word) - 2]) + str2 = f"{word} {str2[: -len(word) - 2]}" # Perform a couple of basic normalizing substitutions. for pat, repl in SD_REPLACE: @@ -230,7 +230,7 @@ class Distance: """Adds all the distance penalties from `dist`.""" if not isinstance(dist, Distance): raise ValueError( - "`dist` must be a Distance object, not {}".format(type(dist)) + f"`dist` must be a Distance object, not {type(dist)}" ) for key, penalties in dist._penalties.items(): self._penalties.setdefault(key, []).extend(penalties) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 81c1be4b9..82c7217b7 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -390,9 +390,9 @@ class Model(ABC, Generic[D]): return obj def __repr__(self) -> str: - return "{}({})".format( - type(self).__name__, - ", ".join(f"{k}={v!r}" for k, v in dict(self).items()), + return ( + f"{type(self).__name__}" + f"({', '.join(f'{k}={v!r}' for k, v in dict(self).items())})" ) def clear_dirty(self): @@ -409,9 +409,9 @@ class Model(ABC, Generic[D]): exception is raised otherwise. """ if not self._db: - raise ValueError("{} has no database".format(type(self).__name__)) + raise ValueError(f"{type(self).__name__} has no database") if need_id and not self.id: - raise ValueError("{} has no id".format(type(self).__name__)) + raise ValueError(f"{type(self).__name__} has no id") return self._db @@ -595,9 +595,7 @@ class Model(ABC, Generic[D]): with db.transaction() as tx: # Main table update. if assignments: - query = "UPDATE {} SET {} WHERE id=?".format( - self._table, ",".join(assignments) - ) + query = f"UPDATE {self._table} SET {','.join(assignments)} WHERE id=?" subvars.append(self.id) tx.mutate(query, subvars) @@ -607,9 +605,9 @@ class Model(ABC, Generic[D]): self._dirty.remove(key) value = self._type(key).to_sql(value) tx.mutate( - "INSERT INTO {} " + f"INSERT INTO {self._flex_table} " "(entity_id, key, value) " - "VALUES (?, ?, ?);".format(self._flex_table), + "VALUES (?, ?, ?);", (self.id, key, value), ) @@ -1173,9 +1171,7 @@ class Database: columns = [] for name, typ in fields.items(): columns.append(f"{name} {typ.sql}") - setup_sql = "CREATE TABLE {} ({});\n".format( - table, ", ".join(columns) - ) + setup_sql = f"CREATE TABLE {table} ({', '.join(columns)});\n" else: # Table exists does not match the field set. @@ -1183,8 +1179,8 @@ class Database: for name, typ in fields.items(): if name in current_fields: continue - setup_sql += "ALTER TABLE {} ADD COLUMN {} {};\n".format( - table, name, typ.sql + setup_sql += ( + f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n" ) with self.transaction() as tx: @@ -1195,18 +1191,16 @@ class Database: for the given entity (if they don't exist). """ with self.transaction() as tx: - tx.script( - """ - CREATE TABLE IF NOT EXISTS {0} ( + tx.script(f""" + CREATE TABLE IF NOT EXISTS {flex_table} ( id INTEGER PRIMARY KEY, entity_id INTEGER, key TEXT, value TEXT, UNIQUE(entity_id, key) ON CONFLICT REPLACE); - CREATE INDEX IF NOT EXISTS {0}_by_entity - ON {0} (entity_id); - """.format(flex_table) - ) + CREATE INDEX IF NOT EXISTS {flex_table}_by_entity + ON {flex_table} (entity_id); + """) # Querying. diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 49d7f6428..1f4fff1c0 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -475,7 +475,7 @@ class NumericQuery(FieldQuery[str]): else: if self.rangemin is not None and self.rangemax is not None: return ( - "{0} >= ? AND {0} <= ?".format(self.field), + f"{self.field} >= ? AND {self.field} <= ?", (self.rangemin, self.rangemax), ) elif self.rangemin is not None: @@ -800,9 +800,7 @@ class DateInterval: def __init__(self, start: datetime | None, end: datetime | None): if start is not None and end is not None and not start < end: - raise ValueError( - "start date {} is not before end date {}".format(start, end) - ) + raise ValueError(f"start date {start} is not before end date {end}") self.start = start self.end = end @@ -1074,9 +1072,9 @@ class FixedFieldSort(FieldSort): if self.case_insensitive: field = ( "(CASE " - "WHEN TYPEOF({0})='text' THEN LOWER({0}) " - "WHEN TYPEOF({0})='blob' THEN LOWER({0}) " - "ELSE {0} END)".format(self.field) + f"WHEN TYPEOF({self.field})='text' THEN LOWER({self.field}) " + f"WHEN TYPEOF({self.field})='blob' THEN LOWER({self.field}) " + f"ELSE {self.field} END)" ) else: field = self.field diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 1b8434a0b..3b4badd33 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -194,7 +194,7 @@ class BasePaddedInt(BaseInteger[N]): self.digits = digits def format(self, value: int | N) -> str: - return "{0:0{1}d}".format(value or 0, self.digits) + return f"{value or 0:0{self.digits}d}" class PaddedInt(BasePaddedInt[int]): @@ -219,7 +219,7 @@ class ScaledInt(Integer): self.suffix = suffix def format(self, value: int) -> str: - return "{}{}".format((value or 0) // self.unit, self.suffix) + return f"{(value or 0) // self.unit}{self.suffix}" class Id(NullInteger): @@ -249,7 +249,7 @@ class BaseFloat(Type[float, N]): self.digits = digits def format(self, value: float | N) -> str: - return "{0:.{1}f}".format(value or 0, self.digits) + return f"{value or 0:.{self.digits}f}" class Float(BaseFloat[float]): diff --git a/beets/importer/stages.py b/beets/importer/stages.py index 24ff815f3..e8ce3fbac 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -341,9 +341,7 @@ def _resolve_duplicates(session: ImportSession, task: ImportTask): if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: - log.debug( - "found duplicates: {}".format([o.id for o in found_duplicates]) - ) + log.debug(f"found duplicates: {[o.id for o in found_duplicates]}") # Get the default action to follow from config. duplicate_action = config["import"]["duplicate_action"].as_choice( diff --git a/beets/library/models.py b/beets/library/models.py index 7501513a1..e004fb83b 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -844,12 +844,9 @@ class Item(LibModel): # This must not use `with_album=True`, because that might access # the database. When debugging, that is not guaranteed to succeed, and # can even deadlock due to the database lock. - return "{}({})".format( - type(self).__name__, - ", ".join( - "{}={!r}".format(k, self[k]) - for k in self.keys(with_album=False) - ), + return ( + f"{type(self).__name__}" + f"({', '.join(f'{k}={self[k]!r}' for k in self.keys(with_album=False))})" ) def keys(self, computed=False, with_album=True): diff --git a/beets/plugins.py b/beets/plugins.py index 2daede655..f7a449b22 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -424,9 +424,9 @@ def types(model_cls: type[AnyModel]) -> dict[str, Type]: for field in plugin_types: if field in types and plugin_types[field] != types[field]: raise PluginConflictError( - "Plugin {} defines flexible field {} " + f"Plugin {plugin.name} defines flexible field {field} " "which has already been defined with " - "another type.".format(plugin.name, field) + "another type." ) types.update(plugin_types) return types @@ -560,8 +560,8 @@ def feat_tokens(for_artist: bool = True) -> str: feat_words = ["ft", "featuring", "feat", "feat.", "ft."] if for_artist: feat_words += ["with", "vs", "and", "con", "&"] - return r"(?<=[\s(\[])(?:{})(?=\s)".format( - "|".join(re.escape(x) for x in feat_words) + return ( + rf"(?<=[\s(\[])(?:{'|'.join(re.escape(x) for x in feat_words)})(?=\s)" ) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 01030a977..60c99c8e1 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -726,7 +726,7 @@ def get_replacements(): replacements.append((re.compile(pattern), repl)) except re.error: raise UserError( - "malformed regular expression in replace: {}".format(pattern) + f"malformed regular expression in replace: {pattern}" ) return replacements @@ -1163,7 +1163,7 @@ def show_model_changes(new, old=None, fields=None, always=False): continue changes.append( - " {}: {}".format(field, colorize("text_highlight", new_fmt[field])) + f" {field}: {colorize('text_highlight', new_fmt[field])}" ) # Print changes. @@ -1204,22 +1204,16 @@ def show_path_changes(path_changes): # Print every change over two lines for source, dest in zip(sources, destinations): color_source, color_dest = colordiff(source, dest) - print_("{0} \n -> {1}".format(color_source, color_dest)) + print_(f"{color_source} \n -> {color_dest}") else: # Print every change on a single line, and add a header title_pad = max_width - len("Source ") + len(" -> ") - print_("Source {0} Destination".format(" " * title_pad)) + print_(f"Source {' ' * title_pad} Destination") for source, dest in zip(sources, destinations): pad = max_width - len(source) color_source, color_dest = colordiff(source, dest) - print_( - "{0} {1} -> {2}".format( - color_source, - " " * pad, - color_dest, - ) - ) + print_(f"{color_source} {' ' * pad} -> {color_dest}") # Helper functions for option parsing. @@ -1245,9 +1239,7 @@ def _store_dict(option, opt_str, value, parser): raise ValueError except ValueError: raise UserError( - "supplied argument `{}' is not of the form `key=value'".format( - value - ) + f"supplied argument `{value}' is not of the form `key=value'" ) option_values[key] = value @@ -1426,8 +1418,8 @@ class Subcommand: @root_parser.setter def root_parser(self, root_parser): self._root_parser = root_parser - self.parser.prog = "{} {}".format( - as_string(root_parser.get_prog_name()), self.name + self.parser.prog = ( + f"{as_string(root_parser.get_prog_name())} {self.name}" ) @@ -1637,10 +1629,8 @@ def _ensure_db_directory_exists(path): newpath = os.path.dirname(path) if not os.path.isdir(newpath): if input_yn( - "The database directory {} does not \ - exist. Create it (Y/n)?".format( - util.displayable_path(newpath) - ) + f"The database directory {util.displayable_path(newpath)} does not \ + exist. Create it (Y/n)?" ): os.makedirs(newpath) @@ -1660,9 +1650,8 @@ def _open_library(config: confuse.LazyConfig) -> library.Library: except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error: log.debug("{}", traceback.format_exc()) raise UserError( - "database file {} cannot not be opened: {}".format( - util.displayable_path(dbpath), db_error - ) + f"database file {util.displayable_path(dbpath)} cannot not be" + f" opened: {db_error}" ) log.debug( "library database: {0}\nlibrary directory: {1}", diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 12a8d6875..d76d2b2ab 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -112,15 +112,11 @@ def _parse_logfiles(logfiles): yield from _paths_from_logfile(syspath(normpath(logfile))) except ValueError as err: raise ui.UserError( - "malformed logfile {}: {}".format( - util.displayable_path(logfile), str(err) - ) + f"malformed logfile {util.displayable_path(logfile)}: {err}" ) from err except OSError as err: raise ui.UserError( - "unreadable logfile {}: {}".format( - util.displayable_path(logfile), str(err) - ) + f"unreadable logfile {util.displayable_path(logfile)}: {err}" ) from err @@ -213,10 +209,10 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]: out = [] chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq() calculated_values = { - "index": "Index {}".format(str(info.index)), - "track_alt": "Track {}".format(info.track_alt), + "index": f"Index {info.index}", + "track_alt": f"Track {info.track_alt}", "album": ( - "[{}]".format(info.album) + f"[{info.album}]" if ( config["import"]["singleton_album_disambig"].get() and info.get("album") @@ -242,7 +238,7 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: chosen_fields = config["match"]["album_disambig_fields"].as_str_seq() calculated_values = { "media": ( - "{}x{}".format(info.mediums, info.media) + f"{info.mediums}x{info.media}" if (info.mediums and info.mediums > 1) else info.media ), @@ -277,7 +273,7 @@ def dist_string(dist): """Formats a distance (a float) as a colorized similarity percentage string. """ - string = "{:.1f}%".format(((1 - dist) * 100)) + string = f"{(1 - dist) * 100:.1f}%" return dist_colorize(string, dist) @@ -295,7 +291,7 @@ def penalty_string(distance, limit=None): if limit and len(penalties) > limit: penalties = penalties[:limit] + ["..."] # Prefix penalty string with U+2260: Not Equal To - penalty_string = "\u2260 {}".format(", ".join(penalties)) + penalty_string = f"\u2260 {', '.join(penalties)}" return ui.colorize("changed", penalty_string) @@ -697,11 +693,9 @@ class AlbumChange(ChangeRepresentation): # Missing and unmatched tracks. if self.match.extra_tracks: print_( - "Missing tracks ({0}/{1} - {2:.1%}):".format( - len(self.match.extra_tracks), - len(self.match.info.tracks), - len(self.match.extra_tracks) / len(self.match.info.tracks), - ) + "Missing tracks" + f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -" + f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):" ) for track_info in self.match.extra_tracks: line = f" ! {track_info.title} (#{self.format_index(track_info)})" @@ -711,9 +705,9 @@ class AlbumChange(ChangeRepresentation): if self.match.extra_items: print_(f"Unmatched tracks ({len(self.match.extra_items)}):") for item in self.match.extra_items: - line = " ! {} (#{})".format(item.title, self.format_index(item)) + line = f" ! {item.title} (#{self.format_index(item)})" if item.length: - line += " ({})".format(human_seconds_short(item.length)) + line += f" ({human_seconds_short(item.length)})" print_(ui.colorize("text_warning", line)) @@ -769,7 +763,7 @@ def summarize_items(items, singleton): """ summary_parts = [] if not singleton: - summary_parts.append("{} items".format(len(items))) + summary_parts.append(f"{len(items)} items") format_counts = {} for item in items: @@ -789,10 +783,11 @@ def summarize_items(items, singleton): average_bitrate = sum([item.bitrate for item in items]) / len(items) total_duration = sum([item.length for item in items]) total_filesize = sum([item.filesize for item in items]) - summary_parts.append("{}kbps".format(int(average_bitrate / 1000))) + summary_parts.append(f"{int(average_bitrate / 1000)}kbps") if items[0].format == "FLAC": - sample_bits = "{}kHz/{} bit".format( - round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth + sample_bits = ( + f"{round(int(items[0].samplerate) / 1000, 1)}kHz" + f"/{items[0].bitdepth} bit" ) summary_parts.append(sample_bits) summary_parts.append(human_seconds_short(total_duration)) @@ -885,7 +880,7 @@ def choose_candidate( if singleton: print_("No matching recordings found.") else: - print_("No matching release found for {} tracks.".format(itemcount)) + print_(f"No matching release found for {itemcount} tracks.") print_( "For help, see: " "https://beets.readthedocs.org/en/latest/faq.html#nomatch" @@ -910,23 +905,21 @@ def choose_candidate( # Display list of candidates. print_("") print_( - 'Finding tags for {} "{} - {}".'.format( - "track" if singleton else "album", - item.artist if singleton else cur_artist, - item.title if singleton else cur_album, - ) + f"Finding tags for {'track' if singleton else 'album'}" + f'"{item.artist if singleton else cur_artist} -' + f' {item.title if singleton else cur_album}".' ) print_(ui.indent(2) + "Candidates:") for i, match in enumerate(candidates): # Index, metadata, and distance. - index0 = "{0}.".format(i + 1) + index0 = f"{i + 1}." index = dist_colorize(index0, match.distance) - dist = "({:.1f}%)".format((1 - match.distance) * 100) + dist = f"({(1 - match.distance) * 100:.1f}%)" distance = dist_colorize(dist, match.distance) - metadata = "{0} - {1}".format( - match.info.artist, - match.info.title if singleton else match.info.album, + metadata = ( + f"{match.info.artist} -" + f" {match.info.title if singleton else match.info.album}" ) if i == 0: metadata = dist_colorize(metadata, match.distance) @@ -1015,7 +1008,7 @@ def manual_id(session, task): Input an ID, either for an album ("release") or a track ("recording"). """ - prompt = "Enter {} ID:".format("release" if task.is_album else "recording") + prompt = f"Enter {'release' if task.is_album else 'recording'} ID:" search_id = input_(prompt).strip() if task.is_album: @@ -1043,7 +1036,7 @@ class TerminalImportSession(importer.ImportSession): path_str0 = displayable_path(task.paths, "\n") path_str = ui.colorize("import_path", path_str0) - items_str0 = "({} items)".format(len(task.items)) + items_str0 = f"({len(task.items)} items)" items_str = ui.colorize("import_path_items", items_str0) print_(" ".join([path_str, items_str])) @@ -1217,8 +1210,8 @@ class TerminalImportSession(importer.ImportSession): def should_resume(self, path): return ui.input_yn( - "Import of the directory:\n{}\n" - "was interrupted. Resume (Y/n)?".format(displayable_path(path)) + f"Import of the directory:\n{displayable_path(path)}\n" + "was interrupted. Resume (Y/n)?" ) def _get_choices(self, task): @@ -1317,7 +1310,8 @@ def import_files(lib, paths: list[bytes], query): loghandler = logging.FileHandler(logpath, encoding="utf-8") except OSError: raise ui.UserError( - f"Could not open log file for writing: {displayable_path(logpath)}" + "Could not open log file for writing:" + f" {displayable_path(logpath)}" ) else: loghandler = None @@ -1362,9 +1356,7 @@ def import_func(lib, opts, args: list[str]): for path in byte_paths: if not os.path.exists(syspath(normpath(path))): raise ui.UserError( - "no such file or directory: {}".format( - displayable_path(path) - ) + f"no such file or directory: {displayable_path(path)}" ) # Check the directories from the logfiles, but don't throw an error in @@ -1374,9 +1366,7 @@ def import_func(lib, opts, args: list[str]): for path in paths_from_logfiles: if not os.path.exists(syspath(normpath(path))): log.warning( - "No such file or directory: {}".format( - displayable_path(path) - ) + f"No such file or directory: {displayable_path(path)}" ) continue @@ -1808,7 +1798,7 @@ def remove_items(lib, query, album, delete, force): if not force: # Prepare confirmation with user. album_str = ( - " in {} album{}".format(len(albums), "s" if len(albums) > 1 else "") + f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" if album else "" ) @@ -1816,14 +1806,17 @@ def remove_items(lib, query, album, delete, force): if delete: fmt = "$path - $title" prompt = "Really DELETE" - prompt_all = "Really DELETE {} file{}{}".format( - len(items), "s" if len(items) > 1 else "", album_str + prompt_all = ( + "Really DELETE" + f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}" ) else: fmt = "" prompt = "Really remove from the library?" - prompt_all = "Really remove {} item{}{} from the library?".format( - len(items), "s" if len(items) > 1 else "", album_str + prompt_all = ( + "Really remove" + f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}" + " from the library?" ) # Helpers for printing affected items @@ -1906,23 +1899,13 @@ def show_stats(lib, query, exact): if exact: size_str += f" ({total_size} bytes)" - print_( - """Tracks: {} -Total time: {}{} -{}: {} -Artists: {} -Albums: {} -Album artists: {}""".format( - total_items, - human_seconds(total_time), - f" ({total_time:.2f} seconds)" if exact else "", - "Total size" if exact else "Approximate total size", - size_str, - len(artists), - len(albums), - len(album_artists), - ), - ) + print_(f"""Tracks: {total_items} +Total time: {human_seconds(total_time)} +{f" ({total_time:.2f} seconds)" if exact else ""} +{"Total size" if exact else "Approximate total size"}: {size_str} +Artists: {len(artists)} +Albums: {len(albums)} +Album artists: {len(album_artists)}""") def stats_func(lib, opts, args): @@ -1977,7 +1960,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): # Apply changes *temporarily*, preview them, and collect modified # objects. - print_("Modifying {} {}s.".format(len(objs), "album" if album else "item")) + print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") changed = [] templates = { key: functemplate.template(value) for key, value in mods.items() @@ -2213,9 +2196,7 @@ def move_func(lib, opts, args): if dest is not None: dest = normpath(dest) if not os.path.isdir(syspath(dest)): - raise ui.UserError( - "no such directory: {}".format(displayable_path(dest)) - ) + raise ui.UserError(f"no such directory: {displayable_path(dest)}") move_items( lib, @@ -2486,7 +2467,7 @@ def completion_script(commands): # Command aliases yield " local aliases='%s'\n" % " ".join(aliases.keys()) for alias, cmd in aliases.items(): - yield " local alias__{}={}\n".format(alias.replace("-", "_"), cmd) + yield f" local alias__{alias.replace('-', '_')}={cmd}\n" yield "\n" # Fields @@ -2502,8 +2483,9 @@ def completion_script(commands): for option_type, option_list in opts.items(): if option_list: option_list = " ".join(option_list) - yield " local {}__{}='{}'\n".format( - option_type, cmd.replace("-", "_"), option_list + yield ( + " local" + f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n" ) yield " _beet_dispatch\n" diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e2f7f46bd..88d535c69 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -112,7 +112,7 @@ class HumanReadableError(Exception): elif hasattr(self.reason, "strerror"): # i.e., EnvironmentError return self.reason.strerror else: - return '"{}"'.format(str(self.reason)) + return f'"{self.reason}"' def get_message(self): """Create the human-readable description of the error, sans @@ -142,18 +142,16 @@ class FilesystemError(HumanReadableError): def get_message(self): # Use a nicer English phrasing for some specific verbs. if self.verb in ("move", "copy", "rename"): - clause = "while {} {} to {}".format( - self._gerund(), - displayable_path(self.paths[0]), - displayable_path(self.paths[1]), + clause = ( + f"while {self._gerund()} {displayable_path(self.paths[0])} to" + f" {displayable_path(self.paths[1])}" ) elif self.verb in ("delete", "write", "create", "read"): - clause = "while {} {}".format( - self._gerund(), displayable_path(self.paths[0]) - ) + clause = f"while {self._gerund()} {displayable_path(self.paths[0])}" else: - clause = "during {} of paths {}".format( - self.verb, ", ".join(displayable_path(p) for p in self.paths) + clause = ( + f"during {self.verb} of paths" + f" {', '.join(displayable_path(p) for p in self.paths)}" ) return f"{self._reasonstr()} {clause}" @@ -226,9 +224,8 @@ def sorted_walk( except OSError as exc: if logger: logger.warning( - "could not list directory {}: {}".format( - displayable_path(bytes_path), exc.strerror - ) + f"could not list directory {displayable_path(bytes_path)}:" + f" {exc.strerror}" ) return dirs = [] diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index fe67c506e..c72fda5af 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -54,7 +54,7 @@ def resize_url(url: str, maxwidth: int, quality: int = 0) -> str: if quality > 0: params["q"] = quality - return "{}?{}".format(PROXY_URL, urlencode(params)) + return f"{PROXY_URL}?{urlencode(params)}" class LocalBackendNotAvailableError(Exception): diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index b0daefac2..768371b07 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -165,9 +165,7 @@ class Call: self.original = original def __repr__(self): - return "Call({}, {}, {})".format( - repr(self.ident), repr(self.args), repr(self.original) - ) + return f"Call({self.ident!r}, {self.args!r}, {self.original!r})" def evaluate(self, env): """Evaluate the function call in the environment, returning a diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index c02a1c923..df81eb234 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -42,9 +42,7 @@ def call(args): try: return util.command_output(args).stdout except subprocess.CalledProcessError as e: - raise ABSubmitError( - "{} exited with status {}".format(args[0], e.returncode) - ) + raise ABSubmitError(f"{args[0]} exited with status {e.returncode}") class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): @@ -63,9 +61,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): # Explicit path to extractor if not os.path.isfile(self.extractor): raise ui.UserError( - "Extractor command does not exist: {0}.".format( - self.extractor - ) + f"Extractor command does not exist: {self.extractor}." ) else: # Implicit path to extractor, search for it in path diff --git a/beetsplug/aura.py b/beetsplug/aura.py index 53458d7ee..fd7a58c1b 100644 --- a/beetsplug/aura.py +++ b/beetsplug/aura.py @@ -243,7 +243,7 @@ class AURADocument: else: # Increment page token by 1 next_url = request.url.replace( - f"page={page}", "page={}".format(page + 1) + f"page={page}", f"page={page + 1}" ) # Get only the items in the page range data = [ @@ -427,9 +427,7 @@ class TrackDocument(AURADocument): return self.error( "404 Not Found", "No track with the requested id.", - "There is no track with an id of {} in the library.".format( - track_id - ), + f"There is no track with an id of {track_id} in the library.", ) return self.single_resource_document( self.get_resource_object(self.lib, track) @@ -513,9 +511,7 @@ class AlbumDocument(AURADocument): return self.error( "404 Not Found", "No album with the requested id.", - "There is no album with an id of {} in the library.".format( - album_id - ), + f"There is no album with an id of {album_id} in the library.", ) return self.single_resource_document( self.get_resource_object(self.lib, album) @@ -600,9 +596,7 @@ class ArtistDocument(AURADocument): return self.error( "404 Not Found", "No artist with the requested id.", - "There is no artist with an id of {} in the library.".format( - artist_id - ), + f"There is no artist with an id of {artist_id} in the library.", ) return self.single_resource_document(artist_resource) @@ -727,9 +721,7 @@ class ImageDocument(AURADocument): return self.error( "404 Not Found", "No image with the requested id.", - "There is no image with an id of {} in the library.".format( - image_id - ), + f"There is no image with an id of {image_id} in the library.", ) return self.single_resource_document(image_resource) @@ -775,9 +767,7 @@ def audio_file(track_id): return AURADocument.error( "404 Not Found", "No track with the requested id.", - "There is no track with an id of {} in the library.".format( - track_id - ), + f"There is no track with an id of {track_id} in the library.", ) path = os.fsdecode(track.path) @@ -785,9 +775,8 @@ def audio_file(track_id): return AURADocument.error( "404 Not Found", "No audio file for the requested track.", - ( - "There is no audio file for track {} at the expected location" - ).format(track_id), + f"There is no audio file for track {track_id} at the expected" + " location", ) file_mimetype = guess_type(path)[0] @@ -795,10 +784,8 @@ def audio_file(track_id): return AURADocument.error( "500 Internal Server Error", "Requested audio file has an unknown mimetype.", - ( - "The audio file for track {} has an unknown mimetype. " - "Its file extension is {}." - ).format(track_id, path.split(".")[-1]), + f"The audio file for track {track_id} has an unknown mimetype. " + f"Its file extension is {path.split('.')[-1]}.", ) # Check that the Accept header contains the file's mimetype @@ -810,10 +797,8 @@ def audio_file(track_id): return AURADocument.error( "406 Not Acceptable", "Unsupported MIME type or bitrate parameter in Accept header.", - ( - "The audio file for track {} is only available as {} and " - "bitrate parameters are not supported." - ).format(track_id, file_mimetype), + f"The audio file for track {track_id} is only available as" + f" {file_mimetype} and bitrate parameters are not supported.", ) return send_file( @@ -896,9 +881,7 @@ def image_file(image_id): return AURADocument.error( "404 Not Found", "No image with the requested id.", - "There is no image with an id of {} in the library".format( - image_id - ), + f"There is no image with an id of {image_id} in the library", ) return send_file(img_path) diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 0511d960d..7b63a7496 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -110,9 +110,7 @@ class BadFiles(BeetsPlugin): self._log.debug("checking path: {}", dpath) if not os.path.exists(item.path): ui.print_( - "{}: file does not exist".format( - ui.colorize("text_error", dpath) - ) + f"{ui.colorize('text_error', dpath)}: file does not exist" ) # Run the checker against the file if one is found @@ -141,25 +139,21 @@ class BadFiles(BeetsPlugin): if status > 0: error_lines.append( - "{}: checker exited with status {}".format( - ui.colorize("text_error", dpath), status - ) + f"{ui.colorize('text_error', dpath)}: checker exited with" + f" status {status}" ) for line in output: error_lines.append(f" {line}") elif errors > 0: error_lines.append( - "{}: checker found {} errors or warnings".format( - ui.colorize("text_warning", dpath), errors - ) + f"{ui.colorize('text_warning', dpath)}: checker found" + f" {status} errors or warnings" ) for line in output: error_lines.append(f" {line}") elif self.verbose: - error_lines.append( - "{}: ok".format(ui.colorize("text_success", dpath)) - ) + error_lines.append(f"{ui.colorize('text_success', dpath)}: ok") return error_lines @@ -180,9 +174,8 @@ class BadFiles(BeetsPlugin): def on_import_task_before_choice(self, task, session): if hasattr(task, "_badfiles_checks_failed"): ui.print_( - "{} one or more files failed checks:".format( - ui.colorize("text_warning", "BAD") - ) + f"{ui.colorize('text_warning', 'BAD')} one or more files failed" + " checks:" ) for error in task._badfiles_checks_failed: for error_line in error: diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 16e0dc896..039ef3885 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -212,14 +212,10 @@ class BeatportClient: try: response = self.api.get(self._make_url(endpoint), params=kwargs) except Exception as e: - raise BeatportAPIError( - "Error connecting to Beatport API: {}".format(e) - ) + raise BeatportAPIError(f"Error connecting to Beatport API: {e}") if not response: raise BeatportAPIError( - "Error {0.status_code} for '{0.request.path_url}".format( - response - ) + f"Error {response.status_code} for '{response.request.path_url}" ) return response.json()["results"] @@ -275,15 +271,14 @@ class BeatportRelease(BeatportObject): self.genre = data.get("genre") if "slug" in data: - self.url = "https://beatport.com/release/{}/{}".format( - data["slug"], data["id"] + self.url = ( + f"https://beatport.com/release/{data['slug']}/{data['id']}" ) def __str__(self) -> str: - return "<BeatportRelease: {} - {} ({})>".format( - self.artists_str(), - self.name, - self.catalog_number, + return ( + "<BeatportRelease: " + f"{self.artists_str()} - {self.name} ({self.catalog_number})>" ) @@ -311,9 +306,7 @@ class BeatportTrack(BeatportObject): except ValueError: pass if "slug" in data: - self.url = "https://beatport.com/track/{}/{}".format( - data["slug"], data["id"] - ) + self.url = f"https://beatport.com/track/{data['slug']}/{data['id']}" self.track_number = data.get("trackNumber") self.bpm = data.get("bpm") self.initial_key = str((data.get("key") or {}).get("shortName")) diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index a2ad2835c..1da15e949 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -759,7 +759,7 @@ class Connection: """Create a new connection for the accepted socket `client`.""" self.server = server self.sock = sock - self.address = "{}:{}".format(*sock.sock.getpeername()) + self.address = ":".join(map(str, sock.sock.getpeername())) def debug(self, message, kind=" "): """Log a debug message about this connection.""" @@ -899,9 +899,7 @@ class MPDConnection(Connection): return except BPDIdleError as e: self.idle_subscriptions = e.subsystems - self.debug( - "awaiting: {}".format(" ".join(e.subsystems)), kind="z" - ) + self.debug(f"awaiting: {' '.join(e.subsystems)}", kind="z") yield bluelet.call(self.server.dispatch_events()) @@ -933,7 +931,7 @@ class ControlConnection(Connection): func = command.delegate("ctrl_", self) yield bluelet.call(func(*command.args)) except (AttributeError, TypeError) as e: - yield self.send("ERROR: {}".format(e.args[0])) + yield self.send(f"ERROR: {e.args[0]}") except Exception: yield self.send( ["ERROR: server error", traceback.format_exc().rstrip()] @@ -1011,7 +1009,7 @@ class Command: # If the command accepts a variable number of arguments skip the check. if wrong_num and not argspec.varargs: raise TypeError( - 'wrong number of arguments for "{}"'.format(self.name), + f'wrong number of arguments for "{self.name}"', self.name, ) @@ -1110,10 +1108,8 @@ class Server(BaseServer): self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) - log.info("Server ready and listening on {}:{}".format(host, port)) - log.debug( - "Listening for control signals on {}:{}".format(host, ctrl_port) - ) + log.info(f"Server ready and listening on {host}:{port}") + log.debug(f"Listening for control signals on {host}:{ctrl_port}") def run(self): self.player.run() @@ -1142,9 +1138,7 @@ class Server(BaseServer): pass for tagtype, field in self.tagtype_map.items(): - info_lines.append( - "{}: {}".format(tagtype, str(getattr(item, field))) - ) + info_lines.append(f"{tagtype}: {getattr(item, field)}") return info_lines @@ -1303,19 +1297,12 @@ class Server(BaseServer): yield ( "bitrate: " + str(item.bitrate / 1000), - "audio: {}:{}:{}".format( - str(item.samplerate), - str(item.bitdepth), - str(item.channels), - ), + f"audio: {item.samplerate}:{item.bitdepth}:{item.channels}", ) (pos, total) = self.player.time() yield ( - "time: {}:{}".format( - str(int(pos)), - str(int(total)), - ), + f"time: {int(pos)}:{int(total)}", "elapsed: " + f"{pos:.3f}", "duration: " + f"{total:.3f}", ) diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index 9246539fc..aefeb5ce3 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -55,8 +55,8 @@ def span_from_str(span_str): years = [int(x) for x in re.findall(r"\d+", span_str)] if not years: raise ui.UserError( - "invalid range defined for year bucket '%s': no " - "year found" % span_str + "invalid range defined for year bucket '%s': no year found" + % span_str ) try: years = [normalize_year(x, years[0]) for x in years] @@ -125,11 +125,8 @@ def str2fmt(s): "fromnchars": len(m.group("fromyear")), "tonchars": len(m.group("toyear")), } - res["fmt"] = "{}%s{}{}{}".format( - m.group("bef"), - m.group("sep"), - "%s" if res["tonchars"] else "", - m.group("after"), + res["fmt"] = ( + f"{m['bef']}%s{m['sep']}{'%s' if res['tonchars'] else ''}{m['after']}" ) return res diff --git a/beetsplug/convert.py b/beetsplug/convert.py index c4df9ab57..a60a876e2 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -64,9 +64,7 @@ def get_format(fmt=None): command = format_info["command"] extension = format_info.get("extension", fmt) except KeyError: - raise ui.UserError( - 'convert: format {} needs the "command" field'.format(fmt) - ) + raise ui.UserError(f'convert: format {fmt} needs the "command" field') except ConfigTypeError: command = config["convert"]["formats"][fmt].get(str) extension = fmt @@ -77,8 +75,8 @@ def get_format(fmt=None): command = config["convert"]["command"].as_str() elif "opts" in keys: # Undocumented option for backwards compatibility with < 1.3.1. - command = "ffmpeg -i $source -y {} $dest".format( - config["convert"]["opts"].as_str() + command = ( + f"ffmpeg -i $source -y {config['convert']['opts'].as_str()} $dest" ) if "extension" in keys: extension = config["convert"]["extension"].as_str() @@ -125,18 +123,25 @@ class ConvertPlugin(BeetsPlugin): "id3v23": "inherit", "formats": { "aac": { - "command": "ffmpeg -i $source -y -vn -acodec aac " - "-aq 1 $dest", + "command": ( + "ffmpeg -i $source -y -vn -acodec aac -aq 1 $dest" + ), "extension": "m4a", }, "alac": { - "command": "ffmpeg -i $source -y -vn -acodec alac $dest", + "command": ( + "ffmpeg -i $source -y -vn -acodec alac $dest" + ), "extension": "m4a", }, "flac": "ffmpeg -i $source -y -vn -acodec flac $dest", "mp3": "ffmpeg -i $source -y -vn -aq 2 $dest", - "opus": "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest", - "ogg": "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest", + "opus": ( + "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest" + ), + "ogg": ( + "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest" + ), "wma": "ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest", }, "max_bitrate": None, @@ -323,7 +328,7 @@ class ConvertPlugin(BeetsPlugin): raise except OSError as exc: raise ui.UserError( - "convert: couldn't invoke '{}': {}".format(" ".join(args), exc) + f"convert: couldn't invoke {' '.join(args)!r}: {exc}" ) if not quiet and not pretend: diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index ac7421c5f..2b06d804e 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -552,7 +552,7 @@ class DiscogsPlugin(MetadataSourcePlugin): idx, medium_idx, sub_idx = self.get_track_index( subtracks[0]["position"] ) - position = "{}{}".format(idx or "", medium_idx or "") + position = f"{idx or ''}{medium_idx or ''}" if tracklist and not tracklist[-1]["position"]: # Assume the previous index track contains the track title. @@ -574,8 +574,8 @@ class DiscogsPlugin(MetadataSourcePlugin): # option is set if self.config["index_tracks"]: for subtrack in subtracks: - subtrack["title"] = "{}: {}".format( - index_track["title"], subtrack["title"] + subtrack["title"] = ( + f"{index_track['title']}: {subtrack['title']}" ) tracklist.extend(subtracks) else: diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 52387c314..f6fadefd0 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -46,9 +46,7 @@ def edit(filename, log): try: subprocess.call(cmd) except OSError as exc: - raise ui.UserError( - "could not run editor command {!r}: {}".format(cmd[0], exc) - ) + raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}") def dump(arg): @@ -71,9 +69,7 @@ def load(s): for d in yaml.safe_load_all(s): if not isinstance(d, dict): raise ParseError( - "each entry must be a dictionary; found {}".format( - type(d).__name__ - ) + f"each entry must be a dictionary; found {type(d).__name__}" ) # Convert all keys to strings. They started out as strings, diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 8df3c3c05..68ed5da78 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -35,8 +35,9 @@ def _confirm(objs, album): to items). """ noun = "album" if album else "file" - prompt = "Modify artwork for {} {}{} (Y/n)?".format( - len(objs), noun, "s" if len(objs) > 1 else "" + prompt = ( + "Modify artwork for" + f" {len(objs)} {noun}{'s' if len(objs) > 1 else ''} (Y/n)?" ) # Show all the items or albums. @@ -110,9 +111,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): imagepath = normpath(opts.file) if not os.path.isfile(syspath(imagepath)): raise ui.UserError( - "image file {} not found".format( - displayable_path(imagepath) - ) + f"image file {displayable_path(imagepath)} not found" ) items = lib.items(args) @@ -137,7 +136,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): response = requests.get(opts.url, timeout=5) response.raise_for_status() except requests.exceptions.RequestException as e: - self._log.error("{}".format(e)) + self._log.error(f"{e}") return extension = guess_extension(response.headers["Content-Type"]) if extension is None: @@ -149,7 +148,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): with open(tempimg, "wb") as f: f.write(response.content) except Exception as e: - self._log.error("Unable to save image: {}".format(e)) + self._log.error(f"Unable to save image: {e}") return items = lib.items(args) # Confirm with user. diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py index c696f39f3..024f7679f 100644 --- a/beetsplug/embyupdate.py +++ b/beetsplug/embyupdate.py @@ -38,9 +38,7 @@ def api_url(host, port, endpoint): hostname_list.insert(0, "http://") hostname = "".join(hostname_list) - joined = urljoin( - "{hostname}:{port}".format(hostname=hostname, port=port), endpoint - ) + joined = urljoin(f"{hostname}:{port}", endpoint) scheme, netloc, path, query_string, fragment = urlsplit(joined) query_params = parse_qs(query_string) @@ -81,12 +79,12 @@ def create_headers(user_id, token=None): headers = {} authorization = ( - 'MediaBrowser UserId="{user_id}", ' + f'MediaBrowser UserId="{user_id}", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' - ).format(user_id=user_id) + ) headers["x-emby-authorization"] = authorization diff --git a/beetsplug/fish.py b/beetsplug/fish.py index 4cf9b60a1..4d43b85e7 100644 --- a/beetsplug/fish.py +++ b/beetsplug/fish.py @@ -89,8 +89,9 @@ class FishPlugin(BeetsPlugin): "-o", "--output", default="~/.config/fish/completions/beet.fish", - help="where to save the script. default: " - "~/.config/fish/completions", + help=( + "where to save the script. default: ~/.config/fish/completions" + ), ) return [cmd] @@ -127,16 +128,12 @@ class FishPlugin(BeetsPlugin): totstring += "" if nobasicfields else get_standard_fields(fields) totstring += get_extravalues(lib, extravalues) if extravalues else "" totstring += ( - "\n" - + "# ====== {} =====".format("setup basic beet completion") - + "\n" * 2 + "\n" + "# ====== setup basic beet completion =====" + "\n" * 2 ) totstring += get_basic_beet_options() totstring += ( "\n" - + "# ====== {} =====".format( - "setup field completion for subcommands" - ) + + "# ====== setup field completion for subcommands =====" + "\n" ) totstring += get_subcommands(cmd_names_help, nobasicfields, extravalues) @@ -226,11 +223,7 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): for cmdname, cmdhelp in cmd_name_and_help: cmdname = _escape(cmdname) - word += ( - "\n" - + "# ------ {} -------".format("fieldsetups for " + cmdname) - + "\n" - ) + word += "\n" + f"# ------ fieldsetups for {cmdname} -------" + "\n" word += BL_NEED2.format( ("-a " + cmdname), ("-f " + "-d " + wrap(clean_whitespace(cmdhelp))) ) @@ -268,11 +261,7 @@ def get_all_commands(beetcmds): name = _escape(name) word += "\n" - word += ( - ("\n" * 2) - + "# ====== {} =====".format("completions for " + name) - + "\n" - ) + word += ("\n" * 2) + f"# ====== completions for {name} =====" + "\n" for option in cmd.parser._get_all_options()[1:]: cmd_l = ( @@ -332,7 +321,7 @@ def clean_whitespace(word): def wrap(word): # Need " or ' around strings but watch out if they're in the string sptoken = '"' - if ('"') in word and ("'") in word: + if '"' in word and ("'") in word: word.replace('"', sptoken) return '"' + word + '"' diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 103e82901..4c6431061 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -112,7 +112,7 @@ def apply_matches(d, log): for item in d: if not item.artist: item.artist = artist - log.info("Artist replaced with: {}".format(item.artist)) + log.info(f"Artist replaced with: {item.artist}") # No artist field: remaining field is the title. else: @@ -122,11 +122,11 @@ def apply_matches(d, log): for item in d: if bad_title(item.title): item.title = str(d[item][title_field]) - log.info("Title replaced with: {}".format(item.title)) + log.info(f"Title replaced with: {item.title}") if "track" in d[item] and item.track == 0: item.track = int(d[item]["track"]) - log.info("Track replaced with: {}".format(item.track)) + log.info(f"Track replaced with: {item.track}") # Plugin structure and hook into import process. diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index f1c40ab24..f5addcd74 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -154,7 +154,7 @@ def search_pairs(item): # examples include (live), (remix), and (acoustic). r"(.+?)\s+[(].*[)]$", # Remove any featuring artists from the title - r"(.*?) {}".format(plugins.feat_tokens(for_artist=False)), + rf"(.*?) {plugins.feat_tokens(for_artist=False)}", # Remove part of title after colon ':' for songs with subtitles r"(.+?)\s*:.*", ] diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 7a1289d1b..51dcb286b 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -83,9 +83,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): collection = self.config["collection"].as_str() if collection: if collection not in collection_ids: - raise ui.UserError( - "invalid collection ID: {}".format(collection) - ) + raise ui.UserError(f"invalid collection ID: {collection}") return collection # No specified collection. Just return the first collection ID diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index f99e820b5..ada35d870 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -117,7 +117,7 @@ class MetaSyncPlugin(BeetsPlugin): try: cls = META_SOURCES[player] except KeyError: - self._log.error("Unknown metadata source '{}'".format(player)) + self._log.error(f"Unknown metadata source '{player}'") try: meta_source_instances[player] = cls(self.config, self._log) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b52e44b23..5e40e4c33 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -68,9 +68,7 @@ class MusicBrainzAPIError(util.HumanReadableError): super().__init__(reason, verb, tb) def get_message(self): - return "{} in {} with query {}".format( - self._reasonstr(), self.verb, repr(self.query) - ) + return f"{self._reasonstr()} in {self.verb} with query {self.query!r}" RELEASE_INCLUDES = list( diff --git a/beetsplug/play.py b/beetsplug/play.py index 3e7ba0a9e..ac074084b 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -43,7 +43,7 @@ def play( """ # Print number of tracks or albums to be played, log command to be run. item_type += "s" if len(selection) > 1 else "" - ui.print_("Playing {} {}.".format(len(selection), item_type)) + ui.print_(f"Playing {len(selection)} {item_type}.") log.debug("executing command: {} {!r}", command_str, open_args) try: @@ -179,9 +179,7 @@ class PlayPlugin(BeetsPlugin): ui.print_( ui.colorize( "text_warning", - "You are about to queue {} {}.".format( - len(selection), item_type - ), + f"You are about to queue {len(selection)} {item_type}.", ) ) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 7a27b02a3..45d99ad80 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -133,21 +133,16 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): try: self.update_playlist(playlist, base_dir) except beets.util.FilesystemError: - self._log.error( - "Failed to update playlist: {}".format( - beets.util.displayable_path(playlist) - ) - ) + self._log.error("Failed to update playlist: {}", playlist) def find_playlists(self): """Find M3U playlists in the playlist directory.""" + playlist_dir = beets.util.syspath(self.playlist_dir) try: - dir_contents = os.listdir(beets.util.syspath(self.playlist_dir)) + dir_contents = os.listdir(playlist_dir) except OSError: self._log.warning( - "Unable to open playlist directory {}".format( - beets.util.displayable_path(self.playlist_dir) - ) + "Unable to open playlist directory {}", self.playlist_dir ) return @@ -195,9 +190,10 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): if changes or deletions: self._log.info( - "Updated playlist {} ({} changes, {} deletions)".format( - filename, changes, deletions - ) + "Updated playlist {} ({} changes, {} deletions)", + filename, + changes, + deletions, ) beets.util.copy(new_playlist, filename, replace=True) beets.util.remove(new_playlist) diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py index 9b4419c71..c0ea0f4eb 100644 --- a/beetsplug/plexupdate.py +++ b/beetsplug/plexupdate.py @@ -22,9 +22,7 @@ def get_music_section( ): """Getting the section key for the music library in Plex.""" api_endpoint = append_token("library/sections", token) - url = urljoin( - "{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint - ) + url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint) # Sends request. r = requests.get( @@ -54,9 +52,7 @@ def update_plex(host, port, token, library_name, secure, ignore_cert_errors): ) api_endpoint = f"library/sections/{section_key}/refresh" api_endpoint = append_token(api_endpoint, token) - url = urljoin( - "{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint - ) + url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint) # Sends request and returns requests object. r = requests.get( diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 96c854314..a008bec38 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -70,9 +70,7 @@ def call(args: list[str], log: Logger, **kwargs: Any): return command_output(args, **kwargs) except subprocess.CalledProcessError as e: log.debug(e.output.decode("utf8", "ignore")) - raise ReplayGainError( - "{} exited with status {}".format(args[0], e.returncode) - ) + raise ReplayGainError(f"{args[0]} exited with status {e.returncode}") def db_to_lufs(db: float) -> float: @@ -170,9 +168,8 @@ class RgTask: # `track_gains` without throwing FatalReplayGainError # => raise non-fatal exception & continue raise ReplayGainError( - "ReplayGain backend `{}` failed for track {}".format( - self.backend_name, item - ) + f"ReplayGain backend `{self.backend_name}` failed for track" + f" {item}" ) self._store_track_gain(item, self.track_gains[0]) @@ -191,10 +188,8 @@ class RgTask: # `album_gain` without throwing FatalReplayGainError # => raise non-fatal exception & continue raise ReplayGainError( - "ReplayGain backend `{}` failed " - "for some tracks in album {}".format( - self.backend_name, self.album - ) + f"ReplayGain backend `{self.backend_name}` failed " + f"for some tracks in album {self.album}" ) for item, track_gain in zip(self.items, self.track_gains): self._store_track_gain(item, track_gain) @@ -501,12 +496,10 @@ class FfmpegBackend(Backend): if self._parse_float(b"M: " + line[1]) >= gating_threshold: n_blocks += 1 self._log.debug( - "{}: {} blocks over {} LUFS".format( - item, n_blocks, gating_threshold - ) + f"{item}: {n_blocks} blocks over {gating_threshold} LUFS" ) - self._log.debug("{}: gain {} LU, peak {}".format(item, gain, peak)) + self._log.debug(f"{item}: gain {gain} LU, peak {peak}") return Gain(gain, peak), n_blocks @@ -526,9 +519,7 @@ class FfmpegBackend(Backend): if output[i].startswith(search): return i raise ReplayGainError( - "ffmpeg output: missing {} after line {}".format( - repr(search), start_line - ) + f"ffmpeg output: missing {search!r} after line {start_line}" ) def _parse_float(self, line: bytes) -> float: @@ -575,7 +566,7 @@ class CommandBackend(Backend): # Explicit executable path. if not os.path.isfile(self.command): raise FatalReplayGainError( - "replaygain command does not exist: {}".format(self.command) + f"replaygain command does not exist: {self.command}" ) else: # Check whether the program is in $PATH. @@ -1229,10 +1220,8 @@ class ReplayGainPlugin(BeetsPlugin): if self.backend_name not in BACKENDS: raise ui.UserError( - "Selected ReplayGain backend {} is not supported. " - "Please select one of: {}".format( - self.backend_name, ", ".join(BACKENDS.keys()) - ) + f"Selected ReplayGain backend {self.backend_name} is not" + f" supported. Please select one of: {', '.join(BACKENDS)}" ) # FIXME: Consider renaming the configuration option to 'peak_method' @@ -1240,10 +1229,9 @@ class ReplayGainPlugin(BeetsPlugin): peak_method = self.config["peak"].as_str() if peak_method not in PeakMethod.__members__: raise ui.UserError( - "Selected ReplayGain peak method {} is not supported. " - "Please select one of: {}".format( - peak_method, ", ".join(PeakMethod.__members__) - ) + f"Selected ReplayGain peak method {peak_method} is not" + " supported. Please select one of:" + f" {', '.join(PeakMethod.__members__)}" ) # This only applies to plain old rg tags, r128 doesn't store peak # values. @@ -1526,18 +1514,16 @@ class ReplayGainPlugin(BeetsPlugin): if opts.album: albums = lib.albums(args) self._log.info( - "Analyzing {} albums ~ {} backend...".format( - len(albums), self.backend_name - ) + f"Analyzing {len(albums)} albums ~" + f" {self.backend_name} backend..." ) for album in albums: self.handle_album(album, write, force) else: items = lib.items(args) self._log.info( - "Analyzing {} tracks ~ {} backend...".format( - len(items), self.backend_name - ) + f"Analyzing {len(items)} tracks ~" + f" {self.backend_name} backend..." ) for item in items: self.handle_track(item, write, force) @@ -1565,8 +1551,10 @@ class ReplayGainPlugin(BeetsPlugin): dest="force", action="store_true", default=False, - help="analyze all files, including those that " - "already have ReplayGain metadata", + help=( + "analyze all files, including those that already have" + " ReplayGain metadata" + ), ) cmd.parser.add_option( "-w", diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index e65d59649..142571251 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -138,10 +138,9 @@ class SmartPlaylistPlugin(BeetsPlugin): if name in args } if not playlists: + unmatched = [name for name, _, _ in self._unmatched_playlists] raise ui.UserError( - "No playlist matching any of {} found".format( - [name for name, _, _ in self._unmatched_playlists] - ) + f"No playlist matching any of {unmatched} found" ) self._matched_playlists = playlists @@ -331,8 +330,9 @@ class SmartPlaylistPlugin(BeetsPlugin): for key, value in attr ] attrs = "".join(al) - comment = "#EXTINF:{}{},{} - {}\n".format( - int(item.length), attrs, item.artist, item.title + comment = ( + f"#EXTINF:{int(item.length)}{attrs}," + f"{item.artist} - {item.title}\n" ) f.write(comment.encode("utf-8") + entry.uri + b"\n") # Send an event when playlists were updated. diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index f45ed158b..bc3d16ead 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -168,8 +168,9 @@ class SpotifyPlugin( c_secret: str = self.config["client_secret"].as_str() headers = { - "Authorization": "Basic {}".format( - base64.b64encode(f"{c_id}:{c_secret}".encode()).decode() + "Authorization": ( + "Basic" + f" {base64.b64encode(f'{c_id}:{c_secret}'.encode()).decode()}" ) } response = requests.post( @@ -182,7 +183,7 @@ class SpotifyPlugin( response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( - "Spotify authorization failed: {}\n{}".format(e, response.text) + f"Spotify authorization failed: {e}\n{response.text}" ) self.access_token = response.json()["access_token"] @@ -236,7 +237,7 @@ class SpotifyPlugin( if e.response.status_code == 401: self._log.debug( f"{self.data_source} access token has expired. " - f"Reauthenticating." + "Reauthenticating." ) self._authenticate() return self._handle_response( @@ -314,9 +315,7 @@ class SpotifyPlugin( else: raise ui.UserError( "Invalid `release_date_precision` returned " - "by {} API: '{}'".format( - self.data_source, release_date_precision - ) + f"by {self.data_source} API: '{release_date_precision}'" ) tracks_data = album_data["tracks"] @@ -472,17 +471,17 @@ class SpotifyPlugin( "-m", "--mode", action="store", - help='"open" to open {} with playlist, ' - '"list" to print (default)'.format(self.data_source), + help=( + f'"open" to open {self.data_source} with playlist, ' + '"list" to print (default)' + ), ) spotify_cmd.parser.add_option( "-f", "--show-failures", action="store_true", dest="show_failures", - help="list tracks that did not match a {} ID".format( - self.data_source - ), + help=f"list tracks that did not match a {self.data_source} ID", ) spotify_cmd.func = queries @@ -647,9 +646,7 @@ class SpotifyPlugin( spotify_ids = [track_data["id"] for track_data in results] if self.config["mode"].get() == "open": self._log.info( - "Attempting to open {} with playlist".format( - self.data_source - ) + f"Attempting to open {self.data_source} with playlist" ) spotify_url = "spotify:trackset:Playlist:" + ",".join( spotify_ids diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py index 9b4a7778c..2e83f55c0 100644 --- a/beetsplug/subsonicplaylist.py +++ b/beetsplug/subsonicplaylist.py @@ -168,9 +168,7 @@ class SubsonicPlaylistPlugin(BeetsPlugin): params["v"] = "1.12.0" params["c"] = "beets" resp = requests.get( - "{}/rest/{}?{}".format( - self.config["base_url"].get(), endpoint, urlencode(params) - ), + f"{self.config['base_url'].get()}/rest/{endpoint}?{urlencode(params)}", timeout=10, ) return resp diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 5460d3fec..bfdba9630 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -202,7 +202,7 @@ class ThumbnailsPlugin(BeetsPlugin): artfile = os.path.split(album.artpath)[1] with open(syspath(outfilename), "w") as f: f.write("[Desktop Entry]\n") - f.write("Icon=./{}".format(artfile.decode("utf-8"))) + f.write(f"Icon=./{artfile.decode('utf-8')}") f.close() self._log.debug("Wrote file {0}", displayable_path(outfilename)) @@ -266,9 +266,7 @@ class GioURI(URIGetter): g_file_ptr = self.libgio.g_file_new_for_path(path) if not g_file_ptr: raise RuntimeError( - "No gfile pointer received for {}".format( - displayable_path(path) - ) + f"No gfile pointer received for {displayable_path(path)}" ) try: diff --git a/beetsplug/types.py b/beetsplug/types.py index 9bdfdecee..561ce6828 100644 --- a/beetsplug/types.py +++ b/beetsplug/types.py @@ -44,6 +44,6 @@ class TypesPlugin(BeetsPlugin): mytypes[key] = types.DATE else: raise ConfigValueError( - "unknown type '{}' for the '{}' field".format(value, key) + f"unknown type '{value}' for the '{key}' field" ) return mytypes diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 559f0622c..80a95bf1d 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -232,9 +232,7 @@ def _get_unique_table_field_values(model, field, sort_field): raise KeyError with g.lib.transaction() as tx: rows = tx.query( - "SELECT DISTINCT '{}' FROM '{}' ORDER BY '{}'".format( - field, model._table, sort_field - ) + f"SELECT DISTINCT '{field}' FROM '{model._table}' ORDER BY '{sort_field}'" ) return [row[0] for row in rows] diff --git a/pyproject.toml b/pyproject.toml index dbe8e568a..35493cd01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -280,6 +280,7 @@ select = [ "PT", # flake8-pytest-style # "RUF", # ruff # "UP", # pyupgrade + "UP032", # use f-string instead of format call "TCH", # flake8-type-checking "W", # pycodestyle ] diff --git a/test/plugins/test_art.py b/test/plugins/test_art.py index 38f8c7559..23f816774 100644 --- a/test/plugins/test_art.py +++ b/test/plugins/test_art.py @@ -89,8 +89,8 @@ class CAAHelper: MBID_RELASE = "rid" MBID_GROUP = "rgid" - RELEASE_URL = "coverartarchive.org/release/{}".format(MBID_RELASE) - GROUP_URL = "coverartarchive.org/release-group/{}".format(MBID_GROUP) + RELEASE_URL = f"coverartarchive.org/release/{MBID_RELASE}" + GROUP_URL = f"coverartarchive.org/release-group/{MBID_GROUP}" RELEASE_URL = "https://" + RELEASE_URL GROUP_URL = "https://" + GROUP_URL @@ -305,10 +305,8 @@ class FSArtTest(UseThePlugin): class CombinedTest(FetchImageTestCase, CAAHelper): ASIN = "xxxx" MBID = "releaseid" - AMAZON_URL = "https://images.amazon.com/images/P/{}.01.LZZZZZZZ.jpg".format( - ASIN - ) - AAO_URL = "https://www.albumart.org/index_detail.php?asin={}".format(ASIN) + AMAZON_URL = f"https://images.amazon.com/images/P/{ASIN}.01.LZZZZZZZ.jpg" + AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}" def setUp(self): super().setUp() diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index dcf684ccc..1452686a7 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -49,14 +49,12 @@ class ConvertMixin: """ if re.search("[^a-zA-Z0-9]", tag): raise ValueError( - "tag '{}' must only contain letters and digits".format(tag) + f"tag '{tag}' must only contain letters and digits" ) # A Python script that copies the file and appends a tag. stub = os.path.join(_common.RSRC, b"convert_stub.py").decode("utf-8") - return "{} {} $source $dest {}".format( - shell_quote(sys.executable), shell_quote(stub), tag - ) + return f"{shell_quote(sys.executable)} {shell_quote(stub)} $source $dest {tag}" def file_endswith(self, path: Path, tag: str): """Check the path is a file and if its content ends with `tag`.""" diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 62b2bb7d1..58d2a5f63 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -144,9 +144,7 @@ class EmbedartCliTest(IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase): if os.path.isfile(syspath(tmp_path)): os.remove(syspath(tmp_path)) self.fail( - "Artwork file {} was not deleted".format( - displayable_path(tmp_path) - ) + f"Artwork file {displayable_path(tmp_path)} was not deleted" ) def test_art_file_missing(self): diff --git a/test/plugins/test_ipfs.py b/test/plugins/test_ipfs.py index 096bc393b..b94bd551b 100644 --- a/test/plugins/test_ipfs.py +++ b/test/plugins/test_ipfs.py @@ -37,7 +37,7 @@ class IPFSPluginTest(PluginTestCase): try: if check_item.get("ipfs", with_album=False): ipfs_item = os.fsdecode(os.path.basename(want_item.path)) - want_path = "/ipfs/{}/{}".format(test_album.ipfs, ipfs_item) + want_path = f"/ipfs/{test_album.ipfs}/{ipfs_item}" want_path = bytestring_path(want_path) assert check_item.path == want_path assert ( diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index 571af95dd..725236dda 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -96,9 +96,7 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): open_mock.assert_called_once_with(ANY, open_anything()) with open(open_mock.call_args[0][0][0], "rb") as f: playlist = f.read().decode("utf-8") - assert ( - f"{os.path.dirname(self.item.path.decode('utf-8'))}\n" == playlist - ) + assert f"{self.item.filepath.parent}\n" == playlist def test_raw(self, open_mock): self.config["play"]["raw"] = True @@ -125,9 +123,7 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): self.config["play"]["warning_threshold"] = 1 self.other_item = self.add_item(title="another NiceTitle") - expected_playlist = "{}\n{}".format( - self.item.path.decode("utf-8"), self.other_item.path.decode("utf-8") - ) + expected_playlist = f"{self.item.filepath}\n{self.other_item.filepath}" with control_stdin("a"): self.run_and_assert( diff --git a/test/plugins/test_playlist.py b/test/plugins/test_playlist.py index 9d9ce0303..d9eb81b2c 100644 --- a/test/plugins/test_playlist.py +++ b/test/plugins/test_playlist.py @@ -91,14 +91,7 @@ class PlaylistQueryTest: assert {i.title for i in results} == {"some item", "another item"} def test_path_query_with_absolute_paths_in_playlist(self): - q = "playlist:{}".format( - quote( - os.path.join( - self.playlist_dir, - "absolute.m3u", - ) - ) - ) + q = f"playlist:{quote(os.path.join(self.playlist_dir, 'absolute.m3u'))}" results = self.lib.items(q) assert {i.title for i in results} == {"some item", "another item"} @@ -108,14 +101,7 @@ class PlaylistQueryTest: assert {i.title for i in results} == {"some item", "another item"} def test_path_query_with_relative_paths_in_playlist(self): - q = "playlist:{}".format( - quote( - os.path.join( - self.playlist_dir, - "relative.m3u", - ) - ) - ) + q = f"playlist:{quote(os.path.join(self.playlist_dir, 'relative.m3u'))}" results = self.lib.items(q) assert {i.title for i in results} == {"some item", "another item"} @@ -125,15 +111,7 @@ class PlaylistQueryTest: assert set(results) == set() def test_path_query_with_nonexisting_playlist(self): - q = "playlist:{}".format( - quote( - os.path.join( - self.playlist_dir, - self.playlist_dir, - "nonexisting.m3u", - ) - ) - ) + q = f"playlist:{os.path.join(self.playlist_dir, 'nonexisting.m3u')!r}" results = self.lib.items(q) assert set(results) == set() @@ -141,20 +119,22 @@ class PlaylistQueryTest: class PlaylistTestRelativeToLib(PlaylistQueryTest, PlaylistTestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: - f.write( - "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3")) + f.writelines( + [ + os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", + os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", + os.path.join(self.music_dir, "nonexisting.mp3") + "\n", + ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: - f.write("{}\n".format(os.path.join("a", "b", "c.mp3"))) - f.write("{}\n".format(os.path.join("d", "e", "f.mp3"))) - f.write("{}\n".format("nonexisting.mp3")) + f.writelines( + [ + os.path.join("a", "b", "c.mp3") + "\n", + os.path.join("d", "e", "f.mp3") + "\n", + "nonexisting.mp3" + "\n", + ] + ) self.config["playlist"]["relative_to"] = "library" @@ -162,20 +142,22 @@ class PlaylistTestRelativeToLib(PlaylistQueryTest, PlaylistTestCase): class PlaylistTestRelativeToDir(PlaylistQueryTest, PlaylistTestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: - f.write( - "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3")) + f.writelines( + [ + os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", + os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", + os.path.join(self.music_dir, "nonexisting.mp3") + "\n", + ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: - f.write("{}\n".format(os.path.join("a", "b", "c.mp3"))) - f.write("{}\n".format(os.path.join("d", "e", "f.mp3"))) - f.write("{}\n".format("nonexisting.mp3")) + f.writelines( + [ + os.path.join("a", "b", "c.mp3") + "\n", + os.path.join("d", "e", "f.mp3") + "\n", + "nonexisting.mp3" + "\n", + ] + ) self.config["playlist"]["relative_to"] = self.music_dir @@ -183,40 +165,33 @@ class PlaylistTestRelativeToDir(PlaylistQueryTest, PlaylistTestCase): class PlaylistTestRelativeToPls(PlaylistQueryTest, PlaylistTestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: - f.write( - "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3")) + f.writelines( + [ + os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", + os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", + os.path.join(self.music_dir, "nonexisting.mp3") + "\n", + ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: - f.write( - "{}\n".format( + f.writelines( + [ os.path.relpath( os.path.join(self.music_dir, "a", "b", "c.mp3"), start=self.playlist_dir, ) - ) - ) - f.write( - "{}\n".format( + + "\n", os.path.relpath( os.path.join(self.music_dir, "d", "e", "f.mp3"), start=self.playlist_dir, ) - ) - ) - f.write( - "{}\n".format( + + "\n", os.path.relpath( os.path.join(self.music_dir, "nonexisting.mp3"), start=self.playlist_dir, ) - ) + + "\n", + ] ) self.config["playlist"]["relative_to"] = "playlist" @@ -226,20 +201,22 @@ class PlaylistTestRelativeToPls(PlaylistQueryTest, PlaylistTestCase): class PlaylistUpdateTest: def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: - f.write( - "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3")) - ) - f.write( - "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3")) + f.writelines( + [ + os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", + os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", + os.path.join(self.music_dir, "nonexisting.mp3") + "\n", + ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: - f.write("{}\n".format(os.path.join("a", "b", "c.mp3"))) - f.write("{}\n".format(os.path.join("d", "e", "f.mp3"))) - f.write("{}\n".format("nonexisting.mp3")) + f.writelines( + [ + os.path.join("a", "b", "c.mp3") + "\n", + os.path.join("d", "e", "f.mp3") + "\n", + "nonexisting.mp3" + "\n", + ] + ) self.config["playlist"]["auto"] = True self.config["playlist"]["relative_to"] = "library" @@ -249,9 +226,7 @@ class PlaylistTestItemMoved(PlaylistUpdateTest, PlaylistTestCase): def test_item_moved(self): # Emit item_moved event for an item that is in a playlist results = self.lib.items( - "path:{}".format( - quote(os.path.join(self.music_dir, "d", "e", "f.mp3")) - ) + f"path:{quote(os.path.join(self.music_dir, 'd', 'e', 'f.mp3'))}" ) item = results[0] beets.plugins.send( @@ -265,9 +240,7 @@ class PlaylistTestItemMoved(PlaylistUpdateTest, PlaylistTestCase): # Emit item_moved event for an item that is not in a playlist results = self.lib.items( - "path:{}".format( - quote(os.path.join(self.music_dir, "x", "y", "z.mp3")) - ) + f"path:{quote(os.path.join(self.music_dir, 'x', 'y', 'z.mp3'))}" ) item = results[0] beets.plugins.send( @@ -309,18 +282,14 @@ class PlaylistTestItemRemoved(PlaylistUpdateTest, PlaylistTestCase): def test_item_removed(self): # Emit item_removed event for an item that is in a playlist results = self.lib.items( - "path:{}".format( - quote(os.path.join(self.music_dir, "d", "e", "f.mp3")) - ) + f"path:{quote(os.path.join(self.music_dir, 'd', 'e', 'f.mp3'))}" ) item = results[0] beets.plugins.send("item_removed", item=item) # Emit item_removed event for an item that is not in a playlist results = self.lib.items( - "path:{}".format( - quote(os.path.join(self.music_dir, "x", "y", "z.mp3")) - ) + f"path:{quote(os.path.join(self.music_dir, 'x', 'y', 'z.mp3'))}" ) item = results[0] beets.plugins.send("item_removed", item=item) diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index 5bff1ee5e..9bcf8e59b 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -69,7 +69,7 @@ class RandomTest(TestHelper, unittest.TestCase): # Print a histogram (useful for debugging). if histogram: for i in range(len(self.items)): - print("{:2d} {}".format(i, "*" * positions.count(i))) + print(f"{i:2d} {'*' * positions.count(i)}") return self._stats(positions) mean1, stdev1, median1 = experiment("artist") diff --git a/test/plugins/test_replaygain.py b/test/plugins/test_replaygain.py index 091298766..094349b25 100644 --- a/test/plugins/test_replaygain.py +++ b/test/plugins/test_replaygain.py @@ -204,9 +204,7 @@ class ReplayGainCliTest: # This test is a lot less interesting if the backend cannot write # both tag types. self.skipTest( - "r128 tags for opus not supported on backend {}".format( - self.backend - ) + f"r128 tags for opus not supported on backend {self.backend}" ) album_rg = self._add_album(1) @@ -263,9 +261,7 @@ class ReplayGainCliTest: def test_cli_writes_only_r128_tags(self): if not self.has_r128_support: self.skipTest( - "r128 tags for opus not supported on backend {}".format( - self.backend - ) + f"r128 tags for opus not supported on backend {self.backend}" ) album = self._add_album(2, ext="opus") @@ -299,9 +295,7 @@ class ReplayGainCliTest: def test_r128_targetlevel_has_effect(self): if not self.has_r128_support: self.skipTest( - "r128 tags for opus not supported on backend {}".format( - self.backend - ) + f"r128 tags for opus not supported on backend {self.backend}" ) album = self._add_album(1, ext="opus") From 9352a79e4108bd67f7e40b1e944c01e0a7353272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 8 Jul 2025 08:17:09 +0100 Subject: [PATCH 025/301] Replace percent formatting --- beets/autotag/distance.py | 6 +++--- beets/dbcore/db.py | 2 +- beets/library/models.py | 2 +- beets/test/helper.py | 4 ++-- beets/ui/__init__.py | 34 ++++++++++++-------------------- beets/ui/commands.py | 24 ++++++++++------------ beets/util/bluelet.py | 4 ++-- beets/util/functemplate.py | 27 +++++++++---------------- beets/util/units.py | 2 +- beetsplug/bpd/__init__.py | 4 ++-- beetsplug/bucket.py | 17 ++++++++-------- beetsplug/fetchart.py | 4 ++-- beetsplug/inline.py | 3 +-- beetsplug/metasync/amarok.py | 4 ++-- beetsplug/mpdupdate.py | 2 +- beetsplug/rewrite.py | 2 +- beetsplug/web/__init__.py | 2 +- docs/dev/plugins.rst | 4 ++-- docs/plugins/inline.rst | 3 +-- pyproject.toml | 1 + test/plugins/test_discogs.py | 2 +- test/plugins/test_musicbrainz.py | 4 ++-- test/test_dbcore.py | 2 +- test/test_library.py | 2 +- test/test_ui.py | 2 +- 25 files changed, 70 insertions(+), 93 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index db5da585e..727439ea3 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -78,9 +78,9 @@ def string_dist(str1: str | None, str2: str | None) -> float: # example, "the something" should be considered equal to # "something, the". for word in SD_END_WORDS: - if str1.endswith(", %s" % word): + if str1.endswith(f", {word}"): str1 = f"{word} {str1[: -len(word) - 2]}" - if str2.endswith(", %s" % word): + if str2.endswith(f", {word}"): str2 = f"{word} {str2[: -len(word) - 2]}" # Perform a couple of basic normalizing substitutions. @@ -444,7 +444,7 @@ def distance( # Preferred media options. media_patterns: Sequence[str] = preferred_config["media"].as_str_seq() options = [ - re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in media_patterns + re.compile(rf"(\d+x)?({pat})", re.I) for pat in media_patterns ] if options: dist.add_priority("media", album_info.media, options) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 82c7217b7..5c84653d7 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1158,7 +1158,7 @@ class Database: """ # Get current schema. with self.transaction() as tx: - rows = tx.query("PRAGMA table_info(%s)" % table) + rows = tx.query(f"PRAGMA table_info({table})") current_fields = {row[1] for row in rows} field_names = set(fields.keys()) diff --git a/beets/library/models.py b/beets/library/models.py index e004fb83b..fbcfd94f1 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -482,7 +482,7 @@ class Album(LibModel): """ item = self.items().get() if not item: - raise ValueError("empty album for album id %d" % self.id) + raise ValueError(f"empty album for album id {self.id}") return os.path.dirname(item.path) def _albumtotal(self): diff --git a/beets/test/helper.py b/beets/test/helper.py index f1633c110..cd8f520fa 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -831,8 +831,8 @@ class AutotagStub: def _make_track_match(self, artist, album, number): return TrackInfo( - title="Applied Track %d" % number, - track_id="match %d" % number, + title=f"Applied Track {number}", + track_id=f"match {number}", artist=artist, length=1, index=0, diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 60c99c8e1..92372dea4 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -269,7 +269,7 @@ def input_options( ) ): # The first option is the default; mark it. - show_letter = "[%s]" % found_letter.upper() + show_letter = f"[{found_letter.upper()}]" is_default = True else: show_letter = found_letter.upper() @@ -308,9 +308,9 @@ def input_options( if isinstance(default, int): default_name = str(default) default_name = colorize("action_default", default_name) - tmpl = "# selection (default %s)" - prompt_parts.append(tmpl % default_name) - prompt_part_lengths.append(len(tmpl % str(default))) + tmpl = "# selection (default {})" + prompt_parts.append(tmpl.format(default_name)) + prompt_part_lengths.append(len(tmpl) - 2 + len(str(default))) else: prompt_parts.append("# selection") prompt_part_lengths.append(len(prompt_parts[-1])) @@ -349,7 +349,7 @@ def input_options( if not fallback_prompt: fallback_prompt = "Enter one of " if numrange: - fallback_prompt += "%i-%i, " % numrange + fallback_prompt += "{}-{}, ".format(*numrange) fallback_prompt += ", ".join(display_letters) + ":" resp = input_(prompt) @@ -406,7 +406,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): objects individually. """ choice = input_options( - ("y", "n", "s"), False, "%s? (Yes/no/select)" % (prompt_all or prompt) + ("y", "n", "s"), False, f"{prompt_all or prompt}? (Yes/no/select)" ) print() # Blank line. @@ -420,7 +420,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): answer = input_options( ("y", "n", "q"), True, - "%s? (yes/no/quit)" % prompt, + f"{prompt}? (yes/no/quit)", "Enter Y or N:", ) if answer == "y": @@ -534,7 +534,7 @@ def _colorize(color, text): # over all "ANSI codes" in `color`. escape = "" for code in color: - escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code] + escape = escape + COLOR_ESCAPE + f"{ANSI_CODES[code]}m" return escape + text + RESET_COLOR @@ -1475,7 +1475,7 @@ class SubcommandsOptionParser(CommonOptionsParser): for subcommand in subcommands: name = subcommand.name if subcommand.aliases: - name += " (%s)" % ", ".join(subcommand.aliases) + name += f" ({', '.join(subcommand.aliases)})" disp_names.append(name) # Set the help position based on the max width. @@ -1488,26 +1488,18 @@ class SubcommandsOptionParser(CommonOptionsParser): # Lifted directly from optparse.py. name_width = help_position - formatter.current_indent - 2 if len(name) > name_width: - name = "%*s%s\n" % (formatter.current_indent, "", name) + name = f"{' ' * formatter.current_indent}{name}\n" indent_first = help_position else: - name = "%*s%-*s " % ( - formatter.current_indent, - "", - name_width, - name, - ) + name = f"{' ' * formatter.current_indent}{name:<{name_width}}\n" indent_first = 0 result.append(name) help_width = formatter.width - help_position help_lines = textwrap.wrap(subcommand.help, help_width) help_line = help_lines[0] if help_lines else "" - result.append("%*s%s\n" % (indent_first, "", help_line)) + result.append(f"{' ' * indent_first}{help_line}\n") result.extend( - [ - "%*s%s\n" % (help_position, "", line) - for line in help_lines[1:] - ] + [f"{' ' * help_position}{line}\n" for line in help_lines[1:]] ) formatter.dedent() diff --git a/beets/ui/commands.py b/beets/ui/commands.py index d76d2b2ab..509b0e70b 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -144,13 +144,13 @@ def fields_func(lib, opts, args): with lib.transaction() as tx: # The SQL uses the DISTINCT to get unique values from the query - unique_fields = "SELECT DISTINCT key FROM (%s)" + unique_fields = "SELECT DISTINCT key FROM ({})" print_("Item flexible attributes:") - _print_keys(tx.query(unique_fields % library.Item._flex_table)) + _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) print_("Album flexible attributes:") - _print_keys(tx.query(unique_fields % library.Album._flex_table)) + _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) fields_cmd = ui.Subcommand( @@ -1926,7 +1926,7 @@ default_commands.append(stats_cmd) def show_version(lib, opts, args): - print_("beets version %s" % beets.__version__) + print_(f"beets version {beets.__version__}") print_(f"Python version {python_version()}") # Show plugins. names = sorted(p.name for p in plugins.find_plugins()) @@ -1990,7 +1990,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): extra = "" changed = ui.input_select_objects( - "Really modify%s" % extra, + f"Really modify{extra}", changed, lambda o: print_and_modify(o, mods, dels), ) @@ -2168,7 +2168,7 @@ def move_items( else: if confirm: objs = ui.input_select_objects( - "Really %s" % act, + f"Really {act}", objs, lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))] @@ -2461,22 +2461,18 @@ def completion_script(commands): yield "_beet() {\n" # Command names - yield " local commands='%s'\n" % " ".join(command_names) + yield f" local commands={' '.join(command_names)!r}\n" yield "\n" # Command aliases - yield " local aliases='%s'\n" % " ".join(aliases.keys()) + yield f" local aliases={' '.join(aliases.keys())!r}\n" for alias, cmd in aliases.items(): yield f" local alias__{alias.replace('-', '_')}={cmd}\n" yield "\n" # Fields - yield " fields='%s'\n" % " ".join( - set( - list(library.Item._fields.keys()) - + list(library.Album._fields.keys()) - ) - ) + fields = library.Item._fields.keys() | library.Album._fields.keys() + yield f" fields={' '.join(fields)!r}\n" # Command options for cmd, opts in options.items(): diff --git a/beets/util/bluelet.py b/beets/util/bluelet.py index b81b389e0..3f3a88b1e 100644 --- a/beets/util/bluelet.py +++ b/beets/util/bluelet.py @@ -559,7 +559,7 @@ def spawn(coro): and child coroutines run concurrently. """ if not isinstance(coro, types.GeneratorType): - raise ValueError("%s is not a coroutine" % coro) + raise ValueError(f"{coro} is not a coroutine") return SpawnEvent(coro) @@ -569,7 +569,7 @@ def call(coro): returns a value using end(), then this event returns that value. """ if not isinstance(coro, types.GeneratorType): - raise ValueError("%s is not a coroutine" % coro) + raise ValueError(f"{coro} is not a coroutine") return DelegationEvent(coro) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 768371b07..ed4c35596 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -136,7 +136,7 @@ class Symbol: self.original = original def __repr__(self): - return "Symbol(%s)" % repr(self.ident) + return f"Symbol({self.ident!r})" def evaluate(self, env): """Evaluate the symbol in the environment, returning a Unicode @@ -178,7 +178,7 @@ class Call: except Exception as exc: # Function raised exception! Maybe inlining the name of # the exception will help debug. - return "<%s>" % str(exc) + return f"<{exc}>" return str(out) else: return self.original @@ -224,7 +224,7 @@ class Expression: self.parts = parts def __repr__(self): - return "Expression(%s)" % (repr(self.parts)) + return f"Expression({self.parts!r})" def evaluate(self, env): """Evaluate the entire expression in the environment, returning @@ -296,9 +296,6 @@ class Parser: GROUP_CLOSE, ESCAPE_CHAR, ) - special_char_re = re.compile( - r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars) - ) escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP) terminator_chars = (GROUP_CLOSE,) @@ -310,24 +307,18 @@ class Parser: """ # Append comma (ARG_SEP) to the list of special characters only when # parsing function arguments. - extra_special_chars = () - special_char_re = self.special_char_re - if self.in_argument: - extra_special_chars = (ARG_SEP,) - special_char_re = re.compile( - r"[%s]|\Z" - % "".join( - re.escape(c) - for c in self.special_chars + extra_special_chars - ) - ) + extra_special_chars = (ARG_SEP,) if self.in_argument else () + special_chars = (*self.special_chars, *extra_special_chars) + special_char_re = re.compile( + rf"[{''.join(map(re.escape, special_chars))}]|\Z" + ) text_parts = [] while self.pos < len(self.string): char = self.string[self.pos] - if char not in self.special_chars + extra_special_chars: + if char not in special_chars: # A non-special character. Skip to the next special # character, treating the interstice as literal text. next_pos = ( diff --git a/beets/util/units.py b/beets/util/units.py index d07d42546..f5fcb743b 100644 --- a/beets/util/units.py +++ b/beets/util/units.py @@ -19,7 +19,7 @@ def human_seconds_short(interval): string. """ interval = int(interval) - return "%i:%02i" % (interval // 60, interval % 60) + return f"{interval // 60}:{interval % 60:02d}" def human_bytes(size): diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 1da15e949..6e45d5721 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -52,7 +52,7 @@ except ImportError as e: PROTOCOL_VERSION = "0.16.0" BUFSIZE = 1024 -HELLO = "OK MPD %s" % PROTOCOL_VERSION +HELLO = f"OK MPD {PROTOCOL_VERSION}" CLIST_BEGIN = "command_list_begin" CLIST_VERBOSE_BEGIN = "command_list_ok_begin" CLIST_END = "command_list_end" @@ -1219,7 +1219,7 @@ class Server(BaseServer): if dirpath.startswith("/"): # Strip leading slash (libmpc rejects this). dirpath = dirpath[1:] - yield "directory: %s" % dirpath + yield f"directory: {dirpath}" def _listall(self, basepath, node, info=False): """Helper function for recursive listing. If info, show diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index aefeb5ce3..71d7f06db 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -41,7 +41,7 @@ def span_from_str(span_str): def normalize_year(d, yearfrom): """Convert string to a 4 digits year""" if yearfrom < 100: - raise BucketError("%d must be expressed on 4 digits" % yearfrom) + raise BucketError(f"{yearfrom} must be expressed on 4 digits") # if two digits only, pick closest year that ends by these two # digits starting from yearfrom @@ -55,14 +55,13 @@ def span_from_str(span_str): years = [int(x) for x in re.findall(r"\d+", span_str)] if not years: raise ui.UserError( - "invalid range defined for year bucket '%s': no year found" - % span_str + f"invalid range defined for year bucket {span_str!r}: no year found" ) try: years = [normalize_year(x, years[0]) for x in years] except BucketError as exc: raise ui.UserError( - "invalid range defined for year bucket '%s': %s" % (span_str, exc) + f"invalid range defined for year bucket {span_str!r}: {exc}" ) res = {"from": years[0], "str": span_str} @@ -126,18 +125,18 @@ def str2fmt(s): "tonchars": len(m.group("toyear")), } res["fmt"] = ( - f"{m['bef']}%s{m['sep']}{'%s' if res['tonchars'] else ''}{m['after']}" + f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}" ) return res def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): """Return a span string representation.""" - args = str(yearfrom)[-fromnchars:] + args = [str(yearfrom)[-fromnchars:]] if tonchars: - args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:]) + args.append(str(yearto)[-tonchars:]) - return fmt % args + return fmt.format(*args) def extract_modes(spans): @@ -166,7 +165,7 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs): else: raise ui.UserError( "invalid range defined for alpha bucket " - "'%s': no alphanumeric character found" % elem + f"'{elem}': no alphanumeric character found" ) spans.append( re.compile( diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index e1ec5aa09..538a8e6fb 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -593,7 +593,7 @@ class CoverArtArchive(RemoteArtSource): class Amazon(RemoteArtSource): NAME = "Amazon" ID = "amazon" - URL = "https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg" + URL = "https://images.amazon.com/images/P/{}.{:02d}.LZZZZZZZ.jpg" INDICES = (1, 2) def get( @@ -606,7 +606,7 @@ class Amazon(RemoteArtSource): if album.asin: for index in self.INDICES: yield self._candidate( - url=self.URL % (album.asin, index), + url=self.URL.format(album.asin, index), match=MetadataMatch.EXACT, ) diff --git a/beetsplug/inline.py b/beetsplug/inline.py index c4258fc83..3c728bf8d 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -28,8 +28,7 @@ class InlineError(Exception): def __init__(self, code, exc): super().__init__( - ("error in inline path field code:\n%s\n%s: %s") - % (code, type(exc).__name__, str(exc)) + f"error in inline path field code:\n{code}\n{type(exc).__name__}: {exc}" ) diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index 9afe6dbca..1e0793d25 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -46,7 +46,7 @@ class Amarok(MetaSource): query_xml = '<query version="1.0"> \ <filters> \ - <and><include field="filename" value=%s /></and> \ + <and><include field="filename" value={} /></and> \ </filters> \ </query>' @@ -68,7 +68,7 @@ class Amarok(MetaSource): # of the result set. So query for the filename and then try to match # the correct item from the results we get back results = self.collection.Query( - self.query_xml % quoteattr(basename(path)) + self.query_xml.format(quoteattr(basename(path))) ) for result in results: if result["xesam:url"] != path: diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index cb53afaa5..3e950cf54 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -111,7 +111,7 @@ class MPDUpdatePlugin(BeetsPlugin): return if password: - s.send(b'password "%s"\n' % password.encode("utf8")) + s.send(f'password "{password}"\n'.encode()) resp = s.readline() if b"OK" not in resp: self._log.warning("Authentication failed: {0!r}", resp) diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py index 83829d657..9489612e1 100644 --- a/beetsplug/rewrite.py +++ b/beetsplug/rewrite.py @@ -57,7 +57,7 @@ class RewritePlugin(BeetsPlugin): raise ui.UserError("invalid rewrite specification") if fieldname not in library.Item._fields: raise ui.UserError( - "invalid field name (%s) in rewriter" % fieldname + f"invalid field name ({fieldname}) in rewriter" ) self._log.debug("adding template field {0}", key) pattern = re.compile(pattern.lower()) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 80a95bf1d..438fd5021 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -77,7 +77,7 @@ def json_generator(items, root, expand=False): representation :returns: generator that yields strings """ - yield '{"%s":[' % root + yield f'{{"{root}":[' first = True for item in items: if first: diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 620e1caec..5ee07347f 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -384,9 +384,9 @@ Here's an example that adds a ``$disc_and_track`` field: number. """ if item.disctotal > 1: - return u'%02i.%02i' % (item.disc, item.track) + return f"{item.disc:02d}.{item.track:02d}" else: - return u'%02i' % (item.track) + return f"{item.track:02d}" With this plugin enabled, templates can reference ``$disc_and_track`` as they can any standard metadata field. diff --git a/docs/plugins/inline.rst b/docs/plugins/inline.rst index 46ee3d634..d653b6d52 100644 --- a/docs/plugins/inline.rst +++ b/docs/plugins/inline.rst @@ -20,8 +20,7 @@ Here are a couple of examples of expressions: item_fields: initial: albumartist[0].upper() + u'.' - disc_and_track: u'%02i.%02i' % (disc, track) if - disctotal > 1 else u'%02i' % (track) + disc_and_track: f"{disc:02d}.{track:02d}" if disctotal > 1 else f"{track:02d}" Note that YAML syntax allows newlines in values if the subsequent lines are indented. diff --git a/pyproject.toml b/pyproject.toml index 35493cd01..e0393ea81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -280,6 +280,7 @@ select = [ "PT", # flake8-pytest-style # "RUF", # ruff # "UP", # pyupgrade + "UP031", # do not use percent formatting "UP032", # use f-string instead of format call "TCH", # flake8-type-checking "W", # pycodestyle diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index c31ac7511..e3e51042c 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -82,7 +82,7 @@ class DGAlbumInfoTest(BeetsTestCase): """Return a Bag that mimics a discogs_client.Release with a tracklist where tracks have the specified `positions`.""" tracks = [ - self._make_track("TITLE%s" % i, position) + self._make_track(f"TITLE{i}", position) for (i, position) in enumerate(positions, start=1) ] return self._make_release(tracks) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index aea05bc20..97b805924 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -99,7 +99,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): for recording in tracks: i += 1 track = { - "id": "RELEASE TRACK ID %d" % i, + "id": f"RELEASE TRACK ID {i}", "recording": recording, "position": i, "number": "A1", @@ -140,7 +140,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): for recording in data_tracks: i += 1 data_track = { - "id": "RELEASE TRACK ID %d" % i, + "id": f"RELEASE TRACK ID {i}", "recording": recording, "position": i, "number": "A1", diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 3f9a9d45e..b2ec2e968 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -256,7 +256,7 @@ class TransactionTest(unittest.TestCase): def test_query_no_increase_revision(self): old_rev = self.db.revision with self.db.transaction() as tx: - tx.query("PRAGMA table_info(%s)" % ModelFixture1._table) + tx.query(f"PRAGMA table_info({ModelFixture1._table})") assert self.db.revision == old_rev diff --git a/test/test_library.py b/test/test_library.py index 35791bad7..7c0529001 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1033,7 +1033,7 @@ class ArtDestinationTest(BeetsTestCase): def test_art_filename_respects_setting(self): art = self.ai.art_destination("something.jpg") - new_art = bytestring_path("%sartimage.jpg" % os.path.sep) + new_art = bytestring_path(f"{os.path.sep}artimage.jpg") assert new_art in art def test_art_path_in_item_dir(self): diff --git a/test/test_ui.py b/test/test_ui.py index 664323e2a..63d88f668 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1020,7 +1020,7 @@ class ConfigTest(TestPluginTestCase): def test_cli_config_file_loads_plugin_commands(self): with open(self.cli_config_path, "w") as file: - file.write("pluginpath: %s\n" % _common.PLUGINPATH) + file.write(f"pluginpath: {_common.PLUGINPATH}\n") file.write("plugins: test") self.run_command("--config", self.cli_config_path, "plugin", lib=None) From a7c83d91e9e57cc9be91e6a45d1a531ed0f587a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 30 Aug 2025 18:14:46 +0100 Subject: [PATCH 026/301] Replace slightly more advanced attempts to use format calls --- CONTRIBUTING.rst | 3 ++- beets/dbcore/query.py | 6 ++---- beets/ui/commands.py | 7 ++----- beetsplug/duplicates.py | 13 ++++++------- beetsplug/info.py | 6 ++---- beetsplug/inline.py | 3 ++- test/test_ui.py | 1 - 7 files changed, 16 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a49a0443f..9251cea34 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -252,7 +252,8 @@ There are a few coding conventions we use in beets: Transaction objects help control concurrent access to the database and assist in debugging conflicting accesses. -- ``str.format()`` should be used instead of the ``%`` operator +- f-strings should be used instead of the ``%`` operator and ``str.format()`` + calls. - Never ``print`` informational messages; use the `logging <http://docs.python.org/library/logging.html>`__ module instead. In particular, we have our own logging shim, so you’ll see ``from beets import diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 1f4fff1c0..5242ea026 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -848,8 +848,6 @@ class DateQuery(FieldQuery[str]): date = datetime.fromtimestamp(timestamp) return self.interval.contains(date) - _clause_tmpl = "{0} {1} ?" - def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: clause_parts = [] subvals = [] @@ -857,11 +855,11 @@ class DateQuery(FieldQuery[str]): # Convert the `datetime` objects to an integer number of seconds since # the (local) Unix epoch using `datetime.timestamp()`. if self.interval.start: - clause_parts.append(self._clause_tmpl.format(self.field, ">=")) + clause_parts.append(f"{self.field} >= ?") subvals.append(int(self.interval.start.timestamp())) if self.interval.end: - clause_parts.append(self._clause_tmpl.format(self.field, "<")) + clause_parts.append(f"{self.field} < ?") subvals.append(int(self.interval.end.timestamp())) if clause_parts: diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 509b0e70b..91420a5c4 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -486,7 +486,6 @@ class ChangeRepresentation: """Format colored track indices.""" cur_track = self.format_index(item) new_track = self.format_index(track_info) - templ = "(#{})" changed = False # Choose color based on change. if cur_track != new_track: @@ -498,10 +497,8 @@ class ChangeRepresentation: else: highlight_color = "text_faint" - cur_track = templ.format(cur_track) - new_track = templ.format(new_track) - lhs_track = ui.colorize(highlight_color, cur_track) - rhs_track = ui.colorize(highlight_color, new_track) + lhs_track = ui.colorize(highlight_color, f"(#{cur_track})") + rhs_track = ui.colorize(highlight_color, f"(#{new_track})") return lhs_track, rhs_track, changed @staticmethod diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index ea7abaaff..73fdee6a4 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -150,7 +150,7 @@ class DuplicatesPlugin(BeetsPlugin): count = self.config["count"].get(bool) delete = self.config["delete"].get(bool) remove = self.config["remove"].get(bool) - fmt = self.config["format"].get(str) + fmt_tmpl = self.config["format"].get(str) full = self.config["full"].get(bool) keys = self.config["keys"].as_str_seq() merge = self.config["merge"].get(bool) @@ -175,15 +175,14 @@ class DuplicatesPlugin(BeetsPlugin): return if path: - fmt = "$path" + fmt_tmpl = "$path" # Default format string for count mode. - if count and not fmt: + if count and not fmt_tmpl: if album: - fmt = "$albumartist - $album" + fmt_tmpl = "$albumartist - $album" else: - fmt = "$albumartist - $album - $title" - fmt += ": {0}" + fmt_tmpl = "$albumartist - $album - $title" if checksum: for i in items: @@ -207,7 +206,7 @@ class DuplicatesPlugin(BeetsPlugin): delete=delete, remove=remove, tag=tag, - fmt=fmt.format(obj_count), + fmt=f"{fmt_tmpl}: {obj_count}", ) self._command.func = _dup diff --git a/beetsplug/info.py b/beetsplug/info.py index c4d5aacbf..69e35184f 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -117,7 +117,6 @@ def print_data(data, item=None, fmt=None): return maxwidth = max(len(key) for key in formatted) - lineformat = f"{{0:>{maxwidth}}}: {{1}}" if path: ui.print_(displayable_path(path)) @@ -126,7 +125,7 @@ def print_data(data, item=None, fmt=None): value = formatted[field] if isinstance(value, list): value = "; ".join(value) - ui.print_(lineformat.format(field, value)) + ui.print_(f"{field:>{maxwidth}}: {value}") def print_data_keys(data, item=None): @@ -139,12 +138,11 @@ def print_data_keys(data, item=None): if len(formatted) == 0: return - line_format = "{0}{{0}}".format(" " * 4) if path: ui.print_(displayable_path(path)) for field in sorted(formatted): - ui.print_(line_format.format(field)) + ui.print_(f" {field}") class InfoPlugin(BeetsPlugin): diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 3c728bf8d..00907577a 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -36,7 +36,8 @@ def _compile_func(body): """Given Python code for a function body, return a compiled callable that invokes that code. """ - body = "def {}():\n {}".format(FUNC_NAME, body.replace("\n", "\n ")) + body = body.replace("\n", "\n ") + body = f"def {FUNC_NAME}():\n {body}" code = compile(body, "inline", "exec") env = {} eval(code, env) diff --git a/test/test_ui.py b/test/test_ui.py index 63d88f668..fb166e690 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1261,7 +1261,6 @@ class ShowChangeTest(IOMixin, unittest.TestCase): msg = self._show_change( cur_artist=long_name, cur_album="another album" ) - # _common.log.info("Message:{}".format(msg)) assert "artist: another artist" in msg assert " -> the artist" in msg assert "another album -> the album" not in msg From 1c16b2b3087e9c3635d68d41c9541c4319d0bdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 30 Aug 2025 23:10:15 +0100 Subject: [PATCH 027/301] Replace string concatenation (' + ') - Join hardcoded strings - Replace concatenated variables with f-strings --- beets/art.py | 2 +- beets/autotag/__init__.py | 2 +- beets/dbcore/db.py | 2 +- beets/dbcore/query.py | 20 +++-- beets/library/exceptions.py | 4 +- beets/plugins.py | 4 +- beets/test/_common.py | 2 +- beets/test/helper.py | 26 +++--- beets/ui/__init__.py | 60 ++++++-------- beets/ui/commands.py | 50 ++++++------ beets/util/__init__.py | 10 +-- beets/util/functemplate.py | 12 +-- beetsplug/absubmit.py | 4 +- beetsplug/acousticbrainz.py | 4 +- beetsplug/aura.py | 6 +- beetsplug/beatport.py | 6 +- beetsplug/bpd/__init__.py | 99 +++++++++++------------ beetsplug/bpd/gstplayer.py | 2 +- beetsplug/bucket.py | 6 +- beetsplug/convert.py | 2 +- beetsplug/deezer.py | 6 +- beetsplug/discogs.py | 2 +- beetsplug/fetchart.py | 14 ++-- beetsplug/fish.py | 107 +++++++++---------------- beetsplug/importfeeds.py | 2 +- beetsplug/lastgenre/__init__.py | 2 +- beetsplug/lastimport.py | 2 +- beetsplug/lyrics.py | 2 +- beetsplug/metasync/__init__.py | 2 +- beetsplug/metasync/itunes.py | 4 +- beetsplug/mpdstats.py | 2 +- beetsplug/musicbrainz.py | 6 +- beetsplug/play.py | 2 +- beetsplug/plexupdate.py | 2 +- beetsplug/spotify.py | 18 +++-- beetsplug/subsonicplaylist.py | 2 +- beetsplug/subsonicupdate.py | 4 +- beetsplug/thumbnails.py | 3 +- beetsplug/unimported.py | 2 +- pyproject.toml | 1 + test/plugins/test_art.py | 12 +-- test/plugins/test_importadded.py | 6 +- test/plugins/test_limit.py | 8 +- test/plugins/test_musicbrainz.py | 10 +-- test/plugins/test_play.py | 2 +- test/plugins/test_playlist.py | 6 +- test/plugins/test_plexupdate.py | 2 +- test/plugins/test_plugin_mediafield.py | 2 +- test/plugins/test_smartplaylist.py | 18 ++--- test/plugins/test_spotify.py | 4 +- test/plugins/test_substitute.py | 6 +- test/plugins/test_web.py | 83 +++++++++---------- test/test_art_resize.py | 8 +- test/test_datequery.py | 12 +-- test/test_logging.py | 12 +-- test/test_ui.py | 6 +- 56 files changed, 328 insertions(+), 377 deletions(-) diff --git a/beets/art.py b/beets/art.py index 2ff58c309..c38c38ae2 100644 --- a/beets/art.py +++ b/beets/art.py @@ -192,7 +192,7 @@ def extract(log, outpath, item): if not ext: log.warning("Unknown image type in {0}.", displayable_path(item.path)) return - outpath += bytestring_path("." + ext) + outpath += bytestring_path(f".{ext}") log.info( "Extracting album art from: {0} to: {1}", diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 4d107b3a1..319f7f522 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -261,7 +261,7 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]): continue for suffix in "year", "month", "day": - key = prefix + suffix + key = f"{prefix}{suffix}" value = getattr(album_info, key) or 0 # If we don't even have a year, apply nothing. diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 5c84653d7..8cd89111e 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -588,7 +588,7 @@ class Model(ABC, Generic[D]): for key in fields: if key != "id" and key in self._dirty: self._dirty.remove(key) - assignments.append(key + "=?") + assignments.append(f"{key}=?") value = self._type(key).to_sql(self[key]) subvars.append(value) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 5242ea026..dfeb42707 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -190,7 +190,7 @@ class MatchQuery(FieldQuery[AnySQLiteType]): """A query that looks for exact matches in an Model field.""" def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: - return self.field + " = ?", [self.pattern] + return f"{self.field} = ?", [self.pattern] @classmethod def value_match(cls, pattern: AnySQLiteType, value: Any) -> bool: @@ -204,7 +204,7 @@ class NoneQuery(FieldQuery[None]): super().__init__(field, None, fast) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: - return self.field + " IS NULL", () + return f"{self.field} IS NULL", () def match(self, obj: Model) -> bool: return obj.get(self.field_name) is None @@ -246,7 +246,7 @@ class StringQuery(StringFieldQuery[str]): .replace("%", "\\%") .replace("_", "\\_") ) - clause = self.field + " like ? escape '\\'" + clause = f"{self.field} like ? escape '\\'" subvals = [search] return clause, subvals @@ -264,8 +264,8 @@ class SubstringQuery(StringFieldQuery[str]): .replace("%", "\\%") .replace("_", "\\_") ) - search = "%" + pattern + "%" - clause = self.field + " like ? escape '\\'" + search = f"%{pattern}%" + clause = f"{self.field} like ? escape '\\'" subvals = [search] return clause, subvals @@ -471,7 +471,7 @@ class NumericQuery(FieldQuery[str]): def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: if self.point is not None: - return self.field + "=?", (self.point,) + return f"{self.field}=?", (self.point,) else: if self.rangemin is not None and self.rangemax is not None: return ( @@ -549,9 +549,9 @@ class CollectionQuery(Query): if not subq_clause: # Fall back to slow query. return None, () - clause_parts.append("(" + subq_clause + ")") + clause_parts.append(f"({subq_clause})") subvals += subq_subvals - clause = (" " + joiner + " ").join(clause_parts) + clause = f" {joiner} ".join(clause_parts) return clause, subvals def __repr__(self) -> str: @@ -690,9 +690,7 @@ class Period: ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"), # second ) relative_units = {"y": 365, "m": 30, "w": 7, "d": 1} - relative_re = ( - "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)" + "(?P<timespan>[y|m|w|d])" - ) + relative_re = "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)(?P<timespan>[y|m|w|d])" def __init__(self, date: datetime, precision: str): """Create a period with the given date (a `datetime` object) and diff --git a/beets/library/exceptions.py b/beets/library/exceptions.py index 7f117a2fe..0dc874c2a 100644 --- a/beets/library/exceptions.py +++ b/beets/library/exceptions.py @@ -28,11 +28,11 @@ class ReadError(FileOperationError): """An error while reading a file (i.e. in `Item.read`).""" def __str__(self): - return "error reading " + str(super()) + return f"error reading {super()}" class WriteError(FileOperationError): """An error while writing a file (i.e. in `Item.write`).""" def __str__(self): - return "error writing " + str(super()) + return f"error writing {super()}" diff --git a/beets/plugins.py b/beets/plugins.py index f7a449b22..e959bdfe0 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -130,9 +130,9 @@ class PluginLogFilter(logging.Filter): def filter(self, record): if hasattr(record.msg, "msg") and isinstance(record.msg.msg, str): # A _LogMessage from our hacked-up Logging replacement. - record.msg.msg = self.prefix + record.msg.msg + record.msg.msg = f"{self.prefix}{record.msg.msg}" elif isinstance(record.msg, str): - record.msg = self.prefix + record.msg + record.msg = f"{self.prefix}{record.msg}" return True diff --git a/beets/test/_common.py b/beets/test/_common.py index d70f9ec80..ffb2bfd65 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -153,7 +153,7 @@ class DummyIn: self.out = out def add(self, s): - self.buf.append(s + "\n") + self.buf.append(f"{s}\n") def close(self): pass diff --git a/beets/test/helper.py b/beets/test/helper.py index cd8f520fa..0ff9246ae 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -278,7 +278,7 @@ class TestHelper(ConfigMixin): values_["db"] = self.lib item = Item(**values_) if "path" not in values: - item["path"] = "audio." + item["format"].lower() + item["path"] = f"audio.{item['format'].lower()}" # mtime needs to be set last since other assignments reset it. item.mtime = 12345 return item @@ -310,7 +310,7 @@ class TestHelper(ConfigMixin): item = self.create_item(**values) extension = item["format"].lower() item["path"] = os.path.join( - _common.RSRC, util.bytestring_path("min." + extension) + _common.RSRC, util.bytestring_path(f"min.{extension}") ) item.add(self.lib) item.move(operation=MoveOperation.COPY) @@ -325,7 +325,7 @@ class TestHelper(ConfigMixin): """Add a number of items with files to the database.""" # TODO base this on `add_item()` items = [] - path = os.path.join(_common.RSRC, util.bytestring_path("full." + ext)) + path = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}")) for i in range(count): item = Item.from_path(path) item.album = f"\u00e4lbum {i}" # Check unicode paths @@ -372,7 +372,7 @@ class TestHelper(ConfigMixin): specified extension a cover art image is added to the media file. """ - src = os.path.join(_common.RSRC, util.bytestring_path("full." + ext)) + src = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}")) handle, path = mkstemp(dir=self.temp_dir) path = bytestring_path(path) os.close(handle) @@ -570,7 +570,7 @@ class ImportHelper(TestHelper): medium = MediaFile(track_path) medium.update( { - "album": "Tag Album" + (f" {album_id}" if album_id else ""), + "album": f"Tag Album{f' {album_id}' if album_id else ''}", "albumartist": None, "mb_albumid": None, "comp": None, @@ -839,15 +839,13 @@ class AutotagStub: ) def _make_album_match(self, artist, album, tracks, distance=0, missing=0): - if distance: - id = " " + "M" * distance - else: - id = "" + id = f" {'M' * distance}" if distance else "" + if artist is None: artist = "Various Artists" else: - artist = artist.replace("Tag", "Applied") + id - album = album.replace("Tag", "Applied") + id + artist = f"{artist.replace('Tag', 'Applied')}{id}" + album = f"{album.replace('Tag', 'Applied')}{id}" track_infos = [] for i in range(tracks - missing): @@ -858,8 +856,8 @@ class AutotagStub: album=album, tracks=track_infos, va=False, - album_id="albumid" + id, - artist_id="artistid" + id, + album_id=f"albumid{id}", + artist_id=f"artistid{id}", albumtype="soundtrack", data_source="match_source", bandcamp_album_id="bc_url", @@ -885,7 +883,7 @@ class FetchImageHelper: super().run(*args, **kwargs) IMAGEHEADER: dict[str, bytes] = { - "image/jpeg": b"\xff\xd8\xff" + b"\x00" * 3 + b"JFIF", + "image/jpeg": b"\xff\xd8\xff\x00\x00\x00JFIF", "image/png": b"\211PNG\r\n\032\n", "image/gif": b"GIF89a", # dummy type that is definitely not a valid image content type diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 92372dea4..2c243c8b2 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -125,7 +125,7 @@ def print_(*strings: str, end: str = "\n") -> None: The `end` keyword argument behaves similarly to the built-in `print` (it defaults to a newline). """ - txt = " ".join(strings or ("",)) + end + txt = f"{' '.join(strings or ('',))}{end}" # Encode the string and write it to stdout. # On Python 3, sys.stdout expects text strings and uses the @@ -338,7 +338,7 @@ def input_options( if line_length != 0: # Not the beginning of the line; need a space. - part = " " + part + part = f" {part}" length += 1 prompt += part @@ -350,7 +350,7 @@ def input_options( fallback_prompt = "Enter one of " if numrange: fallback_prompt += "{}-{}, ".format(*numrange) - fallback_prompt += ", ".join(display_letters) + ":" + fallback_prompt += f"{', '.join(display_letters)}:" resp = input_(prompt) while True: @@ -494,7 +494,7 @@ ANSI_CODES = { "bg_cyan": 46, "bg_white": 47, } -RESET_COLOR = COLOR_ESCAPE + "39;49;00m" +RESET_COLOR = f"{COLOR_ESCAPE}39;49;00m" # These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS # as they are defined in the configuration files, see function: colorize @@ -534,8 +534,8 @@ def _colorize(color, text): # over all "ANSI codes" in `color`. escape = "" for code in color: - escape = escape + COLOR_ESCAPE + f"{ANSI_CODES[code]}m" - return escape + text + RESET_COLOR + escape = f"{escape}{COLOR_ESCAPE}{ANSI_CODES[code]}m" + return f"{escape}{text}{RESET_COLOR}" def colorize(color_name, text): @@ -621,8 +621,8 @@ def color_split(colored_text, index): split_index = index - (length - color_len(part)) found_split = True if found_color_code: - pre_split += part[:split_index] + RESET_COLOR - post_split += found_color_code + part[split_index:] + pre_split += f"{part[:split_index]}{RESET_COLOR}" + post_split += f"{found_color_code}{part[split_index:]}" else: pre_split += part[:split_index] post_split += part[split_index:] @@ -806,17 +806,17 @@ def split_into_lines(string, width_tuple): # Colorize each word with pre/post escapes # Reconstruct colored words words += [ - m.group("esc") + raw_word + RESET_COLOR + f"{m['esc']}{raw_word}{RESET_COLOR}" for raw_word in raw_words ] elif raw_words: # Pretext stops mid-word if m.group("esc") != RESET_COLOR: # Add the rest of the current word, with a reset after it - words[-1] += m.group("esc") + raw_words[0] + RESET_COLOR + words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}" # Add the subsequent colored words: words += [ - m.group("esc") + raw_word + RESET_COLOR + f"{m['esc']}{raw_word}{RESET_COLOR}" for raw_word in raw_words[1:] ] else: @@ -907,18 +907,12 @@ def print_column_layout( With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the rest of contents, wrapped if the width would be otherwise exceeded. """ - if right["prefix"] + right["contents"] + right["suffix"] == "": + if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": # No right hand information, so we don't need a separator. separator = "" first_line_no_wrap = ( - indent_str - + left["prefix"] - + left["contents"] - + left["suffix"] - + separator - + right["prefix"] - + right["contents"] - + right["suffix"] + f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" + f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" ) if color_len(first_line_no_wrap) < max_width: # Everything fits, print out line. @@ -1044,18 +1038,12 @@ def print_newline_layout( If {lhs0} would go over the maximum width, the subsequent lines are indented a second time for ease of reading. """ - if right["prefix"] + right["contents"] + right["suffix"] == "": + if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": # No right hand information, so we don't need a separator. separator = "" first_line_no_wrap = ( - indent_str - + left["prefix"] - + left["contents"] - + left["suffix"] - + separator - + right["prefix"] - + right["contents"] - + right["suffix"] + f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" + f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" ) if color_len(first_line_no_wrap) < max_width: # Everything fits, print out line. @@ -1069,7 +1057,7 @@ def print_newline_layout( empty_space - len(indent_str), empty_space - len(indent_str), ) - left_str = left["prefix"] + left["contents"] + left["suffix"] + left_str = f"{left['prefix']}{left['contents']}{left['suffix']}" left_split = split_into_lines(left_str, left_width_tuple) # Repeat calculations for rhs, including separator on first line right_width_tuple = ( @@ -1077,19 +1065,19 @@ def print_newline_layout( empty_space - len(indent_str), empty_space - len(indent_str), ) - right_str = right["prefix"] + right["contents"] + right["suffix"] + right_str = f"{right['prefix']}{right['contents']}{right['suffix']}" right_split = split_into_lines(right_str, right_width_tuple) for i, line in enumerate(left_split): if i == 0: - print_(indent_str + line) + print_(f"{indent_str}{line}") elif line != "": # Ignore empty lines - print_(indent_str * 2 + line) + print_(f"{indent_str * 2}{line}") for i, line in enumerate(right_split): if i == 0: - print_(indent_str + separator + line) + print_(f"{indent_str}{separator}{line}") elif line != "": - print_(indent_str * 2 + line) + print_(f"{indent_str * 2}{line}") FLOAT_EPSILON = 0.01 @@ -1505,7 +1493,7 @@ class SubcommandsOptionParser(CommonOptionsParser): # Concatenate the original help message with the subcommand # list. - return out + "".join(result) + return f"{out}{''.join(result)}" def _subcommand_for_name(self, name): """Return the subcommand in self.subcommands matching the diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 91420a5c4..89950cac8 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -18,6 +18,7 @@ interface. import os import re +import textwrap from collections import Counter from collections.abc import Sequence from itertools import chain @@ -128,13 +129,13 @@ def _print_keys(query): returned row, with indentation of 2 spaces. """ for row in query: - print_(" " * 2 + row["key"]) + print_(f" {row['key']}") def fields_func(lib, opts, args): def _print_rows(names): names.sort() - print_(" " + "\n ".join(names)) + print_(textwrap.indent("\n".join(names), " ")) print_("Item fields:") _print_rows(library.Item.all_keys()) @@ -356,18 +357,18 @@ class ChangeRepresentation: # 'Match' line and similarity. print_( - self.indent_header + f"Match ({dist_string(self.match.distance)}):" + f"{self.indent_header}Match ({dist_string(self.match.distance)}):" ) if isinstance(self.match.info, autotag.hooks.AlbumInfo): # Matching an album - print that artist_album_str = ( - f"{self.match.info.artist}" + f" - {self.match.info.album}" + f"{self.match.info.artist} - {self.match.info.album}" ) else: # Matching a single track artist_album_str = ( - f"{self.match.info.artist}" + f" - {self.match.info.title}" + f"{self.match.info.artist} - {self.match.info.title}" ) print_( self.indent_header @@ -377,22 +378,23 @@ class ChangeRepresentation: # Penalties. penalties = penalty_string(self.match.distance) if penalties: - print_(self.indent_header + penalties) + print_(f"{self.indent_header}{penalties}") # Disambiguation. disambig = disambig_string(self.match.info) if disambig: - print_(self.indent_header + disambig) + print_(f"{self.indent_header}{disambig}") # Data URL. if self.match.info.data_url: url = ui.colorize("text_faint", f"{self.match.info.data_url}") - print_(self.indent_header + url) + print_(f"{self.indent_header}{url}") def show_match_details(self): """Print out the details of the match, including changes in album name and artist name. """ + changed_prefix = ui.colorize("changed", "\u2260") # Artist. artist_l, artist_r = self.cur_artist or "", self.match.info.artist if artist_r == VARIOUS_ARTISTS: @@ -402,7 +404,7 @@ class ChangeRepresentation: artist_l, artist_r = ui.colordiff(artist_l, artist_r) # Prefix with U+2260: Not Equal To left = { - "prefix": ui.colorize("changed", "\u2260") + " Artist: ", + "prefix": f"{changed_prefix} Artist: ", "contents": artist_l, "suffix": "", } @@ -410,7 +412,7 @@ class ChangeRepresentation: self.print_layout(self.indent_detail, left, right) else: - print_(self.indent_detail + "*", "Artist:", artist_r) + print_(f"{self.indent_detail}*", "Artist:", artist_r) if self.cur_album: # Album @@ -422,14 +424,14 @@ class ChangeRepresentation: album_l, album_r = ui.colordiff(album_l, album_r) # Prefix with U+2260: Not Equal To left = { - "prefix": ui.colorize("changed", "\u2260") + " Album: ", + "prefix": f"{changed_prefix} Album: ", "contents": album_l, "suffix": "", } right = {"prefix": "", "contents": album_r, "suffix": ""} self.print_layout(self.indent_detail, left, right) else: - print_(self.indent_detail + "*", "Album:", album_r) + print_(f"{self.indent_detail}*", "Album:", album_r) elif self.cur_title: # Title - for singletons title_l, title_r = self.cur_title or "", self.match.info.title @@ -437,14 +439,14 @@ class ChangeRepresentation: title_l, title_r = ui.colordiff(title_l, title_r) # Prefix with U+2260: Not Equal To left = { - "prefix": ui.colorize("changed", "\u2260") + " Title: ", + "prefix": f"{changed_prefix} Title: ", "contents": title_l, "suffix": "", } right = {"prefix": "", "contents": title_r, "suffix": ""} self.print_layout(self.indent_detail, left, right) else: - print_(self.indent_detail + "*", "Title:", title_r) + print_(f"{self.indent_detail}*", "Title:", title_r) def make_medium_info_line(self, track_info): """Construct a line with the current medium's info.""" @@ -568,9 +570,9 @@ class ChangeRepresentation: prefix = ui.colorize("changed", "\u2260 ") if changed else "* " lhs = { - "prefix": prefix + lhs_track + " ", + "prefix": f"{prefix}{lhs_track} ", "contents": lhs_title, - "suffix": " " + lhs_length, + "suffix": f" {lhs_length}", } rhs = {"prefix": "", "contents": "", "suffix": ""} if not changed: @@ -579,9 +581,9 @@ class ChangeRepresentation: else: # Construct a dictionary for the "changed to" side rhs = { - "prefix": rhs_track + " ", + "prefix": f"{rhs_track} ", "contents": rhs_title, - "suffix": " " + rhs_length, + "suffix": f" {rhs_length}", } return (lhs, rhs) @@ -674,7 +676,7 @@ class AlbumChange(ChangeRepresentation): # Print tracks from previous medium self.print_tracklist(lines) lines = [] - print_(self.indent_detail + header) + print_(f"{self.indent_detail}{header}") # Save new medium details for future comparison. medium, disctitle = track_info.medium, track_info.disctitle @@ -907,7 +909,7 @@ def choose_candidate( f' {item.title if singleton else cur_album}".' ) - print_(ui.indent(2) + "Candidates:") + print_(" Candidates:") for i, match in enumerate(candidates): # Index, metadata, and distance. index0 = f"{i + 1}." @@ -923,17 +925,17 @@ def choose_candidate( else: metadata = ui.colorize("text_highlight_minor", metadata) line1 = [index, distance, metadata] - print_(ui.indent(2) + " ".join(line1)) + print_(f" {' '.join(line1)}") # Penalties. penalties = penalty_string(match.distance, 3) if penalties: - print_(ui.indent(13) + penalties) + print_(f"{' ' * 13}{penalties}") # Disambiguation disambig = disambig_string(match.info) if disambig: - print_(ui.indent(13) + disambig) + print_(f"{' ' * 13}{disambig}") # Ask the user for a choice. sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) @@ -1892,7 +1894,7 @@ def show_stats(lib, query, exact): if item.album_id: albums.add(item.album_id) - size_str = "" + human_bytes(total_size) + size_str = human_bytes(total_size) if exact: size_str += f" ({total_size} bytes)" diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 88d535c69..8ac9722af 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -433,8 +433,8 @@ def syspath(path: PathLike, prefix: bool = True) -> str: if prefix and not str_path.startswith(WINDOWS_MAGIC_PREFIX): if str_path.startswith("\\\\"): # UNC path. Final path should look like \\?\UNC\... - str_path = "UNC" + str_path[1:] - str_path = WINDOWS_MAGIC_PREFIX + str_path + str_path = f"UNC{str_path[1:]}" + str_path = f"{WINDOWS_MAGIC_PREFIX}{str_path}" return str_path @@ -506,8 +506,8 @@ def move(path: bytes, dest: bytes, replace: bool = False): basename = os.path.basename(bytestring_path(dest)) dirname = os.path.dirname(bytestring_path(dest)) tmp = tempfile.NamedTemporaryFile( - suffix=syspath(b".beets", prefix=False), - prefix=syspath(b"." + basename + b".", prefix=False), + suffix=".beets", + prefix=f".{os.fsdecode(basename)}.", dir=syspath(dirname), delete=False, ) @@ -716,7 +716,7 @@ def truncate_path(str_path: str) -> str: path = Path(str_path) parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]] stem = truncate_str(path.stem, max_length - len(path.suffix)) - return str(Path(*parent_parts, stem)) + path.suffix + return f"{Path(*parent_parts, stem)}{path.suffix}" def _legalize_stage( diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index ed4c35596..5d85530a1 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -152,7 +152,7 @@ class Symbol: def translate(self): """Compile the variable lookup.""" ident = self.ident - expr = ex_rvalue(VARIABLE_PREFIX + ident) + expr = ex_rvalue(f"{VARIABLE_PREFIX}{ident}") return [expr], {ident}, set() @@ -211,7 +211,7 @@ class Call: ) ) - subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs) + subexpr_call = ex_call(f"{FUNCTION_PREFIX}{self.ident}", arg_exprs) return [subexpr_call], varnames, funcnames @@ -555,9 +555,9 @@ class Template: argnames = [] for varname in varnames: - argnames.append(VARIABLE_PREFIX + varname) + argnames.append(f"{VARIABLE_PREFIX}{varname}") for funcname in funcnames: - argnames.append(FUNCTION_PREFIX + funcname) + argnames.append(f"{FUNCTION_PREFIX}{funcname}") func = compile_func( argnames, @@ -567,9 +567,9 @@ class Template: def wrapper_func(values={}, functions={}): args = {} for varname in varnames: - args[VARIABLE_PREFIX + varname] = values[varname] + args[f"{VARIABLE_PREFIX}{varname}"] = values[varname] for funcname in funcnames: - args[FUNCTION_PREFIX + funcname] = functions[funcname] + args[f"{FUNCTION_PREFIX}{funcname}"] = functions[funcname] parts = func(**args) return "".join(parts) diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index df81eb234..63d4ada7e 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -97,8 +97,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): "with an HTTP scheme" ) elif base_url[-1] != "/": - base_url = base_url + "/" - self.url = base_url + "{mbid}/low-level" + base_url = f"{base_url}/" + self.url = f"{base_url}{{mbid}}/low-level" def commands(self): cmd = ui.Subcommand( diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py index 56ac0f6c5..29c51d302 100644 --- a/beetsplug/acousticbrainz.py +++ b/beetsplug/acousticbrainz.py @@ -97,7 +97,7 @@ class AcousticPlugin(plugins.BeetsPlugin): "with an HTTP scheme" ) elif self.base_url[-1] != "/": - self.base_url = self.base_url + "/" + self.base_url = f"{self.base_url}/" if self.config["auto"]: self.register_listener("import_task_files", self.import_task_files) @@ -300,4 +300,4 @@ class AcousticPlugin(plugins.BeetsPlugin): def _generate_urls(base_url, mbid): """Generates AcousticBrainz end point urls for given `mbid`.""" for level in LEVELS: - yield base_url + mbid + level + yield f"{base_url}{mbid}{level}" diff --git a/beetsplug/aura.py b/beetsplug/aura.py index fd7a58c1b..7b75f31e5 100644 --- a/beetsplug/aura.py +++ b/beetsplug/aura.py @@ -236,10 +236,10 @@ class AURADocument: # Not the last page so work out links.next url if not self.args: # No existing arguments, so current page is 0 - next_url = request.url + "?page=1" + next_url = f"{request.url}?page=1" elif not self.args.get("page", None): # No existing page argument, so add one to the end - next_url = request.url + "&page=1" + next_url = f"{request.url}&page=1" else: # Increment page token by 1 next_url = request.url.replace( @@ -697,7 +697,7 @@ class ImageDocument(AURADocument): relationships = {} # Split id into [parent_type, parent_id, filename] id_split = image_id.split("-") - relationships[id_split[0] + "s"] = { + relationships[f"{id_split[0]}s"] = { "data": [{"type": id_split[0], "id": id_split[1]}] } diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 039ef3885..b91de0295 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -110,7 +110,7 @@ class BeatportClient: :returns: OAuth resource owner key and secret as unicode """ self.api.parse_authorization_response( - "https://beets.io/auth?" + auth_data + f"https://beets.io/auth?{auth_data}" ) access_data = self.api.fetch_access_token( self._make_url("/identity/1/oauth/access-token") @@ -200,8 +200,8 @@ class BeatportClient: def _make_url(self, endpoint: str) -> str: """Get complete URL for a given API endpoint.""" if not endpoint.startswith("/"): - endpoint = "/" + endpoint - return self._api_base + endpoint + endpoint = f"/{endpoint}" + return f"{self._api_base}{endpoint}" def _get(self, endpoint: str, **kwargs) -> list[JSONDict]: """Perform a GET request on a given API endpoint. diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 6e45d5721..31404c656 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -282,7 +282,7 @@ class BaseServer: if not self.ctrl_sock: self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port)) - self.ctrl_sock.sendall((message + "\n").encode("utf-8")) + self.ctrl_sock.sendall((f"{message}\n").encode("utf-8")) def _send_event(self, event): """Notify subscribed connections of an event.""" @@ -376,13 +376,13 @@ class BaseServer: if self.password and not conn.authenticated: # Not authenticated. Show limited list of commands. for cmd in SAFE_COMMANDS: - yield "command: " + cmd + yield f"command: {cmd}" else: # Authenticated. Show all commands. for func in dir(self): if func.startswith("cmd_"): - yield "command: " + func[4:] + yield f"command: {func[4:]}" def cmd_notcommands(self, conn): """Lists all unavailable commands.""" @@ -392,7 +392,7 @@ class BaseServer: if func.startswith("cmd_"): cmd = func[4:] if cmd not in SAFE_COMMANDS: - yield "command: " + cmd + yield f"command: {cmd}" else: # Authenticated. No commands are unavailable. @@ -406,22 +406,22 @@ class BaseServer: playlist, playlistlength, and xfade. """ yield ( - "repeat: " + str(int(self.repeat)), - "random: " + str(int(self.random)), - "consume: " + str(int(self.consume)), - "single: " + str(int(self.single)), - "playlist: " + str(self.playlist_version), - "playlistlength: " + str(len(self.playlist)), - "mixrampdb: " + str(self.mixrampdb), + f"repeat: {int(self.repeat)}", + f"random: {int(self.random)}", + f"consume: {int(self.consume)}", + f"single: {int(self.single)}", + f"playlist: {self.playlist_version}", + f"playlistlength: {len(self.playlist)}", + f"mixrampdb: {self.mixrampdb}", ) if self.volume > 0: - yield "volume: " + str(self.volume) + yield f"volume: {self.volume}" if not math.isnan(self.mixrampdelay): - yield "mixrampdelay: " + str(self.mixrampdelay) + yield f"mixrampdelay: {self.mixrampdelay}" if self.crossfade > 0: - yield "xfade: " + str(self.crossfade) + yield f"xfade: {self.crossfade}" if self.current_index == -1: state = "stop" @@ -429,20 +429,20 @@ class BaseServer: state = "pause" else: state = "play" - yield "state: " + state + yield f"state: {state}" if self.current_index != -1: # i.e., paused or playing current_id = self._item_id(self.playlist[self.current_index]) - yield "song: " + str(self.current_index) - yield "songid: " + str(current_id) + yield f"song: {self.current_index}" + yield f"songid: {current_id}" if len(self.playlist) > self.current_index + 1: # If there's a next song, report its index too. next_id = self._item_id(self.playlist[self.current_index + 1]) - yield "nextsong: " + str(self.current_index + 1) - yield "nextsongid: " + str(next_id) + yield f"nextsong: {self.current_index + 1}" + yield f"nextsongid: {next_id}" if self.error: - yield "error: " + self.error + yield f"error: {self.error}" def cmd_clearerror(self, conn): """Removes the persistent error state of the server. This @@ -522,7 +522,7 @@ class BaseServer: def cmd_replay_gain_status(self, conn): """Get the replaygain mode.""" - yield "replay_gain_mode: " + str(self.replay_gain_mode) + yield f"replay_gain_mode: {self.replay_gain_mode}" def cmd_clear(self, conn): """Clear the playlist.""" @@ -643,8 +643,8 @@ class BaseServer: Also a dummy implementation. """ for idx, track in enumerate(self.playlist): - yield "cpos: " + str(idx) - yield "Id: " + str(track.id) + yield f"cpos: {idx}" + yield f"Id: {track.id}" def cmd_currentsong(self, conn): """Sends information about the currently-playing song.""" @@ -990,7 +990,7 @@ class Command: of arguments. """ # Attempt to get correct command function. - func_name = prefix + self.name + func_name = f"{prefix}{self.name}" if not hasattr(target, func_name): raise AttributeError(f'unknown command "{self.name}"') func = getattr(target, func_name) @@ -1124,15 +1124,15 @@ class Server(BaseServer): def _item_info(self, item): info_lines = [ - "file: " + as_string(item.destination(relative_to_libdir=True)), - "Time: " + str(int(item.length)), - "duration: " + f"{item.length:.3f}", - "Id: " + str(item.id), + f"file: {as_string(item.destination(relative_to_libdir=True))}", + f"Time: {int(item.length)}", + "duration: {item.length:.3f}", + f"Id: {item.id}", ] try: pos = self._id_to_index(item.id) - info_lines.append("Pos: " + str(pos)) + info_lines.append(f"Pos: {pos}") except ArgumentNotFoundError: # Don't include position if not in playlist. pass @@ -1201,7 +1201,7 @@ class Server(BaseServer): def _path_join(self, p1, p2): """Smashes together two BPD paths.""" - out = p1 + "/" + p2 + out = f"{p1}/{p2}" return out.replace("//", "/").replace("//", "/") def cmd_lsinfo(self, conn, path="/"): @@ -1231,7 +1231,7 @@ class Server(BaseServer): item = self.lib.get_item(node) yield self._item_info(item) else: - yield "file: " + basepath + yield f"file: {basepath}" else: # List a directory. Recurse into both directories and files. for name, itemid in sorted(node.files.items()): @@ -1240,7 +1240,7 @@ class Server(BaseServer): yield from self._listall(newpath, itemid, info) for name, subdir in sorted(node.dirs.items()): newpath = self._path_join(basepath, name) - yield "directory: " + newpath + yield f"directory: {newpath}" yield from self._listall(newpath, subdir, info) def cmd_listall(self, conn, path="/"): @@ -1274,7 +1274,7 @@ class Server(BaseServer): for item in self._all_items(self._resolve_path(path)): self.playlist.append(item) if send_id: - yield "Id: " + str(item.id) + yield f"Id: {item.id}" self.playlist_version += 1 self._send_event("playlist") @@ -1296,7 +1296,7 @@ class Server(BaseServer): item = self.playlist[self.current_index] yield ( - "bitrate: " + str(item.bitrate / 1000), + f"bitrate: {item.bitrate / 1000}", f"audio: {item.samplerate}:{item.bitdepth}:{item.channels}", ) @@ -1322,13 +1322,13 @@ class Server(BaseServer): artists, albums, songs, totaltime = tx.query(statement)[0] yield ( - "artists: " + str(artists), - "albums: " + str(albums), - "songs: " + str(songs), - "uptime: " + str(int(time.time() - self.startup_time)), - "playtime: " + "0", # Missing. - "db_playtime: " + str(int(totaltime)), - "db_update: " + str(int(self.updated_time)), + f"artists: {artists}", + f"albums: {albums}", + f"songs: {songs}", + f"uptime: {int(time.time() - self.startup_time)}", + "playtime: 0", # Missing. + f"db_playtime: {int(totaltime)}", + f"db_update: {int(self.updated_time)}", ) def cmd_decoders(self, conn): @@ -1370,7 +1370,7 @@ class Server(BaseServer): searching. """ for tag in self.tagtype_map: - yield "tagtype: " + tag + yield f"tagtype: {tag}" def _tagtype_lookup(self, tag): """Uses `tagtype_map` to look up the beets column name for an @@ -1445,12 +1445,9 @@ class Server(BaseServer): clause, subvals = query.clause() statement = ( - "SELECT DISTINCT " - + show_key - + " FROM items WHERE " - + clause - + " ORDER BY " - + show_key + f"SELECT DISTINCT {show_key}" + f" FROM items WHERE {clause}" + f" ORDER BY {show_key}" ) self._log.debug(statement) with self.lib.transaction() as tx: @@ -1460,7 +1457,7 @@ class Server(BaseServer): if not row[0]: # Skip any empty values of the field. continue - yield show_tag_canon + ": " + str(row[0]) + yield f"{show_tag_canon}: {row[0]}" def cmd_count(self, conn, tag, value): """Returns the number and total time of songs matching the @@ -1474,8 +1471,8 @@ class Server(BaseServer): ): songs += 1 playtime += item.length - yield "songs: " + str(songs) - yield "playtime: " + str(int(playtime)) + yield f"songs: {songs}" + yield f"playtime: {int(playtime)}" # Persistent playlist manipulation. In MPD this is an optional feature so # these dummy implementations match MPD's behaviour with the feature off. diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 03fb179aa..fa23f2b0e 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -129,7 +129,7 @@ class GstPlayer: self.player.set_state(Gst.State.NULL) if isinstance(path, str): path = path.encode("utf-8") - uri = "file://" + urllib.parse.quote(path) + uri = f"file://{urllib.parse.quote(path)}" self.player.set_property("uri", uri) self.player.set_state(Gst.State.PLAYING) self.playing = True diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index 71d7f06db..40369f74a 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -169,10 +169,8 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs): ) spans.append( re.compile( - "^[" - + ASCII_DIGITS[begin_index : end_index + 1] - + ASCII_DIGITS[begin_index : end_index + 1].upper() - + "]" + rf"^[{ASCII_DIGITS[begin_index : end_index + 1]}]", + re.IGNORECASE, ) ) return spans diff --git a/beetsplug/convert.py b/beetsplug/convert.py index a60a876e2..3e3295808 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -649,7 +649,7 @@ class ConvertPlugin(BeetsPlugin): tmpdir = self.config["tmpdir"].get() if tmpdir: tmpdir = os.fsdecode(util.bytestring_path(tmpdir)) - fd, dest = tempfile.mkstemp(os.fsdecode(b"." + ext), dir=tmpdir) + fd, dest = tempfile.mkstemp(f".{os.fsdecode(ext)}", dir=tmpdir) os.close(fd) dest = util.bytestring_path(dest) _temp_files.append(dest) # Delete the transcode later. diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index c8602b5e8..1f777c4aa 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -96,7 +96,7 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): f"Invalid `release_date` returned by {self.data_source} API: " f"{release_date!r}" ) - tracks_obj = self.fetch_data(self.album_url + deezer_id + "/tracks") + tracks_obj = self.fetch_data(f"{self.album_url}{deezer_id}/tracks") if tracks_obj is None: return None try: @@ -169,7 +169,7 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): # the track's disc). if not ( album_tracks_obj := self.fetch_data( - self.album_url + str(track_data["album"]["id"]) + "/tracks" + f"{self.album_url}{track_data['album']['id']}/tracks" ) ): return None @@ -244,7 +244,7 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): self._log.debug(f"Searching {self.data_source} for '{query}'") try: response = requests.get( - self.search_url + query_type, + f"{self.search_url}{query_type}", params={"q": query}, timeout=10, ) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 2b06d804e..c4bcaa25a 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -385,7 +385,7 @@ class DiscogsPlugin(MetadataSourcePlugin): track.artist_id = artist_id # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. - track.track_id = str(album_id) + "-" + track.track_alt + track.track_id = f"{album_id}-{track.track_alt}" track.data_url = data_url track.data_source = "Discogs" diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 538a8e6fb..656be9c78 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -682,7 +682,7 @@ class GoogleImages(RemoteArtSource): """ if not (album.albumartist and album.album): return - search_string = (album.albumartist + "," + album.album).encode("utf-8") + search_string = f"{album.albumartist},{album.album}".encode("utf-8") try: response = self.request( @@ -723,7 +723,7 @@ class FanartTV(RemoteArtSource): NAME = "fanart.tv" ID = "fanarttv" API_URL = "https://webservice.fanart.tv/v3/" - API_ALBUMS = API_URL + "music/albums/" + API_ALBUMS = f"{API_URL}music/albums/" PROJECT_KEY = "61a7d0ab4e67162b7a0c7c35915cd48e" def __init__(self, *args, **kwargs): @@ -750,7 +750,7 @@ class FanartTV(RemoteArtSource): try: response = self.request( - self.API_ALBUMS + album.mb_releasegroupid, + f"{self.API_ALBUMS}{album.mb_releasegroupid}", headers={ "api-key": self.PROJECT_KEY, "client-key": self.client_key, @@ -820,7 +820,7 @@ class ITunesStore(RemoteArtSource): return payload = { - "term": album.albumartist + " " + album.album, + "term": f"{album.albumartist} {album.album}", "entity": "album", "media": "music", "limit": 200, @@ -947,7 +947,7 @@ class Wikipedia(RemoteArtSource): data = dbpedia_response.json() results = data["results"]["bindings"] if results: - cover_filename = "File:" + results[0]["coverFilename"]["value"] + cover_filename = f"File:{results[0]['coverFilename']['value']}" page_id = results[0]["pageId"]["value"] else: self._log.debug("wikipedia: album not found on dbpedia") @@ -996,7 +996,7 @@ class Wikipedia(RemoteArtSource): results = data["query"]["pages"][page_id]["images"] for result in results: if re.match( - re.escape(lpart) + r".*?\." + re.escape(rpart), + rf"{re.escape(lpart)}.*?\.{re.escape(rpart)}", result["title"], ): cover_filename = result["title"] @@ -1227,7 +1227,7 @@ class Spotify(RemoteArtSource): paths: None | Sequence[bytes], ) -> Iterator[Candidate]: try: - url = self.SPOTIFY_ALBUM_URL + album.items().get().spotify_album_id + url = f"{self.SPOTIFY_ALBUM_URL}{album.items().get().spotify_album_id}" except AttributeError: self._log.debug("Fetchart: no Spotify album ID found") return diff --git a/beetsplug/fish.py b/beetsplug/fish.py index 4d43b85e7..b1518f1c4 100644 --- a/beetsplug/fish.py +++ b/beetsplug/fish.py @@ -123,19 +123,13 @@ class FishPlugin(BeetsPlugin): for name in names: cmd_names_help.append((name, cmd.help)) # Concatenate the string - totstring = HEAD + "\n" + totstring = f"{HEAD}\n" totstring += get_cmds_list([name[0] for name in cmd_names_help]) totstring += "" if nobasicfields else get_standard_fields(fields) totstring += get_extravalues(lib, extravalues) if extravalues else "" - totstring += ( - "\n" + "# ====== setup basic beet completion =====" + "\n" * 2 - ) + totstring += "\n# ====== setup basic beet completion =====\n\n" totstring += get_basic_beet_options() - totstring += ( - "\n" - + "# ====== setup field completion for subcommands =====" - + "\n" - ) + totstring += "\n# ====== setup field completion for subcommands =====\n" totstring += get_subcommands(cmd_names_help, nobasicfields, extravalues) # Set up completion for all the command options totstring += get_all_commands(beetcmds) @@ -147,23 +141,19 @@ class FishPlugin(BeetsPlugin): def _escape(name): # Escape ? in fish if name == "?": - name = "\\" + name + name = f"\\{name}" return name def get_cmds_list(cmds_names): # Make a list of all Beets core & plugin commands - substr = "" - substr += "set CMDS " + " ".join(cmds_names) + ("\n" * 2) - return substr + return f"set CMDS {' '.join(cmds_names)}\n\n" def get_standard_fields(fields): # Make a list of album/track fields and append with ':' - fields = (field + ":" for field in fields) - substr = "" - substr += "set FIELDS " + " ".join(fields) + ("\n" * 2) - return substr + fields = (f"{field}:" for field in fields) + return f"set FIELDS {' '.join(fields)}\n\n" def get_extravalues(lib, extravalues): @@ -172,14 +162,8 @@ def get_extravalues(lib, extravalues): word = "" values_set = get_set_of_values_for_field(lib, extravalues) for fld in extravalues: - extraname = fld.upper() + "S" - word += ( - "set " - + extraname - + " " - + " ".join(sorted(values_set[fld])) - + ("\n" * 2) - ) + extraname = f"{fld.upper()}S" + word += f"set {extraname} {' '.join(sorted(values_set[fld]))}\n\n" return word @@ -223,31 +207,29 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): for cmdname, cmdhelp in cmd_name_and_help: cmdname = _escape(cmdname) - word += "\n" + f"# ------ fieldsetups for {cmdname} -------" + "\n" + word += f"\n# ------ fieldsetups for {cmdname} -------\n" word += BL_NEED2.format( - ("-a " + cmdname), ("-f " + "-d " + wrap(clean_whitespace(cmdhelp))) + f"-a {cmdname}", f"-f -d {wrap(clean_whitespace(cmdhelp))}" ) if nobasicfields is False: word += BL_USE3.format( cmdname, - ("-a " + wrap("$FIELDS")), - ("-f " + "-d " + wrap("fieldname")), + f"-a {wrap('$FIELDS')}", + f"-f -d {wrap('fieldname')}", ) if extravalues: for f in extravalues: - setvar = wrap("$" + f.upper() + "S") - word += ( - " ".join( - BL_EXTRA3.format( - (cmdname + " " + f + ":"), - ("-f " + "-A " + "-a " + setvar), - ("-d " + wrap(f)), - ).split() - ) - + "\n" + setvar = wrap(f"${f.upper()}S") + word += " ".join( + BL_EXTRA3.format( + f"{cmdname} {f}:", + f"-f -A -a {setvar}", + f"-d {wrap(f)}", + ).split() ) + word += "\n" return word @@ -260,55 +242,44 @@ def get_all_commands(beetcmds): for name in names: name = _escape(name) - word += "\n" - word += ("\n" * 2) + f"# ====== completions for {name} =====" + "\n" + word += f"\n\n\n# ====== completions for {name} =====\n" for option in cmd.parser._get_all_options()[1:]: cmd_l = ( - (" -l " + option._long_opts[0].replace("--", "")) + f" -l {option._long_opts[0].replace('--', '')}" if option._long_opts else "" ) cmd_s = ( - (" -s " + option._short_opts[0].replace("-", "")) + f" -s {option._short_opts[0].replace('-', '')}" if option._short_opts else "" ) cmd_need_arg = " -r " if option.nargs in [1] else "" cmd_helpstr = ( - (" -d " + wrap(" ".join(option.help.split()))) + f" -d {wrap(' '.join(option.help.split()))}" if option.help else "" ) cmd_arglist = ( - (" -a " + wrap(" ".join(option.choices))) + f" -a {wrap(' '.join(option.choices))}" if option.choices else "" ) - word += ( - " ".join( - BL_USE3.format( - name, - ( - cmd_need_arg - + cmd_s - + cmd_l - + " -f " - + cmd_arglist - ), - cmd_helpstr, - ).split() - ) - + "\n" + word += " ".join( + BL_USE3.format( + name, + f"{cmd_need_arg}{cmd_s}{cmd_l} -f {cmd_arglist}", + cmd_helpstr, + ).split() ) + word += "\n" - word = word + " ".join( - BL_USE3.format( - name, - ("-s " + "h " + "-l " + "help" + " -f "), - ("-d " + wrap("print help") + "\n"), - ).split() + word = word + BL_USE3.format( + name, + "-s h -l help -f", + f"-d {wrap('print help')}", ) return word @@ -323,7 +294,7 @@ def wrap(word): sptoken = '"' if '"' in word and ("'") in word: word.replace('"', sptoken) - return '"' + word + '"' + return f'"{word}"' tok = '"' if "'" in word else "'" - return tok + word + tok + return f"{tok}{word}{tok}" diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index 0a5a6afe4..27b4488db 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -50,7 +50,7 @@ def _build_m3u_filename(basename): path = normpath( os.path.join( config["importfeeds"]["dir"].as_filename(), - date + "_" + basename + ".m3u", + f"{date}_{basename}.m3u", ) ) return path diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index b0808e4b9..69df94e35 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -361,7 +361,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) if resolved_genres: suffix = "whitelist" if self.whitelist else "any" - label = stage_label + f", {suffix}" + label = f"{stage_label}, {suffix}" if keep_genres: label = f"keep + {label}" return self._format_and_stringify(resolved_genres), label diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index 122e5f9cd..f41905226 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -70,7 +70,7 @@ class CustomUser(pylast.User): tuple with the total number of pages of results. Includes an MBID, if found. """ - doc = self._request(self.ws_prefix + "." + method, cacheable, params) + doc = self._request(f"{self.ws_prefix}.{method}", cacheable, params) toptracks_node = doc.getElementsByTagName("toptracks")[0] total_pages = int(toptracks_node.getAttribute("totalPages")) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index f5addcd74..185188491 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -582,7 +582,7 @@ class Tekstowo(SearchBackend): """Fetch lyrics from Tekstowo.pl.""" BASE_URL = "https://www.tekstowo.pl" - SEARCH_URL = BASE_URL + "/szukaj,{}.html" + SEARCH_URL = f"{BASE_URL}/szukaj,{{}}.html" def build_url(self, artist, title): artistitle = f"{artist.title()} {title.title()}" diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index ada35d870..2c2b8cd89 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -49,7 +49,7 @@ def load_meta_sources(): meta_sources = {} for module_path, class_name in SOURCES.items(): - module = import_module(METASYNC_MODULE + "." + module_path) + module = import_module(f"{METASYNC_MODULE}.{module_path}") meta_sources[class_name.lower()] = getattr(module, class_name) return meta_sources diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py index f777d0d55..05e2039a4 100644 --- a/beetsplug/metasync/itunes.py +++ b/beetsplug/metasync/itunes.py @@ -81,7 +81,7 @@ class Itunes(MetaSource): with open(library_copy, "rb") as library_copy_f: raw_library = plistlib.load(library_copy_f) except OSError as e: - raise ConfigValueError("invalid iTunes library: " + e.strerror) + raise ConfigValueError(f"invalid iTunes library: {e.strerror}") except Exception: # It's likely the user configured their '.itl' library (<> xml) if os.path.splitext(library_path)[1].lower() != ".xml": @@ -91,7 +91,7 @@ class Itunes(MetaSource): ) else: hint = "" - raise ConfigValueError("invalid iTunes library" + hint) + raise ConfigValueError(f"invalid iTunes library{hint}") # Make the iTunes library queryable using the path self.collection = { diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 52ae88e1f..f84d04518 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -307,7 +307,7 @@ class MPDStats: if "player" in events: status = self.mpd.status() - handler = getattr(self, "on_" + status["state"], None) + handler = getattr(self, f"on_{status['state']}", None) if handler: handler(status) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 5e40e4c33..2a939902d 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -201,7 +201,7 @@ def _multi_artist_credit( def track_url(trackid: str) -> str: - return urljoin(BASE_URL, "recording/" + trackid) + return urljoin(BASE_URL, f"recording/{trackid}") def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]: @@ -246,7 +246,7 @@ def _get_related_artist_names(relations, relation_type): def album_url(albumid: str) -> str: - return urljoin(BASE_URL, "release/" + albumid) + return urljoin(BASE_URL, f"release/{albumid}") def _preferred_release_event( @@ -291,7 +291,7 @@ def _set_date_str( continue if original: - key = "original_" + key + key = f"original_{key}" setattr(info, key, date_num) diff --git a/beetsplug/play.py b/beetsplug/play.py index ac074084b..35b4b1f76 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -154,7 +154,7 @@ class PlayPlugin(BeetsPlugin): return f"{command_str} {args}" else: # Don't include the marker in the command. - return command_str.replace(" " + ARGS_MARKER, "") + return command_str.replace(f" {ARGS_MARKER}", "") def _playlist_or_paths(self, paths): """Return either the raw paths of items or a playlist of the items.""" diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py index c0ea0f4eb..5e255d45b 100644 --- a/beetsplug/plexupdate.py +++ b/beetsplug/plexupdate.py @@ -66,7 +66,7 @@ def update_plex(host, port, token, library_name, secure, ignore_cert_errors): def append_token(url, token): """Appends the Plex Home token to the api call if required.""" if token: - url += "?" + urlencode({"X-Plex-Token": token}) + url += f"?{urlencode({'X-Plex-Token': token})}" return url diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index bc3d16ead..0c96898b1 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -292,7 +292,9 @@ class SpotifyPlugin( if not (spotify_id := self._extract_id(album_id)): return None - album_data = self._handle_response("get", self.album_url + spotify_id) + album_data = self._handle_response( + "get", f"{self.album_url}{spotify_id}" + ) if album_data["name"] == "": self._log.debug("Album removed from Spotify: {}", album_id) return None @@ -408,7 +410,7 @@ class SpotifyPlugin( # release) and `track.medium_total` (total number of tracks on # the track's disc). album_data = self._handle_response( - "get", self.album_url + track_data["album"]["id"] + "get", f"{self.album_url}{track_data['album']['id']}" ) medium_total = 0 for i, track_data in enumerate(album_data["tracks"]["items"], start=1): @@ -447,7 +449,7 @@ class SpotifyPlugin( except APIError as e: self._log.debug("Spotify API error: {}", e) return () - response_data = response.get(query_type + "s", {}).get("items", []) + response_data = response.get(f"{query_type}s", {}).get("items", []) self._log.debug( "Found {} result(s) from {} for '{}'", len(response_data), @@ -648,13 +650,13 @@ class SpotifyPlugin( self._log.info( f"Attempting to open {self.data_source} with playlist" ) - spotify_url = "spotify:trackset:Playlist:" + ",".join( - spotify_ids + spotify_url = ( + f"spotify:trackset:Playlist:{','.join(spotify_ids)}" ) webbrowser.open(spotify_url) else: for spotify_id in spotify_ids: - print(self.open_track_url + spotify_id) + print(f"{self.open_track_url}{spotify_id}") else: self._log.warning( f"No {self.data_source} tracks found from beets query" @@ -702,7 +704,7 @@ class SpotifyPlugin( def track_info(self, track_id: str): """Fetch a track's popularity and external IDs using its Spotify ID.""" - track_data = self._handle_response("get", self.track_url + track_id) + track_data = self._handle_response("get", f"{self.track_url}{track_id}") external_ids = track_data.get("external_ids", {}) popularity = track_data.get("popularity") self._log.debug( @@ -721,7 +723,7 @@ class SpotifyPlugin( """Fetch track audio features by its Spotify ID.""" try: return self._handle_response( - "get", self.audio_features_url + track_id + "get", f"{self.audio_features_url}{track_id}" ) except APIError as e: self._log.debug("Spotify API error: {}", e) diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py index 2e83f55c0..6c11ab918 100644 --- a/beetsplug/subsonicplaylist.py +++ b/beetsplug/subsonicplaylist.py @@ -180,5 +180,5 @@ class SubsonicPlaylistPlugin(BeetsPlugin): for track in tracks: if track not in output: output[track] = ";" - output[track] += name + ";" + output[track] += f"{name};" return output diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index ce888cb76..d966f3dbe 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -74,7 +74,7 @@ class SubsonicUpdate(BeetsPlugin): # Pick the random sequence and salt the password r = string.ascii_letters + string.digits salt = "".join([random.choice(r) for _ in range(6)]) - salted_password = password + salt + salted_password = f"{password}{salt}" token = hashlib.md5(salted_password.encode("utf-8")).hexdigest() # Put together the payload of the request to the server and the URL @@ -101,7 +101,7 @@ class SubsonicUpdate(BeetsPlugin): context_path = "" url = f"http://{host}:{port}{context_path}" - return url + f"/rest/{endpoint}" + return f"{url}/rest/{endpoint}" def start_scan(self): user = self.config["user"].as_str() diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index bfdba9630..b9ab1fd89 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -230,8 +230,7 @@ def copy_c_string(c_string): # This is a pretty dumb way to get a string copy, but it seems to # work. A more surefire way would be to allocate a ctypes buffer and copy # the data with `memcpy` or somesuch. - s = ctypes.cast(c_string, ctypes.c_char_p).value - return b"" + s + return ctypes.cast(c_string, ctypes.c_char_p).value class GioURI(URIGetter): diff --git a/beetsplug/unimported.py b/beetsplug/unimported.py index b473a346a..20ae195a7 100644 --- a/beetsplug/unimported.py +++ b/beetsplug/unimported.py @@ -34,7 +34,7 @@ class Unimported(BeetsPlugin): def commands(self): def print_unimported(lib, opts, args): ignore_exts = [ - ("." + x).encode() + f".{x}".encode() for x in self.config["ignore_extensions"].as_str_seq() ] ignore_dirs = [ diff --git a/pyproject.toml b/pyproject.toml index e0393ea81..6691221b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -276,6 +276,7 @@ select = [ "F", # pyflakes # "B", # flake8-bugbear "I", # isort + "ISC", # flake8-implicit-str-concat "N", # pep8-naming "PT", # flake8-pytest-style # "RUF", # ruff diff --git a/test/plugins/test_art.py b/test/plugins/test_art.py index 23f816774..285bb70e5 100644 --- a/test/plugins/test_art.py +++ b/test/plugins/test_art.py @@ -92,8 +92,8 @@ class CAAHelper: RELEASE_URL = f"coverartarchive.org/release/{MBID_RELASE}" GROUP_URL = f"coverartarchive.org/release-group/{MBID_GROUP}" - RELEASE_URL = "https://" + RELEASE_URL - GROUP_URL = "https://" + GROUP_URL + RELEASE_URL = f"https://{RELEASE_URL}" + GROUP_URL = f"https://{GROUP_URL}" RESPONSE_RELEASE = """{ "images": [ @@ -706,7 +706,7 @@ class FanartTVTest(UseThePlugin): def test_fanarttv_finds_image(self): album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( - fetchart.FanartTV.API_ALBUMS + "thereleasegroupid", + f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_MULTIPLE, ) candidate = next(self.source.get(album, self.settings, [])) @@ -715,7 +715,7 @@ class FanartTVTest(UseThePlugin): def test_fanarttv_returns_no_result_when_error_received(self): album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( - fetchart.FanartTV.API_ALBUMS + "thereleasegroupid", + f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_ERROR, ) with pytest.raises(StopIteration): @@ -724,7 +724,7 @@ class FanartTVTest(UseThePlugin): def test_fanarttv_returns_no_result_with_malformed_response(self): album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( - fetchart.FanartTV.API_ALBUMS + "thereleasegroupid", + f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_MALFORMED, ) with pytest.raises(StopIteration): @@ -734,7 +734,7 @@ class FanartTVTest(UseThePlugin): # The source used to fail when there were images present, but no cover album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( - fetchart.FanartTV.API_ALBUMS + "thereleasegroupid", + f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_NO_ART, ) with pytest.raises(StopIteration): diff --git a/test/plugins/test_importadded.py b/test/plugins/test_importadded.py index 1b198b31d..352471f9b 100644 --- a/test/plugins/test_importadded.py +++ b/test/plugins/test_importadded.py @@ -65,7 +65,7 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase): if m.title.replace("Tag", "Applied") == item.title: return m raise AssertionError( - "No MediaFile found for Item " + displayable_path(item.path) + f"No MediaFile found for Item {displayable_path(item.path)}" ) def test_import_album_with_added_dates(self): @@ -117,7 +117,7 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase): for item_path, added_after in items_added_after.items(): assert items_added_before[item_path] == pytest.approx( added_after, rel=1e-4 - ), "reimport modified Item.added for " + displayable_path(item_path) + ), f"reimport modified Item.added for {displayable_path(item_path)}" def test_import_singletons_with_added_dates(self): self.config["import"]["singletons"] = True @@ -157,4 +157,4 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase): for item_path, added_after in items_added_after.items(): assert items_added_before[item_path] == pytest.approx( added_after, rel=1e-4 - ), "reimport modified Item.added for " + displayable_path(item_path) + ), f"reimport modified Item.added for {displayable_path(item_path)}" diff --git a/test/plugins/test_limit.py b/test/plugins/test_limit.py index 12700295e..d77e47ca8 100644 --- a/test/plugins/test_limit.py +++ b/test/plugins/test_limit.py @@ -42,8 +42,8 @@ class LimitPluginTest(PluginTestCase): # a subset of tests has only `num_limit` results, identified by a # range filter on the track number - self.track_head_range = "track:.." + str(self.num_limit) - self.track_tail_range = "track:" + str(self.num_limit + 1) + ".." + self.track_head_range = f"track:..{self.num_limit}" + self.track_tail_range = f"track:{self.num_limit + 1}{'..'}" def test_no_limit(self): """Returns all when there is no limit or filter.""" @@ -82,13 +82,13 @@ class LimitPluginTest(PluginTestCase): def test_prefix_when_correctly_ordered(self): """Returns the expected number with the query prefix and filter when the prefix portion (correctly) appears last.""" - correct_order = self.track_tail_range + " " + self.num_limit_prefix + correct_order = f"{self.track_tail_range} {self.num_limit_prefix}" result = self.lib.items(correct_order) assert len(result) == self.num_limit def test_prefix_when_incorrectly_ordred(self): """Returns no results with the query prefix and filter when the prefix portion (incorrectly) appears first.""" - incorrect_order = self.num_limit_prefix + " " + self.track_tail_range + incorrect_order = f"{self.num_limit_prefix} {self.track_tail_range}" result = self.lib.items(incorrect_order) assert len(result) == 0 diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 97b805924..844b2ad4e 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -670,17 +670,17 @@ class ArtistFlatteningTest(unittest.TestCase): def _credit_dict(self, suffix=""): return { "artist": { - "name": "NAME" + suffix, - "sort-name": "SORT" + suffix, + "name": f"NAME{suffix}", + "sort-name": f"SORT{suffix}", }, - "name": "CREDIT" + suffix, + "name": f"CREDIT{suffix}", } def _add_alias(self, credit_dict, suffix="", locale="", primary=False): alias = { - "alias": "ALIAS" + suffix, + "alias": f"ALIAS{suffix}", "locale": locale, - "sort-name": "ALIASSORT" + suffix, + "sort-name": f"ALIASSORT{suffix}", } if primary: alias["primary"] = "primary" diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index 725236dda..293a50a20 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -49,7 +49,7 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): open_mock.assert_called_once_with(ANY, expected_cmd) expected_playlist = expected_playlist or self.item.path.decode("utf-8") - exp_playlist = expected_playlist + "\n" + exp_playlist = f"{expected_playlist}\n" with open(open_mock.call_args[0][0][0], "rb") as playlist: assert exp_playlist == playlist.read().decode("utf-8") diff --git a/test/plugins/test_playlist.py b/test/plugins/test_playlist.py index d9eb81b2c..a8c145696 100644 --- a/test/plugins/test_playlist.py +++ b/test/plugins/test_playlist.py @@ -132,7 +132,7 @@ class PlaylistTestRelativeToLib(PlaylistQueryTest, PlaylistTestCase): [ os.path.join("a", "b", "c.mp3") + "\n", os.path.join("d", "e", "f.mp3") + "\n", - "nonexisting.mp3" + "\n", + "nonexisting.mp3\n", ] ) @@ -155,7 +155,7 @@ class PlaylistTestRelativeToDir(PlaylistQueryTest, PlaylistTestCase): [ os.path.join("a", "b", "c.mp3") + "\n", os.path.join("d", "e", "f.mp3") + "\n", - "nonexisting.mp3" + "\n", + "nonexisting.mp3\n", ] ) @@ -214,7 +214,7 @@ class PlaylistUpdateTest: [ os.path.join("a", "b", "c.mp3") + "\n", os.path.join("d", "e", "f.mp3") + "\n", - "nonexisting.mp3" + "\n", + "nonexisting.mp3\n", ] ) diff --git a/test/plugins/test_plexupdate.py b/test/plugins/test_plexupdate.py index f319db6ce..ab53d8c2e 100644 --- a/test/plugins/test_plexupdate.py +++ b/test/plugins/test_plexupdate.py @@ -29,7 +29,7 @@ class PlexUpdateTest(PluginTestCase): "</Directory>" '<Directory allowSync="0" art="/:/resources/artist-fanart.jpg" ' 'filters="1" refreshing="0" thumb="/:/resources/artist.png" ' - 'key="2" type="artist" title="' + escaped_section_name + '" ' + f'key="2" type="artist" title="{escaped_section_name}" ' 'composite="/library/sections/2/composite/1416929243" ' 'agent="com.plexapp.agents.lastfm" scanner="Plex Music Scanner" ' 'language="en" uuid="90897c95-b3bd-4778-a9c8-1f43cb78f047" ' diff --git a/test/plugins/test_plugin_mediafield.py b/test/plugins/test_plugin_mediafield.py index 898e891ce..84565b47b 100644 --- a/test/plugins/test_plugin_mediafield.py +++ b/test/plugins/test_plugin_mediafield.py @@ -43,7 +43,7 @@ list_field_extension = mediafile.ListMediaField( class ExtendedFieldTestMixin(BeetsTestCase): def _mediafile_fixture(self, name, extension="mp3"): - name = bytestring_path(name + "." + extension) + name = bytestring_path(f"{name}.{extension}") src = os.path.join(_common.RSRC, name) target = os.path.join(self.temp_dir, name) shutil.copy(syspath(src), syspath(target)) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index c8e516e8b..d3569d836 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -227,11 +227,10 @@ class SmartPlaylistTest(BeetsTestCase): content = m3u_filepath.read_bytes() rmtree(syspath(dir)) - assert ( - content - == b"#EXTM3U\n" - + b"#EXTINF:300,fake artist - fake title\n" - + b"http://beets:8337/files/tagada.mp3\n" + assert content == ( + b"#EXTM3U\n" + b"#EXTINF:300,fake artist - fake title\n" + b"http://beets:8337/files/tagada.mp3\n" ) def test_playlist_update_output_extm3u_fields(self): @@ -278,11 +277,10 @@ class SmartPlaylistTest(BeetsTestCase): content = m3u_filepath.read_bytes() rmtree(syspath(dir)) - assert ( - content - == b"#EXTM3U\n" - + b'#EXTINF:300 id="456" genre="Fake%20Genre",Fake Artist - fake Title\n' - + b"/tagada.mp3\n" + assert content == ( + b"#EXTM3U\n" + b'#EXTINF:300 id="456" genre="Fake%20Genre",Fake Artist - fake Title\n' + b"/tagada.mp3\n" ) def test_playlist_update_uri_format(self): diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 67deca36f..86b5651b9 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -132,7 +132,7 @@ class SpotifyPluginTest(PluginTestCase): responses.add( responses.GET, - spotify.SpotifyPlugin.track_url + "6NPVjNh8Jhru9xOmyQigds", + f"{spotify.SpotifyPlugin.track_url}6NPVjNh8Jhru9xOmyQigds", body=response_body, status=200, content_type="application/json", @@ -145,7 +145,7 @@ class SpotifyPluginTest(PluginTestCase): responses.add( responses.GET, - spotify.SpotifyPlugin.album_url + "5l3zEmMrOhOzG8d8s83GOL", + f"{spotify.SpotifyPlugin.album_url}5l3zEmMrOhOzG8d8s83GOL", body=response_body, status=200, content_type="application/json", diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index 48014e231..fc3789c0b 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -55,8 +55,10 @@ class SubstitutePluginTest(PluginTestCase): [ ("King Creosote & Jon Hopkins", "King Creosote"), ( - "Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & " - + "The Clamtones", + ( + "Michael Hurley, The Holy Modal Rounders, Jeffrey" + " Frederick & The Clamtones" + ), "Michael Hurley", ), ("James Yorkston and the Athletes", "James Yorkston"), diff --git a/test/plugins/test_web.py b/test/plugins/test_web.py index 2ad07bbe5..9fc3d109d 100644 --- a/test/plugins/test_web.py +++ b/test/plugins/test_web.py @@ -142,7 +142,7 @@ class WebPluginTest(ItemInDBTestCase): def test_get_single_item_by_path(self): data_path = os.path.join(_common.RSRC, b"full.mp3") self.lib.add(Item.from_path(data_path)) - response = self.client.get("/item/path/" + data_path.decode("utf-8")) + response = self.client.get(f"/item/path/{data_path.decode('utf-8')}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -152,12 +152,11 @@ class WebPluginTest(ItemInDBTestCase): data_path = os.path.join(_common.RSRC, b"full.mp3") # data_path points to a valid file, but we have not added the file # to the library. - response = self.client.get("/item/path/" + data_path.decode("utf-8")) + response = self.client.get(f"/item/path/{data_path.decode('utf-8')}") assert response.status_code == 404 def test_get_item_empty_query(self): - """testing item query: <empty>""" response = self.client.get("/item/query/") res_json = json.loads(response.data.decode("utf-8")) @@ -165,7 +164,6 @@ class WebPluginTest(ItemInDBTestCase): assert len(res_json["items"]) == 3 def test_get_simple_item_query(self): - """testing item query: another""" response = self.client.get("/item/query/another") res_json = json.loads(response.data.decode("utf-8")) @@ -174,8 +172,7 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["results"][0]["title"] == "another title" def test_query_item_string(self): - """testing item query: testattr:ABC""" - response = self.client.get("/item/query/testattr%3aABC") + response = self.client.get("/item/query/testattr%3aABC") # testattr:ABC res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -183,8 +180,9 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["results"][0]["title"] == "and a third" def test_query_item_regex(self): - """testing item query: testattr::[A-C]+""" - response = self.client.get("/item/query/testattr%3a%3a[A-C]%2b") + response = self.client.get( + "/item/query/testattr%3a%3a[A-C]%2b" + ) # testattr::[A-C]+ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -192,8 +190,9 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["results"][0]["title"] == "and a third" def test_query_item_regex_backslash(self): - # """ testing item query: testattr::\w+ """ - response = self.client.get("/item/query/testattr%3a%3a%5cw%2b") + response = self.client.get( + "/item/query/testattr%3a%3a%5cw%2b" + ) # testattr::\w+ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -201,7 +200,6 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["results"][0]["title"] == "and a third" def test_query_item_path(self): - # """ testing item query: path:\somewhere\a """ """Note: path queries are special: the query item must match the path from the root all the way to a directory, so this matches 1 item""" """ Note: filesystem separators in the query must be '\' """ @@ -267,8 +265,9 @@ class WebPluginTest(ItemInDBTestCase): assert response_track_titles == {"title", "and a third"} def test_query_album_string(self): - """testing query: albumtest:xy""" - response = self.client.get("/album/query/albumtest%3axy") + response = self.client.get( + "/album/query/albumtest%3axy" + ) # albumtest:xy res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -276,8 +275,9 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["results"][0]["album"] == "album" def test_query_album_artpath_regex(self): - """testing query: artpath::art_""" - response = self.client.get("/album/query/artpath%3a%3aart_") + response = self.client.get( + "/album/query/artpath%3a%3aart_" + ) # artpath::art_ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -285,8 +285,9 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["results"][0]["album"] == "other album" def test_query_album_regex_backslash(self): - # """ testing query: albumtest::\w+ """ - response = self.client.get("/album/query/albumtest%3a%3a%5cw%2b") + response = self.client.get( + "/album/query/albumtest%3a%3a%5cw%2b" + ) # albumtest::\w+ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -310,18 +311,18 @@ class WebPluginTest(ItemInDBTestCase): ) # Check we can find the temporary item we just created - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Delete item by id - response = self.client.delete("/item/" + str(item_id)) + response = self.client.delete(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") assert response.status_code == 404 # Note: if this fails, the item may still be around # and may cause other tests to fail @@ -336,18 +337,18 @@ class WebPluginTest(ItemInDBTestCase): item_id = self.lib.add(Item.from_path(ipath)) # Check we can find the temporary item we just created - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Delete item by id, without deleting file - response = self.client.delete("/item/" + str(item_id)) + response = self.client.delete(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") assert response.status_code == 404 # Check the file has not gone @@ -364,18 +365,18 @@ class WebPluginTest(ItemInDBTestCase): item_id = self.lib.add(Item.from_path(ipath)) # Check we can find the temporary item we just created - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Delete item by id, with file - response = self.client.delete("/item/" + str(item_id) + "?delete") + response = self.client.delete(f"/item/{item_id}?delete") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") assert response.status_code == 404 # Check the file has gone @@ -427,17 +428,17 @@ class WebPluginTest(ItemInDBTestCase): ) # Check we can find the temporary item we just created - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Try to delete item by id - response = self.client.delete("/item/" + str(item_id)) + response = self.client.delete(f"/item/{item_id}") assert response.status_code == 405 # Check the item has not gone - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id @@ -481,18 +482,18 @@ class WebPluginTest(ItemInDBTestCase): ) # Check we can find the temporary album we just created - response = self.client.get("/album/" + str(album_id)) + response = self.client.get(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == album_id # Delete album by id - response = self.client.delete("/album/" + str(album_id)) + response = self.client.delete(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the album has gone - response = self.client.get("/album/" + str(album_id)) + response = self.client.get(f"/album/{album_id}") assert response.status_code == 404 # Note: if this fails, the album may still be around # and may cause other tests to fail @@ -543,17 +544,17 @@ class WebPluginTest(ItemInDBTestCase): ) # Check we can find the temporary album we just created - response = self.client.get("/album/" + str(album_id)) + response = self.client.get(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == album_id # Try to delete album by id - response = self.client.delete("/album/" + str(album_id)) + response = self.client.delete(f"/album/{album_id}") assert response.status_code == 405 # Check the item has not gone - response = self.client.get("/album/" + str(album_id)) + response = self.client.get(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == album_id @@ -603,7 +604,7 @@ class WebPluginTest(ItemInDBTestCase): ) # Check we can find the temporary item we just created - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id @@ -613,7 +614,7 @@ class WebPluginTest(ItemInDBTestCase): # Patch item by id # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}]}) response = self.client.patch( - "/item/" + str(item_id), json={"test_patch_f2": "New"} + f"/item/{item_id}", json={"test_patch_f2": "New"} ) res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 @@ -622,7 +623,7 @@ class WebPluginTest(ItemInDBTestCase): assert res_json["test_patch_f2"] == "New" # Check the update has really worked - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id @@ -647,7 +648,7 @@ class WebPluginTest(ItemInDBTestCase): ) # Check we can find the temporary item we just created - response = self.client.get("/item/" + str(item_id)) + response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id @@ -657,7 +658,7 @@ class WebPluginTest(ItemInDBTestCase): # Patch item by id # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}) response = self.client.patch( - "/item/" + str(item_id), json={"test_patch_f2": "New"} + f"/item/{item_id}", json={"test_patch_f2": "New"} ) assert response.status_code == 405 @@ -670,6 +671,6 @@ class WebPluginTest(ItemInDBTestCase): assert os.path.exists(ipath) item_id = self.lib.add(Item.from_path(ipath)) - response = self.client.get("/item/" + str(item_id) + "/file") + response = self.client.get(f"/item/{item_id}/file") assert response.status_code == 200 diff --git a/test/test_art_resize.py b/test/test_art_resize.py index 34bf810b9..0ccbb0eae 100644 --- a/test/test_art_resize.py +++ b/test/test_art_resize.py @@ -150,9 +150,5 @@ class ArtResizerFileSizeTest(CleanupModulesMixin, BeetsTestCase): metadata = {"a": "A", "b": "B"} im = DummyIMBackend() im.write_metadata("foo", metadata) - try: - command = im.convert_cmd + "foo -set a A -set b B foo".split() - mock_util.command_output.assert_called_once_with(command) - except AssertionError: - command = im.convert_cmd + "foo -set b B -set a A foo".split() - mock_util.command_output.assert_called_once_with(command) + command = [*im.convert_cmd, *"foo -set a A -set b B foo".split()] + mock_util.command_output.assert_called_once_with(command) diff --git a/test/test_datequery.py b/test/test_datequery.py index 1063a62c1..d73fca45f 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -186,37 +186,37 @@ class DateQueryTestRelativeMore(ItemInDBTestCase): def test_relative(self): for timespan in ["d", "w", "m", "y"]: - query = DateQuery("added", "-4" + timespan + "..+4" + timespan) + query = DateQuery("added", f"-4{timespan}..+4{timespan}") matched = self.lib.items(query) assert len(matched) == 1 def test_relative_fail(self): for timespan in ["d", "w", "m", "y"]: - query = DateQuery("added", "-2" + timespan + "..-1" + timespan) + query = DateQuery("added", f"-2{timespan}..-1{timespan}") matched = self.lib.items(query) assert len(matched) == 0 def test_start_relative(self): for timespan in ["d", "w", "m", "y"]: - query = DateQuery("added", "-4" + timespan + "..") + query = DateQuery("added", f"-4{timespan}..") matched = self.lib.items(query) assert len(matched) == 1 def test_start_relative_fail(self): for timespan in ["d", "w", "m", "y"]: - query = DateQuery("added", "4" + timespan + "..") + query = DateQuery("added", f"4{timespan}..") matched = self.lib.items(query) assert len(matched) == 0 def test_end_relative(self): for timespan in ["d", "w", "m", "y"]: - query = DateQuery("added", "..+4" + timespan) + query = DateQuery("added", f"..+4{timespan}") matched = self.lib.items(query) assert len(matched) == 1 def test_end_relative_fail(self): for timespan in ["d", "w", "m", "y"]: - query = DateQuery("added", "..-4" + timespan) + query = DateQuery("added", f"..-4{timespan}") matched = self.lib.items(query) assert len(matched) == 0 diff --git a/test/test_logging.py b/test/test_logging.py index 1859ea2dd..74475ada1 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -58,9 +58,9 @@ class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): self.register_listener("dummy_event", self.listener) def log_all(self, name): - self._log.debug("debug " + name) - self._log.info("info " + name) - self._log.warning("warning " + name) + self._log.debug(f"debug {name}") + self._log.info(f"info {name}") + self._log.warning(f"warning {name}") def commands(self): cmd = ui.Subcommand("dummy") @@ -172,9 +172,9 @@ class ConcurrentEventsTest(AsIsImporterMixin, ImportTestCase): self.t1_step = self.t2_step = 0 def log_all(self, name): - self._log.debug("debug " + name) - self._log.info("info " + name) - self._log.warning("warning " + name) + self._log.debug(f"debug {name}") + self._log.info(f"info {name}") + self._log.warning(f"warning {name}") def listener1(self): try: diff --git a/test/test_ui.py b/test/test_ui.py index fb166e690..534d0e466 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1257,7 +1257,7 @@ class ShowChangeTest(IOMixin, unittest.TestCase): with patch("beets.ui.commands.ui.term_width", return_value=30): # Test newline layout config["ui"]["import"]["layout"] = "newline" - long_name = "another artist with a" + (" very" * 10) + " long name" + long_name = f"another artist with a{' very' * 10} long name" msg = self._show_change( cur_artist=long_name, cur_album="another album" ) @@ -1270,7 +1270,7 @@ class ShowChangeTest(IOMixin, unittest.TestCase): with patch("beets.ui.commands.ui.term_width", return_value=54): # Test Column layout config["ui"]["import"]["layout"] = "column" - long_title = "a track with a" + (" very" * 10) + " long name" + long_title = f"a track with a{' very' * 10} long name" self.items[0].title = long_title msg = self._show_change() assert "(#1) a track (1:00) -> (#1) the title (0:00)" in msg @@ -1279,7 +1279,7 @@ class ShowChangeTest(IOMixin, unittest.TestCase): # Patch ui.term_width to force wrapping with patch("beets.ui.commands.ui.term_width", return_value=30): config["ui"]["import"]["layout"] = "newline" - long_title = "a track with a" + (" very" * 10) + " long name" + long_title = f"a track with a{' very' * 10} long name" self.items[0].title = long_title msg = self._show_change() assert "(#1) a track with" in msg From 2fccf64efe82851861e195b521b14680b480a42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 7 Aug 2025 11:46:10 +0100 Subject: [PATCH 028/301] Do not use backslashes to deal with long strings --- beets/art.py | 3 +-- beets/ui/__init__.py | 4 ++-- beetsplug/absubmit.py | 6 ++++-- beetsplug/convert.py | 17 +++++++++-------- beetsplug/metasync/amarok.py | 11 ++++++----- beetsplug/parentwork.py | 3 +-- beetsplug/replaygain.py | 6 ++++-- test/plugins/test_parentwork.py | 12 ++++-------- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/beets/art.py b/beets/art.py index c38c38ae2..0ee63f94b 100644 --- a/beets/art.py +++ b/beets/art.py @@ -143,8 +143,7 @@ def resize_image(log, imagepath, maxwidth, quality): specified quality level. """ log.debug( - "Resizing album art to {0} pixels wide and encoding at quality \ - level {1}", + "Resizing album art to {0} pixels wide and encoding at quality level {1}", maxwidth, quality, ) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 2c243c8b2..6540479c3 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1609,8 +1609,8 @@ def _ensure_db_directory_exists(path): newpath = os.path.dirname(path) if not os.path.isdir(newpath): if input_yn( - f"The database directory {util.displayable_path(newpath)} does not \ - exist. Create it (Y/n)?" + f"The database directory {util.displayable_path(newpath)} does not" + " exist. Create it (Y/n)?" ): os.makedirs(newpath) diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index 63d4ada7e..62a248482 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -118,8 +118,10 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): dest="pretend_fetch", action="store_true", default=False, - help="pretend to perform action, but show \ -only files which would be processed", + help=( + "pretend to perform action, but show only files which would be" + " processed" + ), ) cmd.func = self.command return [cmd] diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 3e3295808..26cf59d62 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -176,16 +176,17 @@ class ConvertPlugin(BeetsPlugin): "--threads", action="store", type="int", - help="change the number of threads, \ - defaults to maximum available processors", + help=( + "change the number of threads, defaults to maximum available" + " processors" + ), ) cmd.parser.add_option( "-k", "--keep-new", action="store_true", dest="keep_new", - help="keep only the converted \ - and move the old files", + help="keep only the converted and move the old files", ) cmd.parser.add_option( "-d", "--dest", action="store", help="set the destination directory" @@ -209,16 +210,16 @@ class ConvertPlugin(BeetsPlugin): "--link", action="store_true", dest="link", - help="symlink files that do not \ - need transcoding.", + help="symlink files that do not need transcoding.", ) cmd.parser.add_option( "-H", "--hardlink", action="store_true", dest="hardlink", - help="hardlink files that do not \ - need transcoding. Overrides --link.", + help=( + "hardlink files that do not need transcoding. Overrides --link." + ), ) cmd.parser.add_option( "-m", diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index 1e0793d25..47e6a1a65 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -44,11 +44,12 @@ class Amarok(MetaSource): "amarok_lastplayed": types.DATE, } - query_xml = '<query version="1.0"> \ - <filters> \ - <and><include field="filename" value={} /></and> \ - </filters> \ - </query>' + query_xml = """ + <query version="1.0"> + <filters> + <and><include field="filename" value={} /></and> + </filters> + </query>""" def __init__(self, config, log): super().__init__(config, log) diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index ab2d39b2b..5b5f215f3 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -179,8 +179,7 @@ class ParentWorkPlugin(BeetsPlugin): if not item.mb_workid: self._log.info( - "No work for {}, \ -add one at https://musicbrainz.org/recording/{}", + "No work for {}, add one at https://musicbrainz.org/recording/{}", item, item.mb_trackid, ) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index a008bec38..97e7ee294 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -1542,8 +1542,10 @@ class ReplayGainPlugin(BeetsPlugin): "--threads", dest="threads", type=int, - help="change the number of threads, \ - defaults to maximum available processors", + help=( + "change the number of threads, defaults to maximum available" + " processors" + ), ) cmd.parser.add_option( "-f", diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 99267f6ff..1abe25709 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -93,8 +93,7 @@ class ParentWorkIntegrationTest(PluginTestCase): item = Item( path="/file", mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", - parentwork_workid_current="e27bda6e-531e-36d3-9cd7-\ - b8ebc18e8c53", + parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", ) item.add(self.lib) @@ -109,8 +108,7 @@ class ParentWorkIntegrationTest(PluginTestCase): path="/file", mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", mb_parentworkid="XXX", - parentwork_workid_current="e27bda6e-531e-36d3-9cd7-\ - b8ebc18e8c53", + parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", parentwork="whatever", ) item.add(self.lib) @@ -124,11 +122,9 @@ class ParentWorkIntegrationTest(PluginTestCase): self.config["parentwork"]["force"] = False item = Item( path="/file", - mb_workid="e27bda6e-531e-36d3-9cd7-\ - b8ebc18e8c53", + mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", mb_parentworkid="XXX", - parentwork_workid_current="e27bda6e-531e-36d3-9cd7-\ - b8ebc18e8c53", + parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", parentwork="whatever", ) item.add(self.lib) From d93ddf8dd43e4f9ed072a03829e287c78d2570a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 2 Aug 2025 12:33:13 +0100 Subject: [PATCH 029/301] Do not use explicit indices for logging args when not needed --- CONTRIBUTING.rst | 2 +- beets/art.py | 24 ++++++++++---------- beets/autotag/match.py | 30 ++++++++++++------------- beets/importer/session.py | 6 ++--- beets/importer/stages.py | 16 ++++++------- beets/importer/state.py | 4 ++-- beets/importer/tasks.py | 34 +++++++++++++--------------- beets/library/models.py | 8 +++---- beets/plugins.py | 2 +- beets/test/helper.py | 2 +- beets/ui/__init__.py | 20 ++++++++--------- beets/ui/commands.py | 24 +++++++++----------- beets/util/__init__.py | 2 +- beets/util/artresizer.py | 16 ++++++------- beetsplug/beatport.py | 14 ++++++------ beetsplug/bpm.py | 6 ++--- beetsplug/chroma.py | 28 +++++++++++------------ beetsplug/convert.py | 40 ++++++++++++++++----------------- beetsplug/discogs.py | 14 ++++++------ beetsplug/duplicates.py | 20 ++++++++--------- beetsplug/embedart.py | 2 +- beetsplug/embyupdate.py | 2 +- beetsplug/export.py | 2 +- beetsplug/fetchart.py | 6 ++--- beetsplug/ftintitle.py | 8 +++---- beetsplug/hook.py | 8 +++---- beetsplug/ihate.py | 4 ++-- beetsplug/importadded.py | 12 +++++----- beetsplug/importfeeds.py | 2 +- beetsplug/info.py | 2 +- beetsplug/inline.py | 6 ++--- beetsplug/ipfs.py | 20 ++++++++--------- beetsplug/keyfinder.py | 6 ++--- beetsplug/kodiupdate.py | 4 ++-- beetsplug/lastgenre/__init__.py | 6 ++--- beetsplug/lastimport.py | 30 ++++++++++++------------- beetsplug/listenbrainz.py | 4 ++-- beetsplug/lyrics.py | 2 +- beetsplug/mbcollection.py | 4 ++-- beetsplug/missing.py | 2 +- beetsplug/mpdstats.py | 24 ++++++++++---------- beetsplug/mpdupdate.py | 2 +- beetsplug/musicbrainz.py | 4 ++-- beetsplug/replaygain.py | 38 +++++++++++++++---------------- beetsplug/rewrite.py | 2 +- beetsplug/scrub.py | 10 ++++----- beetsplug/smartplaylist.py | 14 +++++------- beetsplug/spotify.py | 2 +- beetsplug/subsonicupdate.py | 6 ++--- beetsplug/the.py | 10 ++++----- beetsplug/thumbnails.py | 24 ++++++++++---------- beetsplug/web/__init__.py | 2 +- beetsplug/zero.py | 6 ++--- test/plugins/test_ftintitle.py | 8 +++---- test/plugins/test_mpdstats.py | 4 ++-- test/test_logging.py | 2 +- 56 files changed, 297 insertions(+), 305 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9251cea34..11dac07d8 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -261,7 +261,7 @@ There are a few coding conventions we use in beets: - The loggers use `str.format <http://docs.python.org/library/stdtypes.html#str.format>`__-style logging - instead of ``%``-style, so you can type ``log.debug("{0}", obj)`` to do your + instead of ``%``-style, so you can type ``log.debug("{}", obj)`` to do your formatting. - Exception handlers must use ``except A as B:`` instead of ``except A, B:``. diff --git a/beets/art.py b/beets/art.py index 0ee63f94b..c829787c2 100644 --- a/beets/art.py +++ b/beets/art.py @@ -39,7 +39,7 @@ def get_art(log, item): mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: log.warning( - "Could not extract art from {0}: {1}", + "Could not extract art from {}: {}", displayable_path(item.path), exc, ) @@ -83,10 +83,10 @@ def embed_item( # Get the `Image` object from the file. try: - log.debug("embedding {0}", displayable_path(imagepath)) + log.debug("embedding {}", displayable_path(imagepath)) image = mediafile_image(imagepath, maxwidth) except OSError as exc: - log.warning("could not read image file: {0}", exc) + log.warning("could not read image file: {}", exc) return # Make sure the image kind is safe (some formats only support PNG @@ -110,11 +110,11 @@ def embed_album( """Embed album art into all of the album's items.""" imagepath = album.artpath if not imagepath: - log.info("No album art present for {0}", album) + log.info("No album art present for {}", album) return if not os.path.isfile(syspath(imagepath)): log.info( - "Album art not found at {0} for {1}", + "Album art not found at {} for {}", displayable_path(imagepath), album, ) @@ -122,7 +122,7 @@ def embed_album( if maxwidth: imagepath = resize_image(log, imagepath, maxwidth, quality) - log.info("Embedding album art into {0}", album) + log.info("Embedding album art into {}", album) for item in album.items(): embed_item( @@ -143,7 +143,7 @@ def resize_image(log, imagepath, maxwidth, quality): specified quality level. """ log.debug( - "Resizing album art to {0} pixels wide and encoding at quality level {1}", + "Resizing album art to {} pixels wide and encoding at quality level {}", maxwidth, quality, ) @@ -183,18 +183,18 @@ def extract(log, outpath, item): art = get_art(log, item) outpath = bytestring_path(outpath) if not art: - log.info("No album art present in {0}, skipping.", item) + log.info("No album art present in {}, skipping.", item) return # Add an extension to the filename. ext = mediafile.image_extension(art) if not ext: - log.warning("Unknown image type in {0}.", displayable_path(item.path)) + log.warning("Unknown image type in {}.", displayable_path(item.path)) return outpath += bytestring_path(f".{ext}") log.info( - "Extracting album art from: {0} to: {1}", + "Extracting album art from: {} to: {}", item, displayable_path(outpath), ) @@ -212,7 +212,7 @@ def extract_first(log, outpath, items): def clear(log, lib, query): items = lib.items(query) - log.info("Clearing album art from {0} items", len(items)) + log.info("Clearing album art from {} items", len(items)) for item in items: - log.debug("Clearing art for {0}", item) + log.debug("Clearing art for {}", item) item.try_write(tags={"images": None}) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index e74d21755..dd992facc 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -118,7 +118,7 @@ def match_by_id(items: Iterable[Item]) -> AlbumInfo | None: log.debug("No album ID consensus.") return None # If all album IDs are equal, look up the album. - log.debug("Searching for discovered album ID: {0}", first) + log.debug("Searching for discovered album ID: {}", first) return metadata_plugins.album_for_id(first) @@ -197,9 +197,7 @@ def _add_candidate( checking the track count, ordering the items, checking for duplicates, and calculating the distance. """ - log.debug( - "Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id - ) + log.debug("Candidate: {} - {} ({})", info.artist, info.album, info.album_id) # Discard albums with zero tracks. if not info.tracks: @@ -215,7 +213,7 @@ def _add_candidate( required_tags: Sequence[str] = config["match"]["required"].as_str_seq() for req_tag in required_tags: if getattr(info, req_tag) is None: - log.debug("Ignored. Missing required tag: {0}", req_tag) + log.debug("Ignored. Missing required tag: {}", req_tag) return # Find mapping between the items and the track info. @@ -229,10 +227,10 @@ def _add_candidate( ignored_tags: Sequence[str] = config["match"]["ignored"].as_str_seq() for penalty in ignored_tags: if penalty in penalties: - log.debug("Ignored. Penalty: {0}", penalty) + log.debug("Ignored. Penalty: {}", penalty) return - log.debug("Success. Distance: {0}", dist) + log.debug("Success. Distance: {}", dist) results[info.album_id] = hooks.AlbumMatch( dist, info, mapping, extra_items, extra_tracks ) @@ -265,7 +263,7 @@ def tag_album( likelies, consensus = get_most_common_tags(items) cur_artist: str = likelies["artist"] cur_album: str = likelies["album"] - log.debug("Tagging {0} - {1}", cur_artist, cur_album) + log.debug("Tagging {} - {}", cur_artist, cur_album) # The output result, keys are the MB album ID. candidates: dict[Any, AlbumMatch] = {} @@ -273,7 +271,7 @@ def tag_album( # Search by explicit ID. if search_ids: for search_id in search_ids: - log.debug("Searching for album ID: {0}", search_id) + log.debug("Searching for album ID: {}", search_id) if info := metadata_plugins.album_for_id(search_id): _add_candidate(items, candidates, info) @@ -283,7 +281,7 @@ def tag_album( if info := match_by_id(items): _add_candidate(items, candidates, info) rec = _recommendation(list(candidates.values())) - log.debug("Album ID match recommendation is {0}", rec) + log.debug("Album ID match recommendation is {}", rec) if candidates and not config["import"]["timid"]: # If we have a very good MBID match, return immediately. # Otherwise, this match will compete against metadata-based @@ -300,7 +298,7 @@ def tag_album( if not (search_artist and search_album): # No explicit search terms -- use current metadata. search_artist, search_album = cur_artist, cur_album - log.debug("Search terms: {0} - {1}", search_artist, search_album) + log.debug("Search terms: {} - {}", search_artist, search_album) # Is this album likely to be a "various artist" release? va_likely = ( @@ -308,7 +306,7 @@ def tag_album( or (search_artist.lower() in VA_ARTISTS) or any(item.comp for item in items) ) - log.debug("Album might be VA: {0}", va_likely) + log.debug("Album might be VA: {}", va_likely) # Get the results from the data sources. for matched_candidate in metadata_plugins.candidates( @@ -316,7 +314,7 @@ def tag_album( ): _add_candidate(items, candidates, matched_candidate) - log.debug("Evaluating {0} candidates.", len(candidates)) + log.debug("Evaluating {} candidates.", len(candidates)) # Sort and get the recommendation. candidates_sorted = _sort_candidates(candidates.values()) rec = _recommendation(candidates_sorted) @@ -345,7 +343,7 @@ def tag_item( trackids = search_ids or [t for t in [item.mb_trackid] if t] if trackids: for trackid in trackids: - log.debug("Searching for track ID: {0}", trackid) + log.debug("Searching for track ID: {}", trackid) if info := metadata_plugins.track_for_id(trackid): dist = track_distance(item, info, incl_artist=True) candidates[info.track_id] = hooks.TrackMatch(dist, info) @@ -369,7 +367,7 @@ def tag_item( # Search terms. search_artist = search_artist or item.artist search_title = search_title or item.title - log.debug("Item search terms: {0} - {1}", search_artist, search_title) + log.debug("Item search terms: {} - {}", search_artist, search_title) # Get and evaluate candidate metadata. for track_info in metadata_plugins.item_candidates( @@ -379,7 +377,7 @@ def tag_item( candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) # Sort by distance and return with recommendation. - log.debug("Found {0} candidates.", len(candidates)) + log.debug("Found {} candidates.", len(candidates)) candidates_sorted = _sort_candidates(candidates.values()) rec = _recommendation(candidates_sorted) return Proposal(candidates_sorted, rec) diff --git a/beets/importer/session.py b/beets/importer/session.py index e45644fa3..46277837e 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -150,7 +150,7 @@ class ImportSession: """Log a message about a given album to the importer log. The status should reflect the reason the album couldn't be tagged. """ - self.logger.info("{0} {1}", status, displayable_path(paths)) + self.logger.info("{} {}", status, displayable_path(paths)) def log_choice(self, task: ImportTask, duplicate=False): """Logs the task's current choice if it should be logged. If @@ -187,7 +187,7 @@ class ImportSession: def run(self): """Run the import task.""" - self.logger.info("import started {0}", time.asctime()) + self.logger.info("import started {}", time.asctime()) self.set_config(config["import"]) # Set up the pipeline. @@ -297,7 +297,7 @@ class ImportSession: # Either accept immediately or prompt for input to decide. if self.want_resume is True or self.should_resume(toppath): log.warning( - "Resuming interrupted import of {0}", + "Resuming interrupted import of {}", util.displayable_path(toppath), ) self._is_resuming[toppath] = True diff --git a/beets/importer/stages.py b/beets/importer/stages.py index e8ce3fbac..b68a68824 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -58,11 +58,11 @@ def read_tasks(session: ImportSession): skipped += task_factory.skipped if not task_factory.imported: - log.warning("No files imported from {0}", displayable_path(toppath)) + log.warning("No files imported from {}", displayable_path(toppath)) # Show skipped directories (due to incremental/resume). if skipped: - log.info("Skipped {0} paths.", skipped) + log.info("Skipped {} paths.", skipped) def query_tasks(session: ImportSession): @@ -82,7 +82,7 @@ def query_tasks(session: ImportSession): # Search for albums. for album in session.lib.albums(session.query): log.debug( - "yielding album {0}: {1} - {2}", + "yielding album {}: {} - {}", album.id, album.albumartist, album.album, @@ -140,7 +140,7 @@ def lookup_candidates(session: ImportSession, task: ImportTask): return plugins.send("import_task_start", session=session, task=task) - log.debug("Looking up: {0}", displayable_path(task.paths)) + log.debug("Looking up: {}", displayable_path(task.paths)) # Restrict the initial lookup to IDs specified by the user via the -m # option. Currently all the IDs are passed onto the tasks directly. @@ -259,11 +259,11 @@ def plugin_stage( def log_files(session: ImportSession, task: ImportTask): """A coroutine (pipeline stage) to log each file to be imported.""" if isinstance(task, SingletonImportTask): - log.info("Singleton: {0}", displayable_path(task.item["path"])) + log.info("Singleton: {}", displayable_path(task.item["path"])) elif task.items: - log.info("Album: {0}", displayable_path(task.paths[0])) + log.info("Album: {}", displayable_path(task.paths[0])) for item in task.items: - log.info(" {0}", displayable_path(item["path"])) + log.info(" {}", displayable_path(item["path"])) # --------------------------------- Consumer --------------------------------- # @@ -353,7 +353,7 @@ def _resolve_duplicates(session: ImportSession, task: ImportTask): "ask": "a", } ) - log.debug("default action for duplicates: {0}", duplicate_action) + log.debug("default action for duplicates: {}", duplicate_action) if duplicate_action == "s": # Skip new. diff --git a/beets/importer/state.py b/beets/importer/state.py index fccb7c282..fde26c606 100644 --- a/beets/importer/state.py +++ b/beets/importer/state.py @@ -87,7 +87,7 @@ class ImportState: # unpickling, including ImportError. We use a catch-all # exception to avoid enumerating them all (the docs don't even have a # full list!). - log.debug("state file could not be read: {0}", exc) + log.debug("state file could not be read: {}", exc) def _save(self): try: @@ -100,7 +100,7 @@ class ImportState: f, ) except OSError as exc: - log.error("state file could not be written: {0}", exc) + log.error("state file could not be written: {}", exc) # -------------------------------- Tagprogress ------------------------------- # diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index abe2ca8a9..2c653c4ce 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -267,12 +267,12 @@ class ImportTask(BaseImportTask): def remove_duplicates(self, lib: library.Library): duplicate_items = self.duplicate_items(lib) - log.debug("removing {0} old duplicated items", len(duplicate_items)) + log.debug("removing {} old duplicated items", len(duplicate_items)) for item in duplicate_items: item.remove() if lib.directory in util.ancestry(item.path): log.debug( - "deleting duplicate {0}", util.displayable_path(item.path) + "deleting duplicate {}", util.displayable_path(item.path) ) util.remove(item.path) util.prune_dirs(os.path.dirname(item.path), lib.directory) @@ -285,10 +285,10 @@ class ImportTask(BaseImportTask): for field, view in config["import"]["set_fields"].items(): value = str(view.get()) log.debug( - "Set field {1}={2} for {0}", - util.displayable_path(self.paths), + "Set field {}={} for {}", field, value, + util.displayable_path(self.paths), ) self.album.set_parse(field, format(self.album, value)) for item in items: @@ -622,13 +622,13 @@ class ImportTask(BaseImportTask): for item in self.imported_items(): for dup_item in self.replaced_items[item]: log.debug( - "Replacing item {0}: {1}", + "Replacing item {}: {}", dup_item.id, util.displayable_path(item.path), ) dup_item.remove() log.debug( - "{0} of {1} items replaced", + "{} of {} items replaced", sum(bool(v) for v in self.replaced_items.values()), len(self.imported_items()), ) @@ -747,10 +747,10 @@ class SingletonImportTask(ImportTask): for field, view in config["import"]["set_fields"].items(): value = str(view.get()) log.debug( - "Set field {1}={2} for {0}", - util.displayable_path(self.paths), + "Set field {}={} for {}", field, value, + util.displayable_path(self.paths), ) self.item.set_parse(field, format(self.item, value)) self.item.store() @@ -870,7 +870,7 @@ class ArchiveImportTask(SentinelImportTask): """Removes the temporary directory the archive was extracted to.""" if self.extracted and self.toppath: log.debug( - "Removing extracted directory: {0}", + "Removing extracted directory: {}", util.displayable_path(self.toppath), ) shutil.rmtree(util.syspath(self.toppath)) @@ -1002,7 +1002,7 @@ class ImportTaskFactory: """Return a `SingletonImportTask` for the music file.""" if self.session.already_imported(self.toppath, [path]): log.debug( - "Skipping previously-imported path: {0}", + "Skipping previously-imported path: {}", util.displayable_path(path), ) self.skipped += 1 @@ -1026,7 +1026,7 @@ class ImportTaskFactory: if self.session.already_imported(self.toppath, dirs): log.debug( - "Skipping previously-imported path: {0}", + "Skipping previously-imported path: {}", util.displayable_path(dirs), ) self.skipped += 1 @@ -1063,19 +1063,17 @@ class ImportTaskFactory: ) return - log.debug( - "Extracting archive: {0}", util.displayable_path(self.toppath) - ) + log.debug("Extracting archive: {}", util.displayable_path(self.toppath)) archive_task = ArchiveImportTask(self.toppath) try: archive_task.extract() except Exception as exc: - log.error("extraction failed: {0}", exc) + log.error("extraction failed: {}", exc) return # Now read albums from the extracted directory. self.toppath = archive_task.toppath - log.debug("Archive extracted to: {0}", self.toppath) + log.debug("Archive extracted to: {}", self.toppath) return archive_task def read_item(self, path: util.PathBytes): @@ -1091,10 +1089,10 @@ class ImportTaskFactory: # Silently ignore non-music files. pass elif isinstance(exc.reason, mediafile.UnreadableFileError): - log.warning("unreadable file: {0}", util.displayable_path(path)) + log.warning("unreadable file: {}", util.displayable_path(path)) else: log.error( - "error reading {0}: {1}", util.displayable_path(path), exc + "error reading {}: {}", util.displayable_path(path), exc ) diff --git a/beets/library/models.py b/beets/library/models.py index fbcfd94f1..3c93d0cb7 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -425,7 +425,7 @@ class Album(LibModel): new_art = util.unique_path(new_art) log.debug( - "moving album art {0} to {1}", + "moving album art {} to {}", util.displayable_path(old_art), util.displayable_path(new_art), ) @@ -992,7 +992,7 @@ class Item(LibModel): self.write(*args, **kwargs) return True except FileOperationError as exc: - log.error("{0}", exc) + log.error("{}", exc) return False def try_sync(self, write, move, with_album=True): @@ -1013,7 +1013,7 @@ class Item(LibModel): # Check whether this file is inside the library directory. if self._db and self._db.directory in util.ancestry(self.path): log.debug( - "moving {0} to synchronize path", + "moving {} to synchronize path", util.displayable_path(self.path), ) self.move(with_album=with_album) @@ -1087,7 +1087,7 @@ class Item(LibModel): try: return os.path.getsize(syspath(self.path)) except (OSError, Exception) as exc: - log.warning("could not get filesize: {0}", exc) + log.warning("could not get filesize: {}", exc) return 0 # Model methods. diff --git a/beets/plugins.py b/beets/plugins.py index e959bdfe0..d9df4323c 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -543,7 +543,7 @@ def send(event: EventType, **arguments: Any) -> list[Any]: Return a list of non-None values returned from the handlers. """ - log.debug("Sending event: {0}", event) + log.debug("Sending event: {}", event) return [ r for handler in BeetsPlugin.listeners[event] diff --git a/beets/test/helper.py b/beets/test/helper.py index 0ff9246ae..85adc0825 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -267,7 +267,7 @@ class TestHelper(ConfigMixin): The item is attached to the database from `self.lib`. """ values_ = { - "title": "t\u00eftle {0}", + "title": "t\u00eftle {}", "artist": "the \u00e4rtist", "album": "the \u00e4lbum", "track": 1, diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 6540479c3..9f0ae82e1 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -572,7 +572,7 @@ def colorize(color_name, text): # instead of the abstract color name ('text_error') color = COLORS.get(color_name) if not color: - log.debug("Invalid color_name: {0}", color_name) + log.debug("Invalid color_name: {}", color_name) color = color_name return _colorize(color, text) else: @@ -1587,19 +1587,19 @@ def _configure(options): if overlay_path: log.debug( - "overlaying configuration: {0}", util.displayable_path(overlay_path) + "overlaying configuration: {}", util.displayable_path(overlay_path) ) config_path = config.user_config_path() if os.path.isfile(config_path): - log.debug("user configuration: {0}", util.displayable_path(config_path)) + log.debug("user configuration: {}", util.displayable_path(config_path)) else: log.debug( - "no user configuration found at {0}", + "no user configuration found at {}", util.displayable_path(config_path), ) - log.debug("data directory: {0}", util.displayable_path(config.config_dir())) + log.debug("data directory: {}", util.displayable_path(config.config_dir())) return config @@ -1634,7 +1634,7 @@ def _open_library(config: confuse.LazyConfig) -> library.Library: f" opened: {db_error}" ) log.debug( - "library database: {0}\nlibrary directory: {1}", + "library database: {}\nlibrary directory: {}", util.displayable_path(lib.path), util.displayable_path(lib.directory), ) @@ -1751,7 +1751,7 @@ def main(args=None): _raw_main(args) except UserError as exc: message = exc.args[0] if exc.args else None - log.error("error: {0}", message) + log.error("error: {}", message) sys.exit(1) except util.HumanReadableError as exc: exc.log(log) @@ -1763,10 +1763,10 @@ def main(args=None): log.error("{}", exc) sys.exit(1) except confuse.ConfigError as exc: - log.error("configuration error: {0}", exc) + log.error("configuration error: {}", exc) sys.exit(1) except db_query.InvalidQueryError as exc: - log.error("invalid query: {0}", exc) + log.error("invalid query: {}", exc) sys.exit(1) except OSError as exc: if exc.errno == errno.EPIPE: @@ -1779,7 +1779,7 @@ def main(args=None): log.debug("{}", traceback.format_exc()) except db.DBAccessError as exc: log.error( - "database access error: {0}\n" + "database access error: {}\n" "the library file might have a permissions problem", exc, ) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 89950cac8..6a4c23e11 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1148,7 +1148,7 @@ class TerminalImportSession(importer.ImportSession): that's already in the library. """ log.warning( - "This {0} is already in the library!", + "This {} is already in the library!", ("album" if task.is_album else "item"), ) @@ -1280,8 +1280,8 @@ class TerminalImportSession(importer.ImportSession): dup_choices = [c for c in all_choices if c.short == short] for c in dup_choices[1:]: log.warning( - "Prompt choice '{0}' removed due to conflict " - "with '{1}' (short letter: '{2}')", + "Prompt choice '{}' removed due to conflict " + "with '{}' (short letter: '{}')", c.long, dup_choices[0].long, c.short, @@ -1639,7 +1639,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): # Did the item change since last checked? if item.current_mtime() <= item.mtime: log.debug( - "skipping {0} because mtime is up to date ({1})", + "skipping {} because mtime is up to date ({})", displayable_path(item.path), item.mtime, ) @@ -1650,7 +1650,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): item.read() except library.ReadError as exc: log.error( - "error reading {0}: {1}", displayable_path(item.path), exc + "error reading {}: {}", displayable_path(item.path), exc ) continue @@ -1692,7 +1692,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): continue album = lib.get_album(album_id) if not album: # Empty albums have already been removed. - log.debug("emptied album {0}", album_id) + log.debug("emptied album {}", album_id) continue first_item = album.items().get() @@ -1703,7 +1703,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): # Move album art (and any inconsistent items). if move and lib.directory in ancestry(first_item.path): - log.debug("moving album {0}", album_id) + log.debug("moving album {}", album_id) # Manually moving and storing the album. items = list(album.items()) @@ -2141,7 +2141,7 @@ def move_items( act = "copy" if copy else "move" entity = "album" if album else "item" log.info( - "{0} {1} {2}{3}{4}.", + "{} {} {}{}{}.", action, len(objs), entity, @@ -2175,7 +2175,7 @@ def move_items( ) for obj in objs: - log.debug("moving: {0}", util.displayable_path(obj.path)) + log.debug("moving: {}", util.displayable_path(obj.path)) if export: # Copy without affecting the database. @@ -2258,16 +2258,14 @@ def write_items(lib, query, pretend, force): for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): - log.info("missing file: {0}", util.displayable_path(item.path)) + log.info("missing file: {}", util.displayable_path(item.path)) continue # Get an Item object reflecting the "clean" (on-disk) state. try: clean_item = library.Item.from_path(item.path) except library.ReadError as exc: - log.error( - "error reading {0}: {1}", displayable_path(item.path), exc - ) + log.error("error reading {}: {}", displayable_path(item.path), exc) continue # Check for and display changes. diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 8ac9722af..7d2992ba2 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -126,7 +126,7 @@ class HumanReadableError(Exception): """ if self.tb: logger.debug(self.tb) - logger.error("{0}: {1}", self.error_kind, self.args[0]) + logger.error("{}: {}", self.error_kind, self.args[0]) class FilesystemError(HumanReadableError): diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index c72fda5af..fa6d5d236 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -255,7 +255,7 @@ class IMBackend(LocalBackend): path_out = get_temp_filename(__name__, "resize_IM_", path_in) log.debug( - "artresizer: ImageMagick resizing {0} to {1}", + "artresizer: ImageMagick resizing {} to {}", displayable_path(path_in), displayable_path(path_out), ) @@ -287,7 +287,7 @@ class IMBackend(LocalBackend): util.command_output(cmd) except subprocess.CalledProcessError: log.warning( - "artresizer: IM convert failed for {0}", + "artresizer: IM convert failed for {}", displayable_path(path_in), ) return path_in @@ -452,7 +452,7 @@ class IMBackend(LocalBackend): if compare_proc.returncode: if compare_proc.returncode != 1: log.debug( - "ImageMagick compare failed: {0}, {1}", + "ImageMagick compare failed: {}, {}", displayable_path(im2), displayable_path(im1), ) @@ -472,7 +472,7 @@ class IMBackend(LocalBackend): log.debug("IM output is not a number: {0!r}", out_str) return None - log.debug("ImageMagick compare score: {0}", phash_diff) + log.debug("ImageMagick compare score: {}", phash_diff) return phash_diff <= compare_threshold @property @@ -523,7 +523,7 @@ class PILBackend(LocalBackend): from PIL import Image log.debug( - "artresizer: PIL resizing {0} to {1}", + "artresizer: PIL resizing {} to {}", displayable_path(path_in), displayable_path(path_out), ) @@ -552,7 +552,7 @@ class PILBackend(LocalBackend): for i in range(5): # 5 attempts is an arbitrary choice filesize = os.stat(syspath(path_out)).st_size - log.debug("PIL Pass {0} : Output size: {1}B", i, filesize) + log.debug("PIL Pass {} : Output size: {}B", i, filesize) if filesize <= max_filesize: return path_out # The relationship between filesize & quality will be @@ -569,7 +569,7 @@ class PILBackend(LocalBackend): progressive=False, ) log.warning( - "PIL Failed to resize file to below {0}B", max_filesize + "PIL Failed to resize file to below {}B", max_filesize ) return path_out @@ -577,7 +577,7 @@ class PILBackend(LocalBackend): return path_out except OSError: log.error( - "PIL cannot create thumbnail for '{0}'", + "PIL cannot create thumbnail for '{}'", displayable_path(path_in), ) return path_in diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index b91de0295..fa681ce6a 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -366,7 +366,7 @@ class BeatportPlugin(MetadataSourcePlugin): try: url = auth_client.get_authorize_url() except AUTH_ERRORS as e: - self._log.debug("authentication error: {0}", e) + self._log.debug("authentication error: {}", e) raise beets.ui.UserError("communication with Beatport failed") beets.ui.print_("To authenticate with Beatport, visit:") @@ -377,11 +377,11 @@ class BeatportPlugin(MetadataSourcePlugin): try: token, secret = auth_client.get_access_token(data) except AUTH_ERRORS as e: - self._log.debug("authentication error: {0}", e) + self._log.debug("authentication error: {}", e) raise beets.ui.UserError("Beatport token request failed") # Save the token for later use. - self._log.debug("Beatport token {0}, secret {1}", token, secret) + self._log.debug("Beatport token {}, secret {}", token, secret) with open(self._tokenfile(), "w") as f: json.dump({"token": token, "secret": secret}, f) @@ -405,7 +405,7 @@ class BeatportPlugin(MetadataSourcePlugin): try: yield from self._get_releases(query) except BeatportAPIError as e: - self._log.debug("API Error: {0} (query: {1})", e, query) + self._log.debug("API Error: {} (query: {})", e, query) return def item_candidates( @@ -415,14 +415,14 @@ class BeatportPlugin(MetadataSourcePlugin): try: return self._get_tracks(query) except BeatportAPIError as e: - self._log.debug("API Error: {0} (query: {1})", e, query) + self._log.debug("API Error: {} (query: {})", e, query) return [] def album_for_id(self, album_id: str): """Fetches a release by its Beatport ID and returns an AlbumInfo object or None if the query is not a valid ID or release is not found. """ - self._log.debug("Searching for release {0}", album_id) + self._log.debug("Searching for release {}", album_id) if not (release_id := self._extract_id(album_id)): self._log.debug("Not a valid Beatport release ID.") @@ -437,7 +437,7 @@ class BeatportPlugin(MetadataSourcePlugin): """Fetches a track by its Beatport ID and returns a TrackInfo object or None if the track is not a valid Beatport ID or track is not found. """ - self._log.debug("Searching for track {0}", track_id) + self._log.debug("Searching for track {}", track_id) # TODO: move to extractor match = re.search(r"(^|beatport\.com/track/.+/)(\d+)$", track_id) if not match: diff --git a/beetsplug/bpm.py b/beetsplug/bpm.py index 145986a95..d49963b72 100644 --- a/beetsplug/bpm.py +++ b/beetsplug/bpm.py @@ -73,12 +73,12 @@ class BPMPlugin(BeetsPlugin): item = items[0] if item["bpm"]: - self._log.info("Found bpm {0}", item["bpm"]) + self._log.info("Found bpm {}", item["bpm"]) if not overwrite: return self._log.info( - "Press Enter {0} times to the rhythm or Ctrl-D to exit", + "Press Enter {} times to the rhythm or Ctrl-D to exit", self.config["max_strokes"].get(int), ) new_bpm = bpm(self.config["max_strokes"].get(int)) @@ -86,4 +86,4 @@ class BPMPlugin(BeetsPlugin): if write: item.try_write() item.store() - self._log.info("Added new bpm {0}", item["bpm"]) + self._log.info("Added new bpm {}", item["bpm"]) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 3b31382b7..1e04d51f5 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -90,7 +90,7 @@ def acoustid_match(log, path): duration, fp = acoustid.fingerprint_file(util.syspath(path)) except acoustid.FingerprintGenerationError as exc: log.error( - "fingerprinting of {0} failed: {1}", + "fingerprinting of {} failed: {}", util.displayable_path(repr(path)), exc, ) @@ -103,12 +103,12 @@ def acoustid_match(log, path): ) except acoustid.AcoustidError as exc: log.debug( - "fingerprint matching {0} failed: {1}", + "fingerprint matching {} failed: {}", util.displayable_path(repr(path)), exc, ) return None - log.debug("chroma: fingerprinted {0}", util.displayable_path(repr(path))) + log.debug("chroma: fingerprinted {}", util.displayable_path(repr(path))) # Ensure the response is usable and parse it. if res["status"] != "ok" or not res.get("results"): @@ -146,7 +146,7 @@ def acoustid_match(log, path): release_ids = [rel["id"] for rel in releases] log.debug( - "matched recordings {0} on releases {1}", recording_ids, release_ids + "matched recordings {} on releases {}", recording_ids, release_ids ) _matches[path] = recording_ids, release_ids @@ -211,7 +211,7 @@ class AcoustidPlugin(MetadataSourcePlugin): if album: albums.append(album) - self._log.debug("acoustid album candidates: {0}", len(albums)) + self._log.debug("acoustid album candidates: {}", len(albums)) return albums def item_candidates(self, item, artist, title) -> Iterable[TrackInfo]: @@ -224,7 +224,7 @@ class AcoustidPlugin(MetadataSourcePlugin): track = self.mb.track_for_id(recording_id) if track: tracks.append(track) - self._log.debug("acoustid item candidates: {0}", len(tracks)) + self._log.debug("acoustid item candidates: {}", len(tracks)) return tracks def album_for_id(self, *args, **kwargs): @@ -292,11 +292,11 @@ def submit_items(log, userkey, items, chunksize=64): def submit_chunk(): """Submit the current accumulated fingerprint data.""" - log.info("submitting {0} fingerprints", len(data)) + log.info("submitting {} fingerprints", len(data)) try: acoustid.submit(API_KEY, userkey, data, timeout=10) except acoustid.AcoustidError as exc: - log.warning("acoustid submission error: {0}", exc) + log.warning("acoustid submission error: {}", exc) del data[:] for item in items: @@ -343,31 +343,31 @@ def fingerprint_item(log, item, write=False): """ # Get a fingerprint and length for this track. if not item.length: - log.info("{0}: no duration available", util.displayable_path(item.path)) + log.info("{}: no duration available", util.displayable_path(item.path)) elif item.acoustid_fingerprint: if write: log.info( - "{0}: fingerprint exists, skipping", + "{}: fingerprint exists, skipping", util.displayable_path(item.path), ) else: log.info( - "{0}: using existing fingerprint", + "{}: using existing fingerprint", util.displayable_path(item.path), ) return item.acoustid_fingerprint else: - log.info("{0}: fingerprinting", util.displayable_path(item.path)) + log.info("{}: fingerprinting", util.displayable_path(item.path)) try: _, fp = acoustid.fingerprint_file(util.syspath(item.path)) item.acoustid_fingerprint = fp.decode() if write: log.info( - "{0}: writing fingerprint", util.displayable_path(item.path) + "{}: writing fingerprint", util.displayable_path(item.path) ) item.try_write() if item._db: item.store() return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: - log.info("fingerprint generation failed: {0}", exc) + log.info("fingerprint generation failed: {}", exc) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 26cf59d62..93b66d976 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -288,7 +288,7 @@ class ConvertPlugin(BeetsPlugin): quiet = self.config["quiet"].get(bool) if not quiet and not pretend: - self._log.info("Encoding {0}", util.displayable_path(source)) + self._log.info("Encoding {}", util.displayable_path(source)) command = os.fsdecode(command) source = os.fsdecode(source) @@ -307,7 +307,7 @@ class ConvertPlugin(BeetsPlugin): encode_cmd.append(os.fsdecode(args[i])) if pretend: - self._log.info("{0}", " ".join(args)) + self._log.info("{}", " ".join(args)) return try: @@ -315,11 +315,11 @@ class ConvertPlugin(BeetsPlugin): except subprocess.CalledProcessError as exc: # Something went wrong (probably Ctrl+C), remove temporary files self._log.info( - "Encoding {0} failed. Cleaning up...", + "Encoding {} failed. Cleaning up...", util.displayable_path(source), ) self._log.debug( - "Command {0} exited with status {1}: {2}", + "Command {} exited with status {}: {}", args, exc.returncode, exc.output, @@ -334,7 +334,7 @@ class ConvertPlugin(BeetsPlugin): if not quiet and not pretend: self._log.info( - "Finished encoding {0}", util.displayable_path(source) + "Finished encoding {}", util.displayable_path(source) ) def convert_item( @@ -362,7 +362,7 @@ class ConvertPlugin(BeetsPlugin): try: mediafile.MediaFile(util.syspath(item.path)) except mediafile.UnreadableFileError as exc: - self._log.error("Could not open file to convert: {0}", exc) + self._log.error("Could not open file to convert: {}", exc) continue # When keeping the new file in the library, we first move the @@ -388,7 +388,7 @@ class ConvertPlugin(BeetsPlugin): if os.path.exists(util.syspath(dest)): self._log.info( - "Skipping {0} (target file exists)", + "Skipping {} (target file exists)", util.displayable_path(item.path), ) continue @@ -396,13 +396,13 @@ class ConvertPlugin(BeetsPlugin): if keep_new: if pretend: self._log.info( - "mv {0} {1}", + "mv {} {}", util.displayable_path(item.path), util.displayable_path(original), ) else: self._log.info( - "Moving to {0}", util.displayable_path(original) + "Moving to {}", util.displayable_path(original) ) util.move(item.path, original) @@ -418,10 +418,10 @@ class ConvertPlugin(BeetsPlugin): msg = "ln" if hardlink else ("ln -s" if link else "cp") self._log.info( - "{2} {0} {1}", + "{} {} {}", + msg, util.displayable_path(original), util.displayable_path(converted), - msg, ) else: # No transcoding necessary. @@ -432,7 +432,7 @@ class ConvertPlugin(BeetsPlugin): ) self._log.info( - "{1} {0}", util.displayable_path(item.path), msg + "{} {}", msg, util.displayable_path(item.path) ) if hardlink: @@ -523,7 +523,7 @@ class ConvertPlugin(BeetsPlugin): if os.path.exists(util.syspath(dest)): self._log.info( - "Skipping {0} (target file exists)", + "Skipping {} (target file exists)", util.displayable_path(album.artpath), ) return @@ -534,7 +534,7 @@ class ConvertPlugin(BeetsPlugin): # Either copy or resize (while copying) the image. if maxwidth is not None: self._log.info( - "Resizing cover art from {0} to {1}", + "Resizing cover art from {} to {}", util.displayable_path(album.artpath), util.displayable_path(dest), ) @@ -545,10 +545,10 @@ class ConvertPlugin(BeetsPlugin): msg = "ln" if hardlink else ("ln -s" if link else "cp") self._log.info( - "{2} {0} {1}", + "{} {} {}", + msg, util.displayable_path(album.artpath), util.displayable_path(dest), - msg, ) else: msg = ( @@ -558,10 +558,10 @@ class ConvertPlugin(BeetsPlugin): ) self._log.info( - "{2} cover art from {0} to {1}", + "{} cover art from {} to {}", + msg, util.displayable_path(album.artpath), util.displayable_path(dest), - msg, ) if hardlink: util.hardlink(album.artpath, dest) @@ -622,7 +622,7 @@ class ConvertPlugin(BeetsPlugin): # Playlist paths are understood as relative to the dest directory. pl_normpath = util.normpath(playlist) pl_dir = os.path.dirname(pl_normpath) - self._log.info("Creating playlist file {0}", pl_normpath) + self._log.info("Creating playlist file {}", pl_normpath) # Generates a list of paths to media files, ensures the paths are # relative to the playlist's location and translates the unicode # strings we get from item.destination to bytes. @@ -672,7 +672,7 @@ class ConvertPlugin(BeetsPlugin): if self.config["delete_originals"]: self._log.log( logging.DEBUG if self.config["quiet"] else logging.INFO, - "Removing original file {0}", + "Removing original file {}", source_path, ) util.remove(source_path, False) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index c4bcaa25a..bf41cf38d 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -145,7 +145,7 @@ class DiscogsPlugin(MetadataSourcePlugin): try: _, _, url = auth_client.get_authorize_url() except CONNECTION_ERRORS as e: - self._log.debug("connection error: {0}", e) + self._log.debug("connection error: {}", e) raise beets.ui.UserError("communication with Discogs failed") beets.ui.print_("To authenticate with Discogs, visit:") @@ -158,11 +158,11 @@ class DiscogsPlugin(MetadataSourcePlugin): except DiscogsAPIError: raise beets.ui.UserError("Discogs authorization failed") except CONNECTION_ERRORS as e: - self._log.debug("connection error: {0}", e) + self._log.debug("connection error: {}", e) raise beets.ui.UserError("Discogs token request failed") # Save the token for later use. - self._log.debug("Discogs token {0}, secret {1}", token, secret) + self._log.debug("Discogs token {}, secret {}", token, secret) with open(self._tokenfile(), "w") as f: json.dump({"token": token, "secret": secret}, f) @@ -202,7 +202,7 @@ class DiscogsPlugin(MetadataSourcePlugin): """Fetches an album by its Discogs ID and returns an AlbumInfo object or None if the album is not found. """ - self._log.debug("Searching for release {0}", album_id) + self._log.debug("Searching for release {}", album_id) discogs_id = self._extract_id(album_id) @@ -216,7 +216,7 @@ class DiscogsPlugin(MetadataSourcePlugin): except DiscogsAPIError as e: if e.status_code != 404: self._log.debug( - "API Error: {0} (query: {1})", + "API Error: {} (query: {})", e, result.data["resource_url"], ) @@ -266,7 +266,7 @@ class DiscogsPlugin(MetadataSourcePlugin): """Fetches a master release given its Discogs ID and returns its year or None if the master release is not found. """ - self._log.debug("Getting master release {0}", master_id) + self._log.debug("Getting master release {}", master_id) result = Master(self.discogs_client, {"id": master_id}) try: @@ -274,7 +274,7 @@ class DiscogsPlugin(MetadataSourcePlugin): except DiscogsAPIError as e: if e.status_code != 404: self._log.debug( - "API Error: {0} (query: {1})", + "API Error: {} (query: {})", e, result.data["resource_url"], ) diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 73fdee6a4..c16348c17 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -254,7 +254,7 @@ class DuplicatesPlugin(BeetsPlugin): checksum = getattr(item, key, False) if not checksum: self._log.debug( - "key {0} on item {1} not cached:computing checksum", + "key {} on item {} not cached:computing checksum", key, displayable_path(item.path), ) @@ -263,17 +263,17 @@ class DuplicatesPlugin(BeetsPlugin): setattr(item, key, checksum) item.store() self._log.debug( - "computed checksum for {0} using {1}", item.title, key + "computed checksum for {} using {}", item.title, key ) except subprocess.CalledProcessError as e: self._log.debug( - "failed to checksum {0}: {1}", + "failed to checksum {}: {}", displayable_path(item.path), e, ) else: self._log.debug( - "key {0} on item {1} cached:not computing checksum", + "key {} on item {} cached:not computing checksum", key, displayable_path(item.path), ) @@ -293,13 +293,13 @@ class DuplicatesPlugin(BeetsPlugin): values = [v for v in values if v not in (None, "")] if strict and len(values) < len(keys): self._log.debug( - "some keys {0} on item {1} are null or empty: skipping", + "some keys {} on item {} are null or empty: skipping", keys, displayable_path(obj.path), ) elif not strict and not len(values): self._log.debug( - "all keys {0} on item {1} are null or empty: skipping", + "all keys {} on item {} are null or empty: skipping", keys, displayable_path(obj.path), ) @@ -359,8 +359,8 @@ class DuplicatesPlugin(BeetsPlugin): value = getattr(o, f, None) if value: self._log.debug( - "key {0} on item {1} is null " - "or empty: setting from item {2}", + "key {} on item {} is null " + "or empty: setting from item {}", f, displayable_path(objs[0].path), displayable_path(o.path), @@ -383,8 +383,8 @@ class DuplicatesPlugin(BeetsPlugin): missing.album_id = objs[0].id missing.add(i._db) self._log.debug( - "item {0} missing from album {1}:" - " merging from {2} into {3}", + "item {} missing from album {}:" + " merging from {} into {}", missing, objs[0], displayable_path(o.path), diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 68ed5da78..c684c4d17 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -273,7 +273,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): """ if self.config["remove_art_file"] and album.artpath: if os.path.isfile(syspath(album.artpath)): - self._log.debug("Removing album art file for {0}", album) + self._log.debug("Removing album art file for {}", album) os.remove(syspath(album.artpath)) album.artpath = None album.store() diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py index 024f7679f..62770934b 100644 --- a/beetsplug/embyupdate.py +++ b/beetsplug/embyupdate.py @@ -196,7 +196,7 @@ class EmbyUpdate(BeetsPlugin): # Get authentication token. token = get_token(host, port, headers, auth_data) if not token: - self._log.warning("Could not get token for user {0}", username) + self._log.warning("Could not get token for user {}", username) return # Recreate headers with a token. diff --git a/beetsplug/export.py b/beetsplug/export.py index 05ca3f24a..e6c2b88c7 100644 --- a/beetsplug/export.py +++ b/beetsplug/export.py @@ -150,7 +150,7 @@ class ExportPlugin(BeetsPlugin): try: data, item = data_emitter(included_keys or "*") except (mediafile.UnreadableFileError, OSError) as ex: - self._log.error("cannot read file: {0}", ex) + self._log.error("cannot read file: {}", ex) continue for key, value in data.items(): diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 656be9c78..38a0996cc 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1541,7 +1541,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): out = candidate assert out.path is not None # help mypy self._log.debug( - "using {0.LOC} image {1}", + "using {.LOC} image {}", source, util.displayable_path(out.path), ) @@ -1576,7 +1576,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): message = ui.colorize( "text_highlight_minor", "has album art" ) - self._log.info("{0}: {1}", album, message) + self._log.info("{}: {}", album, message) else: # In ordinary invocations, look for images on the # filesystem. When forcing, however, always go to the Web @@ -1589,4 +1589,4 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): message = ui.colorize("text_success", "found album art") else: message = ui.colorize("text_error", "no art found") - self._log.info("{0}: {1}", album, message) + self._log.info("{}: {}", album, message) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 150f230aa..5d5db2e92 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -90,7 +90,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): { "auto": True, "drop": False, - "format": "feat. {0}", + "format": "feat. {}", "keep_in_artist": False, } ) @@ -151,10 +151,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # In case the artist is kept, do not update the artist fields. if keep_in_artist_field: self._log.info( - "artist: {0} (Not changing due to keep_in_artist)", item.artist + "artist: {} (Not changing due to keep_in_artist)", item.artist ) else: - self._log.info("artist: {0} -> {1}", item.artist, item.albumartist) + self._log.info("artist: {} -> {}", item.artist, item.albumartist) item.artist = item.albumartist if item.artist_sort: @@ -167,7 +167,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) new_title = f"{item.title} {new_format}" - self._log.info("title: {0} -> {1}", item.title, new_title) + self._log.info("title: {} -> {}", item.title, new_title) item.title = new_title def ft_in_title( diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 90d66553a..62cb86567 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -62,7 +62,7 @@ class HookPlugin(BeetsPlugin): def create_and_register_hook(self, event, command): def hook_function(**kwargs): if command is None or len(command) == 0: - self._log.error('invalid command "{0}"', command) + self._log.error('invalid command "{}"', command) return # For backwards compatibility, use a string formatter that decodes @@ -74,7 +74,7 @@ class HookPlugin(BeetsPlugin): ] self._log.debug( - 'running command "{0}" for event {1}', + 'running command "{}" for event {}', " ".join(command_pieces), event, ) @@ -83,9 +83,9 @@ class HookPlugin(BeetsPlugin): subprocess.check_call(command_pieces) except subprocess.CalledProcessError as exc: self._log.error( - "hook for {0} exited with status {1}", event, exc.returncode + "hook for {} exited with status {}", event, exc.returncode ) except OSError as exc: - self._log.error("hook for {0} failed: {1}", event, exc) + self._log.error("hook for {} failed: {}", event, exc) self.register_listener(event, hook_function) diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py index d6357294d..54a61384c 100644 --- a/beetsplug/ihate.py +++ b/beetsplug/ihate.py @@ -70,10 +70,10 @@ class IHatePlugin(BeetsPlugin): self._log.debug("processing your hate") if self.do_i_hate_this(task, skip_queries): task.choice_flag = Action.SKIP - self._log.info("skipped: {0}", summary(task)) + self._log.info("skipped: {}", summary(task)) return if self.do_i_hate_this(task, warn_queries): - self._log.info("you may hate this: {0}", summary(task)) + self._log.info("you may hate this: {}", summary(task)) else: self._log.debug("nothing to do") else: diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index 2564f26b2..3dcfe1cc2 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -94,7 +94,7 @@ class ImportAddedPlugin(BeetsPlugin): mtime = os.stat(util.syspath(source)).st_mtime self.item_mtime[destination] = mtime self._log.debug( - "Recorded mtime {0} for item '{1}' imported from '{2}'", + "Recorded mtime {} for item '{}' imported from '{}'", mtime, util.displayable_path(destination), util.displayable_path(source), @@ -103,7 +103,7 @@ class ImportAddedPlugin(BeetsPlugin): def update_album_times(self, lib, album): if self.reimported_album(album): self._log.debug( - "Album '{0}' is reimported, skipping import of " + "Album '{}' is reimported, skipping import of " "added dates for the album and its items.", util.displayable_path(album.path), ) @@ -119,7 +119,7 @@ class ImportAddedPlugin(BeetsPlugin): item.store() album.added = min(album_mtimes) self._log.debug( - "Import of album '{0}', selected album.added={1} " + "Import of album '{}', selected album.added={} " "from item file mtimes.", album.album, album.added, @@ -129,7 +129,7 @@ class ImportAddedPlugin(BeetsPlugin): def update_item_times(self, lib, item): if self.reimported_item(item): self._log.debug( - "Item '{0}' is reimported, skipping import of added date.", + "Item '{}' is reimported, skipping import of added date.", util.displayable_path(item.path), ) return @@ -139,7 +139,7 @@ class ImportAddedPlugin(BeetsPlugin): if self.config["preserve_mtimes"].get(bool): self.write_item_mtime(item, mtime) self._log.debug( - "Import of item '{0}', selected item.added={1}", + "Import of item '{}', selected item.added={}", util.displayable_path(item.path), item.added, ) @@ -153,7 +153,7 @@ class ImportAddedPlugin(BeetsPlugin): if self.config["preserve_write_mtimes"].get(bool): self.write_item_mtime(item, item.added) self._log.debug( - "Write of item '{0}', selected item.added={1}", + "Write of item '{}', selected item.added={}", util.displayable_path(item.path), item.added, ) diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index 27b4488db..a74746f8b 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -136,7 +136,7 @@ class ImportFeedsPlugin(BeetsPlugin): if "echo" in formats: self._log.info("Location of imported music:") for path in paths: - self._log.info(" {0}", path) + self._log.info(" {}", path) def album_imported(self, lib, album): self._record_items(lib, album.album, album.items()) diff --git a/beetsplug/info.py b/beetsplug/info.py index 69e35184f..cc78aaffe 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -219,7 +219,7 @@ class InfoPlugin(BeetsPlugin): try: data, item = data_emitter(included_keys or "*") except (mediafile.UnreadableFileError, OSError) as ex: - self._log.error("cannot read file: {0}", ex) + self._log.error("cannot read file: {}", ex) continue if opts.summarize: diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 00907577a..e9a94ac38 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -60,14 +60,14 @@ class InlinePlugin(BeetsPlugin): for key, view in itertools.chain( config["item_fields"].items(), config["pathfields"].items() ): - self._log.debug("adding item field {0}", key) + self._log.debug("adding item field {}", key) func = self.compile_inline(view.as_str(), False) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config["album_fields"].items(): - self._log.debug("adding album field {0}", key) + self._log.debug("adding album field {}", key) func = self.compile_inline(view.as_str(), True) if func is not None: self.album_template_fields[key] = func @@ -87,7 +87,7 @@ class InlinePlugin(BeetsPlugin): func = _compile_func(python_code) except SyntaxError: self._log.error( - "syntax error in inline field definition:\n{0}", + "syntax error in inline field definition:\n{}", traceback.format_exc(), ) return diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index 3c6425c06..e7e597d26 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -77,7 +77,7 @@ class IPFSPlugin(BeetsPlugin): for album in lib.albums(args): if len(album.items()) == 0: self._log.info( - "{0} does not contain items, aborting", album + "{} does not contain items, aborting", album ) self.ipfs_add(album) @@ -122,13 +122,13 @@ class IPFSPlugin(BeetsPlugin): return False try: if album.ipfs: - self._log.debug("{0} already added", album_dir) + self._log.debug("{} already added", album_dir) # Already added to ipfs return False except AttributeError: pass - self._log.info("Adding {0} to ipfs", album_dir) + self._log.info("Adding {} to ipfs", album_dir) if self.config["nocopy"]: cmd = "ipfs add --nocopy -q -r".split() @@ -138,7 +138,7 @@ class IPFSPlugin(BeetsPlugin): try: output = util.command_output(cmd).stdout.split() except (OSError, subprocess.CalledProcessError) as exc: - self._log.error("Failed to add {0}, error: {1}", album_dir, exc) + self._log.error("Failed to add {}, error: {}", album_dir, exc) return False length = len(output) @@ -146,12 +146,12 @@ class IPFSPlugin(BeetsPlugin): line = line.strip() if linenr == length - 1: # last printed line is the album hash - self._log.info("album: {0}", line) + self._log.info("album: {}", line) album.ipfs = line else: try: item = album.items()[linenr] - self._log.info("item: {0}", line) + self._log.info("item: {}", line) item.ipfs = line item.store() except IndexError: @@ -180,11 +180,11 @@ class IPFSPlugin(BeetsPlugin): util.command_output(cmd) except (OSError, subprocess.CalledProcessError) as err: self._log.error( - "Failed to get {0} from ipfs.\n{1}", _hash, err.output + "Failed to get {} from ipfs.\n{}", _hash, err.output ) return False - self._log.info("Getting {0} from ipfs", _hash) + self._log.info("Getting {} from ipfs", _hash) imp = ui.commands.TerminalImportSession( lib, loghandler=None, query=None, paths=[_hash] ) @@ -208,7 +208,7 @@ class IPFSPlugin(BeetsPlugin): msg = f"Failed to publish library. Error: {err}" self._log.error(msg) return False - self._log.info("hash of library: {0}", output) + self._log.info("hash of library: {}", output) def ipfs_import(self, lib, args): _hash = args[0] @@ -306,7 +306,7 @@ class IPFSPlugin(BeetsPlugin): items.append(item) if len(items) < 1: return False - self._log.info("Adding '{0}' to temporary library", album) + self._log.info("Adding '{}' to temporary library", album) new_album = tmplib.add_album(items) new_album.ipfs = album.ipfs new_album.store(inherit=False) diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index 00b688d4f..e95e999d0 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -65,7 +65,7 @@ class KeyFinderPlugin(BeetsPlugin): command + [util.syspath(item.path)] ).stdout except (subprocess.CalledProcessError, OSError) as exc: - self._log.error("execution failed: {0}", exc) + self._log.error("execution failed: {}", exc) continue try: @@ -73,7 +73,7 @@ class KeyFinderPlugin(BeetsPlugin): 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("no key returned for path: {0}", item.path) + self._log.error("no key returned for path: {}", item.path) continue try: @@ -84,7 +84,7 @@ class KeyFinderPlugin(BeetsPlugin): item["initial_key"] = key self._log.info( - "added computed initial key {0} for {1}", + "added computed initial key {} for {}", key, util.displayable_path(item.path), ) diff --git a/beetsplug/kodiupdate.py b/beetsplug/kodiupdate.py index 2f679c38b..890ab16c4 100644 --- a/beetsplug/kodiupdate.py +++ b/beetsplug/kodiupdate.py @@ -96,10 +96,10 @@ class KodiUpdate(BeetsPlugin): continue self._log.info( - "Kodi update triggered for {0}:{1}", + "Kodi update triggered for {}:{}", instance["host"], instance["port"], ) except requests.exceptions.RequestException as e: - self._log.warning("Kodi update failed: {0}", str(e)) + self._log.warning("Kodi update failed: {}", str(e)) continue diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 69df94e35..b473d8bea 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -139,7 +139,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Read the tree if c14n_filename: - self._log.debug("Loading canonicalization tree {0}", c14n_filename) + self._log.debug("Loading canonicalization tree {}", c14n_filename) c14n_filename = normpath(c14n_filename) with codecs.open(c14n_filename, "r", encoding="utf-8") as f: genres_tree = yaml.safe_load(f) @@ -607,12 +607,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): try: res = obj.get_top_tags() except PYLAST_EXCEPTIONS as exc: - self._log.debug("last.fm error: {0}", exc) + self._log.debug("last.fm error: {}", exc) return [] except Exception as exc: # Isolate bugs in pylast. self._log.debug("{}", traceback.format_exc()) - self._log.error("error in pylast library: {0}", exc) + self._log.error("error in pylast library: {}", exc) return [] # Filter by weight (optionally). diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index f41905226..039b5dfc4 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -120,7 +120,7 @@ def import_lastfm(lib, log): if not user: raise ui.UserError("You must specify a user name for lastimport") - log.info("Fetching last.fm library for @{0}", user) + log.info("Fetching last.fm library for @{}", user) page_total = 1 page_current = 0 @@ -130,7 +130,7 @@ def import_lastfm(lib, log): # Iterate through a yet to be known page total count while page_current < page_total: log.info( - "Querying page #{0}{1}...", + "Querying page #{}{}...", page_current + 1, f"/{page_total}" if page_total > 1 else "", ) @@ -147,27 +147,27 @@ def import_lastfm(lib, log): unknown_total += unknown break else: - log.error("ERROR: unable to read page #{0}", page_current + 1) + log.error("ERROR: unable to read page #{}", page_current + 1) if retry < retry_limit: log.info( - "Retrying page #{0}... ({1}/{2} retry)", + "Retrying page #{}... ({}/{} retry)", page_current + 1, retry + 1, retry_limit, ) else: log.error( - "FAIL: unable to fetch page #{0}, ", - "tried {1} times", + "FAIL: unable to fetch page #{}, ", + "tried {} times", page_current, retry + 1, ) page_current += 1 log.info("... done!") - log.info("finished processing {0} song pages", page_total) - log.info("{0} unknown play-counts", unknown_total) - log.info("{0} play-counts imported", found_total) + log.info("finished processing {} song pages", page_total) + log.info("{} unknown play-counts", unknown_total) + log.info("{} play-counts imported", found_total) def fetch_tracks(user, page, limit): @@ -201,7 +201,7 @@ def process_tracks(lib, tracks, log): total = len(tracks) total_found = 0 total_fails = 0 - log.info("Received {0} tracks in this page, processing...", total) + log.info("Received {} tracks in this page, processing...", total) for num in range(0, total): song = None @@ -220,7 +220,7 @@ def process_tracks(lib, tracks, log): else None ) - log.debug("query: {0} - {1} ({2})", artist, title, album) + log.debug("query: {} - {} ({})", artist, title, album) # First try to query by musicbrainz's trackid if trackid: @@ -231,7 +231,7 @@ def process_tracks(lib, tracks, log): # If not, try just album/title if song is None: log.debug( - "no album match, trying by album/title: {0} - {1}", album, title + "no album match, trying by album/title: {} - {}", album, title ) query = dbcore.AndQuery( [ @@ -268,7 +268,7 @@ def process_tracks(lib, tracks, log): count = int(song.get("play_count", 0)) new_count = int(tracks[num].get("playcount", 1)) log.debug( - "match: {0} - {1} ({2}) updating: play_count {3} => {4}", + "match: {} - {} ({}) updating: play_count {} => {}", song.artist, song.title, song.album, @@ -280,11 +280,11 @@ def process_tracks(lib, tracks, log): total_found += 1 else: total_fails += 1 - log.info(" - No match: {0} - {1} ({2})", artist, title, album) + log.info(" - No match: {} - {} ({})", artist, title, album) if total_fails > 0: log.info( - "Acquired {0}/{1} play-counts ({2} unknown)", + "Acquired {}/{} play-counts ({} unknown)", total_found, total, total_fails, diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index c579645db..72639ecae 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -48,8 +48,8 @@ class ListenBrainzPlugin(BeetsPlugin): found_total += found unknown_total += unknown log.info("... done!") - log.info("{0} unknown play-counts", unknown_total) - log.info("{0} play-counts imported", found_total) + log.info("{} unknown play-counts", unknown_total) + log.info("{} play-counts imported", found_total) def _make_request(self, url, params=None): """Makes a request to the ListenBrainz API.""" diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 185188491..7b5ee55c7 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -1090,7 +1090,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin): return if lyrics := self.find_lyrics(item): - self.info("🟢 Found lyrics: {0}", item) + self.info("🟢 Found lyrics: {}", item) if translator := self.translator: lyrics = translator.translate(lyrics, item.lyrics) else: diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 51dcb286b..2f9ef709e 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -154,10 +154,10 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): if re.match(UUID_REGEX, aid): album_ids.append(aid) else: - self._log.info("skipping invalid MBID: {0}", aid) + self._log.info("skipping invalid MBID: {}", aid) # Submit to MusicBrainz. - self._log.info("Updating MusicBrainz collection {0}...", collection_id) + self._log.info("Updating MusicBrainz collection {}...", collection_id) submit_albums(collection_id, album_ids) if remove_missing: self.remove_missing(collection_id, lib.albums()) diff --git a/beetsplug/missing.py b/beetsplug/missing.py index d0e956930..c440f6c69 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -226,7 +226,7 @@ class MissingPlugin(BeetsPlugin): for track_info in album_info.tracks: if track_info.track_id not in item_mbids: self._log.debug( - "track {0} in album {1}", + "track {} in album {}", track_info.track_id, album_info.album_id, ) diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index f84d04518..a4e9fb843 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -51,8 +51,8 @@ class MPDClientWrapper: if not self.strip_path.endswith("/"): self.strip_path += "/" - self._log.debug("music_directory: {0}", self.music_directory) - self._log.debug("strip_path: {0}", self.strip_path) + self._log.debug("music_directory: {}", self.music_directory) + self._log.debug("strip_path: {}", self.strip_path) self.client = mpd.MPDClient() @@ -64,7 +64,7 @@ class MPDClientWrapper: if host[0] in ["/", "~"]: host = os.path.expanduser(host) - self._log.info("connecting to {0}:{1}", host, port) + self._log.info("connecting to {}:{}", host, port) try: self.client.connect(host, port) except OSError as e: @@ -89,7 +89,7 @@ class MPDClientWrapper: try: return getattr(self.client, command)() except (OSError, mpd.ConnectionError) as err: - self._log.error("{0}", err) + self._log.error("{}", err) if retries <= 0: # if we exited without breaking, we couldn't reconnect in time :( @@ -123,7 +123,7 @@ class MPDClientWrapper: result = os.path.join(self.music_directory, file) else: result = entry["file"] - self._log.debug("returning: {0}", result) + self._log.debug("returning: {}", result) return result, entry.get("id") def status(self): @@ -169,7 +169,7 @@ class MPDStats: if item: return item else: - self._log.info("item not found: {0}", displayable_path(path)) + self._log.info("item not found: {}", displayable_path(path)) def update_item(self, item, attribute, value=None, increment=None): """Update the beets item. Set attribute to value or increment the value @@ -188,7 +188,7 @@ class MPDStats: item.store() self._log.debug( - "updated: {0} = {1} [{2}]", + "updated: {} = {} [{}]", attribute, item[attribute], displayable_path(item.path), @@ -234,12 +234,12 @@ class MPDStats: def handle_played(self, song): """Updates the play count of a song.""" self.update_item(song["beets_item"], "play_count", increment=1) - self._log.info("played {0}", displayable_path(song["path"])) + self._log.info("played {}", displayable_path(song["path"])) def handle_skipped(self, song): """Updates the skip count of a song.""" self.update_item(song["beets_item"], "skip_count", increment=1) - self._log.info("skipped {0}", displayable_path(song["path"])) + self._log.info("skipped {}", displayable_path(song["path"])) def on_stop(self, status): self._log.info("stop") @@ -278,11 +278,11 @@ class MPDStats: self.handle_song_change(self.now_playing) if is_url(path): - self._log.info("playing stream {0}", displayable_path(path)) + self._log.info("playing stream {}", displayable_path(path)) self.now_playing = None return - self._log.info("playing {0}", displayable_path(path)) + self._log.info("playing {}", displayable_path(path)) self.now_playing = { "started": time.time(), @@ -312,7 +312,7 @@ class MPDStats: if handler: handler(status) else: - self._log.debug('unhandled status "{0}"', status) + self._log.debug('unhandled status "{}"', status) events = self.mpd.events() diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 3e950cf54..e92d5c63f 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -102,7 +102,7 @@ class MPDUpdatePlugin(BeetsPlugin): try: s = BufferedSocket(host, port) except OSError as e: - self._log.warning("MPD connection failed: {0}", str(e.strerror)) + self._log.warning("MPD connection failed: {}", str(e.strerror)) return resp = s.readline() diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2a939902d..524fb3c8c 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -836,7 +836,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): """ self._log.debug("Requesting MusicBrainz release {}", album_id) if not (albumid := self._extract_id(album_id)): - self._log.debug("Invalid MBID ({0}).", album_id) + self._log.debug("Invalid MBID ({}).", album_id) return None try: @@ -873,7 +873,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): or None if no track is found. May raise a MusicBrainzAPIError. """ if not (trackid := self._extract_id(track_id)): - self._log.debug("Invalid MBID ({0}).", track_id) + self._log.debug("Invalid MBID ({}).", track_id) return None try: diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 97e7ee294..852fccf4b 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -141,7 +141,7 @@ class RgTask: item.rg_track_peak = track_gain.peak item.store() self._log.debug( - "applied track gain {0} LU, peak {1} of FS", + "applied track gain {} LU, peak {} of FS", item.rg_track_gain, item.rg_track_peak, ) @@ -155,7 +155,7 @@ class RgTask: item.rg_album_peak = album_gain.peak item.store() self._log.debug( - "applied album gain {0} LU, peak {1} of FS", + "applied album gain {} LU, peak {} of FS", item.rg_album_gain, item.rg_album_peak, ) @@ -175,7 +175,7 @@ class RgTask: self._store_track_gain(item, self.track_gains[0]) if write: item.try_write() - self._log.debug("done analyzing {0}", item) + self._log.debug("done analyzing {}", item) def _store_album(self, write: bool): """Store track/album gains for all tracks of the task in the database.""" @@ -196,7 +196,7 @@ class RgTask: self._store_album_gain(item, self.album_gain) if write: item.try_write() - self._log.debug("done analyzing {0}", item) + self._log.debug("done analyzing {}", item) def store(self, write: bool): """Store computed gains for the items of this task in the database.""" @@ -230,7 +230,7 @@ class R128Task(RgTask): def _store_track_gain(self, item: Item, track_gain: Gain): item.r128_track_gain = track_gain.gain item.store() - self._log.debug("applied r128 track gain {0} LU", item.r128_track_gain) + self._log.debug("applied r128 track gain {} LU", item.r128_track_gain) def _store_album_gain(self, item: Item, album_gain: Gain): """ @@ -239,7 +239,7 @@ class R128Task(RgTask): """ item.r128_album_gain = album_gain.gain item.store() - self._log.debug("applied r128 album gain {0} LU", item.r128_album_gain) + self._log.debug("applied r128 album gain {} LU", item.r128_album_gain) AnyRgTask = TypeVar("AnyRgTask", bound=RgTask) @@ -428,7 +428,7 @@ class FfmpegBackend(Backend): # call ffmpeg self._log.debug(f"analyzing {item}") cmd = self._construct_cmd(item, peak_method) - self._log.debug("executing {0}", " ".join(map(displayable_path, cmd))) + self._log.debug("executing {}", " ".join(map(displayable_path, cmd))) output = call(cmd, self._log).stderr.splitlines() # parse output @@ -654,8 +654,8 @@ class CommandBackend(Backend): cmd = cmd + ["-d", str(int(target_level - 89))] cmd = cmd + [syspath(i.path) for i in items] - self._log.debug("analyzing {0} files", len(items)) - self._log.debug("executing {0}", " ".join(map(displayable_path, cmd))) + self._log.debug("analyzing {} files", len(items)) + self._log.debug("executing {}", " ".join(map(displayable_path, cmd))) output = call(cmd, self._log).stdout self._log.debug("analysis finished") return self.parse_tool_output( @@ -671,7 +671,7 @@ class CommandBackend(Backend): for line in text.split(b"\n")[1 : num_lines + 1]: parts = line.split(b"\t") if len(parts) != 6 or parts[0] == b"File": - self._log.debug("bad tool output: {0}", text) + self._log.debug("bad tool output: {}", text) raise ReplayGainError("mp3gain failed") # _file = parts[0] @@ -1096,7 +1096,7 @@ class AudioToolsBackend(Backend): ) self._log.debug( - "ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}", + "ReplayGain for track {} - {}: {2:.2f}, {3:.2f}", item.artist, item.title, rg_track_gain, @@ -1123,7 +1123,7 @@ class AudioToolsBackend(Backend): ) track_gains.append(Gain(gain=rg_track_gain, peak=rg_track_peak)) self._log.debug( - "ReplayGain for track {0}: {1:.2f}, {2:.2f}", + "ReplayGain for track {}: {.2f}, {.2f}", item, rg_track_gain, rg_track_peak, @@ -1136,7 +1136,7 @@ class AudioToolsBackend(Backend): rg_album_gain, task.target_level ) self._log.debug( - "ReplayGain for album {0}: {1:.2f}, {2:.2f}", + "ReplayGain for album {}: {.2f}, {.2f}", task.items[0].album, rg_album_gain, rg_album_peak, @@ -1336,19 +1336,19 @@ class ReplayGainPlugin(BeetsPlugin): items, nothing is done. """ if not force and not self.album_requires_gain(album): - self._log.info("Skipping album {0}", album) + self._log.info("Skipping album {}", album) return items_iter = iter(album.items()) use_r128 = self.should_use_r128(next(items_iter)) if any(use_r128 != self.should_use_r128(i) for i in items_iter): self._log.error( - "Cannot calculate gain for album {0} (incompatible formats)", + "Cannot calculate gain for album {} (incompatible formats)", album, ) return - self._log.info("analyzing {0}", album) + self._log.info("analyzing {}", album) discs: dict[int, list[Item]] = {} if self.config["per_disc"].get(bool): @@ -1372,7 +1372,7 @@ class ReplayGainPlugin(BeetsPlugin): callback=store_cb, ) except ReplayGainError as e: - self._log.info("ReplayGain error: {0}", e) + self._log.info("ReplayGain error: {}", e) except FatalReplayGainError as e: raise ui.UserError(f"Fatal replay gain error: {e}") @@ -1384,7 +1384,7 @@ class ReplayGainPlugin(BeetsPlugin): in the item, nothing is done. """ if not force and not self.track_requires_gain(item): - self._log.info("Skipping track {0}", item) + self._log.info("Skipping track {}", item) return use_r128 = self.should_use_r128(item) @@ -1401,7 +1401,7 @@ class ReplayGainPlugin(BeetsPlugin): callback=store_cb, ) except ReplayGainError as e: - self._log.info("ReplayGain error: {0}", e) + self._log.info("ReplayGain error: {}", e) except FatalReplayGainError as e: raise ui.UserError(f"Fatal replay gain error: {e}") diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py index 9489612e1..1cc21ad75 100644 --- a/beetsplug/rewrite.py +++ b/beetsplug/rewrite.py @@ -59,7 +59,7 @@ class RewritePlugin(BeetsPlugin): raise ui.UserError( f"invalid field name ({fieldname}) in rewriter" ) - self._log.debug("adding template field {0}", key) + self._log.debug("adding template field {}", key) pattern = re.compile(pattern.lower()) rules[fieldname].append((pattern, value)) if fieldname == "artist": diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 813effb5f..5f971ea38 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -60,7 +60,7 @@ class ScrubPlugin(BeetsPlugin): # Walk through matching files and remove tags. for item in lib.items(args): self._log.info( - "scrubbing: {0}", util.displayable_path(item.path) + "scrubbing: {}", util.displayable_path(item.path) ) self._scrub_item(item, opts.write) @@ -110,7 +110,7 @@ class ScrubPlugin(BeetsPlugin): f.save() except (OSError, mutagen.MutagenError) as exc: self._log.error( - "could not scrub {0}: {1}", util.displayable_path(path), exc + "could not scrub {}: {}", util.displayable_path(path), exc ) def _scrub_item(self, item, restore): @@ -124,7 +124,7 @@ class ScrubPlugin(BeetsPlugin): util.syspath(item.path), config["id3v23"].get(bool) ) except mediafile.UnreadableFileError as exc: - self._log.error("could not open file to scrub: {0}", exc) + self._log.error("could not open file to scrub: {}", exc) return images = mf.images @@ -144,12 +144,12 @@ class ScrubPlugin(BeetsPlugin): mf.images = images mf.save() except mediafile.UnreadableFileError as exc: - self._log.error("could not write tags: {0}", exc) + self._log.error("could not write tags: {}", exc) def import_task_files(self, session, task): """Automatically scrub imported files.""" for item in task.imported_items(): self._log.debug( - "auto-scrubbing {0}", util.displayable_path(item.path) + "auto-scrubbing {}", util.displayable_path(item.path) ) self._scrub_item(item, ui.should_write()) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 142571251..8203ce4ef 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -234,7 +234,7 @@ class SmartPlaylistPlugin(BeetsPlugin): for playlist in self._unmatched_playlists: n, (q, _), (a_q, _) = playlist if self.matches(model, q, a_q): - self._log.debug("{0} will be updated because of {1}", n, model) + self._log.debug("{} will be updated because of {}", n, model) self._matched_playlists.add(playlist) self.register_listener("cli_exit", self.update_playlists) @@ -243,12 +243,12 @@ class SmartPlaylistPlugin(BeetsPlugin): def update_playlists(self, lib, pretend=False): if pretend: self._log.info( - "Showing query results for {0} smart playlists...", + "Showing query results for {} smart playlists...", len(self._matched_playlists), ) else: self._log.info( - "Updating {0} smart playlists...", len(self._matched_playlists) + "Updating {} smart playlists...", len(self._matched_playlists) ) playlist_dir = self.config["playlist_dir"].as_filename() @@ -267,7 +267,7 @@ class SmartPlaylistPlugin(BeetsPlugin): if pretend: self._log.info("Results for playlist {}:", name) else: - self._log.info("Creating playlist {0}", name) + self._log.info("Creating playlist {}", name) items = [] if query: @@ -340,13 +340,11 @@ class SmartPlaylistPlugin(BeetsPlugin): if pretend: self._log.info( - "Displayed results for {0} playlists", + "Displayed results for {} playlists", len(self._matched_playlists), ) else: - self._log.info( - "{0} playlists updated", len(self._matched_playlists) - ) + self._log.info("{} playlists updated", len(self._matched_playlists)) class PlaylistItem: diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 0c96898b1..d1fa11a03 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -516,7 +516,7 @@ class SpotifyPlugin( if self.config["mode"].get() not in ["list", "open"]: self._log.warning( - "{0} is not a valid mode", self.config["mode"].get() + "{} is not a valid mode", self.config["mode"].get() ) return False diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index d966f3dbe..ef19d13e1 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -107,8 +107,8 @@ class SubsonicUpdate(BeetsPlugin): user = self.config["user"].as_str() auth = self.config["auth"].as_str() url = self.__format_url("startScan") - self._log.debug("URL is {0}", url) - self._log.debug("auth type is {0}", self.config["auth"]) + self._log.debug("URL is {}", url) + self._log.debug("auth type is {}", self.config["auth"]) if auth == "token": salt, token = self.__create_token() @@ -153,6 +153,6 @@ class SubsonicUpdate(BeetsPlugin): error_message = json["subsonic-response"]["error"]["message"] self._log.error(f"Error: {error_message}") else: - self._log.error("Error: {0}", json) + self._log.error("Error: {}", json) except Exception as error: self._log.error(f"Error: {error}") diff --git a/beetsplug/the.py b/beetsplug/the.py index 802b0a3db..664d4c01e 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -23,7 +23,7 @@ __version__ = "1.1" PATTERN_THE = "^the\\s" PATTERN_A = "^[a][n]?\\s" -FORMAT = "{0}, {1}" +FORMAT = "{}, {}" class ThePlugin(BeetsPlugin): @@ -38,7 +38,7 @@ class ThePlugin(BeetsPlugin): { "the": True, "a": True, - "format": "{0}, {1}", + "format": "{}, {}", "strip": False, "patterns": [], } @@ -50,11 +50,11 @@ class ThePlugin(BeetsPlugin): try: re.compile(p) except re.error: - self._log.error("invalid pattern: {0}", p) + self._log.error("invalid pattern: {}", p) else: if not (p.startswith("^") or p.endswith("$")): self._log.warning( - 'warning: "{0}" will not match string start/end', + 'warning: "{}" will not match string start/end', p, ) if self.config["a"]: @@ -94,7 +94,7 @@ class ThePlugin(BeetsPlugin): for p in self.patterns: r = self.unthe(text, p) if r != text: - self._log.debug('"{0}" -> "{1}"', text, r) + self._log.debug('"{}" -> "{}"', text, r) break return r else: diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index b9ab1fd89..e96f62b3d 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -109,16 +109,16 @@ class ThumbnailsPlugin(BeetsPlugin): uri_getter = GioURI() if not uri_getter.available: uri_getter = PathlibURI() - self._log.debug("using {0.name} to compute URIs", uri_getter) + self._log.debug("using {.name} to compute URIs", uri_getter) self.get_uri = uri_getter.uri return True def process_album(self, album): """Produce thumbnails for the album folder.""" - self._log.debug("generating thumbnail for {0}", album) + self._log.debug("generating thumbnail for {}", album) if not album.artpath: - self._log.info("album {0} has no art", album) + self._log.info("album {} has no art", album) return if self.config["dolphin"]: @@ -127,7 +127,7 @@ class ThumbnailsPlugin(BeetsPlugin): size = ArtResizer.shared.get_size(album.artpath) if not size: self._log.warning( - "problem getting the picture size for {0}", album.artpath + "problem getting the picture size for {}", album.artpath ) return @@ -137,9 +137,9 @@ class ThumbnailsPlugin(BeetsPlugin): wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR) if wrote: - self._log.info("wrote thumbnail for {0}", album) + self._log.info("wrote thumbnail for {}", album) else: - self._log.info("nothing to do for {0}", album) + self._log.info("nothing to do for {}", album) def make_cover_thumbnail(self, album, size, target_dir): """Make a thumbnail of given size for `album` and put it in @@ -154,16 +154,16 @@ class ThumbnailsPlugin(BeetsPlugin): ): if self.config["force"]: self._log.debug( - "found a suitable {1}x{1} thumbnail for {0}, " + "found a suitable {0}x{0} thumbnail for {1}, " "forcing regeneration", - album, size, + album, ) else: self._log.debug( - "{1}x{1} thumbnail for {0} exists and is recent enough", - album, + "{0}x{0} thumbnail for {1} exists and is recent enough", size, + album, ) return False resized = ArtResizer.shared.resize(size, album.artpath, target) @@ -192,7 +192,7 @@ class ThumbnailsPlugin(BeetsPlugin): ArtResizer.shared.write_metadata(image_path, metadata) except Exception: self._log.exception( - "could not write metadata to {0}", displayable_path(image_path) + "could not write metadata to {}", displayable_path(image_path) ) def make_dolphin_cover_thumbnail(self, album): @@ -204,7 +204,7 @@ class ThumbnailsPlugin(BeetsPlugin): f.write("[Desktop Entry]\n") f.write(f"Icon=./{artfile.decode('utf-8')}") f.close() - self._log.debug("Wrote file {0}", displayable_path(outfilename)) + self._log.debug("Wrote file {}", displayable_path(outfilename)) class URIGetter: diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 438fd5021..7b13cf016 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -474,7 +474,7 @@ class WebPlugin(BeetsPlugin): # Enable CORS if required. if self.config["cors"]: self._log.info( - "Enabling CORS with origin: {0}", self.config["cors"] + "Enabling CORS with origin: {}", self.config["cors"] ) from flask_cors import CORS diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 05e55bfcd..bce3b1a72 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -90,10 +90,10 @@ class ZeroPlugin(BeetsPlugin): Do some sanity checks then compile the regexes. """ if field not in MediaFile.fields(): - self._log.error("invalid field: {0}", field) + self._log.error("invalid field: {}", field) elif field in ("id", "path", "album_id"): self._log.warning( - "field '{0}' ignored, zeroing it would be dangerous", field + "field '{}' ignored, zeroing it would be dangerous", field ) else: try: @@ -137,7 +137,7 @@ class ZeroPlugin(BeetsPlugin): if match: fields_set = True - self._log.debug("{0}: {1} -> None", field, value) + self._log.debug("{}: {} -> None", field, value) tags[field] = None if self.config["update_database"]: item[field] = None diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 1dbe4a727..572431b45 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -56,21 +56,21 @@ class FtInTitlePluginFunctional(PluginTestCase): assert item["title"] == "Song 1" def test_functional_custom_format(self): - self._ft_set_config("feat. {0}") + self._ft_set_config("feat. {}") item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") self.run_command("ftintitle") item.load() assert item["artist"] == "Alice" assert item["title"] == "Song 1 feat. Bob" - self._ft_set_config("featuring {0}") + self._ft_set_config("featuring {}") item = self._ft_add_item("/", "Alice feat. Bob", "Song 1", "Alice") self.run_command("ftintitle") item.load() assert item["artist"] == "Alice" assert item["title"] == "Song 1 featuring Bob" - self._ft_set_config("with {0}") + self._ft_set_config("with {}") item = self._ft_add_item("/", "Alice feat Bob", "Song 1", "Alice") self.run_command("ftintitle") item.load() @@ -78,7 +78,7 @@ class FtInTitlePluginFunctional(PluginTestCase): assert item["title"] == "Song 1 with Bob" def test_functional_keep_in_artist(self): - self._ft_set_config("feat. {0}", keep_in_artist=True) + self._ft_set_config("feat. {}", keep_in_artist=True) item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") self.run_command("ftintitle") item.load() diff --git a/test/plugins/test_mpdstats.py b/test/plugins/test_mpdstats.py index dcaf196ef..6f5d3f3ce 100644 --- a/test/plugins/test_mpdstats.py +++ b/test/plugins/test_mpdstats.py @@ -77,7 +77,7 @@ class MPDStatsTest(PluginTestCase): except KeyboardInterrupt: pass - log.debug.assert_has_calls([call('unhandled status "{0}"', ANY)]) + log.debug.assert_has_calls([call('unhandled status "{}"', ANY)]) log.info.assert_has_calls( - [call("pause"), call("playing {0}", ANY), call("stop")] + [call("pause"), call("playing {}", ANY), call("stop")] ) diff --git a/test/test_logging.py b/test/test_logging.py index 74475ada1..87e4e49b6 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -42,7 +42,7 @@ class LoggingTest(unittest.TestCase): logger.addHandler(handler) logger.propagate = False - logger.warning("foo {0} {bar}", "oof", bar="baz") + logger.warning("foo {} {bar}", "oof", bar="baz") handler.flush() assert stream.getvalue(), "foo oof baz" From d6b6ac33875d0ea6f63493846072214f108149b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 22 Jul 2025 05:02:58 +0100 Subject: [PATCH 030/301] Replace logging f-strings with arguments --- beets/importer/stages.py | 2 +- beets/ui/commands.py | 2 +- beets/util/__init__.py | 7 ++++--- beets/util/artresizer.py | 2 +- beets/util/id_extractors.py | 3 ++- beetsplug/bpd/__init__.py | 4 ++-- beetsplug/deezer.py | 2 +- beetsplug/embedart.py | 4 ++-- beetsplug/embyupdate.py | 2 +- beetsplug/fromfilename.py | 6 +++--- beetsplug/ipfs.py | 2 +- beetsplug/lastgenre/__init__.py | 10 ++++------ beetsplug/listenbrainz.py | 10 ++++------ beetsplug/mbsubmit.py | 2 +- beetsplug/metasync/__init__.py | 4 ++-- beetsplug/metasync/itunes.py | 4 ++-- beetsplug/playlist.py | 2 +- beetsplug/replaygain.py | 11 ++++------- beetsplug/spotify.py | 18 +++++++++--------- beetsplug/subsonicupdate.py | 9 +++++---- beetsplug/thumbnails.py | 2 +- test/test_logging.py | 12 ++++++------ 22 files changed, 58 insertions(+), 62 deletions(-) diff --git a/beets/importer/stages.py b/beets/importer/stages.py index b68a68824..eb7b22ccf 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -341,7 +341,7 @@ def _resolve_duplicates(session: ImportSession, task: ImportTask): if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: - log.debug(f"found duplicates: {[o.id for o in found_duplicates]}") + log.debug("found duplicates: {}", [o.id for o in found_duplicates]) # Get the default action to follow from config. duplicate_action = config["import"]["duplicate_action"].as_choice( diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 6a4c23e11..64819d781 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1365,7 +1365,7 @@ def import_func(lib, opts, args: list[str]): for path in paths_from_logfiles: if not os.path.exists(syspath(normpath(path))): log.warning( - f"No such file or directory: {displayable_path(path)}" + "No such file or directory: {}", displayable_path(path) ) continue diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 7d2992ba2..3ac7ad6a9 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -221,11 +221,12 @@ def sorted_walk( # Get all the directories and files at this level. try: contents = os.listdir(syspath(bytes_path)) - except OSError as exc: + except OSError: if logger: logger.warning( - f"could not list directory {displayable_path(bytes_path)}:" - f" {exc.strerror}" + "could not list directory {}", + displayable_path(bytes_path), + exc_info=True, ) return dirs = [] diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index fa6d5d236..1e3b836ae 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -696,7 +696,7 @@ class ArtResizer: for backend_cls in BACKEND_CLASSES: try: self.local_method = backend_cls() - log.debug(f"artresizer: method is {self.local_method.NAME}") + log.debug("artresizer: method is {.local_method.NAME}", self) break except LocalBackendNotAvailableError: continue diff --git a/beets/util/id_extractors.py b/beets/util/id_extractors.py index 6cdb787d1..f66f1690f 100644 --- a/beets/util/id_extractors.py +++ b/beets/util/id_extractors.py @@ -58,7 +58,8 @@ def extract_release_id(source: str, id_: str) -> str | None: source_pattern = PATTERN_BY_SOURCE[source.lower()] except KeyError: log.debug( - f"Unknown source '{source}' for ID extraction. Returning id/url as-is." + "Unknown source '{}' for ID extraction. Returning id/url as-is.", + source, ) return id_ diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 31404c656..bd90f4d6b 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -1108,8 +1108,8 @@ class Server(BaseServer): self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) - log.info(f"Server ready and listening on {host}:{port}") - log.debug(f"Listening for control signals on {host}:{ctrl_port}") + log.info("Server ready and listening on {}:{}", host, port) + log.debug("Listening for control signals on {}:{}", host, ctrl_port) def run(self): self.player.run() diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 1f777c4aa..def2b37a2 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -241,7 +241,7 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): query = self._construct_search_query( query_string=query_string, filters=filters ) - self._log.debug(f"Searching {self.data_source} for '{query}'") + self._log.debug("Searching {.data_source} for '{}'", self, query) try: response = requests.get( f"{self.search_url}{query_type}", diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index c684c4d17..1a59e4f9c 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -136,7 +136,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): response = requests.get(opts.url, timeout=5) response.raise_for_status() except requests.exceptions.RequestException as e: - self._log.error(f"{e}") + self._log.error("{}", e) return extension = guess_extension(response.headers["Content-Type"]) if extension is None: @@ -148,7 +148,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): with open(tempimg, "wb") as f: f.write(response.content) except Exception as e: - self._log.error(f"Unable to save image: {e}") + self._log.error("Unable to save image: {}", e) return items = lib.items(args) # Confirm with user. diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py index 62770934b..25f3ed8b3 100644 --- a/beetsplug/embyupdate.py +++ b/beetsplug/embyupdate.py @@ -184,7 +184,7 @@ class EmbyUpdate(BeetsPlugin): # Get user information from the Emby API. user = get_user(host, port, username) if not user: - self._log.warning(f"User {username} could not be found.") + self._log.warning("User {} could not be found.", username) return userid = user[0]["Id"] diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 4c6431061..5e8b338c7 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -112,7 +112,7 @@ def apply_matches(d, log): for item in d: if not item.artist: item.artist = artist - log.info(f"Artist replaced with: {item.artist}") + log.info("Artist replaced with: {.artist}", item) # No artist field: remaining field is the title. else: @@ -122,11 +122,11 @@ def apply_matches(d, log): for item in d: if bad_title(item.title): item.title = str(d[item][title_field]) - log.info(f"Title replaced with: {item.title}") + log.info("Title replaced with: {.title}", item) if "track" in d[item] and item.track == 0: item.track = int(d[item]["track"]) - log.info(f"Track replaced with: {item.track}") + log.info("Track replaced with: {.track}", item) # Plugin structure and hook into import process. diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index e7e597d26..8b1b77a3e 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -232,7 +232,7 @@ class IPFSPlugin(BeetsPlugin): try: util.command_output(cmd) except (OSError, subprocess.CalledProcessError): - self._log.error(f"Could not import {_hash}") + self._log.error("Could not import {}", _hash) return False # add all albums from remotes into a combined library diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index b473d8bea..dacd72f93 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -277,7 +277,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): genre = self._genre_cache[key] if self.config["extended_debug"]: - self._log.debug(f"last.fm (unfiltered) {entity} tags: {genre}") + self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre) return genre def fetch_album_genre(self, obj): @@ -327,8 +327,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): self, old: list[str], new: list[str] ) -> list[str]: """Combine old and new genres and process via _resolve_genres.""" - self._log.debug(f"raw last.fm tags: {new}") - self._log.debug(f"existing genres taken into account: {old}") + self._log.debug("raw last.fm tags: {}", new) + self._log.debug("existing genres taken into account: {}", old) combined = old + new return self._resolve_genres(combined) @@ -583,9 +583,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): item = task.item item.genre, src = self._get_genre(item) self._log.debug( - 'genre for track "{0.title}" ({1}): {0.genre}', - item, - src, + 'genre for track "{0.title}" ({1}): {0.genre}', item, src ) item.store() diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index 72639ecae..f7b1389ef 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -42,7 +42,7 @@ class ListenBrainzPlugin(BeetsPlugin): unknown_total = 0 ls = self.get_listens() tracks = self.get_tracks_from_listens(ls) - log.info(f"Found {len(ls)} listens") + log.info("Found {} listens", len(ls)) if tracks: found, unknown = process_tracks(lib, tracks, log) found_total += found @@ -63,7 +63,7 @@ class ListenBrainzPlugin(BeetsPlugin): response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: - self._log.debug(f"Invalid Search Error: {e}") + self._log.debug("Invalid Search Error: {}", e) return None def get_listens(self, min_ts=None, max_ts=None, count=None): @@ -156,7 +156,7 @@ class ListenBrainzPlugin(BeetsPlugin): playlist_info = playlist.get("playlist") if playlist_info.get("creator") == "listenbrainz": title = playlist_info.get("title") - self._log.debug(f"Playlist title: {title}") + self._log.debug("Playlist title: {}", title) playlist_type = ( "Exploration" if "Exploration" in title else "Jams" ) @@ -179,9 +179,7 @@ class ListenBrainzPlugin(BeetsPlugin): listenbrainz_playlists, key=lambda x: x["date"], reverse=True ) for playlist in listenbrainz_playlists: - self._log.debug( - f"Playlist: {playlist['type']} - {playlist['date']}" - ) + self._log.debug("Playlist: {0[type]} - {0[date]}", playlist) return listenbrainz_playlists def get_playlist(self, identifier): diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index e23c0d610..93e88dc9e 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -73,7 +73,7 @@ class MBSubmitPlugin(BeetsPlugin): subprocess.Popen([picard_path] + paths) self._log.info("launched picard from\n{}", picard_path) except OSError as exc: - self._log.error(f"Could not open picard, got error:\n{exc}") + self._log.error("Could not open picard, got error:\n{}", exc) def print_tracks(self, session, task): for i in sorted(task.items, key=lambda i: i.track): diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index 2c2b8cd89..d4e31851e 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -117,13 +117,13 @@ class MetaSyncPlugin(BeetsPlugin): try: cls = META_SOURCES[player] except KeyError: - self._log.error(f"Unknown metadata source '{player}'") + self._log.error("Unknown metadata source '{}'", player) try: meta_source_instances[player] = cls(self.config, self._log) except (ImportError, ConfigValueError) as e: self._log.error( - f"Failed to instantiate metadata source {player!r}: {e}" + "Failed to instantiate metadata source {!r}: {}", player, e ) # Avoid needlessly iterating over items diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py index 05e2039a4..6f441ef8b 100644 --- a/beetsplug/metasync/itunes.py +++ b/beetsplug/metasync/itunes.py @@ -76,7 +76,7 @@ class Itunes(MetaSource): library_path = config["itunes"]["library"].as_filename() try: - self._log.debug(f"loading iTunes library from {library_path}") + self._log.debug("loading iTunes library from {}", library_path) with create_temporary_copy(library_path) as library_copy: with open(library_copy, "rb") as library_copy_f: raw_library = plistlib.load(library_copy_f) @@ -104,7 +104,7 @@ class Itunes(MetaSource): result = self.collection.get(util.bytestring_path(item.path).lower()) if not result: - self._log.warning(f"no iTunes match found for {item}") + self._log.warning("no iTunes match found for {}", item) return item.itunes_rating = result.get("Rating") diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 45d99ad80..01625c281 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -123,7 +123,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): def cli_exit(self, lib): for playlist in self.find_playlists(): - self._log.info(f"Updating playlist: {playlist}") + self._log.info("Updating playlist: {}", playlist) base_dir = beets.util.bytestring_path( self.relative_to if self.relative_to diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 852fccf4b..39bbf2e17 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -380,10 +380,7 @@ class FfmpegBackend(Backend): album_gain = target_level_lufs - album_gain self._log.debug( - "{}: gain {} LU, peak {}", - task.album, - album_gain, - album_peak, + "{}: gain {} LU, peak {}", task.album, album_gain, album_peak ) task.album_gain = Gain(album_gain, album_peak) @@ -426,7 +423,7 @@ class FfmpegBackend(Backend): target_level_lufs = db_to_lufs(target_level) # call ffmpeg - self._log.debug(f"analyzing {item}") + self._log.debug("analyzing {}", item) cmd = self._construct_cmd(item, peak_method) self._log.debug("executing {}", " ".join(map(displayable_path, cmd))) output = call(cmd, self._log).stderr.splitlines() @@ -496,10 +493,10 @@ class FfmpegBackend(Backend): if self._parse_float(b"M: " + line[1]) >= gating_threshold: n_blocks += 1 self._log.debug( - f"{item}: {n_blocks} blocks over {gating_threshold} LUFS" + "{}: {} blocks over {} LUFS", item, n_blocks, gating_threshold ) - self._log.debug(f"{item}: gain {gain} LU, peak {peak}") + self._log.debug("{}: gain {} LU, peak {}", item, gain, peak) return Gain(gain, peak), n_blocks diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index d1fa11a03..897899301 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -228,16 +228,16 @@ class SpotifyPlugin( self._log.error("ReadTimeout.") raise APIError("Request timed out.") except requests.exceptions.ConnectionError as e: - self._log.error(f"Network error: {e}") + self._log.error("Network error: {}", e) raise APIError("Network error.") except requests.exceptions.RequestException as e: if e.response is None: - self._log.error(f"Request failed: {e}") + self._log.error("Request failed: {}", e) raise APIError("Request failed.") if e.response.status_code == 401: self._log.debug( - f"{self.data_source} access token has expired. " - "Reauthenticating." + "{.data_source} access token has expired. Reauthenticating.", + self, ) self._authenticate() return self._handle_response( @@ -256,7 +256,7 @@ class SpotifyPlugin( "Retry-After", DEFAULT_WAITING_TIME ) self._log.debug( - f"Too many API requests. Retrying after {seconds} seconds." + "Too many API requests. Retrying after {} seconds.", seconds ) time.sleep(int(seconds) + 1) return self._handle_response( @@ -277,7 +277,7 @@ class SpotifyPlugin( f"URL:\n{url}\nparams:\n{params}" ) else: - self._log.error(f"Request failed. Error: {e}") + self._log.error("Request failed. Error: {}", e) raise APIError("Request failed.") def album_for_id(self, album_id: str) -> AlbumInfo | None: @@ -439,7 +439,7 @@ class SpotifyPlugin( filters=filters, query_string=query_string ) - self._log.debug(f"Searching {self.data_source} for '{query}'") + self._log.debug("Searching {.data_source} for '{}'", self, query) try: response = self._handle_response( "get", @@ -648,7 +648,7 @@ class SpotifyPlugin( spotify_ids = [track_data["id"] for track_data in results] if self.config["mode"].get() == "open": self._log.info( - f"Attempting to open {self.data_source} with playlist" + "Attempting to open {.data_source} with playlist", self ) spotify_url = ( f"spotify:trackset:Playlist:{','.join(spotify_ids)}" @@ -659,7 +659,7 @@ class SpotifyPlugin( print(f"{self.open_track_url}{spotify_id}") else: self._log.warning( - f"No {self.data_source} tracks found from beets query" + "No {.data_source} tracks found from beets query", self ) def _fetch_info(self, items, write, force): diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index ef19d13e1..683574aaa 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -145,14 +145,15 @@ class SubsonicUpdate(BeetsPlugin): and json["subsonic-response"]["status"] == "ok" ): count = json["subsonic-response"]["scanStatus"]["count"] - self._log.info(f"Updating Subsonic; scanning {count} tracks") + self._log.info("Updating Subsonic; scanning {} tracks", count) elif ( response.status_code == 200 and json["subsonic-response"]["status"] == "failed" ): - error_message = json["subsonic-response"]["error"]["message"] - self._log.error(f"Error: {error_message}") + self._log.error( + "Error: {[subsonic-response][error][message]}", json + ) else: self._log.error("Error: {}", json) except Exception as error: - self._log.error(f"Error: {error}") + self._log.error("Error: {}", error) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index e96f62b3d..e5094a87d 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -104,7 +104,7 @@ class ThumbnailsPlugin(BeetsPlugin): f"Thumbnails: ArtResizer backend {ArtResizer.shared.method}" f" unexpectedly cannot write image metadata." ) - self._log.debug(f"using {ArtResizer.shared.method} to write metadata") + self._log.debug("using {.shared.method} to write metadata", ArtResizer) uri_getter = GioURI() if not uri_getter.available: diff --git a/test/test_logging.py b/test/test_logging.py index 87e4e49b6..aee0bd61b 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -58,9 +58,9 @@ class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): self.register_listener("dummy_event", self.listener) def log_all(self, name): - self._log.debug(f"debug {name}") - self._log.info(f"info {name}") - self._log.warning(f"warning {name}") + self._log.debug("debug {}", name) + self._log.info("info {}", name) + self._log.warning("warning {}", name) def commands(self): cmd = ui.Subcommand("dummy") @@ -172,9 +172,9 @@ class ConcurrentEventsTest(AsIsImporterMixin, ImportTestCase): self.t1_step = self.t2_step = 0 def log_all(self, name): - self._log.debug(f"debug {name}") - self._log.info(f"info {name}") - self._log.warning(f"warning {name}") + self._log.debug("debug {}", name) + self._log.info("info {}", name) + self._log.warning("warning {}", name) def listener1(self): try: From e334e81d40b3ed7fca0bcd7c728b0db7c9011b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 7 Aug 2025 13:26:32 +0100 Subject: [PATCH 031/301] Use item.filepath instead of displayable_path in logging --- beets/art.py | 8 ++------ beets/importer/tasks.py | 20 +++++++------------- beets/library/models.py | 5 +---- beets/ui/commands.py | 12 +++++------- beetsplug/chroma.py | 18 +++++------------- beetsplug/convert.py | 21 ++++++++------------- beetsplug/duplicates.py | 18 +++++++----------- beetsplug/fetchart.py | 4 +--- beetsplug/ftintitle.py | 3 +-- beetsplug/importadded.py | 8 ++++---- beetsplug/keyfinder.py | 4 +--- beetsplug/mpdstats.py | 2 +- beetsplug/scrub.py | 8 ++------ 13 files changed, 45 insertions(+), 86 deletions(-) diff --git a/beets/art.py b/beets/art.py index c829787c2..73e875202 100644 --- a/beets/art.py +++ b/beets/art.py @@ -38,11 +38,7 @@ def get_art(log, item): try: mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: - log.warning( - "Could not extract art from {}: {}", - displayable_path(item.path), - exc, - ) + log.warning("Could not extract art from {}: {}", item.filepath, exc) return return mf.art @@ -189,7 +185,7 @@ def extract(log, outpath, item): # Add an extension to the filename. ext = mediafile.image_extension(art) if not ext: - log.warning("Unknown image type in {}.", displayable_path(item.path)) + log.warning("Unknown image type in {}.", item.filepath) return outpath += bytestring_path(f".{ext}") diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 2c653c4ce..7fa11c972 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -271,9 +271,7 @@ class ImportTask(BaseImportTask): for item in duplicate_items: item.remove() if lib.directory in util.ancestry(item.path): - log.debug( - "deleting duplicate {}", util.displayable_path(item.path) - ) + log.debug("deleting duplicate {}", item.filepath) util.remove(item.path) util.prune_dirs(os.path.dirname(item.path), lib.directory) @@ -559,7 +557,7 @@ class ImportTask(BaseImportTask): noun, new_obj.id, overwritten_fields, - util.displayable_path(new_obj.path), + new_obj.filepath, ) for key in overwritten_fields: del existing_fields[key] @@ -581,14 +579,14 @@ class ImportTask(BaseImportTask): "Reimported album {}. Preserving attribute ['added']. " "Path: {}", self.album.id, - util.displayable_path(self.album.path), + self.album.filepath, ) log.debug( "Reimported album {}. Preserving flexible attributes {}. " "Path: {}", self.album.id, list(album_fields.keys()), - util.displayable_path(self.album.path), + self.album.filepath, ) for item in self.imported_items(): @@ -600,7 +598,7 @@ class ImportTask(BaseImportTask): "Reimported item {}. Preserving attribute ['added']. " "Path: {}", item.id, - util.displayable_path(item.path), + item.filepath, ) item_fields = _reduce_and_log( item, dup_item._values_flex, REIMPORT_FRESH_FIELDS_ITEM @@ -611,7 +609,7 @@ class ImportTask(BaseImportTask): "Path: {}", item.id, list(item_fields.keys()), - util.displayable_path(item.path), + item.filepath, ) item.store() @@ -621,11 +619,7 @@ class ImportTask(BaseImportTask): """ for item in self.imported_items(): for dup_item in self.replaced_items[item]: - log.debug( - "Replacing item {}: {}", - dup_item.id, - util.displayable_path(item.path), - ) + log.debug("Replacing item {}: {}", dup_item.id, item.filepath) dup_item.remove() log.debug( "{} of {} items replaced", diff --git a/beets/library/models.py b/beets/library/models.py index 3c93d0cb7..cd7bc1e92 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -1012,10 +1012,7 @@ class Item(LibModel): if move: # Check whether this file is inside the library directory. if self._db and self._db.directory in util.ancestry(self.path): - log.debug( - "moving {} to synchronize path", - util.displayable_path(self.path), - ) + log.debug("moving {} to synchronize path", self.filepath) self.move(with_album=with_album) self.store() diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 64819d781..1c92186d2 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1640,7 +1640,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): if item.current_mtime() <= item.mtime: log.debug( "skipping {} because mtime is up to date ({})", - displayable_path(item.path), + item.filepath, item.mtime, ) continue @@ -1649,9 +1649,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): try: item.read() except library.ReadError as exc: - log.error( - "error reading {}: {}", displayable_path(item.path), exc - ) + log.error("error reading {}: {}", item.filepath, exc) continue # Special-case album artist when it matches track artist. (Hacky @@ -2175,7 +2173,7 @@ def move_items( ) for obj in objs: - log.debug("moving: {}", util.displayable_path(obj.path)) + log.debug("moving: {}", obj.filepath) if export: # Copy without affecting the database. @@ -2258,14 +2256,14 @@ def write_items(lib, query, pretend, force): for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): - log.info("missing file: {}", util.displayable_path(item.path)) + log.info("missing file: {}", item.filepath) continue # Get an Item object reflecting the "clean" (on-disk) state. try: clean_item = library.Item.from_path(item.path) except library.ReadError as exc: - log.error("error reading {}: {}", displayable_path(item.path), exc) + log.error("error reading {}: {}", item.filepath, exc) continue # Check for and display changes. diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 1e04d51f5..43f556f05 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -343,28 +343,20 @@ def fingerprint_item(log, item, write=False): """ # Get a fingerprint and length for this track. if not item.length: - log.info("{}: no duration available", util.displayable_path(item.path)) + log.info("{}: no duration available", item.filepath) elif item.acoustid_fingerprint: if write: - log.info( - "{}: fingerprint exists, skipping", - util.displayable_path(item.path), - ) + log.info("{}: fingerprint exists, skipping", item.filepath) else: - log.info( - "{}: using existing fingerprint", - util.displayable_path(item.path), - ) + log.info("{}: using existing fingerprint", item.filepath) return item.acoustid_fingerprint else: - log.info("{}: fingerprinting", util.displayable_path(item.path)) + log.info("{}: fingerprinting", item.filepath) try: _, fp = acoustid.fingerprint_file(util.syspath(item.path)) item.acoustid_fingerprint = fp.decode() if write: - log.info( - "{}: writing fingerprint", util.displayable_path(item.path) - ) + log.info("{}: writing fingerprint", item.filepath) item.try_write() if item._db: item.store() diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 93b66d976..25c3a6f0c 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -388,8 +388,7 @@ class ConvertPlugin(BeetsPlugin): if os.path.exists(util.syspath(dest)): self._log.info( - "Skipping {} (target file exists)", - util.displayable_path(item.path), + "Skipping {} (target file exists)", item.filepath ) continue @@ -397,7 +396,7 @@ class ConvertPlugin(BeetsPlugin): if pretend: self._log.info( "mv {} {}", - util.displayable_path(item.path), + item.filepath, util.displayable_path(original), ) else: @@ -431,9 +430,7 @@ class ConvertPlugin(BeetsPlugin): else ("Linking" if link else "Copying") ) - self._log.info( - "{} {}", msg, util.displayable_path(item.path) - ) + self._log.info("{} {}", msg, item.filepath) if hardlink: util.hardlink(original, converted) @@ -464,8 +461,7 @@ class ConvertPlugin(BeetsPlugin): if album and album.artpath: maxwidth = self._get_art_resize(album.artpath) self._log.debug( - "embedding album art from {}", - util.displayable_path(album.artpath), + "embedding album art from {}", album.art_filepath ) art.embed_item( self._log, @@ -523,8 +519,7 @@ class ConvertPlugin(BeetsPlugin): if os.path.exists(util.syspath(dest)): self._log.info( - "Skipping {} (target file exists)", - util.displayable_path(album.artpath), + "Skipping {} (target file exists)", album.art_filepath ) return @@ -535,7 +530,7 @@ class ConvertPlugin(BeetsPlugin): if maxwidth is not None: self._log.info( "Resizing cover art from {} to {}", - util.displayable_path(album.artpath), + album.art_filepath, util.displayable_path(dest), ) if not pretend: @@ -547,7 +542,7 @@ class ConvertPlugin(BeetsPlugin): self._log.info( "{} {} {}", msg, - util.displayable_path(album.artpath), + album.art_filepath, util.displayable_path(dest), ) else: @@ -560,7 +555,7 @@ class ConvertPlugin(BeetsPlugin): self._log.info( "{} cover art from {} to {}", msg, - util.displayable_path(album.artpath), + album.art_filepath, util.displayable_path(dest), ) if hardlink: diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index c16348c17..03714ab81 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -256,7 +256,7 @@ class DuplicatesPlugin(BeetsPlugin): self._log.debug( "key {} on item {} not cached:computing checksum", key, - displayable_path(item.path), + item.filepath, ) try: checksum = command_output(args).stdout @@ -266,16 +266,12 @@ class DuplicatesPlugin(BeetsPlugin): "computed checksum for {} using {}", item.title, key ) except subprocess.CalledProcessError as e: - self._log.debug( - "failed to checksum {}: {}", - displayable_path(item.path), - e, - ) + self._log.debug("failed to checksum {}: {}", item.filepath, e) else: self._log.debug( "key {} on item {} cached:not computing checksum", key, - displayable_path(item.path), + item.filepath, ) return key, checksum @@ -295,13 +291,13 @@ class DuplicatesPlugin(BeetsPlugin): self._log.debug( "some keys {} on item {} are null or empty: skipping", keys, - displayable_path(obj.path), + obj.filepath, ) elif not strict and not len(values): self._log.debug( "all keys {} on item {} are null or empty: skipping", keys, - displayable_path(obj.path), + obj.filepath, ) else: key = tuple(values) @@ -363,7 +359,7 @@ class DuplicatesPlugin(BeetsPlugin): "or empty: setting from item {}", f, displayable_path(objs[0].path), - displayable_path(o.path), + o.filepath, ) setattr(objs[0], f, value) objs[0].store() @@ -387,7 +383,7 @@ class DuplicatesPlugin(BeetsPlugin): " merging from {} into {}", missing, objs[0], - displayable_path(o.path), + o.filepath, displayable_path(missing.destination()), ) missing.move(operation=MoveOperation.COPY) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 38a0996cc..156dd6132 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1541,9 +1541,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): out = candidate assert out.path is not None # help mypy self._log.debug( - "using {.LOC} image {}", - source, - util.displayable_path(out.path), + "using {.LOC} image {}", source, out.path ) break # Remove temporary files for invalid candidates. diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 5d5db2e92..6571c9c90 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -20,7 +20,6 @@ import re from typing import TYPE_CHECKING from beets import plugins, ui -from beets.util import displayable_path if TYPE_CHECKING: from beets.importer import ImportSession, ImportTask @@ -195,7 +194,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not featured: return False - self._log.info("{}", displayable_path(item.path)) + self._log.info("{}", item.filepath) # Attempt to find the featured artist. feat_part = find_feat_part(artist, albumartist) diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index 3dcfe1cc2..b6244c518 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -105,7 +105,7 @@ class ImportAddedPlugin(BeetsPlugin): self._log.debug( "Album '{}' is reimported, skipping import of " "added dates for the album and its items.", - util.displayable_path(album.path), + album.filepath, ) return @@ -130,7 +130,7 @@ class ImportAddedPlugin(BeetsPlugin): if self.reimported_item(item): self._log.debug( "Item '{}' is reimported, skipping import of added date.", - util.displayable_path(item.path), + item.filepath, ) return mtime = self.item_mtime.pop(item.path, None) @@ -140,7 +140,7 @@ class ImportAddedPlugin(BeetsPlugin): self.write_item_mtime(item, mtime) self._log.debug( "Import of item '{}', selected item.added={}", - util.displayable_path(item.path), + item.filepath, item.added, ) item.store() @@ -154,6 +154,6 @@ class ImportAddedPlugin(BeetsPlugin): self.write_item_mtime(item, item.added) self._log.debug( "Write of item '{}', selected item.added={}", - util.displayable_path(item.path), + item.filepath, item.added, ) diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index e95e999d0..43f04e263 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -84,9 +84,7 @@ class KeyFinderPlugin(BeetsPlugin): item["initial_key"] = key self._log.info( - "added computed initial key {} for {}", - key, - util.displayable_path(item.path), + "added computed initial key {} for {}", key, item.filepath ) if write: diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index a4e9fb843..f4fe21a13 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -191,7 +191,7 @@ class MPDStats: "updated: {} = {} [{}]", attribute, item[attribute], - displayable_path(item.path), + item.filepath, ) def update_rating(self, item, skipped): diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 5f971ea38..66d5aeea3 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -59,9 +59,7 @@ class ScrubPlugin(BeetsPlugin): def scrub_func(lib, opts, args): # Walk through matching files and remove tags. for item in lib.items(args): - self._log.info( - "scrubbing: {}", util.displayable_path(item.path) - ) + self._log.info("scrubbing: {}", item.filepath) self._scrub_item(item, opts.write) scrub_cmd = ui.Subcommand("scrub", help="clean audio tags") @@ -149,7 +147,5 @@ class ScrubPlugin(BeetsPlugin): def import_task_files(self, session, task): """Automatically scrub imported files.""" for item in task.imported_items(): - self._log.debug( - "auto-scrubbing {}", util.displayable_path(item.path) - ) + self._log.debug("auto-scrubbing {}", item.filepath) self._scrub_item(item, ui.should_write()) From b3d434f58f449050d54aa2042c53210bc1318eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 30 Aug 2025 18:33:25 +0100 Subject: [PATCH 032/301] Delegate attribute access to logging --- beets/art.py | 6 +++--- beets/autotag/match.py | 2 +- beets/importer/stages.py | 5 +---- beets/importer/tasks.py | 41 ++++++++++++++++-------------------- beets/library/models.py | 2 +- beets/ui/commands.py | 24 ++++++++++----------- beets/util/__init__.py | 2 +- beets/util/artresizer.py | 8 +++---- beetsplug/acousticbrainz.py | 2 +- beetsplug/badfiles.py | 7 +++---- beetsplug/bpd/__init__.py | 4 ++-- beetsplug/bpsync.py | 16 +++++++------- beetsplug/chroma.py | 10 ++++----- beetsplug/convert.py | 29 +++++++++++++------------ beetsplug/deezer.py | 8 +++---- beetsplug/duplicates.py | 28 ++++++++++++------------- beetsplug/fetchart.py | 42 ++++++++++++++++++------------------- beetsplug/ftintitle.py | 8 +++---- beetsplug/hook.py | 2 +- beetsplug/importadded.py | 23 +++++++++----------- beetsplug/ipfs.py | 2 +- beetsplug/keyfinder.py | 4 ++-- beetsplug/lastimport.py | 7 +++---- beetsplug/lyrics.py | 17 ++++++++------- beetsplug/missing.py | 6 +++--- beetsplug/mpdstats.py | 8 +++---- beetsplug/mpdupdate.py | 4 ++-- beetsplug/parentwork.py | 3 +-- beetsplug/playlist.py | 2 +- beetsplug/replaygain.py | 25 ++++++++++------------ beetsplug/scrub.py | 4 ++-- beetsplug/spotify.py | 24 ++++++++++----------- beetsplug/subsonicupdate.py | 2 +- beetsplug/thumbnails.py | 2 +- pyproject.toml | 1 + 35 files changed, 179 insertions(+), 201 deletions(-) diff --git a/beets/art.py b/beets/art.py index 73e875202..656c303ce 100644 --- a/beets/art.py +++ b/beets/art.py @@ -38,7 +38,7 @@ def get_art(log, item): try: mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: - log.warning("Could not extract art from {}: {}", item.filepath, exc) + log.warning("Could not extract art from {.filepath}: {}", item, exc) return return mf.art @@ -88,7 +88,7 @@ def embed_item( # Make sure the image kind is safe (some formats only support PNG # and JPEG). if image.mime_type not in ("image/jpeg", "image/png"): - log.info("not embedding image of unsupported type: {}", image.mime_type) + log.info("not embedding image of unsupported type: {.mime_type}", image) return item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23) @@ -185,7 +185,7 @@ def extract(log, outpath, item): # Add an extension to the filename. ext = mediafile.image_extension(art) if not ext: - log.warning("Unknown image type in {}.", item.filepath) + log.warning("Unknown image type in {.filepath}.", item) return outpath += bytestring_path(f".{ext}") diff --git a/beets/autotag/match.py b/beets/autotag/match.py index dd992facc..8fec844a6 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -197,7 +197,7 @@ def _add_candidate( checking the track count, ordering the items, checking for duplicates, and calculating the distance. """ - log.debug("Candidate: {} - {} ({})", info.artist, info.album, info.album_id) + log.debug("Candidate: {0.artist} - {0.album} ({0.album_id})", info) # Discard albums with zero tracks. if not info.tracks: diff --git a/beets/importer/stages.py b/beets/importer/stages.py index eb7b22ccf..d99b742a2 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -82,10 +82,7 @@ def query_tasks(session: ImportSession): # Search for albums. for album in session.lib.albums(session.query): log.debug( - "yielding album {}: {} - {}", - album.id, - album.albumartist, - album.album, + "yielding album {0.id}: {0.albumartist} - {0.album}", album ) items = list(album.items()) _freshen_items(items) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 7fa11c972..b4d566032 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -271,7 +271,7 @@ class ImportTask(BaseImportTask): for item in duplicate_items: item.remove() if lib.directory in util.ancestry(item.path): - log.debug("deleting duplicate {}", item.filepath) + log.debug("deleting duplicate {.filepath}", item) util.remove(item.path) util.prune_dirs(os.path.dirname(item.path), lib.directory) @@ -552,12 +552,11 @@ class ImportTask(BaseImportTask): ] if overwritten_fields: log.debug( - "Reimported {} {}. Not preserving flexible attributes {}. " - "Path: {}", + "Reimported {0} {1.id}. Not preserving flexible attributes {2}. " + "Path: {1.filepath}", noun, - new_obj.id, + new_obj, overwritten_fields, - new_obj.filepath, ) for key in overwritten_fields: del existing_fields[key] @@ -576,17 +575,15 @@ class ImportTask(BaseImportTask): self.album.artpath = replaced_album.artpath self.album.store() log.debug( - "Reimported album {}. Preserving attribute ['added']. " - "Path: {}", - self.album.id, - self.album.filepath, + "Reimported album {0.album.id}. Preserving attribute ['added']. " + "Path: {0.album.filepath}", + self, ) log.debug( - "Reimported album {}. Preserving flexible attributes {}. " - "Path: {}", - self.album.id, + "Reimported album {0.album.id}. Preserving flexible" + " attributes {1}. Path: {0.album.filepath}", + self, list(album_fields.keys()), - self.album.filepath, ) for item in self.imported_items(): @@ -595,21 +592,19 @@ class ImportTask(BaseImportTask): if dup_item.added and dup_item.added != item.added: item.added = dup_item.added log.debug( - "Reimported item {}. Preserving attribute ['added']. " - "Path: {}", - item.id, - item.filepath, + "Reimported item {0.id}. Preserving attribute ['added']. " + "Path: {0.filepath}", + item, ) item_fields = _reduce_and_log( item, dup_item._values_flex, REIMPORT_FRESH_FIELDS_ITEM ) item.update(item_fields) log.debug( - "Reimported item {}. Preserving flexible attributes {}. " - "Path: {}", - item.id, + "Reimported item {0.id}. Preserving flexible attributes {1}. " + "Path: {0.filepath}", + item, list(item_fields.keys()), - item.filepath, ) item.store() @@ -619,7 +614,7 @@ class ImportTask(BaseImportTask): """ for item in self.imported_items(): for dup_item in self.replaced_items[item]: - log.debug("Replacing item {}: {}", dup_item.id, item.filepath) + log.debug("Replacing item {.id}: {.filepath}", dup_item, item) dup_item.remove() log.debug( "{} of {} items replaced", @@ -1067,7 +1062,7 @@ class ImportTaskFactory: # Now read albums from the extracted directory. self.toppath = archive_task.toppath - log.debug("Archive extracted to: {}", self.toppath) + log.debug("Archive extracted to: {.toppath}", self) return archive_task def read_item(self, path: util.PathBytes): diff --git a/beets/library/models.py b/beets/library/models.py index cd7bc1e92..cbee2a411 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -1012,7 +1012,7 @@ class Item(LibModel): if move: # Check whether this file is inside the library directory. if self._db and self._db.directory in util.ancestry(self.path): - log.debug("moving {} to synchronize path", self.filepath) + log.debug("moving {.filepath} to synchronize path", self) self.move(with_album=with_album) self.store() diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 1c92186d2..911a5cfd3 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1280,11 +1280,10 @@ class TerminalImportSession(importer.ImportSession): dup_choices = [c for c in all_choices if c.short == short] for c in dup_choices[1:]: log.warning( - "Prompt choice '{}' removed due to conflict " - "with '{}' (short letter: '{}')", - c.long, - dup_choices[0].long, - c.short, + "Prompt choice '{0.long}' removed due to conflict " + "with '{1[0].long}' (short letter: '{0.short}')", + c, + dup_choices, ) extra_choices.remove(c) @@ -1639,9 +1638,8 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): # Did the item change since last checked? if item.current_mtime() <= item.mtime: log.debug( - "skipping {} because mtime is up to date ({})", - item.filepath, - item.mtime, + "skipping {0.filepath} because mtime is up to date ({0.mtime})", + item, ) continue @@ -1649,7 +1647,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): try: item.read() except library.ReadError as exc: - log.error("error reading {}: {}", item.filepath, exc) + log.error("error reading {.filepath}: {}", item, exc) continue # Special-case album artist when it matches track artist. (Hacky @@ -1882,7 +1880,7 @@ def show_stats(lib, query, exact): try: total_size += os.path.getsize(syspath(item.path)) except OSError as exc: - log.info("could not get size of {}: {}", item.path, exc) + log.info("could not get size of {.path}: {}", item, exc) else: total_size += int(item.length * item.bitrate / 8) total_time += item.length @@ -2173,7 +2171,7 @@ def move_items( ) for obj in objs: - log.debug("moving: {}", obj.filepath) + log.debug("moving: {.filepath}", obj) if export: # Copy without affecting the database. @@ -2256,14 +2254,14 @@ def write_items(lib, query, pretend, force): for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): - log.info("missing file: {}", item.filepath) + log.info("missing file: {.filepath}", item) continue # Get an Item object reflecting the "clean" (on-disk) state. try: clean_item = library.Item.from_path(item.path) except library.ReadError as exc: - log.error("error reading {}: {}", item.filepath, exc) + log.error("error reading {.filepath}: {}", item, exc) continue # Check for and display changes. diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 3ac7ad6a9..f895a60ee 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -126,7 +126,7 @@ class HumanReadableError(Exception): """ if self.tb: logger.debug(self.tb) - logger.error("{}: {}", self.error_kind, self.args[0]) + logger.error("{0.error_kind}: {0.args[0]}", self) class FilesystemError(HumanReadableError): diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 1e3b836ae..5ecde5140 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -306,9 +306,9 @@ class IMBackend(LocalBackend): except subprocess.CalledProcessError as exc: log.warning("ImageMagick size query failed") log.debug( - "`convert` exited with (status {}) when " + "`convert` exited with (status {.returncode}) when " "getting size with command {}:\n{}", - exc.returncode, + exc, cmd, exc.output.strip(), ) @@ -441,8 +441,8 @@ class IMBackend(LocalBackend): convert_proc.wait() if convert_proc.returncode: log.debug( - "ImageMagick convert failed with status {}: {!r}", - convert_proc.returncode, + "ImageMagick convert failed with status {.returncode}: {!r}", + convert_proc, convert_stderr, ) return None diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py index 29c51d302..92a1976a1 100644 --- a/beetsplug/acousticbrainz.py +++ b/beetsplug/acousticbrainz.py @@ -153,7 +153,7 @@ class AcousticPlugin(plugins.BeetsPlugin): try: data.update(res.json()) except ValueError: - self._log.debug("Invalid Response: {}", res.text) + self._log.debug("Invalid Response: {.text}", res) return {} return data diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 7b63a7496..070008be8 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -127,12 +127,11 @@ class BadFiles(BeetsPlugin): except CheckerCommandError as e: if e.errno == errno.ENOENT: self._log.error( - "command not found: {} when validating file: {}", - e.checker, - e.path, + "command not found: {0.checker} when validating file: {0.path}", + e, ) else: - self._log.error("error invoking {}: {}", e.checker, e.msg) + self._log.error("error invoking {0.checker}: {0.msg}", e) return [] error_lines = [] diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index bd90f4d6b..aa7013150 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -763,7 +763,7 @@ class Connection: def debug(self, message, kind=" "): """Log a debug message about this connection.""" - self.server._log.debug("{}[{}]: {}", kind, self.address, message) + self.server._log.debug("{}[{.address}]: {}", kind, self, message) def run(self): pass @@ -911,7 +911,7 @@ class ControlConnection(Connection): super().__init__(server, sock) def debug(self, message, kind=" "): - self.server._log.debug("CTRL {}[{}]: {}", kind, self.address, message) + self.server._log.debug("CTRL {}[{.address}]: {}", kind, self, message) def run(self): """Listen for control commands and delegate to `ctrl_*` methods.""" diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index ccd781b28..9ae6d47d5 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -82,8 +82,8 @@ class BPSyncPlugin(BeetsPlugin): if not self.is_beatport_track(item): self._log.info( - "Skipping non-{} singleton: {}", - self.beatport_plugin.data_source, + "Skipping non-{.beatport_plugin.data_source} singleton: {}", + self, item, ) continue @@ -107,8 +107,8 @@ class BPSyncPlugin(BeetsPlugin): return False if not album.mb_albumid.isnumeric(): self._log.info( - "Skipping album with invalid {} ID: {}", - self.beatport_plugin.data_source, + "Skipping album with invalid {.beatport_plugin.data_source} ID: {}", + self, album, ) return False @@ -117,8 +117,8 @@ class BPSyncPlugin(BeetsPlugin): return items if not all(self.is_beatport_track(item) for item in items): self._log.info( - "Skipping non-{} release: {}", - self.beatport_plugin.data_source, + "Skipping non-{.beatport_plugin.data_source} release: {}", + self, album, ) return False @@ -139,9 +139,7 @@ class BPSyncPlugin(BeetsPlugin): albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid) if not albuminfo: self._log.info( - "Release ID {} not found for album {}", - album.mb_albumid, - album, + "Release ID {0.mb_albumid} not found for album {0}", album ) continue diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 43f556f05..192310fb8 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -343,20 +343,20 @@ def fingerprint_item(log, item, write=False): """ # Get a fingerprint and length for this track. if not item.length: - log.info("{}: no duration available", item.filepath) + log.info("{.filepath}: no duration available", item) elif item.acoustid_fingerprint: if write: - log.info("{}: fingerprint exists, skipping", item.filepath) + log.info("{.filepath}: fingerprint exists, skipping", item) else: - log.info("{}: using existing fingerprint", item.filepath) + log.info("{.filepath}: using existing fingerprint", item) return item.acoustid_fingerprint else: - log.info("{}: fingerprinting", item.filepath) + log.info("{.filepath}: fingerprinting", item) try: _, fp = acoustid.fingerprint_file(util.syspath(item.path)) item.acoustid_fingerprint = fp.decode() if write: - log.info("{}: writing fingerprint", item.filepath) + log.info("{.filepath}: writing fingerprint", item) item.try_write() if item._db: item.store() diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 25c3a6f0c..e9db3592e 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -319,10 +319,9 @@ class ConvertPlugin(BeetsPlugin): util.displayable_path(source), ) self._log.debug( - "Command {} exited with status {}: {}", + "Command {0} exited with status {1.returncode}: {1.output}", args, - exc.returncode, - exc.output, + exc, ) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) @@ -388,15 +387,15 @@ class ConvertPlugin(BeetsPlugin): if os.path.exists(util.syspath(dest)): self._log.info( - "Skipping {} (target file exists)", item.filepath + "Skipping {.filepath} (target file exists)", item ) continue if keep_new: if pretend: self._log.info( - "mv {} {}", - item.filepath, + "mv {.filepath} {}", + item, util.displayable_path(original), ) else: @@ -430,7 +429,7 @@ class ConvertPlugin(BeetsPlugin): else ("Linking" if link else "Copying") ) - self._log.info("{} {}", msg, item.filepath) + self._log.info("{} {.filepath}", msg, item) if hardlink: util.hardlink(original, converted) @@ -461,7 +460,7 @@ class ConvertPlugin(BeetsPlugin): if album and album.artpath: maxwidth = self._get_art_resize(album.artpath) self._log.debug( - "embedding album art from {}", album.art_filepath + "embedding album art from {.art_filepath}", album ) art.embed_item( self._log, @@ -519,7 +518,7 @@ class ConvertPlugin(BeetsPlugin): if os.path.exists(util.syspath(dest)): self._log.info( - "Skipping {} (target file exists)", album.art_filepath + "Skipping {.art_filepath} (target file exists)", album ) return @@ -529,8 +528,8 @@ class ConvertPlugin(BeetsPlugin): # Either copy or resize (while copying) the image. if maxwidth is not None: self._log.info( - "Resizing cover art from {} to {}", - album.art_filepath, + "Resizing cover art from {.art_filepath} to {}", + album, util.displayable_path(dest), ) if not pretend: @@ -540,9 +539,9 @@ class ConvertPlugin(BeetsPlugin): msg = "ln" if hardlink else ("ln -s" if link else "cp") self._log.info( - "{} {} {}", + "{} {.art_filepath} {}", msg, - album.art_filepath, + album, util.displayable_path(dest), ) else: @@ -553,9 +552,9 @@ class ConvertPlugin(BeetsPlugin): ) self._log.info( - "{} cover art from {} to {}", + "{} cover art from {.art_filepath} to {}", msg, - album.art_filepath, + album, util.displayable_path(dest), ) if hardlink: diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index def2b37a2..e427b08b1 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -251,16 +251,16 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): response.raise_for_status() except requests.exceptions.RequestException as e: self._log.error( - "Error fetching data from {} API\n Error: {}", - self.data_source, + "Error fetching data from {.data_source} API\n Error: {}", + self, e, ) return () response_data: Sequence[IDResponse] = response.json().get("data", []) self._log.debug( - "Found {} result(s) from {} for '{}'", + "Found {} result(s) from {.data_source} for '{}'", len(response_data), - self.data_source, + self, query, ) return response_data diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 03714ab81..904e19262 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -254,24 +254,24 @@ class DuplicatesPlugin(BeetsPlugin): checksum = getattr(item, key, False) if not checksum: self._log.debug( - "key {} on item {} not cached:computing checksum", + "key {} on item {.filepath} not cached:computing checksum", key, - item.filepath, + item, ) try: checksum = command_output(args).stdout setattr(item, key, checksum) item.store() self._log.debug( - "computed checksum for {} using {}", item.title, key + "computed checksum for {.title} using {}", item, key ) except subprocess.CalledProcessError as e: - self._log.debug("failed to checksum {}: {}", item.filepath, e) + self._log.debug("failed to checksum {.filepath}: {}", item, e) else: self._log.debug( - "key {} on item {} cached:not computing checksum", + "key {} on item {.filepath} cached:not computing checksum", key, - item.filepath, + item, ) return key, checksum @@ -289,15 +289,15 @@ class DuplicatesPlugin(BeetsPlugin): values = [v for v in values if v not in (None, "")] if strict and len(values) < len(keys): self._log.debug( - "some keys {} on item {} are null or empty: skipping", + "some keys {} on item {.filepath} are null or empty: skipping", keys, - obj.filepath, + obj, ) elif not strict and not len(values): self._log.debug( - "all keys {} on item {} are null or empty: skipping", + "all keys {} on item {.filepath} are null or empty: skipping", keys, - obj.filepath, + obj, ) else: key = tuple(values) @@ -356,10 +356,10 @@ class DuplicatesPlugin(BeetsPlugin): if value: self._log.debug( "key {} on item {} is null " - "or empty: setting from item {}", + "or empty: setting from item {.filepath}", f, displayable_path(objs[0].path), - o.filepath, + o, ) setattr(objs[0], f, value) objs[0].store() @@ -380,10 +380,10 @@ class DuplicatesPlugin(BeetsPlugin): missing.add(i._db) self._log.debug( "item {} missing from album {}:" - " merging from {} into {}", + " merging from {.filepath} into {}", missing, objs[0], - o.filepath, + o, displayable_path(missing.destination()), ) missing.move(operation=MoveOperation.COPY) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 156dd6132..54c065da4 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -133,7 +133,7 @@ class Candidate: # get_size returns None if no local imaging backend is available if not self.size: self.size = ArtResizer.shared.get_size(self.path) - self._log.debug("image size: {}", self.size) + self._log.debug("image size: {.size}", self) if not self.size: self._log.warning( @@ -151,7 +151,7 @@ class Candidate: # Check minimum dimension. if plugin.minwidth and self.size[0] < plugin.minwidth: self._log.debug( - "image too small ({} < {})", self.size[0], plugin.minwidth + "image too small ({} < {.minwidth})", self.size[0], plugin ) return ImageAction.BAD @@ -162,10 +162,10 @@ class Candidate: if edge_diff > plugin.margin_px: self._log.debug( "image is not close enough to being " - "square, ({} - {} > {})", + "square, ({} - {} > {.margin_px})", long_edge, short_edge, - plugin.margin_px, + plugin, ) return ImageAction.BAD elif plugin.margin_percent: @@ -190,7 +190,7 @@ class Candidate: downscale = False if plugin.maxwidth and self.size[0] > plugin.maxwidth: self._log.debug( - "image needs rescaling ({} > {})", self.size[0], plugin.maxwidth + "image needs rescaling ({} > {.maxwidth})", self.size[0], plugin ) downscale = True @@ -200,9 +200,9 @@ class Candidate: filesize = os.stat(syspath(self.path)).st_size if filesize > plugin.max_filesize: self._log.debug( - "image needs resizing ({}B > {}B)", + "image needs resizing ({}B > {.max_filesize}B)", filesize, - plugin.max_filesize, + plugin, ) downsize = True @@ -213,9 +213,9 @@ class Candidate: reformat = fmt != plugin.cover_format if reformat: self._log.debug( - "image needs reformatting: {} -> {}", + "image needs reformatting: {} -> {.cover_format}", fmt, - plugin.cover_format, + plugin, ) skip_check_for = skip_check_for or [] @@ -329,7 +329,7 @@ def _logged_get(log: Logger, *args, **kwargs) -> requests.Response: prepped.url, {}, None, None, None ) send_kwargs.update(settings) - log.debug("{}: {}", message, prepped.url) + log.debug("{}: {.url}", message, prepped) return s.send(prepped, **send_kwargs) @@ -542,14 +542,14 @@ class CoverArtArchive(RemoteArtSource): try: response = self.request(url) except requests.RequestException: - self._log.debug("{}: error receiving response", self.NAME) + self._log.debug("{.NAME}: error receiving response", self) return try: data = response.json() except ValueError: self._log.debug( - "{}: error loading response: {}", self.NAME, response.text + "{.NAME}: error loading response: {.text}", self, response ) return @@ -629,7 +629,7 @@ class AlbumArtOrg(RemoteArtSource): # Get the page from albumart.org. try: resp = self.request(self.URL, params={"asin": album.asin}) - self._log.debug("scraped art URL: {}", resp.url) + self._log.debug("scraped art URL: {.url}", resp) except requests.RequestException: self._log.debug("error scraping art page") return @@ -702,7 +702,7 @@ class GoogleImages(RemoteArtSource): try: data = response.json() except ValueError: - self._log.debug("google: error loading response: {}", response.text) + self._log.debug("google: error loading response: {.text}", response) return if "error" in data: @@ -764,7 +764,7 @@ class FanartTV(RemoteArtSource): data = response.json() except ValueError: self._log.debug( - "fanart.tv: error loading response: {}", response.text + "fanart.tv: error loading response: {.text}", response ) return @@ -953,8 +953,8 @@ class Wikipedia(RemoteArtSource): self._log.debug("wikipedia: album not found on dbpedia") except (ValueError, KeyError, IndexError): self._log.debug( - "wikipedia: error scraping dbpedia response: {}", - dbpedia_response.text, + "wikipedia: error scraping dbpedia response: {.text}", + dbpedia_response, ) # Ensure we have a filename before attempting to query wikipedia @@ -1179,7 +1179,7 @@ class LastFM(RemoteArtSource): if "error" in data: if data["error"] == 6: self._log.debug( - "lastfm: no results for {}", album.mb_albumid + "lastfm: no results for {.mb_albumid}", album ) else: self._log.error( @@ -1200,7 +1200,7 @@ class LastFM(RemoteArtSource): url=images[size], size=self.SIZES[size] ) except ValueError: - self._log.debug("lastfm: error loading response: {}", response.text) + self._log.debug("lastfm: error loading response: {.text}", response) return @@ -1244,7 +1244,7 @@ class Spotify(RemoteArtSource): soup = BeautifulSoup(html, "html.parser") except ValueError: self._log.debug( - "Spotify: error loading response: {}", response.text + "Spotify: error loading response: {.text}", response ) return @@ -1541,7 +1541,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): out = candidate assert out.path is not None # help mypy self._log.debug( - "using {.LOC} image {}", source, out.path + "using {.LOC} image {.path}", source, out ) break # Remove temporary files for invalid candidates. diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 6571c9c90..f0f088099 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -150,10 +150,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # In case the artist is kept, do not update the artist fields. if keep_in_artist_field: self._log.info( - "artist: {} (Not changing due to keep_in_artist)", item.artist + "artist: {.artist} (Not changing due to keep_in_artist)", item ) else: - self._log.info("artist: {} -> {}", item.artist, item.albumartist) + self._log.info("artist: {0.artist} -> {0.albumartist}", item) item.artist = item.albumartist if item.artist_sort: @@ -166,7 +166,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) new_title = f"{item.title} {new_format}" - self._log.info("title: {} -> {}", item.title, new_title) + self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title def ft_in_title( @@ -194,7 +194,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not featured: return False - self._log.info("{}", item.filepath) + self._log.info("{.filepath}", item) # Attempt to find the featured artist. feat_part = find_feat_part(artist, albumartist) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 62cb86567..b8869eca4 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -83,7 +83,7 @@ class HookPlugin(BeetsPlugin): subprocess.check_call(command_pieces) except subprocess.CalledProcessError as exc: self._log.error( - "hook for {} exited with status {}", event, exc.returncode + "hook for {} exited with status {.returncode}", event, exc ) except OSError as exc: self._log.error("hook for {} failed: {}", event, exc) diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index b6244c518..f728a104f 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -103,9 +103,9 @@ class ImportAddedPlugin(BeetsPlugin): def update_album_times(self, lib, album): if self.reimported_album(album): self._log.debug( - "Album '{}' is reimported, skipping import of " + "Album '{.filepath}' is reimported, skipping import of " "added dates for the album and its items.", - album.filepath, + album, ) return @@ -119,18 +119,17 @@ class ImportAddedPlugin(BeetsPlugin): item.store() album.added = min(album_mtimes) self._log.debug( - "Import of album '{}', selected album.added={} " + "Import of album '{0.album}', selected album.added={0.added} " "from item file mtimes.", - album.album, - album.added, + album, ) album.store() def update_item_times(self, lib, item): if self.reimported_item(item): self._log.debug( - "Item '{}' is reimported, skipping import of added date.", - item.filepath, + "Item '{.filepath}' is reimported, skipping import of added date.", + item, ) return mtime = self.item_mtime.pop(item.path, None) @@ -139,9 +138,8 @@ class ImportAddedPlugin(BeetsPlugin): if self.config["preserve_mtimes"].get(bool): self.write_item_mtime(item, mtime) self._log.debug( - "Import of item '{}', selected item.added={}", - item.filepath, - item.added, + "Import of item '{0.filepath}', selected item.added={0.added}", + item, ) item.store() @@ -153,7 +151,6 @@ class ImportAddedPlugin(BeetsPlugin): if self.config["preserve_write_mtimes"].get(bool): self.write_item_mtime(item, item.added) self._log.debug( - "Write of item '{}', selected item.added={}", - item.filepath, - item.added, + "Write of item '{0.filepath}', selected item.added={0.added}", + item, ) diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index 8b1b77a3e..8b6d57fd3 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -180,7 +180,7 @@ class IPFSPlugin(BeetsPlugin): util.command_output(cmd) except (OSError, subprocess.CalledProcessError) as err: self._log.error( - "Failed to get {} from ipfs.\n{}", _hash, err.output + "Failed to get {} from ipfs.\n{.output}", _hash, err ) return False diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index 43f04e263..e2aff24e5 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -73,7 +73,7 @@ class KeyFinderPlugin(BeetsPlugin): 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("no key returned for path: {}", item.path) + self._log.error("no key returned for path: {.path}", item) continue try: @@ -84,7 +84,7 @@ class KeyFinderPlugin(BeetsPlugin): item["initial_key"] = key self._log.info( - "added computed initial key {} for {}", key, item.filepath + "added computed initial key {} for {.filepath}", key, item ) if write: diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index 039b5dfc4..baa522d14 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -268,10 +268,9 @@ def process_tracks(lib, tracks, log): count = int(song.get("play_count", 0)) new_count = int(tracks[num].get("playcount", 1)) log.debug( - "match: {} - {} ({}) updating: play_count {} => {}", - song.artist, - song.title, - song.album, + "match: {0.artist} - {0.title} ({0.album}) updating:" + " play_count {1} => {2}", + song, count, new_count, ) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 7b5ee55c7..bd451db59 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -508,9 +508,9 @@ class SearchBackend(SoupMixin, Backend): # log out the candidate that did not make it but was close. # This may show a matching candidate with some noise in the name self.debug( - "({}, {}) does not match ({}, {}) but dist was close: {:.2f}", - result.artist, - result.title, + "({0.artist}, {0.title}) does not match ({1}, {2}) but dist" + " was close: {3:.2f}", + result, target_artist, target_title, max_dist, @@ -838,15 +838,16 @@ class Translator(RequestHandler): lyrics_language = langdetect.detect(new_lyrics).upper() if lyrics_language == self.to_language: self.info( - "🔵 Lyrics are already in the target language {}", - self.to_language, + "🔵 Lyrics are already in the target language {.to_language}", + self, ) return new_lyrics if self.from_languages and lyrics_language not in self.from_languages: self.info( - "🔵 Configuration {} does not permit translating from {}", - self.from_languages, + "🔵 Configuration {.from_languages} does not permit translating" + " from {}", + self, lyrics_language, ) return new_lyrics @@ -854,7 +855,7 @@ class Translator(RequestHandler): lyrics, *url = new_lyrics.split("\n\nSource: ") with self.handle_request(): translated_lines = self.append_translations(lyrics.splitlines()) - self.info("🟢 Translated lyrics to {}", self.to_language) + self.info("🟢 Translated lyrics to {.to_language}", self) return "\n\nSource: ".join(["\n".join(translated_lines), *url]) diff --git a/beetsplug/missing.py b/beetsplug/missing.py index c440f6c69..cbdda4599 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -226,8 +226,8 @@ class MissingPlugin(BeetsPlugin): for track_info in album_info.tracks: if track_info.track_id not in item_mbids: self._log.debug( - "track {} in album {}", - track_info.track_id, - album_info.album_id, + "track {.track_id} in album {.album_id}", + track_info, + album_info, ) yield _item(track_info, album_info, album.id) diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index f4fe21a13..0a3e1de02 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -51,8 +51,8 @@ class MPDClientWrapper: if not self.strip_path.endswith("/"): self.strip_path += "/" - self._log.debug("music_directory: {}", self.music_directory) - self._log.debug("strip_path: {}", self.strip_path) + self._log.debug("music_directory: {.music_directory}", self) + self._log.debug("strip_path: {.strip_path}", self) self.client = mpd.MPDClient() @@ -188,10 +188,10 @@ class MPDStats: item.store() self._log.debug( - "updated: {} = {} [{}]", + "updated: {} = {} [{.filepath}]", attribute, item[attribute], - item.filepath, + item, ) def update_rating(self, item, skipped): diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index e92d5c63f..5d8fc598b 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -101,8 +101,8 @@ class MPDUpdatePlugin(BeetsPlugin): try: s = BufferedSocket(host, port) - except OSError as e: - self._log.warning("MPD connection failed: {}", str(e.strerror)) + except OSError: + self._log.warning("MPD connection failed", exc_info=True) return resp = s.readline() diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index 5b5f215f3..eb2fd8f11 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -179,9 +179,8 @@ class ParentWorkPlugin(BeetsPlugin): if not item.mb_workid: self._log.info( - "No work for {}, add one at https://musicbrainz.org/recording/{}", + "No work for {0}, add one at https://musicbrainz.org/recording/{0.mb_trackid}", item, - item.mb_trackid, ) return diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 01625c281..07c12e0e0 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -142,7 +142,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): dir_contents = os.listdir(playlist_dir) except OSError: self._log.warning( - "Unable to open playlist directory {}", self.playlist_dir + "Unable to open playlist directory {.playlist_dir}", self ) return diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 39bbf2e17..3e777d977 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -141,9 +141,8 @@ class RgTask: item.rg_track_peak = track_gain.peak item.store() self._log.debug( - "applied track gain {} LU, peak {} of FS", - item.rg_track_gain, - item.rg_track_peak, + "applied track gain {0.rg_track_gain} LU, peak {0.rg_track_peak} of FS", + item, ) def _store_album_gain(self, item: Item, album_gain: Gain): @@ -155,9 +154,8 @@ class RgTask: item.rg_album_peak = album_gain.peak item.store() self._log.debug( - "applied album gain {} LU, peak {} of FS", - item.rg_album_gain, - item.rg_album_peak, + "applied album gain {0.rg_album_gain} LU, peak {0.rg_album_peak} of FS", + item, ) def _store_track(self, write: bool): @@ -230,7 +228,7 @@ class R128Task(RgTask): def _store_track_gain(self, item: Item, track_gain: Gain): item.r128_track_gain = track_gain.gain item.store() - self._log.debug("applied r128 track gain {} LU", item.r128_track_gain) + self._log.debug("applied r128 track gain {.r128_track_gain} LU", item) def _store_album_gain(self, item: Item, album_gain: Gain): """ @@ -239,7 +237,7 @@ class R128Task(RgTask): """ item.r128_album_gain = album_gain.gain item.store() - self._log.debug("applied r128 album gain {} LU", item.r128_album_gain) + self._log.debug("applied r128 album gain {.r128_album_gain} LU", item) AnyRgTask = TypeVar("AnyRgTask", bound=RgTask) @@ -380,7 +378,7 @@ class FfmpegBackend(Backend): album_gain = target_level_lufs - album_gain self._log.debug( - "{}: gain {} LU, peak {}", task.album, album_gain, album_peak + "{.album}: gain {} LU, peak {}", task, album_gain, album_peak ) task.album_gain = Gain(album_gain, album_peak) @@ -1093,9 +1091,8 @@ class AudioToolsBackend(Backend): ) self._log.debug( - "ReplayGain for track {} - {}: {2:.2f}, {3:.2f}", - item.artist, - item.title, + "ReplayGain for track {0.artist} - {0.title}: {1:.2f}, {2:.2f}", + item, rg_track_gain, rg_track_peak, ) @@ -1133,8 +1130,8 @@ class AudioToolsBackend(Backend): rg_album_gain, task.target_level ) self._log.debug( - "ReplayGain for album {}: {.2f}, {.2f}", - task.items[0].album, + "ReplayGain for album {.items[0].album}: {.2f}, {.2f}", + task, rg_album_gain, rg_album_peak, ) diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 66d5aeea3..c39894137 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -59,7 +59,7 @@ class ScrubPlugin(BeetsPlugin): def scrub_func(lib, opts, args): # Walk through matching files and remove tags. for item in lib.items(args): - self._log.info("scrubbing: {}", item.filepath) + self._log.info("scrubbing: {.filepath}", item) self._scrub_item(item, opts.write) scrub_cmd = ui.Subcommand("scrub", help="clean audio tags") @@ -147,5 +147,5 @@ class ScrubPlugin(BeetsPlugin): def import_task_files(self, session, task): """Automatically scrub imported files.""" for item in task.imported_items(): - self._log.debug("auto-scrubbing {}", item.filepath) + self._log.debug("auto-scrubbing {.filepath}", item) self._scrub_item(item, ui.should_write()) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 897899301..d83927328 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -188,9 +188,7 @@ class SpotifyPlugin( self.access_token = response.json()["access_token"] # Save the token for later use. - self._log.debug( - "{} access token: {}", self.data_source, self.access_token - ) + self._log.debug("{0.data_source} access token: {0.access_token}", self) with open(self._tokenfile(), "w") as f: json.dump({"access_token": self.access_token}, f) @@ -451,9 +449,9 @@ class SpotifyPlugin( return () response_data = response.get(f"{query_type}s", {}).get("items", []) self._log.debug( - "Found {} result(s) from {} for '{}'", + "Found {} result(s) from {.data_source} for '{}'", len(response_data), - self.data_source, + self, query, ) return response_data @@ -539,8 +537,8 @@ class SpotifyPlugin( if not items: self._log.debug( - "Your beets query returned no items, skipping {}.", - self.data_source, + "Your beets query returned no items, skipping {.data_source}.", + self, ) return @@ -595,8 +593,8 @@ class SpotifyPlugin( or self.config["tiebreak"].get() == "first" ): self._log.debug( - "{} track(s) found, count: {}", - self.data_source, + "{.data_source} track(s) found, count: {}", + self, len(response_data_tracks), ) chosen_result = response_data_tracks[0] @@ -619,19 +617,19 @@ class SpotifyPlugin( if failure_count > 0: if self.config["show_failures"].get(): self._log.info( - "{} track(s) did not match a {} ID:", + "{} track(s) did not match a {.data_source} ID:", failure_count, - self.data_source, + self, ) for track in failures: self._log.info("track: {}", track) self._log.info("") else: self._log.warning( - "{} track(s) did not match a {} ID:\n" + "{} track(s) did not match a {.data_source} ID:\n" "use --show-failures to display", failure_count, - self.data_source, + self, ) return results diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index 683574aaa..673cc94a8 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -108,7 +108,7 @@ class SubsonicUpdate(BeetsPlugin): auth = self.config["auth"].as_str() url = self.__format_url("startScan") self._log.debug("URL is {}", url) - self._log.debug("auth type is {}", self.config["auth"]) + self._log.debug("auth type is {.config[auth]}", self) if auth == "token": salt, token = self.__create_token() diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index e5094a87d..651eaf3ac 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -127,7 +127,7 @@ class ThumbnailsPlugin(BeetsPlugin): size = ArtResizer.shared.get_size(album.artpath) if not size: self._log.warning( - "problem getting the picture size for {}", album.artpath + "problem getting the picture size for {.artpath}", album ) return diff --git a/pyproject.toml b/pyproject.toml index 6691221b5..3ba7b8b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -275,6 +275,7 @@ select = [ "E", # pycodestyle "F", # pyflakes # "B", # flake8-bugbear + "G", # flake8-logging-format "I", # isort "ISC", # flake8-implicit-str-concat "N", # pep8-naming From 44fda7ca0ad28243c0ce9b07e9bd43db976c2323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 2 Aug 2025 10:39:36 +0100 Subject: [PATCH 033/301] lyrics: use another beatles song for Lyricsmode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lady Madonna apparently is gone from this website. ¯\_(ツ)_/¯ --- beetsplug/lyrics.py | 6 ++--- test/plugins/lyrics_pages.py | 49 ++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index bd451db59..f492ab3cc 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -644,7 +644,7 @@ class Google(SearchBackend): re.IGNORECASE | re.VERBOSE, ) #: Split cleaned up URL title into artist and title parts. - URL_TITLE_PARTS_RE = re.compile(r" +(?:[ :|-]+|par|by) +") + URL_TITLE_PARTS_RE = re.compile(r" +(?:[ :|-]+|par|by) +|, ") SOURCE_DIST_FACTOR = {"www.azlyrics.com": 0.5, "www.songlyrics.com": 0.6} @@ -702,8 +702,8 @@ class Google(SearchBackend): result_artist, result_title = "", parts[0] else: # sort parts by their similarity to the artist - parts.sort(key=lambda p: cls.get_part_dist(artist, title, p)) - result_artist, result_title = parts[0], " ".join(parts[1:]) + result_artist = min(parts, key=lambda p: string_dist(artist, p)) + result_title = min(parts, key=lambda p: string_dist(title, p)) return SearchResult(result_artist, result_title, item["link"]) diff --git a/test/plugins/lyrics_pages.py b/test/plugins/lyrics_pages.py index e1806b167..9d84c5327 100644 --- a/test/plugins/lyrics_pages.py +++ b/test/plugins/lyrics_pages.py @@ -128,6 +128,7 @@ lyrics_pages = [ artist="Atlanta", track_title="Mergaitės Nori Mylėt", url_title="Mergaitės nori mylėt – Atlanta | Dainų Žodžiai", + marks=[xfail_on_ci("Expired SSL certificate")], ), LyricsPage.make( "https://genius.com/The-beatles-lady-madonna-lyrics", @@ -328,34 +329,40 @@ lyrics_pages = [ url_title="The Beatles - Lady Madonna Lyrics", ), LyricsPage.make( - "https://www.lyricsmode.com/lyrics/b/beatles/lady_madonna.html", + "https://www.lyricsmode.com/lyrics/b/beatles/mother_natures_son.html", """ - Lady Madonna, children at your feet. - Wonder how you manage to make ends meet. - Who finds the money? When you pay the rent? - Did you think that money was heaven sent? + Born a poor young country boy, Mother Nature's son + All day long I'm sitting singing songs for everyone - Friday night arrives without a suitcase. - Sunday morning creep in like a nun. - Mondays child has learned to tie his bootlace. - See how they run. + Sit beside a mountain stream, see her waters rise + Listen to the pretty sound of music as she flies - Lady Madonna, baby at your breast. - Wonder how you manage to feed the rest. + Doo doo doo doo doo doo doo doo doo doo doo + Doo doo doo doo doo doo doo doo doo + Doo doo doo - See how they run. - Lady Madonna, lying on the bed, - Listen to the music playing in your head. + Find me in my field of grass, Mother Nature's son + Swaying daises sing a lazy song beneath the sun - Tuesday afternoon is never ending. - Wednesday morning papers didn't come. - Thursday night you stockings needed mending. - See how they run. + Doo doo doo doo doo doo doo doo doo doo doo + Doo doo doo doo doo doo doo doo doo + Doo doo doo doo doo doo + Yeah yeah yeah - Lady Madonna, children at your feet. - Wonder how you manage to make ends meet. + Mm mm mm mm mm mm mm + Mm mm mm, ooh ooh ooh + Mm mm mm mm mm mm mm + Mm mm mm mm, wah wah wah + + Wah, Mother Nature's son """, - url_title="Lady Madonna lyrics by The Beatles - original song full text. Official Lady Madonna lyrics, 2024 version | LyricsMode.com", # noqa: E501 + artist="The Beatles", + track_title="Mother Nature's Son", + url_title=( + "Mother Nature's Son lyrics by The Beatles - original song full" + " text. Official Mother Nature's Son lyrics, 2025 version" + " | LyricsMode.com" + ), ), LyricsPage.make( "https://www.lyricsontop.com/amy-winehouse-songs/jazz-n-blues-lyrics.html", From 3bc653b98987404a9ed3b63574ff57483eca325c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 2 Aug 2025 11:03:21 +0100 Subject: [PATCH 034/301] lyrics: xfail sweetslyrics end-to-end test --- test/plugins/lyrics_pages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/lyrics_pages.py b/test/plugins/lyrics_pages.py index 9d84c5327..15cb812a1 100644 --- a/test/plugins/lyrics_pages.py +++ b/test/plugins/lyrics_pages.py @@ -535,6 +535,7 @@ lyrics_pages = [ Wonder how you manage to make ends meet. """, url_title="The Beatles - Lady Madonna", + marks=[xfail_on_ci("Sweetslyrics also fails with 403 FORBIDDEN in CI")], ), LyricsPage.make( "https://www.tekstowo.pl/piosenka,the_beatles,lady_madonna.html", From a0ae664ae0a66bec36571208b3acd3868619de68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 7 Aug 2025 16:46:14 +0100 Subject: [PATCH 035/301] Add a note about SQL injection --- CONTRIBUTING.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 11dac07d8..031e8fbc5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -242,12 +242,12 @@ There are a few coding conventions we use in beets: To fetch Item objects from the database, use lib.items(…) and supply a query as an argument. Resist the urge to write raw SQL for your query. If you must - use lower-level queries into the database, do this: + use lower-level queries into the database, do this, for example: .. code-block:: python with lib.transaction() as tx: - rows = tx.query("SELECT …") + rows = tx.query("SELECT path FROM items WHERE album_id = ?", (album_id,)) Transaction objects help control concurrent access to the database and assist in debugging conflicting accesses. From 99a060b01d537b17c21766cebc2f6e563af30831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sat, 30 Aug 2025 23:10:51 +0100 Subject: [PATCH 036/301] Exclude certain commits from git blame --- .git-blame-ignore-revs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index c6ec4cb5f..b20434e23 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -57,3 +57,13 @@ c490ac5810b70f3cf5fd8649669838e8fdb19f4d 769dcdc88a1263638ae25944ba6b2be3e8933666 # Reformat all docs using docstrfmt ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d +# Replace format calls with f-strings +4a361bd501e85de12c91c2474c423559ca672852 +# Replace percent formatting +9352a79e4108bd67f7e40b1e944c01e0a7353272 +# Replace string concatenation (' + ') +1c16b2b3087e9c3635d68d41c9541c4319d0bdbe +# Do not use backslashes to deal with long strings +2fccf64efe82851861e195b521b14680b480a42a +# Do not use explicit indices for logging args when not needed +d93ddf8dd43e4f9ed072a03829e287c78d2570a2 From d00d51e0bf8d8db159dc6a6def5da4deafaaba2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 25 Aug 2025 19:30:10 +0100 Subject: [PATCH 037/301] Add configurable search_limit to Spotify and Deezer plugins --- beetsplug/deezer.py | 6 +++++- beetsplug/spotify.py | 3 ++- docs/changelog.rst | 2 ++ docs/plugins/deezer.rst | 6 +++++- docs/plugins/spotify.rst | 2 ++ 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index e427b08b1..0e162372a 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -49,6 +49,10 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): album_url = "https://api.deezer.com/album/" track_url = "https://api.deezer.com/track/" + def __init__(self) -> None: + super().__init__() + self.config.add({"search_limit": 5}) + def commands(self): """Add beet UI commands to interact with Deezer.""" deezer_update_cmd = ui.Subcommand( @@ -263,7 +267,7 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): self, query, ) - return response_data + return response_data[: self.config["search_limit"].get()] def deezerupdate(self, items: Sequence[Item], write: bool): """Obtain rank information from Deezer.""" diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index d83927328..ffeb844a6 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -138,6 +138,7 @@ class SpotifyPlugin( "client_id": "4e414367a1d14c75a5c5129a627fcab8", "client_secret": "f82bdc09b2254f1a8286815d02fd46dc", "tokenfile": "spotify_token.json", + "search_limit": 5, } ) self.config["client_id"].redact = True @@ -454,7 +455,7 @@ class SpotifyPlugin( self, query, ) - return response_data + return response_data[: self.config["search_limit"].get()] def commands(self) -> list[ui.Subcommand]: # autotagger import command diff --git a/docs/changelog.rst b/docs/changelog.rst index e12050fdc..1cc537136 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,8 @@ New features: ``played_ratio_threshold``, to allow configuring the percentage the song must be played for it to be counted as played instead of skipped. - :doc:`plugins/web`: Display artist and album as part of the search results. +- :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option + ``search_limit`` to limit the number of results returned by search queries. Bug fixes: diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 2d0bd7009..b3a57e825 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -27,7 +27,11 @@ Configuration ------------- This plugin can be configured like other metadata source plugins as described in -:ref:`metadata-source-plugin-configuration`. +:ref:`metadata-source-plugin-configuration`. In addition, the following +configuration options are provided. + +- **search_limit**: The maximum number of results to return from Deezer for each + search query. Default: ``5``. The default options should work as-is, but there are some options you can put in config.yaml under the ``deezer:`` section: diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index be929adf7..2c6cb3d1c 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -98,6 +98,8 @@ config.yaml under the ``spotify:`` section: enhance search results in some cases, but in general, it is not recommended. For instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice ``×!=x``). Default: ``no``. +- **search_limit**: The maximum number of results to return from Spotify for + each search query. Default: ``5``. Here's an example: From a674fd3095fe65772e1a89e8cee5df7912e9e143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 25 Aug 2025 20:07:46 +0100 Subject: [PATCH 038/301] musicbrainz: Rename searchlimit config option to search_limit --- beetsplug/musicbrainz.py | 15 +++++++++++++-- docs/changelog.rst | 2 +- docs/plugins/musicbrainz.rst | 13 +++++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 524fb3c8c..171fe5381 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -18,12 +18,14 @@ from __future__ import annotations import traceback from collections import Counter +from contextlib import suppress from functools import cached_property from itertools import product from typing import TYPE_CHECKING, Any, Iterable, Sequence from urllib.parse import urljoin import musicbrainzngs +from confuse.exceptions import NotFoundError import beets import beets.autotag.hooks @@ -371,7 +373,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): "https": False, "ratelimit": 1, "ratelimit_interval": 1, - "searchlimit": 5, + "search_limit": 5, "genres": False, "external_ids": { "discogs": False, @@ -383,6 +385,15 @@ class MusicBrainzPlugin(MetadataSourcePlugin): "extra_tags": [], }, ) + # TODO: Remove in 3.0.0 + with suppress(NotFoundError): + self.config["search_limit"] = self.config["match"][ + "searchlimit" + ].get() + self._log.warning( + "'musicbrainz.searchlimit' option is deprecated and will be " + "removed in 3.0.0. Use 'musicbrainz.search_limit' instead." + ) hostname = self.config["host"].as_str() https = self.config["https"].get(bool) # Only call set_hostname when a custom server is configured. Since @@ -799,7 +810,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): ) try: method = getattr(musicbrainzngs, f"search_{query_type}s") - res = method(limit=self.config["searchlimit"].get(int), **filters) + res = method(limit=self.config["search_limit"].get(int), **filters) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, f"{query_type} search", filters, traceback.format_exc() diff --git a/docs/changelog.rst b/docs/changelog.rst index 1cc537136..d27596b64 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2554,7 +2554,7 @@ Major new features and bigger changes: analysis tool. Thanks to :user:`jmwatte`. :bug:`1343` - A new ``filesize`` field on items indicates the number of bytes in the file. :bug:`1291` -- A new :ref:`searchlimit` configuration option allows you to specify how many +- A new :ref:`search_limit` configuration option allows you to specify how many search results you wish to see when looking up releases at MusicBrainz during import. :bug:`1245` - The importer now records the data source for a match in a new flexible diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index fe22335b0..ed8eefa36 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -27,7 +27,7 @@ Default https: no ratelimit: 1 ratelimit_interval: 1.0 - searchlimit: 5 + search_limit: 5 extra_tags: [] genres: no external_ids: @@ -82,16 +82,21 @@ make the import process quicker. Default: ``yes``. -.. _searchlimit: +.. _search_limit: -searchlimit -+++++++++++ +search_limit +++++++++++++ The number of matches returned when sending search queries to the MusicBrainz server. Default: ``5``. +searchlimit ++++++++++++ + +.. deprecated:: 2.4 Use `search_limit`_. + .. _extra_tags: extra_tags From 20497d3d9b5de56b5483bcd0f44802846e613f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 1 Sep 2025 04:10:53 +0100 Subject: [PATCH 039/301] Dedupe search_limit config option init --- beets/metadata_plugins.py | 7 ++++++- beetsplug/deezer.py | 1 - beetsplug/discogs.py | 1 - beetsplug/musicbrainz.py | 1 - beetsplug/spotify.py | 1 - 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 1cdba5fe2..429a6e716 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -148,7 +148,12 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.config.add({"source_weight": 0.5}) + self.config.add( + { + "search_limit": 5, + "source_weight": 0.5, + } + ) @abc.abstractmethod def album_for_id(self, album_id: str) -> AlbumInfo | None: diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 0e162372a..5fb310bad 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -51,7 +51,6 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): def __init__(self) -> None: super().__init__() - self.config.add({"search_limit": 5}) def commands(self): """Add beet UI commands to interact with Deezer.""" diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index bf41cf38d..21169c6cd 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -96,7 +96,6 @@ class DiscogsPlugin(MetadataSourcePlugin): "separator": ", ", "index_tracks": False, "append_style_genre": False, - "search_limit": 5, } ) self.config["apikey"].redact = True diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 171fe5381..8144c22d3 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -373,7 +373,6 @@ class MusicBrainzPlugin(MetadataSourcePlugin): "https": False, "ratelimit": 1, "ratelimit_interval": 1, - "search_limit": 5, "genres": False, "external_ids": { "discogs": False, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ffeb844a6..44285ad3a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -138,7 +138,6 @@ class SpotifyPlugin( "client_id": "4e414367a1d14c75a5c5129a627fcab8", "client_secret": "f82bdc09b2254f1a8286815d02fd46dc", "tokenfile": "spotify_token.json", - "search_limit": 5, } ) self.config["client_id"].redact = True From 17bc11034fe971614c65450157a8171b8c7970a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 1 Sep 2025 04:36:54 +0100 Subject: [PATCH 040/301] Limit search query results using request parameters --- beetsplug/deezer.py | 7 +++++-- beetsplug/discogs.py | 2 +- beetsplug/musicbrainz.py | 2 +- beetsplug/spotify.py | 8 ++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 5fb310bad..3eaca1e05 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -248,7 +248,10 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): try: response = requests.get( f"{self.search_url}{query_type}", - params={"q": query}, + params={ + "q": query, + "limit": self.config["search_limit"].get(), + }, timeout=10, ) response.raise_for_status() @@ -266,7 +269,7 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): self, query, ) - return response_data[: self.config["search_limit"].get()] + return response_data def deezerupdate(self, items: Sequence[Item], write: bool): """Obtain rank information from Deezer.""" diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 21169c6cd..c1c782f3e 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -249,7 +249,7 @@ class DiscogsPlugin(MetadataSourcePlugin): try: results = self.discogs_client.search(query, type="release") - results.per_page = self.config["search_limit"].as_number() + results.per_page = self.config["search_limit"].get() releases = results.page(1) except CONNECTION_ERRORS: self._log.debug( diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8144c22d3..8e259e94b 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -809,7 +809,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): ) try: method = getattr(musicbrainzngs, f"search_{query_type}s") - res = method(limit=self.config["search_limit"].get(int), **filters) + res = method(limit=self.config["search_limit"].get(), **filters) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, f"{query_type} search", filters, traceback.format_exc() diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 44285ad3a..a0a5c4358 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -442,7 +442,11 @@ class SpotifyPlugin( response = self._handle_response( "get", self.search_url, - params={"q": query, "type": query_type}, + params={ + "q": query, + "type": query_type, + "limit": self.config["search_limit"].get(), + }, ) except APIError as e: self._log.debug("Spotify API error: {}", e) @@ -454,7 +458,7 @@ class SpotifyPlugin( self, query, ) - return response_data[: self.config["search_limit"].get()] + return response_data def commands(self) -> list[ui.Subcommand]: # autotagger import command From 07549ed896d9649562d40b75cd30702e6fa6e975 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:12:56 +0200 Subject: [PATCH 041/301] Moved plugin docs into folder. --- docs/dev/plugins/commands.rst | 50 ++++ docs/dev/plugins/events.rst | 142 +++++++++++ docs/dev/plugins/index.rst | 72 ++++++ docs/dev/{plugins.rst => plugins/other.rst} | 263 -------------------- 4 files changed, 264 insertions(+), 263 deletions(-) create mode 100644 docs/dev/plugins/commands.rst create mode 100644 docs/dev/plugins/events.rst create mode 100644 docs/dev/plugins/index.rst rename docs/dev/{plugins.rst => plugins/other.rst} (56%) diff --git a/docs/dev/plugins/commands.rst b/docs/dev/plugins/commands.rst new file mode 100644 index 000000000..6a9727859 --- /dev/null +++ b/docs/dev/plugins/commands.rst @@ -0,0 +1,50 @@ +.. _add_subcommands: + +Add Commands to the CLI +~~~~~~~~~~~~~~~~~~~~~~~ + +Plugins can add new subcommands to the ``beet`` command-line interface. Define +the plugin class' ``commands()`` method to return a list of ``Subcommand`` +objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.) +Here's an example plugin that adds a simple command: + +:: + + from beets.plugins import BeetsPlugin + from beets.ui import Subcommand + + my_super_command = Subcommand('super', help='do something super') + def say_hi(lib, opts, args): + print("Hello everybody! I'm a plugin!") + my_super_command.func = say_hi + + class SuperPlug(BeetsPlugin): + def commands(self): + return [my_super_command] + +To make a subcommand, invoke the constructor like so: ``Subcommand(name, parser, +help, aliases)``. The ``name`` parameter is the only required one and should +just be the name of your command. ``parser`` can be an `OptionParser instance`_, +but it defaults to an empty parser (you can extend it later). ``help`` is a +description of your command, and ``aliases`` is a list of shorthand versions of +your command name. + +.. _optionparser instance: https://docs.python.org/library/optparse.html + +You'll need to add a function to your command by saying ``mycommand.func = +myfunction``. This function should take the following parameters: ``lib`` (a +beets ``Library`` object) and ``opts`` and ``args`` (command-line options and +arguments as returned by OptionParser.parse_args_). + +.. _optionparser.parse_args: https://docs.python.org/library/optparse.html#parsing-arguments + +The function should use any of the utility functions defined in ``beets.ui``. +Try running ``pydoc beets.ui`` to see what's available. + +You can add command-line options to your new command using the ``parser`` member +of the ``Subcommand`` class, which is a ``CommonOptionsParser`` instance. Just +use it like you would a normal ``OptionParser`` in an independent script. Note +that it offers several methods to add common options: ``--album``, ``--path`` +and ``--format``. This feature is versatile and extensively documented, try +``pydoc beets.ui.CommonOptionsParser`` for more information. + diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst new file mode 100644 index 000000000..704d4c794 --- /dev/null +++ b/docs/dev/plugins/events.rst @@ -0,0 +1,142 @@ +.. _plugin_events: + +Listen for Events +~~~~~~~~~~~~~~~~~ + +Event handlers allow plugins to run code whenever something happens in beets' +operation. For instance, a plugin could write a log message every time an album +is successfully autotagged or update MPD's index whenever the database is +changed. + +You can "listen" for events using ``BeetsPlugin.register_listener``. Here's an +example: + +:: + + from beets.plugins import BeetsPlugin + + def loaded(): + print 'Plugin loaded!' + + class SomePlugin(BeetsPlugin): + def __init__(self): + super().__init__() + self.register_listener('pluginload', loaded) + +Note that if you want to access an attribute of your plugin (e.g. ``config`` or +``log``) you'll have to define a method and not a function. Here is the usual +registration process in this case: + +:: + + from beets.plugins import BeetsPlugin + + class SomePlugin(BeetsPlugin): + def __init__(self): + super().__init__() + self.register_listener('pluginload', self.loaded) + + def loaded(self): + self._log.info('Plugin loaded!') + +The events currently available are: + +- ``pluginload``: called after all the plugins have been loaded after the + ``beet`` command starts +- ``import``: called after a ``beet import`` command finishes (the ``lib`` + keyword argument is a Library object; ``paths`` is a list of paths (strings) + that were imported) +- ``album_imported``: called with an ``Album`` object every time the ``import`` + command finishes adding an album to the library. Parameters: ``lib``, + ``album`` +- ``album_removed``: called with an ``Album`` object every time an album is + removed from the library (even when its file is not deleted from disk). +- ``item_copied``: called with an ``Item`` object whenever its file is copied. + Parameters: ``item``, ``source`` path, ``destination`` path +- ``item_imported``: called with an ``Item`` object every time the importer adds + a singleton to the library (not called for full-album imports). Parameters: + ``lib``, ``item`` +- ``before_item_moved``: called with an ``Item`` object immediately before its + file is moved. Parameters: ``item``, ``source`` path, ``destination`` path +- ``item_moved``: called with an ``Item`` object whenever its file is moved. + Parameters: ``item``, ``source`` path, ``destination`` path +- ``item_linked``: called with an ``Item`` object whenever a symlink is created + for a file. Parameters: ``item``, ``source`` path, ``destination`` path +- ``item_hardlinked``: called with an ``Item`` object whenever a hardlink is + 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). +- ``write``: called with an ``Item`` object, a ``path``, and a ``tags`` + dictionary just before a file's metadata is written to disk (i.e., just before + the file on disk is opened). Event handlers may change the ``tags`` dictionary + to customize the tags that are written to the media file. Event handlers may + also raise a ``library.FileOperationError`` exception to abort the write + operation. Beets will catch that exception, print an error message and + continue. +- ``after_write``: called with an ``Item`` object after a file's metadata is + written to disk (i.e., just after the file on disk is closed). +- ``import_task_created``: called immediately after an import task is + initialized. Plugins can use this to, for example, change imported files of a + task before anything else happens. It's also possible to replace the task with + another task by returning a list of tasks. This list can contain zero or more + ``ImportTask``. Returning an empty list will stop the task. Parameters: + ``task`` (an ``ImportTask``) and ``session`` (an ``ImportSession``). +- ``import_task_start``: called when before an import task begins processing. + Parameters: ``task`` and ``session``. +- ``import_task_apply``: called after metadata changes have been applied in an + import task. This is called on the same thread as the UI, so use this + sparingly and only for tasks that can be done quickly. For most plugins, an + import pipeline stage is a better choice (see :ref:`plugin-stage`). + Parameters: ``task`` and ``session``. +- ``import_task_before_choice``: called after candidate search for an import + task before any decision is made about how/if to import or tag. Can be used to + present information about the task or initiate interaction with the user + before importing occurs. Return an importer action to take a specific action. + Only one handler may return a non-None result. Parameters: ``task`` and + ``session`` +- ``import_task_choice``: called after a decision has been made about an import + task. This event can be used to initiate further interaction with the user. + Use ``task.choice_flag`` to determine or change the action to be taken. + Parameters: ``task`` and ``session``. +- ``import_task_files``: called after an import task finishes manipulating the + filesystem (copying and moving files, writing metadata tags). Parameters: + ``task`` and ``session``. +- ``library_opened``: called after beets starts up and initializes the main + Library object. Parameter: ``lib``. +- ``database_change``: a modification has been made to the library database. The + change might not be committed yet. Parameters: ``lib`` and ``model``. +- ``cli_exit``: called just before the ``beet`` command-line program exits. + Parameter: ``lib``. +- ``import_begin``: called just before a ``beet import`` session starts up. + Parameter: ``session``. +- ``trackinfo_received``: called after metadata for a track item has been + fetched from a data source, such as MusicBrainz. You can modify the tags that + the rest of the pipeline sees on a ``beet import`` operation or during later + adjustments, such as ``mbsync``. Slow handlers of the event can impact the + operation, since the event is fired for any fetched possible match ``before`` + the user (or the autotagger machinery) gets to see the match. Parameter: + ``info``. +- ``albuminfo_received``: like ``trackinfo_received``, the event indicates new + metadata for album items. The parameter is an ``AlbumInfo`` object instead of + a ``TrackInfo``. Parameter: ``info``. +- ``before_choose_candidate``: called before the user is prompted for a decision + during a ``beet import`` interactive session. Plugins can use this event for + :ref:`appending choices to the prompt <append_prompt_choices>` by returning a + list of ``PromptChoices``. Parameters: ``task`` and ``session``. +- ``mb_track_extract``: called after the metadata is obtained from MusicBrainz. + The parameter is a ``dict`` containing the tags retrieved from MusicBrainz for + a track. Plugins must return a new (potentially empty) ``dict`` with + additional ``field: value`` pairs, which the autotagger will apply to the + item, as flexible attributes if ``field`` is not a hardcoded field. Fields + already present on the track are overwritten. Parameter: ``data`` +- ``mb_album_extract``: Like ``mb_track_extract``, but for album tags. + Overwrites tags set at the track level, if they have the same ``field``. + Parameter: ``data`` + +The included ``mpdupdate`` plugin provides an example use case for event +listeners. \ No newline at end of file diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst new file mode 100644 index 000000000..6c3578e4a --- /dev/null +++ b/docs/dev/plugins/index.rst @@ -0,0 +1,72 @@ +Plugin Development Guide +======================== + +Beets plugins are Python modules or packages that extend the core functionality +of beets. The plugin system is designed to be flexible, allowing developers to +add virtually any type of features. + +.. _writing-plugins: + +Writing Plugins +--------------- + +A beets plugin is just a Python module or package inside the ``beetsplug`` +namespace package. (Check out `this article`_ and `this Stack Overflow +question`_ if you haven't heard about namespace packages.) So, to make one, +create a directory called ``beetsplug`` and add either your plugin module: + +:: + + beetsplug/ + myawesomeplugin.py + +or your plugin subpackage: + +:: + + beetsplug/ + myawesomeplugin/ + __init__.py + myawesomeplugin.py + +.. attention:: + + You do not anymore need to add a ``__init__.py`` file to the ``beetsplug`` + directory. Python treats your plugin as a namespace package automatically, + thus we do not depend on ``pkgutil``-based setup in the ``__init__.py`` file + anymore. + +.. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages + +.. _this stack overflow question: https://stackoverflow.com/a/27586272/9582674 + +The meat of your plugin goes in ``myawesomeplugin.py``. There, you'll have to +import ``BeetsPlugin`` from ``beets.plugins`` and subclass it, for example + +.. code-block:: python + + from beets.plugins import BeetsPlugin + + + class MyAwesomePlugin(BeetsPlugin): + pass + +Once you have your ``BeetsPlugin`` subclass, there's a variety of things your +plugin can do. (Read on!) + +To use your new plugin, package your plugin (see how to do this with poetry_ or +setuptools_, for example) and install it into your ``beets`` virtual +environment. Then, add your plugin to beets configuration + +.. _poetry: https://python-poetry.org/docs/pyproject/#packages + +.. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages + +.. code-block:: yaml + + # config.yaml + plugins: + - myawesomeplugin + +and you're good to go! + diff --git a/docs/dev/plugins.rst b/docs/dev/plugins/other.rst similarity index 56% rename from docs/dev/plugins.rst rename to docs/dev/plugins/other.rst index 5ee07347f..9e4589ce7 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins/other.rst @@ -1,267 +1,4 @@ -Plugin Development Guide -======================== -Beets plugins are Python modules or packages that extend the core functionality -of beets. The plugin system is designed to be flexible, allowing developers to -add virtually any type of features. - -.. _writing-plugins: - -Writing Plugins ---------------- - -A beets plugin is just a Python module or package inside the ``beetsplug`` -namespace package. (Check out `this article`_ and `this Stack Overflow -question`_ if you haven't heard about namespace packages.) So, to make one, -create a directory called ``beetsplug`` and add either your plugin module: - -:: - - beetsplug/ - myawesomeplugin.py - -or your plugin subpackage: - -:: - - beetsplug/ - myawesomeplugin/ - __init__.py - myawesomeplugin.py - -.. attention:: - - You do not anymore need to add a ``__init__.py`` file to the ``beetsplug`` - directory. Python treats your plugin as a namespace package automatically, - thus we do not depend on ``pkgutil``-based setup in the ``__init__.py`` file - anymore. - -.. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages - -.. _this stack overflow question: https://stackoverflow.com/a/27586272/9582674 - -The meat of your plugin goes in ``myawesomeplugin.py``. There, you'll have to -import ``BeetsPlugin`` from ``beets.plugins`` and subclass it, for example - -.. code-block:: python - - from beets.plugins import BeetsPlugin - - - class MyAwesomePlugin(BeetsPlugin): - pass - -Once you have your ``BeetsPlugin`` subclass, there's a variety of things your -plugin can do. (Read on!) - -To use your new plugin, package your plugin (see how to do this with poetry_ or -setuptools_, for example) and install it into your ``beets`` virtual -environment. Then, add your plugin to beets configuration - -.. _poetry: https://python-poetry.org/docs/pyproject/#packages - -.. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages - -.. code-block:: yaml - - # config.yaml - plugins: - - myawesomeplugin - -and you're good to go! - -.. _add_subcommands: - -Add Commands to the CLI -~~~~~~~~~~~~~~~~~~~~~~~ - -Plugins can add new subcommands to the ``beet`` command-line interface. Define -the plugin class' ``commands()`` method to return a list of ``Subcommand`` -objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.) -Here's an example plugin that adds a simple command: - -:: - - from beets.plugins import BeetsPlugin - from beets.ui import Subcommand - - my_super_command = Subcommand('super', help='do something super') - def say_hi(lib, opts, args): - print("Hello everybody! I'm a plugin!") - my_super_command.func = say_hi - - class SuperPlug(BeetsPlugin): - def commands(self): - return [my_super_command] - -To make a subcommand, invoke the constructor like so: ``Subcommand(name, parser, -help, aliases)``. The ``name`` parameter is the only required one and should -just be the name of your command. ``parser`` can be an `OptionParser instance`_, -but it defaults to an empty parser (you can extend it later). ``help`` is a -description of your command, and ``aliases`` is a list of shorthand versions of -your command name. - -.. _optionparser instance: https://docs.python.org/library/optparse.html - -You'll need to add a function to your command by saying ``mycommand.func = -myfunction``. This function should take the following parameters: ``lib`` (a -beets ``Library`` object) and ``opts`` and ``args`` (command-line options and -arguments as returned by OptionParser.parse_args_). - -.. _optionparser.parse_args: https://docs.python.org/library/optparse.html#parsing-arguments - -The function should use any of the utility functions defined in ``beets.ui``. -Try running ``pydoc beets.ui`` to see what's available. - -You can add command-line options to your new command using the ``parser`` member -of the ``Subcommand`` class, which is a ``CommonOptionsParser`` instance. Just -use it like you would a normal ``OptionParser`` in an independent script. Note -that it offers several methods to add common options: ``--album``, ``--path`` -and ``--format``. This feature is versatile and extensively documented, try -``pydoc beets.ui.CommonOptionsParser`` for more information. - -.. _plugin_events: - -Listen for Events -~~~~~~~~~~~~~~~~~ - -Event handlers allow plugins to run code whenever something happens in beets' -operation. For instance, a plugin could write a log message every time an album -is successfully autotagged or update MPD's index whenever the database is -changed. - -You can "listen" for events using ``BeetsPlugin.register_listener``. Here's an -example: - -:: - - from beets.plugins import BeetsPlugin - - def loaded(): - print 'Plugin loaded!' - - class SomePlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.register_listener('pluginload', loaded) - -Note that if you want to access an attribute of your plugin (e.g. ``config`` or -``log``) you'll have to define a method and not a function. Here is the usual -registration process in this case: - -:: - - from beets.plugins import BeetsPlugin - - class SomePlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.register_listener('pluginload', self.loaded) - - def loaded(self): - self._log.info('Plugin loaded!') - -The events currently available are: - -- ``pluginload``: called after all the plugins have been loaded after the - ``beet`` command starts -- ``import``: called after a ``beet import`` command finishes (the ``lib`` - keyword argument is a Library object; ``paths`` is a list of paths (strings) - that were imported) -- ``album_imported``: called with an ``Album`` object every time the ``import`` - command finishes adding an album to the library. Parameters: ``lib``, - ``album`` -- ``album_removed``: called with an ``Album`` object every time an album is - removed from the library (even when its file is not deleted from disk). -- ``item_copied``: called with an ``Item`` object whenever its file is copied. - Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_imported``: called with an ``Item`` object every time the importer adds - a singleton to the library (not called for full-album imports). Parameters: - ``lib``, ``item`` -- ``before_item_moved``: called with an ``Item`` object immediately before its - file is moved. Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_moved``: called with an ``Item`` object whenever its file is moved. - Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_linked``: called with an ``Item`` object whenever a symlink is created - for a file. Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_hardlinked``: called with an ``Item`` object whenever a hardlink is - 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). -- ``write``: called with an ``Item`` object, a ``path``, and a ``tags`` - dictionary just before a file's metadata is written to disk (i.e., just before - the file on disk is opened). Event handlers may change the ``tags`` dictionary - to customize the tags that are written to the media file. Event handlers may - also raise a ``library.FileOperationError`` exception to abort the write - operation. Beets will catch that exception, print an error message and - continue. -- ``after_write``: called with an ``Item`` object after a file's metadata is - written to disk (i.e., just after the file on disk is closed). -- ``import_task_created``: called immediately after an import task is - initialized. Plugins can use this to, for example, change imported files of a - task before anything else happens. It's also possible to replace the task with - another task by returning a list of tasks. This list can contain zero or more - ``ImportTask``. Returning an empty list will stop the task. Parameters: - ``task`` (an ``ImportTask``) and ``session`` (an ``ImportSession``). -- ``import_task_start``: called when before an import task begins processing. - Parameters: ``task`` and ``session``. -- ``import_task_apply``: called after metadata changes have been applied in an - import task. This is called on the same thread as the UI, so use this - sparingly and only for tasks that can be done quickly. For most plugins, an - import pipeline stage is a better choice (see :ref:`plugin-stage`). - Parameters: ``task`` and ``session``. -- ``import_task_before_choice``: called after candidate search for an import - task before any decision is made about how/if to import or tag. Can be used to - present information about the task or initiate interaction with the user - before importing occurs. Return an importer action to take a specific action. - Only one handler may return a non-None result. Parameters: ``task`` and - ``session`` -- ``import_task_choice``: called after a decision has been made about an import - task. This event can be used to initiate further interaction with the user. - Use ``task.choice_flag`` to determine or change the action to be taken. - Parameters: ``task`` and ``session``. -- ``import_task_files``: called after an import task finishes manipulating the - filesystem (copying and moving files, writing metadata tags). Parameters: - ``task`` and ``session``. -- ``library_opened``: called after beets starts up and initializes the main - Library object. Parameter: ``lib``. -- ``database_change``: a modification has been made to the library database. The - change might not be committed yet. Parameters: ``lib`` and ``model``. -- ``cli_exit``: called just before the ``beet`` command-line program exits. - Parameter: ``lib``. -- ``import_begin``: called just before a ``beet import`` session starts up. - Parameter: ``session``. -- ``trackinfo_received``: called after metadata for a track item has been - fetched from a data source, such as MusicBrainz. You can modify the tags that - the rest of the pipeline sees on a ``beet import`` operation or during later - adjustments, such as ``mbsync``. Slow handlers of the event can impact the - operation, since the event is fired for any fetched possible match ``before`` - the user (or the autotagger machinery) gets to see the match. Parameter: - ``info``. -- ``albuminfo_received``: like ``trackinfo_received``, the event indicates new - metadata for album items. The parameter is an ``AlbumInfo`` object instead of - a ``TrackInfo``. Parameter: ``info``. -- ``before_choose_candidate``: called before the user is prompted for a decision - during a ``beet import`` interactive session. Plugins can use this event for - :ref:`appending choices to the prompt <append_prompt_choices>` by returning a - list of ``PromptChoices``. Parameters: ``task`` and ``session``. -- ``mb_track_extract``: called after the metadata is obtained from MusicBrainz. - The parameter is a ``dict`` containing the tags retrieved from MusicBrainz for - a track. Plugins must return a new (potentially empty) ``dict`` with - additional ``field: value`` pairs, which the autotagger will apply to the - item, as flexible attributes if ``field`` is not a hardcoded field. Fields - already present on the track are overwritten. Parameter: ``data`` -- ``mb_album_extract``: Like ``mb_track_extract``, but for album tags. - Overwrites tags set at the track level, if they have the same ``field``. - Parameter: ``data`` - -The included ``mpdupdate`` plugin provides an example use case for event -listeners. Extend the Autotagger ~~~~~~~~~~~~~~~~~~~~~ From 69b47b3071ba71946943a394e2a4a45288549c05 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:13:47 +0200 Subject: [PATCH 042/301] A number of smaller additions. --- docs/_templates/autosummary/class.rst | 2 +- docs/api/plugins.rst | 8 ++++++++ docs/dev/index.rst | 10 +++++++--- docs/plugins/index.rst | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index fdf251b15..586b207b7 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -1,4 +1,4 @@ -{{ fullname | escape | underline}} +{{ name | escape | underline}} .. currentmodule:: {{ module }} diff --git a/docs/api/plugins.rst b/docs/api/plugins.rst index 9320425db..2ce8dbed6 100644 --- a/docs/api/plugins.rst +++ b/docs/api/plugins.rst @@ -7,3 +7,11 @@ Plugins :toctree: generated/ BeetsPlugin + +.. currentmodule:: beets.metadata_plugins + +.. autosummary:: + :toctree: generated/ + + MetadataSourcePlugin + SearchApiMetadataSourcePlugin diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 7f8af5276..8d9200f67 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -4,15 +4,19 @@ For Developers This section contains information for developers. Read on if you're interested 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. +See also the documentation for the MediaFile_ and Confuse_ libraries. These are +maintained by the beets team and used to read and write metadata tags and manage +configuration files, respectively. + +.. _confuse: https://confuse.readthedocs.io/en/latest/ .. _mediafile: https://mediafile.readthedocs.io/en/latest/ .. toctree:: :maxdepth: 1 + :titlesonly: - plugins + plugins/index library importer cli diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1dfa3aae2..960ecfbef 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -5,7 +5,7 @@ Plugins extend beets' core functionality. They add new commands, fetch additional data during import, provide new metadata sources, and much more. If beets by itself doesn't do what you want it to, you may just need to enable a plugin---or, if you want to do something new, :doc:`writing a plugin -</dev/plugins>` is easy if you know a little Python. +</dev/plugins/index>` is easy if you know a little Python. .. _using-plugins: From 35ea9a7011db46f4f95cb02a89211257b4430a1b Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:18:34 +0200 Subject: [PATCH 043/301] Enhanced index, changed wording slightly --- docs/dev/plugins/index.rst | 94 +++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index 6c3578e4a..491e48e0e 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -1,66 +1,58 @@ -Plugin Development Guide -======================== +Plugin Development +================== Beets plugins are Python modules or packages that extend the core functionality of beets. The plugin system is designed to be flexible, allowing developers to -add virtually any type of features. +add virtually any type of features to beets. -.. _writing-plugins: +For instance you can create plugins that add new commands to the command-line +interface, listen for events in the beets lifecycle or extend the autotagger +with new metadata sources. .. _writing-plugins: -Writing Plugins ---------------- +Basic Plugin Setup +------------------ A beets plugin is just a Python module or package inside the ``beetsplug`` -namespace package. (Check out `this article`_ and `this Stack Overflow -question`_ if you haven't heard about namespace packages.) So, to make one, -create a directory called ``beetsplug`` and add either your plugin module: +namespace [namespace]_ package. To create the basic plugin layout, create a +directory called ``beetsplug`` and add either your plugin module: -:: +.. code-block:: shell beetsplug/ - myawesomeplugin.py + └── myawesomeplugin.py -or your plugin subpackage: +or your plugin subpackage -:: +.. code-block:: shell beetsplug/ - myawesomeplugin/ - __init__.py - myawesomeplugin.py + └── myawesomeplugin/ + ├── __init__.py + └── myawesomeplugin.py .. attention:: - You do not anymore need to add a ``__init__.py`` file to the ``beetsplug`` + You do not need to add an ``__init__.py`` file to the ``beetsplug`` directory. Python treats your plugin as a namespace package automatically, thus we do not depend on ``pkgutil``-based setup in the ``__init__.py`` file anymore. -.. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages - -.. _this stack overflow question: https://stackoverflow.com/a/27586272/9582674 - -The meat of your plugin goes in ``myawesomeplugin.py``. There, you'll have to -import ``BeetsPlugin`` from ``beets.plugins`` and subclass it, for example +The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to +extend the :class:`beets.plugins.BeetsPlugin` abstract base class [baseclass]_ . +For instance, a minimal plugin without any functionality would look like this: .. code-block:: python + # beetsplug/myawesomeplugin.py from beets.plugins import BeetsPlugin class MyAwesomePlugin(BeetsPlugin): pass -Once you have your ``BeetsPlugin`` subclass, there's a variety of things your -plugin can do. (Read on!) - -To use your new plugin, package your plugin (see how to do this with poetry_ or -setuptools_, for example) and install it into your ``beets`` virtual -environment. Then, add your plugin to beets configuration - -.. _poetry: https://python-poetry.org/docs/pyproject/#packages - -.. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages +To use your new plugin, you need to package [packaging]_ your plugin and install +it into your ``beets`` (virtual) environment. To enable your plugin, add it it +to the beets configuration .. code-block:: yaml @@ -70,3 +62,39 @@ environment. Then, add your plugin to beets configuration and you're good to go! +.. [namespace] Check out `this article`_ and `this Stack Overflow question`_ if + you haven't heard about namespace packages. + +.. [baseclass] Abstract base classes allow us to define a contract which any + plugin must follow. This is a common paradigm in object-oriented + programming, and it helps to ensure that plugins are implemented in a + consistent way. For more information, see for example pep-3119_. + +.. [packaging] There are a variety of packaging tools available for python, for + example you can use poetry_, setuptools_ or hatchling_. + +.. _hatchling: https://hatch.pypa.io/latest/config/build/#build-system + +.. _pep-3119: https://peps.python.org/pep-3119/#rationale + +.. _poetry: https://python-poetry.org/docs/pyproject/#packages + +.. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages + +.. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages + +.. _this stack overflow question: https://stackoverflow.com/a/27586272/9582674 + +More information +---------------- + +For more information on writing plugins, feel free to check out the following +resources: + +.. toctree:: + :maxdepth: 2 + :includehidden: + + commands + events + other From 6627a0740c950cdf4e5c7ada3f1645ae1c85d552 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:20:08 +0200 Subject: [PATCH 044/301] Changed events doc list to table. Added references to api. --- docs/dev/plugins/events.rst | 292 ++++++++++++++++++++++-------------- 1 file changed, 178 insertions(+), 114 deletions(-) diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst index 704d4c794..3895d35aa 100644 --- a/docs/dev/plugins/events.rst +++ b/docs/dev/plugins/events.rst @@ -1,142 +1,206 @@ .. _plugin_events: Listen for Events -~~~~~~~~~~~~~~~~~ +================= -Event handlers allow plugins to run code whenever something happens in beets' -operation. For instance, a plugin could write a log message every time an album +.. currentmodule:: beets.plugins + +Event handlers allow plugins to hook into whenever something happens in beets' +operations. For instance, a plugin could write a log message every time an album is successfully autotagged or update MPD's index whenever the database is changed. -You can "listen" for events using ``BeetsPlugin.register_listener``. Here's an -example: +You can "listen" for events using :py:meth:`BeetsPlugin.register_listener`. +Here's an example: -:: +.. code-block:: python from beets.plugins import BeetsPlugin + def loaded(): - print 'Plugin loaded!' + print("Plugin loaded!") + class SomePlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.register_listener('pluginload', loaded) + def __init__(self): + super().__init__() + self.register_listener("pluginload", loaded) Note that if you want to access an attribute of your plugin (e.g. ``config`` or ``log``) you'll have to define a method and not a function. Here is the usual registration process in this case: -:: +.. code-block:: python from beets.plugins import BeetsPlugin + class SomePlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.register_listener('pluginload', self.loaded) + def __init__(self): + super().__init__() + self.register_listener("pluginload", self.loaded) - def loaded(self): - self._log.info('Plugin loaded!') + def loaded(self): + self._log.info("Plugin loaded!") -The events currently available are: +.. list-table:: Plugin Events + :widths: 15 25 60 + :header-rows: 1 -- ``pluginload``: called after all the plugins have been loaded after the - ``beet`` command starts -- ``import``: called after a ``beet import`` command finishes (the ``lib`` - keyword argument is a Library object; ``paths`` is a list of paths (strings) - that were imported) -- ``album_imported``: called with an ``Album`` object every time the ``import`` - command finishes adding an album to the library. Parameters: ``lib``, - ``album`` -- ``album_removed``: called with an ``Album`` object every time an album is - removed from the library (even when its file is not deleted from disk). -- ``item_copied``: called with an ``Item`` object whenever its file is copied. - Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_imported``: called with an ``Item`` object every time the importer adds - a singleton to the library (not called for full-album imports). Parameters: - ``lib``, ``item`` -- ``before_item_moved``: called with an ``Item`` object immediately before its - file is moved. Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_moved``: called with an ``Item`` object whenever its file is moved. - Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_linked``: called with an ``Item`` object whenever a symlink is created - for a file. Parameters: ``item``, ``source`` path, ``destination`` path -- ``item_hardlinked``: called with an ``Item`` object whenever a hardlink is - 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). -- ``write``: called with an ``Item`` object, a ``path``, and a ``tags`` - dictionary just before a file's metadata is written to disk (i.e., just before - the file on disk is opened). Event handlers may change the ``tags`` dictionary - to customize the tags that are written to the media file. Event handlers may - also raise a ``library.FileOperationError`` exception to abort the write - operation. Beets will catch that exception, print an error message and - continue. -- ``after_write``: called with an ``Item`` object after a file's metadata is - written to disk (i.e., just after the file on disk is closed). -- ``import_task_created``: called immediately after an import task is - initialized. Plugins can use this to, for example, change imported files of a - task before anything else happens. It's also possible to replace the task with - another task by returning a list of tasks. This list can contain zero or more - ``ImportTask``. Returning an empty list will stop the task. Parameters: - ``task`` (an ``ImportTask``) and ``session`` (an ``ImportSession``). -- ``import_task_start``: called when before an import task begins processing. - Parameters: ``task`` and ``session``. -- ``import_task_apply``: called after metadata changes have been applied in an - import task. This is called on the same thread as the UI, so use this - sparingly and only for tasks that can be done quickly. For most plugins, an - import pipeline stage is a better choice (see :ref:`plugin-stage`). - Parameters: ``task`` and ``session``. -- ``import_task_before_choice``: called after candidate search for an import - task before any decision is made about how/if to import or tag. Can be used to - present information about the task or initiate interaction with the user - before importing occurs. Return an importer action to take a specific action. - Only one handler may return a non-None result. Parameters: ``task`` and - ``session`` -- ``import_task_choice``: called after a decision has been made about an import - task. This event can be used to initiate further interaction with the user. - Use ``task.choice_flag`` to determine or change the action to be taken. - Parameters: ``task`` and ``session``. -- ``import_task_files``: called after an import task finishes manipulating the - filesystem (copying and moving files, writing metadata tags). Parameters: - ``task`` and ``session``. -- ``library_opened``: called after beets starts up and initializes the main - Library object. Parameter: ``lib``. -- ``database_change``: a modification has been made to the library database. The - change might not be committed yet. Parameters: ``lib`` and ``model``. -- ``cli_exit``: called just before the ``beet`` command-line program exits. - Parameter: ``lib``. -- ``import_begin``: called just before a ``beet import`` session starts up. - Parameter: ``session``. -- ``trackinfo_received``: called after metadata for a track item has been - fetched from a data source, such as MusicBrainz. You can modify the tags that - the rest of the pipeline sees on a ``beet import`` operation or during later - adjustments, such as ``mbsync``. Slow handlers of the event can impact the - operation, since the event is fired for any fetched possible match ``before`` - the user (or the autotagger machinery) gets to see the match. Parameter: - ``info``. -- ``albuminfo_received``: like ``trackinfo_received``, the event indicates new - metadata for album items. The parameter is an ``AlbumInfo`` object instead of - a ``TrackInfo``. Parameter: ``info``. -- ``before_choose_candidate``: called before the user is prompted for a decision - during a ``beet import`` interactive session. Plugins can use this event for - :ref:`appending choices to the prompt <append_prompt_choices>` by returning a - list of ``PromptChoices``. Parameters: ``task`` and ``session``. -- ``mb_track_extract``: called after the metadata is obtained from MusicBrainz. - The parameter is a ``dict`` containing the tags retrieved from MusicBrainz for - a track. Plugins must return a new (potentially empty) ``dict`` with - additional ``field: value`` pairs, which the autotagger will apply to the - item, as flexible attributes if ``field`` is not a hardcoded field. Fields - already present on the track are overwritten. Parameter: ``data`` -- ``mb_album_extract``: Like ``mb_track_extract``, but for album tags. - Overwrites tags set at the track level, if they have the same ``field``. - Parameter: ``data`` + - - Event + - Parameters + - Description + - - `pluginload` + - + - called after all the plugins have been loaded after the ``beet`` command + starts + - - `import` + - :py:class:`lib <beets.library.Library>`, ``paths`` is a list of paths + (strings) + - called after the ``import`` command finishes. + - - `album_imported` + - :py:class:`lib <beets.library.Library>`, :py:class:`album + <beets.library.Album>` + - called every time the ``import`` command finishes adding an album to the + library + - - `album_removed` + - :py:class:`lib <beets.library.Library>`, :py:class:`album + <beets.library.Album>` + - called every time an album is removed from the library (even when its + file is not deleted from disk) + - - `item_copied` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called whenever an item file is copied + - - `item_imported` + - :py:class:`lib <beets.library.Library>`, :py:class:`item + <beets.library.Item>` + - called every time the importer adds a singleton to the library (not + called for full-album imports) + - - `before_item_imported` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called with an item object immediately before it is imported + - - `before_item_moved` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called with an ``Item`` object immediately before its file is moved + - - `item_moved` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called with an ``Item`` object whenever its file is moved + - - `item_linked` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called with an ``Item`` object whenever a symlink is created for a file + - - `item_hardlinked` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called with an ``Item`` object whenever a hardlink is created for a file + - - `item_reflinked` + - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` + path + - called with an ``Item`` object whenever a reflink is created for a file + - - `item_removed` + - :py:class:`item <beets.library.Item>` + - 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). + - - `write` + - :py:class:`item <beets.library.Item>`, ``path``, ``tags`` dictionary + - called with an ``Item`` object, a ``path``, and a ``tags`` dictionary + just before a file's metadata is written to disk (i.e., just before the + file on disk is opened). Event handlers may change the ``tags`` + dictionary to customize the tags that are written to the media file. + Event handlers may also raise a ``library.FileOperationError`` exception + to abort the write operation. Beets will catch that exception, print an + error message, and continue. + - - `after_write` + - :py:class:`item <beets.library.Item>` + - called with an ``Item`` object after a file's metadata is written to + disk (i.e., just after the file on disk is closed). + - - `import_task_created` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called immediately after an import task is initialized. Plugins can use + this to, for example, change imported files of a task before anything + else happens. It's also possible to replace the task with another task + by returning a list of tasks. This list can contain zero or more + ImportTasks. Returning an empty list will stop the task. + - - `import_task_start` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called when before an import task begins processing. + - - `import_task_apply` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called after metadata changes have been applied in an import task. This + is called on the same thread as the UI, so use this sparingly and only + for tasks that can be done quickly. For most plugins, an import pipeline + stage is a better choice (see :ref:`plugin-stage`). + - - `import_task_before_choice` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called after candidate search for an import task before any decision is + made about how/if to import or tag. Can be used to present information + about the task or initiate interaction with the user before importing + occurs. Return an importer action to take a specific action. Only one + handler may return a non-None result. + - - `import_task_choice` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called after a decision has been made about an import task. This event + can be used to initiate further interaction with the user. Use + ``task.choice_flag`` to determine or change the action to be taken. + - - `import_task_files` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called after an import task finishes manipulating the filesystem + (copying and moving files, writing metadata tags). + - - `library_opened` + - :py:class:`lib <beets.library.Library>` + - called after beets starts up and initializes the main Library object. + - - `database_change` + - :py:class:`lib <beets.library.Library>`, :py:class:`model + <beets.library.Model>` + - a modification has been made to the library database. The change might + not be committed yet. + - - `cli_exit` + - :py:class:`lib <beets.library.Library>` + - called just before the ``beet`` command-line program exits. + - - `import_begin` + - :py:class:`session <beets.importer.ImportSession>` + - called just before a ``beet import`` session starts up. + - - `trackinfo_received` + - :py:class:`info <beets.autotag.TrackInfo>` + - called after metadata for a track item has been fetched from a data + source, such as MusicBrainz. You can modify the tags that the rest of + the pipeline sees on a ``beet import`` operation or during later + adjustments, such as ``mbsync``. + - - `albuminfo_received` + - :py:class:`info <beets.autotag.AlbumInfo>` + - like `trackinfo_received`, the event indicates new metadata for album + items. + - - `before_choose_candidate` + - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session + <beets.importer.ImportSession>` + - called before the user is prompted for a decision during a ``beet + import`` interactive session. Plugins can use this event for + :ref:`appending choices to the prompt <append_prompt_choices>` by + returning a list of ``PromptChoices``. + - - `mb_track_extract` + - :py:class:`data <dict>` + - called after the metadata is obtained from MusicBrainz. The parameter is + a ``dict`` containing the tags retrieved from MusicBrainz for a track. + Plugins must return a new (potentially empty) ``dict`` with additional + ``field: value`` pairs, which the autotagger will apply to the item, as + flexible attributes if ``field`` is not a hardcoded field. Fields + already present on the track are overwritten. + - - `mb_album_extract` + - :py:class:`data <dict>` + - Like `mb_track_extract`, but for album tags. Overwrites tags set at the + track level, if they have the same ``field``. The included ``mpdupdate`` plugin provides an example use case for event -listeners. \ No newline at end of file +listeners. From 1dfd232270e61c23fe9ce385ad0c651b504ca75d Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:21:17 +0200 Subject: [PATCH 045/301] further reading formatting and changed extending the autotagger to conform to new metadatasource plugin. --- docs/dev/plugins/other.rst | 169 +++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 84 deletions(-) diff --git a/docs/dev/plugins/other.rst b/docs/dev/plugins/other.rst index 9e4589ce7..20441c0e9 100644 --- a/docs/dev/plugins/other.rst +++ b/docs/dev/plugins/other.rst @@ -1,52 +1,41 @@ +Further Reading +=============== +.. contents:: Table of Contents + :local: + :depth: 2 -Extend the Autotagger -~~~~~~~~~~~~~~~~~~~~~ +Extending the Autotagger +------------------------ -Plugins can also enhance the functionality of the autotagger. For a -comprehensive example, try looking at the ``chroma`` plugin, which is included -with beets. +.. currentmodule:: beets.metadata_plugins -A plugin can extend three parts of the autotagger's process: the track distance -function, the album distance function, and the initial MusicBrainz search. The -distance functions determine how "good" a match is at the track and album -levels; the initial search controls which candidates are presented to the -matching algorithm. Plugins implement these extensions by implementing four -methods on the plugin class: +Plugins can also be used to extend the autotagger functions i.e. the metadata +lookup from external sources. For this your plugin has to extend the +:py:class:`MetadataSourcePlugin` base class and implement all abstract methods. -- ``track_distance(self, item, info)``: adds a component to the distance - function (i.e., the similarity metric) for individual tracks. ``item`` is the - track to be matched (an Item object) and ``info`` is the TrackInfo object that - is proposed as a match. Should return a ``(dist, dist_max)`` pair of floats - indicating the distance. -- ``album_distance(self, items, album_info, mapping)``: like the above, but - compares a list of items (representing an album) to an album-level MusicBrainz - entry. ``items`` is a list of Item objects; ``album_info`` is an AlbumInfo - object; and ``mapping`` is a dictionary that maps Items to their corresponding - TrackInfo objects. -- ``candidates(self, items, artist, album, va_likely)``: given a list of items - comprised by an album to be matched, return a list of ``AlbumInfo`` objects - for candidate albums to be compared and matched. -- ``item_candidates(self, item, artist, album)``: given a *singleton* item, - return a list of ``TrackInfo`` objects for candidate tracks to be compared and - matched. -- ``album_for_id(self, album_id)``: given an ID from user input or an album's - tags, return a candidate AlbumInfo object (or None). -- ``track_for_id(self, track_id)``: given an ID from user input or a file's - tags, return a candidate TrackInfo object (or None). +On metadata lookup, the autotagger will try to find matching candidates from all +enabled metadata source plugins. To do this, we will call the +:py:meth:`MetadataSourcePlugin.candidates` (or +:py:meth:`MetadataSourcePlugin.item_candidates`) with all available (local) +metadata. The list of retrieved candidates will be ranked by their +:py:meth:`MetadataSourcePlugin.album_distance` (or +:py:meth:`MetadataSourcePlugin.track_distance`) and be presented to the user for +selection (or automatically selected if the threshold is met). -When implementing these functions, you may want to use the functions from the -``beets.autotag`` and ``beets.autotag.mb`` modules, both of which have somewhat -helpful docstrings. +Please have a look at the ``beets.autotag`` and especially the +``beets.metadata_plugin`` modules for more information. Additionally, for a +comprehensive example, see the ``musicbrainz`` or ``chroma`` plugins, which are +included with beets. Read Configuration Options -~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------- Plugins can configure themselves using the ``config.yaml`` file. You can read -configuration values in two ways. The first is to use ``self.config`` within -your plugin class. This gives you a view onto the configuration values in a -section with the same name as your plugin's module. For example, if your plugin -is in ``greatplugin.py``, then ``self.config`` will refer to options under the +configuration values in two ways. The first is to use `self.config` within your +plugin class. This gives you a view onto the configuration values in a section +with the same name as your plugin's module. For example, if your plugin is in +``greatplugin.py``, then `self.config` will refer to options under the ``greatplugin:`` section of the config file. For example, if you have a configuration value called "foo", then users can put @@ -58,26 +47,26 @@ this in their ``config.yaml``: foo: bar 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_ +plugin's code. The `self.config` object is a *view* as defined by the Confuse_ library. .. _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 +import the `config` object from the `beets` module. That is, just put ``from beets import config`` at the top of your plugin and access values from there. If your plugin provides configuration values for sensitive data (e.g., passwords, API keys, ...), you should add these to the config so they can be redacted automatically when users dump their config. This can be done by setting -each value's ``redact`` flag, like so: +each value's `redact` flag, like so: :: self.config['password'].redact = True Add Path Format Functions and Fields -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------------------ Beets supports *function calls* in its path format syntax (see :doc:`/reference/pathformat`). Beets includes a few built-in functions, but @@ -86,18 +75,19 @@ dictionary. Here's an example: -:: +.. code-block:: python class MyPlugin(BeetsPlugin): def __init__(self): super().__init__() - self.template_funcs['initial'] = _tmpl_initial + self.template_funcs["initial"] = _tmpl_initial + def _tmpl_initial(text: str) -> str: if text: return text[0].upper() else: - return u'' + return "" This plugin provides a function ``%initial`` to path templates where ``%initial{$artist}`` expands to the artist's initial (its capitalized first @@ -108,12 +98,13 @@ Plugins can also add template *fields*, which are computed values referenced as ``Item`` object to the ``template_fields`` dictionary on the plugin object. Here's an example that adds a ``$disc_and_track`` field: -:: +.. code-block:: python class MyPlugin(BeetsPlugin): def __init__(self): super().__init__() - self.template_fields['disc_and_track'] = _tmpl_disc_and_track + self.template_fields["disc_and_track"] = _tmpl_disc_and_track + def _tmpl_disc_and_track(item: Item) -> str: """Expand to the disc number and track number if this is a @@ -133,7 +124,7 @@ template fields by adding a function accepting an ``Album`` argument to the ``album_template_fields`` dict. Extend MediaFile -~~~~~~~~~~~~~~~~ +---------------- MediaFile_ is the file tag abstraction layer that beets uses to make cross-format metadata manipulation simple. Plugins can add fields to MediaFile @@ -141,34 +132,34 @@ to extend the kinds of metadata that they can easily manage. The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to file tags. If you have created a descriptor you can add it through your plugins -:py:meth:`beets.plugins.BeetsPlugin.add_media_field()` method. +:py:meth:`beets.plugins.BeetsPlugin.add_media_field()`` method. .. _mediafile: https://mediafile.readthedocs.io/en/latest/ Here's an example plugin that provides a meaningless new field "foo": -:: +.. code-block:: python class FooPlugin(BeetsPlugin): def __init__(self): field = mediafile.MediaField( - mediafile.MP3DescStorageStyle(u'foo'), - mediafile.StorageStyle(u'foo') + mediafile.MP3DescStorageStyle("foo"), mediafile.StorageStyle("foo") ) - self.add_media_field('foo', field) + self.add_media_field("foo", field) + FooPlugin() - item = Item.from_path('/path/to/foo/tag.mp3') - assert item['foo'] == 'spam' + item = Item.from_path("/path/to/foo/tag.mp3") + assert item["foo"] == "spam" - item['foo'] == 'ham' + item["foo"] == "ham" item.write() # The "foo" tag of the file is now "ham" .. _plugin-stage: Add Import Pipeline Stages -~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------- Many plugins need to add high-latency operations to the import workflow. For example, a plugin that fetches lyrics from the Web would, ideally, not block the @@ -186,20 +177,25 @@ Plugins provide stages as functions that take two arguments: ``config`` and in ``beets.importer``). Add such a function to the plugin's ``import_stages`` field to register it: -:: +.. code-block:: python from beets.plugins import BeetsPlugin + from beets.importer import ImportSession, ImportTask + + class ExamplePlugin(BeetsPlugin): + def __init__(self): super().__init__() self.import_stages = [self.stage] - def stage(self, session, task): - print('Importing something!') + + def stage(self, session: ImportSession, task: ImportTask): + print("Importing something!") It is also possible to request your function to run early in the pipeline by adding the function to the plugin's ``early_import_stages`` field instead: -:: +.. code-block:: python self.early_import_stages = [self.stage] @@ -233,46 +229,47 @@ from that class and override the ``value_match`` class method. (Remember the the ``@`` prefix to delimit exact string matches. The plugin will be used if we issue a command like ``beet ls @something`` or ``beet ls artist:@something``: -:: +.. code-block:: python from beets.plugins import BeetsPlugin from beets.dbcore import FieldQuery + class ExactMatchQuery(FieldQuery): @classmethod def value_match(self, pattern, val): return pattern == val + class ExactMatchPlugin(BeetsPlugin): def queries(self): - return { - '@': ExactMatchQuery - } + return {"@": ExactMatchQuery} Flexible Field Types -~~~~~~~~~~~~~~~~~~~~ +-------------------- If your plugin uses flexible fields to store numbers or other non-string values, you can specify the types of those fields. A rating plugin, for example, might want to declare that the ``rating`` field should have an integer type: -:: +.. code-block:: python from beets.plugins import BeetsPlugin from beets.dbcore import types + class RatingPlugin(BeetsPlugin): - item_types = {'rating': types.INTEGER} + item_types = {"rating": types.INTEGER} @property def album_types(self): - return {'rating': types.INTEGER} + return {"rating": types.INTEGER} -A plugin may define two attributes: ``item_types`` and ``album_types``. Each of +A plugin may define two attributes: `item_types` and `album_types`. Each of those attributes is a dictionary mapping a flexible field name to a type -instance. You can find the built-in types in the ``beets.dbcore.types`` and -``beets.library`` modules or implement your own type by inheriting from the -``Type`` class. +instance. You can find the built-in types in the `beets.dbcore.types` and +`beets.library` modules or implement your own type by inheriting from the `Type` +class. Specifying types has several advantages: @@ -287,7 +284,7 @@ Specifying types has several advantages: .. _plugin-logging: Logging -~~~~~~~ +------- Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the `standard Python logging module`_. The logger is set up to `PEP 3101`_, @@ -295,7 +292,7 @@ str.format-style string formatting. So you can write logging calls like this: :: - self._log.debug(u'Processing {0.title} by {0.artist}', item) + self._log.debug('Processing {0.title} by {0.artist}', item) .. _pep 3101: https://www.python.org/dev/peps/pep-3101/ @@ -326,7 +323,7 @@ the importer interface when running automatically.) .. _append_prompt_choices: Append Prompt Choices -~~~~~~~~~~~~~~~~~~~~~ +--------------------- Plugins can also append choices to the prompt presented to the user during an import session. @@ -335,20 +332,24 @@ To do so, add a listener for the ``before_choose_candidate`` event, and return a list of ``PromptChoices`` that represent the additional choices that your plugin shall expose to the user: -:: +.. code-block:: python from beets.plugins import BeetsPlugin from beets.ui.commands import PromptChoice + class ExamplePlugin(BeetsPlugin): def __init__(self): super().__init__() - self.register_listener('before_choose_candidate', - self.before_choose_candidate_event) + self.register_listener( + "before_choose_candidate", self.before_choose_candidate_event + ) def before_choose_candidate_event(self, session, task): - return [PromptChoice('p', 'Print foo', self.foo), - PromptChoice('d', 'Do bar', self.bar)] + return [ + PromptChoice("p", "Print foo", self.foo), + PromptChoice("d", "Do bar", self.bar), + ] def foo(self, session, task): print('User has chosen "Print foo"!') @@ -358,14 +359,14 @@ shall expose to the user: The previous example modifies the standard prompt: -:: +.. code-block:: shell # selection (default 1), Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort? by appending two additional options (``Print foo`` and ``Do bar``): -:: +.. code-block:: shell # selection (default 1), Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort, Print foo, Do bar? From d6e3548d4a4ca02d2e306c20d8c77881439641dc Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:21:47 +0200 Subject: [PATCH 046/301] Formatting commands --- docs/dev/plugins/commands.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/dev/plugins/commands.rst b/docs/dev/plugins/commands.rst index 6a9727859..f39578f11 100644 --- a/docs/dev/plugins/commands.rst +++ b/docs/dev/plugins/commands.rst @@ -1,23 +1,28 @@ .. _add_subcommands: Add Commands to the CLI -~~~~~~~~~~~~~~~~~~~~~~~ +======================= Plugins can add new subcommands to the ``beet`` command-line interface. Define the plugin class' ``commands()`` method to return a list of ``Subcommand`` objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.) Here's an example plugin that adds a simple command: -:: +.. code-block:: python from beets.plugins import BeetsPlugin from beets.ui import Subcommand - my_super_command = Subcommand('super', help='do something super') + my_super_command = Subcommand("super", help="do something super") + + def say_hi(lib, opts, args): print("Hello everybody! I'm a plugin!") + + my_super_command.func = say_hi + class SuperPlug(BeetsPlugin): def commands(self): return [my_super_command] @@ -47,4 +52,3 @@ use it like you would a normal ``OptionParser`` in an independent script. Note that it offers several methods to add common options: ``--album``, ``--path`` and ``--format``. This feature is versatile and extensively documented, try ``pydoc beets.ui.CommonOptionsParser`` for more information. - From 83eda270516f6a9e87ad461cdfe93e6d093def78 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:21:59 +0200 Subject: [PATCH 047/301] Update docstrfmt --- docs/code_of_conduct.rst | 3 +-- docs/contributing.rst | 3 +-- poetry.lock | 28 ++++++++++++++-------------- pyproject.toml | 4 ++-- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/docs/code_of_conduct.rst b/docs/code_of_conduct.rst index 772800d44..76e57d0e6 100644 --- a/docs/code_of_conduct.rst +++ b/docs/code_of_conduct.rst @@ -1,4 +1,3 @@ -.. - code_of_conduct: +.. code_of_conduct: .. include:: ../CODE_OF_CONDUCT.rst diff --git a/docs/contributing.rst b/docs/contributing.rst index 6c71b2ce0..6af7deaef 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1,4 +1,3 @@ -.. - contributing: +.. contributing: .. include:: ../CONTRIBUTING.rst diff --git a/poetry.lock b/poetry.lock index 25d9448ba..3383129ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -696,28 +696,28 @@ files = [ [[package]] name = "docstrfmt" -version = "1.10.0" +version = "1.11.0" description = "docstrfmt: A formatter for Sphinx flavored reStructuredText." optional = false -python-versions = "<4,>=3.8" +python-versions = ">=3.9" files = [ - {file = "docstrfmt-1.10.0-py3-none-any.whl", hash = "sha256:a34ef6f3d8ab3233a7d0b3d1c2f3c66f8acbb3917df5ed2f3e34c1629ac29cef"}, - {file = "docstrfmt-1.10.0.tar.gz", hash = "sha256:9da96e71552937f4b49ae2d6ab1c118ffa8ad6968082e6b8fd978b01d1bc0066"}, + {file = "docstrfmt-1.11.0-py3-none-any.whl", hash = "sha256:3d56bdd6e083091a8c5d7db098684f281de84667f4b7d4cc806092a63efc4844"}, + {file = "docstrfmt-1.11.0.tar.gz", hash = "sha256:37500c8086770294f265187c375c5c35a91a334d2c0b4f764aeace069d4ed501"}, ] [package.dependencies] -black = "==24.*" -click = "==8.*" -docutils = "==0.20.*" -libcst = "==1.*" -platformdirs = "==4.*" -sphinx = ">=7,<9" -tabulate = "==0.9.*" -toml = "==0.10.*" +black = ">=24" +click = ">=8" +docutils = ">=0.20" +libcst = ">=1" +platformdirs = ">=4" +sphinx = ">=7" +tabulate = ">=0.9" +toml = {version = ">=0.10", markers = "python_version < \"3.11\""} [package.extras] ci = ["coveralls"] -d = ["aiohttp (==3.*)"] +d = ["aiohttp (>=3)"] dev = ["docstrfmt[lint]", "docstrfmt[test]", "packaging"] lint = ["pre-commit", "ruff (>=0.0.292)"] test = ["pytest", "pytest-aiohttp"] @@ -3617,4 +3617,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "daa6c3c2b5bee3180f74f4186bb29ee1ad825870b5b9f6c2b743fcaa61b34c8c" +content-hash = "b68f663437ec41a92319456277243438a5c5f5c5f3dca113df1d7cac1b78703a" diff --git a/pyproject.toml b/pyproject.toml index 3ba7b8b6a..2377f52f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ requests_oauthlib = "*" responses = ">=0.3.0" [tool.poetry.group.lint.dependencies] -docstrfmt = ">=1.10.0" +docstrfmt = ">=1.11.0" ruff = ">=0.6.4" sphinx-lint = ">=1.0.0" @@ -212,7 +212,7 @@ cmd = "ruff check" [tool.poe.tasks.lint-docs] help = "Lint the documentation" -shell = "sphinx-lint --enable all $(git ls-files '*.rst')" +shell = "sphinx-lint --enable all --disable default-role $(git ls-files '*.rst')" [tool.poe.tasks.update-dependencies] help = "Update dependencies to their latest versions." From ea80ecab366431e795420e1567833a354ae0eaa3 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:22:50 +0200 Subject: [PATCH 048/301] Git blame for first commit --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index b20434e23..75e49f8cc 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -57,6 +57,8 @@ c490ac5810b70f3cf5fd8649669838e8fdb19f4d 769dcdc88a1263638ae25944ba6b2be3e8933666 # Reformat all docs using docstrfmt ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d +# Moved dev docs +2504595532abd7584143007ede087ee4abc00916 # Replace format calls with f-strings 4a361bd501e85de12c91c2474c423559ca672852 # Replace percent formatting From 0dcd7caa9d512a8e536f4522eefe51c13a73d7b3 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 24 Aug 2025 11:33:03 +0200 Subject: [PATCH 049/301] writing plugins label --- docs/dev/plugins/index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index 491e48e0e..98a37aa87 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -9,6 +9,8 @@ For instance you can create plugins that add new commands to the command-line interface, listen for events in the beets lifecycle or extend the autotagger with new metadata sources. .. _writing-plugins: +.. _writing-plugins: + Basic Plugin Setup ------------------ From 676dc9c9537c8c9b49eeef13e25d68b3fda44aa7 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 26 Aug 2025 11:23:12 +0200 Subject: [PATCH 050/301] Replaced writing-plugins with basic-plugin-setup. --- docs/changelog.rst | 6 +++--- docs/dev/plugins/index.rst | 4 ++-- docs/reference/pathformat.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d27596b64..95c22115b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4178,7 +4178,7 @@ fetching cover art for your music, enable this plugin after upgrading to beets "database is locked"). This release synchronizes access to the database to avoid internal SQLite contention, which should avoid this error. - Plugins can now add parallel stages to the import pipeline. See - :ref:`writing-plugins`. + :ref:`basic-plugin-setup`. - Beets now prints out an error when you use an unrecognized field name in a query: for example, when running ``beet ls -a artist:foo`` (because ``artist`` is an item-level field). @@ -4361,7 +4361,7 @@ to come in the next couple of releases. addition to replacing them) if the special string ``<strip>`` is specified as the replacement. - New plugin API: plugins can now add fields to the MediaFile tag abstraction - layer. See :ref:`writing-plugins`. + layer. See :ref:`basic-plugin-setup`. - A reasonable error message is now shown when the import log file cannot be opened. - The import log file is now flushed and closed properly so that it can be used @@ -4405,7 +4405,7 @@ filenames that would otherwise conflict. Three new plugins (``inline``, naming rules: for example, ``%upper{%left{$artist,1}}`` will insert the capitalized first letter of the track's artist. For more details, see :doc:`/reference/pathformat`. If you're interested in adding your own template - functions via a plugin, see :ref:`writing-plugins`. + functions via a plugin, see :ref:`basic-plugin-setup`. - Plugins can also now define new path *fields* in addition to functions. - The new :doc:`/plugins/inline` lets you **use Python expressions to customize path formats** by defining new fields in the config file. diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index 98a37aa87..ff5a03652 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -7,9 +7,9 @@ add virtually any type of features to beets. For instance you can create plugins that add new commands to the command-line interface, listen for events in the beets lifecycle or extend the autotagger -with new metadata sources. .. _writing-plugins: +with new metadata sources. -.. _writing-plugins: +.. _basic-plugin-setup: Basic Plugin Setup ------------------ diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 1fc204b62..30871cf55 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -289,4 +289,4 @@ constructs include: The :doc:`/plugins/inline` lets you define template fields in your beets configuration file using Python snippets. And for more advanced processing, you can go all-in and write a dedicated plugin to register your own fields and -functions (see :ref:`writing-plugins`). +functions (see :ref:`basic-plugin-setup`). From 037e59fe8f528a6ccded8848303c07ee45e1a261 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 26 Aug 2025 12:15:25 +0200 Subject: [PATCH 051/301] Created autotagger file and enhanced the docs significantly. --- docs/dev/plugins/autotagger.rst | 103 ++++++++++++++++++++++++++++++++ docs/dev/plugins/index.rst | 1 + docs/dev/plugins/other.rst | 23 ------- 3 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 docs/dev/plugins/autotagger.rst diff --git a/docs/dev/plugins/autotagger.rst b/docs/dev/plugins/autotagger.rst new file mode 100644 index 000000000..2d59ecdcf --- /dev/null +++ b/docs/dev/plugins/autotagger.rst @@ -0,0 +1,103 @@ +Extending the Autotagger +======================== + +.. currentmodule:: beets.metadata_plugins + +Beets supports **metadata source plugins**, which allow it to fetch and match +metadata from external services (such as Spotify, Discogs, or Deezer). This +guide explains how to build your own metadata source plugin by extending the +:py:class:`MetadataSourcePlugin`. + +These plugins integrate directly with the autotagger, providing candidate +metadata during lookups. To implement one, you must subclass +:py:class:`MetadataSourcePlugin` and implement its abstract methods. + +Overview +-------- + +Creating a metadata source plugin is very similar to writing a standard plugin +(see :ref:`basic-plugin-setup`). The main difference is that your plugin must: + +1. Subclass :py:class:`MetadataSourcePlugin`. +2. Implement all required abstract methods. + +Here`s a minimal example: + +.. code-block:: python + + # beetsplug/myawesomeplugin.py + from typing import Sequence + from beets.autotag.hooks import Item + from beets.metadata_plugin import MetadataSourcePlugin + + + class MyAwesomePlugin(MetadataSourcePlugin): + + def candidates( + self, + items: Sequence[Item], + artist: str, + album: str, + va_likely: bool, + ): ... + + def item_candidates(self, item: Item, artist: str, title: str): ... + + def track_for_id(self, track_id: str): ... + + def album_for_id(self, album_id: str): ... + +How Metadata Lookup Works +------------------------- + +When beets runs the autotagger, it queries **all enabled metadata source +plugins** for potential matches: + +- For **albums**, it calls :py:meth:`~MetadataSourcePlugin.candidates`. +- For **individual items**, it calls + :py:meth:`~MetadataSourcePlugin.item_candidates`. + +The results are combined and scored. By default, candidate ranking is handled +automatically by the beets core, but you can customize weighting by overriding: + +- :py:meth:`~MetadataSourcePlugin.album_distance` +- :py:meth:`~MetadataSourcePlugin.track_distance` + +This is optional, if not overridden, both methods return a constant distance of +`0.5`. + +Implementing ID-based Lookups +----------------------------- + +Your plugin must also define: + +- :py:meth:`~MetadataSourcePlugin.album_for_id` — fetch album metadata by ID. +- :py:meth:`~MetadataSourcePlugin.track_for_id` — fetch track metadata by ID. + +These methods should return `None` if your source doesn`t support ID lookups. +IDs are expected to be strings. If your source uses specific formats, consider +contributing an extractor regex to the core module: +:py:mod:`beets.util.id_extractors`. + +Best Practices +-------------- + +Beets already ships with several metadata source plugins. Studying these +implementations can help you follow conventions and avoid pitfalls. Good +starting points include: + +- `spotify` +- `deezer` +- `discogs` + +Migration Guidance +------------------ + +Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should +be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed +in **beets v3.0.0**. + +.. seealso:: + + - :py:mod:`beets.autotag` + - :py:mod:`beets.metadata_plugins` diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index ff5a03652..1321c61dc 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -99,4 +99,5 @@ resources: commands events + autotagger other diff --git a/docs/dev/plugins/other.rst b/docs/dev/plugins/other.rst index 20441c0e9..164bac13c 100644 --- a/docs/dev/plugins/other.rst +++ b/docs/dev/plugins/other.rst @@ -5,29 +5,6 @@ Further Reading :local: :depth: 2 -Extending the Autotagger ------------------------- - -.. currentmodule:: beets.metadata_plugins - -Plugins can also be used to extend the autotagger functions i.e. the metadata -lookup from external sources. For this your plugin has to extend the -:py:class:`MetadataSourcePlugin` base class and implement all abstract methods. - -On metadata lookup, the autotagger will try to find matching candidates from all -enabled metadata source plugins. To do this, we will call the -:py:meth:`MetadataSourcePlugin.candidates` (or -:py:meth:`MetadataSourcePlugin.item_candidates`) with all available (local) -metadata. The list of retrieved candidates will be ranked by their -:py:meth:`MetadataSourcePlugin.album_distance` (or -:py:meth:`MetadataSourcePlugin.track_distance`) and be presented to the user for -selection (or automatically selected if the threshold is met). - -Please have a look at the ``beets.autotag`` and especially the -``beets.metadata_plugin`` modules for more information. Additionally, for a -comprehensive example, see the ``musicbrainz`` or ``chroma`` plugins, which are -included with beets. - Read Configuration Options -------------------------- From dee906e1ae1771fcb399feb782fe86be6114dd64 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 26 Aug 2025 12:20:09 +0200 Subject: [PATCH 052/301] Minor formatting issues. --- docs/dev/plugins/other.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/dev/plugins/other.rst b/docs/dev/plugins/other.rst index 164bac13c..42fa9e768 100644 --- a/docs/dev/plugins/other.rst +++ b/docs/dev/plugins/other.rst @@ -109,7 +109,7 @@ to extend the kinds of metadata that they can easily manage. The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to file tags. If you have created a descriptor you can add it through your plugins -:py:meth:`beets.plugins.BeetsPlugin.add_media_field()`` method. +:py:meth:`beets.plugins.BeetsPlugin.add_media_field` method. .. _mediafile: https://mediafile.readthedocs.io/en/latest/ @@ -156,8 +156,8 @@ field to register it: .. code-block:: python - from beets.plugins import BeetsPlugin from beets.importer import ImportSession, ImportTask + from beets.plugins import BeetsPlugin class ExamplePlugin(BeetsPlugin): @@ -242,11 +242,11 @@ want to declare that the ``rating`` field should have an integer type: def album_types(self): return {"rating": types.INTEGER} -A plugin may define two attributes: `item_types` and `album_types`. Each of +A plugin may define two attributes: ``item_types`` and ``album_types``. Each of those attributes is a dictionary mapping a flexible field name to a type -instance. You can find the built-in types in the `beets.dbcore.types` and -`beets.library` modules or implement your own type by inheriting from the `Type` -class. +instance. You can find the built-in types in the ``beets.dbcore.types`` and +``beets.library`` modules or implement your own type by inheriting from the +``Type`` class. Specifying types has several advantages: @@ -267,9 +267,9 @@ Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the `standard Python logging module`_. The logger is set up to `PEP 3101`_, str.format-style string formatting. So you can write logging calls like this: -:: +.. code-block:: python - self._log.debug('Processing {0.title} by {0.artist}', item) + self._log.debug("Processing {0.title} by {0.artist}", item) .. _pep 3101: https://www.python.org/dev/peps/pep-3101/ From 4a8cabdaeb84ccd2de946cf6eabbb7fac598ebca Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 26 Aug 2025 12:31:29 +0200 Subject: [PATCH 053/301] Use rubric instead of list table. --- docs/dev/plugins/events.rst | 317 +++++++++++++++++++----------------- 1 file changed, 165 insertions(+), 152 deletions(-) diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst index 3895d35aa..325b01b33 100644 --- a/docs/dev/plugins/events.rst +++ b/docs/dev/plugins/events.rst @@ -44,163 +44,176 @@ registration process in this case: def loaded(self): self._log.info("Plugin loaded!") -.. list-table:: Plugin Events - :widths: 15 25 60 - :header-rows: 1 +.. rubric:: Plugin Events - - - Event - - Parameters - - Description - - - `pluginload` - - - - called after all the plugins have been loaded after the ``beet`` command - starts - - - `import` - - :py:class:`lib <beets.library.Library>`, ``paths`` is a list of paths - (strings) - - called after the ``import`` command finishes. - - - `album_imported` - - :py:class:`lib <beets.library.Library>`, :py:class:`album +``pluginload`` + :Parameters: (none) + :Description: Called after all plugins have been loaded after the ``beet`` + command starts. + +``import`` + :Parameters: :py:class:`lib <beets.library.Library>`, ``paths`` (list of + path strings) + :Description: Called after the ``import`` command finishes. + +``album_imported`` + :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`album <beets.library.Album>` - - called every time the ``import`` command finishes adding an album to the - library - - - `album_removed` - - :py:class:`lib <beets.library.Library>`, :py:class:`album + :Description: Called every time the importer finishes adding an album to the + library. + +``album_removed`` + :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`album <beets.library.Album>` - - called every time an album is removed from the library (even when its - file is not deleted from disk) - - - `item_copied` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called whenever an item file is copied - - - `item_imported` - - :py:class:`lib <beets.library.Library>`, :py:class:`item + :Description: Called every time an album is removed from the library (even + when its files are not deleted from disk). + +``item_copied`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called whenever an item file is copied. + +``item_imported`` + :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`item <beets.library.Item>` - - called every time the importer adds a singleton to the library (not - called for full-album imports) - - - `before_item_imported` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called with an item object immediately before it is imported - - - `before_item_moved` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called with an ``Item`` object immediately before its file is moved - - - `item_moved` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called with an ``Item`` object whenever its file is moved - - - `item_linked` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called with an ``Item`` object whenever a symlink is created for a file - - - `item_hardlinked` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called with an ``Item`` object whenever a hardlink is created for a file - - - `item_reflinked` - - :py:class:`item <beets.library.Item>`, ``source`` path, ``destination`` - path - - called with an ``Item`` object whenever a reflink is created for a file - - - `item_removed` - - :py:class:`item <beets.library.Item>` - - 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). - - - `write` - - :py:class:`item <beets.library.Item>`, ``path``, ``tags`` dictionary - - called with an ``Item`` object, a ``path``, and a ``tags`` dictionary - just before a file's metadata is written to disk (i.e., just before the - file on disk is opened). Event handlers may change the ``tags`` - dictionary to customize the tags that are written to the media file. - Event handlers may also raise a ``library.FileOperationError`` exception - to abort the write operation. Beets will catch that exception, print an - error message, and continue. - - - `after_write` - - :py:class:`item <beets.library.Item>` - - called with an ``Item`` object after a file's metadata is written to - disk (i.e., just after the file on disk is closed). - - - `import_task_created` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called immediately after an import task is initialized. Plugins can use - this to, for example, change imported files of a task before anything - else happens. It's also possible to replace the task with another task - by returning a list of tasks. This list can contain zero or more - ImportTasks. Returning an empty list will stop the task. - - - `import_task_start` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called when before an import task begins processing. - - - `import_task_apply` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called after metadata changes have been applied in an import task. This - is called on the same thread as the UI, so use this sparingly and only - for tasks that can be done quickly. For most plugins, an import pipeline - stage is a better choice (see :ref:`plugin-stage`). - - - `import_task_before_choice` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called after candidate search for an import task before any decision is - made about how/if to import or tag. Can be used to present information - about the task or initiate interaction with the user before importing - occurs. Return an importer action to take a specific action. Only one - handler may return a non-None result. - - - `import_task_choice` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called after a decision has been made about an import task. This event - can be used to initiate further interaction with the user. Use - ``task.choice_flag`` to determine or change the action to be taken. - - - `import_task_files` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called after an import task finishes manipulating the filesystem - (copying and moving files, writing metadata tags). - - - `library_opened` - - :py:class:`lib <beets.library.Library>` - - called after beets starts up and initializes the main Library object. - - - `database_change` - - :py:class:`lib <beets.library.Library>`, :py:class:`model + :Description: Called every time the importer adds a singleton to the library + (not called for full-album imports). + +``before_item_imported`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called with an ``Item`` object immediately before it is + imported. + +``before_item_moved`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called with an ``Item`` object immediately before its file is + moved. + +``item_moved`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called with an ``Item`` object whenever its file is moved. + +``item_linked`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called with an ``Item`` object whenever a symlink is created + for a file. + +``item_hardlinked`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called with an ``Item`` object whenever a hardlink is created + for a file. + +``item_reflinked`` + :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), + ``destination`` (path) + :Description: Called with an ``Item`` object whenever a reflink is created + for a file. + +``item_removed`` + :Parameters: :py:class:`item <beets.library.Item>` + :Description: Called with an ``Item`` object every time an item (singleton + or part of an album) is removed from the library (even when its file is + not deleted from disk). + +``write`` + :Parameters: :py:class:`item <beets.library.Item>`, ``path`` (path), + ``tags`` (dict) + :Description: Called just before a file's metadata is written to disk. + Handlers may modify ``tags`` or raise ``library.FileOperationError`` to + abort. + +``after_write`` + :Parameters: :py:class:`item <beets.library.Item>` + :Description: Called after a file's metadata is written to disk. + +``import_task_created`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called immediately after an import task is initialized. May + return a list (possibly empty) of replacement tasks. + +``import_task_start`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called before an import task begins processing. + +``import_task_apply`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called after metadata changes have been applied in an import + task (on the UI thread; keep fast). Prefer a pipeline stage otherwise + (see :ref:`plugin-stage`). + +``import_task_before_choice`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called after candidate search and before deciding how to + import. May return an importer action (only one handler may return + non-None). + +``import_task_choice`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called after a decision has been made about an import task. + Use ``task.choice_flag`` to inspect or change the action. + +``import_task_files`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called after filesystem manipulation (copy/move/write) for an + import task. + +``library_opened`` + :Parameters: :py:class:`lib <beets.library.Library>` + :Description: Called after beets starts and initializes the main Library + object. + +``database_change`` + :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`model <beets.library.Model>` - - a modification has been made to the library database. The change might - not be committed yet. - - - `cli_exit` - - :py:class:`lib <beets.library.Library>` - - called just before the ``beet`` command-line program exits. - - - `import_begin` - - :py:class:`session <beets.importer.ImportSession>` - - called just before a ``beet import`` session starts up. - - - `trackinfo_received` - - :py:class:`info <beets.autotag.TrackInfo>` - - called after metadata for a track item has been fetched from a data - source, such as MusicBrainz. You can modify the tags that the rest of - the pipeline sees on a ``beet import`` operation or during later - adjustments, such as ``mbsync``. - - - `albuminfo_received` - - :py:class:`info <beets.autotag.AlbumInfo>` - - like `trackinfo_received`, the event indicates new metadata for album - items. - - - `before_choose_candidate` - - :py:class:`task <beets.importer.ImportTask>`, :py:class:`session - <beets.importer.ImportSession>` - - called before the user is prompted for a decision during a ``beet - import`` interactive session. Plugins can use this event for - :ref:`appending choices to the prompt <append_prompt_choices>` by - returning a list of ``PromptChoices``. - - - `mb_track_extract` - - :py:class:`data <dict>` - - called after the metadata is obtained from MusicBrainz. The parameter is - a ``dict`` containing the tags retrieved from MusicBrainz for a track. - Plugins must return a new (potentially empty) ``dict`` with additional - ``field: value`` pairs, which the autotagger will apply to the item, as - flexible attributes if ``field`` is not a hardcoded field. Fields - already present on the track are overwritten. - - - `mb_album_extract` - - :py:class:`data <dict>` - - Like `mb_track_extract`, but for album tags. Overwrites tags set at the - track level, if they have the same ``field``. + :Description: A modification has been made to the library database (may not + yet be committed). + +``cli_exit`` + :Parameters: :py:class:`lib <beets.library.Library>` + :Description: Called just before the ``beet`` command-line program exits. + +``import_begin`` + :Parameters: :py:class:`session <beets.importer.ImportSession>` + :Description: Called just before a ``beet import`` session starts. + +``trackinfo_received`` + :Parameters: :py:class:`info <beets.autotag.TrackInfo>` + :Description: Called after metadata for a track is fetched (e.g., from + MusicBrainz). Handlers can modify the tags seen by later pipeline stages + or adjustments (e.g., ``mbsync``). + +``albuminfo_received`` + :Parameters: :py:class:`info <beets.autotag.AlbumInfo>` + :Description: Like ``trackinfo_received`` but for album-level metadata. + +``before_choose_candidate`` + :Parameters: :py:class:`task <beets.importer.ImportTask>`, + :py:class:`session <beets.importer.ImportSession>` + :Description: Called before prompting the user during interactive import. + May return a list of ``PromptChoices`` to append to the prompt (see + :ref:`append_prompt_choices`). + +``mb_track_extract`` + :Parameters: :py:class:`data <dict>` + :Description: Called after metadata is obtained from MusicBrainz for a + track. Must return a (possibly empty) dict of additional ``field: + value`` pairs to apply (overwriting existing fields). + +``mb_album_extract`` + :Parameters: :py:class:`data <dict>` + :Description: Like ``mb_track_extract`` but for album tags. Overwrites tags + set at the track level with the same field. The included ``mpdupdate`` plugin provides an example use case for event listeners. From db9a587492a9e8feef6cd74ed918315bf0bdd5b5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 26 Aug 2025 13:02:23 +0200 Subject: [PATCH 054/301] Replaced named citations with number, seems to work for some reason. --- docs/dev/plugins/index.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index 1321c61dc..6f284b90b 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -15,8 +15,8 @@ Basic Plugin Setup ------------------ A beets plugin is just a Python module or package inside the ``beetsplug`` -namespace [namespace]_ package. To create the basic plugin layout, create a -directory called ``beetsplug`` and add either your plugin module: +namespace [1]_ package. To create the basic plugin layout, create a directory +called ``beetsplug`` and add either your plugin module: .. code-block:: shell @@ -40,8 +40,8 @@ or your plugin subpackage anymore. The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to -extend the :class:`beets.plugins.BeetsPlugin` abstract base class [baseclass]_ . -For instance, a minimal plugin without any functionality would look like this: +extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For +instance, a minimal plugin without any functionality would look like this: .. code-block:: python @@ -52,9 +52,9 @@ For instance, a minimal plugin without any functionality would look like this: class MyAwesomePlugin(BeetsPlugin): pass -To use your new plugin, you need to package [packaging]_ your plugin and install -it into your ``beets`` (virtual) environment. To enable your plugin, add it it -to the beets configuration +To use your new plugin, you need to package [3]_ your plugin and install it into +your ``beets`` (virtual) environment. To enable your plugin, add it it to the +beets configuration .. code-block:: yaml @@ -64,16 +64,16 @@ to the beets configuration and you're good to go! -.. [namespace] Check out `this article`_ and `this Stack Overflow question`_ if - you haven't heard about namespace packages. +.. [1] Check out `this article`_ and `this Stack Overflow question`_ if you + haven't heard about namespace packages. -.. [baseclass] Abstract base classes allow us to define a contract which any - plugin must follow. This is a common paradigm in object-oriented - programming, and it helps to ensure that plugins are implemented in a - consistent way. For more information, see for example pep-3119_. +.. [2] Abstract base classes allow us to define a contract which any plugin must + follow. This is a common paradigm in object-oriented programming, and it + helps to ensure that plugins are implemented in a consistent way. For more + information, see for example pep-3119_. -.. [packaging] There are a variety of packaging tools available for python, for - example you can use poetry_, setuptools_ or hatchling_. +.. [3] There are a variety of packaging tools available for python, for example + you can use poetry_, setuptools_ or hatchling_. .. _hatchling: https://hatch.pypa.io/latest/config/build/#build-system From 1c6921758cd54a07092b1fff02dae8c1aeca8009 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Wed, 27 Aug 2025 11:40:54 +0200 Subject: [PATCH 055/301] Capitalization and some more minor adjustments. Updated docstrfmt. --- docs/dev/index.rst | 2 +- docs/dev/plugins/autotagger.rst | 22 +++++++++++++--------- docs/dev/plugins/index.rst | 2 +- docs/guides/tagger.rst | 2 ++ poetry.lock | 20 ++++++++++++++++---- pyproject.toml | 2 +- 6 files changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 8d9200f67..633d50cd1 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -13,7 +13,7 @@ configuration files, respectively. .. _mediafile: https://mediafile.readthedocs.io/en/latest/ .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :titlesonly: plugins/index diff --git a/docs/dev/plugins/autotagger.rst b/docs/dev/plugins/autotagger.rst index 2d59ecdcf..1a4fa6dd6 100644 --- a/docs/dev/plugins/autotagger.rst +++ b/docs/dev/plugins/autotagger.rst @@ -47,15 +47,18 @@ Here`s a minimal example: def album_for_id(self, album_id: str): ... -How Metadata Lookup Works -------------------------- +Each metadata source plugin automatically gets a unique identifier. You can +access this identifier using the :py:meth:`~MetadataSourcePlugin.data_source` +class property to tell plugins apart. + +Metadata lookup +--------------- When beets runs the autotagger, it queries **all enabled metadata source plugins** for potential matches: - For **albums**, it calls :py:meth:`~MetadataSourcePlugin.candidates`. -- For **individual items**, it calls - :py:meth:`~MetadataSourcePlugin.item_candidates`. +- For **singletons**, it calls :py:meth:`~MetadataSourcePlugin.item_candidates`. The results are combined and scored. By default, candidate ranking is handled automatically by the beets core, but you can customize weighting by overriding: @@ -66,20 +69,19 @@ automatically by the beets core, but you can customize weighting by overriding: This is optional, if not overridden, both methods return a constant distance of `0.5`. -Implementing ID-based Lookups ------------------------------ +ID-based lookups +---------------- Your plugin must also define: - :py:meth:`~MetadataSourcePlugin.album_for_id` — fetch album metadata by ID. - :py:meth:`~MetadataSourcePlugin.track_for_id` — fetch track metadata by ID. -These methods should return `None` if your source doesn`t support ID lookups. IDs are expected to be strings. If your source uses specific formats, consider contributing an extractor regex to the core module: :py:mod:`beets.util.id_extractors`. -Best Practices +Best practices -------------- Beets already ships with several metadata source plugins. Studying these @@ -90,7 +92,7 @@ starting points include: - `deezer` - `discogs` -Migration Guidance +Migration guidance ------------------ Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should @@ -101,3 +103,5 @@ in **beets v3.0.0**. - :py:mod:`beets.autotag` - :py:mod:`beets.metadata_plugins` + - :ref:`autotagger_extensions` + - :ref:`using-the-auto-tagger` diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index 6f284b90b..018dce7e4 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -94,7 +94,7 @@ For more information on writing plugins, feel free to check out the following resources: .. toctree:: - :maxdepth: 2 + :maxdepth: 3 :includehidden: commands diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index dea1713f3..3ad85ec85 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -1,3 +1,5 @@ +.. _using-the-auto-tagger: + Using the Auto-Tagger ===================== diff --git a/poetry.lock b/poetry.lock index 3383129ff..8c109f930 100644 --- a/poetry.lock +++ b/poetry.lock @@ -696,13 +696,13 @@ files = [ [[package]] name = "docstrfmt" -version = "1.11.0" +version = "1.11.1" description = "docstrfmt: A formatter for Sphinx flavored reStructuredText." optional = false python-versions = ">=3.9" files = [ - {file = "docstrfmt-1.11.0-py3-none-any.whl", hash = "sha256:3d56bdd6e083091a8c5d7db098684f281de84667f4b7d4cc806092a63efc4844"}, - {file = "docstrfmt-1.11.0.tar.gz", hash = "sha256:37500c8086770294f265187c375c5c35a91a334d2c0b4f764aeace069d4ed501"}, + {file = "docstrfmt-1.11.1-py3-none-any.whl", hash = "sha256:6782d8663321c3a7c40be08a36fbcb1ea9e46d1efba85411ba807d97f384871a"}, + {file = "docstrfmt-1.11.1.tar.gz", hash = "sha256:d41e19d6c5d524cc7f8ff6cbfecb8762d77e696b9fe4f5057269051fb966fc80"}, ] [package.dependencies] @@ -711,6 +711,7 @@ click = ">=8" docutils = ">=0.20" libcst = ">=1" platformdirs = ">=4" +roman = "*" sphinx = ">=7" tabulate = ">=0.9" toml = {version = ">=0.10", markers = "python_version < \"3.11\""} @@ -2921,6 +2922,17 @@ urllib3 = ">=1.25.10,<3.0" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] +[[package]] +name = "roman" +version = "5.1" +description = "Integer to Roman numerals converter" +optional = false +python-versions = ">=3.9" +files = [ + {file = "roman-5.1-py3-none-any.whl", hash = "sha256:bf595d8a9bc4a8e8b1dfa23e1d4def0251b03b494786df6b8c3d3f1635ce285a"}, + {file = "roman-5.1.tar.gz", hash = "sha256:3a86572e9bc9183e771769601189e5fa32f1620ffeceebb9eca836affb409986"}, +] + [[package]] name = "ruff" version = "0.12.3" @@ -3617,4 +3629,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "b68f663437ec41a92319456277243438a5c5f5c5f3dca113df1d7cac1b78703a" +content-hash = "faea27878ce1ca3f1335fd83e027b289351c51c73550bda72bf501a9c82166f7" diff --git a/pyproject.toml b/pyproject.toml index 2377f52f2..3cf3b9b47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ requests_oauthlib = "*" responses = ">=0.3.0" [tool.poetry.group.lint.dependencies] -docstrfmt = ">=1.11.0" +docstrfmt = ">=1.11.1" ruff = ">=0.6.4" sphinx-lint = ">=1.0.0" From 33f1a5d0bef8ca08be79ee7a0d02a018d502680d Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 31 Aug 2025 07:50:02 +0200 Subject: [PATCH 056/301] docs: Split Further Reading chapter to files to make it viewable in the primary sidebar instead of the secondary. --- docs/dev/plugins/index.rst | 2 +- docs/dev/plugins/other.rst | 368 --------------------------- docs/dev/plugins/other/config.rst | 36 +++ docs/dev/plugins/other/fields.rst | 35 +++ docs/dev/plugins/other/import.rst | 88 +++++++ docs/dev/plugins/other/index.rst | 16 ++ docs/dev/plugins/other/logging.rst | 38 +++ docs/dev/plugins/other/mediafile.rst | 32 +++ docs/dev/plugins/other/prompts.rst | 69 +++++ docs/dev/plugins/other/templates.rst | 57 +++++ 10 files changed, 372 insertions(+), 369 deletions(-) create mode 100644 docs/dev/plugins/other/config.rst create mode 100644 docs/dev/plugins/other/fields.rst create mode 100644 docs/dev/plugins/other/import.rst create mode 100644 docs/dev/plugins/other/index.rst create mode 100644 docs/dev/plugins/other/logging.rst create mode 100644 docs/dev/plugins/other/mediafile.rst create mode 100644 docs/dev/plugins/other/prompts.rst create mode 100644 docs/dev/plugins/other/templates.rst diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index 018dce7e4..d258e7df6 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -100,4 +100,4 @@ resources: commands events autotagger - other + other/index diff --git a/docs/dev/plugins/other.rst b/docs/dev/plugins/other.rst index 42fa9e768..e69de29bb 100644 --- a/docs/dev/plugins/other.rst +++ b/docs/dev/plugins/other.rst @@ -1,368 +0,0 @@ -Further Reading -=============== - -.. contents:: Table of Contents - :local: - :depth: 2 - -Read Configuration Options --------------------------- - -Plugins can configure themselves using the ``config.yaml`` file. You can read -configuration values in two ways. The first is to use `self.config` within your -plugin class. This gives you a view onto the configuration values in a section -with the same name as your plugin's module. For example, if your plugin is in -``greatplugin.py``, then `self.config` will refer to options under the -``greatplugin:`` section of the config file. - -For example, if you have a configuration value called "foo", then users can put -this in their ``config.yaml``: - -:: - - greatplugin: - foo: bar - -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.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 -beets import config`` at the top of your plugin and access values from there. - -If your plugin provides configuration values for sensitive data (e.g., -passwords, API keys, ...), you should add these to the config so they can be -redacted automatically when users dump their config. This can be done by setting -each value's `redact` flag, like so: - -:: - - self.config['password'].redact = True - -Add Path Format Functions and Fields ------------------------------------- - -Beets supports *function calls* in its path format syntax (see -:doc:`/reference/pathformat`). Beets includes a few built-in functions, but -plugins can register new functions by adding them to the ``template_funcs`` -dictionary. - -Here's an example: - -.. code-block:: python - - class MyPlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.template_funcs["initial"] = _tmpl_initial - - - def _tmpl_initial(text: str) -> str: - if text: - return text[0].upper() - else: - return "" - -This plugin provides a function ``%initial`` to path templates where -``%initial{$artist}`` expands to the artist's initial (its capitalized first -character). - -Plugins can also add template *fields*, which are computed values referenced as -``$name`` in templates. To add a new field, add a function that takes an -``Item`` object to the ``template_fields`` dictionary on the plugin object. -Here's an example that adds a ``$disc_and_track`` field: - -.. code-block:: python - - class MyPlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.template_fields["disc_and_track"] = _tmpl_disc_and_track - - - def _tmpl_disc_and_track(item: Item) -> str: - """Expand to the disc number and track number if this is a - multi-disc release. Otherwise, just expands to the track - number. - """ - if item.disctotal > 1: - return f"{item.disc:02d}.{item.track:02d}" - else: - return f"{item.track:02d}" - -With this plugin enabled, templates can reference ``$disc_and_track`` as they -can any standard metadata field. - -This field works for *item* templates. Similarly, you can register *album* -template fields by adding a function accepting an ``Album`` argument to the -``album_template_fields`` dict. - -Extend MediaFile ----------------- - -MediaFile_ is the file tag abstraction layer that beets uses to make -cross-format metadata manipulation simple. Plugins can add fields to MediaFile -to extend the kinds of metadata that they can easily manage. - -The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to -file tags. If you have created a descriptor you can add it through your plugins -:py:meth:`beets.plugins.BeetsPlugin.add_media_field` method. - -.. _mediafile: https://mediafile.readthedocs.io/en/latest/ - -Here's an example plugin that provides a meaningless new field "foo": - -.. code-block:: python - - class FooPlugin(BeetsPlugin): - def __init__(self): - field = mediafile.MediaField( - mediafile.MP3DescStorageStyle("foo"), mediafile.StorageStyle("foo") - ) - self.add_media_field("foo", field) - - - FooPlugin() - item = Item.from_path("/path/to/foo/tag.mp3") - assert item["foo"] == "spam" - - item["foo"] == "ham" - item.write() - # The "foo" tag of the file is now "ham" - -.. _plugin-stage: - -Add Import Pipeline Stages --------------------------- - -Many plugins need to add high-latency operations to the import workflow. For -example, a plugin that fetches lyrics from the Web would, ideally, not block the -progress of the rest of the importer. Beets allows plugins to add stages to the -parallel import pipeline. - -Each stage is run in its own thread. Plugin stages run after metadata changes -have been applied to a unit of music (album or track) and before file -manipulation has occurred (copying and moving files, writing tags to disk). -Multiple stages run in parallel but each stage processes only one task at a time -and each task is processed by only one stage at a time. - -Plugins provide stages as functions that take two arguments: ``config`` and -``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined -in ``beets.importer``). Add such a function to the plugin's ``import_stages`` -field to register it: - -.. code-block:: python - - from beets.importer import ImportSession, ImportTask - from beets.plugins import BeetsPlugin - - - class ExamplePlugin(BeetsPlugin): - - def __init__(self): - super().__init__() - self.import_stages = [self.stage] - - def stage(self, session: ImportSession, task: ImportTask): - print("Importing something!") - -It is also possible to request your function to run early in the pipeline by -adding the function to the plugin's ``early_import_stages`` field instead: - -.. code-block:: python - - self.early_import_stages = [self.stage] - -.. _extend-query: - -Extend the Query Syntax -~~~~~~~~~~~~~~~~~~~~~~~ - -You can add new kinds of queries to beets' :doc:`query syntax -</reference/query>`. There are two ways to add custom queries: using a prefix -and using a name. Prefix-based query extension can apply to *any* field, while -named queries are not associated with any field. For example, beets already -supports regular expression queries, which are indicated by a colon -prefix---plugins can do the same. - -For either kind of query extension, define a subclass of the ``Query`` type from -the ``beets.dbcore.query`` module. Then: - -- To define a prefix-based query, define a ``queries`` method in your plugin - class. Return from this method a dictionary mapping prefix strings to query - classes. -- To define a named query, defined dictionaries named either ``item_queries`` or - ``album_queries``. These should map names to query types. So if you use ``{ - "foo": FooQuery }``, then the query ``foo:bar`` will construct a query like - ``FooQuery("bar")``. - -For prefix-based queries, you will want to extend ``FieldQuery``, which -implements string comparisons on fields. To use it, create a subclass inheriting -from that class and override the ``value_match`` class method. (Remember the -``@classmethod`` decorator!) The following example plugin declares a query using -the ``@`` prefix to delimit exact string matches. The plugin will be used if we -issue a command like ``beet ls @something`` or ``beet ls artist:@something``: - -.. code-block:: python - - from beets.plugins import BeetsPlugin - from beets.dbcore import FieldQuery - - - class ExactMatchQuery(FieldQuery): - @classmethod - def value_match(self, pattern, val): - return pattern == val - - - class ExactMatchPlugin(BeetsPlugin): - def queries(self): - return {"@": ExactMatchQuery} - -Flexible Field Types --------------------- - -If your plugin uses flexible fields to store numbers or other non-string values, -you can specify the types of those fields. A rating plugin, for example, might -want to declare that the ``rating`` field should have an integer type: - -.. code-block:: python - - from beets.plugins import BeetsPlugin - from beets.dbcore import types - - - class RatingPlugin(BeetsPlugin): - item_types = {"rating": types.INTEGER} - - @property - def album_types(self): - return {"rating": types.INTEGER} - -A plugin may define two attributes: ``item_types`` and ``album_types``. Each of -those attributes is a dictionary mapping a flexible field name to a type -instance. You can find the built-in types in the ``beets.dbcore.types`` and -``beets.library`` modules or implement your own type by inheriting from the -``Type`` class. - -Specifying types has several advantages: - -- Code that accesses the field like ``item['my_field']`` gets the right type - (instead of just a string). -- You can use advanced queries (like :ref:`ranges <numericquery>`) from the - command line. -- User input for flexible fields may be validated and converted. -- Items missing the given field can use an appropriate null value for querying - and sorting purposes. - -.. _plugin-logging: - -Logging -------- - -Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the -`standard Python logging module`_. The logger is set up to `PEP 3101`_, -str.format-style string formatting. So you can write logging calls like this: - -.. code-block:: python - - self._log.debug("Processing {0.title} by {0.artist}", item) - -.. _pep 3101: https://www.python.org/dev/peps/pep-3101/ - -.. _standard python logging module: https://docs.python.org/2/library/logging.html - -When beets is in verbose mode, plugin messages are prefixed with the plugin name -to make them easier to see. - -Which messages will be logged depends on the logging level and the action -performed: - -- Inside import stages and event handlers, the default is ``WARNING`` messages - and above. -- Everywhere else, the default is ``INFO`` or above. - -The verbosity can be increased with ``--verbose`` (``-v``) flags: each flags -lowers the level by a notch. That means that, with a single ``-v`` flag, event -handlers won't have their ``DEBUG`` messages displayed, but command functions -(for example) will. With ``-vv`` on the command line, ``DEBUG`` messages will be -displayed everywhere. - -This addresses a common pattern where plugins need to use the same code for a -command and an import stage, but the command needs to print more messages than -the import stage. (For example, you'll want to log "found lyrics for this song" -when you're run explicitly as a command, but you don't want to noisily interrupt -the importer interface when running automatically.) - -.. _append_prompt_choices: - -Append Prompt Choices ---------------------- - -Plugins can also append choices to the prompt presented to the user during an -import session. - -To do so, add a listener for the ``before_choose_candidate`` event, and return a -list of ``PromptChoices`` that represent the additional choices that your plugin -shall expose to the user: - -.. code-block:: python - - from beets.plugins import BeetsPlugin - from beets.ui.commands import PromptChoice - - - class ExamplePlugin(BeetsPlugin): - def __init__(self): - super().__init__() - self.register_listener( - "before_choose_candidate", self.before_choose_candidate_event - ) - - def before_choose_candidate_event(self, session, task): - return [ - PromptChoice("p", "Print foo", self.foo), - PromptChoice("d", "Do bar", self.bar), - ] - - def foo(self, session, task): - print('User has chosen "Print foo"!') - - def bar(self, session, task): - print('User has chosen "Do bar"!') - -The previous example modifies the standard prompt: - -.. code-block:: shell - - # selection (default 1), Skip, Use as-is, as Tracks, Group albums, - Enter search, enter Id, aBort? - -by appending two additional options (``Print foo`` and ``Do bar``): - -.. code-block:: shell - - # selection (default 1), Skip, Use as-is, as Tracks, Group albums, - Enter search, enter Id, aBort, Print foo, Do bar? - -If the user selects a choice, the ``callback`` attribute of the corresponding -``PromptChoice`` will be called. It is the responsibility of the plugin to check -for the status of the import session and decide the choices to be appended: for -example, if a particular choice should only be presented if the album has no -candidates, the relevant checks against ``task.candidates`` should be performed -inside the plugin's ``before_choose_candidate_event`` accordingly. - -Please make sure that the short letter for each of the choices provided by the -plugin is not already in use: the importer will emit a warning and discard all -but one of the choices using the same letter, giving priority to the core -importer prompt choices. As a reference, the following characters are used by -the choices on the core importer prompt, and hence should not be used: ``a``, -``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``. - -Additionally, the callback function can optionally specify the next action to be -performed by returning a ``importer.Action`` value. It may also return a -``autotag.Proposal`` value to update the set of current proposals to be -considered. diff --git a/docs/dev/plugins/other/config.rst b/docs/dev/plugins/other/config.rst new file mode 100644 index 000000000..043d4b28a --- /dev/null +++ b/docs/dev/plugins/other/config.rst @@ -0,0 +1,36 @@ +Read Configuration Options +-------------------------- + +Plugins can configure themselves using the ``config.yaml`` file. You can read +configuration values in two ways. The first is to use `self.config` within your +plugin class. This gives you a view onto the configuration values in a section +with the same name as your plugin's module. For example, if your plugin is in +``greatplugin.py``, then `self.config` will refer to options under the +``greatplugin:`` section of the config file. + +For example, if you have a configuration value called "foo", then users can put +this in their ``config.yaml``: + +:: + + greatplugin: + foo: bar + +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.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 +beets import config`` at the top of your plugin and access values from there. + +If your plugin provides configuration values for sensitive data (e.g., +passwords, API keys, ...), you should add these to the config so they can be +redacted automatically when users dump their config. This can be done by setting +each value's `redact` flag, like so: + +:: + + self.config['password'].redact = True diff --git a/docs/dev/plugins/other/fields.rst b/docs/dev/plugins/other/fields.rst new file mode 100644 index 000000000..429b726dc --- /dev/null +++ b/docs/dev/plugins/other/fields.rst @@ -0,0 +1,35 @@ +Flexible Field Types +-------------------- + +If your plugin uses flexible fields to store numbers or other non-string values, +you can specify the types of those fields. A rating plugin, for example, might +want to declare that the ``rating`` field should have an integer type: + +.. code-block:: python + + from beets.plugins import BeetsPlugin + from beets.dbcore import types + + + class RatingPlugin(BeetsPlugin): + item_types = {"rating": types.INTEGER} + + @property + def album_types(self): + return {"rating": types.INTEGER} + +A plugin may define two attributes: ``item_types`` and ``album_types``. Each of +those attributes is a dictionary mapping a flexible field name to a type +instance. You can find the built-in types in the ``beets.dbcore.types`` and +``beets.library`` modules or implement your own type by inheriting from the +``Type`` class. + +Specifying types has several advantages: + +- Code that accesses the field like ``item['my_field']`` gets the right type + (instead of just a string). +- You can use advanced queries (like :ref:`ranges <numericquery>`) from the + command line. +- User input for flexible fields may be validated and converted. +- Items missing the given field can use an appropriate null value for querying + and sorting purposes. diff --git a/docs/dev/plugins/other/import.rst b/docs/dev/plugins/other/import.rst new file mode 100644 index 000000000..77d961522 --- /dev/null +++ b/docs/dev/plugins/other/import.rst @@ -0,0 +1,88 @@ +.. _plugin-stage: + +Add Import Pipeline Stages +-------------------------- + +Many plugins need to add high-latency operations to the import workflow. For +example, a plugin that fetches lyrics from the Web would, ideally, not block the +progress of the rest of the importer. Beets allows plugins to add stages to the +parallel import pipeline. + +Each stage is run in its own thread. Plugin stages run after metadata changes +have been applied to a unit of music (album or track) and before file +manipulation has occurred (copying and moving files, writing tags to disk). +Multiple stages run in parallel but each stage processes only one task at a time +and each task is processed by only one stage at a time. + +Plugins provide stages as functions that take two arguments: ``config`` and +``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined +in ``beets.importer``). Add such a function to the plugin's ``import_stages`` +field to register it: + +.. code-block:: python + + from beets.importer import ImportSession, ImportTask + from beets.plugins import BeetsPlugin + + + class ExamplePlugin(BeetsPlugin): + + def __init__(self): + super().__init__() + self.import_stages = [self.stage] + + def stage(self, session: ImportSession, task: ImportTask): + print("Importing something!") + +It is also possible to request your function to run early in the pipeline by +adding the function to the plugin's ``early_import_stages`` field instead: + +.. code-block:: python + + self.early_import_stages = [self.stage] + +.. _extend-query: + +Extend the Query Syntax +~~~~~~~~~~~~~~~~~~~~~~~ + +You can add new kinds of queries to beets' :doc:`query syntax +</reference/query>`. There are two ways to add custom queries: using a prefix +and using a name. Prefix-based query extension can apply to *any* field, while +named queries are not associated with any field. For example, beets already +supports regular expression queries, which are indicated by a colon +prefix---plugins can do the same. + +For either kind of query extension, define a subclass of the ``Query`` type from +the ``beets.dbcore.query`` module. Then: + +- To define a prefix-based query, define a ``queries`` method in your plugin + class. Return from this method a dictionary mapping prefix strings to query + classes. +- To define a named query, defined dictionaries named either ``item_queries`` or + ``album_queries``. These should map names to query types. So if you use ``{ + "foo": FooQuery }``, then the query ``foo:bar`` will construct a query like + ``FooQuery("bar")``. + +For prefix-based queries, you will want to extend ``FieldQuery``, which +implements string comparisons on fields. To use it, create a subclass inheriting +from that class and override the ``value_match`` class method. (Remember the +``@classmethod`` decorator!) The following example plugin declares a query using +the ``@`` prefix to delimit exact string matches. The plugin will be used if we +issue a command like ``beet ls @something`` or ``beet ls artist:@something``: + +.. code-block:: python + + from beets.plugins import BeetsPlugin + from beets.dbcore import FieldQuery + + + class ExactMatchQuery(FieldQuery): + @classmethod + def value_match(self, pattern, val): + return pattern == val + + + class ExactMatchPlugin(BeetsPlugin): + def queries(self): + return {"@": ExactMatchQuery} diff --git a/docs/dev/plugins/other/index.rst b/docs/dev/plugins/other/index.rst new file mode 100644 index 000000000..595139042 --- /dev/null +++ b/docs/dev/plugins/other/index.rst @@ -0,0 +1,16 @@ +Further Reading +=============== + +For more information on writing plugins, feel free to check out the following +resources: + +.. toctree:: + :maxdepth: 2 + + config + templates + mediafile + import + fields + logging + prompts diff --git a/docs/dev/plugins/other/logging.rst b/docs/dev/plugins/other/logging.rst new file mode 100644 index 000000000..cae088f50 --- /dev/null +++ b/docs/dev/plugins/other/logging.rst @@ -0,0 +1,38 @@ +.. _plugin-logging: + +Logging +------- + +Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the +`standard Python logging module`_. The logger is set up to `PEP 3101`_, +str.format-style string formatting. So you can write logging calls like this: + +.. code-block:: python + + self._log.debug("Processing {0.title} by {0.artist}", item) + +.. _pep 3101: https://www.python.org/dev/peps/pep-3101/ + +.. _standard python logging module: https://docs.python.org/2/library/logging.html + +When beets is in verbose mode, plugin messages are prefixed with the plugin name +to make them easier to see. + +Which messages will be logged depends on the logging level and the action +performed: + +- Inside import stages and event handlers, the default is ``WARNING`` messages + and above. +- Everywhere else, the default is ``INFO`` or above. + +The verbosity can be increased with ``--verbose`` (``-v``) flags: each flags +lowers the level by a notch. That means that, with a single ``-v`` flag, event +handlers won't have their ``DEBUG`` messages displayed, but command functions +(for example) will. With ``-vv`` on the command line, ``DEBUG`` messages will be +displayed everywhere. + +This addresses a common pattern where plugins need to use the same code for a +command and an import stage, but the command needs to print more messages than +the import stage. (For example, you'll want to log "found lyrics for this song" +when you're run explicitly as a command, but you don't want to noisily interrupt +the importer interface when running automatically.) diff --git a/docs/dev/plugins/other/mediafile.rst b/docs/dev/plugins/other/mediafile.rst new file mode 100644 index 000000000..467fd34ea --- /dev/null +++ b/docs/dev/plugins/other/mediafile.rst @@ -0,0 +1,32 @@ +Extend MediaFile +---------------- + +MediaFile_ is the file tag abstraction layer that beets uses to make +cross-format metadata manipulation simple. Plugins can add fields to MediaFile +to extend the kinds of metadata that they can easily manage. + +The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to +file tags. If you have created a descriptor you can add it through your plugins +:py:meth:`beets.plugins.BeetsPlugin.add_media_field` method. + +.. _mediafile: https://mediafile.readthedocs.io/en/latest/ + +Here's an example plugin that provides a meaningless new field "foo": + +.. code-block:: python + + class FooPlugin(BeetsPlugin): + def __init__(self): + field = mediafile.MediaField( + mediafile.MP3DescStorageStyle("foo"), mediafile.StorageStyle("foo") + ) + self.add_media_field("foo", field) + + + FooPlugin() + item = Item.from_path("/path/to/foo/tag.mp3") + assert item["foo"] == "spam" + + item["foo"] == "ham" + item.write() + # The "foo" tag of the file is now "ham" diff --git a/docs/dev/plugins/other/prompts.rst b/docs/dev/plugins/other/prompts.rst new file mode 100644 index 000000000..8916a3a03 --- /dev/null +++ b/docs/dev/plugins/other/prompts.rst @@ -0,0 +1,69 @@ +.. _append_prompt_choices: + +Append Prompt Choices +--------------------- + +Plugins can also append choices to the prompt presented to the user during an +import session. + +To do so, add a listener for the ``before_choose_candidate`` event, and return a +list of ``PromptChoices`` that represent the additional choices that your plugin +shall expose to the user: + +.. code-block:: python + + from beets.plugins import BeetsPlugin + from beets.ui.commands import PromptChoice + + + class ExamplePlugin(BeetsPlugin): + def __init__(self): + super().__init__() + self.register_listener( + "before_choose_candidate", self.before_choose_candidate_event + ) + + def before_choose_candidate_event(self, session, task): + return [ + PromptChoice("p", "Print foo", self.foo), + PromptChoice("d", "Do bar", self.bar), + ] + + def foo(self, session, task): + print('User has chosen "Print foo"!') + + def bar(self, session, task): + print('User has chosen "Do bar"!') + +The previous example modifies the standard prompt: + +.. code-block:: shell + + # selection (default 1), Skip, Use as-is, as Tracks, Group albums, + Enter search, enter Id, aBort? + +by appending two additional options (``Print foo`` and ``Do bar``): + +.. code-block:: shell + + # selection (default 1), Skip, Use as-is, as Tracks, Group albums, + Enter search, enter Id, aBort, Print foo, Do bar? + +If the user selects a choice, the ``callback`` attribute of the corresponding +``PromptChoice`` will be called. It is the responsibility of the plugin to check +for the status of the import session and decide the choices to be appended: for +example, if a particular choice should only be presented if the album has no +candidates, the relevant checks against ``task.candidates`` should be performed +inside the plugin's ``before_choose_candidate_event`` accordingly. + +Please make sure that the short letter for each of the choices provided by the +plugin is not already in use: the importer will emit a warning and discard all +but one of the choices using the same letter, giving priority to the core +importer prompt choices. As a reference, the following characters are used by +the choices on the core importer prompt, and hence should not be used: ``a``, +``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``. + +Additionally, the callback function can optionally specify the next action to be +performed by returning a ``importer.Action`` value. It may also return a +``autotag.Proposal`` value to update the set of current proposals to be +considered. diff --git a/docs/dev/plugins/other/templates.rst b/docs/dev/plugins/other/templates.rst new file mode 100644 index 000000000..21a592603 --- /dev/null +++ b/docs/dev/plugins/other/templates.rst @@ -0,0 +1,57 @@ +Add Path Format Functions and Fields +------------------------------------ + +Beets supports *function calls* in its path format syntax (see +:doc:`/reference/pathformat`). Beets includes a few built-in functions, but +plugins can register new functions by adding them to the ``template_funcs`` +dictionary. + +Here's an example: + +.. code-block:: python + + class MyPlugin(BeetsPlugin): + def __init__(self): + super().__init__() + self.template_funcs["initial"] = _tmpl_initial + + + def _tmpl_initial(text: str) -> str: + if text: + return text[0].upper() + else: + return "" + +This plugin provides a function ``%initial`` to path templates where +``%initial{$artist}`` expands to the artist's initial (its capitalized first +character). + +Plugins can also add template *fields*, which are computed values referenced as +``$name`` in templates. To add a new field, add a function that takes an +``Item`` object to the ``template_fields`` dictionary on the plugin object. +Here's an example that adds a ``$disc_and_track`` field: + +.. code-block:: python + + class MyPlugin(BeetsPlugin): + def __init__(self): + super().__init__() + self.template_fields["disc_and_track"] = _tmpl_disc_and_track + + + def _tmpl_disc_and_track(item: Item) -> str: + """Expand to the disc number and track number if this is a + multi-disc release. Otherwise, just expands to the track + number. + """ + if item.disctotal > 1: + return "%02i.%02i" % (item.disc, item.track) + else: + return "%02i" % (item.track) + +With this plugin enabled, templates can reference ``$disc_and_track`` as they +can any standard metadata field. + +This field works for *item* templates. Similarly, you can register *album* +template fields by adding a function accepting an ``Album`` argument to the +``album_template_fields`` dict. From fddda507ea25dca2cecd6df23b78b7cf361942b4 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 31 Aug 2025 07:36:03 +0200 Subject: [PATCH 057/301] docs: Reveal 3 nav levels in primary sidebar and also allow a max level of 3 to expand. --- docs/conf.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index d0f8cdffe..840c55a3b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -81,7 +81,12 @@ man_pages = [ html_theme = "pydata_sphinx_theme" -html_theme_options = {"collapse_navigation": True, "logo": {"text": "beets"}} +html_theme_options = { + "collapse_navigation": False, + "logo": {"text": "beets"}, + "show_nav_level": 3, # How many levels in left sidebar to show automatically + "navigation_depth": 4, # How many levels of navigation to expand +} html_title = "beets" html_logo = "_static/beets_logo_nobg.png" html_static_path = ["_static"] From e0d16c20f426b67ff3b43c5a6fbf666149a17349 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 31 Aug 2025 17:02:38 +0200 Subject: [PATCH 058/301] Add another docs file move to git blame ignore revs --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 75e49f8cc..ebe27088c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -59,6 +59,8 @@ c490ac5810b70f3cf5fd8649669838e8fdb19f4d ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d # Moved dev docs 2504595532abd7584143007ede087ee4abc00916 +# Moved plugin docs Further Reading chapter +c8cb3813e38fe1381509c39e415c3a5fe0deb808 # Replace format calls with f-strings 4a361bd501e85de12c91c2474c423559ca672852 # Replace percent formatting From 09da8a29914c4d56e3d3c1d627a92ccc6be5b98d Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 2 Sep 2025 09:24:17 +0200 Subject: [PATCH 059/301] - Updated git blame ignore with new hashes - run docstrfmt --- .git-blame-ignore-revs | 8 ++++---- docs/dev/plugins/other.rst | 0 docs/dev/plugins/other/config.rst | 2 +- docs/dev/plugins/other/fields.rst | 2 +- docs/dev/plugins/other/import.rst | 4 ++-- docs/dev/plugins/other/logging.rst | 2 +- docs/dev/plugins/other/mediafile.rst | 2 +- docs/dev/plugins/other/prompts.rst | 2 +- docs/dev/plugins/other/templates.rst | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) delete mode 100644 docs/dev/plugins/other.rst diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index ebe27088c..54cb86242 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -57,10 +57,6 @@ c490ac5810b70f3cf5fd8649669838e8fdb19f4d 769dcdc88a1263638ae25944ba6b2be3e8933666 # Reformat all docs using docstrfmt ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d -# Moved dev docs -2504595532abd7584143007ede087ee4abc00916 -# Moved plugin docs Further Reading chapter -c8cb3813e38fe1381509c39e415c3a5fe0deb808 # Replace format calls with f-strings 4a361bd501e85de12c91c2474c423559ca672852 # Replace percent formatting @@ -71,3 +67,7 @@ c8cb3813e38fe1381509c39e415c3a5fe0deb808 2fccf64efe82851861e195b521b14680b480a42a # Do not use explicit indices for logging args when not needed d93ddf8dd43e4f9ed072a03829e287c78d2570a2 +# Moved dev docs +1f94bdbe4963c693847c24af18b151e12c670995 +# Moved plugin docs Further Reading chapter +18088c654665e84afc0a67173aa8056ca6b57a58 \ No newline at end of file diff --git a/docs/dev/plugins/other.rst b/docs/dev/plugins/other.rst deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/dev/plugins/other/config.rst b/docs/dev/plugins/other/config.rst index 043d4b28a..e5581fe63 100644 --- a/docs/dev/plugins/other/config.rst +++ b/docs/dev/plugins/other/config.rst @@ -1,5 +1,5 @@ Read Configuration Options --------------------------- +========================== Plugins can configure themselves using the ``config.yaml`` file. You can read configuration values in two ways. The first is to use `self.config` within your diff --git a/docs/dev/plugins/other/fields.rst b/docs/dev/plugins/other/fields.rst index 429b726dc..6ee570043 100644 --- a/docs/dev/plugins/other/fields.rst +++ b/docs/dev/plugins/other/fields.rst @@ -1,5 +1,5 @@ Flexible Field Types --------------------- +==================== If your plugin uses flexible fields to store numbers or other non-string values, you can specify the types of those fields. A rating plugin, for example, might diff --git a/docs/dev/plugins/other/import.rst b/docs/dev/plugins/other/import.rst index 77d961522..706a520b7 100644 --- a/docs/dev/plugins/other/import.rst +++ b/docs/dev/plugins/other/import.rst @@ -1,7 +1,7 @@ .. _plugin-stage: Add Import Pipeline Stages --------------------------- +========================== Many plugins need to add high-latency operations to the import workflow. For example, a plugin that fetches lyrics from the Web would, ideally, not block the @@ -44,7 +44,7 @@ adding the function to the plugin's ``early_import_stages`` field instead: .. _extend-query: Extend the Query Syntax -~~~~~~~~~~~~~~~~~~~~~~~ +----------------------- You can add new kinds of queries to beets' :doc:`query syntax </reference/query>`. There are two ways to add custom queries: using a prefix diff --git a/docs/dev/plugins/other/logging.rst b/docs/dev/plugins/other/logging.rst index cae088f50..1c4ce4838 100644 --- a/docs/dev/plugins/other/logging.rst +++ b/docs/dev/plugins/other/logging.rst @@ -1,7 +1,7 @@ .. _plugin-logging: Logging -------- +======= Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the `standard Python logging module`_. The logger is set up to `PEP 3101`_, diff --git a/docs/dev/plugins/other/mediafile.rst b/docs/dev/plugins/other/mediafile.rst index 467fd34ea..8fa22ceca 100644 --- a/docs/dev/plugins/other/mediafile.rst +++ b/docs/dev/plugins/other/mediafile.rst @@ -1,5 +1,5 @@ Extend MediaFile ----------------- +================ MediaFile_ is the file tag abstraction layer that beets uses to make cross-format metadata manipulation simple. Plugins can add fields to MediaFile diff --git a/docs/dev/plugins/other/prompts.rst b/docs/dev/plugins/other/prompts.rst index 8916a3a03..f734f0de3 100644 --- a/docs/dev/plugins/other/prompts.rst +++ b/docs/dev/plugins/other/prompts.rst @@ -1,7 +1,7 @@ .. _append_prompt_choices: Append Prompt Choices ---------------------- +===================== Plugins can also append choices to the prompt presented to the user during an import session. diff --git a/docs/dev/plugins/other/templates.rst b/docs/dev/plugins/other/templates.rst index 21a592603..89509dcb7 100644 --- a/docs/dev/plugins/other/templates.rst +++ b/docs/dev/plugins/other/templates.rst @@ -1,5 +1,5 @@ Add Path Format Functions and Fields ------------------------------------- +==================================== Beets supports *function calls* in its path format syntax (see :doc:`/reference/pathformat`). Beets includes a few built-in functions, but From 67dd35596814985965b5fc65c652d8956a51e091 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Wed, 3 Sep 2025 07:14:17 +0200 Subject: [PATCH 060/301] docs: Ensure cleanup in poe docs shortcut otherwise Sphinx output is unpredictable! --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3cf3b9b47..184325599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,7 +196,7 @@ cmd = "mypy" [tool.poe.tasks.docs] help = "Build documentation" -cmd = "make -C docs html" +cmd = "make -C docs clean html" [tool.poe.tasks.format] help = "Format the codebase" From 33feb0348d68a974f3c39c6013cd2cb1d17dcdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 4 Sep 2025 10:23:52 +0100 Subject: [PATCH 061/301] Define replacements for commonly used classes --- docs/conf.py | 11 ++++++ docs/dev/library.rst | 68 ++++++++++++++++---------------- docs/dev/plugins/events.rst | 78 ++++++++++++++----------------------- 3 files changed, 73 insertions(+), 84 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 840c55a3b..1e53fe427 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -75,6 +75,17 @@ man_pages = [ ), ] +# Global substitutions that can be used anywhere in the documentation. +rst_epilog = """ +.. |Album| replace:: :class:`~beets.library.models.Album` +.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo` +.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession` +.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask` +.. |Item| replace:: :class:`~beets.library.models.Item` +.. |Library| replace:: :class:`~beets.library.library.Library` +.. |Model| replace:: :class:`~beets.dbcore.db.Model` +.. |TrackInfo| replace:: :class:`beets.autotag.hooks.TrackInfo` +""" # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/docs/dev/library.rst b/docs/dev/library.rst index 0f7554aac..8b854937d 100644 --- a/docs/dev/library.rst +++ b/docs/dev/library.rst @@ -7,18 +7,18 @@ This page describes the internal API of beets' core database features. It doesn't exhaustively document the API, but is aimed at giving an overview of the architecture to orient anyone who wants to dive into the code. -The :class:`Library` object is the central repository for data in beets. It -represents a database containing songs, which are :class:`Item` instances, and -groups of items, which are :class:`Album` instances. +The |Library| object is the central repository for data in beets. It represents +a database containing songs, which are |Item| instances, and groups of items, +which are |Album| instances. The Library Class ----------------- -The :class:`Library` is typically instantiated as a singleton. A single -invocation of beets usually has only one :class:`Library`. It's powered by -:class:`dbcore.Database` under the hood, which handles the SQLite_ abstraction, -something like a very minimal ORM_. The library is also responsible for handling -queries to retrieve stored objects. +The |Library| is typically instantiated as a singleton. A single invocation of +beets usually has only one |Library|. It's powered by :class:`dbcore.Database` +under the hood, which handles the SQLite_ abstraction, something like a very +minimal ORM_. The library is also responsible for handling queries to retrieve +stored objects. Overview ~~~~~~~~ @@ -40,10 +40,9 @@ which you can get using the :py:meth:`Library.transaction` context manager. Model Classes ------------- -The two model entities in beets libraries, :class:`Item` and :class:`Album`, -share a base class, :class:`LibModel`, that provides common functionality. That -class itself specialises :class:`beets.dbcore.Model` which provides an ORM-like -abstraction. +The two model entities in beets libraries, |Item| and |Album|, share a base +class, :class:`LibModel`, that provides common functionality. That class itself +specialises :class:`beets.dbcore.Model` which provides an ORM-like abstraction. To get or change the metadata of a model (an item or album), either access its attributes (e.g., ``print(album.year)`` or ``album.year = 2012``) or use the @@ -56,8 +55,7 @@ Models use dirty-flags to track when the object's metadata goes out of sync with the database. The dirty dictionary maps field names to booleans indicating whether the field has been written since the object was last synchronized (via load or store) with the database. This logic is implemented in the model base -class :class:`LibModel` and is inherited by both :class:`Item` and -:class:`Album`. +class :class:`LibModel` and is inherited by both |Item| and |Album|. We provide CRUD-like methods for interacting with the database: @@ -77,10 +75,10 @@ normal the normal mapping API is supported: Item ~~~~ -Each :class:`Item` object represents a song or track. (We use the more generic -term item because, one day, beets might support non-music media.) An item can -either be purely abstract, in which case it's just a bag of metadata fields, or -it can have an associated file (indicated by ``item.path``). +Each |Item| object represents a song or track. (We use the more generic term +item because, one day, beets might support non-music media.) An item can either +be purely abstract, in which case it's just a bag of metadata fields, or it can +have an associated file (indicated by ``item.path``). In terms of the underlying SQLite database, items are backed by a single table called items with one column per metadata fields. The metadata fields currently @@ -97,12 +95,12 @@ become out of sync with on-disk metadata, mainly to speed up the :ref:`update-cmd` (which needs to check whether the database is in sync with the filesystem). This feature turns out to be sort of complicated. -For any :class:`Item`, there are two mtimes: the on-disk mtime (maintained by -the OS) and the database mtime (maintained by beets). Correspondingly, there is -on-disk metadata (ID3 tags, for example) and DB metadata. The goal with the -mtime is to ensure that the on-disk and DB mtimes match when the on-disk and DB -metadata are in sync; this lets beets do a quick mtime check and avoid rereading -files in some circumstances. +For any |Item|, there are two mtimes: the on-disk mtime (maintained by the OS) +and the database mtime (maintained by beets). Correspondingly, there is on-disk +metadata (ID3 tags, for example) and DB metadata. The goal with the mtime is to +ensure that the on-disk and DB mtimes match when the on-disk and DB metadata are +in sync; this lets beets do a quick mtime check and avoid rereading files in +some circumstances. Specifically, beets attempts to maintain the following invariant: @@ -126,14 +124,14 @@ This leads to the following implementation policy: Album ~~~~~ -An :class:`Album` is a collection of Items in the database. Every item in the -database has either zero or one associated albums (accessible via -``item.album_id``). An item that has no associated album is called a singleton. -Changing fields on an album (e.g. ``album.year = 2012``) updates the album -itself and also changes the same field in all associated items. +An |Album| is a collection of Items in the database. Every item in the database +has either zero or one associated albums (accessible via ``item.album_id``). An +item that has no associated album is called a singleton. Changing fields on an +album (e.g. ``album.year = 2012``) updates the album itself and also changes the +same field in all associated items. -An :class:`Album` object keeps track of album-level metadata, which is (mostly) -a subset of the track-level metadata. The album-level metadata fields are listed +An |Album| object keeps track of album-level metadata, which is (mostly) a +subset of the track-level metadata. The album-level metadata fields are listed in ``Album._fields``. For those fields that are both item-level and album-level (e.g., ``year`` or ``albumartist``), every item in an album should share the same value. Albums use an SQLite table called ``albums``, in which each column @@ -147,7 +145,7 @@ is an album metadata field. Transactions ~~~~~~~~~~~~ -The :class:`Library` class provides the basic methods necessary to access and +The |Library| class provides the basic methods necessary to access and manipulate its contents. To perform more complicated operations atomically, or to interact directly with the underlying SQLite database, you must use a *transaction* (see this `blog post`_ for motivation). For example @@ -181,8 +179,8 @@ matching items/albums. The ``clause()`` method should return an SQLite ``WHERE`` clause that matches appropriate albums/items. This allows for efficient batch queries. -Correspondingly, the ``match(item)`` method should take an :class:`Item` object -and return a boolean, indicating whether or not a specific item matches the +Correspondingly, the ``match(item)`` method should take an |Item| object and +return a boolean, indicating whether or not a specific item matches the criterion. This alternate implementation allows clients to determine whether items that have already been fetched from the database match the query. @@ -194,4 +192,4 @@ together, matching only albums/items that match all constituent queries. Beets has a human-writable plain-text query syntax that can be parsed into :class:`Query` objects. Calling ``AndQuery.from_strings`` parses a list of query -parts into a query object that can then be used with :class:`Library` objects. +parts into a query object that can then be used with |Library| objects. diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst index 325b01b33..68773db3b 100644 --- a/docs/dev/plugins/events.rst +++ b/docs/dev/plugins/events.rst @@ -52,166 +52,146 @@ registration process in this case: command starts. ``import`` - :Parameters: :py:class:`lib <beets.library.Library>`, ``paths`` (list of - path strings) + :Parameters: ``lib`` (|Library|), ``paths`` (list of path strings) :Description: Called after the ``import`` command finishes. ``album_imported`` - :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`album - <beets.library.Album>` + :Parameters: ``lib`` (|Library|), ``album`` (|Album|) :Description: Called every time the importer finishes adding an album to the library. ``album_removed`` - :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`album - <beets.library.Album>` + :Parameters: ``lib`` (|Library|), ``album`` (|Album|) :Description: Called every time an album is removed from the library (even when its files are not deleted from disk). ``item_copied`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called whenever an item file is copied. ``item_imported`` - :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`item - <beets.library.Item>` + :Parameters: ``lib`` (|Library|), ``item`` (|Item|) :Description: Called every time the importer adds a singleton to the library (not called for full-album imports). ``before_item_imported`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object immediately before it is imported. ``before_item_moved`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object immediately before its file is moved. ``item_moved`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever its file is moved. ``item_linked`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever a symlink is created for a file. ``item_hardlinked`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever a hardlink is created for a file. ``item_reflinked`` - :Parameters: :py:class:`item <beets.library.Item>`, ``source`` (path), - ``destination`` (path) + :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever a reflink is created for a file. ``item_removed`` - :Parameters: :py:class:`item <beets.library.Item>` + :Parameters: ``item`` (|Item|) :Description: Called with an ``Item`` object every time an item (singleton or part of an album) is removed from the library (even when its file is not deleted from disk). ``write`` - :Parameters: :py:class:`item <beets.library.Item>`, ``path`` (path), - ``tags`` (dict) + :Parameters: ``item`` (|Item|), ``path`` (path), ``tags`` (dict) :Description: Called just before a file's metadata is written to disk. Handlers may modify ``tags`` or raise ``library.FileOperationError`` to abort. ``after_write`` - :Parameters: :py:class:`item <beets.library.Item>` + :Parameters: ``item`` (|Item|) :Description: Called after a file's metadata is written to disk. ``import_task_created`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called immediately after an import task is initialized. May return a list (possibly empty) of replacement tasks. ``import_task_start`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before an import task begins processing. ``import_task_apply`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after metadata changes have been applied in an import task (on the UI thread; keep fast). Prefer a pipeline stage otherwise (see :ref:`plugin-stage`). ``import_task_before_choice`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after candidate search and before deciding how to import. May return an importer action (only one handler may return non-None). ``import_task_choice`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after a decision has been made about an import task. Use ``task.choice_flag`` to inspect or change the action. ``import_task_files`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after filesystem manipulation (copy/move/write) for an import task. ``library_opened`` - :Parameters: :py:class:`lib <beets.library.Library>` + :Parameters: ``lib`` (|Library|) :Description: Called after beets starts and initializes the main Library object. ``database_change`` - :Parameters: :py:class:`lib <beets.library.Library>`, :py:class:`model - <beets.library.Model>` + :Parameters: ``lib`` (|Library|), ``model`` (|Model|) :Description: A modification has been made to the library database (may not yet be committed). ``cli_exit`` - :Parameters: :py:class:`lib <beets.library.Library>` + :Parameters: ``lib`` (|Library|) :Description: Called just before the ``beet`` command-line program exits. ``import_begin`` - :Parameters: :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``session`` (|ImportSession|) :Description: Called just before a ``beet import`` session starts. ``trackinfo_received`` - :Parameters: :py:class:`info <beets.autotag.TrackInfo>` + :Parameters: ``info`` (|TrackInfo|) :Description: Called after metadata for a track is fetched (e.g., from MusicBrainz). Handlers can modify the tags seen by later pipeline stages or adjustments (e.g., ``mbsync``). ``albuminfo_received`` - :Parameters: :py:class:`info <beets.autotag.AlbumInfo>` + :Parameters: ``info`` (|AlbumInfo|) :Description: Like ``trackinfo_received`` but for album-level metadata. ``before_choose_candidate`` - :Parameters: :py:class:`task <beets.importer.ImportTask>`, - :py:class:`session <beets.importer.ImportSession>` + :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before prompting the user during interactive import. May return a list of ``PromptChoices`` to append to the prompt (see :ref:`append_prompt_choices`). ``mb_track_extract`` - :Parameters: :py:class:`data <dict>` + :Parameters: ``data`` (dict) :Description: Called after metadata is obtained from MusicBrainz for a track. Must return a (possibly empty) dict of additional ``field: value`` pairs to apply (overwriting existing fields). ``mb_album_extract`` - :Parameters: :py:class:`data <dict>` + :Parameters: ``data`` (dict) :Description: Like ``mb_track_extract`` but for album tags. Overwrites tags set at the track level with the same field. From 1736a5e735ff681d246ed784599c4d3efb534d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 4 Sep 2025 11:47:05 +0100 Subject: [PATCH 062/301] Define MetadataSourcePlugin methods on the subclass only --- beets/metadata_plugins.py | 11 ----------- beets/plugins.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 429a6e716..381881b51 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -8,7 +8,6 @@ implemented as plugins. from __future__ import annotations import abc -import inspect import re import warnings from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar @@ -421,13 +420,3 @@ class SearchApiMetadataSourcePlugin( query = unidecode.unidecode(query) return query - - -# Dynamically copy methods to BeetsPlugin for legacy support -# TODO: Remove this in the future major release, v3.0.0 - -for name, method in inspect.getmembers( - MetadataSourcePlugin, predicate=inspect.isfunction -): - if not hasattr(BeetsPlugin, name): - setattr(BeetsPlugin, name, method) diff --git a/beets/plugins.py b/beets/plugins.py index d9df4323c..d8d465183 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -158,6 +158,21 @@ class BeetsPlugin(metaclass=abc.ABCMeta): early_import_stages: list[ImportStageFunc] import_stages: list[ImportStageFunc] + def __init_subclass__(cls) -> None: + # Dynamically copy methods to BeetsPlugin for legacy support + # TODO: Remove this in the future major release, v3.0.0 + if inspect.isabstract(cls): + return + + from beets.metadata_plugins import MetadataSourcePlugin + + abstractmethods = MetadataSourcePlugin.__abstractmethods__ + for name, method in inspect.getmembers( + MetadataSourcePlugin, predicate=inspect.isfunction + ): + if name not in abstractmethods and not hasattr(cls, name): + setattr(cls, name, method) + def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" From f8a98ac518a11d71deeabfcf7b0cb842a05746a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 4 Sep 2025 11:47:34 +0100 Subject: [PATCH 063/301] Add index for API Reference --- docs/api/index.rst | 9 +++++++++ docs/conf.py | 2 +- docs/dev/index.rst | 10 ++-------- 3 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 docs/api/index.rst diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 000000000..edec5fe96 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,9 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + plugins + database diff --git a/docs/conf.py b/docs/conf.py index 1e53fe427..838d82800 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,7 +95,7 @@ html_theme = "pydata_sphinx_theme" html_theme_options = { "collapse_navigation": False, "logo": {"text": "beets"}, - "show_nav_level": 3, # How many levels in left sidebar to show automatically + "show_nav_level": 2, # How many levels in left sidebar to show automatically "navigation_depth": 4, # How many levels of navigation to expand } html_title = "beets" diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 633d50cd1..7bd0ba709 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -13,17 +13,11 @@ configuration files, respectively. .. _mediafile: https://mediafile.readthedocs.io/en/latest/ .. toctree:: - :maxdepth: 2 + :maxdepth: 3 :titlesonly: plugins/index library importer cli - -.. toctree:: - :maxdepth: 1 - :caption: API Reference - - ../api/plugins - ../api/database + ../api/index From 4cb667cbb3b11405852a6569fdb1dc0343feba3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 4 Sep 2025 12:25:22 +0100 Subject: [PATCH 064/301] Fix formatting issues --- docs/dev/plugins/autotagger.rst | 6 +++--- docs/dev/plugins/other/config.rst | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/dev/plugins/autotagger.rst b/docs/dev/plugins/autotagger.rst index 1a4fa6dd6..1cae5295e 100644 --- a/docs/dev/plugins/autotagger.rst +++ b/docs/dev/plugins/autotagger.rst @@ -88,9 +88,9 @@ Beets already ships with several metadata source plugins. Studying these implementations can help you follow conventions and avoid pitfalls. Good starting points include: -- `spotify` -- `deezer` -- `discogs` +- ``spotify`` +- ``deezer`` +- ``discogs`` Migration guidance ------------------ diff --git a/docs/dev/plugins/other/config.rst b/docs/dev/plugins/other/config.rst index e5581fe63..7c529af93 100644 --- a/docs/dev/plugins/other/config.rst +++ b/docs/dev/plugins/other/config.rst @@ -2,10 +2,10 @@ Read Configuration Options ========================== Plugins can configure themselves using the ``config.yaml`` file. You can read -configuration values in two ways. The first is to use `self.config` within your -plugin class. This gives you a view onto the configuration values in a section -with the same name as your plugin's module. For example, if your plugin is in -``greatplugin.py``, then `self.config` will refer to options under the +configuration values in two ways. The first is to use ``self.config`` within +your plugin class. This gives you a view onto the configuration values in a +section with the same name as your plugin's module. For example, if your plugin +is in ``greatplugin.py``, then ``self.config`` will refer to options under the ``greatplugin:`` section of the config file. For example, if you have a configuration value called "foo", then users can put @@ -17,19 +17,19 @@ this in their ``config.yaml``: foo: bar 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_ +plugin's code. The ``self.config`` object is a *view* as defined by the Confuse_ library. .. _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 +import the ``config`` object from the ``beets`` module. That is, just put ``from beets import config`` at the top of your plugin and access values from there. If your plugin provides configuration values for sensitive data (e.g., passwords, API keys, ...), you should add these to the config so they can be redacted automatically when users dump their config. This can be done by setting -each value's `redact` flag, like so: +each value's ``redact`` flag, like so: :: From b7e53579144c66329ca8bf650f08fe7ffa219965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 4 Sep 2025 12:52:37 +0100 Subject: [PATCH 065/301] Run only html by default but allow adjustments --- .github/workflows/lint.yml | 2 +- pyproject.toml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0048a8f6e..8fdfa94e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -143,4 +143,4 @@ jobs: run: poe lint-docs - name: Build docs - run: poe docs -e 'SPHINXOPTS=--fail-on-warning --keep-going' + run: poe docs -- -e 'SPHINXOPTS=--fail-on-warning --keep-going' diff --git a/pyproject.toml b/pyproject.toml index 184325599..63a22f3f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,7 +196,8 @@ cmd = "mypy" [tool.poe.tasks.docs] help = "Build documentation" -cmd = "make -C docs clean html" +args = [{ name = "COMMANDS", positional = true, multiple = true, default = "html" }] +cmd = "make -C docs $COMMANDS" [tool.poe.tasks.format] help = "Format the codebase" From 123075d51133589ec5fadbf7802f04ad092ce3ef Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Thu, 4 Sep 2025 16:07:25 +0200 Subject: [PATCH 066/301] Updated git blame hashes yet again :) --- .git-blame-ignore-revs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 54cb86242..fbe32b497 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -68,6 +68,6 @@ ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d # Do not use explicit indices for logging args when not needed d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved dev docs -1f94bdbe4963c693847c24af18b151e12c670995 +07549ed896d9649562d40b75cd30702e6fa6e975 # Moved plugin docs Further Reading chapter -18088c654665e84afc0a67173aa8056ca6b57a58 \ No newline at end of file +33f1a5d0bef8ca08be79ee7a0d02a018d502680d \ No newline at end of file From e51de5de915278ad29c108d89926904a907ac845 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Thu, 4 Sep 2025 17:40:54 +0200 Subject: [PATCH 067/301] Removed data source as listenbrainz is not an metadata source plugin. closes #5975 --- beetsplug/listenbrainz.py | 3 +-- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index f7b1389ef..2aa4e7ad6 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -13,7 +13,6 @@ from beetsplug.lastimport import process_tracks class ListenBrainzPlugin(BeetsPlugin): """A Beets plugin for interacting with ListenBrainz.""" - data_source = "ListenBrainz" ROOT = "http://api.listenbrainz.org/1/" def __init__(self): @@ -27,7 +26,7 @@ class ListenBrainzPlugin(BeetsPlugin): def commands(self): """Add beet UI commands to interact with ListenBrainz.""" lbupdate_cmd = ui.Subcommand( - "lbimport", help=f"Import {self.data_source} history" + "lbimport", help="Import ListenBrainz history" ) def func(lib, opts, args): diff --git a/docs/changelog.rst b/docs/changelog.rst index 95c22115b..4b4134cae 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -63,6 +63,8 @@ Bug fixes: ascii encoded. This resulted in bad matches for queries that contained special e.g. non latin characters as 盗作. If you want to keep the legacy behavior set the config option ``deezer.search_query_ascii: yes``. :bug:`5860` +- Fixed regression with :doc:`/plugins/listenbrainz` where the plugin could not + be loaded :bug:`5975` For packagers: From 37ae23faf507363d3a8788c496cca238d5774f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Fri, 5 Sep 2025 09:53:47 +0100 Subject: [PATCH 068/301] Add CODEOWNERS file to assign the entire repo to @maintainers --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..767509c9a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# assign the entire repo to the maintainers team +* @beetbox/maintainers From ecc4a72f2c2feb949d7ce0a9ed815da6687752c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Fri, 5 Sep 2025 09:54:10 +0100 Subject: [PATCH 069/301] Add a note about ownership --- CONTRIBUTING.rst | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 031e8fbc5..ee963ab46 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -180,8 +180,7 @@ Your First Contribution If this is your first time contributing to an open source project, welcome! If you are confused at all about how to contribute or what to contribute, take a look at `this great tutorial <http://makeapullrequest.com/>`__, or stop by our -`discussion board <https://github.com/beetbox/beets/discussions/>`__ if you have -any questions. +`discussion board`_ if you have any questions. We maintain a list of issues we reserved for those new to open source labeled `first timers only`_. Since the goal of these issues is to get users comfortable @@ -216,6 +215,15 @@ will ship in no time. Remember, code contributions have four parts: the code, the tests, the documentation, and the changelog entry. Thank you for contributing! +.. admonition:: Ownership + + If you are the owner of a plugin, please consider reviewing pull requests + that affect your plugin. If you are not the owner of a plugin, please + consider becoming one! You can do so by adding an entry to + ``.github/CODEOWNERS``. This way, you will automatically receive a review + request for pull requests that adjust the code that you own. If you have any + questions, please ask on our `discussion board`_. + The Code -------- @@ -394,6 +402,8 @@ This way, the test will be run only in the integration test suite. .. _codecov: https://codecov.io/github/beetbox/beets +.. _discussion board: https://github.com/beetbox/beets/discussions + .. _documentation: https://beets.readthedocs.io/en/stable/ .. _https://github.com/beetbox/beets/blob/master/test/test_template.py#l224: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224 From e8d2c28e9408325cafea313bb6410f399c5a8d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 26 Aug 2025 01:33:09 +0100 Subject: [PATCH 070/301] hooks: Generalise AlbumInfo and TrackInfo into Info --- beets/autotag/hooks.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 7cd215fc4..b3e8afcaf 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -16,8 +16,11 @@ from __future__ import annotations +from copy import deepcopy from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar +from typing_extensions import Self + from beets import logging if TYPE_CHECKING: @@ -36,6 +39,9 @@ class AttrDict(dict[str, V]): is equivalent to `d['field']`. """ + def copy(self) -> Self: + return deepcopy(self) + def __getattr__(self, attr: str) -> V: if attr in self: return self[attr] @@ -49,7 +55,11 @@ class AttrDict(dict[str, V]): return id(self) -class AlbumInfo(AttrDict[Any]): +class Info(AttrDict[Any]): + pass + + +class AlbumInfo(Info): """Describes a canonical release that may be used to match a release in the library. Consists of these data members: @@ -152,14 +162,8 @@ class AlbumInfo(AttrDict[Any]): self.discogs_artistid = discogs_artistid self.update(kwargs) - def copy(self) -> AlbumInfo: - dupe = AlbumInfo([]) - dupe.update(self) - dupe.tracks = [track.copy() for track in self.tracks] - return dupe - -class TrackInfo(AttrDict[Any]): +class TrackInfo(Info): """Describes a canonical track present on a release. Appears as part of an AlbumInfo's ``tracks`` list. Consists of these data members: @@ -242,11 +246,6 @@ class TrackInfo(AttrDict[Any]): self.album = album self.update(kwargs) - def copy(self) -> TrackInfo: - dupe = TrackInfo() - dupe.update(self) - return dupe - # Structures that compose all the information for a candidate match. From 60773d66b00d7df9305eff1cfe5f8df3f74fc9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 26 Aug 2025 02:38:18 +0100 Subject: [PATCH 071/301] hooks: abstract common attrs info Info class --- beets/autotag/hooks.py | 88 ++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 55 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index b3e8afcaf..31418559a 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -56,7 +56,37 @@ class AttrDict(dict[str, V]): class Info(AttrDict[Any]): - pass + def __init__( + self, + album: str | None = None, + artist_credit: str | None = None, + artist_id: str | None = None, + artist: str | None = None, + artists_credit: list[str] | None = None, + artists_ids: list[str] | None = None, + artists: list[str] | None = None, + artist_sort: str | None = None, + artists_sort: list[str] | None = None, + data_source: str | None = None, + data_url: str | None = None, + genre: str | None = None, + media: str | None = None, + **kwargs, + ) -> None: + self.album = album + self.artist = artist + self.artist_credit = artist_credit + self.artist_id = artist_id + self.artists = artists or [] + self.artists_credit = artists_credit or [] + self.artists_ids = artists_ids or [] + self.artist_sort = artist_sort + self.artists_sort = artists_sort or [] + self.data_source = data_source + self.data_url = data_url + self.genre = genre + self.media = media + self.update(kwargs) class AlbumInfo(Info): @@ -77,12 +107,7 @@ class AlbumInfo(Info): def __init__( self, tracks: list[TrackInfo], - album: str | None = None, album_id: str | None = None, - artist: str | None = None, - artist_id: str | None = None, - artists: list[str] | None = None, - artists_ids: list[str] | None = None, asin: str | None = None, albumtype: str | None = None, albumtypes: list[str] | None = None, @@ -93,8 +118,6 @@ class AlbumInfo(Info): label: str | None = None, barcode: str | None = None, mediums: int | None = None, - artist_sort: str | None = None, - artists_sort: list[str] | None = None, releasegroup_id: str | None = None, release_group_title: str | None = None, catalognum: str | None = None, @@ -102,29 +125,18 @@ class AlbumInfo(Info): language: str | None = None, country: str | None = None, style: str | None = None, - genre: str | None = None, albumstatus: str | None = None, - media: str | None = None, albumdisambig: str | None = None, releasegroupdisambig: str | None = None, - artist_credit: str | None = None, - artists_credit: list[str] | None = None, original_year: int | None = None, original_month: int | None = None, original_day: int | None = None, - data_source: str | None = None, - data_url: str | None = None, discogs_albumid: str | None = None, discogs_labelid: str | None = None, discogs_artistid: str | None = None, **kwargs, ): - self.album = album self.album_id = album_id - self.artist = artist - self.artist_id = artist_id - self.artists = artists or [] - self.artists_ids = artists_ids or [] self.tracks = tracks self.asin = asin self.albumtype = albumtype @@ -136,8 +148,6 @@ class AlbumInfo(Info): self.label = label self.barcode = barcode self.mediums = mediums - self.artist_sort = artist_sort - self.artists_sort = artists_sort or [] self.releasegroup_id = releasegroup_id self.release_group_title = release_group_title self.catalognum = catalognum @@ -145,22 +155,16 @@ class AlbumInfo(Info): self.language = language self.country = country self.style = style - self.genre = genre self.albumstatus = albumstatus - self.media = media self.albumdisambig = albumdisambig self.releasegroupdisambig = releasegroupdisambig - self.artist_credit = artist_credit - self.artists_credit = artists_credit or [] self.original_year = original_year self.original_month = original_month self.original_day = original_day - self.data_source = data_source - self.data_url = data_url self.discogs_albumid = discogs_albumid self.discogs_labelid = discogs_labelid self.discogs_artistid = discogs_artistid - self.update(kwargs) + super().__init__(**kwargs) class TrackInfo(Info): @@ -181,23 +185,12 @@ class TrackInfo(Info): title: str | None = None, track_id: str | None = None, release_track_id: str | None = None, - artist: str | None = None, - artist_id: str | None = None, - artists: list[str] | None = None, - artists_ids: list[str] | None = None, length: float | None = None, index: int | None = None, medium: int | None = None, medium_index: int | None = None, medium_total: int | None = None, - artist_sort: str | None = None, - artists_sort: list[str] | None = None, disctitle: str | None = None, - artist_credit: str | None = None, - artists_credit: list[str] | None = None, - data_source: str | None = None, - data_url: str | None = None, - media: str | None = None, lyricist: str | None = None, composer: str | None = None, composer_sort: str | None = None, @@ -208,30 +201,17 @@ class TrackInfo(Info): work_disambig: str | None = None, bpm: str | None = None, initial_key: str | None = None, - genre: str | None = None, - album: str | None = None, **kwargs, ): self.title = title self.track_id = track_id self.release_track_id = release_track_id - self.artist = artist - self.artist_id = artist_id - self.artists = artists or [] - self.artists_ids = artists_ids or [] self.length = length self.index = index - self.media = media self.medium = medium self.medium_index = medium_index self.medium_total = medium_total - self.artist_sort = artist_sort - self.artists_sort = artists_sort or [] self.disctitle = disctitle - self.artist_credit = artist_credit - self.artists_credit = artists_credit or [] - self.data_source = data_source - self.data_url = data_url self.lyricist = lyricist self.composer = composer self.composer_sort = composer_sort @@ -242,9 +222,7 @@ class TrackInfo(Info): self.work_disambig = work_disambig self.bpm = bpm self.initial_key = initial_key - self.genre = genre - self.album = album - self.update(kwargs) + super().__init__(**kwargs) # Structures that compose all the information for a candidate match. From e74646b8ae28fdc7f9163596ff72447eeff31d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 26 Aug 2025 03:52:44 +0100 Subject: [PATCH 072/301] hooks: Tidy up types and unused attributes --- beets/autotag/hooks.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 31418559a..9bc504a0c 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -21,15 +21,11 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar from typing_extensions import Self -from beets import logging - if TYPE_CHECKING: from beets.library import Item from .distance import Distance -log = logging.getLogger("beets") - V = TypeVar("V") @@ -45,13 +41,15 @@ class AttrDict(dict[str, V]): def __getattr__(self, attr: str) -> V: if attr in self: return self[attr] - else: - raise AttributeError - def __setattr__(self, key: str, value: V): + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{attr}'" + ) + + def __setattr__(self, key: str, value: V) -> None: self.__setitem__(key, value) - def __hash__(self): + def __hash__(self) -> int: # type: ignore[override] return id(self) @@ -103,7 +101,6 @@ class AlbumInfo(Info): The others are optional and may be None. """ - # TYPING: are all of these correct? I've assumed optional strings def __init__( self, tracks: list[TrackInfo], @@ -135,7 +132,7 @@ class AlbumInfo(Info): discogs_labelid: str | None = None, discogs_artistid: str | None = None, **kwargs, - ): + ) -> None: self.album_id = album_id self.tracks = tracks self.asin = asin @@ -179,7 +176,6 @@ class TrackInfo(Info): are all 1-based. """ - # TYPING: are all of these correct? I've assumed optional strings def __init__( self, title: str | None = None, @@ -202,7 +198,7 @@ class TrackInfo(Info): bpm: str | None = None, initial_key: str | None = None, **kwargs, - ): + ) -> None: self.title = title self.track_id = track_id self.release_track_id = release_track_id From 150db9c0d99fd3e9a769aea0aa0ebb3bd36b59c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 26 Aug 2025 03:58:31 +0100 Subject: [PATCH 073/301] hooks: Update Info classes documentation --- beets/autotag/hooks.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 9bc504a0c..f6f2a4f1c 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -31,9 +31,7 @@ V = TypeVar("V") # Classes used to represent candidate options. class AttrDict(dict[str, V]): - """A dictionary that supports attribute ("dot") access, so `d.field` - is equivalent to `d['field']`. - """ + """Mapping enabling attribute-style access to stored metadata values.""" def copy(self) -> Self: return deepcopy(self) @@ -54,6 +52,8 @@ class AttrDict(dict[str, V]): class Info(AttrDict[Any]): + """Container for metadata about a musical entity.""" + def __init__( self, album: str | None = None, @@ -88,17 +88,11 @@ class Info(AttrDict[Any]): class AlbumInfo(Info): - """Describes a canonical release that may be used to match a release - in the library. Consists of these data members: + """Metadata snapshot representing a single album candidate. - - ``album``: the release title - - ``album_id``: MusicBrainz ID; UUID fragment only - - ``artist``: name of the release's primary artist - - ``artist_id`` - - ``tracks``: list of TrackInfo objects making up the release - - ``mediums`` along with the fields up through ``tracks`` are required. - The others are optional and may be None. + Aggregates track entries and album-wide context gathered from an external + provider. Used during matching to evaluate similarity against a group of + user items, and later to drive tagging decisions once selected. """ def __init__( @@ -165,15 +159,11 @@ class AlbumInfo(Info): class TrackInfo(Info): - """Describes a canonical track present on a release. Appears as part - of an AlbumInfo's ``tracks`` list. Consists of these data members: + """Metadata snapshot for a single track candidate. - - ``title``: name of the track - - ``track_id``: MusicBrainz ID; UUID fragment only - - Only ``title`` and ``track_id`` are required. The rest of the fields - may be None. The indices ``index``, ``medium``, and ``medium_index`` - are all 1-based. + Captures identifying details and creative credits used to compare against + a user's item. Instances often originate within an AlbumInfo but may also + stand alone for singleton matching. """ def __init__( From 19c43c979666d6f80a07278e000cfb39cd153ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 26 Aug 2025 04:01:42 +0100 Subject: [PATCH 074/301] hooks: Sort Info classes parameters and attr assignments alphabetically --- beets/autotag/hooks.py | 140 +++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 69 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index f6f2a4f1c..b809609ea 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -98,63 +98,64 @@ class AlbumInfo(Info): def __init__( self, tracks: list[TrackInfo], + *, album_id: str | None = None, - asin: str | None = None, + albumdisambig: str | None = None, + albumstatus: str | None = None, albumtype: str | None = None, albumtypes: list[str] | None = None, + asin: str | None = None, + barcode: str | None = None, + catalognum: str | None = None, + country: str | None = None, + day: int | None = None, + discogs_albumid: str | None = None, + discogs_artistid: str | None = None, + discogs_labelid: str | None = None, + label: str | None = None, + language: str | None = None, + mediums: int | None = None, + month: int | None = None, + original_day: int | None = None, + original_month: int | None = None, + original_year: int | None = None, + release_group_title: str | None = None, + releasegroup_id: str | None = None, + releasegroupdisambig: str | None = None, + script: str | None = None, + style: str | None = None, va: bool = False, year: int | None = None, - month: int | None = None, - day: int | None = None, - label: str | None = None, - barcode: str | None = None, - mediums: int | None = None, - releasegroup_id: str | None = None, - release_group_title: str | None = None, - catalognum: str | None = None, - script: str | None = None, - language: str | None = None, - country: str | None = None, - style: str | None = None, - albumstatus: str | None = None, - albumdisambig: str | None = None, - releasegroupdisambig: str | None = None, - original_year: int | None = None, - original_month: int | None = None, - original_day: int | None = None, - discogs_albumid: str | None = None, - discogs_labelid: str | None = None, - discogs_artistid: str | None = None, **kwargs, ) -> None: - self.album_id = album_id self.tracks = tracks - self.asin = asin + self.album_id = album_id + self.albumdisambig = albumdisambig + self.albumstatus = albumstatus self.albumtype = albumtype self.albumtypes = albumtypes or [] + self.asin = asin + self.barcode = barcode + self.catalognum = catalognum + self.country = country + self.day = day + self.discogs_albumid = discogs_albumid + self.discogs_artistid = discogs_artistid + self.discogs_labelid = discogs_labelid + self.label = label + self.language = language + self.mediums = mediums + self.month = month + self.original_day = original_day + self.original_month = original_month + self.original_year = original_year + self.release_group_title = release_group_title + self.releasegroup_id = releasegroup_id + self.releasegroupdisambig = releasegroupdisambig + self.script = script + self.style = style self.va = va self.year = year - self.month = month - self.day = day - self.label = label - self.barcode = barcode - self.mediums = mediums - self.releasegroup_id = releasegroup_id - self.release_group_title = release_group_title - self.catalognum = catalognum - self.script = script - self.language = language - self.country = country - self.style = style - self.albumstatus = albumstatus - self.albumdisambig = albumdisambig - self.releasegroupdisambig = releasegroupdisambig - self.original_year = original_year - self.original_month = original_month - self.original_day = original_day - self.discogs_albumid = discogs_albumid - self.discogs_labelid = discogs_labelid - self.discogs_artistid = discogs_artistid super().__init__(**kwargs) @@ -168,46 +169,47 @@ class TrackInfo(Info): def __init__( self, - title: str | None = None, - track_id: str | None = None, - release_track_id: str | None = None, - length: float | None = None, + *, + arranger: str | None = None, + bpm: str | None = None, + composer: str | None = None, + composer_sort: str | None = None, + disctitle: str | None = None, index: int | None = None, + initial_key: str | None = None, + length: float | None = None, + lyricist: str | None = None, + mb_workid: str | None = None, medium: int | None = None, medium_index: int | None = None, medium_total: int | None = None, - disctitle: str | None = None, - lyricist: str | None = None, - composer: str | None = None, - composer_sort: str | None = None, - arranger: str | None = None, + release_track_id: str | None = None, + title: str | None = None, track_alt: str | None = None, + track_id: str | None = None, work: str | None = None, - mb_workid: str | None = None, work_disambig: str | None = None, - bpm: str | None = None, - initial_key: str | None = None, **kwargs, ) -> None: - self.title = title - self.track_id = track_id - self.release_track_id = release_track_id - self.length = length + self.arranger = arranger + self.bpm = bpm + self.composer = composer + self.composer_sort = composer_sort + self.disctitle = disctitle self.index = index + self.initial_key = initial_key + self.length = length + self.lyricist = lyricist + self.mb_workid = mb_workid self.medium = medium self.medium_index = medium_index self.medium_total = medium_total - self.disctitle = disctitle - self.lyricist = lyricist - self.composer = composer - self.composer_sort = composer_sort - self.arranger = arranger + self.release_track_id = release_track_id + self.title = title self.track_alt = track_alt + self.track_id = track_id self.work = work - self.mb_workid = mb_workid self.work_disambig = work_disambig - self.bpm = bpm - self.initial_key = initial_key super().__init__(**kwargs) From bf903fc27dfa5782436c7dd98597ced609ecd7bb Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 3 Aug 2025 22:05:16 +0200 Subject: [PATCH 075/301] lastgenre: Move file loading to helpers and add return types. --- beetsplug/lastgenre/__init__.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index dacd72f93..934874b97 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -111,9 +111,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): self.import_stages = [self.imported] self._genre_cache = {} + self.whitelist = self._load_whitelist() + self.c14n_branches, self.canonicalize = self._load_c14n_tree() - # Read the whitelist file if enabled. - self.whitelist = set() + def _load_whitelist(self) -> set[str]: + whitelist = set() wl_filename = self.config["whitelist"].get() if wl_filename in (True, ""): # Indicates the default whitelist. wl_filename = WHITELIST @@ -123,27 +125,27 @@ class LastGenrePlugin(plugins.BeetsPlugin): for line in f: line = line.decode("utf-8").strip().lower() if line and not line.startswith("#"): - self.whitelist.add(line) + whitelist.add(line) + return whitelist - # Read the genres tree for canonicalization if enabled. - self.c14n_branches = [] + def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: + c14n_branches = [] c14n_filename = self.config["canonical"].get() - self.canonicalize = c14n_filename is not False - + canonicalize = c14n_filename is not False # Default tree if c14n_filename in (True, ""): c14n_filename = C14N_TREE - elif not self.canonicalize and self.config["prefer_specific"].get(): + elif not canonicalize and self.config["prefer_specific"].get(): # prefer_specific requires a tree, load default tree c14n_filename = C14N_TREE - # Read the tree if c14n_filename: self._log.debug("Loading canonicalization tree {}", c14n_filename) c14n_filename = normpath(c14n_filename) with codecs.open(c14n_filename, "r", encoding="utf-8") as f: genres_tree = yaml.safe_load(f) - flatten_tree(genres_tree, [], self.c14n_branches) + flatten_tree(genres_tree, [], c14n_branches) + return c14n_branches, canonicalize @property def sources(self) -> tuple[str, ...]: From 6ed17912b498d04cfde8d061f26a1a5d73dc623f Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 30 Aug 2025 20:18:06 +0200 Subject: [PATCH 076/301] lastgenre: Fix _load_whitelist return type bytes --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 934874b97..6c16fc6fe 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -114,7 +114,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): self.whitelist = self._load_whitelist() self.c14n_branches, self.canonicalize = self._load_c14n_tree() - def _load_whitelist(self) -> set[str]: + def _load_whitelist(self) -> set[bytes]: whitelist = set() wl_filename = self.config["whitelist"].get() if wl_filename in (True, ""): # Indicates the default whitelist. From 8ae29e42bf9c045b5a823002aadb68f46365365b Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 31 Aug 2025 17:59:03 +0200 Subject: [PATCH 077/301] lastgenre: Fix mypy errors in file load methods --- beetsplug/lastgenre/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 6c16fc6fe..aa627ca3d 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -114,7 +114,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): self.whitelist = self._load_whitelist() self.c14n_branches, self.canonicalize = self._load_c14n_tree() - def _load_whitelist(self) -> set[bytes]: + def _load_whitelist(self) -> set[str]: whitelist = set() wl_filename = self.config["whitelist"].get() if wl_filename in (True, ""): # Indicates the default whitelist. @@ -122,14 +122,14 @@ class LastGenrePlugin(plugins.BeetsPlugin): if wl_filename: wl_filename = normpath(wl_filename) with open(wl_filename, "rb") as f: - for line in f: - line = line.decode("utf-8").strip().lower() + for raw_line in f: + line = raw_line.decode("utf-8").strip().lower() if line and not line.startswith("#"): whitelist.add(line) return whitelist def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: - c14n_branches = [] + c14n_branches: list[list[str]] = [] c14n_filename = self.config["canonical"].get() canonicalize = c14n_filename is not False # Default tree From 5ff88b46cf9e428dfc56f01817501516ba6d2f9c Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 31 Aug 2025 19:24:45 +0200 Subject: [PATCH 078/301] lastgenre: Fix another mypy error in c14n load (that was only happening in CI and not by local poe check-types) --- beetsplug/lastgenre/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index aa627ca3d..be329e8b5 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -140,9 +140,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): c14n_filename = C14N_TREE # Read the tree if c14n_filename: - self._log.debug("Loading canonicalization tree {}", c14n_filename) - c14n_filename = normpath(c14n_filename) - with codecs.open(c14n_filename, "r", encoding="utf-8") as f: + self._log.debug("Loading canonicalization tree {0}", c14n_filename) + with codecs.open( + str(normpath(c14n_filename)), "r", encoding="utf-8" + ) as f: genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize From a98fa054fe97c204461b370de6ee90d03e6f4ac4 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Mon, 1 Sep 2025 00:32:27 +0200 Subject: [PATCH 079/301] lastgenre: Fix failing CI tests by using syspath when loading c14n file. --- beetsplug/lastgenre/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index be329e8b5..f9a37d874 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -32,7 +32,7 @@ import yaml from beets import config, library, plugins, ui from beets.library import Album, Item -from beets.util import normpath, plurality, unique_list +from beets.util import normpath, plurality, syspath, unique_list LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -142,7 +142,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if c14n_filename: self._log.debug("Loading canonicalization tree {0}", c14n_filename) with codecs.open( - str(normpath(c14n_filename)), "r", encoding="utf-8" + syspath(normpath(c14n_filename)), "r", encoding="utf-8" ) as f: genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], c14n_branches) From bbde63d87efe99b02128a56058c5946e60e5722c Mon Sep 17 00:00:00 2001 From: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> Date: Mon, 1 Sep 2025 07:29:25 +0200 Subject: [PATCH 080/301] lastgenre: Accept AI suggested use open() for tree file instead of codecs.open(), which most probably is a relict of beets' Python2/3 compatibility area. Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- beetsplug/lastgenre/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index f9a37d874..20a757a55 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -22,7 +22,6 @@ The scraper script used is available here: https://gist.github.com/1241307 """ -import codecs import os import traceback from typing import Union @@ -141,7 +140,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Read the tree if c14n_filename: self._log.debug("Loading canonicalization tree {0}", c14n_filename) - with codecs.open( + with open( syspath(normpath(c14n_filename)), "r", encoding="utf-8" ) as f: genres_tree = yaml.safe_load(f) From 856bde1efbf67b7170603878b91e09ced3292bd5 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Mon, 1 Sep 2025 07:44:39 +0200 Subject: [PATCH 081/301] Changelog for #5979 lastgenre move file loading --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4b4134cae..6532c5beb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -131,6 +131,8 @@ Other changes: beets/library directory. - Added a test to check that all plugins can be imported without errors. - :doc:`/guides/main`: Add instructions to install beets on Void Linux. +- :doc:`plugins/lastgenre`: Refactor loading whitelist and canonicalization + file. :bug:`5979` 2.3.1 (May 14, 2025) -------------------- From 0cdb1224b9feeb3fa61ea5ac9ae4c8d3cbad919d Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 4 Sep 2025 22:54:30 +0200 Subject: [PATCH 082/301] lastgenre: Fix c14n load log msg format --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 20a757a55..c314a69ab 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -139,7 +139,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): c14n_filename = C14N_TREE # Read the tree if c14n_filename: - self._log.debug("Loading canonicalization tree {0}", c14n_filename) + self._log.debug("Loading canonicalization tree {}", c14n_filename) with open( syspath(normpath(c14n_filename)), "r", encoding="utf-8" ) as f: From fbd90b050733a2445d3c10ac182e8704b342616c Mon Sep 17 00:00:00 2001 From: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:45:41 +0200 Subject: [PATCH 083/301] lastgenre: Use pathlib for opening tree file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instead of syspath(normpath()) Co-authored-by: Šarūnas Nejus <snejus@protonmail.com> --- beetsplug/lastgenre/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index c314a69ab..3e9186106 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -24,6 +24,7 @@ https://gist.github.com/1241307 import os import traceback +from pathlib import Path from typing import Union import pylast @@ -140,9 +141,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Read the tree if c14n_filename: self._log.debug("Loading canonicalization tree {}", c14n_filename) - with open( - syspath(normpath(c14n_filename)), "r", encoding="utf-8" - ) as f: + with Path(c14n_filename).open(encoding="utf-8") as f: genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize From c54a54682f1a55305547d03810334b9298ed2579 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 18:36:45 +0200 Subject: [PATCH 084/301] lastgenre: Use pathlib and simplify whitelist load - Read the whole file using Path().read_text() - Strip whitespace and lower() transform Drawbacks: - Normalization gets lost (normpath did that) --- beetsplug/lastgenre/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3e9186106..9e3f22445 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -32,7 +32,7 @@ import yaml from beets import config, library, plugins, ui from beets.library import Album, Item -from beets.util import normpath, plurality, syspath, unique_list +from beets.util import plurality, unique_list LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -120,12 +120,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): if wl_filename in (True, ""): # Indicates the default whitelist. wl_filename = WHITELIST if wl_filename: - wl_filename = normpath(wl_filename) - with open(wl_filename, "rb") as f: - for raw_line in f: - line = raw_line.decode("utf-8").strip().lower() - if line and not line.startswith("#"): - whitelist.add(line) + text = Path(wl_filename).read_text(encoding="utf-8") + for line in text.splitlines(): + if (line := line.strip().lower()) and not line.startswith("#"): + whitelist.add(line) + return whitelist def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: From 81d342b79fb76049d089be3ed5aa83176119165e Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 18:42:52 +0200 Subject: [PATCH 085/301] lastgenre: Simplify default tree loading --- beetsplug/lastgenre/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 9e3f22445..bf4081261 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -132,10 +132,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): c14n_filename = self.config["canonical"].get() canonicalize = c14n_filename is not False # Default tree - if c14n_filename in (True, ""): - c14n_filename = C14N_TREE - elif not canonicalize and self.config["prefer_specific"].get(): + if c14n_filename in (True, "") or ( # prefer_specific requires a tree, load default tree + not canonicalize and self.config["prefer_specific"].get() + ): c14n_filename = C14N_TREE # Read the tree if c14n_filename: From 6da72beeb092e05a8f1bcecc35c216e71e499797 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 18:58:42 +0200 Subject: [PATCH 086/301] lastgenre: Add expanduser to whitelist/tree load --- beetsplug/lastgenre/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bf4081261..343c50888 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -120,7 +120,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if wl_filename in (True, ""): # Indicates the default whitelist. wl_filename = WHITELIST if wl_filename: - text = Path(wl_filename).read_text(encoding="utf-8") + text = Path(wl_filename).expanduser().read_text(encoding="utf-8") for line in text.splitlines(): if (line := line.strip().lower()) and not line.startswith("#"): whitelist.add(line) @@ -140,7 +140,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Read the tree if c14n_filename: self._log.debug("Loading canonicalization tree {}", c14n_filename) - with Path(c14n_filename).open(encoding="utf-8") as f: + with Path(c14n_filename).expanduser().open(encoding="utf-8") as f: genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize From 6bc30eaf185d6f04b0942c34a874da9cc7f5784d Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 19:41:43 +0200 Subject: [PATCH 087/301] lastgenre: Add docstrings to file load methods --- beetsplug/lastgenre/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 343c50888..53371e114 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -115,6 +115,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): self.c14n_branches, self.canonicalize = self._load_c14n_tree() def _load_whitelist(self) -> set[str]: + """Load the whitelist from a text file. + + Default whitelist is used if config is True or empty string. + """ whitelist = set() wl_filename = self.config["whitelist"].get() if wl_filename in (True, ""): # Indicates the default whitelist. @@ -128,6 +132,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): return whitelist def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: + """Load the canonicalization tree from a YAML file. + + Default tree is used if config is True or empty string, or if + prefer_specific is enabled. + """ c14n_branches: list[list[str]] = [] c14n_filename = self.config["canonical"].get() canonicalize = c14n_filename is not False From 6601cbf8c02e1541bed950e7e0c0ea43553b4746 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 22:43:22 +0200 Subject: [PATCH 088/301] lastgenre: canonical/whitelist setting None load default files --- beetsplug/lastgenre/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 53371e114..f8619ec97 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -121,7 +121,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): """ whitelist = set() wl_filename = self.config["whitelist"].get() - if wl_filename in (True, ""): # Indicates the default whitelist. + if wl_filename in (True, "", None): # Indicates the default whitelist. wl_filename = WHITELIST if wl_filename: text = Path(wl_filename).expanduser().read_text(encoding="utf-8") @@ -141,7 +141,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): c14n_filename = self.config["canonical"].get() canonicalize = c14n_filename is not False # Default tree - if c14n_filename in (True, "") or ( + if c14n_filename in (True, "", None) or ( # prefer_specific requires a tree, load default tree not canonicalize and self.config["prefer_specific"].get() ): From d2caed3971772dd79a8996e8d1febc3425bd0302 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 22:50:05 +0200 Subject: [PATCH 089/301] lastgenre: Also log which whitelist file is loading --- beetsplug/lastgenre/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index f8619ec97..cd385677f 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -124,6 +124,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if wl_filename in (True, "", None): # Indicates the default whitelist. wl_filename = WHITELIST if wl_filename: + self._log.debug("Loading whitelist {}", wl_filename) text = Path(wl_filename).expanduser().read_text(encoding="utf-8") for line in text.splitlines(): if (line := line.strip().lower()) and not line.startswith("#"): From 7a5cfa8f466bcb2af23f55a61f79b440378b3ec6 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 7 Sep 2025 22:59:08 +0200 Subject: [PATCH 090/301] lastgerne: Update wl/tree load methods docstrings --- beetsplug/lastgenre/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index cd385677f..8c09eefea 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -117,7 +117,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): def _load_whitelist(self) -> set[str]: """Load the whitelist from a text file. - Default whitelist is used if config is True or empty string. + Default whitelist is used if config is True, empty string or set to "nothing". """ whitelist = set() wl_filename = self.config["whitelist"].get() @@ -135,8 +135,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: """Load the canonicalization tree from a YAML file. - Default tree is used if config is True or empty string, or if - prefer_specific is enabled. + Default tree is used if config is True, empty string, set to "nothing" + or if prefer_specific is enabled. """ c14n_branches: list[list[str]] = [] c14n_filename = self.config["canonical"].get() From 4949f44e82c127990cba3ba8589fcb00b014e0f1 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 30 Aug 2025 10:10:55 +0200 Subject: [PATCH 091/301] lastgenre: tree/whitlist: Jojo's essential fixes Fixes to the beets default tree and whitlist files I collected over the years; Includes Tags last.fm returns quite often; Also the chart.getTopTags API endpoint was checked to make sure the top 100 charts are included in beets default tree and whitelist. --- beetsplug/lastgenre/genres-tree.yaml | 41 ++++++++++++++++++++++++---- beetsplug/lastgenre/genres.txt | 7 ++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml index c8ae42478..87f7c73c3 100644 --- a/beetsplug/lastgenre/genres-tree.yaml +++ b/beetsplug/lastgenre/genres-tree.yaml @@ -9,6 +9,7 @@ - cape jazz - chimurenga - coupé-décalé + - egyptian - fuji music - genge - highlife @@ -35,6 +36,7 @@ - sega - seggae - semba + - shangaan electro - soukous - taarab - zouglou @@ -133,6 +135,7 @@ - chutney - chutney soca - compas + - folklore argentino - mambo - merengue - méringue @@ -185,6 +188,7 @@ - humor - parody music - stand-up + - kabarett - country: - alternative country: - cowpunk @@ -287,12 +291,16 @@ - jump-up - liquid funk - neurofunk - - oldschool jungle: + - jungle: - darkside jungle - ragga jungle + - oldschool jungle + - uk hardcore - raggacore - sambass - techstep + - leftfield + - halftime - electro: - crunk - electro backbeat @@ -336,6 +344,7 @@ - skweee - sound art - synthcore + - experimental - eurodance: - bubblegum dance - italo dance @@ -354,7 +363,6 @@ - makina - speedcore - terrorcore - - uk hardcore - hi-nrg: - eurobeat - hard nrg @@ -400,6 +408,8 @@ - power electronics - power noise - witch house + - juke: + - footwork - post-disco: - boogie - dance-pop @@ -414,6 +424,7 @@ - techno: - acid techno - detroit techno + - dub techno - free tekno - ghettotech - minimal @@ -481,6 +492,7 @@ - freestyle rap - g-funk - gangsta rap + - glitch hop - golden age hip hop - hip hop soul - hip pop @@ -521,11 +533,14 @@ - west coast hip hop: - chicano rap - jerkin' + - austrian hip hop + - german hip hop - jazz: - asian american jazz - avant-garde jazz - bebop - boogie-woogie + - brass band - british dance band - chamber jazz - continental jazz @@ -568,14 +583,13 @@ - vocal jazz - west coast gypsy jazz - west coast jazz -- other: - - worldbeat +- kids music: + - kinderlieder - pop: - adult contemporary - arab pop - baroque pop - bubblegum pop - - chanson - christian pop - classical crossover - europop: @@ -640,6 +654,7 @@ - beat music - chinese rock - christian rock + - classic rock - dark cabaret - desert rock - experimental rock @@ -720,6 +735,7 @@ - art punk - christian punk - deathrock + - deutschpunk - folk punk: - celtic punk - gypsy punk @@ -762,5 +778,18 @@ - dancehall - ska: - 2 tone - - dub - rocksteady + - dub +- soundtrack: +- singer-songwriter: + - cantautorato + - cantautor + - cantautora + - chanson + - canción de autor + - nueva canción +- world: + - world dub + - world fusion + - worldbeat + diff --git a/beetsplug/lastgenre/genres.txt b/beetsplug/lastgenre/genres.txt index 28b1225c3..d3d36db48 100644 --- a/beetsplug/lastgenre/genres.txt +++ b/beetsplug/lastgenre/genres.txt @@ -160,10 +160,14 @@ calypso jazz calypso-style baila campursari canatronic +canción de autor candombe canon canrock cantata +cantautorato +cantautor +cantautora cante chico cante jondo canterbury scene @@ -371,6 +375,7 @@ desert rock desi detroit blues detroit techno +dub techno dhamar dhimotiká dhrupad @@ -1069,10 +1074,10 @@ nortec norteño northern soul nota -nu breaks nu jazz nu metal nu soul +nu skool breaks nueva canción nyatiti néo kýma From 9f442dcf7573c767bb5453f0ec699fa1c87afcbe Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 30 Aug 2025 10:41:10 +0200 Subject: [PATCH 092/301] lastgenre: tree/whitelist: Contributor feedback that came up in the pull request comments and other discussions. --- beetsplug/lastgenre/genres-tree.yaml | 1 - beetsplug/lastgenre/genres.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml index 87f7c73c3..c8020f650 100644 --- a/beetsplug/lastgenre/genres-tree.yaml +++ b/beetsplug/lastgenre/genres-tree.yaml @@ -480,7 +480,6 @@ - chap hop - christian hip hop - conscious hip hop - - country-rap - crunkcore - cumbia rap - east coast hip hop: diff --git a/beetsplug/lastgenre/genres.txt b/beetsplug/lastgenre/genres.txt index d3d36db48..571b6f350 100644 --- a/beetsplug/lastgenre/genres.txt +++ b/beetsplug/lastgenre/genres.txt @@ -689,7 +689,7 @@ indo rock indonesian pop indoyíftika industrial death metal -industrial hip-hop +industrial hip hop industrial metal industrial music industrial musical From a98ba061e893805501ddbdcbaf765f2e9b4f80b3 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Fri, 5 Sep 2025 15:25:16 +0200 Subject: [PATCH 093/301] lastgenre: tree/whitelist: UK/Breakbeat Hardcore Also changes decided during PR discussions and more research. --- beetsplug/lastgenre/genres-tree.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml index c8020f650..6a359b148 100644 --- a/beetsplug/lastgenre/genres-tree.yaml +++ b/beetsplug/lastgenre/genres-tree.yaml @@ -254,7 +254,6 @@ - acid breaks - baltimore club - big beat - - breakbeat hardcore - broken beat - florida breaks - nu skool breaks @@ -295,7 +294,6 @@ - darkside jungle - ragga jungle - oldschool jungle - - uk hardcore - raggacore - sambass - techstep @@ -352,6 +350,7 @@ - hardcore: - bouncy house - bouncy techno + - breakbeat hardcore - breakcore - digital hardcore - doomcore @@ -363,6 +362,7 @@ - makina - speedcore - terrorcore + - uk hardcore - hi-nrg: - eurobeat - hard nrg From e59521e3755f7fa7e2b09b3af05727f2de2341cc Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 13 Sep 2025 07:48:35 +0200 Subject: [PATCH 094/301] lastgenre: tree/whitelist: Remove experimental experimental, even though a tag last.fm very often returns (in top 20 tag charts!), it is too broad of a term to be pinned downed with any particular genre, thus can't really be used for canonicalization. --- beetsplug/lastgenre/genres-tree.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml index 6a359b148..d7acfbc1f 100644 --- a/beetsplug/lastgenre/genres-tree.yaml +++ b/beetsplug/lastgenre/genres-tree.yaml @@ -342,7 +342,6 @@ - skweee - sound art - synthcore - - experimental - eurodance: - bubblegum dance - italo dance From d24a85121b805a13b11ee8b9a2c2c4c0baf49485 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 13 Sep 2025 09:28:39 +0200 Subject: [PATCH 095/301] Changelog for #5977 lastgenre whitelist/tree --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6532c5beb..794013806 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -133,6 +133,8 @@ Other changes: - :doc:`/guides/main`: Add instructions to install beets on Void Linux. - :doc:`plugins/lastgenre`: Refactor loading whitelist and canonicalization file. :bug:`5979` +- :doc:`plugins/lastgenre`: Updated and streamlined the genre whitelist and + canonicalization tree :bug:`5977` 2.3.1 (May 14, 2025) -------------------- From e7c12988bca74367de4c16338425738dedab0985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 29 May 2025 10:56:28 +0100 Subject: [PATCH 096/301] Remove unused colors --- beets/config_default.yaml | 7 ------- beets/ui/__init__.py | 7 ------- docs/reference/config.rst | 7 ------- 3 files changed, 21 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index d1329f494..4367abb19 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -127,19 +127,12 @@ ui: action_default: ['bold', 'cyan'] action: ['bold', 'cyan'] # New Colors - text: ['normal'] text_faint: ['faint'] import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] - added: ['green'] - removed: ['red'] changed: ['yellow'] - added_highlight: ['bold', 'green'] - removed_highlight: ['bold', 'red'] - changed_highlight: ['bold', 'yellow'] text_diff_added: ['bold', 'red'] text_diff_removed: ['bold', 'red'] - text_diff_changed: ['bold', 'red'] action_description: ['white'] import: indentation: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 9f0ae82e1..79e5f1b20 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -507,20 +507,13 @@ COLOR_NAMES = [ "action_default", "action", # New Colors - "text", "text_faint", "import_path", "import_path_items", "action_description", - "added", - "removed", "changed", - "added_highlight", - "removed_highlight", - "changed_highlight", "text_diff_added", "text_diff_removed", - "text_diff_changed", ] COLORS: dict[str, list[str]] | None = None diff --git a/docs/reference/config.rst b/docs/reference/config.rst index d4f5b3674..4ed3bc9dd 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -465,19 +465,12 @@ your configuration file that looks like this: action_default: ['bold', 'cyan'] action: ['bold', 'cyan'] # New colors after UI overhaul - text: ['normal'] text_faint: ['faint'] import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] - added: ['green'] - removed: ['red'] changed: ['yellow'] - added_highlight: ['bold', 'green'] - removed_highlight: ['bold', 'red'] - changed_highlight: ['bold', 'yellow'] text_diff_added: ['bold', 'red'] text_diff_removed: ['bold', 'red'] - text_diff_changed: ['bold', 'red'] action_description: ['white'] Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue, From 0818505334e8aa9dbfdcc52bb40ec1c40e94ed70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Fri, 11 Jul 2025 20:36:31 +0100 Subject: [PATCH 097/301] Fix diff coloring for added and removed text in field diffs - Update default `text_diff_added` value: red -> green - Use `text_diff_removed` and `text_diff_added` instead of `text_error` in UI --- beets/config_default.yaml | 2 +- beets/ui/__init__.py | 4 ++-- docs/changelog.rst | 4 ++++ docs/reference/config.rst | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 4367abb19..0a80f77f2 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -131,7 +131,7 @@ ui: import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] changed: ['yellow'] - text_diff_added: ['bold', 'red'] + text_diff_added: ['bold', 'green'] text_diff_removed: ['bold', 'red'] action_description: ['white'] import: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 79e5f1b20..5735b0ba0 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1103,8 +1103,8 @@ def _field_diff(field, old, old_fmt, new, new_fmt): if isinstance(oldval, str): oldstr, newstr = colordiff(oldval, newstr) else: - oldstr = colorize("text_error", oldstr) - newstr = colorize("text_error", newstr) + oldstr = colorize("text_diff_removed", oldstr) + newstr = colorize("text_diff_added", newstr) return f"{oldstr} -> {newstr}" diff --git a/docs/changelog.rst b/docs/changelog.rst index 794013806..b9ac55b01 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -135,6 +135,10 @@ Other changes: file. :bug:`5979` - :doc:`plugins/lastgenre`: Updated and streamlined the genre whitelist and canonicalization tree :bug:`5977` +- UI: Update default ``text_diff_added`` color from **bold red** to **bold + green.** +- UI: Use ``text_diff_added`` and ``text_diff_removed`` colors in **all** diff + comparisons. 2.3.1 (May 14, 2025) -------------------- diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 4ed3bc9dd..37ff2f8fa 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -469,7 +469,7 @@ your configuration file that looks like this: import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] changed: ['yellow'] - text_diff_added: ['bold', 'red'] + text_diff_added: ['bold', 'green'] text_diff_removed: ['bold', 'red'] action_description: ['white'] From 04380676e1874cb822cba325708765aff9a5df3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 19 Aug 2025 21:42:47 +0100 Subject: [PATCH 098/301] Slightly simplify colors setup This replaces the funky color setup based on a global `COLORS` variable with a cached function `get_color_config`. --- beets/ui/__init__.py | 91 ++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 53 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 5735b0ba0..293571ce2 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -30,7 +30,9 @@ import textwrap import traceback import warnings from difflib import SequenceMatcher -from typing import Any, Callable +from functools import cache +from itertools import chain +from typing import Any, Callable, Literal import confuse @@ -463,7 +465,7 @@ LEGACY_COLORS = { "white": ["bold", "white"], } # All ANSI Colors. -ANSI_CODES = { +CODE_BY_COLOR = { # Styles. "normal": 0, "bold": 1, @@ -496,9 +498,7 @@ ANSI_CODES = { } RESET_COLOR = f"{COLOR_ESCAPE}39;49;00m" -# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS -# as they are defined in the configuration files, see function: colorize -COLOR_NAMES = [ +ColorName = Literal[ "text_success", "text_warning", "text_error", @@ -515,61 +515,46 @@ COLOR_NAMES = [ "text_diff_added", "text_diff_removed", ] -COLORS: dict[str, list[str]] | None = None -def _colorize(color, text): - """Returns a string that prints the given text in the given color - in a terminal that is ANSI color-aware. The color must be a list of strings - from ANSI_CODES. +@cache +def get_color_config() -> dict[ColorName, str]: + """Parse and validate color configuration, converting names to ANSI codes. + + Processes the UI color configuration, handling both new list format and + legacy single-color format. Validates all color names against known codes + and raises an error for any invalid entries. """ - # Construct escape sequence to be put before the text by iterating - # over all "ANSI codes" in `color`. - escape = "" - for code in color: - escape = f"{escape}{COLOR_ESCAPE}{ANSI_CODES[code]}m" - return f"{escape}{text}{RESET_COLOR}" + colors_by_color_name: dict[ColorName, list[str]] = { + k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) + for k, v in config["ui"]["colors"].flatten().items() + } + + if invalid_colors := ( + set(chain.from_iterable(colors_by_color_name.values())) + - CODE_BY_COLOR.keys() + ): + raise UserError( + f"Invalid color(s) in configuration: {', '.join(invalid_colors)}" + ) + + return { + n: ";".join(str(CODE_BY_COLOR[c]) for c in colors) + for n, colors in colors_by_color_name.items() + } -def colorize(color_name, text): - """Colorize text if colored output is enabled. (Like _colorize but - conditional.) +def colorize(color_name: ColorName, text: str) -> str: + """Apply ANSI color formatting to text based on configuration settings. + + Returns colored text when color output is enabled and NO_COLOR environment + variable is not set, otherwise returns plain text unchanged. """ if config["ui"]["color"] and "NO_COLOR" not in os.environ: - global COLORS - if not COLORS: - # Read all color configurations and set global variable COLORS. - COLORS = dict() - for name in COLOR_NAMES: - # Convert legacy color definitions (strings) into the new - # list-based color definitions. Do this by trying to read the - # color definition from the configuration as unicode - if this - # is successful, the color definition is a legacy definition - # and has to be converted. - try: - color_def = config["ui"]["colors"][name].get(str) - except (confuse.ConfigTypeError, NameError): - # Normal color definition (type: list of unicode). - color_def = config["ui"]["colors"][name].get(list) - else: - # Legacy color definition (type: unicode). Convert. - if color_def in LEGACY_COLORS: - color_def = LEGACY_COLORS[color_def] - else: - raise UserError("no such color %s", color_def) - for code in color_def: - if code not in ANSI_CODES.keys(): - raise ValueError("no such ANSI code %s", code) - COLORS[name] = color_def - # In case a 3rd party plugin is still passing the actual color ('red') - # instead of the abstract color name ('text_error') - color = COLORS.get(color_name) - if not color: - log.debug("Invalid color_name: {}", color_name) - color = color_name - return _colorize(color, text) - else: - return text + color_code = get_color_config()[color_name] + return f"{COLOR_ESCAPE}{color_code}m{text}{RESET_COLOR}" + + return text def uncolorize(colored_text): From f8c2008f294ff0301daeb44313d70f89f4feea87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 20 Aug 2025 11:14:04 +0100 Subject: [PATCH 099/301] Dedupe "changed" colorize calls --- beets/ui/commands.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 911a5cfd3..a6fbb3500 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -21,6 +21,7 @@ import re import textwrap from collections import Counter from collections.abc import Sequence +from functools import cached_property from itertools import chain from platform import python_version from typing import Any, NamedTuple @@ -303,6 +304,10 @@ class ChangeRepresentation: TrackMatch object, accordingly. """ + @cached_property + def changed_prefix(self) -> str: + return ui.colorize("changed", "\u2260") + cur_artist = None # cur_album set if album, cur_title set if singleton cur_album = None @@ -394,7 +399,6 @@ class ChangeRepresentation: """Print out the details of the match, including changes in album name and artist name. """ - changed_prefix = ui.colorize("changed", "\u2260") # Artist. artist_l, artist_r = self.cur_artist or "", self.match.info.artist if artist_r == VARIOUS_ARTISTS: @@ -402,9 +406,8 @@ class ChangeRepresentation: artist_l, artist_r = "", "" if artist_l != artist_r: artist_l, artist_r = ui.colordiff(artist_l, artist_r) - # Prefix with U+2260: Not Equal To left = { - "prefix": f"{changed_prefix} Artist: ", + "prefix": f"{self.changed_prefix} Artist: ", "contents": artist_l, "suffix": "", } @@ -422,9 +425,8 @@ class ChangeRepresentation: and self.match.info.album != VARIOUS_ARTISTS ): album_l, album_r = ui.colordiff(album_l, album_r) - # Prefix with U+2260: Not Equal To left = { - "prefix": f"{changed_prefix} Album: ", + "prefix": f"{self.changed_prefix} Album: ", "contents": album_l, "suffix": "", } @@ -437,9 +439,8 @@ class ChangeRepresentation: title_l, title_r = self.cur_title or "", self.match.info.title if self.cur_title != self.match.info.title: title_l, title_r = ui.colordiff(title_l, title_r) - # Prefix with U+2260: Not Equal To left = { - "prefix": f"{changed_prefix} Title: ", + "prefix": f"{self.changed_prefix} Title: ", "contents": title_l, "suffix": "", } @@ -568,9 +569,8 @@ class ChangeRepresentation: # the case, thus the 'info' dictionary is unneeded. # penalties = penalty_string(self.match.distance.tracks[track_info]) - prefix = ui.colorize("changed", "\u2260 ") if changed else "* " lhs = { - "prefix": f"{prefix}{lhs_track} ", + "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", "contents": lhs_title, "suffix": f" {lhs_length}", } From f816f894d3790719a1013711329c6c8d07b3301a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 20 Aug 2025 11:28:39 +0100 Subject: [PATCH 100/301] Use default red/green for case differences --- beets/ui/__init__.py | 38 ++++++++++++-------------------------- docs/changelog.rst | 2 +- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 293571ce2..be8d29e87 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -620,7 +620,7 @@ def color_len(colored_text): return len(uncolorize(colored_text)) -def _colordiff(a, b): +def _colordiff(a: Any, b: Any) -> tuple[str, str]: """Given two values, return the same pair of strings except with their differences highlighted in the specified color. Strings are highlighted intelligently to show differences; other values are @@ -642,35 +642,21 @@ def _colordiff(a, b): colorize("text_diff_added", str(b)), ) - a_out = [] - b_out = [] + before = "" + after = "" matcher = SequenceMatcher(lambda x: False, a, b) for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): - if op == "equal": - # In both strings. - a_out.append(a[a_start:a_end]) - b_out.append(b[b_start:b_end]) - elif op == "insert": - # Right only. - b_out.append(colorize("text_diff_added", b[b_start:b_end])) - elif op == "delete": - # Left only. - a_out.append(colorize("text_diff_removed", a[a_start:a_end])) - elif op == "replace": - # Right and left differ. Colorise with second highlight if - # it's just a case change. - if a[a_start:a_end].lower() != b[b_start:b_end].lower(): - a_color = "text_diff_removed" - b_color = "text_diff_added" - else: - a_color = b_color = "text_highlight_minor" - a_out.append(colorize(a_color, a[a_start:a_end])) - b_out.append(colorize(b_color, b[b_start:b_end])) - else: - assert False + before_part, after_part = a[a_start:a_end], b[b_start:b_end] + if op in {"delete", "replace"}: + before_part = colorize("text_diff_removed", before_part) + if op in {"insert", "replace"}: + after_part = colorize("text_diff_added", after_part) - return "".join(a_out), "".join(b_out) + before += before_part + after += after_part + + return before, after def colordiff(a, b): diff --git a/docs/changelog.rst b/docs/changelog.rst index b9ac55b01..71fd657b7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -138,7 +138,7 @@ Other changes: - UI: Update default ``text_diff_added`` color from **bold red** to **bold green.** - UI: Use ``text_diff_added`` and ``text_diff_removed`` colors in **all** diff - comparisons. + comparisons, including case differences. 2.3.1 (May 14, 2025) -------------------- From 841c49d494b60c59c79635b1012e4f17458a6e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 20 Aug 2025 13:09:13 +0100 Subject: [PATCH 101/301] Update ui configuration docs --- docs/reference/config.rst | 53 ++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 37ff2f8fa..bc823ded4 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -429,20 +429,11 @@ UI Options The options that allow for customization of the visual appearance of the console interface. -These options are available in this section: - color ~~~~~ -Either ``yes`` or ``no``; whether to use color in console output (currently only -in the ``import`` command). Turn this off if your terminal doesn't support ANSI -colors. - -.. note:: - - The ``color`` option was previously a top-level configuration. This is still - respected, but a deprecation message will be shown until your top-level - ``color`` configuration has been nested under ``ui``. +Either ``yes`` or ``no``; whether to use color in console output. Turn this off +if your terminal doesn't support ANSI colors. .. _colors: @@ -450,10 +441,9 @@ colors ~~~~~~ The colors that are used throughout the user interface. These are only used if -the ``color`` option is set to ``yes``. For example, you might have a section in -your configuration file that looks like this: +the ``color`` option is set to ``yes``. See the default configuration: -:: +.. code-block:: yaml ui: colors: @@ -473,13 +463,18 @@ your configuration file that looks like this: text_diff_removed: ['bold', 'red'] action_description: ['white'] -Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue, -purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green, yellow, -blue, fuchsia (magenta), turquoise (cyan), white +Available attributes: -Legacy UI colors config directive used strings. If any colors value is still a -string instead of a list, it will be translated to list automatically. For -example ``blue`` will become ``['blue']``. +Foreground colors + ``black``, ``red``, ``green``, ``yellow``, ``blue``, ``magenta``, ``cyan``, + ``white`` + +Background colors + ``bg_black``, ``bg_red``, ``bg_green``, ``bg_yellow``, ``bg_blue``, + ``bg_magenta``, ``bg_cyan``, ``bg_white`` + +Text styles + ``normal``, ``bold``, ``faint``, ``underline``, ``reverse`` terminal_width ~~~~~~~~~~~~~~ @@ -488,7 +483,7 @@ Controls line wrapping on non-Unix systems. On Unix systems, the width of the terminal is detected automatically. If this fails, or on non-Unix systems, the specified value is used as a fallback. Defaults to ``80`` characters: -:: +.. code-block:: yaml ui: terminal_width: 80 @@ -504,7 +499,7 @@ different track lengths are colored with ``text_highlight_minor``. matching or distance score calculation (see :ref:`match-config`, ``distance_weights`` and :ref:`colors`): -:: +.. code-block:: yaml ui: length_diff_thresh: 10.0 @@ -516,18 +511,18 @@ When importing, beets will read several options to configure the visuals of the import dialogue. There are two layouts controlling how horizontal space and line wrapping is dealt with: ``column`` and ``newline``. The indentation of the respective elements of the import UI can also be configured. For example setting -``4`` for ``match_header`` will indent the very first block of a proposed match -by five characters in the terminal: +``2`` for ``match_header`` will indent the very first block of a proposed match +by two characters in the terminal: -:: +.. code-block:: yaml ui: import: indentation: - match_header: 4 - match_details: 4 - match_tracklist: 7 - layout: newline + match_header: 2 + match_details: 2 + match_tracklist: 5 + layout: column Importer Options ---------------- From 30093c517e11d2a352e1f26e6c5d4cc3be582920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 20 Aug 2025 15:10:20 +0100 Subject: [PATCH 102/301] Define color regex patterns once --- beets/ui/__init__.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index be8d29e87..e0c1bb486 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -440,7 +440,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): # ANSI terminal colorization code heavily inspired by pygments: # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) -COLOR_ESCAPE = "\x1b[" +COLOR_ESCAPE = "\x1b" LEGACY_COLORS = { "black": ["black"], "darkred": ["red"], @@ -496,8 +496,16 @@ CODE_BY_COLOR = { "bg_cyan": 46, "bg_white": 47, } -RESET_COLOR = f"{COLOR_ESCAPE}39;49;00m" - +RESET_COLOR = f"{COLOR_ESCAPE}[39;49;00m" +# Precompile common ANSI-escape regex patterns +ANSI_CODE_REGEX = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)") +ESC_TEXT_REGEX = re.compile( + rf"""(?P<pretext>[^{COLOR_ESCAPE}]*) + (?P<esc>(?:{ANSI_CODE_REGEX.pattern})+) + (?P<text>[^{COLOR_ESCAPE}]+)(?P<reset>{re.escape(RESET_COLOR)}) + (?P<posttext>[^{COLOR_ESCAPE}]*)""", + re.VERBOSE, +) ColorName = Literal[ "text_success", "text_warning", @@ -552,7 +560,7 @@ def colorize(color_name: ColorName, text: str) -> str: """ if config["ui"]["color"] and "NO_COLOR" not in os.environ: color_code = get_color_config()[color_name] - return f"{COLOR_ESCAPE}{color_code}m{text}{RESET_COLOR}" + return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" return text @@ -567,26 +575,22 @@ def uncolorize(colored_text): # [;\d]* - matches a sequence consisting of one or more digits or # semicola # [A-Za-z] - matches a letter - ansi_code_regex = re.compile(r"\x1b\[[;\d]*[A-Za-z]", re.VERBOSE) - # Strip ANSI codes from `colored_text` using the regular expression. - text = ansi_code_regex.sub("", colored_text) - return text + return ANSI_CODE_REGEX.sub("", colored_text) def color_split(colored_text, index): - ansi_code_regex = re.compile(r"(\x1b\[[;\d]*[A-Za-z])", re.VERBOSE) length = 0 pre_split = "" post_split = "" found_color_code = None found_split = False - for part in ansi_code_regex.split(colored_text): + for part in ANSI_CODE_REGEX.split(colored_text): # Count how many real letters we have passed length += color_len(part) if found_split: post_split += part else: - if ansi_code_regex.match(part): + if ANSI_CODE_REGEX.match(part): # This is a color code if part == RESET_COLOR: found_color_code = None @@ -729,19 +733,13 @@ def split_into_lines(string, width_tuple): """ first_width, middle_width, last_width = width_tuple words = [] - esc_text = re.compile( - r"""(?P<pretext>[^\x1b]*) - (?P<esc>(?:\x1b\[[;\d]*[A-Za-z])+) - (?P<text>[^\x1b]+)(?P<reset>\x1b\[39;49;00m) - (?P<posttext>[^\x1b]*)""", - re.VERBOSE, - ) + if uncolorize(string) == string: # No colors in string words = string.split() else: # Use a regex to find escapes and the text within them. - for m in esc_text.finditer(string): + for m in ESC_TEXT_REGEX.finditer(string): # m contains four groups: # pretext - any text before escape sequence # esc - intitial escape sequence From e837598e796dd4dcf3a388fec90fe06d58e453b2 Mon Sep 17 00:00:00 2001 From: JOJ0 <JOJ0@users.noreply.github.com> Date: Sat, 13 Sep 2025 16:47:21 +0000 Subject: [PATCH 103/301] Increment version to 2.4.0 --- beets/__init__.py | 2 +- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 4 ++-- pyproject.toml | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index c5b93230f..10b0f58b0 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,7 +17,7 @@ from sys import stderr import confuse -__version__ = "2.3.1" +__version__ = "2.4.0" __author__ = "Adrian Sampson <adrian@radbox.org>" diff --git a/docs/changelog.rst b/docs/changelog.rst index 71fd657b7..9a514f9f0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,17 @@ Unreleased New features: +Bug fixes: + +For packagers: + +Other changes: + +2.4.0 (September 13, 2025) +-------------------------- + +New features: + - :doc:`plugins/musicbrainz`: The MusicBrainz autotagger has been moved to a separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``, but if you've customized your ``plugins`` list in your configuration, you'll diff --git a/docs/conf.py b/docs/conf.py index 838d82800..7465bdb27 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,8 +13,8 @@ copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" -version = "2.3" -release = "2.3.1" +version = "2.4" +release = "2.4.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 63a22f3f1..2546360ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.3.1" +version = "2.4.0" description = "music tagger and library organizer" authors = ["Adrian Sampson <adrian@radbox.org>"] maintainers = ["Serene-Arc"] From 394d78de9716aae4cfda35fb7cdf64b0ad257417 Mon Sep 17 00:00:00 2001 From: neofright <68615872+neofright@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:02:22 +0000 Subject: [PATCH 104/301] Replace flowery language (#6002) Simplify wording by replacing flowery or pretentious terms throughout the project. A bouquet refers to trees or flowers. The etymology is pretty clear on the Latin root via French. Co-authored-by: Sebastian Mohr <sebastian@mohrenclan.de> --- README.rst | 2 +- docs/guides/main.rst | 5 ++--- test/rsrc/lyrics/examplecom/beetssong.txt | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index e8cec8ce9..3d5a84712 100644 --- a/README.rst +++ b/README.rst @@ -17,7 +17,7 @@ Beets is the media library management system for obsessive music geeks. The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It -then provides a bouquet of tools for manipulating and accessing your music. +then provides a suite of tools for manipulating and accessing your music. Here's an example of beets' brainy tag corrector doing its thing: diff --git a/docs/guides/main.rst b/docs/guides/main.rst index bbb0ea858..0b502bfb1 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -203,9 +203,8 @@ beets to behave many other ways: to disable renaming and tag-writing. -There are approximately six million other configuration options you can set -here, including the directory and file naming scheme. See -:doc:`/reference/config` for a full reference. +There are other configuration options you can set here, including the directory +and file naming scheme. See :doc:`/reference/config` for a full reference. .. _yaml: https://yaml.org/ diff --git a/test/rsrc/lyrics/examplecom/beetssong.txt b/test/rsrc/lyrics/examplecom/beetssong.txt index c546dd602..436612ce0 100644 --- a/test/rsrc/lyrics/examplecom/beetssong.txt +++ b/test/rsrc/lyrics/examplecom/beetssong.txt @@ -221,7 +221,7 @@ e9.size = "120x600, 160x600"; <h2>John Doe <br> beets song lyrics</h2> <img src="images/phone-left.gif" alt="Ringtones left icon" width="16" height="17"> <a href="http://www.ringtonematcher.com/go/?sid=LBSMros&artist=The+John Doe&song=Beets+Song" target="_blank"><b><font size="+1" color="red" face="arial">Send "beets song" Ringtone to your Cell</font></b></a> <img src="images/phone-right.gif" alt="Ringtones right icon" width="16" height="17"><br><br><center>Beets is the media library management system for obsessive music geeks.<br> -The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music.<br> +The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a suite of tools for manipulating and accessing your music.<br> <div class='flow breaker'> </div> Here's an example of beets' brainy tag corrector doing its thing: Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via plugins, beets becomes a panacea</center> From c30f9603eb175924f07d666eaea3f069cc9b7096 Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:31:42 +0200 Subject: [PATCH 105/301] Fix format and lint errors --- beetsplug/fromfilename.py | 8 +-- test/plugins/test_fromfilename.py | 83 +++++++++++++++---------------- 2 files changed, 46 insertions(+), 45 deletions(-) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 42544e655..5b8bafc44 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -25,8 +25,10 @@ from beets.util import displayable_path # Filename field extraction patterns. PATTERNS = [ # Useful patterns. - (r"^(?P<track>\d+)\.?\s*-\s*(?P<artist>.+?)\s*-\s*(?P<title>.+?)" - r"(\s*-\s*(?P<tag>.*))?$"), + ( + r"^(?P<track>\d+)\.?\s*-\s*(?P<artist>.+?)\s*-\s*(?P<title>.+?)" + r"(\s*-\s*(?P<tag>.*))?$" + ), r"^(?P<artist>.+?)\s*-\s*(?P<title>.+?)(\s*-\s*(?P<tag>.*))?$", r"^(?P<track>\d+)\.?[\s\-_]+(?P<title>.+)$", r"^(?P<title>.+) by (?P<artist>.+)$", @@ -160,7 +162,7 @@ class FromFilenamePlugin(plugins.BeetsPlugin): # Look for useful information in the filenames. for pattern in PATTERNS: - self._log.debug("Trying pattern: {}".format(pattern)) + self._log.debug(f"Trying pattern: {pattern}") d = all_matches(names, pattern) if d: apply_matches(d, self._log) diff --git a/test/plugins/test_fromfilename.py b/test/plugins/test_fromfilename.py index 3dc600ced..511f63d38 100644 --- a/test/plugins/test_fromfilename.py +++ b/test/plugins/test_fromfilename.py @@ -12,27 +12,26 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Tests for the fromfilename plugin. -""" +"""Tests for the fromfilename plugin.""" import unittest from unittest.mock import Mock + from beetsplug import fromfilename class FromfilenamePluginTest(unittest.TestCase): - def setUp(self): """Create mock objects for import session and task.""" self.session = Mock() - item1config = {'path': '', 'track': 0, 'artist': '', 'title': ''} + item1config = {"path": "", "track": 0, "artist": "", "title": ""} self.item1 = Mock(**item1config) - item2config = {'path': '', 'track': 0, 'artist': '', 'title': ''} + item2config = {"path": "", "track": 0, "artist": "", "title": ""} self.item2 = Mock(**item2config) - taskconfig = {'is_album': True, 'items': [self.item1, self.item2]} + taskconfig = {"is_album": True, "items": [self.item1, self.item2]} self.task = Mock(**taskconfig) def tearDown(self): @@ -47,12 +46,12 @@ class FromfilenamePluginTest(unittest.TestCase): f = fromfilename.FromFilenamePlugin() f.filename_task(self.task, self.session) - self.assertEqual(self.task.items[0].track, 1) - self.assertEqual(self.task.items[1].track, 2) - self.assertEqual(self.task.items[0].artist, "Artist Name") - self.assertEqual(self.task.items[1].artist, "Artist Name") - self.assertEqual(self.task.items[0].title, "Song One") - self.assertEqual(self.task.items[1].title, "Song Two") + assert self.task.items[0].track == 1 + assert self.task.items[1].track == 2 + assert self.task.items[0].artist == "Artist Name" + assert self.task.items[1].artist == "Artist Name" + assert self.task.items[0].title == "Song One" + assert self.task.items[1].title == "Song Two" def test_sep_dash(self): """Test filenames that use "-" as separator.""" @@ -63,12 +62,12 @@ class FromfilenamePluginTest(unittest.TestCase): f = fromfilename.FromFilenamePlugin() f.filename_task(self.task, self.session) - self.assertEqual(self.task.items[0].track, 1) - self.assertEqual(self.task.items[1].track, 2) - self.assertEqual(self.task.items[0].artist, "Artist_Name") - self.assertEqual(self.task.items[1].artist, "Artist_Name") - self.assertEqual(self.task.items[0].title, "Song_One") - self.assertEqual(self.task.items[1].title, "Song_Two") + assert self.task.items[0].track == 1 + assert self.task.items[1].track == 2 + assert self.task.items[0].artist == "Artist_Name" + assert self.task.items[1].artist == "Artist_Name" + assert self.task.items[0].title == "Song_One" + assert self.task.items[1].title == "Song_Two" def test_track_title(self): """Test filenames including track and title.""" @@ -79,12 +78,12 @@ class FromfilenamePluginTest(unittest.TestCase): f = fromfilename.FromFilenamePlugin() f.filename_task(self.task, self.session) - self.assertEqual(self.task.items[0].track, 1) - self.assertEqual(self.task.items[1].track, 2) - self.assertEqual(self.task.items[0].artist, "") - self.assertEqual(self.task.items[1].artist, "") - self.assertEqual(self.task.items[0].title, "Song_One") - self.assertEqual(self.task.items[1].title, "Song_Two") + assert self.task.items[0].track == 1 + assert self.task.items[1].track == 2 + assert self.task.items[0].artist == "" + assert self.task.items[1].artist == "" + assert self.task.items[0].title == "Song_One" + assert self.task.items[1].title == "Song_Two" def test_title_by_artist(self): """Test filenames including title by artist.""" @@ -95,12 +94,12 @@ class FromfilenamePluginTest(unittest.TestCase): f = fromfilename.FromFilenamePlugin() f.filename_task(self.task, self.session) - self.assertEqual(self.task.items[0].track, 0) - self.assertEqual(self.task.items[1].track, 0) - self.assertEqual(self.task.items[0].artist, "The Artist") - self.assertEqual(self.task.items[1].artist, "The Artist") - self.assertEqual(self.task.items[0].title, "Song One") - self.assertEqual(self.task.items[1].title, "Song Two") + assert self.task.items[0].track == 0 + assert self.task.items[1].track == 0 + assert self.task.items[0].artist == "The Artist" + assert self.task.items[1].artist == "The Artist" + assert self.task.items[0].title == "Song One" + assert self.task.items[1].title == "Song Two" def test_track_only(self): """Test filenames including only track.""" @@ -111,12 +110,12 @@ class FromfilenamePluginTest(unittest.TestCase): f = fromfilename.FromFilenamePlugin() f.filename_task(self.task, self.session) - self.assertEqual(self.task.items[0].track, 1) - self.assertEqual(self.task.items[1].track, 2) - self.assertEqual(self.task.items[0].artist, "") - self.assertEqual(self.task.items[1].artist, "") - self.assertEqual(self.task.items[0].title, "01") - self.assertEqual(self.task.items[1].title, "02") + assert self.task.items[0].track == 1 + assert self.task.items[1].track == 2 + assert self.task.items[0].artist == "" + assert self.task.items[1].artist == "" + assert self.task.items[0].title == "01" + assert self.task.items[1].title == "02" def test_title_only(self): """Test filenames including only title.""" @@ -127,12 +126,12 @@ class FromfilenamePluginTest(unittest.TestCase): f = fromfilename.FromFilenamePlugin() f.filename_task(self.task, self.session) - self.assertEqual(self.task.items[0].track, 0) - self.assertEqual(self.task.items[1].track, 0) - self.assertEqual(self.task.items[0].artist, "") - self.assertEqual(self.task.items[1].artist, "") - self.assertEqual(self.task.items[0].title, "Song One") - self.assertEqual(self.task.items[1].title, "Song Two") + assert self.task.items[0].track == 0 + assert self.task.items[1].track == 0 + assert self.task.items[0].artist == "" + assert self.task.items[1].artist == "" + assert self.task.items[0].title == "Song One" + assert self.task.items[1].title == "Song Two" def suite(): From da08978eca23cea19948295c12ffe63f0e62ad61 Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:20:43 +0200 Subject: [PATCH 106/301] Add entry to changelog.rst --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a514f9f0..8d7f8c5a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,9 @@ New features: Bug fixes: +- :doc:`/plugins/fromfilename`: Fix :bug:`5218` and improve the code + (refactor regexps, allow for more cases, add some logging) + For packagers: Other changes: From ae9489cb928f635a20dbde5c350c56139bfae5e0 Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:48:40 +0200 Subject: [PATCH 107/301] Fix formatting to make poe format-docs happy --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8d7f8c5a9..bedcac9d7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,8 +11,8 @@ New features: Bug fixes: -- :doc:`/plugins/fromfilename`: Fix :bug:`5218` and improve the code - (refactor regexps, allow for more cases, add some logging) +- :doc:`/plugins/fromfilename`: Fix :bug:`5218` and improve the code (refactor + regexps, allow for more cases, add some logging) For packagers: From 3336b68d08429e9efd0dd74bc28053c3e6228173 Mon Sep 17 00:00:00 2001 From: henry <137741507+henry-oberholtzer@users.noreply.github.com> Date: Thu, 18 Sep 2025 02:33:55 -0700 Subject: [PATCH 108/301] Fix musicbrainz plugin documentation (#6024) Add several lines to documentation to clear up possible confusion on musicbrainz plugin being disabled when plugin list is modified. closes #6020 --- docs/changelog.rst | 7 +++++++ docs/faq.rst | 4 +++- docs/guides/tagger.rst | 10 ++++++++++ docs/plugins/index.rst | 6 +++--- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a514f9f0..4dc38da94 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,13 @@ For packagers: Other changes: +- :doc:`plugins/index`: Clarify that musicbrainz must be mentioned if plugin + list modified :bug:`6020` +- :doc:`/faq`: Add check for musicbrainz plugin if auto-tagger can't find a + match :bug:`6020` +- :doc:`guides/tagger`: Section on no matching release found, related to + possibly disabled musicbrainz plugin :bug:`6020` + 2.4.0 (September 13, 2025) -------------------------- diff --git a/docs/faq.rst b/docs/faq.rst index 718356e42..3e527e8bc 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -236,7 +236,9 @@ Why does beets… There are a number of possibilities: -- First, make sure the album is in `the MusicBrainz database +- First, make sure you have at least one autotagger extension/plugin enabled. + See :ref:`autotagger_extensions` for a list of valid plugins. +- Check that the album is in `the MusicBrainz database <https://musicbrainz.org/>`__. You can search on their site to make sure it's cataloged there. (If not, anyone can edit MusicBrainz---so consider adding the data yourself.) diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index 3ad85ec85..c07d5df58 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -289,11 +289,21 @@ MusicBrainz---so consider adding the data yourself. .. _the musicbrainz database: https://musicbrainz.org/ +If you receive a "No matching release found" message from the Auto-Tagger for an +album you know is present in MusicBrainz, check that musicbrainz is in the +plugin list. Until version v2.4.0_ the default metadata source for the +Auto-Tagger, the :doc:`musicbrainz plugin </plugins/musicbrainz>`, had to be +manually disabled. At present, if the plugin list is changed, musicbrainz needs +to be added to the plugin list in order to continue contributing results to +Auto-Tagger. + If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. .. _file a bug report: https://github.com/beetbox/beets/issues +.. _v2.4.0: https://github.com/beetbox/beets/releases/tag/v2.4.0 + I Hope That Makes Sense ----------------------- diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 960ecfbef..64874dd32 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -18,7 +18,7 @@ list), just use the ``plugins`` option in your :doc:`config.yaml .. code-block:: sh - plugins: inline convert web + plugins: musicbrainz inline convert web The value for ``plugins`` can be a space-separated list of plugin names or a YAML list like ``[foo, bar]``. You can see which plugins are currently enabled @@ -29,7 +29,7 @@ its name: .. code-block:: yaml - plugins: inline convert web + plugins: musicbrainz inline convert web convert: auto: true @@ -58,7 +58,7 @@ following to your configuration: .. code-block:: yaml - plugins: discogs + plugins: musicbrainz discogs discogs: source_weight: 0.0 From a302b6d9c23d250d2978172cc3d4b70e3eee08af Mon Sep 17 00:00:00 2001 From: Rebecca Turner <rbt@sent.as> Date: Tue, 29 Jul 2025 22:07:01 -0700 Subject: [PATCH 109/301] fromfilename: Don't crash if title is missing Prevents this crash: ``` $ beet import ~/Music/Music/_/[1405]/00.mp3 Traceback (most recent call last): File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/bin/.beet-wrapped", line 9, in <module> sys.exit(main()) ~~~~^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/ui/__init__.py", line 1859, in main _raw_main(args) ~~~~~~~~~^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/ui/__init__.py", line 1838, in _raw_main subcommand.func(lib, suboptions, subargs) ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/ui/commands.py", line 1390, in import_func import_files(lib, byte_paths, query) ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/ui/commands.py", line 1330, in import_files session.run() ~~~~~~~~~~~^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/importer/session.py", line 234, in run pl.run_parallel(QUEUE_SIZE) ~~~~~~~~~~~~~~~^^^^^^^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/util/pipeline.py", line 471, in run_parallel raise exc_info[1].with_traceback(exc_info[2]) File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/util/pipeline.py", line 336, in run out = self.coro.send(msg) File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/util/pipeline.py", line 219, in coro func(*(args + (task,))) ~~~~^^^^^^^^^^^^^^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/importer/stages.py", line 141, in lookup_candidates plugins.send("import_task_start", session=session, task=task) ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/plugins.py", line 505, in send result = handler(**arguments) File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beets/plugins.py", line 200, in wrapper return func(*args, **kwargs) File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beetsplug/fromfilename.py", line 165, in filename_task apply_matches(d, self._log) ~~~~~~~~~~~~~^^^^^^^^^^^^^^ File "/nix/store/lfv9ns20hz2bg6d44js378vcxjfm9261-beets-2.3.1/lib/python3.13/site-packages/beetsplug/fromfilename.py", line 124, in apply_matches item.title = str(d[item][title_field]) ~~~~~~~^^^^^^^^^^^^^ KeyError: 'title' ``` --- beetsplug/fromfilename.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 5e8b338c7..7f9e9b92e 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -121,7 +121,7 @@ def apply_matches(d, log): # Apply the title and track. for item in d: if bad_title(item.title): - item.title = str(d[item][title_field]) + item.title = str(d[item].get(title_field, "")) log.info("Title replaced with: {.title}", item) if "track" in d[item] and item.track == 0: diff --git a/docs/changelog.rst b/docs/changelog.rst index 4dc38da94..67c284a88 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -83,6 +83,8 @@ Bug fixes: the config option ``deezer.search_query_ascii: yes``. :bug:`5860` - Fixed regression with :doc:`/plugins/listenbrainz` where the plugin could not be loaded :bug:`5975` +- :doc:`/plugins/fromfilename`: Beets will no longer crash if a track's title + field is missing. For packagers: From c7ba399dd1057021f6e7344511705ede6955fa28 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 15 Sep 2025 23:32:04 +0200 Subject: [PATCH 110/301] fix incorrect matches when album is missing or empty closes #5189 --- beets/metadata_plugins.py | 4 +++- beetsplug/spotify.py | 8 +++++++- docs/changelog.rst | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 381881b51..6a75d0f79 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -371,7 +371,9 @@ class SearchApiMetadataSourcePlugin( album: str, va_likely: bool, ) -> Iterable[AlbumInfo]: - query_filters: SearchFilter = {"album": album} + query_filters: SearchFilter = {} + if album: + query_filters["album"] = album if not va_likely: query_filters["artist"] = artist diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index a0a5c4358..e97484bc1 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -569,7 +569,13 @@ class SpotifyPlugin( query_string = item[self.config["track_field"].get()] # Query the Web API for each track, look for the items' JSON data - query_filters: SearchFilter = {"artist": artist, "album": album} + + query_filters: SearchFilter = {} + if artist: + query_filters["artist"] = artist + if album: + query_filters["album"] = album + response_data_tracks = self._search_api( query_type="track", query_string=query_string, diff --git a/docs/changelog.rst b/docs/changelog.rst index 67c284a88..f4ac99d27 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,11 @@ New features: Bug fixes: +- :doc:`plugins/spotify` Fixed an issue where track matching and lookups could + return incorrect or misleading results when using the Spotify plugin. The + problem occurred primarily when no album was provided or when the album field + was an empty string. :bug:`5189` + For packagers: Other changes: From efbfc23931748773675dd2939aa1be4861918c85 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Wed, 17 Sep 2025 12:47:22 +0200 Subject: [PATCH 111/301] Removed config options and fixed a bug with `beet spotify command` --- beetsplug/spotify.py | 11 +++-------- docs/changelog.rst | 3 +++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index e97484bc1..0f6e0012b 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -130,9 +130,6 @@ class SpotifyPlugin( "mode": "list", "tiebreak": "popularity", "show_failures": False, - "artist_field": "albumartist", - "album_field": "album", - "track_field": "title", "region_filter": None, "regex": [], "client_id": "4e414367a1d14c75a5c5129a627fcab8", @@ -563,13 +560,11 @@ class SpotifyPlugin( regex["search"], regex["replace"], value ) - # Custom values can be passed in the config (just in case) - artist = item[self.config["artist_field"].get()] - album = item[self.config["album_field"].get()] - query_string = item[self.config["track_field"].get()] + artist = item["artist"] or item["albumartist"] + album = item["album"] + query_string = item["title"] # Query the Web API for each track, look for the items' JSON data - query_filters: SearchFilter = {} if artist: query_filters["artist"] = artist diff --git a/docs/changelog.rst b/docs/changelog.rst index f4ac99d27..8a35569c9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,9 @@ Bug fixes: return incorrect or misleading results when using the Spotify plugin. The problem occurred primarily when no album was provided or when the album field was an empty string. :bug:`5189` +- :doc:`plugins/spotify` Removed old and undocumented config options + `artist_field`, `album_field` and `track` that were causing issues with track + matching. :bug:`5189` For packagers: From b0caac871a36107ce8119e49bcfde32168edaa67 Mon Sep 17 00:00:00 2001 From: ThinkChaos <ThinkChaos@users.noreply.github.com> Date: Fri, 9 Aug 2024 18:36:11 -0400 Subject: [PATCH 112/301] fix: enable tracebacks for "user"/custom sqlite functions A bit niche but I tried setting my bareasc prefix to an empty string, and was getting an obtuse error. This should help make clearer what is happening when queries fail. The exception is not properly raised up the stack in the first place because it happens across 2 FFI boundaries: the DB query (Python -> SQLite), and the custom DB function (SQLite -> Python). Thus Python cannot forwarded it back to itself through SQLite, and it's treated as an "unraisable" exception. We could override `sys.unraisablehook` to not print anything for the original exception, and store it in a global for the outer Python interpreter to fetch and raise properly, but that's pretty hacky, limited to a single DB instance and query at once, and risks swallowing other "unraisable" exceptions. Instead we just tell the user to look above for what Python prints. Sample output: ``` Exception ignored in: <function unidecode_expect_ascii at 0x7f7fa20bb060> Traceback (most recent call last): File "site-packages/unidecode/__init__.py", line 60, in unidecode_expect_ascii bytestring = string.encode('ASCII') ^^^^^^^^^^^^^ AttributeError: 'bytes' object has no attribute 'encode' Traceback (most recent call last): File "site-packages/beets/dbcore/db.py", line 988, in query cursor = self.db._connection().execute(statement, subvals) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ sqlite3.OperationalError: user-defined function raised exception During handling of the above exception, another exception occurred: Traceback (most recent call last): File "site-packages/beets/__main__.py", line 9, in <module> sys.exit(main()) ^^^^^^ File "site-packages/beets/ui/__init__.py", line 1865, in main _raw_main(args) File "site-packages/beets/ui/__init__.py", line 1852, in _raw_main subcommand.func(lib, suboptions, subargs) File "site-packages/beets/ui/commands.py", line 1599, in list_func list_items(lib, decargs(args), opts.album) File "site-packages/beets/ui/commands.py", line 1594, in list_items for item in lib.items(query): ^^^^^^^^^^^^^^^^ File "site-packages/beets/library.py", line 1695, in items return self._fetch(Item, query, sort or self.get_default_item_sort()) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "site-packages/beets/library.py", line 1673, in _fetch return super()._fetch(model_cls, query, sort) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "site-packages/beets/dbcore/db.py", line 1301, in _fetch rows = tx.query(sql, subvals) ^^^^^^^^^^^^^^^^^^^^^^ File "site-packages/beets/dbcore/db.py", line 991, in query raise DBCustomFunctionError() beets.dbcore.db.DBCustomFunctionError: beets defined SQLite function failed; see the other errors above for details ``` --- beets/dbcore/db.py | 23 +++++++++++++++++++++++ test/test_dbcore.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 8cd89111e..b3a6c7dd8 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -64,6 +64,16 @@ class DBAccessError(Exception): """ +class DBCustomFunctionError(Exception): + """A sqlite function registered by beets failed.""" + + def __init__(self): + super().__init__( + "beets defined SQLite function failed; " + "see the other errors above for details" + ) + + class FormattedMapping(Mapping[str, str]): """A `dict`-like formatted view of a model. @@ -947,6 +957,12 @@ class Transaction: self._mutated = False self.db._db_lock.release() + if ( + isinstance(exc_value, sqlite3.OperationalError) + and exc_value.args[0] == "user-defined function raised exception" + ): + raise DBCustomFunctionError() + def query( self, statement: str, subvals: Sequence[SQLiteType] = () ) -> list[sqlite3.Row]: @@ -1007,6 +1023,13 @@ class Database: "sqlite3 must be compiled with multi-threading support" ) + # Print tracebacks for exceptions in user defined functions + # See also `self.add_functions` and `DBCustomFunctionError`. + # + # `if`: use feature detection because PyPy doesn't support this. + if hasattr(sqlite3, "enable_callback_tracebacks"): + sqlite3.enable_callback_tracebacks(True) + self.path = path self.timeout = timeout diff --git a/test/test_dbcore.py b/test/test_dbcore.py index b2ec2e968..d2c76d852 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -23,6 +23,7 @@ from tempfile import mkstemp import pytest from beets import dbcore +from beets.dbcore.db import DBCustomFunctionError from beets.library import LibModel from beets.test import _common from beets.util import cached_classproperty @@ -31,6 +32,13 @@ from beets.util import cached_classproperty # have multiple models with different numbers of fields. +@pytest.fixture +def db(model): + db = model(":memory:") + yield db + db._connection().close() + + class SortFixture(dbcore.query.FieldSort): pass @@ -784,3 +792,25 @@ class ResultsIteratorTest(unittest.TestCase): self.db._fetch(ModelFixture1, dbcore.query.FalseQuery()).get() is None ) + + +class TestException: + @pytest.mark.parametrize("model", [DatabaseFixture1]) + @pytest.mark.filterwarnings( + "ignore: .*plz_raise.*: pytest.PytestUnraisableExceptionWarning" + ) + @pytest.mark.filterwarnings( + "error: .*: pytest.PytestUnraisableExceptionWarning" + ) + def test_custom_function_error(self, db: DatabaseFixture1): + def plz_raise(): + raise Exception("i haz raized") + + db._connection().create_function("plz_raise", 0, plz_raise) + + with db.transaction() as tx: + tx.mutate("insert into test (field_one) values (1)") + + with pytest.raises(DBCustomFunctionError): + with db.transaction() as tx: + tx.query("select * from test where plz_raise()") From e7e22ebb3d2bd71b0d60fdd3ed1947fbaa6d389d Mon Sep 17 00:00:00 2001 From: ThinkChaos <ThinkChaos@users.noreply.github.com> Date: Fri, 9 Aug 2024 19:33:43 -0400 Subject: [PATCH 113/301] feat: mark SQLite custom functions as deterministic to allow caching --- beets/dbcore/db.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index b3a6c7dd8..192cfac70 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -17,15 +17,17 @@ from __future__ import annotations import contextlib +import functools import os import re import sqlite3 +import sys import threading import time from abc import ABC from collections import defaultdict from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence -from sqlite3 import Connection +from sqlite3 import Connection, sqlite_version_info from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic from typing_extensions import TypeVar # default value support @@ -1125,9 +1127,16 @@ class Database: return bytestring - conn.create_function("regexp", 2, regexp) - conn.create_function("unidecode", 1, unidecode) - conn.create_function("bytelower", 1, bytelower) + create_function = conn.create_function + if sys.version_info >= (3, 8) and sqlite_version_info >= (3, 8, 3): + # Let sqlite make extra optimizations + create_function = functools.partial( + conn.create_function, deterministic=True + ) + + create_function("regexp", 2, regexp) + create_function("unidecode", 1, unidecode) + create_function("bytelower", 1, bytelower) def _close(self): """Close the all connections to the underlying SQLite database From eb83058b13a1ddb8d78f9794acaacce0eb2f1f38 Mon Sep 17 00:00:00 2001 From: ThinkChaos <ThinkChaos@users.noreply.github.com> Date: Sat, 9 Aug 2025 09:38:12 -0400 Subject: [PATCH 114/301] style: remove extraneous `pass` statements --- test/test_dbcore.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/test_dbcore.py b/test/test_dbcore.py index d2c76d852..653adf298 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -89,7 +89,6 @@ class ModelFixture1(LibModel): class DatabaseFixture1(dbcore.Database): _models = (ModelFixture1,) - pass class ModelFixture2(ModelFixture1): @@ -102,7 +101,6 @@ class ModelFixture2(ModelFixture1): class DatabaseFixture2(dbcore.Database): _models = (ModelFixture2,) - pass class ModelFixture3(ModelFixture1): @@ -116,7 +114,6 @@ class ModelFixture3(ModelFixture1): class DatabaseFixture3(dbcore.Database): _models = (ModelFixture3,) - pass class ModelFixture4(ModelFixture1): @@ -131,7 +128,6 @@ class ModelFixture4(ModelFixture1): class DatabaseFixture4(dbcore.Database): _models = (ModelFixture4,) - pass class AnotherModelFixture(ModelFixture1): @@ -153,12 +149,10 @@ class ModelFixture5(ModelFixture1): class DatabaseFixture5(dbcore.Database): _models = (ModelFixture5,) - pass class DatabaseFixtureTwoModels(dbcore.Database): _models = (ModelFixture2, AnotherModelFixture) - pass class ModelFixtureWithGetters(dbcore.Model): From 23e46315e3a957f2c9ca969535e9455780045ccc Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer <henryoberholtzer@gmail.com> Date: Sat, 20 Sep 2025 01:52:53 +0200 Subject: [PATCH 115/301] Remove Discogs Disambiguation stripping from metadata_plugins --- beets/metadata_plugins.py | 2 -- docs/changelog.rst | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 6a75d0f79..0372790af 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -298,8 +298,6 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): if not artist_id: artist_id = artist[id_key] name = artist[name_key] - # Strip disambiguation number. - name = re.sub(r" \(\d+\)$", "", name) # Move articles to the front. name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I) # Use a join keyword if requested and available. diff --git a/docs/changelog.rst b/docs/changelog.rst index 8a35569c9..4ff7b7088 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,7 +22,7 @@ Bug fixes: For packagers: Other changes: - +- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping - :doc:`plugins/index`: Clarify that musicbrainz must be mentioned if plugin list modified :bug:`6020` - :doc:`/faq`: Add check for musicbrainz plugin if auto-tagger can't find a From 24fbc566f61d86d1fb2e6c1fcde98630b82e7828 Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer <henryoberholtzer@gmail.com> Date: Sat, 20 Sep 2025 01:58:56 +0200 Subject: [PATCH 116/301] initial changes, changelog adjusted, TODO: test for various artists and update docs --- beetsplug/discogs.py | 9 +++++++ docs/changelog.rst | 6 ++++- test/plugins/test_discogs.py | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index c1c782f3e..d8f1d6ba5 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -76,6 +76,8 @@ TRACK_INDEX_RE = re.compile( re.VERBOSE, ) +DISAMBIGUATION_RE = re.compile(r" \(\d+\)$") + class ReleaseFormat(TypedDict): name: str @@ -96,6 +98,7 @@ class DiscogsPlugin(MetadataSourcePlugin): "separator": ", ", "index_tracks": False, "append_style_genre": False, + "strip_disambiguation": True, } ) self.config["apikey"].redact = True @@ -373,6 +376,12 @@ class DiscogsPlugin(MetadataSourcePlugin): artist = config["va_name"].as_str() if catalogno == "none": catalogno = None + + # Remove Discogs specific artist disambiguation 'Artist (2)' or 'Label (3)' + if self.config["strip_disambiguation"]: + artist = DISAMBIGUATION_RE.sub("", artist) + if label is not None: + label = DISAMBIGUATION_RE.sub("", label) # Explicitly set the `media` for the tracks, since it is expected by # `autotag.apply_metadata`, and set `medium_total`. for track in tracks: diff --git a/docs/changelog.rst b/docs/changelog.rst index 4ff7b7088..0814686f8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,17 +18,21 @@ Bug fixes: - :doc:`plugins/spotify` Removed old and undocumented config options `artist_field`, `album_field` and `track` that were causing issues with track matching. :bug:`5189` +- :doc:`plugins/discogs` Added config option `strip_disambiguation` to allow choice of removing discogs numeric disambiguation :bug:`5366` +- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels :bug:`5366` For packagers: Other changes: -- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping + - :doc:`plugins/index`: Clarify that musicbrainz must be mentioned if plugin list modified :bug:`6020` - :doc:`/faq`: Add check for musicbrainz plugin if auto-tagger can't find a match :bug:`6020` - :doc:`guides/tagger`: Section on no matching release found, related to possibly disabled musicbrainz plugin :bug:`6020` +- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific + disambiguation stripping 2.4.0 (September 13, 2025) -------------------------- diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index e3e51042c..c279ff128 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -374,6 +374,55 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.genre == "GENRE1, GENRE2" assert d.style is None + def test_strip_disambiguation_label_artist(self): + """Test removing discogs disambiguation""" + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [self._make_track("A", "1", "01:01")], + "artists": [{"name": "ARTIST NAME (2)", "id": 321, "join": ""}], + "title": "TITLE", + "labels": [ + { + "name": "LABEL NAME (5)", + "catno": "CATALOG NUMBER", + } + ], + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + d = DiscogsPlugin().get_album_info(release) + assert d.artist == "ARTIST NAME" + assert d.label == "LABEL NAME" + + def test_strip_disambiguation_off_label_artist(self): + """Test not removing discogs disambiguation""" + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [self._make_track("A", "1", "01:01")], + "artists": [{"name": "ARTIST NAME (2)", "id": 321, "join": ""}], + "title": "TITLE", + "labels": [ + { + "name": "LABEL NAME (5)", + "catno": "CATALOG NUMBER", + } + ], + } + config["discogs"]["strip_disambiguation"] = False + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + d = DiscogsPlugin().get_album_info(release) + assert d.artist == "ARTIST NAME (2)" + assert d.label == "LABEL NAME (5)" + @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", From dda265dc77a9765274a2f484047123a06761d6a2 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 19 Sep 2025 20:46:07 -0700 Subject: [PATCH 117/301] Disambiguation fix implemented & tested --- beets/metadata_plugins.py | 7 ++-- beetsplug/discogs.py | 26 +++++++++------ docs/changelog.rst | 8 +++-- docs/plugins/discogs.rst | 3 ++ test/plugins/test_discogs.py | 63 ++++++++++++++++++++++++++++++++---- 5 files changed, 86 insertions(+), 21 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 0372790af..0b8d81c95 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -271,10 +271,9 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of artist object dicts. - For each artist, this function moves articles (such as 'a', 'an', - and 'the') to the front and strips trailing disambiguation numbers. It - returns a tuple containing the comma-separated string of all - normalized artists and the ``id`` of the main/first artist. + For each artist, this function moves articles (such as 'a', 'an', and 'the') + to the front. It returns a tuple containing the comma-separated string + of all normalized artists and the ``id`` of the main/first artist. Alternatively a keyword can be used to combine artists together into a single string by passing the join_key argument. diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index d8f1d6ba5..0ffbb0e3e 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -76,7 +76,7 @@ TRACK_INDEX_RE = re.compile( re.VERBOSE, ) -DISAMBIGUATION_RE = re.compile(r" \(\d+\)$") +DISAMBIGUATION_RE = re.compile(r" \(\d+\)") class ReleaseFormat(TypedDict): @@ -365,23 +365,22 @@ class DiscogsPlugin(MetadataSourcePlugin): label = catalogno = labelid = None if result.data.get("labels"): - label = result.data["labels"][0].get("name") + label = self.strip_disambiguation( + result.data["labels"][0].get("name") + ) catalogno = result.data["labels"][0].get("catno") labelid = result.data["labels"][0].get("id") cover_art_url = self.select_cover_art(result) - # Additional cleanups (various artists name, catalog number, media). + # Additional cleanups + # (various artists name, catalog number, media, disambiguation). if va: artist = config["va_name"].as_str() + else: + artist = self.strip_disambiguation(artist) if catalogno == "none": catalogno = None - - # Remove Discogs specific artist disambiguation 'Artist (2)' or 'Label (3)' - if self.config["strip_disambiguation"]: - artist = DISAMBIGUATION_RE.sub("", artist) - if label is not None: - label = DISAMBIGUATION_RE.sub("", label) # Explicitly set the `media` for the tracks, since it is expected by # `autotag.apply_metadata`, and set `medium_total`. for track in tracks: @@ -631,6 +630,14 @@ class DiscogsPlugin(MetadataSourcePlugin): return tracklist + def strip_disambiguation(self, text) -> str: + """Removes discogs specific disambiguations from a string. + Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)' + to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False.""" + if not self.config["strip_disambiguation"]: + return text + return DISAMBIGUATION_RE.sub("", text) + def get_track_info(self, track, index, divisions): """Returns a TrackInfo object for a discogs track.""" title = track["title"] @@ -643,6 +650,7 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_id = self.get_artist( track.get("artists", []), join_key="join" ) + artist = self.strip_disambiguation(artist) length = self.get_track_length(track["duration"]) return TrackInfo( title=title, diff --git a/docs/changelog.rst b/docs/changelog.rst index 0814686f8..ddafd975d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,8 +18,12 @@ Bug fixes: - :doc:`plugins/spotify` Removed old and undocumented config options `artist_field`, `album_field` and `track` that were causing issues with track matching. :bug:`5189` -- :doc:`plugins/discogs` Added config option `strip_disambiguation` to allow choice of removing discogs numeric disambiguation :bug:`5366` -- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels :bug:`5366` +- :doc:`plugins/discogs` Added config option `strip_disambiguation` to allow + choice of removing discogs numeric disambiguation :bug:`5366` +- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from + artists but not labels. :bug:`5366` +- :doc:`plugins/discogs` Wrote test coverage for removing disambiguation. + :bug:`5366` For packagers: diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 44c0c0e22..3dac558bd 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -109,6 +109,9 @@ Other configurations available under ``discogs:`` are: - **search_limit**: The maximum number of results to return from Discogs. This is useful if you want to limit the number of results returned to speed up searches. Default: ``5`` +- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct + artists and labels with the same name. If you'd like to use the discogs + disambiguation in your tags, you can disable it. Default: ``True`` .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index c279ff128..5fe73dcac 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -375,7 +375,7 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.style is None def test_strip_disambiguation_label_artist(self): - """Test removing discogs disambiguation""" + """Test removing discogs disambiguation from artist and label""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", @@ -399,21 +399,21 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.label == "LABEL NAME" def test_strip_disambiguation_off_label_artist(self): - """Test not removing discogs disambiguation""" + """Test not removing discogs disambiguation from artist and label""" + config["discogs"]["strip_disambiguation"] = False data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [self._make_track("A", "1", "01:01")], + "tracklist": [self._make_track("a", "1", "01:01")], "artists": [{"name": "ARTIST NAME (2)", "id": 321, "join": ""}], - "title": "TITLE", + "title": "title", "labels": [ { "name": "LABEL NAME (5)", - "catno": "CATALOG NUMBER", + "catno": "catalog number", } ], } - config["discogs"]["strip_disambiguation"] = False release = Bag( data=data, title=data["title"], @@ -423,6 +423,57 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.artist == "ARTIST NAME (2)" assert d.label == "LABEL NAME (5)" + def test_strip_disambiguation_multiple_artists(self): + """Test removing disambiguation if there are multiple artists on the release""" + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [self._make_track("a", "1", "01:01")], + "artists": [ + {"name": "ARTIST NAME (2)", "id": 321, "join": "&"}, + {"name": "OTHER ARTIST (5)", "id": 321, "join": ""}, + ], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + d = DiscogsPlugin().get_album_info(release) + assert d.artist == "ARTIST NAME & OTHER ARTIST" + + def test_strip_disambiguation_artist_tracks(self): + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [ + { + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [ + { + "name": "TEST ARTIST (5)", + "tracks": "", + "id": 11146, + } + ], + } + ], + "artists": [{"name": "OTHER ARTIST (5)", "id": 321, "join": ""}], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + d = DiscogsPlugin().get_album_info(release) + assert d.tracks[0].artist == "TEST ARTIST" + assert d.artist == "OTHER ARTIST" + @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", From 8a21bacd1455d06d571fd1251cd4a0513067b0e8 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 19 Sep 2025 21:00:37 -0700 Subject: [PATCH 118/301] Add type check --- beetsplug/discogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 0ffbb0e3e..f22ea2274 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -630,7 +630,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return tracklist - def strip_disambiguation(self, text) -> str: + def strip_disambiguation(self, text: str) -> str: """Removes discogs specific disambiguations from a string. Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)' to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False.""" From 28aee0fde463f1e18dfdba1994e2bdb80833722f Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 15 Sep 2025 23:56:43 +0200 Subject: [PATCH 119/301] Moved arts.py file into beetsplug namespace as it is not used in core. --- {beets => beetsplug/_utils}/art.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {beets => beetsplug/_utils}/art.py (100%) diff --git a/beets/art.py b/beetsplug/_utils/art.py similarity index 100% rename from beets/art.py rename to beetsplug/_utils/art.py From a796d6d7999b6e579932b96e489cabc609d35638 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 15 Sep 2025 23:57:15 +0200 Subject: [PATCH 120/301] New import location for art.py --- beetsplug/_utils/__init__.py | 0 beetsplug/convert.py | 3 ++- beetsplug/embedart.py | 3 ++- test/plugins/test_embedart.py | 3 ++- 4 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 beetsplug/_utils/__init__.py diff --git a/beetsplug/_utils/__init__.py b/beetsplug/_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/beetsplug/convert.py b/beetsplug/convert.py index e9db3592e..102976dd7 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -25,12 +25,13 @@ from string import Template import mediafile from confuse import ConfigTypeError, Optional -from beets import art, config, plugins, ui, util +from beets import config, plugins, ui, util from beets.library import Item, parse_query_string from beets.plugins import BeetsPlugin from beets.util import par_map from beets.util.artresizer import ArtResizer from beets.util.m3u import M3UFile +from beetsplug._utils import art _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 1a59e4f9c..cbf40f570 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -20,11 +20,12 @@ from mimetypes import guess_extension import requests -from beets import art, config, ui +from beets import config, ui from beets.plugins import BeetsPlugin from beets.ui import print_ from beets.util import bytestring_path, displayable_path, normpath, syspath from beets.util.artresizer import ArtResizer +from beetsplug._utils import art def _confirm(objs, album): diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 58d2a5f63..734183d3b 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -23,7 +23,7 @@ from unittest.mock import MagicMock, patch import pytest from mediafile import MediaFile -from beets import art, config, logging, ui +from beets import config, logging, ui from beets.test import _common from beets.test.helper import ( BeetsTestCase, @@ -33,6 +33,7 @@ from beets.test.helper import ( ) from beets.util import bytestring_path, displayable_path, syspath from beets.util.artresizer import ArtResizer +from beetsplug._utils import art from test.test_art_resize import DummyIMBackend From 4ab1bb4df463ef54a066e293661cd8db3c91995f Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 16 Sep 2025 00:03:16 +0200 Subject: [PATCH 121/301] Added changelog and git blame ignore rev --- .git-blame-ignore-revs | 4 +++- docs/changelog.rst | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index fbe32b497..ed86e3f8c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -70,4 +70,6 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved dev docs 07549ed896d9649562d40b75cd30702e6fa6e975 # Moved plugin docs Further Reading chapter -33f1a5d0bef8ca08be79ee7a0d02a018d502680d \ No newline at end of file +33f1a5d0bef8ca08be79ee7a0d02a018d502680d +# Moved art.py utility module from beets into beetsplug +a7b69e50108eebef8ce92c015f18a42f8bf7276f \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index 8a35569c9..674f3ff28 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,9 @@ Other changes: match :bug:`6020` - :doc:`guides/tagger`: Section on no matching release found, related to possibly disabled musicbrainz plugin :bug:`6020` +- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it + is not used in the core beets codebase. It can now be found in + ``beetsplug._utils``. 2.4.0 (September 13, 2025) -------------------------- From 73dc8f2bc74c78fd74d7afc844535fc3b9e64da3 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 16 Sep 2025 00:18:04 +0200 Subject: [PATCH 122/301] fix test by changing patch --- .git-blame-ignore-revs | 2 +- docs/changelog.rst | 4 ++-- test/plugins/test_embedart.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index ed86e3f8c..2ee64a97d 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -72,4 +72,4 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved plugin docs Further Reading chapter 33f1a5d0bef8ca08be79ee7a0d02a018d502680d # Moved art.py utility module from beets into beetsplug -a7b69e50108eebef8ce92c015f18a42f8bf7276f \ No newline at end of file +28aee0fde463f1e18dfdba1994e2bdb80833722f \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index 674f3ff28..ba6a357b7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,8 +29,8 @@ Other changes: match :bug:`6020` - :doc:`guides/tagger`: Section on no matching release found, related to possibly disabled musicbrainz plugin :bug:`6020` -- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it - is not used in the core beets codebase. It can now be found in +- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as + it is not used in the core beets codebase. It can now be found in ``beetsplug._utils``. 2.4.0 (September 13, 2025) diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 734183d3b..d40025374 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -284,7 +284,7 @@ class DummyArtResizer(ArtResizer): @patch("beets.util.artresizer.subprocess") -@patch("beets.art.extract") +@patch("beetsplug._utils.art.extract") class ArtSimilarityTest(unittest.TestCase): def setUp(self): self.item = _common.item() From a57ef2cb3b037db43277595aaff974f1120baa15 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Sun, 14 Sep 2025 15:36:42 -0400 Subject: [PATCH 123/301] Add --pretend option to lastgenre plugin for previewing genre changes --- beetsplug/lastgenre/__init__.py | 44 +++++++++++++++++++++++---------- docs/changelog.rst | 3 +++ docs/plugins/lastgenre.rst | 4 +++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 8c09eefea..bebe15047 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -461,6 +461,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") + lastgenre_cmd.parser.add_option( + "-p", + "--pretend", + action="store_true", + help="show actions but do nothing", + ) lastgenre_cmd.parser.add_option( "-f", "--force", @@ -521,45 +527,57 @@ class LastGenrePlugin(plugins.BeetsPlugin): def lastgenre_func(lib, opts, args): write = ui.should_write() + pretend = getattr(opts, "pretend", False) self.config.set_args(opts) if opts.album: # Fetch genres for whole albums for album in lib.albums(args): - album.genre, src = self._get_genre(album) + album_genre, src = self._get_genre(album) self._log.info( - 'genre for album "{0.album}" ({1}): {0.genre}', + 'genre for album "{0.album}" ({1}): {}', album, src, + album_genre, ) - if "track" in self.sources: - album.store(inherit=False) - else: - album.store() + if not pretend: + album.genre = album_genre + if "track" in self.sources: + album.store(inherit=False) + else: + album.store() for item in album.items(): # If we're using track-level sources, also look up each # track on the album. if "track" in self.sources: - item.genre, src = self._get_genre(item) - item.store() + item_genre, src = self._get_genre(item) self._log.info( - 'genre for track "{0.title}" ({1}): {0.genre}', + 'genre for track "{0.title}" ({1}): {}', item, src, + item_genre, ) + if not pretend: + item.genre = item_genre + item.store() - if write: + if write and not pretend: item.try_write() else: # Just query singletons, i.e. items that are not part of # an album for item in lib.items(args): - item.genre, src = self._get_genre(item) - item.store() + item_genre, src = self._get_genre(item) self._log.info( - "genre for track {0.title} ({1}): {0.genre}", item, src + 'genre for track "{0.title}" ({1}): {}', + item, + src, + item_genre, ) + if not pretend: + item.genre = item_genre + item.store() lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index ba6a357b7..63a8fe339 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,9 @@ Unreleased New features: +- :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes + without storing or writing them. + Bug fixes: - :doc:`plugins/spotify` Fixed an issue where track matching and lookups could diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 5ebe2d721..f4f92f7d1 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -213,5 +213,9 @@ fetch genres for albums or items matching a certain query. By default, ``beet lastgenre`` matches albums. To match individual tracks or singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``. +To preview changes without modifying your library, use the ``-p`` +(``--pretend``) flag. This shows which genres would be set but does not write +or store any changes. + To disable automatic genre fetching on import, set the ``auto`` config option to false. From 84986dc42d315c52ca32b130862fb16d762c0237 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Sun, 14 Sep 2025 15:49:34 -0400 Subject: [PATCH 124/301] Enhance lastgenre plugin: add item.try_write() for write operations and improve documentation clarity --- beetsplug/lastgenre/__init__.py | 2 ++ docs/plugins/lastgenre.rst | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bebe15047..cfecbf0f8 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -578,6 +578,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): if not pretend: item.genre = item_genre item.store() + if write and not pretend: + item.try_write() lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index f4f92f7d1..a33a79230 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -124,7 +124,7 @@ tags** and will only **fetch new genres for empty tags**. When ``force`` is ``yes`` the setting of the ``whitelist`` option (as documented in Usage_) applies to any existing or newly fetched genres. -The follwing configurations are possible: +The following configurations are possible: **Setup 1** (default) @@ -213,9 +213,9 @@ fetch genres for albums or items matching a certain query. By default, ``beet lastgenre`` matches albums. To match individual tracks or singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``. -To preview changes without modifying your library, use the ``-p`` -(``--pretend``) flag. This shows which genres would be set but does not write -or store any changes. +- To preview the changes that would be made without applying them, use the + ``-p`` (``--pretend``) flag. This shows which genres would be set but does + not write or store any changes. To disable automatic genre fetching on import, set the ``auto`` config option to false. From 95b35ded4ab74234f8de470e57217b87d36b920b Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Sun, 14 Sep 2025 15:54:12 -0400 Subject: [PATCH 125/301] Lint --- docs/plugins/lastgenre.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index a33a79230..a932585ac 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -213,9 +213,9 @@ fetch genres for albums or items matching a certain query. By default, ``beet lastgenre`` matches albums. To match individual tracks or singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``. -- To preview the changes that would be made without applying them, use the - ``-p`` (``--pretend``) flag. This shows which genres would be set but does - not write or store any changes. +To preview the changes that would be made without applying them, use the +``-p`` or ``--pretend`` flag. This shows which genres would be set but does +not write or store any changes. To disable automatic genre fetching on import, set the ``auto`` config option to false. From 56e132f3527b5abca61dec676bf43e78b5a9a607 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Sun, 14 Sep 2025 16:00:01 -0400 Subject: [PATCH 126/301] more lint --- docs/plugins/lastgenre.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index a932585ac..230694b06 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -213,9 +213,9 @@ fetch genres for albums or items matching a certain query. By default, ``beet lastgenre`` matches albums. To match individual tracks or singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``. -To preview the changes that would be made without applying them, use the -``-p`` or ``--pretend`` flag. This shows which genres would be set but does -not write or store any changes. +To preview the changes that would be made without applying them, use the ``-p`` +or ``--pretend`` flag. This shows which genres would be set but does not write +or store any changes. To disable automatic genre fetching on import, set the ``auto`` config option to false. From 5e6dd674a965c89c4fa9c18f5d99b7d9df9c39b6 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@users.noreply.github.com> Date: Wed, 17 Sep 2025 07:47:04 -0400 Subject: [PATCH 127/301] Update beetsplug/lastgenre/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus <snejus@protonmail.com> --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index cfecbf0f8..fc8510a0a 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -535,7 +535,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): for album in lib.albums(args): album_genre, src = self._get_genre(album) self._log.info( - 'genre for album "{0.album}" ({1}): {}', + 'genre for album "{.album}" ({}): {}', album, src, album_genre, From 0be4cecf820785633feac956ccadca4b8da3f5a8 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@users.noreply.github.com> Date: Wed, 17 Sep 2025 07:47:14 -0400 Subject: [PATCH 128/301] Update beetsplug/lastgenre/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus <snejus@protonmail.com> --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index fc8510a0a..946b041c3 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -553,7 +553,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if "track" in self.sources: item_genre, src = self._get_genre(item) self._log.info( - 'genre for track "{0.title}" ({1}): {}', + 'genre for track "{.title}" ({}): {}', item, src, item_genre, From c7ee0e326c3579e6a8207567621b781f2f60a530 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Thu, 18 Sep 2025 07:53:48 -0400 Subject: [PATCH 129/301] Add prefix to log messages for genre fetching in LastGenrePlugin --- beetsplug/lastgenre/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 946b041c3..1da5ecde4 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -534,8 +534,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Fetch genres for whole albums for album in lib.albums(args): album_genre, src = self._get_genre(album) + prefix = "Pretend: " if pretend else "" self._log.info( - 'genre for album "{.album}" ({}): {}', + '{}genre for album "{.album}" ({}): {}', + prefix, album, src, album_genre, @@ -553,7 +555,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): if "track" in self.sources: item_genre, src = self._get_genre(item) self._log.info( - 'genre for track "{.title}" ({}): {}', + '{}genre for track "{.title}" ({}): {}', + prefix, item, src, item_genre, @@ -569,8 +572,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): # an album for item in lib.items(args): item_genre, src = self._get_genre(item) + prefix = "Pretend: " if pretend else "" self._log.info( - 'genre for track "{0.title}" ({1}): {}', + '{}genre for track "{0.title}" ({1}): {}', + prefix, item, src, item_genre, From 9b1537f22625746019f102e7f057001e957e5689 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Thu, 18 Sep 2025 08:20:40 -0400 Subject: [PATCH 130/301] Add test for --pretend option in LastGenrePlugin to skip library updates --- test/plugins/test_lastgenre.py | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 72b0d4f00..273be4b28 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -14,7 +14,7 @@ """Tests for the 'lastgenre' plugin.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -131,6 +131,41 @@ class LastGenrePluginTest(BeetsTestCase): "math rock", ] + def test_pretend_option_skips_library_updates(self): + item = self.create_item( + album="Pretend Album", + albumartist="Pretend Artist", + artist="Pretend Artist", + title="Pretend Track", + genre="", + ) + album = self.lib.add_album([item]) + + command = self.plugin.commands()[0] + opts, args = command.parser.parse_args(["--pretend"]) + + with patch.object(lastgenre.ui, "should_write", return_value=True): + with patch.object( + self.plugin, + "_get_genre", + return_value=("Mock Genre", "mock stage"), + ) as mock_get_genre: + with patch.object(self.plugin._log, "info") as log_info: + command.func(self.lib, opts, args) + + mock_get_genre.assert_called_once() + + assert any( + call.args[1] == "Pretend: " for call in log_info.call_args_list + ) + + stored_album = self.lib.get_album(album.id) + assert stored_album.genre == "" + + items = list(stored_album.items()) + assert items + assert items[0].genre == "" + def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) From 2e307b519af55b6e6a12eb9f9bcb72d13f8ac5cc Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 20 Sep 2025 19:18:56 +0200 Subject: [PATCH 131/301] lastgenre: Also mock try_write in test_pretend.. and add and original genre instead empty string (clarify intention of test / readability). Remove not really necessary assert items checks. --- test/plugins/test_lastgenre.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 273be4b28..d6df42f97 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -137,7 +137,7 @@ class LastGenrePluginTest(BeetsTestCase): albumartist="Pretend Artist", artist="Pretend Artist", title="Pretend Track", - genre="", + genre="Original Genre", ) album = self.lib.add_album([item]) @@ -151,7 +151,9 @@ class LastGenrePluginTest(BeetsTestCase): return_value=("Mock Genre", "mock stage"), ) as mock_get_genre: with patch.object(self.plugin._log, "info") as log_info: - command.func(self.lib, opts, args) + # Mock try_write to verify it's never called in pretend mode + with patch.object(item, "try_write") as mock_try_write: + command.func(self.lib, opts, args) mock_get_genre.assert_called_once() @@ -159,12 +161,12 @@ class LastGenrePluginTest(BeetsTestCase): call.args[1] == "Pretend: " for call in log_info.call_args_list ) - stored_album = self.lib.get_album(album.id) - assert stored_album.genre == "" + # Verify that try_write was never called (file operations skipped) + mock_try_write.assert_not_called() - items = list(stored_album.items()) - assert items - assert items[0].genre == "" + stored_album = self.lib.get_album(album.id) + assert stored_album.genre == "Original Genre" + assert stored_album.items()[0].genre == "Original Genre" def test_no_duplicate(self): """Remove duplicated genres.""" From 3fd49a7de8948379278adf48b6231f66068a8d56 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 15 Sep 2025 23:56:43 +0200 Subject: [PATCH 132/301] Moved arts.py file into beetsplug namespace as it is not used in core. --- {beets => beetsplug/_utils}/art.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {beets => beetsplug/_utils}/art.py (100%) diff --git a/beets/art.py b/beetsplug/_utils/art.py similarity index 100% rename from beets/art.py rename to beetsplug/_utils/art.py From 34114fe9155d1e8cf9b3cee72617429274bab949 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 15 Sep 2025 23:57:15 +0200 Subject: [PATCH 133/301] New import location for art.py --- beetsplug/_utils/__init__.py | 0 beetsplug/convert.py | 3 ++- beetsplug/embedart.py | 3 ++- test/plugins/test_embedart.py | 3 ++- 4 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 beetsplug/_utils/__init__.py diff --git a/beetsplug/_utils/__init__.py b/beetsplug/_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/beetsplug/convert.py b/beetsplug/convert.py index e9db3592e..102976dd7 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -25,12 +25,13 @@ from string import Template import mediafile from confuse import ConfigTypeError, Optional -from beets import art, config, plugins, ui, util +from beets import config, plugins, ui, util from beets.library import Item, parse_query_string from beets.plugins import BeetsPlugin from beets.util import par_map from beets.util.artresizer import ArtResizer from beets.util.m3u import M3UFile +from beetsplug._utils import art _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 1a59e4f9c..cbf40f570 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -20,11 +20,12 @@ from mimetypes import guess_extension import requests -from beets import art, config, ui +from beets import config, ui from beets.plugins import BeetsPlugin from beets.ui import print_ from beets.util import bytestring_path, displayable_path, normpath, syspath from beets.util.artresizer import ArtResizer +from beetsplug._utils import art def _confirm(objs, album): diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 58d2a5f63..734183d3b 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -23,7 +23,7 @@ from unittest.mock import MagicMock, patch import pytest from mediafile import MediaFile -from beets import art, config, logging, ui +from beets import config, logging, ui from beets.test import _common from beets.test.helper import ( BeetsTestCase, @@ -33,6 +33,7 @@ from beets.test.helper import ( ) from beets.util import bytestring_path, displayable_path, syspath from beets.util.artresizer import ArtResizer +from beetsplug._utils import art from test.test_art_resize import DummyIMBackend From f4691c85e947aa34c389fe6b2613cd5e89eb1763 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 16 Sep 2025 00:03:16 +0200 Subject: [PATCH 134/301] Added changelog and git blame ignore rev --- .git-blame-ignore-revs | 4 +++- docs/changelog.rst | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index fbe32b497..ed86e3f8c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -70,4 +70,6 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved dev docs 07549ed896d9649562d40b75cd30702e6fa6e975 # Moved plugin docs Further Reading chapter -33f1a5d0bef8ca08be79ee7a0d02a018d502680d \ No newline at end of file +33f1a5d0bef8ca08be79ee7a0d02a018d502680d +# Moved art.py utility module from beets into beetsplug +a7b69e50108eebef8ce92c015f18a42f8bf7276f \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index ddafd975d..94a90a1b5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,6 +35,9 @@ Other changes: match :bug:`6020` - :doc:`guides/tagger`: Section on no matching release found, related to possibly disabled musicbrainz plugin :bug:`6020` +- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it + is not used in the core beets codebase. It can now be found in + ``beetsplug._utils``. - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping From c991b14e7d913d719e0ab3580336b1e386bbfb69 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 16 Sep 2025 00:18:04 +0200 Subject: [PATCH 135/301] fix test by changing patch --- .git-blame-ignore-revs | 2 +- docs/changelog.rst | 4 ++-- test/plugins/test_embedart.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index ed86e3f8c..2ee64a97d 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -72,4 +72,4 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved plugin docs Further Reading chapter 33f1a5d0bef8ca08be79ee7a0d02a018d502680d # Moved art.py utility module from beets into beetsplug -a7b69e50108eebef8ce92c015f18a42f8bf7276f \ No newline at end of file +28aee0fde463f1e18dfdba1994e2bdb80833722f \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index 94a90a1b5..44449e811 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,8 +35,8 @@ Other changes: match :bug:`6020` - :doc:`guides/tagger`: Section on no matching release found, related to possibly disabled musicbrainz plugin :bug:`6020` -- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it - is not used in the core beets codebase. It can now be found in +- Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as + it is not used in the core beets codebase. It can now be found in ``beetsplug._utils``. - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 734183d3b..d40025374 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -284,7 +284,7 @@ class DummyArtResizer(ArtResizer): @patch("beets.util.artresizer.subprocess") -@patch("beets.art.extract") +@patch("beetsplug._utils.art.extract") class ArtSimilarityTest(unittest.TestCase): def setUp(self): self.item = _common.item() From 92579b30d833ba897cd22dd3b6e176fea83a4d27 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sun, 21 Sep 2025 09:25:30 -0700 Subject: [PATCH 136/301] Reformat docs --- docs/changelog.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4e7b92840..5d7e3f803 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,9 +37,8 @@ Other changes: possibly disabled musicbrainz plugin :bug:`6020` - Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it is not used in the core beets codebase. It can now be found in - ``beetsplug._utils``. - - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific - disambiguation stripping + ``beetsplug._utils``. - :class:`beets.metadata_plugin.MetadataSourcePlugin`: + Remove discogs specific disambiguation stripping 2.4.0 (September 13, 2025) -------------------------- From 76c049938c8253ebb48e783f295e2c5d2cb97ed8 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Wed, 17 Sep 2025 13:25:20 -0400 Subject: [PATCH 137/301] Update missing plugin configuration options and formatting details --- docs/plugins/missing.rst | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index 7764f5fe1..81d0b2d0e 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -39,21 +39,26 @@ Configuration To configure the plugin, make a ``missing:`` section in your configuration file. The available options are: -- **count**: Print a count of missing tracks per album, with ``format`` - defaulting to ``$albumartist - $album: $missing``. Default: ``no``. -- **format**: A specific format with which to print every track. This uses the - same template syntax as beets' :doc:`path formats </reference/pathformat>`. - The usage is inspired by, and therefore similar to, the :ref:`list <list-cmd>` - command. Default: :ref:`format_item`. +- **count**: Print a count of missing tracks per album, with the global + ``format_album`` used for formatting. Default: ``no``. - **total**: Print a single count of missing tracks in all albums. Default: ``no``. +Formatting +~~~~~~~~~~ + +- This plugin uses global formatting options from the main configuration; see :ref:`format_item` and :ref:`format_album`: + +- :ref:`format_item`: Used when listing missing tracks (default item format). +- :ref:`format_album`: Used when showing counts (``-c``) or missing albums (``-a``). + Here's an example :: + format_album: $albumartist - $album + format_item: $artist - $album - $title missing: - format: $albumartist - $album - $title count: no total: no From de4494a5b19890b80ed141d459c6625d0a79cc52 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Wed, 17 Sep 2025 13:29:00 -0400 Subject: [PATCH 138/301] lint --- docs/plugins/missing.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index 81d0b2d0e..10842933c 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -47,10 +47,11 @@ The available options are: Formatting ~~~~~~~~~~ -- This plugin uses global formatting options from the main configuration; see :ref:`format_item` and :ref:`format_album`: - +- This plugin uses global formatting options from the main configuration; see + :ref:`format_item` and :ref:`format_album`: - :ref:`format_item`: Used when listing missing tracks (default item format). -- :ref:`format_album`: Used when showing counts (``-c``) or missing albums (``-a``). +- :ref:`format_album`: Used when showing counts (``-c``) or missing albums + (``-a``). Here's an example From f0a6059685f32d171c967d06a1a2c3a9563376f8 Mon Sep 17 00:00:00 2001 From: Trey Turner <treyturner@users.noreply.github.com> Date: Sat, 23 Aug 2025 08:37:22 -0500 Subject: [PATCH 139/301] feat(FtInTitle): support tracks by artists != album artist --- beetsplug/ftintitle.py | 56 +++++++++++++++++-------------- docs/changelog.rst | 3 ++ test/plugins/test_ftintitle.py | 60 +++++++++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index f0f088099..e17d7bc1c 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -26,14 +26,16 @@ if TYPE_CHECKING: from beets.library import Item -def split_on_feat(artist: str) -> tuple[str, str | None]: +def split_on_feat( + artist: str, for_artist: bool = True +) -> tuple[str, str | None]: """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main artist, which is always a string, and the featuring artist, which may be a string or None if none is present. """ # split on the first "feat". - regex = re.compile(plugins.feat_tokens(), re.IGNORECASE) + regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: return parts[0], None @@ -53,32 +55,35 @@ def contains_feat(title: str) -> bool: ) -def find_feat_part(artist: str, albumartist: str) -> str | None: +def find_feat_part(artist: str, albumartist: str | None) -> str | None: """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ - # Look for the album artist in the artist field. If it's not - # present, give up. - albumartist_split = artist.split(albumartist, 1) - if len(albumartist_split) <= 1: - return None + # Handle a wider variety of extraction cases if the album artist is + # contained within the track artist. + if albumartist and albumartist in artist: + albumartist_split = artist.split(albumartist, 1) - # If the last element of the split (the right-hand side of the - # album artist) is nonempty, then it probably contains the - # featured artist. - elif albumartist_split[1] != "": - # Extract the featured artist from the right-hand side. - _, feat_part = split_on_feat(albumartist_split[1]) - return feat_part + # If the last element of the split (the right-hand side of the + # album artist) is nonempty, then it probably contains the + # featured artist. + if albumartist_split[1] != "": + # Extract the featured artist from the right-hand side. + _, feat_part = split_on_feat(albumartist_split[1]) + return feat_part - # Otherwise, if there's nothing on the right-hand side, look for a - # featuring artist on the left-hand side. - else: - lhs, rhs = split_on_feat(albumartist_split[0]) - if lhs: - return lhs + # Otherwise, if there's nothing on the right-hand side, + # look for a featuring artist on the left-hand side. + else: + lhs, _ = split_on_feat(albumartist_split[0]) + if lhs: + return lhs - return None + # Fall back to conservative handling of the track artist without relying + # on albumartist, which covers compilations using a 'Various Artists' + # albumartist and album tracks by a guest artist featuring a third artist. + _, feat_part = split_on_feat(artist, False) + return feat_part class FtInTitlePlugin(plugins.BeetsPlugin): @@ -153,8 +158,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "artist: {.artist} (Not changing due to keep_in_artist)", item ) else: - self._log.info("artist: {0.artist} -> {0.albumartist}", item) - item.artist = item.albumartist + track_artist, _ = split_on_feat(item.artist) + self._log.info("artist: {0.artist} -> {1}", item, track_artist) + item.artist = track_artist if item.artist_sort: # Just strip the featured artist from the sort name. @@ -187,7 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # Check whether there is a featured artist on this track and the # artist field does not exactly match the album artist field. In # that case, we attempt to move the featured artist to the title. - if not albumartist or albumartist == artist: + if albumartist and artist == albumartist: return False _, featured = split_on_feat(artist) diff --git a/docs/changelog.rst b/docs/changelog.rst index 63a8fe339..c2d6ea1cf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -161,6 +161,9 @@ Other changes: Autogenerated API references are now located in the ``docs/api`` subdirectory. - :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each case is shown on separate lines. +- :doc:`/plugins/ftintitle`: Process items whose albumartist is not contained in + the artist field, including compilations using Various Artists as an + albumartist and album tracks by guest artists featuring a third artist. - Refactored library.py file by splitting it into multiple modules within the beets/library directory. - Added a test to check that all plugins can be imported without errors. diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 572431b45..e049fe32a 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -40,6 +40,43 @@ class FtInTitlePluginFunctional(PluginTestCase): self.config["ftintitle"]["auto"] = auto self.config["ftintitle"]["keep_in_artist"] = keep_in_artist + def test_functional_no_featured_artist(self): + item = self._ft_add_item("/", "Alice", "Song 1", "Alice") + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_no_albumartist(self): + self._ft_set_config("feat {0}") + item = self._ft_add_item("/", "Alice ft. Bob", "Song 1", None) + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 feat Bob" + + def test_functional_no_albumartist_no_feature(self): + item = self._ft_add_item("/", "Alice", "Song 1", None) + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_guest_artist(self): + self._ft_set_config("featuring {0}") + item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 featuring Bob" + + def test_functional_guest_artist_no_feature(self): + item = self._ft_add_item("/", "Alice", "Song 1", "George") + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + def test_functional_drop(self): item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") self.run_command("ftintitle", "-d") @@ -47,12 +84,25 @@ class FtInTitlePluginFunctional(PluginTestCase): assert item["artist"] == "Alice" assert item["title"] == "Song 1" - def test_functional_not_found(self): + def test_functional_drop_no_featured_artist(self): + item = self._ft_add_item("/", "Alice", "Song 1", "Alice") + self.run_command("ftintitle", "-d") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_drop_guest_artist(self): item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") self.run_command("ftintitle", "-d") item.load() - # item should be unchanged - assert item["artist"] == "Alice ft Bob" + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_drop_guest_artist_no_feature(self): + item = self._ft_add_item("/", "Alice", "Song 1", "George") + self.run_command("ftintitle", "-d") + item.load() + assert item["artist"] == "Alice" assert item["title"] == "Song 1" def test_functional_custom_format(self): @@ -147,7 +197,7 @@ class FtInTitlePluginTest(unittest.TestCase): { "artist": "Alice ft. Carol", "album_artist": "Bob", - "feat_part": None, + "feat_part": "Carol", }, ] @@ -155,7 +205,7 @@ class FtInTitlePluginTest(unittest.TestCase): feat_part = ftintitle.find_feat_part( test_case["artist"], test_case["album_artist"] ) - assert feat_part == test_case["feat_part"] + assert feat_part == test_case["feat_part"], f"failed: {test_case}" def test_split_on_feat(self): parts = ftintitle.split_on_feat("Alice ft. Bob") From 6ad7c5489c1d1c1d6ca7e86b32e0f3497796bd48 Mon Sep 17 00:00:00 2001 From: Trey Turner <treyturner@users.noreply.github.com> Date: Thu, 18 Sep 2025 23:00:30 -0500 Subject: [PATCH 140/301] test(ftintitle): parameterize tests --- test/plugins/test_ftintitle.py | 435 +++++++++++++++++---------------- 1 file changed, 225 insertions(+), 210 deletions(-) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index e049fe32a..2b560e01c 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -14,8 +14,11 @@ """Tests for the 'ftintitle' plugin.""" -import unittest +from typing import Dict, Optional +from _pytest.fixtures import SubRequest +import pytest +from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -23,219 +26,231 @@ from beetsplug import ftintitle class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" - def _ft_add_item(self, path, artist, title, aartist): - return self.add_item( - path=path, - artist=artist, - artist_sort=artist, - title=title, - albumartist=aartist, - ) - def _ft_set_config( - self, ftformat, drop=False, auto=True, keep_in_artist=False - ): - self.config["ftintitle"]["format"] = ftformat - self.config["ftintitle"]["drop"] = drop - self.config["ftintitle"]["auto"] = auto - self.config["ftintitle"]["keep_in_artist"] = keep_in_artist - - def test_functional_no_featured_artist(self): - item = self._ft_add_item("/", "Alice", "Song 1", "Alice") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_no_albumartist(self): - self._ft_set_config("feat {0}") - item = self._ft_add_item("/", "Alice ft. Bob", "Song 1", None) - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 feat Bob" - - def test_functional_no_albumartist_no_feature(self): - item = self._ft_add_item("/", "Alice", "Song 1", None) - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_guest_artist(self): - self._ft_set_config("featuring {0}") - item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 featuring Bob" - - def test_functional_guest_artist_no_feature(self): - item = self._ft_add_item("/", "Alice", "Song 1", "George") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_drop(self): - item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") - self.run_command("ftintitle", "-d") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_drop_no_featured_artist(self): - item = self._ft_add_item("/", "Alice", "Song 1", "Alice") - self.run_command("ftintitle", "-d") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_drop_guest_artist(self): - item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") - self.run_command("ftintitle", "-d") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_drop_guest_artist_no_feature(self): - item = self._ft_add_item("/", "Alice", "Song 1", "George") - self.run_command("ftintitle", "-d") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1" - - def test_functional_custom_format(self): - self._ft_set_config("feat. {}") - item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 feat. Bob" - - self._ft_set_config("featuring {}") - item = self._ft_add_item("/", "Alice feat. Bob", "Song 1", "Alice") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 featuring Bob" - - self._ft_set_config("with {}") - item = self._ft_add_item("/", "Alice feat Bob", "Song 1", "Alice") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 with Bob" - - def test_functional_keep_in_artist(self): - self._ft_set_config("feat. {}", keep_in_artist=True) - item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") - self.run_command("ftintitle") - item.load() - assert item["artist"] == "Alice ft Bob" - assert item["title"] == "Song 1 feat. Bob" - - item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") - self.run_command("ftintitle", "-d") - item.load() - assert item["artist"] == "Alice ft Bob" - assert item["title"] == "Song 1" +@pytest.fixture +def env(request: SubRequest) -> FtInTitlePluginFunctional: + case = FtInTitlePluginFunctional(methodName="runTest") + case.setUp() + request.addfinalizer(case.tearDown) + return case -class FtInTitlePluginTest(unittest.TestCase): - def setUp(self): - """Set up configuration""" - ftintitle.FtInTitlePlugin() +def set_config( + env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, str | bool]] +) -> None: + cfg = {} if cfg is None else cfg + defaults = { + "drop": False, + "auto": True, + "keep_in_artist": False, + } + env.config["ftintitle"].set(defaults) + env.config["ftintitle"].set(cfg) - def test_find_feat_part(self): - test_cases = [ - { - "artist": "Alice ft. Bob", - "album_artist": "Alice", - "feat_part": "Bob", - }, - { - "artist": "Alice feat Bob", - "album_artist": "Alice", - "feat_part": "Bob", - }, - { - "artist": "Alice featuring Bob", - "album_artist": "Alice", - "feat_part": "Bob", - }, - { - "artist": "Alice & Bob", - "album_artist": "Alice", - "feat_part": "Bob", - }, - { - "artist": "Alice and Bob", - "album_artist": "Alice", - "feat_part": "Bob", - }, - { - "artist": "Alice With Bob", - "album_artist": "Alice", - "feat_part": "Bob", - }, - { - "artist": "Alice defeat Bob", - "album_artist": "Alice", - "feat_part": None, - }, - { - "artist": "Alice & Bob", - "album_artist": "Bob", - "feat_part": "Alice", - }, - { - "artist": "Alice ft. Bob", - "album_artist": "Bob", - "feat_part": "Alice", - }, - { - "artist": "Alice ft. Carol", - "album_artist": "Bob", - "feat_part": "Carol", - }, - ] - for test_case in test_cases: - feat_part = ftintitle.find_feat_part( - test_case["artist"], test_case["album_artist"] - ) - assert feat_part == test_case["feat_part"], f"failed: {test_case}" +def add_item( + env: FtInTitlePluginFunctional, + path: str, + artist: str, + title: str, + albumartist: str, +) -> Item: + return env.add_item( + path=path, + artist=artist, + artist_sort=artist, + title=title, + albumartist=albumartist, + ) - def test_split_on_feat(self): - parts = ftintitle.split_on_feat("Alice ft. Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice feat Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice feat. Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice featuring Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice & Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice and Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice With Bob") - assert parts == ("Alice", "Bob") - parts = ftintitle.split_on_feat("Alice defeat Bob") - assert parts == ("Alice defeat Bob", None) - def test_contains_feat(self): - assert ftintitle.contains_feat("Alice ft. Bob") - assert ftintitle.contains_feat("Alice feat. Bob") - assert ftintitle.contains_feat("Alice feat Bob") - assert ftintitle.contains_feat("Alice featuring Bob") - assert ftintitle.contains_feat("Alice (ft. Bob)") - assert ftintitle.contains_feat("Alice (feat. Bob)") - assert ftintitle.contains_feat("Alice [ft. Bob]") - assert ftintitle.contains_feat("Alice [feat. Bob]") - assert not ftintitle.contains_feat("Alice defeat Bob") - assert not ftintitle.contains_feat("Aliceft.Bob") - assert not ftintitle.contains_feat("Alice (defeat Bob)") - assert not ftintitle.contains_feat("Live and Let Go") - assert not ftintitle.contains_feat("Come With Me") +@pytest.mark.parametrize( + "cfg, cmd_args, input, expected", + [ + pytest.param( + None, + ("ftintitle",), + ("Alice", "Song 1", "Alice"), + ("Alice", "Song 1"), + id="no-featured-artist", + ), + pytest.param( + {"format": "feat {0}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1", None), + ("Alice", "Song 1 feat Bob"), + id="no-albumartist-custom-format", + ), + pytest.param( + None, + ("ftintitle",), + ("Alice", "Song 1", None), + ("Alice", "Song 1"), + id="no-albumartist-no-feature", + ), + pytest.param( + {"format": "featuring {0}"}, + ("ftintitle",), + ("Alice ft Bob", "Song 1", "George"), + ("Alice", "Song 1 featuring Bob"), + id="guest-artist-custom-format", + ), + pytest.param( + None, + ("ftintitle",), + ("Alice", "Song 1", "George"), + ("Alice", "Song 1"), + id="guest-artist-no-feature", + ), + # ---- drop (-d) variants ---- + pytest.param( + None, + ("ftintitle", "-d"), + ("Alice ft Bob", "Song 1", "Alice"), + ("Alice", "Song 1"), + id="drop-self-ft", + ), + pytest.param( + None, + ("ftintitle", "-d"), + ("Alice", "Song 1", "Alice"), + ("Alice", "Song 1"), + id="drop-self-no-ft", + ), + pytest.param( + None, + ("ftintitle", "-d"), + ("Alice ft Bob", "Song 1", "George"), + ("Alice", "Song 1"), + id="drop-guest-ft", + ), + pytest.param( + None, + ("ftintitle", "-d"), + ("Alice", "Song 1", "George"), + ("Alice", "Song 1"), + id="drop-guest-no-ft", + ), + # ---- custom format variants ---- + pytest.param( + {"format": "feat. {}"}, + ("ftintitle",), + ("Alice ft Bob", "Song 1", "Alice"), + ("Alice", "Song 1 feat. Bob"), + id="custom-format-feat-dot", + ), + pytest.param( + {"format": "featuring {}"}, + ("ftintitle",), + ("Alice feat. Bob", "Song 1", "Alice"), + ("Alice", "Song 1 featuring Bob"), + id="custom-format-featuring", + ), + pytest.param( + {"format": "with {}"}, + ("ftintitle",), + ("Alice feat Bob", "Song 1", "Alice"), + ("Alice", "Song 1 with Bob"), + id="custom-format-with", + ), + # ---- keep_in_artist variants ---- + pytest.param( + {"format": "feat. {}", "keep_in_artist": True}, + ("ftintitle",), + ("Alice ft Bob", "Song 1", "Alice"), + ("Alice ft Bob", "Song 1 feat. Bob"), + id="keep-in-artist-add-to-title", + ), + pytest.param( + {"format": "feat. {}", "keep_in_artist": True}, + ("ftintitle", "-d"), + ("Alice ft Bob", "Song 1", "Alice"), + ("Alice ft Bob", "Song 1"), + id="keep-in-artist-drop-from-title", + ), + ], +) +def test_ftintitle_functional( + env: FtInTitlePluginFunctional, + cfg: Optional[Dict[str, str | bool]], + cmd_args: tuple[str, ...], + input: tuple[str, str, str], + expected: tuple[str, str], +) -> None: + set_config(env, cfg) + ftintitle.FtInTitlePlugin() + + artist, title, albumartist = input + item = add_item(env, "/", artist, title, albumartist) + + env.run_command(*cmd_args) + item.load() + + expected_artist, expected_title = expected + assert item["artist"] == expected_artist + assert item["title"] == expected_title + + +@pytest.mark.parametrize( + "artist,albumartist,expected", + [ + ("Alice ft. Bob", "Alice", "Bob"), + ("Alice feat Bob", "Alice", "Bob"), + ("Alice featuring Bob", "Alice", "Bob"), + ("Alice & Bob", "Alice", "Bob"), + ("Alice and Bob", "Alice", "Bob"), + ("Alice With Bob", "Alice", "Bob"), + ("Alice defeat Bob", "Alice", None), + ("Alice & Bob", "Bob", "Alice"), + ("Alice ft. Bob", "Bob", "Alice"), + ("Alice ft. Carol", "Bob", "Carol"), + ], +) +def test_find_feat_part( + artist: str, + albumartist: str, + expected: Optional[str], +) -> None: + assert ftintitle.find_feat_part(artist, albumartist) == expected + + +@pytest.mark.parametrize( + "input,expected", + [ + ("Alice ft. Bob", ("Alice", "Bob")), + ("Alice feat Bob", ("Alice", "Bob")), + ("Alice feat. Bob", ("Alice", "Bob")), + ("Alice featuring Bob", ("Alice", "Bob")), + ("Alice & Bob", ("Alice", "Bob")), + ("Alice and Bob", ("Alice", "Bob")), + ("Alice With Bob", ("Alice", "Bob")), + ("Alice defeat Bob", ("Alice defeat Bob", None)), + ], +) +def test_split_on_feat( + input: str, + expected: tuple[str, Optional[str]], +) -> None: + assert ftintitle.split_on_feat(input) == expected + + +@pytest.mark.parametrize( + "input,expected", + [ + ("Alice ft. Bob", True), + ("Alice feat. Bob", True), + ("Alice feat Bob", True), + ("Alice featuring Bob", True), + ("Alice (ft. Bob)", True), + ("Alice (feat. Bob)", True), + ("Alice [ft. Bob]", True), + ("Alice [feat. Bob]", True), + ("Alice defeat Bob", False), + ("Aliceft.Bob", False), + ("Alice (defeat Bob)", False), + ("Live and Let Go", False), + ("Come With Me", False), + ], +) +def test_contains_feat(input: str, expected: bool) -> None: + assert ftintitle.contains_feat(input) is expected From 042b5d64ebceb22cbcf5dbdf1745b7d9f0125b80 Mon Sep 17 00:00:00 2001 From: Trey Turner <treyturner@users.noreply.github.com> Date: Thu, 18 Sep 2025 23:18:01 -0500 Subject: [PATCH 141/301] test(ftintitle): fix flake, massage mypy --- test/plugins/test_ftintitle.py | 42 ++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 2b560e01c..005318b11 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -14,8 +14,8 @@ """Tests for the 'ftintitle' plugin.""" -from typing import Dict, Optional -from _pytest.fixtures import SubRequest +from typing import Dict, Generator, Optional, Tuple, Union + import pytest from beets.library.models import Item @@ -28,15 +28,17 @@ class FtInTitlePluginFunctional(PluginTestCase): @pytest.fixture -def env(request: SubRequest) -> FtInTitlePluginFunctional: +def env() -> Generator[FtInTitlePluginFunctional, None, None]: case = FtInTitlePluginFunctional(methodName="runTest") case.setUp() - request.addfinalizer(case.tearDown) - return case + try: + yield case + finally: + case.tearDown() def set_config( - env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, str | bool]] + env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, Union[str, bool]]] ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -53,7 +55,7 @@ def add_item( path: str, artist: str, title: str, - albumartist: str, + albumartist: Optional[str], ) -> Item: return env.add_item( path=path, @@ -65,7 +67,7 @@ def add_item( @pytest.mark.parametrize( - "cfg, cmd_args, input, expected", + "cfg, cmd_args, given, expected", [ pytest.param( None, @@ -172,15 +174,15 @@ def add_item( ) def test_ftintitle_functional( env: FtInTitlePluginFunctional, - cfg: Optional[Dict[str, str | bool]], - cmd_args: tuple[str, ...], - input: tuple[str, str, str], - expected: tuple[str, str], + cfg: Optional[Dict[str, Union[str, bool]]], + cmd_args: Tuple[str, ...], + given: Tuple[str, str, Optional[str]], + expected: Tuple[str, str], ) -> None: set_config(env, cfg) ftintitle.FtInTitlePlugin() - artist, title, albumartist = input + artist, title, albumartist = given item = add_item(env, "/", artist, title, albumartist) env.run_command(*cmd_args) @@ -215,7 +217,7 @@ def test_find_feat_part( @pytest.mark.parametrize( - "input,expected", + "given,expected", [ ("Alice ft. Bob", ("Alice", "Bob")), ("Alice feat Bob", ("Alice", "Bob")), @@ -228,14 +230,14 @@ def test_find_feat_part( ], ) def test_split_on_feat( - input: str, - expected: tuple[str, Optional[str]], + given: str, + expected: Tuple[str, Optional[str]], ) -> None: - assert ftintitle.split_on_feat(input) == expected + assert ftintitle.split_on_feat(given) == expected @pytest.mark.parametrize( - "input,expected", + "given,expected", [ ("Alice ft. Bob", True), ("Alice feat. Bob", True), @@ -252,5 +254,5 @@ def test_split_on_feat( ("Come With Me", False), ], ) -def test_contains_feat(input: str, expected: bool) -> None: - assert ftintitle.contains_feat(input) is expected +def test_contains_feat(given: str, expected: bool) -> None: + assert ftintitle.contains_feat(given) is expected From 8e644157e87c970bca3140075b022d0ac3a377bb Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer <henryoberholtzer@gmail.com> Date: Mon, 22 Sep 2025 19:47:50 +0200 Subject: [PATCH 142/301] Refactor tests, adjust changelog, move config option to new features. --- docs/changelog.rst | 11 ++--- test/plugins/test_discogs.py | 89 +++++++++++++----------------------- 2 files changed, 38 insertions(+), 62 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b182a6cba..8a16399f9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,8 @@ New features: - :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes without storing or writing them. +- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle + stripping discogs numeric disambiguation on artist and label fields. Bug fixes: @@ -21,12 +23,8 @@ Bug fixes: - :doc:`plugins/spotify` Removed old and undocumented config options `artist_field`, `album_field` and `track` that were causing issues with track matching. :bug:`5189` -- :doc:`plugins/discogs` Added config option `strip_disambiguation` to allow - choice of removing discogs numeric disambiguation :bug:`5366` - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` -- :doc:`plugins/discogs` Wrote test coverage for removing disambiguation. - :bug:`5366` For packagers: @@ -40,8 +38,9 @@ Other changes: possibly disabled musicbrainz plugin :bug:`6020` - Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it is not used in the core beets codebase. It can now be found in - ``beetsplug._utils``. - :class:`beets.metadata_plugin.MetadataSourcePlugin`: - Remove discogs specific disambiguation stripping + ``beetsplug._utils``. +- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific + disambiguation stripping. 2.4.0 (September 13, 2025) -------------------------- diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 5fe73dcac..92301380e 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -374,38 +374,26 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.genre == "GENRE1, GENRE2" assert d.style is None - def test_strip_disambiguation_label_artist(self): - """Test removing discogs disambiguation from artist and label""" + def test_strip_disambiguation(self): + """Test removing disambiguation from all disambiguated fields.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [self._make_track("A", "1", "01:01")], - "artists": [{"name": "ARTIST NAME (2)", "id": 321, "join": ""}], - "title": "TITLE", - "labels": [ + "tracklist": [ { - "name": "LABEL NAME (5)", - "catno": "CATALOG NUMBER", + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [ + {"name": "TEST ARTIST (5)", "tracks": "", "id": 11146} + ], } ], - } - release = Bag( - data=data, - title=data["title"], - artists=[Bag(data=d) for d in data["artists"]], - ) - d = DiscogsPlugin().get_album_info(release) - assert d.artist == "ARTIST NAME" - assert d.label == "LABEL NAME" - - def test_strip_disambiguation_off_label_artist(self): - """Test not removing discogs disambiguation from artist and label""" - config["discogs"]["strip_disambiguation"] = False - data = { - "id": 123, - "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [self._make_track("a", "1", "01:01")], - "artists": [{"name": "ARTIST NAME (2)", "id": 321, "join": ""}], + "artists": [ + {"name": "ARTIST NAME (2)", "id": 321, "join": "&"}, + {"name": "OTHER ARTIST (5)", "id": 321, "join": ""}, + ], "title": "title", "labels": [ { @@ -420,30 +408,13 @@ class DGAlbumInfoTest(BeetsTestCase): artists=[Bag(data=d) for d in data["artists"]], ) d = DiscogsPlugin().get_album_info(release) - assert d.artist == "ARTIST NAME (2)" - assert d.label == "LABEL NAME (5)" - - def test_strip_disambiguation_multiple_artists(self): - """Test removing disambiguation if there are multiple artists on the release""" - data = { - "id": 123, - "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [self._make_track("a", "1", "01:01")], - "artists": [ - {"name": "ARTIST NAME (2)", "id": 321, "join": "&"}, - {"name": "OTHER ARTIST (5)", "id": 321, "join": ""}, - ], - "title": "title", - } - release = Bag( - data=data, - title=data["title"], - artists=[Bag(data=d) for d in data["artists"]], - ) - d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME & OTHER ARTIST" + assert d.tracks[0].artist == "TEST ARTIST" + assert d.label == "LABEL NAME" - def test_strip_disambiguation_artist_tracks(self): + def test_strip_disambiguation_false(self): + """Test disabling disambiguation removal from all disambiguated fields.""" + config["discogs"]["strip_disambiguation"] = False data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", @@ -454,16 +425,21 @@ class DGAlbumInfoTest(BeetsTestCase): "type_": "track", "duration": "5:44", "artists": [ - { - "name": "TEST ARTIST (5)", - "tracks": "", - "id": 11146, - } + {"name": "TEST ARTIST (5)", "tracks": "", "id": 11146} ], } ], - "artists": [{"name": "OTHER ARTIST (5)", "id": 321, "join": ""}], + "artists": [ + {"name": "ARTIST NAME (2)", "id": 321, "join": "&"}, + {"name": "OTHER ARTIST (5)", "id": 321, "join": ""}, + ], "title": "title", + "labels": [ + { + "name": "LABEL NAME (5)", + "catno": "catalog number", + } + ], } release = Bag( data=data, @@ -471,8 +447,9 @@ class DGAlbumInfoTest(BeetsTestCase): artists=[Bag(data=d) for d in data["artists"]], ) d = DiscogsPlugin().get_album_info(release) - assert d.tracks[0].artist == "TEST ARTIST" - assert d.artist == "OTHER ARTIST" + assert d.artist == "ARTIST NAME (2) & OTHER ARTIST (5)" + assert d.tracks[0].artist == "TEST ARTIST (5)" + assert d.label == "LABEL NAME (5)" @pytest.mark.parametrize( From 84e52e1b4a4a73151f29d3a99da857dfa0c96583 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Mon, 22 Sep 2025 20:49:53 -0700 Subject: [PATCH 143/301] Test written, beginning fix --- beetsplug/discogs.py | 1 + test/plugins/test_discogs.py | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index f22ea2274..caefbe64a 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -652,6 +652,7 @@ class DiscogsPlugin(MetadataSourcePlugin): ) artist = self.strip_disambiguation(artist) length = self.get_track_length(track["duration"]) + featured = map(lambda artist: artist["name"] if artist["role"].find("Featuring") else "", track.get("extraartists", [])) return TrackInfo( title=title, track_id=track_id, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 92301380e..84573d337 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -451,6 +451,63 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.tracks[0].artist == "TEST ARTIST (5)" assert d.label == "LABEL NAME (5)" +@pytest.mark.parametrize("track, expected_artist", + [({ + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [{ + "name": "MUSICIAN", + "role": "Featuring", + }] + }, + "ARTIST feat. MUSICIAN" + ), + ({ + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [{ + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, { + "name": "MUSICIAN", + "role": "Featuring", + }] + }, + "ARTIST feat. PERFORMER & MUSICIAN" + ), + ({ + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "artist": "NEW ARTIST", + "extraartists": [{ + "name": "SOLIST", + "role": "Featuring", + }, { + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, { + "name": "RANDOM", + "role": "Written-By", + }, { + "name": "MUSICIAN", + "role": "Featuring", + }] + }, + "NEW ARTIST feat. SOLIST, PERFORMER & MUSICIAN" + )]) +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +def test_parse_featured_artists(track, expected_artist): + """ Tests the plugins ability to parse a featured artist. + Initial check with one featured artist, two featured artists, + and three. Ignores artists that are not listed as featured.""" + t = DiscogsPlugin().get_track_info(track, 1, 1) + assert t.artist == expected_artist @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", From 2bf411e77d9d5feb077c2d86ad48c1bfc3daeade Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Tue, 23 Sep 2025 10:11:13 -0700 Subject: [PATCH 144/301] Featured artists extracted and appended, need to see if join needs to be variable --- beetsplug/discogs.py | 20 +++++++++++++++----- test/plugins/test_discogs.py | 13 ++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index caefbe64a..43a501a22 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -339,7 +339,7 @@ class DiscogsPlugin(MetadataSourcePlugin): # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. - tracks = self.get_tracks(result.data["tracklist"]) + tracks = self.get_tracks(result.data["tracklist"], artist, artist_id) # Extract information for the optional AlbumInfo fields, if possible. va = result.data["artists"][0].get("name", "").lower() == "various" @@ -446,7 +446,7 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def get_tracks(self, tracklist): + def get_tracks(self, tracklist, album_artist, album_artist_id): """Returns a list of TrackInfo objects for a discogs tracklist.""" try: clean_tracklist = self.coalesce_tracks(tracklist) @@ -471,7 +471,8 @@ class DiscogsPlugin(MetadataSourcePlugin): # divisions. divisions += next_divisions del next_divisions[:] - track_info = self.get_track_info(track, index, divisions) + track_info = self.get_track_info(track, index, divisions, + album_artist, album_artist_id) track_info.track_alt = track["position"] tracks.append(track_info) else: @@ -638,7 +639,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) - def get_track_info(self, track, index, divisions): + def get_track_info(self, track, index, divisions, album_artist, album_artist_id): """Returns a TrackInfo object for a discogs track.""" title = track["title"] if self.config["index_tracks"]: @@ -650,9 +651,18 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_id = self.get_artist( track.get("artists", []), join_key="join" ) + # If no artist and artist is returned, set to match album artist + if not artist: + artist = album_artist + artist_id = album_artist_id artist = self.strip_disambiguation(artist) length = self.get_track_length(track["duration"]) - featured = map(lambda artist: artist["name"] if artist["role"].find("Featuring") else "", track.get("extraartists", [])) + # Add featured artists + extraartists = track.get("extraartists", []) + featured = [ + artist["name"] for artist in extraartists if artist["role"].find("Featuring") != -1] + if featured: + artist = f"{artist} feat. {', '.join(featured)}" return TrackInfo( title=title, track_id=track_id, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 84573d337..19428b125 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -477,16 +477,19 @@ class DGAlbumInfoTest(BeetsTestCase): "role": "Featuring", }] }, - "ARTIST feat. PERFORMER & MUSICIAN" + "ARTIST feat. PERFORMER, MUSICIAN" ), ({ "type_": "track", "title": "track", "position": "1", "duration": "5:00", - "artist": "NEW ARTIST", + "artists": [{ + "name": "NEW ARTIST", + "tracks": "", + "id": 11146}], "extraartists": [{ - "name": "SOLIST", + "name": "SOLOIST", "role": "Featuring", }, { "name": "PERFORMER", @@ -499,14 +502,14 @@ class DGAlbumInfoTest(BeetsTestCase): "role": "Featuring", }] }, - "NEW ARTIST feat. SOLIST, PERFORMER & MUSICIAN" + "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN" )]) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_parse_featured_artists(track, expected_artist): """ Tests the plugins ability to parse a featured artist. Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" - t = DiscogsPlugin().get_track_info(track, 1, 1) + t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2) assert t.artist == expected_artist @pytest.mark.parametrize( From 6aba11d4a0fa85163607218853549004187969ae Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Tue, 23 Sep 2025 11:05:48 -0700 Subject: [PATCH 145/301] testing, updated changelog --- beetsplug/discogs.py | 18 ++--- docs/changelog.rst | 2 + test/plugins/test_discogs.py | 126 ++++++++++++++++++++--------------- 3 files changed, 83 insertions(+), 63 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 43a501a22..c8ce43a1e 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -386,10 +386,6 @@ class DiscogsPlugin(MetadataSourcePlugin): for track in tracks: track.media = media track.medium_total = mediums.count(track.medium) - if not track.artist: # get_track_info often fails to find artist - track.artist = artist - if not track.artist_id: - track.artist_id = artist_id # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. track.track_id = f"{album_id}-{track.track_alt}" @@ -471,8 +467,9 @@ class DiscogsPlugin(MetadataSourcePlugin): # divisions. divisions += next_divisions del next_divisions[:] - track_info = self.get_track_info(track, index, divisions, - album_artist, album_artist_id) + track_info = self.get_track_info( + track, index, divisions, album_artist, album_artist_id + ) track_info.track_alt = track["position"] tracks.append(track_info) else: @@ -639,7 +636,9 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) - def get_track_info(self, track, index, divisions, album_artist, album_artist_id): + def get_track_info( + self, track, index, divisions, album_artist, album_artist_id + ): """Returns a TrackInfo object for a discogs track.""" title = track["title"] if self.config["index_tracks"]: @@ -660,7 +659,10 @@ class DiscogsPlugin(MetadataSourcePlugin): # Add featured artists extraartists = track.get("extraartists", []) featured = [ - artist["name"] for artist in extraartists if artist["role"].find("Featuring") != -1] + artist["name"] + for artist in extraartists + if artist["role"].find("Featuring") != -1 + ] if featured: artist = f"{artist} feat. {', '.join(featured)}" return TrackInfo( diff --git a/docs/changelog.rst b/docs/changelog.rst index 6c81a0d08..a5393e032 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,8 @@ Bug fixes: matching. :bug:`5189` - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` +- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the + extraartists field. For packagers: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 19428b125..562bda88f 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -451,67 +451,83 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.tracks[0].artist == "TEST ARTIST (5)" assert d.label == "LABEL NAME (5)" -@pytest.mark.parametrize("track, expected_artist", - [({ - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "extraartists": [{ - "name": "MUSICIAN", - "role": "Featuring", - }] - }, - "ARTIST feat. MUSICIAN" - ), - ({ - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "extraartists": [{ - "name": "PERFORMER", - "role": "Other Role, Featuring", - }, { - "name": "MUSICIAN", - "role": "Featuring", - }] - }, - "ARTIST feat. PERFORMER, MUSICIAN" - ), - ({ - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "artists": [{ - "name": "NEW ARTIST", - "tracks": "", - "id": 11146}], - "extraartists": [{ - "name": "SOLOIST", - "role": "Featuring", - }, { - "name": "PERFORMER", - "role": "Other Role, Featuring", - }, { - "name": "RANDOM", - "role": "Written-By", - }, { - "name": "MUSICIAN", - "role": "Featuring", - }] - }, - "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN" - )]) + +@pytest.mark.parametrize( + "track, expected_artist", + [ + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [ + { + "name": "MUSICIAN", + "role": "Featuring", + } + ], + }, + "ARTIST feat. MUSICIAN", + ), + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [ + { + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, + { + "name": "MUSICIAN", + "role": "Featuring", + }, + ], + }, + "ARTIST feat. PERFORMER, MUSICIAN", + ), + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "artists": [{"name": "NEW ARTIST", "tracks": "", "id": 11146}], + "extraartists": [ + { + "name": "SOLOIST", + "role": "Featuring", + }, + { + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, + { + "name": "RANDOM", + "role": "Written-By", + }, + { + "name": "MUSICIAN", + "role": "Featuring", + }, + ], + }, + "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN", + ), + ], +) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_parse_featured_artists(track, expected_artist): - """ Tests the plugins ability to parse a featured artist. - Initial check with one featured artist, two featured artists, + """Tests the plugins ability to parse a featured artist. + Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2) assert t.artist == expected_artist + @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", [ From 61b632f2b428e7e2602b468526272775c83b6690 Mon Sep 17 00:00:00 2001 From: Finn <finn@cnwr.net> Date: Tue, 23 Sep 2025 09:53:46 -0400 Subject: [PATCH 146/301] Add option to not write metadata --- beetsplug/convert.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 102976dd7..e72f8c75a 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -122,6 +122,7 @@ class ConvertPlugin(BeetsPlugin): "threads": os.cpu_count(), "format": "mp3", "id3v23": "inherit", + "write_metadata": True, "formats": { "aac": { "command": ( @@ -446,8 +447,9 @@ class ConvertPlugin(BeetsPlugin): if id3v23 == "inherit": id3v23 = None - # Write tags from the database to the converted file. - item.try_write(path=converted, id3v23=id3v23) + # Write tags from the database to the file if requested + if self.config["write_metadata"].get(bool): + item.try_write(path=converted, id3v23=id3v23) if keep_new: # If we're keeping the transcoded file, read it again (after From 98170f6c04e05f85ccb76eb6e1e741b6f16f4470 Mon Sep 17 00:00:00 2001 From: Multipixelone <finn@cnwr.net> Date: Tue, 23 Sep 2025 19:07:13 -0400 Subject: [PATCH 147/301] add documentation for write_metadata option --- docs/changelog.rst | 1 + docs/plugins/convert.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index c2d6ea1cf..cb10c5810 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ New features: - :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes without storing or writing them. +- :doc:`plugins/convert`: Add a config option to disable writing metadata to converted files. Bug fixes: diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 8917422c5..5cc2ace3f 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -97,6 +97,8 @@ The available options are: - **embed**: Embed album art in converted items. Default: ``yes``. - **id3v23**: Can be used to override the global ``id3v23`` option. Default: ``inherit``. +- **write_metadata**: Can be used to disable writing metadata to converted files. Default: + ``true``. - **max_bitrate**: By default, the plugin does not transcode files that are already in the destination format. This option instead also transcodes files with high bitrates, even if they are already in the same format as the output. From 699e0a12723f65dd6cefefe75dff52dadc1b90ab Mon Sep 17 00:00:00 2001 From: Multipixelone <finn@cnwr.net> Date: Wed, 24 Sep 2025 22:11:47 -0400 Subject: [PATCH 148/301] fixup! add documentation for write_metadata option --- docs/changelog.rst | 3 ++- docs/plugins/convert.rst | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fd3d41f3f..092a1a5a0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,8 @@ New features: - :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes without storing or writing them. -- :doc:`plugins/convert`: Add a config option to disable writing metadata to converted files. +- :doc:`plugins/convert`: Add a config option to disable writing metadata to + converted files. - :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle stripping discogs numeric disambiguation on artist and label fields. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 5cc2ace3f..ecf60a85b 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -97,8 +97,8 @@ The available options are: - **embed**: Embed album art in converted items. Default: ``yes``. - **id3v23**: Can be used to override the global ``id3v23`` option. Default: ``inherit``. -- **write_metadata**: Can be used to disable writing metadata to converted files. Default: - ``true``. +- **write_metadata**: Can be used to disable writing metadata to converted + files. Default: ``true``. - **max_bitrate**: By default, the plugin does not transcode files that are already in the destination format. This option instead also transcodes files with high bitrates, even if they are already in the same format as the output. From 5c036728743743f5abe5fcc893e22c9df5457232 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Mon, 22 Sep 2025 20:49:53 -0700 Subject: [PATCH 149/301] Test written, beginning fix --- beetsplug/discogs.py | 1 + test/plugins/test_discogs.py | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index f22ea2274..caefbe64a 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -652,6 +652,7 @@ class DiscogsPlugin(MetadataSourcePlugin): ) artist = self.strip_disambiguation(artist) length = self.get_track_length(track["duration"]) + featured = map(lambda artist: artist["name"] if artist["role"].find("Featuring") else "", track.get("extraartists", [])) return TrackInfo( title=title, track_id=track_id, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 92301380e..84573d337 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -451,6 +451,63 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.tracks[0].artist == "TEST ARTIST (5)" assert d.label == "LABEL NAME (5)" +@pytest.mark.parametrize("track, expected_artist", + [({ + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [{ + "name": "MUSICIAN", + "role": "Featuring", + }] + }, + "ARTIST feat. MUSICIAN" + ), + ({ + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [{ + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, { + "name": "MUSICIAN", + "role": "Featuring", + }] + }, + "ARTIST feat. PERFORMER & MUSICIAN" + ), + ({ + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "artist": "NEW ARTIST", + "extraartists": [{ + "name": "SOLIST", + "role": "Featuring", + }, { + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, { + "name": "RANDOM", + "role": "Written-By", + }, { + "name": "MUSICIAN", + "role": "Featuring", + }] + }, + "NEW ARTIST feat. SOLIST, PERFORMER & MUSICIAN" + )]) +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +def test_parse_featured_artists(track, expected_artist): + """ Tests the plugins ability to parse a featured artist. + Initial check with one featured artist, two featured artists, + and three. Ignores artists that are not listed as featured.""" + t = DiscogsPlugin().get_track_info(track, 1, 1) + assert t.artist == expected_artist @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", From 876c57c8b3eee4241b3af9e191a4f518fa2ca9b5 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Tue, 23 Sep 2025 10:11:13 -0700 Subject: [PATCH 150/301] Featured artists extracted and appended, need to see if join needs to be variable --- beetsplug/discogs.py | 20 +++++++++++++++----- test/plugins/test_discogs.py | 13 ++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index caefbe64a..43a501a22 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -339,7 +339,7 @@ class DiscogsPlugin(MetadataSourcePlugin): # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. - tracks = self.get_tracks(result.data["tracklist"]) + tracks = self.get_tracks(result.data["tracklist"], artist, artist_id) # Extract information for the optional AlbumInfo fields, if possible. va = result.data["artists"][0].get("name", "").lower() == "various" @@ -446,7 +446,7 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def get_tracks(self, tracklist): + def get_tracks(self, tracklist, album_artist, album_artist_id): """Returns a list of TrackInfo objects for a discogs tracklist.""" try: clean_tracklist = self.coalesce_tracks(tracklist) @@ -471,7 +471,8 @@ class DiscogsPlugin(MetadataSourcePlugin): # divisions. divisions += next_divisions del next_divisions[:] - track_info = self.get_track_info(track, index, divisions) + track_info = self.get_track_info(track, index, divisions, + album_artist, album_artist_id) track_info.track_alt = track["position"] tracks.append(track_info) else: @@ -638,7 +639,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) - def get_track_info(self, track, index, divisions): + def get_track_info(self, track, index, divisions, album_artist, album_artist_id): """Returns a TrackInfo object for a discogs track.""" title = track["title"] if self.config["index_tracks"]: @@ -650,9 +651,18 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_id = self.get_artist( track.get("artists", []), join_key="join" ) + # If no artist and artist is returned, set to match album artist + if not artist: + artist = album_artist + artist_id = album_artist_id artist = self.strip_disambiguation(artist) length = self.get_track_length(track["duration"]) - featured = map(lambda artist: artist["name"] if artist["role"].find("Featuring") else "", track.get("extraartists", [])) + # Add featured artists + extraartists = track.get("extraartists", []) + featured = [ + artist["name"] for artist in extraartists if artist["role"].find("Featuring") != -1] + if featured: + artist = f"{artist} feat. {', '.join(featured)}" return TrackInfo( title=title, track_id=track_id, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 84573d337..19428b125 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -477,16 +477,19 @@ class DGAlbumInfoTest(BeetsTestCase): "role": "Featuring", }] }, - "ARTIST feat. PERFORMER & MUSICIAN" + "ARTIST feat. PERFORMER, MUSICIAN" ), ({ "type_": "track", "title": "track", "position": "1", "duration": "5:00", - "artist": "NEW ARTIST", + "artists": [{ + "name": "NEW ARTIST", + "tracks": "", + "id": 11146}], "extraartists": [{ - "name": "SOLIST", + "name": "SOLOIST", "role": "Featuring", }, { "name": "PERFORMER", @@ -499,14 +502,14 @@ class DGAlbumInfoTest(BeetsTestCase): "role": "Featuring", }] }, - "NEW ARTIST feat. SOLIST, PERFORMER & MUSICIAN" + "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN" )]) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_parse_featured_artists(track, expected_artist): """ Tests the plugins ability to parse a featured artist. Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" - t = DiscogsPlugin().get_track_info(track, 1, 1) + t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2) assert t.artist == expected_artist @pytest.mark.parametrize( From 43f2d423fa84c5d82092d69a39cdbec17e1a28bb Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Tue, 23 Sep 2025 11:05:48 -0700 Subject: [PATCH 151/301] testing, updated changelog --- beetsplug/discogs.py | 18 ++--- docs/changelog.rst | 2 + test/plugins/test_discogs.py | 126 ++++++++++++++++++++--------------- 3 files changed, 83 insertions(+), 63 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 43a501a22..c8ce43a1e 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -386,10 +386,6 @@ class DiscogsPlugin(MetadataSourcePlugin): for track in tracks: track.media = media track.medium_total = mediums.count(track.medium) - if not track.artist: # get_track_info often fails to find artist - track.artist = artist - if not track.artist_id: - track.artist_id = artist_id # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. track.track_id = f"{album_id}-{track.track_alt}" @@ -471,8 +467,9 @@ class DiscogsPlugin(MetadataSourcePlugin): # divisions. divisions += next_divisions del next_divisions[:] - track_info = self.get_track_info(track, index, divisions, - album_artist, album_artist_id) + track_info = self.get_track_info( + track, index, divisions, album_artist, album_artist_id + ) track_info.track_alt = track["position"] tracks.append(track_info) else: @@ -639,7 +636,9 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) - def get_track_info(self, track, index, divisions, album_artist, album_artist_id): + def get_track_info( + self, track, index, divisions, album_artist, album_artist_id + ): """Returns a TrackInfo object for a discogs track.""" title = track["title"] if self.config["index_tracks"]: @@ -660,7 +659,10 @@ class DiscogsPlugin(MetadataSourcePlugin): # Add featured artists extraartists = track.get("extraartists", []) featured = [ - artist["name"] for artist in extraartists if artist["role"].find("Featuring") != -1] + artist["name"] + for artist in extraartists + if artist["role"].find("Featuring") != -1 + ] if featured: artist = f"{artist} feat. {', '.join(featured)}" return TrackInfo( diff --git a/docs/changelog.rst b/docs/changelog.rst index 092a1a5a0..1ae13303e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ Bug fixes: matching. :bug:`5189` - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` +- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the + extraartists field. For packagers: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 19428b125..562bda88f 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -451,67 +451,83 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.tracks[0].artist == "TEST ARTIST (5)" assert d.label == "LABEL NAME (5)" -@pytest.mark.parametrize("track, expected_artist", - [({ - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "extraartists": [{ - "name": "MUSICIAN", - "role": "Featuring", - }] - }, - "ARTIST feat. MUSICIAN" - ), - ({ - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "extraartists": [{ - "name": "PERFORMER", - "role": "Other Role, Featuring", - }, { - "name": "MUSICIAN", - "role": "Featuring", - }] - }, - "ARTIST feat. PERFORMER, MUSICIAN" - ), - ({ - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "artists": [{ - "name": "NEW ARTIST", - "tracks": "", - "id": 11146}], - "extraartists": [{ - "name": "SOLOIST", - "role": "Featuring", - }, { - "name": "PERFORMER", - "role": "Other Role, Featuring", - }, { - "name": "RANDOM", - "role": "Written-By", - }, { - "name": "MUSICIAN", - "role": "Featuring", - }] - }, - "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN" - )]) + +@pytest.mark.parametrize( + "track, expected_artist", + [ + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [ + { + "name": "MUSICIAN", + "role": "Featuring", + } + ], + }, + "ARTIST feat. MUSICIAN", + ), + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "extraartists": [ + { + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, + { + "name": "MUSICIAN", + "role": "Featuring", + }, + ], + }, + "ARTIST feat. PERFORMER, MUSICIAN", + ), + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "artists": [{"name": "NEW ARTIST", "tracks": "", "id": 11146}], + "extraartists": [ + { + "name": "SOLOIST", + "role": "Featuring", + }, + { + "name": "PERFORMER", + "role": "Other Role, Featuring", + }, + { + "name": "RANDOM", + "role": "Written-By", + }, + { + "name": "MUSICIAN", + "role": "Featuring", + }, + ], + }, + "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN", + ), + ], +) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_parse_featured_artists(track, expected_artist): - """ Tests the plugins ability to parse a featured artist. - Initial check with one featured artist, two featured artists, + """Tests the plugins ability to parse a featured artist. + Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2) assert t.artist == expected_artist + @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", [ From b61306ea0d3df2b8644e932c292d27924b432ff6 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Thu, 25 Sep 2025 08:39:38 -0700 Subject: [PATCH 152/301] Fixes, test improvement, rebase to master --- beetsplug/discogs.py | 4 ++-- docs/changelog.rst | 3 +-- test/plugins/test_discogs.py | 44 ++++++------------------------------ 3 files changed, 10 insertions(+), 41 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index c8ce43a1e..3dc962464 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -654,17 +654,17 @@ class DiscogsPlugin(MetadataSourcePlugin): if not artist: artist = album_artist artist_id = album_artist_id - artist = self.strip_disambiguation(artist) length = self.get_track_length(track["duration"]) # Add featured artists extraartists = track.get("extraartists", []) featured = [ artist["name"] for artist in extraartists - if artist["role"].find("Featuring") != -1 + if "Featuring" in artist["role"] ] if featured: artist = f"{artist} feat. {', '.join(featured)}" + artist = self.strip_disambiguation(artist) return TrackInfo( title=title, track_id=track_id, diff --git a/docs/changelog.rst b/docs/changelog.rst index 1ae13303e..34ead44af 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,7 @@ New features: converted files. - :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle stripping discogs numeric disambiguation on artist and label fields. +- :doc:`plugins/discogs` Added support for featured artists. Bug fixes: @@ -27,8 +28,6 @@ Bug fixes: matching. :bug:`5189` - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` -- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the - extraartists field. For packagers: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 562bda88f..3652c0a54 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -450,6 +450,7 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.artist == "ARTIST NAME (2) & OTHER ARTIST (5)" assert d.tracks[0].artist == "TEST ARTIST (5)" assert d.label == "LABEL NAME (5)" + config["discogs"]["strip_disambiguation"] = True @pytest.mark.parametrize( @@ -461,48 +462,17 @@ class DGAlbumInfoTest(BeetsTestCase): "title": "track", "position": "1", "duration": "5:00", - "extraartists": [ - { - "name": "MUSICIAN", - "role": "Featuring", - } + "artists": [ + {"name": "NEW ARTIST", "tracks": "", "id": 11146}, + {"name": "VOCALIST", "tracks": "", "id": 344, "join": "&"}, ], - }, - "ARTIST feat. MUSICIAN", - ), - ( - { - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "extraartists": [ - { - "name": "PERFORMER", - "role": "Other Role, Featuring", - }, - { - "name": "MUSICIAN", - "role": "Featuring", - }, - ], - }, - "ARTIST feat. PERFORMER, MUSICIAN", - ), - ( - { - "type_": "track", - "title": "track", - "position": "1", - "duration": "5:00", - "artists": [{"name": "NEW ARTIST", "tracks": "", "id": 11146}], "extraartists": [ { "name": "SOLOIST", "role": "Featuring", }, { - "name": "PERFORMER", + "name": "PERFORMER (1)", "role": "Other Role, Featuring", }, { @@ -511,11 +481,11 @@ class DGAlbumInfoTest(BeetsTestCase): }, { "name": "MUSICIAN", - "role": "Featuring", + "role": "Featuring [Uncredited]", }, ], }, - "NEW ARTIST feat. SOLOIST, PERFORMER, MUSICIAN", + "NEW ARTIST, VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN", ), ], ) From 9c8172be12fa13973f0f3ca8cd5f538ba0b1f38f Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer <henryoberholtzer@gmail.com> Date: Fri, 26 Sep 2025 01:55:28 +0200 Subject: [PATCH 153/301] Write initial ANV test --- test/plugins/test_discogs.py | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 3652c0a54..a93c0da0b 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -520,6 +520,103 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): assert result == (expected_media, expected_albumtype) +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +class DGTestAnv(BeetsTestCase): + @pytest.mark.parametrize( + "config_input, expected_output", + [ + ({ + "track_artist": False, + "album_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTIST NAME & SOLOIST" + "album_artist_credit": "ARTIST NAME & SOLOIST" + "track_arist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + }), + ({ + "album_artist": False, + "track_artist": True, + "artist_credit": False + }, + { + "album_artist": "ARTIST NAME & SOLOIST" + "album_artist_credit": "ARTIST NAME & SOLOIST" + "track_artist": "ARTY Feat. FORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + }), + ({ + "album_artist": True, + "track_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTY & SOLO" + "album_artist_credit": "ARTIST NAME & SOLOIST" + "track_arist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + }), + ({ + "album_artist": True, + "track_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTY & SOLO" + "album_artist_credit": "ARTIST & SOLOIST" + "track_arist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + }) + ({ + "album_artist": False, + "track_artist": False, + "artist_credit": True + }, + { + "album_artist": "ARTIST & SOLOIST" + "album_artist_credit": "ARTY & SOLO" + "track_arist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTY Feat. FORMER" + }) + ]) + def test_use_anv(self, config_input, expected_output): + config["discogs"]["album_artist_anv"] = config_input["album_artist"] + config["discogs"]["track_artist_anv"] = config_input["track_artist"] + config["discogs"]["artist_credit_anv"] = config_input["artist_credit"] + release = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [ + { + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [ + {"name": "ARTIST", "tracks": "", "anv": "ARTY", "id": 11146} + ], + "extraartists": [ + {"name": "PERFORMER", "role": "Featuring", "tracks": "", "anv": "FORMER", "id": 787} + ], + } + ], + "artists": [ + {"name": "ARTIST NAME", "anv": "ARTY", "id": 321, "join": "&"}, + {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, + ], + "title": "title", + } + d = DiscogsPlugin().get_album_info(release) + assert d.artist == expected_output["album_artist"] + assert d.artist_credit == expected_output["album_artist_credit"] + assert d.tracks[0].artist == expected_output["track_artist"] + assert d.tracks[0].artist_credit == expected_output["track_artist_credit"] + config["discogs"]["album_artist_anv"] = False + config["discogs"]["track_artist_anv"] = False + config["discogs"]["artist_credit_anv"] = False + @pytest.mark.parametrize( "position, medium, index, subindex", From 533aa6379bb3c5aa6e92da6d3d51f34c2f4979f4 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Thu, 25 Sep 2025 22:38:02 -0700 Subject: [PATCH 154/301] Test working, need to implement anv now --- beetsplug/discogs.py | 2 + test/plugins/test_discogs.py | 198 ++++++++++++++++++----------------- 2 files changed, 104 insertions(+), 96 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 3dc962464..ee432f11f 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -402,6 +402,7 @@ class DiscogsPlugin(MetadataSourcePlugin): album=album, album_id=album_id, artist=artist, + artist_credit=artist, artist_id=artist_id, tracks=tracks, albumtype=albumtype, @@ -668,6 +669,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return TrackInfo( title=title, track_id=track_id, + artist_credit=artist, artist=artist, artist_id=artist_id, length=length, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index a93c0da0b..a7d1c8407 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -521,102 +521,108 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): assert result == (expected_media, expected_albumtype) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) -class DGTestAnv(BeetsTestCase): - @pytest.mark.parametrize( - "config_input, expected_output", - [ - ({ - "track_artist": False, - "album_artist": False, - "artist_credit": False - }, - { - "album_artist": "ARTIST NAME & SOLOIST" - "album_artist_credit": "ARTIST NAME & SOLOIST" - "track_arist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - }), - ({ - "album_artist": False, - "track_artist": True, - "artist_credit": False - }, - { - "album_artist": "ARTIST NAME & SOLOIST" - "album_artist_credit": "ARTIST NAME & SOLOIST" - "track_artist": "ARTY Feat. FORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - }), - ({ - "album_artist": True, - "track_artist": False, - "artist_credit": False - }, - { - "album_artist": "ARTY & SOLO" - "album_artist_credit": "ARTIST NAME & SOLOIST" - "track_arist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - }), - ({ - "album_artist": True, - "track_artist": False, - "artist_credit": False - }, - { - "album_artist": "ARTY & SOLO" - "album_artist_credit": "ARTIST & SOLOIST" - "track_arist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - }) - ({ - "album_artist": False, - "track_artist": False, - "artist_credit": True - }, - { - "album_artist": "ARTIST & SOLOIST" - "album_artist_credit": "ARTY & SOLO" - "track_arist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTY Feat. FORMER" - }) - ]) - def test_use_anv(self, config_input, expected_output): - config["discogs"]["album_artist_anv"] = config_input["album_artist"] - config["discogs"]["track_artist_anv"] = config_input["track_artist"] - config["discogs"]["artist_credit_anv"] = config_input["artist_credit"] - release = { - "id": 123, - "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [ - { - "title": "track", - "position": "A", - "type_": "track", - "duration": "5:44", - "artists": [ - {"name": "ARTIST", "tracks": "", "anv": "ARTY", "id": 11146} - ], - "extraartists": [ - {"name": "PERFORMER", "role": "Featuring", "tracks": "", "anv": "FORMER", "id": 787} - ], - } - ], - "artists": [ - {"name": "ARTIST NAME", "anv": "ARTY", "id": 321, "join": "&"}, - {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, - ], - "title": "title", - } - d = DiscogsPlugin().get_album_info(release) - assert d.artist == expected_output["album_artist"] - assert d.artist_credit == expected_output["album_artist_credit"] - assert d.tracks[0].artist == expected_output["track_artist"] - assert d.tracks[0].artist_credit == expected_output["track_artist_credit"] - config["discogs"]["album_artist_anv"] = False - config["discogs"]["track_artist_anv"] = False - config["discogs"]["artist_credit_anv"] = False - +@pytest.mark.parametrize( + "config_input, expected_output", + [ + ({ + "track_artist": False, + "album_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTIST NAME & SOLOIST", + "album_artist_credit": "ARTIST NAME & SOLOIST", + "track_artist": "ARTIST feat. PERFORMER", + "track_artist_credit": "ARTIST feat. PERFORMER" + }), + ({ + "album_artist": False, + "track_artist": True, + "artist_credit": False + }, + { + "album_artist": "ARTIST NAME & SOLOIST", + "album_artist_credit": "ARTIST NAME & SOLOIST", + "track_artist": "ARTY feat. FORMER", + "track_artist_credit": "ARTIST feat. PERFORMER" + }), + ({ + "album_artist": True, + "track_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTY & SOLO", + "album_artist_credit": "ARTIST NAME & SOLOIST", + "track_artist": "ARTIST feat. PERFORMER", + "track_artist_credit": "ARTIST feat. PERFORMER" + }), + ({ + "album_artist": True, + "track_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTY & SOLO", + "album_artist_credit": "ARTIST NAME & SOLOIST", + "track_artist": "ARTIST feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + }), + ({ + "album_artist": False, + "track_artist": False, + "artist_credit": True + }, + { + "album_artist": "ARTIST NAME & SOLOIST", + "album_artist_credit": "ARTY & SOLO", + "track_artist": "ARTIST feat. PERFORMER", + "track_artist_credit": "ARTY Feat. FORMER" + }) + +]) +def test_use_anv(config_input, expected_output): + d = DiscogsPlugin() + d.config["album_artist_anv"] = config_input["album_artist"] + d.config["track_artist_anv"] = config_input["track_artist"] + d.config["artist_credit_anv"] = config_input["artist_credit"] + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [{ + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [{ + "name": "ARTIST", + "tracks": "", + "anv": "ARTY", + "id": 11146 + }], + "extraartists": [{ + "name": "PERFORMER", + "role": "Featuring", + "anv": "FORMER", + "id": 787 + }], + }], + "artists": [ + {"name": "ARTIST NAME", "anv": "ARTY", "id": 321, "join": "&"}, + {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, + ], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + r = d.get_album_info(release) + assert r.artist == expected_output["album_artist"] + assert r.artist_credit == expected_output["album_artist_credit"] + assert r.tracks[0].artist == expected_output["track_artist"] + assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] @pytest.mark.parametrize( "position, medium, index, subindex", From 5a43d6add40abd00f63d3a4e2f9db71880696e32 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 26 Sep 2025 16:01:33 -0700 Subject: [PATCH 155/301] Testing and implemented anv method, also added Featuring customizable string --- beetsplug/discogs.py | 93 ++++++++++++------ test/plugins/test_discogs.py | 177 ++++++++++++++--------------------- 2 files changed, 135 insertions(+), 135 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index ee432f11f..727d8fbb7 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -97,8 +97,12 @@ class DiscogsPlugin(MetadataSourcePlugin): "user_token": "", "separator": ", ", "index_tracks": False, + "featured_label": "Feat.", "append_style_genre": False, "strip_disambiguation": True, + "album_artist_anv": False, + "track_artist_anv": False, + "artist_credit_anv": True } ) self.config["apikey"].redact = True @@ -302,6 +306,19 @@ class DiscogsPlugin(MetadataSourcePlugin): return media, albumtype + def get_artist(self, artists, use_anv=False) -> tuple[str, str | None]: + """ Iterates through a discogs result, fetching data + if the artist anv is to be used, maps that to the name. + Calls the parent class get_artist method.""" + artist_data = [] + for artist in artists: + if use_anv and (anv := artist.get("anv", "")): + artist["name"] = anv + artist_data.append(artist) + artist, artist_id = super().get_artist( + artist_data, join_key="join") + return self.strip_disambiguation(artist), artist_id + def get_album_info(self, result): """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet @@ -330,8 +347,12 @@ class DiscogsPlugin(MetadataSourcePlugin): self._log.warning("Release does not contain the required fields") return None - artist, artist_id = self.get_artist( - [a.data for a in result.artists], join_key="join" + artist_data = [a.data for a in result.artists] + album_artist, album_artist_id = self.get_artist(artist_data, + self.config["album_artist_anv"] + ) + artist_credit, _ = self.get_artist(artist_data, + self.config["artist_credit_anv"] ) album = re.sub(r" +", " ", result.title) album_id = result.data["id"] @@ -339,7 +360,8 @@ class DiscogsPlugin(MetadataSourcePlugin): # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. - tracks = self.get_tracks(result.data["tracklist"], artist, artist_id) + tracks = self.get_tracks(result.data["tracklist"], (album_artist, album_artist_id, + artist_credit)) # Extract information for the optional AlbumInfo fields, if possible. va = result.data["artists"][0].get("name", "").lower() == "various" @@ -376,9 +398,7 @@ class DiscogsPlugin(MetadataSourcePlugin): # Additional cleanups # (various artists name, catalog number, media, disambiguation). if va: - artist = config["va_name"].as_str() - else: - artist = self.strip_disambiguation(artist) + album_artist, artist_credit = config["va_name"].as_str() if catalogno == "none": catalogno = None # Explicitly set the `media` for the tracks, since it is expected by @@ -401,9 +421,9 @@ class DiscogsPlugin(MetadataSourcePlugin): return AlbumInfo( album=album, album_id=album_id, - artist=artist, - artist_credit=artist, - artist_id=artist_id, + artist=album_artist, + artist_credit=artist_credit, + artist_id=album_artist_id, tracks=tracks, albumtype=albumtype, va=va, @@ -421,7 +441,7 @@ class DiscogsPlugin(MetadataSourcePlugin): data_url=data_url, discogs_albumid=discogs_albumid, discogs_labelid=labelid, - discogs_artistid=artist_id, + discogs_artistid=album_artist_id, cover_art_url=cover_art_url, ) @@ -443,7 +463,7 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def get_tracks(self, tracklist, album_artist, album_artist_id): + def get_tracks(self, tracklist, album_artist_data): """Returns a list of TrackInfo objects for a discogs tracklist.""" try: clean_tracklist = self.coalesce_tracks(tracklist) @@ -469,7 +489,7 @@ class DiscogsPlugin(MetadataSourcePlugin): divisions += next_divisions del next_divisions[:] track_info = self.get_track_info( - track, index, divisions, album_artist, album_artist_id + track, index, divisions, album_artist_data ) track_info.track_alt = track["position"] tracks.append(track_info) @@ -638,9 +658,11 @@ class DiscogsPlugin(MetadataSourcePlugin): return DISAMBIGUATION_RE.sub("", text) def get_track_info( - self, track, index, divisions, album_artist, album_artist_id + self, track, index, divisions, album_artist_data ): """Returns a TrackInfo object for a discogs track.""" + album_artist, album_artist_id, artist_credit = album_artist_data + title = track["title"] if self.config["index_tracks"]: prefix = ", ".join(divisions) @@ -648,28 +670,39 @@ class DiscogsPlugin(MetadataSourcePlugin): title = f"{prefix}: {title}" track_id = None medium, medium_index, _ = self.get_track_index(track["position"]) - artist, artist_id = self.get_artist( - track.get("artists", []), join_key="join" - ) - # If no artist and artist is returned, set to match album artist - if not artist: - artist = album_artist - artist_id = album_artist_id + + artist = album_artist + artist_credit = album_artist + artist_id = album_artist_id + + # If artists are found on the track, we will use those instead + if (artists := track.get("artists", [])): + artist, artist_id = self.get_artist(artists, + self.config["track_artist_anv"] + ) + artist_credit, _ = self.get_artist(artists, + self.config["artist_credit_anv"] + ) length = self.get_track_length(track["duration"]) + # Add featured artists - extraartists = track.get("extraartists", []) - featured = [ - artist["name"] - for artist in extraartists - if "Featuring" in artist["role"] - ] - if featured: - artist = f"{artist} feat. {', '.join(featured)}" - artist = self.strip_disambiguation(artist) + if (extraartists := track.get("extraartists", [])): + featured_list = [ + artist for artist + in extraartists + if "Featuring" + in artist["role"]] + featured, _ = self.get_artist(featured_list, + self.config["track_artist_anv"]) + featured_credit, _ = self.get_artist(featured_list, + self.config["artist_credit_anv"]) + if featured: + artist = f"{artist} {self.config['featured_label']} {featured}" + artist_credit = f"{artist_credit} {self.config['featured_label']} {featured_credit}" return TrackInfo( title=title, track_id=track_id, - artist_credit=artist, + artist_credit=artist_credit, artist=artist, artist_id=artist_id, length=length, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index a7d1c8407..97c177736 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -452,6 +452,72 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.label == "LABEL NAME (5)" config["discogs"]["strip_disambiguation"] = True + def test_use_anv(self): + test_cases = [ + ({ + "track_artist": False, + "album_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTIST NAME & SOLOIST", + "album_artist_credit": "ARTIST NAME & SOLOIST", + "track_artist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + }), + ({ + "track_artist": True, + "album_artist": False, + "artist_credit": False + }, + { + "album_artist": "ARTIST NAME & SOLOIST", + "album_artist_credit": "ARTIST NAME & SOLOIST", + "track_artist": "ARTY Feat. FORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER" + })] + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [{ + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [{ + "name": "ARTIST", + "tracks": "", + "anv": "ARTY", + "id": 11146 + }], + "extraartists": [{ + "name": "PERFORMER", + "role": "Featuring", + "anv": "FORMER", + "id": 787 + }], + }], + "artists": [ + {"name": "ARTIST NAME", "anv": "ARTISTIC", "id": 321, "join": "&"}, + {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, + ], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + for test_case in test_cases: + config_input, expected_output = test_case + r = DiscogsPlugin().get_album_info(release) + config["album_artist_anv"] = config_input["album_artist"] + config["track_artist_anv"] = config_input["track_artist"] + config["artist_credit_anv"] = config_input["artist_credit"] + assert r.artist == expected_output["album_artist"] + assert r.artist_credit == expected_output["album_artist_credit"] + assert r.tracks[0].artist == expected_output["track_artist"] + assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] @pytest.mark.parametrize( "track, expected_artist", @@ -469,23 +535,27 @@ class DGAlbumInfoTest(BeetsTestCase): "extraartists": [ { "name": "SOLOIST", + "id": 3, "role": "Featuring", }, { "name": "PERFORMER (1)", + "id": 5, "role": "Other Role, Featuring", }, { "name": "RANDOM", + "id": 8, "role": "Written-By", }, { "name": "MUSICIAN", + "id": 10, "role": "Featuring [Uncredited]", }, ], }, - "NEW ARTIST, VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN", + "NEW ARTIST, VOCALIST Feat. SOLOIST, PERFORMER, MUSICIAN", ), ], ) @@ -494,7 +564,7 @@ def test_parse_featured_artists(track, expected_artist): """Tests the plugins ability to parse a featured artist. Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" - t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2) + t = DiscogsPlugin().get_track_info(track, 1, 1, ("ARTIST", 2, "ARTIST CREDIT")) assert t.artist == expected_artist @@ -520,109 +590,6 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): assert result == (expected_media, expected_albumtype) -@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) -@pytest.mark.parametrize( - "config_input, expected_output", - [ - ({ - "track_artist": False, - "album_artist": False, - "artist_credit": False - }, - { - "album_artist": "ARTIST NAME & SOLOIST", - "album_artist_credit": "ARTIST NAME & SOLOIST", - "track_artist": "ARTIST feat. PERFORMER", - "track_artist_credit": "ARTIST feat. PERFORMER" - }), - ({ - "album_artist": False, - "track_artist": True, - "artist_credit": False - }, - { - "album_artist": "ARTIST NAME & SOLOIST", - "album_artist_credit": "ARTIST NAME & SOLOIST", - "track_artist": "ARTY feat. FORMER", - "track_artist_credit": "ARTIST feat. PERFORMER" - }), - ({ - "album_artist": True, - "track_artist": False, - "artist_credit": False - }, - { - "album_artist": "ARTY & SOLO", - "album_artist_credit": "ARTIST NAME & SOLOIST", - "track_artist": "ARTIST feat. PERFORMER", - "track_artist_credit": "ARTIST feat. PERFORMER" - }), - ({ - "album_artist": True, - "track_artist": False, - "artist_credit": False - }, - { - "album_artist": "ARTY & SOLO", - "album_artist_credit": "ARTIST NAME & SOLOIST", - "track_artist": "ARTIST feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - }), - ({ - "album_artist": False, - "track_artist": False, - "artist_credit": True - }, - { - "album_artist": "ARTIST NAME & SOLOIST", - "album_artist_credit": "ARTY & SOLO", - "track_artist": "ARTIST feat. PERFORMER", - "track_artist_credit": "ARTY Feat. FORMER" - }) - -]) -def test_use_anv(config_input, expected_output): - d = DiscogsPlugin() - d.config["album_artist_anv"] = config_input["album_artist"] - d.config["track_artist_anv"] = config_input["track_artist"] - d.config["artist_credit_anv"] = config_input["artist_credit"] - data = { - "id": 123, - "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [{ - "title": "track", - "position": "A", - "type_": "track", - "duration": "5:44", - "artists": [{ - "name": "ARTIST", - "tracks": "", - "anv": "ARTY", - "id": 11146 - }], - "extraartists": [{ - "name": "PERFORMER", - "role": "Featuring", - "anv": "FORMER", - "id": 787 - }], - }], - "artists": [ - {"name": "ARTIST NAME", "anv": "ARTY", "id": 321, "join": "&"}, - {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, - ], - "title": "title", - } - release = Bag( - data=data, - title=data["title"], - artists=[Bag(data=d) for d in data["artists"]], - ) - r = d.get_album_info(release) - assert r.artist == expected_output["album_artist"] - assert r.artist_credit == expected_output["album_artist_credit"] - assert r.tracks[0].artist == expected_output["track_artist"] - assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] @pytest.mark.parametrize( "position, medium, index, subindex", From 0ec668939534230d51475aeafe5be0db5efc8d01 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 26 Sep 2025 17:56:25 -0700 Subject: [PATCH 156/301] test updates, one case still failing --- beetsplug/discogs.py | 12 +++++------- test/plugins/test_discogs.py | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 727d8fbb7..7082c4730 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -349,19 +349,17 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_data = [a.data for a in result.artists] album_artist, album_artist_id = self.get_artist(artist_data, - self.config["album_artist_anv"] - ) + self.config["album_artist_anv"]) artist_credit, _ = self.get_artist(artist_data, - self.config["artist_credit_anv"] - ) + self.config["artist_credit_anv"]) album = re.sub(r" +", " ", result.title) album_id = result.data["id"] # Use `.data` to access the tracklist directly instead of the # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. - tracks = self.get_tracks(result.data["tracklist"], (album_artist, album_artist_id, - artist_credit)) + tracks = self.get_tracks(result.data["tracklist"], + (album_artist, album_artist_id, artist_credit)) # Extract information for the optional AlbumInfo fields, if possible. va = result.data["artists"][0].get("name", "").lower() == "various" @@ -672,7 +670,7 @@ class DiscogsPlugin(MetadataSourcePlugin): medium, medium_index, _ = self.get_track_index(track["position"]) artist = album_artist - artist_credit = album_artist + artist_credit = artist_credit artist_id = album_artist_id # If artists are found on the track, we will use those instead diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 97c177736..921007fb0 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -453,11 +453,12 @@ class DGAlbumInfoTest(BeetsTestCase): config["discogs"]["strip_disambiguation"] = True def test_use_anv(self): + """ Test using artist name variations. """ test_cases = [ ({ - "track_artist": False, - "album_artist": False, - "artist_credit": False + "track_artist_anv": False, + "album_artist_anv": False, + "artist_credit_anv": False }, { "album_artist": "ARTIST NAME & SOLOIST", @@ -466,9 +467,9 @@ class DGAlbumInfoTest(BeetsTestCase): "track_artist_credit": "ARTIST Feat. PERFORMER" }), ({ - "track_artist": True, - "album_artist": False, - "artist_credit": False + "track_artist_anv": True, + "album_artist_anv": False, + "artist_credit_anv": False }, { "album_artist": "ARTIST NAME & SOLOIST", @@ -510,10 +511,10 @@ class DGAlbumInfoTest(BeetsTestCase): ) for test_case in test_cases: config_input, expected_output = test_case + config["discogs"]["album_artist_anv"] = config_input["album_artist_anv"] + config["discogs"]["track_artist_anv"] = config_input["track_artist_anv"] + config["discogs"]["artist_credit_anv"] = config_input["artist_credit_anv"] r = DiscogsPlugin().get_album_info(release) - config["album_artist_anv"] = config_input["album_artist"] - config["track_artist_anv"] = config_input["track_artist"] - config["artist_credit_anv"] = config_input["artist_credit"] assert r.artist == expected_output["album_artist"] assert r.artist_credit == expected_output["album_artist_credit"] assert r.tracks[0].artist == expected_output["track_artist"] From 1e677d57c16abafb28180b963d44e16ea0e67c82 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 26 Sep 2025 21:37:18 -0700 Subject: [PATCH 157/301] Updates to documentation --- beetsplug/discogs.py | 28 ++--- docs/changelog.rst | 9 ++ test/plugins/test_discogs.py | 193 +++++++++++++++++++++++------------ 3 files changed, 145 insertions(+), 85 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 7082c4730..b3da89058 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -312,9 +312,10 @@ class DiscogsPlugin(MetadataSourcePlugin): Calls the parent class get_artist method.""" artist_data = [] for artist in artists: - if use_anv and (anv := artist.get("anv", "")): - artist["name"] = anv - artist_data.append(artist) + a = artist.copy() + if use_anv and (anv := a.get("anv", "")): + a["name"] = anv + artist_data.append(a) artist, artist_id = super().get_artist( artist_data, join_key="join") return self.strip_disambiguation(artist), artist_id @@ -659,7 +660,8 @@ class DiscogsPlugin(MetadataSourcePlugin): self, track, index, divisions, album_artist_data ): """Returns a TrackInfo object for a discogs track.""" - album_artist, album_artist_id, artist_credit = album_artist_data + + artist, artist_id, artist_credit = album_artist_data title = track["title"] if self.config["index_tracks"]: @@ -669,18 +671,10 @@ class DiscogsPlugin(MetadataSourcePlugin): track_id = None medium, medium_index, _ = self.get_track_index(track["position"]) - artist = album_artist - artist_credit = artist_credit - artist_id = album_artist_id - # If artists are found on the track, we will use those instead if (artists := track.get("artists", [])): - artist, artist_id = self.get_artist(artists, - self.config["track_artist_anv"] - ) - artist_credit, _ = self.get_artist(artists, - self.config["artist_credit_anv"] - ) + artist, artist_id = self.get_artist(artists, self.config["track_artist_anv"]) + artist_credit, _ = self.get_artist(artists, self.config["artist_credit_anv"]) length = self.get_track_length(track["duration"]) # Add featured artists @@ -690,10 +684,8 @@ class DiscogsPlugin(MetadataSourcePlugin): in extraartists if "Featuring" in artist["role"]] - featured, _ = self.get_artist(featured_list, - self.config["track_artist_anv"]) - featured_credit, _ = self.get_artist(featured_list, - self.config["artist_credit_anv"]) + featured, _ = self.get_artist(featured_list, self.config["track_artist_anv"]) + featured_credit, _ = self.get_artist(featured_list, self.config["artist_credit_anv"]) if featured: artist = f"{artist} {self.config['featured_label']} {featured}" artist_credit = f"{artist_credit} {self.config['featured_label']} {featured_credit}" diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b6dae5e2..aafbc5030 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,15 @@ New features: - :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle stripping discogs numeric disambiguation on artist and label fields. - :doc:`plugins/discogs` Added support for featured artists. +- :doc:`plugins/discogs` New configuration option `featured_label` to change the + default string used to join featured artists. The default string is `Feat.` +- :doc:`plugins/discogs` Added support for `artist_credit` in Discogs tags +- :doc:`plugins/discogs` Added support for Discogs artist name variations. + Three new boolean configuration options specify where the variations are written, + if at all. `album_artist_anv` writes variations to the album artist tag. + `track_artist_anv` writes to a tracks artist field. `artist_credit_anv` writes + to the `artist_credit` field on both albums and tracks. + Bug fixes: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 921007fb0..34a95d9e4 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -452,73 +452,132 @@ class DGAlbumInfoTest(BeetsTestCase): assert d.label == "LABEL NAME (5)" config["discogs"]["strip_disambiguation"] = True - def test_use_anv(self): - """ Test using artist name variations. """ - test_cases = [ - ({ - "track_artist_anv": False, - "album_artist_anv": False, - "artist_credit_anv": False - }, - { - "album_artist": "ARTIST NAME & SOLOIST", - "album_artist_credit": "ARTIST NAME & SOLOIST", - "track_artist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - }), - ({ - "track_artist_anv": True, - "album_artist_anv": False, - "artist_credit_anv": False - }, - { - "album_artist": "ARTIST NAME & SOLOIST", - "album_artist_credit": "ARTIST NAME & SOLOIST", - "track_artist": "ARTY Feat. FORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER" - })] - data = { - "id": 123, - "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [{ - "title": "track", - "position": "A", - "type_": "track", - "duration": "5:44", - "artists": [{ - "name": "ARTIST", - "tracks": "", - "anv": "ARTY", - "id": 11146 - }], - "extraartists": [{ - "name": "PERFORMER", - "role": "Featuring", - "anv": "FORMER", - "id": 787 - }], - }], - "artists": [ - {"name": "ARTIST NAME", "anv": "ARTISTIC", "id": 321, "join": "&"}, - {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, - ], - "title": "title", - } - release = Bag( - data=data, - title=data["title"], - artists=[Bag(data=d) for d in data["artists"]], - ) - for test_case in test_cases: - config_input, expected_output = test_case - config["discogs"]["album_artist_anv"] = config_input["album_artist_anv"] - config["discogs"]["track_artist_anv"] = config_input["track_artist_anv"] - config["discogs"]["artist_credit_anv"] = config_input["artist_credit_anv"] - r = DiscogsPlugin().get_album_info(release) - assert r.artist == expected_output["album_artist"] - assert r.artist_credit == expected_output["album_artist_credit"] - assert r.tracks[0].artist == expected_output["track_artist"] - assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] + + +@pytest.mark.parametrize( + "config_input,expected_output", + [ + ({ + "track_artist_anv": False, + "album_artist_anv": False, + "artist_credit_anv": False + }, + { + "track_artist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER", + "album_artist": "ARTIST & SOLOIST", + "album_artist_credit": "ARTIST & SOLOIST", + }), + ({ + "track_artist_anv": True, + "album_artist_anv": False, + "artist_credit_anv": False + }, + { + "track_artist": "VARIATION Feat. VARIATION", + "track_artist_credit": "ARTIST Feat. PERFORMER", + "album_artist": "ARTIST & SOLOIST", + "album_artist_credit": "ARTIST & SOLOIST", + }), + ({ + "track_artist_anv": True, + "album_artist_anv": True, + "artist_credit_anv": False + }, + { + "track_artist": "VARIATION Feat. VARIATION", + "track_artist_credit": "ARTIST Feat. PERFORMER", + "album_artist": "VARIATION & VARIATION", + "album_artist_credit": "ARTIST & SOLOIST", + }), + ({ + "track_artist_anv": True, + "album_artist_anv": True, + "artist_credit_anv": True + }, + { + "track_artist": "VARIATION Feat. VARIATION", + "track_artist_credit": "VARIATION Feat. VARIATION", + "album_artist": "VARIATION & VARIATION", + "album_artist_credit": "VARIATION & VARIATION", + }) +]) +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +def test_anv(config_input, expected_output): + """ Test using artist name variations. """ + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [{ + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [{ + "name": "ARTIST", + "tracks": "", + "anv": "VARIATION", + "id": 11146 + }], + "extraartists": [{ + "name": "PERFORMER", + "role": "Featuring", + "anv": "VARIATION", + "id": 787 + }], + }], + "artists": [ + {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321, "join": "&"}, + {"name": "SOLOIST", "anv": "VARIATION", "id": 445, "join": ""}, + ], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + config["discogs"]["album_artist_anv"] = config_input["album_artist_anv"] + config["discogs"]["track_artist_anv"] = config_input["track_artist_anv"] + config["discogs"]["artist_credit_anv"] = config_input["artist_credit_anv"] + r = DiscogsPlugin().get_album_info(release) + assert r.artist == expected_output["album_artist"] + assert r.artist_credit == expected_output["album_artist_credit"] + assert r.tracks[0].artist == expected_output["track_artist"] + assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] + +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +def test_anv_album_artist(): + """ Test using artist name variations when the album artist + is the same as the track artist, but only the track artist + should use the artist name variation.""" + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [{ + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + }], + "artists": [ + {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321}, + ], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + config["discogs"]["album_artist_anv"] = False + config["discogs"]["track_artist_anv"] = True + config["discogs"]["artist_credit_anv"] = False + r = DiscogsPlugin().get_album_info(release) + assert r.artist == "ARTIST" + assert r.artist_credit == "ARTIST" + assert r.tracks[0].artist == "VARIATION" + assert r.tracks[0].artist_credit == "ARTIST" @pytest.mark.parametrize( "track, expected_artist", From a0a0a094d3ead2c373669037159bfd93aab67272 Mon Sep 17 00:00:00 2001 From: pSpitzner <paul.spitzner@ds.mpg.de> Date: Sat, 27 Sep 2025 13:06:12 +0200 Subject: [PATCH 158/301] Changed query from double to single quotes. --- beets/metadata_plugins.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 0b8d81c95..56bf8124f 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -412,7 +412,7 @@ class SearchApiMetadataSourcePlugin( :return: Query string to be provided to the search API. """ - components = [query_string, *(f'{k}:"{v}"' for k, v in filters.items())] + components = [query_string, *(f"{k}:'{v}'" for k, v in filters.items())] query = " ".join(filter(None, components)) if self.config["search_query_ascii"].get(): diff --git a/docs/changelog.rst b/docs/changelog.rst index 092a1a5a0..ef7eb9ff8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ Bug fixes: matching. :bug:`5189` - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` +- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find + matches due to query escaping (single vs double quotes). For packagers: From cc0024e089112863e2a8661de6a6e2b533b16964 Mon Sep 17 00:00:00 2001 From: pSpitzner <paul.spitzner@ds.mpg.de> Date: Sat, 27 Sep 2025 13:22:41 +0200 Subject: [PATCH 159/301] Spotify tests are now consistent with quote change --- test/plugins/test_spotify.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 86b5651b9..bc55485c6 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -82,8 +82,8 @@ class SpotifyPluginTest(PluginTestCase): params = _params(responses.calls[0].request.url) query = params["q"][0] assert "duifhjslkef" in query - assert 'artist:"ujydfsuihse"' in query - assert 'album:"lkajsdflakjsd"' in query + assert "artist:'ujydfsuihse'" in query + assert "album:'lkajsdflakjsd'" in query assert params["type"] == ["track"] @responses.activate @@ -117,8 +117,8 @@ class SpotifyPluginTest(PluginTestCase): params = _params(responses.calls[0].request.url) query = params["q"][0] assert "Happy" in query - assert 'artist:"Pharrell Williams"' in query - assert 'album:"Despicable Me 2"' in query + assert "artist:'Pharrell Williams'" in query + assert "album:'Despicable Me 2'" in query assert params["type"] == ["track"] @responses.activate @@ -233,8 +233,8 @@ class SpotifyPluginTest(PluginTestCase): params = _params(responses.calls[0].request.url) query = params["q"][0] assert item.title in query - assert f'artist:"{item.albumartist}"' in query - assert f'album:"{item.album}"' in query + assert f"artist:'{item.albumartist}'" in query + assert f"album:'{item.album}'" in query assert not query.isascii() # Is not found in the library if ascii encoding is enabled From dd57c0da2d8fb5a0ecfb1be0b8b767e1eb33ec3e Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 27 Sep 2025 10:42:29 -0700 Subject: [PATCH 160/301] improve flexibility of use of anv on artist tracks --- beetsplug/discogs.py | 23 +++++++++++++++++------ test/plugins/test_discogs.py | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index b3da89058..0eaef2227 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -349,10 +349,10 @@ class DiscogsPlugin(MetadataSourcePlugin): return None artist_data = [a.data for a in result.artists] - album_artist, album_artist_id = self.get_artist(artist_data, - self.config["album_artist_anv"]) - artist_credit, _ = self.get_artist(artist_data, - self.config["artist_credit_anv"]) + album_artist, album_artist_id = self.get_artist(artist_data) + album_artist_anv, _ = self.get_artist(artist_data, use_anv=True) + artist_credit = album_artist_anv + album = re.sub(r" +", " ", result.title) album_id = result.data["id"] # Use `.data` to access the tracklist directly instead of the @@ -360,7 +360,13 @@ class DiscogsPlugin(MetadataSourcePlugin): # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks(result.data["tracklist"], - (album_artist, album_artist_id, artist_credit)) + (album_artist, album_artist_anv, album_artist_id)) + + # Assign ANV to the proper fields for tagging + if not self.config["artist_credit_anv"]: + artist_credit = album_artist + if self.config["album_artist_anv"]: + album_artist = album_artist_anv # Extract information for the optional AlbumInfo fields, if possible. va = result.data["artists"][0].get("name", "").lower() == "various" @@ -661,7 +667,12 @@ class DiscogsPlugin(MetadataSourcePlugin): ): """Returns a TrackInfo object for a discogs track.""" - artist, artist_id, artist_credit = album_artist_data + artist, artist_anv, artist_id = album_artist_data + artist_credit = artist_anv + if not self.config["artist_credit_anv"]: + artist_credit = artist + if self.config["track_artist_anv"]: + artist = artist_anv title = track["title"] if self.config["index_tracks"]: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 34a95d9e4..6dab169ca 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -624,7 +624,7 @@ def test_parse_featured_artists(track, expected_artist): """Tests the plugins ability to parse a featured artist. Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" - t = DiscogsPlugin().get_track_info(track, 1, 1, ("ARTIST", 2, "ARTIST CREDIT")) + t = DiscogsPlugin().get_track_info(track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2)) assert t.artist == expected_artist From b1903417f40e93a79bc1f88f3398882536e1cb63 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 27 Sep 2025 14:29:25 -0700 Subject: [PATCH 161/301] Add artist credit support, artist name variation support, more flexible featured credit. --- beetsplug/discogs.py | 53 +++++++------ docs/changelog.rst | 24 +++--- docs/plugins/discogs.rst | 8 ++ test/plugins/test_discogs.py | 142 +++++++++++++++++------------------ 4 files changed, 117 insertions(+), 110 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 0eaef2227..0d796e7b7 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -97,12 +97,12 @@ class DiscogsPlugin(MetadataSourcePlugin): "user_token": "", "separator": ", ", "index_tracks": False, - "featured_label": "Feat.", + "featured_string": "Feat.", "append_style_genre": False, "strip_disambiguation": True, "album_artist_anv": False, "track_artist_anv": False, - "artist_credit_anv": True + "artist_credit_anv": True, } ) self.config["apikey"].redact = True @@ -307,7 +307,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return media, albumtype def get_artist(self, artists, use_anv=False) -> tuple[str, str | None]: - """ Iterates through a discogs result, fetching data + """Iterates through a discogs result, fetching data if the artist anv is to be used, maps that to the name. Calls the parent class get_artist method.""" artist_data = [] @@ -316,8 +316,7 @@ class DiscogsPlugin(MetadataSourcePlugin): if use_anv and (anv := a.get("anv", "")): a["name"] = anv artist_data.append(a) - artist, artist_id = super().get_artist( - artist_data, join_key="join") + artist, artist_id = super().get_artist(artist_data, join_key="join") return self.strip_disambiguation(artist), artist_id def get_album_info(self, result): @@ -359,8 +358,10 @@ class DiscogsPlugin(MetadataSourcePlugin): # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. - tracks = self.get_tracks(result.data["tracklist"], - (album_artist, album_artist_anv, album_artist_id)) + tracks = self.get_tracks( + result.data["tracklist"], + (album_artist, album_artist_anv, album_artist_id), + ) # Assign ANV to the proper fields for tagging if not self.config["artist_credit_anv"]: @@ -662,9 +663,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) - def get_track_info( - self, track, index, divisions, album_artist_data - ): + def get_track_info(self, track, index, divisions, album_artist_data): """Returns a TrackInfo object for a discogs track.""" artist, artist_anv, artist_id = album_artist_data @@ -683,23 +682,33 @@ class DiscogsPlugin(MetadataSourcePlugin): medium, medium_index, _ = self.get_track_index(track["position"]) # If artists are found on the track, we will use those instead - if (artists := track.get("artists", [])): - artist, artist_id = self.get_artist(artists, self.config["track_artist_anv"]) - artist_credit, _ = self.get_artist(artists, self.config["artist_credit_anv"]) + if artists := track.get("artists", []): + artist, artist_id = self.get_artist( + artists, self.config["track_artist_anv"] + ) + artist_credit, _ = self.get_artist( + artists, self.config["artist_credit_anv"] + ) length = self.get_track_length(track["duration"]) # Add featured artists - if (extraartists := track.get("extraartists", [])): + if extraartists := track.get("extraartists", []): featured_list = [ - artist for artist - in extraartists - if "Featuring" - in artist["role"]] - featured, _ = self.get_artist(featured_list, self.config["track_artist_anv"]) - featured_credit, _ = self.get_artist(featured_list, self.config["artist_credit_anv"]) + artist + for artist in extraartists + if "Featuring" in artist["role"] + ] + featured, _ = self.get_artist( + featured_list, self.config["track_artist_anv"] + ) + featured_credit, _ = self.get_artist( + featured_list, self.config["artist_credit_anv"] + ) if featured: - artist = f"{artist} {self.config['featured_label']} {featured}" - artist_credit = f"{artist_credit} {self.config['featured_label']} {featured_credit}" + artist += f" {self.config['featured_string']} {featured}" + artist_credit += ( + f" {self.config['featured_string']} {featured_credit}" + ) return TrackInfo( title=title, track_id=track_id, diff --git a/docs/changelog.rst b/docs/changelog.rst index ffb916a8d..9476c7ad8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,16 +15,14 @@ New features: converted files. - :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle stripping discogs numeric disambiguation on artist and label fields. -- :doc:`plugins/discogs` Added support for featured artists. -- :doc:`plugins/discogs` New configuration option `featured_label` to change the - default string used to join featured artists. The default string is `Feat.` -- :doc:`plugins/discogs` Added support for `artist_credit` in Discogs tags -- :doc:`plugins/discogs` Added support for Discogs artist name variations. - Three new boolean configuration options specify where the variations are written, - if at all. `album_artist_anv` writes variations to the album artist tag. - `track_artist_anv` writes to a tracks artist field. `artist_credit_anv` writes - to the `artist_credit` field on both albums and tracks. - +- :doc:`plugins/discogs` Added support for featured artists. :bug:`6038` +- :doc:`plugins/discogs` New configuration option `featured_string` to change + the default string used to join featured artists. The default string is + `Feat.`. +- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags. + :bug:`3354` +- :doc:`plugins/discogs` Support for name variations and config options to + specify where the variations are written. :bug:`3354` Bug fixes: @@ -35,12 +33,10 @@ Bug fixes: - :doc:`plugins/spotify` Removed old and undocumented config options `artist_field`, `album_field` and `track` that were causing issues with track matching. :bug:`5189` -- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from - artists but not labels. :bug:`5366` -- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the - extraartists field. - :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find matches due to query escaping (single vs double quotes). +- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from + artists but not labels. :bug:`5366` For packagers: diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 3dac558bd..0446685d7 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -112,6 +112,14 @@ Other configurations available under ``discogs:`` are: - **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with the same name. If you'd like to use the discogs disambiguation in your tags, you can disable it. Default: ``True`` +- **featured_string**: Configure the string used for noting featured artists. + Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.`` +- **artist_credit_anv**: Whether the Artist Name Variation (ANV) should be + applied to the artist credit tag. Default: ``True`` +- **track_artist_anv**: Same as ``artist_credit_anv`` for a track's artist + field. Default: ``False`` +- **album_artist_anv**: As previous, but for the album artist. Default: + ``False`` .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 6dab169ca..439cd3585 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -453,85 +453,73 @@ class DGAlbumInfoTest(BeetsTestCase): config["discogs"]["strip_disambiguation"] = True - @pytest.mark.parametrize( - "config_input,expected_output", - [ - ({ - "track_artist_anv": False, - "album_artist_anv": False, - "artist_credit_anv": False - }, - { - "track_artist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER", - "album_artist": "ARTIST & SOLOIST", - "album_artist_credit": "ARTIST & SOLOIST", - }), - ({ - "track_artist_anv": True, - "album_artist_anv": False, - "artist_credit_anv": False - }, - { - "track_artist": "VARIATION Feat. VARIATION", - "track_artist_credit": "ARTIST Feat. PERFORMER", - "album_artist": "ARTIST & SOLOIST", - "album_artist_credit": "ARTIST & SOLOIST", - }), - ({ - "track_artist_anv": True, - "album_artist_anv": True, - "artist_credit_anv": False - }, - { - "track_artist": "VARIATION Feat. VARIATION", - "track_artist_credit": "ARTIST Feat. PERFORMER", - "album_artist": "VARIATION & VARIATION", - "album_artist_credit": "ARTIST & SOLOIST", - }), - ({ - "track_artist_anv": True, - "album_artist_anv": True, - "artist_credit_anv": True - }, - { - "track_artist": "VARIATION Feat. VARIATION", - "track_artist_credit": "VARIATION Feat. VARIATION", - "album_artist": "VARIATION & VARIATION", - "album_artist_credit": "VARIATION & VARIATION", - }) -]) + "config_input,expected_output", + [ + ( + { + "track_artist_anv": False, + "album_artist_anv": False, + "artist_credit_anv": False, + }, + { + "track_artist": "ARTIST Feat. PERFORMER", + "track_artist_credit": "ARTIST Feat. PERFORMER", + "album_artist": "ARTIST & SOLOIST", + "album_artist_credit": "ARTIST & SOLOIST", + }, + ), + ( + { + "track_artist_anv": True, + "album_artist_anv": True, + "artist_credit_anv": True, + }, + { + "track_artist": "VARIATION Feat. VARIATION", + "track_artist_credit": "VARIATION Feat. VARIATION", + "album_artist": "VARIATION & VARIATION", + "album_artist_credit": "VARIATION & VARIATION", + }, + ), + ], +) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv(config_input, expected_output): - """ Test using artist name variations. """ + """Test using artist name variations.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [{ + "tracklist": [ + { "title": "track", "position": "A", "type_": "track", "duration": "5:44", - "artists": [{ - "name": "ARTIST", - "tracks": "", - "anv": "VARIATION", - "id": 11146 - }], - "extraartists": [{ - "name": "PERFORMER", - "role": "Featuring", - "anv": "VARIATION", - "id": 787 - }], - }], + "artists": [ + { + "name": "ARTIST", + "tracks": "", + "anv": "VARIATION", + "id": 11146, + } + ], + "extraartists": [ + { + "name": "PERFORMER", + "role": "Featuring", + "anv": "VARIATION", + "id": 787, + } + ], + } + ], "artists": [ {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321, "join": "&"}, {"name": "SOLOIST", "anv": "VARIATION", "id": 445, "join": ""}, ], "title": "title", - } + } release = Bag( data=data, title=data["title"], @@ -546,25 +534,28 @@ def test_anv(config_input, expected_output): assert r.tracks[0].artist == expected_output["track_artist"] assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] + @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv_album_artist(): - """ Test using artist name variations when the album artist - is the same as the track artist, but only the track artist + """Test using artist name variations when the album artist + is the same as the track artist, but only the track artist should use the artist name variation.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", - "tracklist": [{ + "tracklist": [ + { "title": "track", "position": "A", "type_": "track", "duration": "5:44", - }], + } + ], "artists": [ {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321}, ], "title": "title", - } + } release = Bag( data=data, title=data["title"], @@ -579,6 +570,7 @@ def test_anv_album_artist(): assert r.tracks[0].artist == "VARIATION" assert r.tracks[0].artist_credit == "ARTIST" + @pytest.mark.parametrize( "track, expected_artist", [ @@ -595,22 +587,22 @@ def test_anv_album_artist(): "extraartists": [ { "name": "SOLOIST", - "id": 3, + "id": 3, "role": "Featuring", }, { "name": "PERFORMER (1)", - "id": 5, + "id": 5, "role": "Other Role, Featuring", }, { "name": "RANDOM", - "id": 8, + "id": 8, "role": "Written-By", }, { "name": "MUSICIAN", - "id": 10, + "id": 10, "role": "Featuring [Uncredited]", }, ], @@ -624,7 +616,9 @@ def test_parse_featured_artists(track, expected_artist): """Tests the plugins ability to parse a featured artist. Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" - t = DiscogsPlugin().get_track_info(track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2)) + t = DiscogsPlugin().get_track_info( + track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2) + ) assert t.artist == expected_artist From 9efe728f47a439cfdb92c35707ed8771c3298a50 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 27 Sep 2025 14:49:56 -0700 Subject: [PATCH 162/301] type checking, tuple unpacking fix in various artists --- beetsplug/discogs.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 0d796e7b7..ec8638589 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -306,7 +306,9 @@ class DiscogsPlugin(MetadataSourcePlugin): return media, albumtype - def get_artist(self, artists, use_anv=False) -> tuple[str, str | None]: + def get_artist( + self, artists: Iterable[dict[str | int, str]], use_anv: bool = False + ) -> tuple[str, str | None]: """Iterates through a discogs result, fetching data if the artist anv is to be used, maps that to the name. Calls the parent class get_artist method.""" @@ -404,7 +406,9 @@ class DiscogsPlugin(MetadataSourcePlugin): # Additional cleanups # (various artists name, catalog number, media, disambiguation). if va: - album_artist, artist_credit = config["va_name"].as_str() + va_name = config["va_name"].as_str() + album_artist = va_name + artist_credit = va_name if catalogno == "none": catalogno = None # Explicitly set the `media` for the tracks, since it is expected by From abc8c2d5d8e2ef11081fadd768b89aae5b9a4718 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 27 Sep 2025 15:05:14 -0700 Subject: [PATCH 163/301] resolve overriding method type error --- beetsplug/discogs.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index ec8638589..3c42a5621 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -306,19 +306,19 @@ class DiscogsPlugin(MetadataSourcePlugin): return media, albumtype - def get_artist( + def get_artist_with_anv( self, artists: Iterable[dict[str | int, str]], use_anv: bool = False ) -> tuple[str, str | None]: """Iterates through a discogs result, fetching data if the artist anv is to be used, maps that to the name. Calls the parent class get_artist method.""" - artist_data = [] - for artist in artists: - a = artist.copy() + artist_list = [] + for artist_data in artists: + a = artist_data.copy() if use_anv and (anv := a.get("anv", "")): a["name"] = anv - artist_data.append(a) - artist, artist_id = super().get_artist(artist_data, join_key="join") + artist_list.append(a) + artist, artist_id = self.get_artist(artist_list, join_key="join") return self.strip_disambiguation(artist), artist_id def get_album_info(self, result): @@ -350,8 +350,10 @@ class DiscogsPlugin(MetadataSourcePlugin): return None artist_data = [a.data for a in result.artists] - album_artist, album_artist_id = self.get_artist(artist_data) - album_artist_anv, _ = self.get_artist(artist_data, use_anv=True) + album_artist, album_artist_id = self.get_artist_with_anv(artist_data) + album_artist_anv, _ = self.get_artist_with_anv( + artist_data, use_anv=True + ) artist_credit = album_artist_anv album = re.sub(r" +", " ", result.title) @@ -687,10 +689,10 @@ class DiscogsPlugin(MetadataSourcePlugin): # If artists are found on the track, we will use those instead if artists := track.get("artists", []): - artist, artist_id = self.get_artist( + artist, artist_id = self.get_artist_with_anv( artists, self.config["track_artist_anv"] ) - artist_credit, _ = self.get_artist( + artist_credit, _ = self.get_artist_with_anv( artists, self.config["artist_credit_anv"] ) length = self.get_track_length(track["duration"]) @@ -702,10 +704,10 @@ class DiscogsPlugin(MetadataSourcePlugin): for artist in extraartists if "Featuring" in artist["role"] ] - featured, _ = self.get_artist( + featured, _ = self.get_artist_with_anv( featured_list, self.config["track_artist_anv"] ) - featured_credit, _ = self.get_artist( + featured_credit, _ = self.get_artist_with_anv( featured_list, self.config["artist_credit_anv"] ) if featured: From c44c535b22339d6cec210b0dd8bea01da90be21d Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sun, 28 Sep 2025 10:49:56 -0700 Subject: [PATCH 164/301] Fully parametrize testing --- test/plugins/test_discogs.py | 62 ++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 439cd3585..40dd30e53 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -454,38 +454,30 @@ class DGAlbumInfoTest(BeetsTestCase): @pytest.mark.parametrize( - "config_input,expected_output", + "track_artist_anv,track_artist", + [(False, "ARTIST Feat. PERFORMER"), (True, "VARIATION Feat. VARIATION")], +) +@pytest.mark.parametrize( + "album_artist_anv,album_artist", + [(False, "ARTIST & SOLOIST"), (True, "VARIATION & VARIATION")], +) +@pytest.mark.parametrize( + "artist_credit_anv,track_artist_credit,album_artist_credit", [ - ( - { - "track_artist_anv": False, - "album_artist_anv": False, - "artist_credit_anv": False, - }, - { - "track_artist": "ARTIST Feat. PERFORMER", - "track_artist_credit": "ARTIST Feat. PERFORMER", - "album_artist": "ARTIST & SOLOIST", - "album_artist_credit": "ARTIST & SOLOIST", - }, - ), - ( - { - "track_artist_anv": True, - "album_artist_anv": True, - "artist_credit_anv": True, - }, - { - "track_artist": "VARIATION Feat. VARIATION", - "track_artist_credit": "VARIATION Feat. VARIATION", - "album_artist": "VARIATION & VARIATION", - "album_artist_credit": "VARIATION & VARIATION", - }, - ), + (False, "ARTIST Feat. PERFORMER", "ARTIST & SOLOIST"), + (True, "VARIATION Feat. VARIATION", "VARIATION & VARIATION"), ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) -def test_anv(config_input, expected_output): +def test_anv( + track_artist_anv, + track_artist, + album_artist_anv, + album_artist, + artist_credit_anv, + track_artist_credit, + album_artist_credit, +): """Test using artist name variations.""" data = { "id": 123, @@ -525,14 +517,14 @@ def test_anv(config_input, expected_output): title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) - config["discogs"]["album_artist_anv"] = config_input["album_artist_anv"] - config["discogs"]["track_artist_anv"] = config_input["track_artist_anv"] - config["discogs"]["artist_credit_anv"] = config_input["artist_credit_anv"] + config["discogs"]["album_artist_anv"] = album_artist_anv + config["discogs"]["track_artist_anv"] = track_artist_anv + config["discogs"]["artist_credit_anv"] = artist_credit_anv r = DiscogsPlugin().get_album_info(release) - assert r.artist == expected_output["album_artist"] - assert r.artist_credit == expected_output["album_artist_credit"] - assert r.tracks[0].artist == expected_output["track_artist"] - assert r.tracks[0].artist_credit == expected_output["track_artist_credit"] + assert r.artist == album_artist + assert r.artist_credit == album_artist_credit + assert r.tracks[0].artist == track_artist + assert r.tracks[0].artist_credit == track_artist_credit @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) From c34b2a00a4d25acf3947403777c8625646a78577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 23 Sep 2025 15:03:17 +0100 Subject: [PATCH 165/301] Fix plugin loading --- beets/plugins.py | 11 +++++++++-- docs/changelog.rst | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index d8d465183..c0dd12e5b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -22,6 +22,7 @@ import re import sys from collections import defaultdict from functools import wraps +from importlib import import_module from pathlib import Path from types import GenericAlias from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar @@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None: """ try: try: - namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None) + namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}") except Exception as exc: raise PluginImportError(name) from exc - for obj in getattr(namespace, name).__dict__.values(): + for obj in namespace.__dict__.values(): if ( inspect.isclass(obj) and not isinstance( @@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None: and issubclass(obj, BeetsPlugin) and obj != BeetsPlugin and not inspect.isabstract(obj) + # Only consider this plugin's module or submodules to avoid + # conflicts when plugins import other BeetsPlugin classes + and ( + obj.__module__ == namespace.__name__ + or obj.__module__.startswith(f"{namespace.__name__}.") + ) ): return obj() diff --git a/docs/changelog.rst b/docs/changelog.rst index cbcfe73f0..52e935445 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,8 @@ Bug fixes: extraartists field. - :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find matches due to query escaping (single vs double quotes). +- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by + an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033` For packagers: From 7954671c737cbcd462f8b6dc22a062b52d260184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 23 Sep 2025 15:45:24 +0100 Subject: [PATCH 166/301] Mock DummyPlugin properly --- test/test_logging.py | 65 ++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/test/test_logging.py b/test/test_logging.py index aee0bd61b..b751dd70e 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -5,9 +5,10 @@ import sys import threading import unittest from io import StringIO +from types import ModuleType +from unittest.mock import patch import beets.logging as blog -import beetsplug from beets import plugins, ui from beets.test import _common, helper from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin @@ -47,36 +48,46 @@ class LoggingTest(unittest.TestCase): assert stream.getvalue(), "foo oof baz" +class DummyModule(ModuleType): + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + plugins.BeetsPlugin.__init__(self, "dummy") + self.import_stages = [self.import_stage] + self.register_listener("dummy_event", self.listener) + + def log_all(self, name): + self._log.debug("debug {}", name) + self._log.info("info {}", name) + self._log.warning("warning {}", name) + + def commands(self): + cmd = ui.Subcommand("dummy") + cmd.func = lambda _, __, ___: self.log_all("cmd") + return (cmd,) + + def import_stage(self, session, task): + self.log_all("import_stage") + + def listener(self): + self.log_all("listener") + + def __init__(self, *_, **__): + module_name = "beetsplug.dummy" + super().__init__(module_name) + self.DummyPlugin.__module__ = module_name + self.DummyPlugin = self.DummyPlugin + + class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): plugin = "dummy" - class DummyModule: - class DummyPlugin(plugins.BeetsPlugin): - def __init__(self): - plugins.BeetsPlugin.__init__(self, "dummy") - self.import_stages = [self.import_stage] - self.register_listener("dummy_event", self.listener) + @classmethod + def setUpClass(cls): + patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()}) + patcher.start() + cls.addClassCleanup(patcher.stop) - def log_all(self, name): - self._log.debug("debug {}", name) - self._log.info("info {}", name) - self._log.warning("warning {}", name) - - def commands(self): - cmd = ui.Subcommand("dummy") - cmd.func = lambda _, __, ___: self.log_all("cmd") - return (cmd,) - - def import_stage(self, session, task): - self.log_all("import_stage") - - def listener(self): - self.log_all("listener") - - def setUp(self): - sys.modules["beetsplug.dummy"] = self.DummyModule - beetsplug.dummy = self.DummyModule - super().setUp() + super().setUpClass() def test_command_level0(self): self.config["verbose"] = 0 From 461bc049a058a233afe02c5ae3ba4a9571651e75 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Wed, 17 Sep 2025 11:42:07 +0200 Subject: [PATCH 167/301] Enhanced custom logger typing and logging tests --- beets/logging.py | 47 ++++++++++++++++++++++++++++++-------------- test/test_logging.py | 45 +++++++++++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index fd8b1962f..becfff86f 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -20,6 +20,9 @@ use {}-style formatting and can interpolate keywords arguments to the logging calls (`debug`, `info`, etc). """ +from __future__ import annotations + +import logging import threading from copy import copy from logging import ( @@ -34,6 +37,9 @@ from logging import ( NullHandler, StreamHandler, ) +from typing import TYPE_CHECKING, Any, Mapping, TypeVar + +from typing_extensions import ParamSpec __all__ = [ "DEBUG", @@ -49,8 +55,14 @@ __all__ = [ "getLogger", ] +if TYPE_CHECKING: + T = TypeVar("T") -def logsafe(val): + +P = ParamSpec("P") + + +def _logsafe(val: T) -> str | T: """Coerce `bytes` to `str` to avoid crashes solely due to logging. This is particularly relevant for bytestring paths. Much of our code @@ -83,40 +95,45 @@ class StrFormatLogger(Logger): """ class _LogMessage: - def __init__(self, msg, args, kwargs): + def __init__( + self, + msg: str, + args: logging._ArgsType, + kwargs: dict[str, Any], + ): self.msg = msg self.args = args self.kwargs = kwargs def __str__(self): - args = [logsafe(a) for a in self.args] - kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()} + args = [_logsafe(a) for a in self.args] + kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()} return self.msg.format(*args, **kwargs) def _log( self, - level, - msg, - args, - exc_info=None, - extra=None, - stack_info=False, + level: int, + msg: object, + args: logging._ArgsType = (), + exc_info: logging._ExcInfoType = None, + extra: Mapping[str, Any] | None = None, + stack_info: bool = False, + stacklevel: int = 1, **kwargs, ): """Log msg.format(*args, **kwargs)""" - m = self._LogMessage(msg, args, kwargs) - stacklevel = kwargs.pop("stacklevel", 1) - stacklevel = {"stacklevel": stacklevel} + if isinstance(msg, str): + msg = self._LogMessage(msg, args, kwargs) return super()._log( level, - m, + msg, (), exc_info=exc_info, extra=extra, stack_info=stack_info, - **stacklevel, + stacklevel=stacklevel, ) diff --git a/test/test_logging.py b/test/test_logging.py index b751dd70e..4a62e3a3b 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -3,10 +3,9 @@ import logging as log import sys import threading -import unittest -from io import StringIO from types import ModuleType -from unittest.mock import patch + +import pytest import beets.logging as blog from beets import plugins, ui @@ -14,8 +13,10 @@ from beets.test import _common, helper from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin -class LoggingTest(unittest.TestCase): - def test_logging_management(self): +class TestStrFormatLogger: + """Tests for the custom str-formatting logger.""" + + def test_logger_creation(self): l1 = log.getLogger("foo123") l2 = blog.getLogger("foo123") assert l1 == l2 @@ -35,17 +36,33 @@ class LoggingTest(unittest.TestCase): l6 = blog.getLogger() assert l1 != l6 - def test_str_format_logging(self): - logger = blog.getLogger("baz123") - stream = StringIO() - handler = log.StreamHandler(stream) + @pytest.mark.parametrize( + "level", [log.DEBUG, log.INFO, log.WARNING, log.ERROR] + ) + @pytest.mark.parametrize( + "msg, args, kwargs, expected", + [ + ("foo {} bar {}", ("oof", "baz"), {}, "foo oof bar baz"), + ( + "foo {bar} baz {foo}", + (), + {"foo": "oof", "bar": "baz"}, + "foo baz baz oof", + ), + ("no args", (), {}, "no args"), + ("foo {} bar {baz}", ("oof",), {"baz": "baz"}, "foo oof bar baz"), + ], + ) + def test_str_format_logging( + self, level, msg, args, kwargs, expected, caplog + ): + logger = blog.getLogger("test_logger") + logger.setLevel(level) - logger.addHandler(handler) - logger.propagate = False + with caplog.at_level(level, logger="test_logger"): + logger.log(level, msg, *args, **kwargs) - logger.warning("foo {} {bar}", "oof", bar="baz") - handler.flush() - assert stream.getvalue(), "foo oof baz" + assert str(caplog.records[0].msg) == expected class DummyModule(ModuleType): From f637e5efbb29c47a3d1800e5c9233ecdea74a644 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Fri, 19 Sep 2025 15:23:36 +0200 Subject: [PATCH 168/301] Added overload to getLogger function. Added changelog entry and added myself to codeowners file. --- .github/CODEOWNERS | 3 +++ beets/logging.py | 12 ++++++++---- docs/changelog.rst | 6 ++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 767509c9a..bb888d520 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,5 @@ # assign the entire repo to the maintainers team * @beetbox/maintainers + +# Specific ownerships: +/beets/metadata_plugins.py @semohr \ No newline at end of file diff --git a/beets/logging.py b/beets/logging.py index becfff86f..29357c0f0 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -35,9 +35,10 @@ from logging import ( Handler, Logger, NullHandler, + RootLogger, StreamHandler, ) -from typing import TYPE_CHECKING, Any, Mapping, TypeVar +from typing import TYPE_CHECKING, Any, Mapping, TypeVar, overload from typing_extensions import ParamSpec @@ -173,9 +174,12 @@ my_manager = copy(Logger.manager) my_manager.loggerClass = BeetsLogger -# Override the `getLogger` to use our machinery. -def getLogger(name=None): # noqa +@overload +def getLogger(name: str) -> BeetsLogger: ... +@overload +def getLogger(name: None = ...) -> RootLogger: ... +def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802 if name: - return my_manager.getLogger(name) + return my_manager.getLogger(name) # type: ignore[return-value] else: return Logger.root diff --git a/docs/changelog.rst b/docs/changelog.rst index 52e935445..38037955e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -51,6 +51,12 @@ Other changes: - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping. +For developers and plugin authors: + +- Typing improvements in ``beets/logging.py``: ``getLogger`` now returns + ``BeetsLogger`` when called with a name, or ``RootLogger`` when called without + a name. + 2.4.0 (September 13, 2025) -------------------------- From b2fc007480f50278c44eb5e7c2ea13181822a0bf Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Fri, 19 Sep 2025 15:35:51 +0200 Subject: [PATCH 169/301] Fixed plugin typehints: use actual logger class. --- beetsplug/fetchart.py | 2 +- beetsplug/lyrics.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 54c065da4..37e7426f6 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -36,10 +36,10 @@ from beets.util.config import sanitize_pairs if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence - from logging import Logger from beets.importer import ImportSession, ImportTask from beets.library import Album, Library + from beets.logging import BeetsLogger as Logger try: from bs4 import BeautifulSoup, Tag diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index f492ab3cc..d245d6a14 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -42,10 +42,9 @@ from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices if TYPE_CHECKING: - from logging import Logger - from beets.importer import ImportTask from beets.library import Item, Library + from beets.logging import BeetsLogger as Logger from ._typing import ( GeniusAPI, @@ -186,7 +185,7 @@ def slug(text: str) -> str: class RequestHandler: - _log: beets.logging.Logger + _log: Logger def debug(self, message: str, *args) -> None: """Log a debug message with the class name.""" From caebf185f11b24ab73ccbf0699e5078bccef6bcf Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Fri, 19 Sep 2025 15:39:43 +0200 Subject: [PATCH 170/301] Removed unused ParamSpec and added a consistency check in the tests. --- beets/logging.py | 5 ----- test/test_logging.py | 1 + 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index 29357c0f0..086a590a0 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -40,8 +40,6 @@ from logging import ( ) from typing import TYPE_CHECKING, Any, Mapping, TypeVar, overload -from typing_extensions import ParamSpec - __all__ = [ "DEBUG", "INFO", @@ -60,9 +58,6 @@ if TYPE_CHECKING: T = TypeVar("T") -P = ParamSpec("P") - - def _logsafe(val: T) -> str | T: """Coerce `bytes` to `str` to avoid crashes solely due to logging. diff --git a/test/test_logging.py b/test/test_logging.py index 4a62e3a3b..f4dda331e 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -62,6 +62,7 @@ class TestStrFormatLogger: with caplog.at_level(level, logger="test_logger"): logger.log(level, msg, *args, **kwargs) + assert caplog.records, "No log records were captured" assert str(caplog.records[0].msg) == expected From 837295e2502a220fea0798f8352756d85627ba07 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 21 Sep 2025 22:27:24 +0200 Subject: [PATCH 171/301] Added typehints from typeshed and removed default argument. --- beets/logging.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index 086a590a0..64d6c50b1 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -22,7 +22,6 @@ calls (`debug`, `info`, etc). from __future__ import annotations -import logging import threading from copy import copy from logging import ( @@ -40,6 +39,8 @@ from logging import ( ) from typing import TYPE_CHECKING, Any, Mapping, TypeVar, overload +from typing_extensions import TypeAlias + __all__ = [ "DEBUG", "INFO", @@ -56,6 +57,15 @@ __all__ = [ if TYPE_CHECKING: T = TypeVar("T") + from types import TracebackType + + # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi + _SysExcInfoType: TypeAlias = ( + tuple[type[BaseException], BaseException, TracebackType | None] + | tuple[None, None, None] + ) + _ExcInfoType: TypeAlias = None | bool | _SysExcInfoType | BaseException + _ArgsType: TypeAlias = tuple[object, ...] | Mapping[str, object] def _logsafe(val: T) -> str | T: @@ -94,7 +104,7 @@ class StrFormatLogger(Logger): def __init__( self, msg: str, - args: logging._ArgsType, + args: _ArgsType, kwargs: dict[str, Any], ): self.msg = msg @@ -110,8 +120,8 @@ class StrFormatLogger(Logger): self, level: int, msg: object, - args: logging._ArgsType = (), - exc_info: logging._ExcInfoType = None, + args: _ArgsType, + exc_info: _ExcInfoType = None, extra: Mapping[str, Any] | None = None, stack_info: bool = False, stacklevel: int = 1, From 89c2e10680b56873a07738b0a745205391df7425 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sun, 21 Sep 2025 22:29:55 +0200 Subject: [PATCH 172/301] Removed typealias, worked locally with mypy but does seem to cause issues with the ci. Also python 3.9 requires unions here... --- beets/logging.py | 16 +++++++--------- test/test_logging.py | 1 + 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index 64d6c50b1..3ed5e5a84 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -37,9 +37,7 @@ from logging import ( RootLogger, StreamHandler, ) -from typing import TYPE_CHECKING, Any, Mapping, TypeVar, overload - -from typing_extensions import TypeAlias +from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload __all__ = [ "DEBUG", @@ -60,12 +58,12 @@ if TYPE_CHECKING: from types import TracebackType # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi - _SysExcInfoType: TypeAlias = ( - tuple[type[BaseException], BaseException, TracebackType | None] - | tuple[None, None, None] - ) - _ExcInfoType: TypeAlias = None | bool | _SysExcInfoType | BaseException - _ArgsType: TypeAlias = tuple[object, ...] | Mapping[str, object] + _SysExcInfoType = Union[ + tuple[type[BaseException], BaseException, Union[TracebackType, None]], + tuple[None, None, None], + ] + _ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException] + _ArgsType = Union[tuple[object, ...], Mapping[str, object]] def _logsafe(val: T) -> str | T: diff --git a/test/test_logging.py b/test/test_logging.py index f4dda331e..48f9cbfd8 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -4,6 +4,7 @@ import logging as log import sys import threading from types import ModuleType +from unittest.mock import patch import pytest From 80ffa4879decc683829162efd238af8d15151f37 Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:46:26 +0200 Subject: [PATCH 173/301] Improve regexp and module docstring --- beetsplug/fromfilename.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 5b8bafc44..c3fb4bc6b 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -12,8 +12,8 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""If the title is empty, try to extract track and title from the -filename. +"""If the title is empty, try to extract it from the filename +(possibly also extract track and artist) """ import os @@ -30,7 +30,7 @@ PATTERNS = [ r"(\s*-\s*(?P<tag>.*))?$" ), r"^(?P<artist>.+?)\s*-\s*(?P<title>.+?)(\s*-\s*(?P<tag>.*))?$", - r"^(?P<track>\d+)\.?[\s\-_]+(?P<title>.+)$", + r"^(?P<track>\d+)\.?[\s_-]+(?P<title>.+)$", r"^(?P<title>.+) by (?P<artist>.+)$", r"^(?P<track>\d+).*$", r"^(?P<title>.+)$", From 638afc3d2cef3cdc337364f5fd6c4ae553f4f6ef Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:54:33 +0200 Subject: [PATCH 174/301] Refactor tests using pytest --- test/plugins/test_fromfilename.py | 195 ++++++++++++------------------ 1 file changed, 76 insertions(+), 119 deletions(-) diff --git a/test/plugins/test_fromfilename.py b/test/plugins/test_fromfilename.py index 511f63d38..f13e88aea 100644 --- a/test/plugins/test_fromfilename.py +++ b/test/plugins/test_fromfilename.py @@ -1,5 +1,4 @@ # This file is part of beets. -# Copyright 2016, Jan-Erik Dahlin. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -14,129 +13,87 @@ """Tests for the fromfilename plugin.""" -import unittest -from unittest.mock import Mock +import pytest from beetsplug import fromfilename -class FromfilenamePluginTest(unittest.TestCase): - def setUp(self): - """Create mock objects for import session and task.""" - self.session = Mock() - - item1config = {"path": "", "track": 0, "artist": "", "title": ""} - self.item1 = Mock(**item1config) - - item2config = {"path": "", "track": 0, "artist": "", "title": ""} - self.item2 = Mock(**item2config) - - taskconfig = {"is_album": True, "items": [self.item1, self.item2]} - self.task = Mock(**taskconfig) - - def tearDown(self): - del self.session, self.task, self.item1, self.item2 - - def test_sep_sds(self): - """Test filenames that use " - " as separator.""" - - self.item1.path = "/music/files/01 - Artist Name - Song One.m4a" - self.item2.path = "/music/files/02. - Artist Name - Song Two.m4a" - - f = fromfilename.FromFilenamePlugin() - f.filename_task(self.task, self.session) - - assert self.task.items[0].track == 1 - assert self.task.items[1].track == 2 - assert self.task.items[0].artist == "Artist Name" - assert self.task.items[1].artist == "Artist Name" - assert self.task.items[0].title == "Song One" - assert self.task.items[1].title == "Song Two" - - def test_sep_dash(self): - """Test filenames that use "-" as separator.""" - - self.item1.path = "/music/files/01-Artist_Name-Song_One.m4a" - self.item2.path = "/music/files/02.-Artist_Name-Song_Two.m4a" - - f = fromfilename.FromFilenamePlugin() - f.filename_task(self.task, self.session) - - assert self.task.items[0].track == 1 - assert self.task.items[1].track == 2 - assert self.task.items[0].artist == "Artist_Name" - assert self.task.items[1].artist == "Artist_Name" - assert self.task.items[0].title == "Song_One" - assert self.task.items[1].title == "Song_Two" - - def test_track_title(self): - """Test filenames including track and title.""" - - self.item1.path = "/music/files/01 - Song_One.m4a" - self.item2.path = "/music/files/02. Song_Two.m4a" - - f = fromfilename.FromFilenamePlugin() - f.filename_task(self.task, self.session) - - assert self.task.items[0].track == 1 - assert self.task.items[1].track == 2 - assert self.task.items[0].artist == "" - assert self.task.items[1].artist == "" - assert self.task.items[0].title == "Song_One" - assert self.task.items[1].title == "Song_Two" - - def test_title_by_artist(self): - """Test filenames including title by artist.""" - - self.item1.path = "/music/files/Song One by The Artist.m4a" - self.item2.path = "/music/files/Song Two by The Artist.m4a" - - f = fromfilename.FromFilenamePlugin() - f.filename_task(self.task, self.session) - - assert self.task.items[0].track == 0 - assert self.task.items[1].track == 0 - assert self.task.items[0].artist == "The Artist" - assert self.task.items[1].artist == "The Artist" - assert self.task.items[0].title == "Song One" - assert self.task.items[1].title == "Song Two" - - def test_track_only(self): - """Test filenames including only track.""" - - self.item1.path = "/music/files/01.m4a" - self.item2.path = "/music/files/02.m4a" - - f = fromfilename.FromFilenamePlugin() - f.filename_task(self.task, self.session) - - assert self.task.items[0].track == 1 - assert self.task.items[1].track == 2 - assert self.task.items[0].artist == "" - assert self.task.items[1].artist == "" - assert self.task.items[0].title == "01" - assert self.task.items[1].title == "02" - - def test_title_only(self): - """Test filenames including only title.""" - - self.item1.path = "/music/files/Song One.m4a" - self.item2.path = "/music/files/Song Two.m4a" - - f = fromfilename.FromFilenamePlugin() - f.filename_task(self.task, self.session) - - assert self.task.items[0].track == 0 - assert self.task.items[1].track == 0 - assert self.task.items[0].artist == "" - assert self.task.items[1].artist == "" - assert self.task.items[0].title == "Song One" - assert self.task.items[1].title == "Song Two" +class Session: + pass -def suite(): - return unittest.TestLoader().loadTestsFromName(__name__) +class Item: + def __init__(self, path): + self.path = path + self.track = 0 + self.artist = "" + self.title = "" -if __name__ == "__main__": - unittest.main(defaultTest="suite") +class Task: + def __init__(self, items): + self.items = items + self.is_album = True + + +@pytest.mark.parametrize( + "song1, song2", + [ + ( + ( + "/tmp/01 - The Artist - Song One.m4a", + 1, + "The Artist", + "Song One", + ), + ( + "/tmp/02. - The Artist - Song Two.m4a", + 2, + "The Artist", + "Song Two", + ), + ), + ( + ("/tmp/01-The_Artist-Song_One.m4a", 1, "The_Artist", "Song_One"), + ("/tmp/02.-The_Artist-Song_Two.m4a", 2, "The_Artist", "Song_Two"), + ), + ( + ("/tmp/01 - Song_One.m4a", 1, "", "Song_One"), + ("/tmp/02. - Song_Two.m4a", 2, "", "Song_Two"), + ), + ( + ("/tmp/Song One by The Artist.m4a", 0, "The Artist", "Song One"), + ("/tmp/Song Two by The Artist.m4a", 0, "The Artist", "Song Two"), + ), + (("/tmp/01.m4a", 1, "", "01"), ("/tmp/02.m4a", 2, "", "02")), + ( + ("/tmp/Song One.m4a", 0, "", "Song One"), + ("/tmp/Song Two.m4a", 0, "", "Song Two"), + ), + ], +) +def test_fromfilename(song1, song2): + """ + Each "song" is a tuple of path, expected track number, expected artist, + expected title. + + We use two songs for each test for two reasons: + - The plugin needs more than one item to look for uniform strings in paths + in order to guess if the string describes an artist or a title. + - Sometimes we allow for an optional "." after the track number in paths. + """ + + session = Session() + item1 = Item(song1[0]) + item2 = Item(song2[0]) + task = Task([item1, item2]) + + f = fromfilename.FromFilenamePlugin() + f.filename_task(task, session) + + assert task.items[0].track == song1[1] + assert task.items[0].artist == song1[2] + assert task.items[0].title == song1[3] + assert task.items[1].track == song2[1] + assert task.items[1].artist == song2[2] + assert task.items[1].title == song2[3] From b8ae222dc4b5b3fea97e9c7512f2017294ff3aec Mon Sep 17 00:00:00 2001 From: Vrihub <Vrihub@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:04:59 +0200 Subject: [PATCH 175/301] Mention tests in changelog entry. --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index af1edaa80..d01148286 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,8 +11,8 @@ New features: Bug fixes: -- :doc:`/plugins/fromfilename`: Fix :bug:`5218` and improve the code (refactor - regexps, allow for more cases, add some logging) +- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor + regexps, allow for more cases, add some logging), add tests. For packagers: From fcebe8123a455b172c8b4fed41d39c2d92ad33d8 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Tue, 30 Sep 2025 20:01:35 -0700 Subject: [PATCH 176/301] Expand documentation --- docs/plugins/discogs.rst | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 0446685d7..43b60148f 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -96,6 +96,11 @@ whereas with ``index_tracks`` disabled you'd get: This option is useful when importing classical music. +### Handling Artist Name Variations (ANVs) + +An ANV is an alternate way that an artist may be credited on a release. If the +band name changes or is misspelled on different releases. The artist name ac + Other configurations available under ``discogs:`` are: - **append_style_genre**: Appends the Discogs style (if found) to the genre tag. @@ -114,12 +119,20 @@ Other configurations available under ``discogs:`` are: disambiguation in your tags, you can disable it. Default: ``True`` - **featured_string**: Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.`` -- **artist_credit_anv**: Whether the Artist Name Variation (ANV) should be - applied to the artist credit tag. Default: ``True`` -- **track_artist_anv**: Same as ``artist_credit_anv`` for a track's artist - field. Default: ``False`` -- **album_artist_anv**: As previous, but for the album artist. Default: - ``False`` +- **artist_credit_anv**, **track_artist_anv**, **album_artist_anv**: These + configuration option are dedicated to handling Arist Name Variations (ANVs). + Sometimes a release credits artists differently compared to the majority of + their work. For example, "Basement Jaxx" may be credited as "Tha Jaxx" or "The + Basement Jaxx". By default, the Discogs plugin stores ANVs in the + ``artist_credit`` field. You can select any combination of these three to + control where beets writes and stores the variation credit. + + - **artist_credit_anv**: Write ANV to the ``artist_credit`` field. + Default: ``True`` + - **track_artist_anv**: Write ANV to the ``artist`` field. Default: + ``False`` + - **album_artist_anv**: Write ANV to the ``album_artist`` field. Default: + ``False`` .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings From 4782e96599e39efebd1a592a1df5acecc9c11c55 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <39738318+semohr@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:28:18 +0200 Subject: [PATCH 177/301] Move `vfs.py` to `beetsplug._utils` package to avoid polluting core namespace (#6017) This PR moves the `vfs.py` module, which is only used by plugins, to avoid polluting the main beets namespace. Also exposes the `vfs` and `art` module from beets with a deprecation warning. --- .git-blame-ignore-revs | 2 +- beets/__init__.py | 15 +++++++++++++++ beetsplug/_utils/__init__.py | 3 +++ {beets => beetsplug/_utils}/vfs.py | 18 +++++++++++++----- beetsplug/bench.py | 3 ++- beetsplug/bpd/__init__.py | 3 ++- docs/changelog.rst | 3 +++ test/{ => plugins/utils}/test_vfs.py | 2 +- 8 files changed, 40 insertions(+), 9 deletions(-) rename {beets => beetsplug/_utils}/vfs.py (82%) rename test/{ => plugins/utils}/test_vfs.py (97%) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 2ee64a97d..14b50859f 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -72,4 +72,4 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved plugin docs Further Reading chapter 33f1a5d0bef8ca08be79ee7a0d02a018d502680d # Moved art.py utility module from beets into beetsplug -28aee0fde463f1e18dfdba1994e2bdb80833722f \ No newline at end of file +28aee0fde463f1e18dfdba1994e2bdb80833722f diff --git a/beets/__init__.py b/beets/__init__.py index 10b0f58b0..65094330b 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,10 +17,25 @@ from sys import stderr import confuse +from .util import deprecate_imports + __version__ = "2.4.0" __author__ = "Adrian Sampson <adrian@radbox.org>" +def __getattr__(name: str): + """Handle deprecated imports.""" + return deprecate_imports( + old_module=__name__, + new_module_by_name={ + "art": "beetsplug._utils", + "vfs": "beetsplug._utils", + }, + name=name, + version="3.0.0", + ) + + class IncludeLazyConfig(confuse.LazyConfig): """A version of Confuse's LazyConfig that also merges in data from YAML files specified in an `include` setting. diff --git a/beetsplug/_utils/__init__.py b/beetsplug/_utils/__init__.py index e69de29bb..7453f88bf 100644 --- a/beetsplug/_utils/__init__.py +++ b/beetsplug/_utils/__init__.py @@ -0,0 +1,3 @@ +from . import art, vfs + +__all__ = ["art", "vfs"] diff --git a/beets/vfs.py b/beetsplug/_utils/vfs.py similarity index 82% rename from beets/vfs.py rename to beetsplug/_utils/vfs.py index 4fd133f5a..6294b644c 100644 --- a/beets/vfs.py +++ b/beetsplug/_utils/vfs.py @@ -16,17 +16,25 @@ libraries. """ -from typing import Any, NamedTuple +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple from beets import util +if TYPE_CHECKING: + from beets.library import Library + class Node(NamedTuple): - files: dict[str, Any] - dirs: dict[str, Any] + files: dict[str, int] + # Maps filenames to Item ids. + + dirs: dict[str, Node] + # Maps directory names to child nodes. -def _insert(node, path, itemid): +def _insert(node: Node, path: list[str], itemid: int): """Insert an item into a virtual filesystem node.""" if len(path) == 1: # Last component. Insert file. @@ -40,7 +48,7 @@ def _insert(node, path, itemid): _insert(node.dirs[dirname], rest, itemid) -def libtree(lib): +def libtree(lib: Library) -> Node: """Generates a filesystem-like directory tree for the files contained in `lib`. Filesystem nodes are (files, dirs) named tuples in which both components are dictionaries. The first diff --git a/beetsplug/bench.py b/beetsplug/bench.py index cf72527e8..d77f1f92a 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -17,10 +17,11 @@ import cProfile import timeit -from beets import importer, library, plugins, ui, vfs +from beets import importer, library, plugins, ui from beets.autotag import match from beets.plugins import BeetsPlugin from beets.util.functemplate import Template +from beetsplug._utils import vfs def aunique_benchmark(lib, prof): diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index aa7013150..1a4f505dd 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -30,10 +30,11 @@ from typing import TYPE_CHECKING import beets import beets.ui -from beets import dbcore, logging, vfs +from beets import dbcore, logging from beets.library import Item from beets.plugins import BeetsPlugin from beets.util import as_string, bluelet +from beetsplug._utils import vfs if TYPE_CHECKING: from beets.dbcore.query import Query diff --git a/docs/changelog.rst b/docs/changelog.rst index 38037955e..b22bcd44b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,9 @@ Other changes: - Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it is not used in the core beets codebase. It can now be found in ``beetsplug._utils``. +- Moved ``vfs.py`` utility module from ``beets`` into ``beetsplug`` namespace as + it is not used in the core beets codebase. It can now be found in + ``beetsplug._utils``. - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping. diff --git a/test/test_vfs.py b/test/plugins/utils/test_vfs.py similarity index 97% rename from test/test_vfs.py rename to test/plugins/utils/test_vfs.py index 7f75fbd83..9505075f9 100644 --- a/test/test_vfs.py +++ b/test/plugins/utils/test_vfs.py @@ -14,9 +14,9 @@ """Tests for the virtual filesystem builder..""" -from beets import vfs from beets.test import _common from beets.test.helper import BeetsTestCase +from beetsplug._utils import vfs class VFSTest(BeetsTestCase): From f6ca68319d25e78eefa76d1e6e0da6a4329fbe58 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <39738318+semohr@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:58:57 +0200 Subject: [PATCH 178/301] Add git commit suffix to __version__ for development installs (#5967) Make it obvious when beets is installed from from a non major version. When installed locally this adds a git hash suffix and the distance to the last release. closes #4448 --- .gitignore | 3 +++ beets/__init__.py | 6 +++++- beets/_version.py | 7 +++++++ docs/changelog.rst | 2 ++ extra/release.py | 6 ------ pyproject.toml | 13 +++++++++++-- 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 beets/_version.py diff --git a/.gitignore b/.gitignore index 90ef7387d..102e1c3e4 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ ENV/ # pyright pyrightconfig.json + +# Versioning +beets/_version.py diff --git a/beets/__init__.py b/beets/__init__.py index 65094330b..5f4c6657d 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,9 +17,10 @@ from sys import stderr import confuse +# Version management using poetry-dynamic-versioning +from ._version import __version__, __version_tuple__ from .util import deprecate_imports -__version__ = "2.4.0" __author__ = "Adrian Sampson <adrian@radbox.org>" @@ -54,3 +55,6 @@ class IncludeLazyConfig(confuse.LazyConfig): config = IncludeLazyConfig("beets", __name__) + + +__all__ = ["__version__", "__version_tuple__", "config"] diff --git a/beets/_version.py b/beets/_version.py new file mode 100644 index 000000000..4dea56035 --- /dev/null +++ b/beets/_version.py @@ -0,0 +1,7 @@ +# This file is auto-generated during the build process. +# Do not edit this file directly. +# Placeholders are replaced during substitution. +# Run `git update-index --assume-unchanged beets/_version.py` +# to ignore local changes to this file. +__version__ = "0.0.0" +__version_tuple__ = (0, 0, 0) diff --git a/docs/changelog.rst b/docs/changelog.rst index b22bcd44b..a39b3db63 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,6 +53,8 @@ Other changes: ``beetsplug._utils``. - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping. +- When installing ``beets`` via git or locally the version string now reflects + the current git branch and commit hash. :bug:`4448` For developers and plugin authors: diff --git a/extra/release.py b/extra/release.py index 647cc49c9..b47de8966 100755 --- a/extra/release.py +++ b/extra/release.py @@ -174,12 +174,6 @@ FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ PYPROJECT, lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), ), - ( - BASE / "beets" / "__init__.py", - lambda text, new: re.sub( - r"(?<=__version__ = )[^\n]+", f'"{new}"', text - ), - ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] diff --git a/pyproject.toml b/pyproject.toml index 2546360ad..8338ce1c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,9 +156,18 @@ web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" + +[tool.poetry-dynamic-versioning] +enable = true +vcs = "git" +format = "{base}.dev{distance}+{commit}" + +[tool.poetry-dynamic-versioning.files."beets/_version.py"] +persistent-substitution = true + [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] +build-backend = "poetry_dynamic_versioning.backend" [tool.pipx-install] poethepoet = ">=0.26" From 70a4d0462dea54b6dd59affd8c65489be795ead8 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Wed, 1 Oct 2025 17:15:13 -0400 Subject: [PATCH 179/301] Persist spotify track attributes even if audio features are missing --- beetsplug/spotify.py | 11 +++++------ docs/changelog.rst | 3 +++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 0f6e0012b..f78041094 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -694,14 +694,13 @@ class SpotifyPlugin( audio_features = self.track_audio_features(spotify_track_id) if audio_features is None: self._log.info("No audio features found for: {}", item) - continue - for feature in audio_features.keys(): - if feature in self.spotify_audio_features.keys(): - item[self.spotify_audio_features[feature]] = audio_features[ - feature - ] + else: + for feature, value in audio_features.items(): + if feature in self.spotify_audio_features: + item[self.spotify_audio_features[feature]] = value item["spotify_updated"] = time.time() item.store() + self._log.debug("Stored spotify_track_popularity={} for {} (item id {})", item.get("spotify_track_popularity"), item.get("title"), item.id) if write: item.try_write() diff --git a/docs/changelog.rst b/docs/changelog.rst index a39b3db63..8c799783f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,9 @@ New features: Bug fixes: +- :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, + and related fields current even when audio features requests fail. + :bug:`6061` - :doc:`plugins/spotify` Fixed an issue where track matching and lookups could return incorrect or misleading results when using the Spotify plugin. The problem occurred primarily when no album was provided or when the album field From 7a097bb4b6dd2ec1f121737c36c82dc182951995 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Wed, 1 Oct 2025 17:47:26 -0400 Subject: [PATCH 180/301] lint --- docs/changelog.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8c799783f..e74f0caa2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,9 +19,8 @@ New features: Bug fixes: -- :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, - and related fields current even when audio features requests fail. - :bug:`6061` +- :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, and + related fields current even when audio features requests fail. :bug:`6061` - :doc:`plugins/spotify` Fixed an issue where track matching and lookups could return incorrect or misleading results when using the Spotify plugin. The problem occurred primarily when no album was provided or when the album field From b66b2b51b5171a84bd3d20b96b6b101447ba132f Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Wed, 1 Oct 2025 17:48:41 -0400 Subject: [PATCH 181/301] Remove potentially expensive item.get() calls --- beetsplug/spotify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index f78041094..7cb9e330d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -700,7 +700,6 @@ class SpotifyPlugin( item[self.spotify_audio_features[feature]] = value item["spotify_updated"] = time.time() item.store() - self._log.debug("Stored spotify_track_popularity={} for {} (item id {})", item.get("spotify_track_popularity"), item.get("title"), item.id) if write: item.try_write() From 93c8950bf40dc40278e3c2aa4cbffd9cb2aab591 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Thu, 2 Oct 2025 08:24:01 -0400 Subject: [PATCH 182/301] =?UTF-8?q?Extends=20the=20importer=E2=80=99s=20?= =?UTF-8?q?=E2=80=9Cfresh=20on=20reimport=E2=80=9D=20lists=20so=20album=20?= =?UTF-8?q?flex=20metadata=20from=20new=20releases=20replaces=20stale=20va?= =?UTF-8?q?lues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- beetsplug/musicbrainz.py | 23 ++++++++++++++++++++++- docs/changelog.rst | 2 ++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..772972c07 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -29,7 +29,7 @@ from confuse.exceptions import NotFoundError import beets import beets.autotag.hooks -from beets import config, plugins, util +from beets import config, importer, plugins, util from beets.metadata_plugins import MetadataSourcePlugin from beets.util.id_extractors import extract_release_id @@ -55,6 +55,26 @@ FIELDS_TO_MB_KEYS = { "year": "date", } +_MB_REIMPORT_FRESH_FIELDS_ALBUM = [ + "media", + "releasegroup_id", + "data_url", +] +_MB_REIMPORT_FRESH_FIELDS_ITEM = [ + "data_url", +] + + +def _extend_reimport_fresh_fields() -> None: + """Ensure MusicBrainz fields stored as flex attrs refresh on reimport.""" + for field in _MB_REIMPORT_FRESH_FIELDS_ALBUM: + if field not in importer.REIMPORT_FRESH_FIELDS_ALBUM: + importer.REIMPORT_FRESH_FIELDS_ALBUM.append(field) + for field in _MB_REIMPORT_FRESH_FIELDS_ITEM: + if field not in importer.REIMPORT_FRESH_FIELDS_ITEM: + importer.REIMPORT_FRESH_FIELDS_ITEM.append(field) + + musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") @@ -367,6 +387,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): from the beets configuration. This should be called at startup. """ super().__init__() + _extend_reimport_fresh_fields() self.config.add( { "host": "musicbrainz.org", diff --git a/docs/changelog.rst b/docs/changelog.rst index e74f0caa2..74093596e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,8 @@ New features: Bug fixes: +- :doc:`plugins/musicbrainz` Refresh flexible MusicBrainz metadata on reimport + so format changes are applied. :bug:`6036` - :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, and related fields current even when audio features requests fail. :bug:`6061` - :doc:`plugins/spotify` Fixed an issue where track matching and lookups could From 20820fdb14d9c469307c9bc809c9792182df0f50 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Thu, 2 Oct 2025 08:31:30 -0400 Subject: [PATCH 183/301] update imports --- beetsplug/musicbrainz.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 772972c07..c4f252e33 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -29,7 +29,11 @@ from confuse.exceptions import NotFoundError import beets import beets.autotag.hooks -from beets import config, importer, plugins, util +from beets import config, plugins, util +from beets.importer.tasks import ( + REIMPORT_FRESH_FIELDS_ALBUM, + REIMPORT_FRESH_FIELDS_ITEM, +) from beets.metadata_plugins import MetadataSourcePlugin from beets.util.id_extractors import extract_release_id @@ -68,11 +72,11 @@ _MB_REIMPORT_FRESH_FIELDS_ITEM = [ def _extend_reimport_fresh_fields() -> None: """Ensure MusicBrainz fields stored as flex attrs refresh on reimport.""" for field in _MB_REIMPORT_FRESH_FIELDS_ALBUM: - if field not in importer.REIMPORT_FRESH_FIELDS_ALBUM: - importer.REIMPORT_FRESH_FIELDS_ALBUM.append(field) + if field not in REIMPORT_FRESH_FIELDS_ALBUM: + REIMPORT_FRESH_FIELDS_ALBUM.append(field) for field in _MB_REIMPORT_FRESH_FIELDS_ITEM: - if field not in importer.REIMPORT_FRESH_FIELDS_ITEM: - importer.REIMPORT_FRESH_FIELDS_ITEM.append(field) + if field not in REIMPORT_FRESH_FIELDS_ITEM: + REIMPORT_FRESH_FIELDS_ITEM.append(field) musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") From 033e613016c42bd98f8681c26b76078824084f37 Mon Sep 17 00:00:00 2001 From: MinchinWeb <w_minchin@hotmail.com> Date: Fri, 3 Oct 2025 14:48:56 -0600 Subject: [PATCH 184/301] fix spacing for "finding tags for" prompt --- beets/ui/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index a6fbb3500..b52e965b7 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -904,7 +904,7 @@ def choose_candidate( # Display list of candidates. print_("") print_( - f"Finding tags for {'track' if singleton else 'album'}" + f"Finding tags for {'track' if singleton else 'album'} " f'"{item.artist if singleton else cur_artist} -' f' {item.title if singleton else cur_album}".' ) From f5acdec2b104b84b0b02000a2bacfc0962a4965f Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 3 Oct 2025 14:44:22 -0700 Subject: [PATCH 185/301] Update configuration format. --- beetsplug/discogs.py | 24 +++++++++++++----------- docs/plugins/discogs.rst | 31 +++++++++++++------------------ test/plugins/test_discogs.py | 12 ++++++------ 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 3c42a5621..ef479f686 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -100,9 +100,11 @@ class DiscogsPlugin(MetadataSourcePlugin): "featured_string": "Feat.", "append_style_genre": False, "strip_disambiguation": True, - "album_artist_anv": False, - "track_artist_anv": False, - "artist_credit_anv": True, + "anv": { + "artist_credit": True, + "artist": False, + "album_artist": False, + }, } ) self.config["apikey"].redact = True @@ -368,9 +370,9 @@ class DiscogsPlugin(MetadataSourcePlugin): ) # Assign ANV to the proper fields for tagging - if not self.config["artist_credit_anv"]: + if not self.config["anv"]["artist_credit"]: artist_credit = album_artist - if self.config["album_artist_anv"]: + if self.config["anv"]["album_artist"]: album_artist = album_artist_anv # Extract information for the optional AlbumInfo fields, if possible. @@ -674,9 +676,9 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_anv, artist_id = album_artist_data artist_credit = artist_anv - if not self.config["artist_credit_anv"]: + if not self.config["anv"]["artist_credit"]: artist_credit = artist - if self.config["track_artist_anv"]: + if self.config["anv"]["artist"]: artist = artist_anv title = track["title"] @@ -690,10 +692,10 @@ class DiscogsPlugin(MetadataSourcePlugin): # If artists are found on the track, we will use those instead if artists := track.get("artists", []): artist, artist_id = self.get_artist_with_anv( - artists, self.config["track_artist_anv"] + artists, self.config["anv"]["artist"] ) artist_credit, _ = self.get_artist_with_anv( - artists, self.config["artist_credit_anv"] + artists, self.config["anv"]["artist_credit"] ) length = self.get_track_length(track["duration"]) @@ -705,10 +707,10 @@ class DiscogsPlugin(MetadataSourcePlugin): if "Featuring" in artist["role"] ] featured, _ = self.get_artist_with_anv( - featured_list, self.config["track_artist_anv"] + featured_list, self.config["anv"]["artist"] ) featured_credit, _ = self.get_artist_with_anv( - featured_list, self.config["artist_credit_anv"] + featured_list, self.config["anv"]["artist_credit"] ) if featured: artist += f" {self.config['featured_string']} {featured}" diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 43b60148f..0d55630c4 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -96,11 +96,6 @@ whereas with ``index_tracks`` disabled you'd get: This option is useful when importing classical music. -### Handling Artist Name Variations (ANVs) - -An ANV is an alternate way that an artist may be credited on a release. If the -band name changes or is misspelled on different releases. The artist name ac - Other configurations available under ``discogs:`` are: - **append_style_genre**: Appends the Discogs style (if found) to the genre tag. @@ -119,20 +114,20 @@ Other configurations available under ``discogs:`` are: disambiguation in your tags, you can disable it. Default: ``True`` - **featured_string**: Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.`` -- **artist_credit_anv**, **track_artist_anv**, **album_artist_anv**: These - configuration option are dedicated to handling Arist Name Variations (ANVs). - Sometimes a release credits artists differently compared to the majority of - their work. For example, "Basement Jaxx" may be credited as "Tha Jaxx" or "The - Basement Jaxx". By default, the Discogs plugin stores ANVs in the - ``artist_credit`` field. You can select any combination of these three to - control where beets writes and stores the variation credit. +- **anv**: These configuration option are dedicated to handling Artist Name + Variations (ANVs). Sometimes a release credits artists differently compared to + the majority of their work. For example, "Basement Jaxx" may be credited as + "Tha Jaxx" or "The Basement Jaxx".You can select any combination of these + config options to control where beets writes and stores the variation credit. + The default, shown below, writes variations to the artist_credit field. - - **artist_credit_anv**: Write ANV to the ``artist_credit`` field. - Default: ``True`` - - **track_artist_anv**: Write ANV to the ``artist`` field. Default: - ``False`` - - **album_artist_anv**: Write ANV to the ``album_artist`` field. Default: - ``False`` +.. code-block:: yaml + + discogs: + anv: + artist_credit: True + artist: False + album_artist: False .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 40dd30e53..eb65bc588 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -517,9 +517,9 @@ def test_anv( title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) - config["discogs"]["album_artist_anv"] = album_artist_anv - config["discogs"]["track_artist_anv"] = track_artist_anv - config["discogs"]["artist_credit_anv"] = artist_credit_anv + config["discogs"]["anv"]["album_artist"] = album_artist_anv + config["discogs"]["anv"]["artist"] = track_artist_anv + config["discogs"]["anv"]["artist_credit"] = artist_credit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == album_artist assert r.artist_credit == album_artist_credit @@ -553,9 +553,9 @@ def test_anv_album_artist(): title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) - config["discogs"]["album_artist_anv"] = False - config["discogs"]["track_artist_anv"] = True - config["discogs"]["artist_credit_anv"] = False + config["discogs"]["anv"]["album_artist"] = False + config["discogs"]["anv"]["artist"] = True + config["discogs"]["anv"]["artist_credit"] = False r = DiscogsPlugin().get_album_info(release) assert r.artist == "ARTIST" assert r.artist_credit == "ARTIST" From b9a840a2a3aa182e76db6c2268ca4dc96fd2f2e8 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 3 Oct 2025 15:01:34 -0700 Subject: [PATCH 186/301] Update all functions with types --- beetsplug/discogs.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index ef479f686..d06cecd38 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -30,7 +30,7 @@ from string import ascii_lowercase from typing import TYPE_CHECKING, Sequence import confuse -from discogs_client import Client, Master, Release +from discogs_client import Client, Master, Release, Track from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError from typing_extensions import TypedDict @@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Callable, Iterable, Tuple from beets.library import Item @@ -104,7 +104,7 @@ class DiscogsPlugin(MetadataSourcePlugin): "artist_credit": True, "artist": False, "album_artist": False, - }, + }, } ) self.config["apikey"].redact = True @@ -147,7 +147,7 @@ class DiscogsPlugin(MetadataSourcePlugin): """Get the path to the JSON file for storing the OAuth token.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) - def authenticate(self, c_key, c_secret): + def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]: # Get the link for the OAuth page. auth_client = Client(USER_AGENT, c_key, c_secret) try: @@ -323,7 +323,7 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_id = self.get_artist(artist_list, join_key="join") return self.strip_disambiguation(artist), artist_id - def get_album_info(self, result): + def get_album_info(self, result: Release) -> AlbumInfo: """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet # present if the result is from a `discogs_client.search()`. @@ -459,7 +459,7 @@ class DiscogsPlugin(MetadataSourcePlugin): cover_art_url=cover_art_url, ) - def select_cover_art(self, result): + def select_cover_art(self, result: Release) -> str | None: """Returns the best candidate image, if any, from a Discogs `Release` object.""" if result.data.get("images") and len(result.data.get("images")) > 0: # The first image in this list appears to be the one displayed first @@ -469,7 +469,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return None - def format(self, classification): + def format(self, classification: Iterable[str]) -> str | None: if classification: return ( self.config["separator"].as_str().join(sorted(classification)) @@ -477,7 +477,11 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def get_tracks(self, tracklist, album_artist_data): + def get_tracks( + self, + tracklist: Iterable[Track], + album_artist_data: Tuple[str, str, int], + ) -> Iterable[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: clean_tracklist = self.coalesce_tracks(tracklist) @@ -579,13 +583,17 @@ class DiscogsPlugin(MetadataSourcePlugin): return tracks - def coalesce_tracks(self, raw_tracklist): + def coalesce_tracks( + self, raw_tracklist: Iterable[Track] + ) -> Iterable[Track]: """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ - def add_merged_subtracks(tracklist, subtracks): + def add_merged_subtracks( + tracklist: Iterable[Track], subtracks: Iterable[Track] + ) -> Iterable[Track]: """Modify `tracklist` in place, merging a list of `subtracks` into a single track into `tracklist`.""" # Calculate position based on first subtrack, without subindex. @@ -671,7 +679,13 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) - def get_track_info(self, track, index, divisions, album_artist_data): + def get_track_info( + self, + track: Track, + index: int, + divisions: int, + album_artist_data: Tuple[str, str, int], + ) -> TrackInfo: """Returns a TrackInfo object for a discogs track.""" artist, artist_anv, artist_id = album_artist_data @@ -746,7 +760,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return medium or None, index or None, subindex or None - def get_track_length(self, duration): + def get_track_length(self, duration: str) -> int: """Returns the track length in seconds for a discogs duration.""" try: length = time.strptime(duration, "%M:%S") From a909dddd1655ef93a55784ec1834497aaace76b4 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 3 Oct 2025 18:52:37 -0700 Subject: [PATCH 187/301] adding typechecks, need to fix the medium discrepancy --- beetsplug/discogs.py | 56 +++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index d06cecd38..3618b2f1d 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -27,13 +27,13 @@ import time import traceback from functools import cache from string import ascii_lowercase -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING, Sequence, cast import confuse -from discogs_client import Client, Master, Release, Track +from discogs_client import Client, Master, Release from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError -from typing_extensions import TypedDict +from typing_extensions import TypedDict, NotRequired import beets import beets.ui @@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Tuple + from collections.abc import Callable, Iterable from beets.library import Item @@ -84,6 +84,25 @@ class ReleaseFormat(TypedDict): qty: int descriptions: list[str] | None +class Artist(TypedDict): + name: str + anv: str + join: str + role: str + tracks: str + id: str + resource_url: str + +class Track(TypedDict): + position: str + type_: str + title: str + duration: str + artists: list[Artist] + extraartists: NotRequired[list[Artist]] + +class TrackWithSubtrack(Track): + sub_tracks: NotRequired[list[Track]] class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): @@ -309,7 +328,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return media, albumtype def get_artist_with_anv( - self, artists: Iterable[dict[str | int, str]], use_anv: bool = False + self, artists: list[Artist], use_anv: bool = False ) -> tuple[str, str | None]: """Iterates through a discogs result, fetching data if the artist anv is to be used, maps that to the name. @@ -320,10 +339,10 @@ class DiscogsPlugin(MetadataSourcePlugin): if use_anv and (anv := a.get("anv", "")): a["name"] = anv artist_list.append(a) - artist, artist_id = self.get_artist(artist_list, join_key="join") + artist, artist_id = self.get_artist(cast(Iterable[dict[str | int, str]], artist_list), join_key="join") return self.strip_disambiguation(artist), artist_id - def get_album_info(self, result: Release) -> AlbumInfo: + def get_album_info(self, result: Release) -> AlbumInfo | None: """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet # present if the result is from a `discogs_client.search()`. @@ -479,9 +498,9 @@ class DiscogsPlugin(MetadataSourcePlugin): def get_tracks( self, - tracklist: Iterable[Track], - album_artist_data: Tuple[str, str, int], - ) -> Iterable[TrackInfo]: + tracklist: list[Track], + album_artist_data: tuple[str, str, str | None], + ) -> list[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: clean_tracklist = self.coalesce_tracks(tracklist) @@ -496,7 +515,8 @@ class DiscogsPlugin(MetadataSourcePlugin): index_tracks = {} index = 0 # Distinct works and intra-work divisions, as defined by index tracks. - divisions, next_divisions = [], [] + divisions: list[str] = [] + next_divisions: list[str] = [] for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track["position"]: @@ -584,16 +604,14 @@ class DiscogsPlugin(MetadataSourcePlugin): return tracks def coalesce_tracks( - self, raw_tracklist: Iterable[Track] - ) -> Iterable[Track]: + self, raw_tracklist + ): """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ - def add_merged_subtracks( - tracklist: Iterable[Track], subtracks: Iterable[Track] - ) -> Iterable[Track]: + def add_merged_subtracks(tracklist, subtracks): """Modify `tracklist` in place, merging a list of `subtracks` into a single track into `tracklist`.""" # Calculate position based on first subtrack, without subindex. @@ -683,8 +701,8 @@ class DiscogsPlugin(MetadataSourcePlugin): self, track: Track, index: int, - divisions: int, - album_artist_data: Tuple[str, str, int], + divisions: list[str], + album_artist_data: tuple[str, str, str | None], ) -> TrackInfo: """Returns a TrackInfo object for a discogs track.""" @@ -760,7 +778,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return medium or None, index or None, subindex or None - def get_track_length(self, duration: str) -> int: + def get_track_length(self, duration: str) -> int | None: """Returns the track length in seconds for a discogs duration.""" try: length = time.strptime(duration, "%M:%S") From 2a80bdef2e358f7b34a973639a7c5211809a37c8 Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 4 Oct 2025 11:03:17 -0700 Subject: [PATCH 188/301] Added type hints to all functions --- beetsplug/discogs.py | 112 ++++++++++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 39 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 3618b2f1d..1ef07be68 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -33,7 +33,7 @@ import confuse from discogs_client import Client, Master, Release from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError -from typing_extensions import TypedDict, NotRequired +from typing_extensions import NotRequired, TypedDict import beets import beets.ui @@ -84,6 +84,7 @@ class ReleaseFormat(TypedDict): qty: int descriptions: list[str] | None + class Artist(TypedDict): name: str anv: str @@ -93,6 +94,7 @@ class Artist(TypedDict): id: str resource_url: str + class Track(TypedDict): position: str type_: str @@ -101,8 +103,23 @@ class Track(TypedDict): artists: list[Artist] extraartists: NotRequired[list[Artist]] -class TrackWithSubtrack(Track): - sub_tracks: NotRequired[list[Track]] + +class TrackWithSubtracks(Track): + sub_tracks: list[TrackWithSubtracks] + + +class IntermediateTrackInfo(TrackInfo): + """Allows work with string mediums from + get_track_info""" + + def __init__( + self, + medium_str: str | None, + **kwargs, + ) -> None: + self.medium_str = medium_str + super().__init__(**kwargs) + class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): @@ -131,7 +148,7 @@ class DiscogsPlugin(MetadataSourcePlugin): self.config["user_token"].redact = True self.setup() - def setup(self, session=None): + def setup(self, session=None) -> None: """Create the `discogs_client` field. Authenticate if necessary.""" c_key = self.config["apikey"].as_str() c_secret = self.config["apisecret"].as_str() @@ -157,12 +174,12 @@ class DiscogsPlugin(MetadataSourcePlugin): self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret) - def reset_auth(self): + def reset_auth(self) -> None: """Delete token file & redo the auth steps.""" os.remove(self._tokenfile()) self.setup() - def _tokenfile(self): + def _tokenfile(self) -> str: """Get the path to the JSON file for storing the OAuth token.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) @@ -339,7 +356,9 @@ class DiscogsPlugin(MetadataSourcePlugin): if use_anv and (anv := a.get("anv", "")): a["name"] = anv artist_list.append(a) - artist, artist_id = self.get_artist(cast(Iterable[dict[str | int, str]], artist_list), join_key="join") + artist, artist_id = self.get_artist( + cast(list[dict[str | int, str]], artist_list), join_key="join" + ) return self.strip_disambiguation(artist), artist_id def get_album_info(self, result: Release) -> AlbumInfo | None: @@ -496,25 +515,15 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def get_tracks( + def _process_clean_tracklist( self, - tracklist: list[Track], + clean_tracklist: list[Track], album_artist_data: tuple[str, str, str | None], - ) -> list[TrackInfo]: - """Returns a list of TrackInfo objects for a discogs tracklist.""" - try: - clean_tracklist = self.coalesce_tracks(tracklist) - except Exception as exc: - # FIXME: this is an extra precaution for making sure there are no - # side effects after #2222. It should be removed after further - # testing. - self._log.debug("{}", traceback.format_exc()) - self._log.error("uncaught exception in coalesce_tracks: {}", exc) - clean_tracklist = tracklist - tracks = [] + ) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]: + # Distinct works and intra-work divisions, as defined by index tracks. + tracks: list[TrackInfo] = [] index_tracks = {} index = 0 - # Distinct works and intra-work divisions, as defined by index tracks. divisions: list[str] = [] next_divisions: list[str] = [] for track in clean_tracklist: @@ -540,7 +549,29 @@ class DiscogsPlugin(MetadataSourcePlugin): except IndexError: pass index_tracks[index + 1] = track["title"] + return tracks, index_tracks, index, divisions, next_divisions + def get_tracks( + self, + tracklist: list[Track], + album_artist_data: tuple[str, str, str | None], + ) -> list[TrackInfo]: + """Returns a list of TrackInfo objects for a discogs tracklist.""" + try: + clean_tracklist: list[Track] = self.coalesce_tracks( + cast(list[TrackWithSubtracks], tracklist) + ) + except Exception as exc: + # FIXME: this is an extra precaution for making sure there are no + # side effects after #2222. It should be removed after further + # testing. + self._log.debug("{}", traceback.format_exc()) + self._log.error("uncaught exception in coalesce_tracks: {}", exc) + clean_tracklist = tracklist + processed = self._process_clean_tracklist( + clean_tracklist, album_artist_data + ) + tracks, index_tracks, index, divisions, next_divisions = processed # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None @@ -549,8 +580,8 @@ class DiscogsPlugin(MetadataSourcePlugin): # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. - if all([track.medium is not None for track in tracks]): - m = sorted({track.medium.lower() for track in tracks}) + if all([track.medium_str is not None for track in tracks]): + m = sorted({track.medium_str.lower() for track in tracks}) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if "".join(m) in ascii_lowercase: @@ -564,17 +595,17 @@ class DiscogsPlugin(MetadataSourcePlugin): # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. medium_is_index = ( - track.medium + track.medium_str and not track.medium_index and ( - len(track.medium) != 1 + len(track.medium_str) != 1 or # Not within standard incremental medium values (A, B, C, ...). - ord(track.medium) - 64 != side_count + 1 + ord(track.medium_str) - 64 != side_count + 1 ) ) - if not medium_is_index and medium != track.medium: + if not medium_is_index and medium != track.medium_str: side_count += 1 if sides_per_medium == 2: if side_count % sides_per_medium: @@ -585,7 +616,7 @@ class DiscogsPlugin(MetadataSourcePlugin): # Medium changed. Reset index_count. medium_count += 1 index_count = 0 - medium = track.medium + medium = track.medium_str index_count += 1 medium_count = 1 if medium_count == 0 else medium_count @@ -601,17 +632,20 @@ class DiscogsPlugin(MetadataSourcePlugin): disctitle = None track.disctitle = disctitle - return tracks + return cast(list[TrackInfo], tracks) def coalesce_tracks( - self, raw_tracklist - ): + self, raw_tracklist: list[TrackWithSubtracks] + ) -> list[Track]: """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ - def add_merged_subtracks(tracklist, subtracks): + def add_merged_subtracks( + tracklist: list[TrackWithSubtracks], + subtracks: list[TrackWithSubtracks], + ) -> None: """Modify `tracklist` in place, merging a list of `subtracks` into a single track into `tracklist`.""" # Calculate position based on first subtrack, without subindex. @@ -651,8 +685,8 @@ class DiscogsPlugin(MetadataSourcePlugin): tracklist.append(track) # Pre-process the tracklist, trying to identify subtracks. - subtracks = [] - tracklist = [] + subtracks: list[TrackWithSubtracks] = [] + tracklist: list[TrackWithSubtracks] = [] prev_subindex = "" for track in raw_tracklist: # Regular subtrack (track with subindex). @@ -687,7 +721,7 @@ class DiscogsPlugin(MetadataSourcePlugin): if subtracks: add_merged_subtracks(tracklist, subtracks) - return tracklist + return cast(list[Track], tracklist) def strip_disambiguation(self, text: str) -> str: """Removes discogs specific disambiguations from a string. @@ -703,7 +737,7 @@ class DiscogsPlugin(MetadataSourcePlugin): index: int, divisions: list[str], album_artist_data: tuple[str, str, str | None], - ) -> TrackInfo: + ) -> IntermediateTrackInfo: """Returns a TrackInfo object for a discogs track.""" artist, artist_anv, artist_id = album_artist_data @@ -749,7 +783,7 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_credit += ( f" {self.config['featured_string']} {featured_credit}" ) - return TrackInfo( + return IntermediateTrackInfo( title=title, track_id=track_id, artist_credit=artist_credit, @@ -757,7 +791,7 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_id=artist_id, length=length, index=index, - medium=medium, + medium_str=medium, medium_index=medium_index, ) From ed73903deb89903d8808966db74f575227c29ccd Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 4 Oct 2025 11:59:15 -0700 Subject: [PATCH 189/301] type corrections --- beetsplug/discogs.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 1ef07be68..878448556 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -350,15 +350,17 @@ class DiscogsPlugin(MetadataSourcePlugin): """Iterates through a discogs result, fetching data if the artist anv is to be used, maps that to the name. Calls the parent class get_artist method.""" - artist_list = [] + artist_list: list[dict[str | int, str]] = [] for artist_data in artists: - a = artist_data.copy() - if use_anv and (anv := a.get("anv", "")): + a: dict[str | int, str] = { + "name": artist_data["name"], + "id": artist_data["id"], + "join": artist_data.get("join", ""), + } + if use_anv and (anv := artist_data.get("anv", "")): a["name"] = anv artist_list.append(a) - artist, artist_id = self.get_artist( - cast(list[dict[str | int, str]], artist_list), join_key="join" - ) + artist, artist_id = self.get_artist(artist_list, join_key="join") return self.strip_disambiguation(artist), artist_id def get_album_info(self, result: Release) -> AlbumInfo | None: From 152cafbf69de036253898a11349b8d59ecb6618b Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Mon, 6 Oct 2025 11:35:49 +0200 Subject: [PATCH 190/301] fromfilename: Fix tiny changelog formatting issue --- docs/changelog.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9c2912e0d..1bd843688 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,7 +26,6 @@ New features: Bug fixes: - - :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, and related fields current even when audio features requests fail. :bug:`6061` - :doc:`plugins/spotify` Fixed an issue where track matching and lookups could From c5f5ffc027cc40d02266e4c206eebfe3db9b39fe Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Mon, 6 Oct 2025 10:55:49 -0400 Subject: [PATCH 191/301] Added releasegroup_id to _MB_REIMPORT_FRESH_FIELDS_ITEM list --- beetsplug/musicbrainz.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index c4f252e33..5d7c415ef 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -66,6 +66,7 @@ _MB_REIMPORT_FRESH_FIELDS_ALBUM = [ ] _MB_REIMPORT_FRESH_FIELDS_ITEM = [ "data_url", + "releasegroup_id", ] From c59aff9e270fa536f26f10680bd6521110a1087c Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 7 Oct 2025 18:02:04 -0400 Subject: [PATCH 192/301] Added the MusicBrainz flex fields directly to the importer --- beets/importer/tasks.py | 7 ++++++- beetsplug/musicbrainz.py | 26 -------------------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index b4d566032..9830a62cc 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -58,8 +58,13 @@ REIMPORT_FRESH_FIELDS_ALBUM = [ "deezer_album_id", "beatport_album_id", "tidal_album_id", + "media", + "releasegroup_id", + "data_url", +] +REIMPORT_FRESH_FIELDS_ITEM = [ + field for field in REIMPORT_FRESH_FIELDS_ALBUM if field != "media" ] -REIMPORT_FRESH_FIELDS_ITEM = list(REIMPORT_FRESH_FIELDS_ALBUM) # Global logger. log = logging.getLogger("beets") diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 5d7c415ef..8e259e94b 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -30,10 +30,6 @@ from confuse.exceptions import NotFoundError import beets import beets.autotag.hooks from beets import config, plugins, util -from beets.importer.tasks import ( - REIMPORT_FRESH_FIELDS_ALBUM, - REIMPORT_FRESH_FIELDS_ITEM, -) from beets.metadata_plugins import MetadataSourcePlugin from beets.util.id_extractors import extract_release_id @@ -59,27 +55,6 @@ FIELDS_TO_MB_KEYS = { "year": "date", } -_MB_REIMPORT_FRESH_FIELDS_ALBUM = [ - "media", - "releasegroup_id", - "data_url", -] -_MB_REIMPORT_FRESH_FIELDS_ITEM = [ - "data_url", - "releasegroup_id", -] - - -def _extend_reimport_fresh_fields() -> None: - """Ensure MusicBrainz fields stored as flex attrs refresh on reimport.""" - for field in _MB_REIMPORT_FRESH_FIELDS_ALBUM: - if field not in REIMPORT_FRESH_FIELDS_ALBUM: - REIMPORT_FRESH_FIELDS_ALBUM.append(field) - for field in _MB_REIMPORT_FRESH_FIELDS_ITEM: - if field not in REIMPORT_FRESH_FIELDS_ITEM: - REIMPORT_FRESH_FIELDS_ITEM.append(field) - - musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") @@ -392,7 +367,6 @@ class MusicBrainzPlugin(MetadataSourcePlugin): from the beets configuration. This should be called at startup. """ super().__init__() - _extend_reimport_fresh_fields() self.config.add( { "host": "musicbrainz.org", From 87874952fad7ab096c236614e7fb10fa96e56943 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 7 Oct 2025 19:56:39 -0400 Subject: [PATCH 193/301] Refactor reimport fresh fields: consolidate REIMPORT_FRESH_FIELDS_ITEM and REIMPORT_FRESH_FIELDS_ALBUM definitions --- beets/importer/tasks.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 9830a62cc..922026c08 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -51,20 +51,17 @@ SINGLE_ARTIST_THRESH = 0.25 # def extend_reimport_fresh_fields_item(): # importer.REIMPORT_FRESH_FIELDS_ITEM.extend(['tidal_track_popularity'] # ) -REIMPORT_FRESH_FIELDS_ALBUM = [ +REIMPORT_FRESH_FIELDS_ITEM = [ "data_source", "bandcamp_album_id", "spotify_album_id", "deezer_album_id", "beatport_album_id", "tidal_album_id", - "media", "releasegroup_id", "data_url", ] -REIMPORT_FRESH_FIELDS_ITEM = [ - field for field in REIMPORT_FRESH_FIELDS_ALBUM if field != "media" -] +REIMPORT_FRESH_FIELDS_ALBUM = [*REIMPORT_FRESH_FIELDS_ITEM, "media"] # Global logger. log = logging.getLogger("beets") From 92a2233e0d05481d0224956e452b3806b8f2e450 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Wed, 8 Oct 2025 09:59:07 -0400 Subject: [PATCH 194/301] remove releasegroup_id --- beets/importer/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 922026c08..710f4da50 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -58,7 +58,6 @@ REIMPORT_FRESH_FIELDS_ITEM = [ "deezer_album_id", "beatport_album_id", "tidal_album_id", - "releasegroup_id", "data_url", ] REIMPORT_FRESH_FIELDS_ALBUM = [*REIMPORT_FRESH_FIELDS_ITEM, "media"] From 545213421ba649e247b280f9b07ea0a9329bc8f8 Mon Sep 17 00:00:00 2001 From: Martin Atukunda <matlads@gmail.com> Date: Thu, 9 Oct 2025 20:11:19 +0300 Subject: [PATCH 195/301] feat(plugin/web): support for nexttrack keypress --- beetsplug/web/static/beets.js | 9 ++++++++- docs/changelog.rst | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/beetsplug/web/static/beets.js b/beetsplug/web/static/beets.js index eace4d27d..0600d09d0 100644 --- a/beetsplug/web/static/beets.js +++ b/beetsplug/web/static/beets.js @@ -241,6 +241,11 @@ var AppView = Backbone.View.extend({ 'pause': _.bind(this.audioPause, this), 'ended': _.bind(this.audioEnded, this) }); + if ("mediaSession" in navigator) { + navigator.mediaSession.setActionHandler("nexttrack", () => { + this.playNext(); + }); + } }, showItems: function(items) { this.shownItems = items; @@ -306,7 +311,9 @@ var AppView = Backbone.View.extend({ }, audioEnded: function() { this.playingItem.entryView.setPlaying(false); - + this.playNext(); + }, + playNext: function(){ // Try to play the next track. var idx = this.shownItems.indexOf(this.playingItem); if (idx == -1) { diff --git a/docs/changelog.rst b/docs/changelog.rst index b56413ee9..b5032a0b9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -23,6 +23,7 @@ New features: :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to specify where the variations are written. :bug:`3354` +- :doc:`plugins/web` Support for `nexttrack` keyboard press Bug fixes: From c2ab93a9468f299011500b6cfa9ab000d955793a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 5 Oct 2025 10:49:44 +0100 Subject: [PATCH 196/301] Remove redundant source_weight defaults --- beetsplug/beatport.py | 1 - beetsplug/discogs.py | 1 - 2 files changed, 2 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index fa681ce6a..c07cce72f 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -328,7 +328,6 @@ class BeatportPlugin(MetadataSourcePlugin): "apikey": "57713c3906af6f5def151b33601389176b37b429", "apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954", "tokenfile": "beatport_token.json", - "source_weight": 0.5, } ) self.config["apikey"].redact = True diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 878448556..874eab6ec 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -129,7 +129,6 @@ class DiscogsPlugin(MetadataSourcePlugin): "apikey": API_KEY, "apisecret": API_SECRET, "tokenfile": "discogs_token.json", - "source_weight": 0.5, "user_token": "", "separator": ", ", "index_tracks": False, From 6e5af90abb129f51ba19f1df91aef1d1d27124e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 7 Oct 2025 13:45:06 +0100 Subject: [PATCH 197/301] Rename source_weight -> data_source_mismatch_penalty --- beets/metadata_plugins.py | 4 ++-- docs/plugins/index.rst | 6 +++--- docs/plugins/spotify.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 56bf8124f..3da137b51 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -133,7 +133,7 @@ def _get_distance( dist = Distance() if info.data_source == data_source: - dist.add("source", config["source_weight"].as_number()) + dist.add("source", config["data_source_mismatch_penalty"].as_number()) return dist @@ -150,7 +150,7 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): self.config.add( { "search_limit": 5, - "source_weight": 0.5, + "data_source_mismatch_penalty": 0.5, } ) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 64874dd32..52009ed84 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -50,8 +50,8 @@ Using Metadata Source Plugins Some plugins provide sources for metadata in addition to MusicBrainz. These plugins share the following configuration option: -- **source_weight**: Penalty applied to matches during import. Set to 0.0 to - disable. Default: ``0.5``. +- **data_source_mismatch_penalty**: Penalty applied to matches during import. + Set to 0.0 to disable. Default: ``0.5``. For example, to equally consider matches from Discogs and MusicBrainz add the following to your configuration: @@ -61,7 +61,7 @@ following to your configuration: plugins: musicbrainz discogs discogs: - source_weight: 0.0 + data_source_mismatch_penalty: 0.0 .. toctree:: :hidden: diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 2c6cb3d1c..33d8f1051 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -106,7 +106,7 @@ Here's an example: :: spotify: - source_weight: 0.7 + data_source_mismatch_penalty: 0.7 mode: open region_filter: US show_failures: on From 60e0efb8ea5aaebdd6f27c03a98c645a05466241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 5 Oct 2025 13:02:13 +0100 Subject: [PATCH 198/301] Make naming consistent with the field name --- beets/config_default.yaml | 2 +- beets/metadata_plugins.py | 4 +++- docs/changelog.rst | 3 +++ docs/reference/config.rst | 2 +- test/autotag/test_distance.py | 4 ++-- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 0a80f77f2..c0bab8056 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -166,7 +166,7 @@ match: missing_tracks: medium unmatched_tracks: medium distance_weights: - source: 2.0 + data_source: 2.0 artist: 3.0 album: 3.0 media: 1.0 diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 3da137b51..780fe30b7 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -133,7 +133,9 @@ def _get_distance( dist = Distance() if info.data_source == data_source: - dist.add("source", config["data_source_mismatch_penalty"].as_number()) + dist.add( + "data_source", config["data_source_mismatch_penalty"].as_number() + ) return dist diff --git a/docs/changelog.rst b/docs/changelog.rst index b56413ee9..7418b51db 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,6 +66,9 @@ Other changes: disambiguation stripping. - When installing ``beets`` via git or locally the version string now reflects the current git branch and commit hash. :bug:`4448` +- :ref:`match-config`: ``match.distance_weights.source`` configuration has been + renamed to ``match.distance_weights.data_source`` for consistency with the + name of the field it refers to. For developers and plugin authors: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index bc823ded4..30582d12c 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -935,7 +935,7 @@ can be one of ``none``, ``low``, ``medium`` or ``strong``. When the maximum recommendation is ``strong``, no "downgrading" occurs. The available penalty names here are: -- source +- data_source - artist - album - media diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index e3ce9f891..ffbb24eca 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -22,7 +22,7 @@ class TestDistance: @pytest.fixture def dist(self, config): - config["match"]["distance_weights"]["source"] = 2.0 + config["match"]["distance_weights"]["data_source"] = 2.0 config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 @@ -103,7 +103,7 @@ class TestDistance: assert dist["media"] == 1 / 6 def test_operators(self, dist): - dist.add("source", 0.0) + dist.add("data_source", 0.0) dist.add("album", 0.5) dist.add("medium", 0.25) dist.add("medium", 0.75) From e6084cd3ee0aee6c7d232474909a77117deb9d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 5 Oct 2025 17:52:09 +0100 Subject: [PATCH 199/301] Set default data_source_penalty to 0.0 --- beets/metadata_plugins.py | 2 +- docs/plugins/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 780fe30b7..7e84167ff 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -152,7 +152,7 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): self.config.add( { "search_limit": 5, - "data_source_mismatch_penalty": 0.5, + "data_source_mismatch_penalty": 0.0, } ) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 52009ed84..1374475cc 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -51,7 +51,7 @@ Some plugins provide sources for metadata in addition to MusicBrainz. These plugins share the following configuration option: - **data_source_mismatch_penalty**: Penalty applied to matches during import. - Set to 0.0 to disable. Default: ``0.5``. + Set to 0.0 to disable. Default: ``0.0``. For example, to equally consider matches from Discogs and MusicBrainz add the following to your configuration: From 203c2176d91a2160e056b6bac17a3b8011164810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 5 Oct 2025 20:02:14 +0100 Subject: [PATCH 200/301] Update data_source_penalty docs --- docs/plugins/index.rst | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1374475cc..1e2f5d5e8 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -47,21 +47,27 @@ some, you can use ``pip``'s "extras" feature to install the dependencies: Using Metadata Source Plugins ----------------------------- -Some plugins provide sources for metadata in addition to MusicBrainz. These -plugins share the following configuration option: +We provide several :ref:`autotagger_extensions` that fetch metadata from online +databases. They share the following configuration options: - **data_source_mismatch_penalty**: Penalty applied to matches during import. - Set to 0.0 to disable. Default: ``0.0``. + Default: ``0.0`` (no penalty). -For example, to equally consider matches from Discogs and MusicBrainz add the -following to your configuration: + Penalize this data source to prioritize others. For example, to prefer Discogs + over MusicBrainz: -.. code-block:: yaml + .. code-block:: yaml - plugins: musicbrainz discogs + plugins: musicbrainz discogs - discogs: - data_source_mismatch_penalty: 0.0 + musicbrainz: + data_source_mismatch_penalty: 2.0 + + By default, all sources are equally preferred with each having + ``data_source_mismatch_penalty`` set to ``0.0``. + +- **search_limit**: Maximum number of search results to consider. Default: + ``5``. .. toctree:: :hidden: From 01e2eb4665529c0c6ca4f3f805b931455e55c58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 5 Oct 2025 21:19:27 +0100 Subject: [PATCH 201/301] Add default config yaml to each data source docs --- docs/plugins/deezer.rst | 15 ++++++----- docs/plugins/discogs.rst | 50 +++++++++++++++++++----------------- docs/plugins/musicbrainz.rst | 6 ++++- docs/plugins/spotify.rst | 24 ++++++++++++----- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index b3a57e825..805c31852 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -27,14 +27,17 @@ Configuration ------------- This plugin can be configured like other metadata source plugins as described in -:ref:`metadata-source-plugin-configuration`. In addition, the following -configuration options are provided. +:ref:`metadata-source-plugin-configuration`. -- **search_limit**: The maximum number of results to return from Deezer for each - search query. Default: ``5``. +Default +~~~~~~~ -The default options should work as-is, but there are some options you can put in -config.yaml under the ``deezer:`` section: +.. code-block:: yaml + + deezer: + data_source_mismatch_penalty: 0.0 + search_limit: 5 + search_query_ascii: no - **search_query_ascii**: If set to ``yes``, the search query will be converted to ASCII before being sent to Deezer. Converting searches to ASCII can enhance diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 0d55630c4..ab7a30c59 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -65,38 +65,45 @@ Configuration This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. -There is one additional option in the ``discogs:`` section, ``index_tracks``. -Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions -between distinct works on the same release or within works. When -``index_tracks`` is enabled: +Default +~~~~~~~ .. code-block:: yaml discogs: - index_tracks: yes + data_source_mismatch_penalty: 0.0 + search_limit: 5 + apikey: REDACTED + apisecret: REDACTED + tokenfile: discogs_token.json + user_token: REDACTED + index_tracks: no + append_style_genre: no + separator: ', ' + strip_disambiguation: yes -beets will incorporate the names of the divisions containing each track into the -imported track's title. Default: ``no``. +- **index_tracks**: Index tracks (see the `Discogs guidelines`_) along with + headers, mark divisions between distinct works on the same release or within + works. When enabled, beets will incorporate the names of the divisions + containing each track into the imported track's title. Default: ``no``. -For example, importing `divisions album`_ would result in track names like: + For example, importing `divisions album`_ would result in track names like: -.. code-block:: text + .. code-block:: text - Messiah, Part I: No.1: Sinfony - Messiah, Part II: No.22: Chorus- Behold The Lamb Of God - Athalia, Act I, Scene I: Sinfonia + Messiah, Part I: No.1: Sinfony + Messiah, Part II: No.22: Chorus- Behold The Lamb Of God + Athalia, Act I, Scene I: Sinfonia -whereas with ``index_tracks`` disabled you'd get: + whereas with ``index_tracks`` disabled you'd get: -.. code-block:: text + .. code-block:: text - No.1: Sinfony - No.22: Chorus- Behold The Lamb Of God - Sinfonia + No.1: Sinfony + No.22: Chorus- Behold The Lamb Of God + Sinfonia -This option is useful when importing classical music. - -Other configurations available under ``discogs:`` are: + This option is useful when importing classical music. - **append_style_genre**: Appends the Discogs style (if found) to the genre tag. This can be useful if you want more granular genres to categorize your music. @@ -106,9 +113,6 @@ Other configurations available under ``discogs:`` are: "Electronic". Default: ``False`` - **separator**: How to join multiple genre and style values from Discogs into a string. Default: ``", "`` -- **search_limit**: The maximum number of results to return from Discogs. This - is useful if you want to limit the number of results returned to speed up - searches. Default: ``5`` - **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with the same name. If you'd like to use the discogs disambiguation in your tags, you can disable it. Default: ``True`` diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index ed8eefa36..4fc4e4092 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -17,17 +17,21 @@ To use the ``musicbrainz`` plugin, enable it in your configuration (see Configuration ------------- +This plugin can be configured like other metadata source plugins as described in +:ref:`metadata-source-plugin-configuration`. + Default ~~~~~~~ .. code-block:: yaml musicbrainz: + data_source_mismatch_penalty: 0.0 + search_limit: 5 host: musicbrainz.org https: no ratelimit: 1 ratelimit_interval: 1.0 - search_limit: 5 extra_tags: [] genres: no external_ids: diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 33d8f1051..2788c0515 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -65,11 +65,25 @@ Configuration ------------- This plugin can be configured like other metadata source plugins as described in -:ref:`metadata-source-plugin-configuration`. In addition, the following -configuration options are provided. +:ref:`metadata-source-plugin-configuration`. -The default options should work as-is, but there are some options you can put in -config.yaml under the ``spotify:`` section: +Default +~~~~~~~ + +.. code-block:: yaml + + spotify: + data_source_mismatch_penalty: 0.0 + search_limit: 5 + mode: list + region_filter: + show_failures: no + tiebreak: popularity + regex: [] + search_query_ascii: no + client_id: REDACTED + client_secret: REDACTED + tokenfile: spotify_token.json - **mode**: One of the following: @@ -98,8 +112,6 @@ config.yaml under the ``spotify:`` section: enhance search results in some cases, but in general, it is not recommended. For instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice ``×!=x``). Default: ``no``. -- **search_limit**: The maximum number of results to return from Spotify for - each search query. Default: ``5``. Here's an example: From 96670cf9710e3068122063f1ff8fa795afa3a1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 7 Oct 2025 14:01:20 +0100 Subject: [PATCH 202/301] Cache found metadata source plugins --- beets/metadata_plugins.py | 29 +++++---------------------- beets/plugins.py | 42 ++++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 7e84167ff..e765e4cbf 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -9,7 +9,7 @@ from __future__ import annotations import abc import re -import warnings +from functools import cache from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar import unidecode @@ -29,30 +29,11 @@ if TYPE_CHECKING: from .autotag.hooks import AlbumInfo, Item, TrackInfo +@cache def find_metadata_source_plugins() -> list[MetadataSourcePlugin]: - """Returns a list of MetadataSourcePlugin subclass instances - - Resolved from all currently loaded beets plugins. - """ - - all_plugins = find_plugins() - metadata_plugins: list[MetadataSourcePlugin | BeetsPlugin] = [] - for plugin in all_plugins: - if isinstance(plugin, MetadataSourcePlugin): - metadata_plugins.append(plugin) - elif hasattr(plugin, "data_source"): - # TODO: Remove this in the future major release, v3.0.0 - warnings.warn( - f"{plugin.__class__.__name__} is used as a legacy metadata source. " - "It should extend MetadataSourcePlugin instead of BeetsPlugin. " - "Support for this will be removed in the v3.0.0 release!", - DeprecationWarning, - stacklevel=2, - ) - metadata_plugins.append(plugin) - - # typeignore: BeetsPlugin is not a MetadataSourcePlugin (legacy support) - return metadata_plugins # type: ignore[return-value] + """Return a list of all loaded metadata source plugins.""" + # TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0 + return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc] @notify_info_yielded("albuminfo_received") diff --git a/beets/plugins.py b/beets/plugins.py index c0dd12e5b..5d3e39cc7 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,6 +20,7 @@ import abc import inspect import re import sys +import warnings from collections import defaultdict from functools import wraps from importlib import import_module @@ -160,19 +161,46 @@ class BeetsPlugin(metaclass=abc.ABCMeta): import_stages: list[ImportStageFunc] def __init_subclass__(cls) -> None: - # Dynamically copy methods to BeetsPlugin for legacy support - # TODO: Remove this in the future major release, v3.0.0 + """Enable legacy metadata‐source plugins to work with the new interface. + + When a plugin subclass of BeetsPlugin defines a `data_source` attribute + but does not inherit from MetadataSourcePlugin, this hook: + + 1. Skips abstract classes. + 2. Warns that the class should extend MetadataSourcePlugin (deprecation). + 3. Copies any nonabstract methods from MetadataSourcePlugin onto the + subclass to provide the full plugin API. + + This compatibility layer will be removed in the v3.0.0 release. + """ + # TODO: Remove in v3.0.0 if inspect.isabstract(cls): return from beets.metadata_plugins import MetadataSourcePlugin - abstractmethods = MetadataSourcePlugin.__abstractmethods__ - for name, method in inspect.getmembers( - MetadataSourcePlugin, predicate=inspect.isfunction + if issubclass(cls, MetadataSourcePlugin) or not hasattr( + cls, "data_source" ): - if name not in abstractmethods and not hasattr(cls, name): - setattr(cls, name, method) + return + + warnings.warn( + f"{cls.__name__} is used as a legacy metadata source. " + "It should extend MetadataSourcePlugin instead of BeetsPlugin. " + "Support for this will be removed in the v3.0.0 release!", + DeprecationWarning, + stacklevel=3, + ) + + for name, method in inspect.getmembers( + MetadataSourcePlugin, + predicate=lambda f: ( + inspect.isfunction(f) + and f.__name__ not in MetadataSourcePlugin.__abstractmethods__ + and not hasattr(cls, f.__name__) + ), + ): + setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" From 455d620ae08448065c8c185ae28eac27321f0e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 6 Oct 2025 07:48:50 +0100 Subject: [PATCH 203/301] Fix data source penalty application logic The data_source penalty was not being calculated correctly because `_get_distance` was being called for **all** enabled metadata plugins which eventually meant that matches were being penalised needlessly. This commit refactors the distance calculation to: - Remove the plugin-based track_distance() and album_distance() methods that were applying penalties incorrectly - Calculate data_source penalties directly in track_distance() and distance() functions when sources don't match - Use a centralized get_penalty() function to retrieve plugin-specific penalty values via a registry with O(1) lookup - Change default data_source_penalty from 0.0 to 0.5 to ensure mismatches are penalized by default - Add data_source to get_most_common_tags() to determine the likely original source for comparison This ensures that tracks and albums from different data sources are properly penalized during matching, improving match quality and preventing cross-source matches. --- beets/autotag/distance.py | 12 +++- beets/metadata_plugins.py | 103 +++++++++------------------------- beets/util/__init__.py | 3 +- docs/changelog.rst | 11 ++++ docs/plugins/deezer.rst | 2 +- docs/plugins/discogs.rst | 2 +- docs/plugins/index.rst | 4 +- docs/plugins/musicbrainz.rst | 2 +- docs/plugins/spotify.rst | 2 +- test/autotag/test_distance.py | 53 +++++++++++++++++ 10 files changed, 108 insertions(+), 86 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 727439ea3..123f4b788 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -409,7 +409,10 @@ def track_distance( dist.add_expr("medium", item.disc != track_info.medium) # Plugins. - dist.update(metadata_plugins.track_distance(item, track_info)) + if (original := item.get("data_source")) and ( + actual := track_info.data_source + ) != original: + dist.add("data_source", metadata_plugins.get_penalty(actual)) return dist @@ -526,6 +529,9 @@ def distance( dist.add("unmatched_tracks", 1.0) # Plugins. - dist.update(metadata_plugins.album_distance(items, album_info, mapping)) - + if ( + likelies["data_source"] + and (data_source := album_info.data_source) != likelies["data_source"] + ): + dist.add("data_source", metadata_plugins.get_penalty(data_source)) return dist diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index e765e4cbf..5e0d8570d 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -9,7 +9,7 @@ from __future__ import annotations import abc import re -from functools import cache +from functools import cache, cached_property from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar import unidecode @@ -23,9 +23,6 @@ from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send if TYPE_CHECKING: from collections.abc import Iterable - from confuse import ConfigView - - from .autotag import Distance from .autotag.hooks import AlbumInfo, Item, TrackInfo @@ -76,48 +73,17 @@ def track_for_id(_id: str) -> TrackInfo | None: return None -def track_distance(item: Item, info: TrackInfo) -> Distance: - """Returns the track distance for an item and trackinfo. - - Returns a Distance object is populated by all metadata source plugins - that implement the :py:meth:`MetadataSourcePlugin.track_distance` method. - """ - from beets.autotag.distance import Distance - - dist = Distance() - for plugin in find_metadata_source_plugins(): - dist.update(plugin.track_distance(item, info)) - return dist - - -def album_distance( - items: Sequence[Item], - album_info: AlbumInfo, - mapping: dict[Item, TrackInfo], -) -> Distance: - """Returns the album distance calculated by plugins.""" - from beets.autotag.distance import Distance - - dist = Distance() - for plugin in find_metadata_source_plugins(): - dist.update(plugin.album_distance(items, album_info, mapping)) - return dist - - -def _get_distance( - config: ConfigView, data_source: str, info: AlbumInfo | TrackInfo -) -> Distance: - """Returns the ``data_source`` weight and the maximum source weight - for albums or individual tracks. - """ - from beets.autotag.distance import Distance - - dist = Distance() - if info.data_source == data_source: - dist.add( - "data_source", config["data_source_mismatch_penalty"].as_number() - ) - return dist +@cache +def get_penalty(data_source: str | None) -> float: + """Get the penalty value for the given data source.""" + return next( + ( + p.data_source_mismatch_penalty + for p in find_metadata_source_plugins() + if p.data_source == data_source + ), + MetadataSourcePlugin.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, + ) class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): @@ -128,12 +94,26 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): and tracks, and to retrieve album and track information by ID. """ + DEFAULT_DATA_SOURCE_MISMATCH_PENALTY = 0.5 + + @cached_classproperty + def data_source(cls) -> str: + """The data source name for this plugin. + + This is inferred from the plugin name. + """ + return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined] + + @cached_property + def data_source_mismatch_penalty(self) -> float: + return self.config["data_source_mismatch_penalty"].as_number() + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.config.add( { "search_limit": 5, - "data_source_mismatch_penalty": 0.0, + "data_source_mismatch_penalty": self.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, # noqa: E501 } ) @@ -207,35 +187,6 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): return (self.track_for_id(id) for id in ids) - def album_distance( - self, - items: Sequence[Item], - album_info: AlbumInfo, - mapping: dict[Item, TrackInfo], - ) -> Distance: - """Calculate the distance for an album based on its items and album info.""" - return _get_distance( - data_source=self.data_source, info=album_info, config=self.config - ) - - def track_distance( - self, - item: Item, - info: TrackInfo, - ) -> Distance: - """Calculate the distance for a track based on its item and track info.""" - return _get_distance( - data_source=self.data_source, info=info, config=self.config - ) - - @cached_classproperty - def data_source(cls) -> str: - """The data source name for this plugin. - - This is inferred from the plugin name. - """ - return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined] - def _extract_id(self, url: str) -> str | None: """Extract an ID from a URL for this metadata source plugin. diff --git a/beets/util/__init__.py b/beets/util/__init__.py index f895a60ee..0f2ef5b97 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -836,9 +836,10 @@ def get_most_common_tags( "country", "media", "albumdisambig", + "data_source", ] for field in fields: - values = [item[field] for item in items if item] + values = [item.get(field) for item in items if item] likelies[field], freq = plurality(values) consensus[field] = freq == len(values) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7418b51db..f807af8c9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -45,6 +45,10 @@ Bug fixes: an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033` - :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor regexps, allow for more cases, add some logging), add tests. +- Metadata source plugins: Fixed data source penalty calculation that was + incorrectly applied during import matching. The ``source_weight`` + configuration option has been renamed to ``data_source_mismatch_penalty`` to + better reflect its purpose. :bug:`6066` For packagers: @@ -75,6 +79,13 @@ For developers and plugin authors: - Typing improvements in ``beets/logging.py``: ``getLogger`` now returns ``BeetsLogger`` when called with a name, or ``RootLogger`` when called without a name. +- The ``track_distance()`` and ``album_distance()`` methods have been removed + from ``MetadataSourcePlugin``. Distance calculation for data source mismatches + is now handled automatically by the core matching logic. This change + simplifies the plugin architecture and fixes incorrect penalty calculations. + :bug:`6066` +- Metadata source plugins are now registered globally when instantiated, which + makes their handling slightly more efficient. 2.4.0 (September 13, 2025) -------------------------- diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 805c31852..96ed34652 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -35,7 +35,7 @@ Default .. code-block:: yaml deezer: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 search_query_ascii: no diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index ab7a30c59..64b68248d 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -71,7 +71,7 @@ Default .. code-block:: yaml discogs: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 apikey: REDACTED apisecret: REDACTED diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1e2f5d5e8..1e1ed43da 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -51,7 +51,7 @@ We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: - **data_source_mismatch_penalty**: Penalty applied to matches during import. - Default: ``0.0`` (no penalty). + Any decimal number between 0 and 1. Default: ``0.5``. Penalize this data source to prioritize others. For example, to prefer Discogs over MusicBrainz: @@ -64,7 +64,7 @@ databases. They share the following configuration options: data_source_mismatch_penalty: 2.0 By default, all sources are equally preferred with each having - ``data_source_mismatch_penalty`` set to ``0.0``. + ``data_source_mismatch_penalty`` set to ``0.5``. - **search_limit**: Maximum number of search results to consider. Default: ``5``. diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 4fc4e4092..110d9b92c 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -26,7 +26,7 @@ Default .. code-block:: yaml musicbrainz: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 host: musicbrainz.org https: no diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 2788c0515..b72f22f20 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -73,7 +73,7 @@ Default .. code-block:: yaml spotify: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 mode: list region_filter: diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index ffbb24eca..5a3a8bee2 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -10,6 +10,7 @@ from beets.autotag.distance import ( track_distance, ) from beets.library import Item +from beets.metadata_plugins import MetadataSourcePlugin, get_penalty from beets.test.helper import ConfigMixin _p = pytest.param @@ -297,3 +298,55 @@ class TestStringDistance: string_dist("The ", "") string_dist("(EP)", "(EP)") string_dist(", An", "") + + +class TestDataSourceDistance: + MATCH = 0.0 + MISMATCH = 0.125 + + @pytest.fixture(autouse=True) + def setup(self, monkeypatch, penalty, weight): + monkeypatch.setitem(Distance._weights, "data_source", weight) + get_penalty.cache_clear() + + class TestMetadataSourcePlugin(MetadataSourcePlugin): + def album_for_id(self, *args, **kwargs): ... + def track_for_id(self, *args, **kwargs): ... + def candidates(self, *args, **kwargs): ... + def item_candidates(self, *args, **kwargs): ... + + class OriginalPlugin(TestMetadataSourcePlugin): + pass + + class OtherPlugin(TestMetadataSourcePlugin): + @property + def data_source_mismatch_penalty(self): + return penalty + + monkeypatch.setattr( + "beets.metadata_plugins.find_metadata_source_plugins", + lambda: [OriginalPlugin(), OtherPlugin()], + ) + + @pytest.mark.parametrize( + "item,info,penalty,weight,expected_distance", + [ + _p("Original", "Original", 0.5, 1.0, MATCH, id="match"), + _p("Original", "Other", 0.5, 1.0, MISMATCH, id="mismatch"), + _p("Original", "unknown", 0.5, 1.0, MISMATCH, id="mismatch-unknown"), # noqa: E501 + _p("Original", None, 0.5, 1.0, MISMATCH, id="mismatch-no-info"), + _p(None, "Other", 0.5, 1.0, MATCH, id="match-no-original"), + _p("unknown", "unknown", 0.5, 1.0, MATCH, id="match-unknown"), + _p("Original", "Other", 1.0, 1.0, 0.25, id="mismatch-max-penalty"), + _p("Original", "Other", 0.5, 5.0, 0.3125, id="mismatch-high-weight"), # noqa: E501 + _p("Original", "Other", 0.0, 1.0, MATCH, id="match-no-penalty"), + _p("Original", "Other", 0.5, 0.0, MATCH, id="match-no-weight"), + ], + ) # fmt: skip + def test_distance(self, item, info, expected_distance): + item = Item(data_source=item) + info = TrackInfo(data_source=info, title="") + + dist = track_distance(item, info) + + assert dist.distance == expected_distance From e6895bb52d3e4233fe76dedcd2ba6bbcb8b4f7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 6 Oct 2025 09:00:46 +0100 Subject: [PATCH 204/301] Reset cached_classproperty cache for every test --- beets/test/helper.py | 2 -- test/autotag/test_distance.py | 2 -- test/conftest.py | 6 ++++++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/beets/test/helper.py b/beets/test/helper.py index 85adc0825..ea08ec840 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -58,7 +58,6 @@ from beets.ui.commands import TerminalImportSession from beets.util import ( MoveOperation, bytestring_path, - cached_classproperty, clean_module_tempdir, syspath, ) @@ -495,7 +494,6 @@ class PluginMixin(ConfigMixin): # FIXME this should eventually be handled by a plugin manager plugins = (self.plugin,) if hasattr(self, "plugin") else plugins self.config["plugins"] = plugins - cached_classproperty.cache.clear() beets.plugins.load_plugins() def unload_plugins(self) -> None: diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 5a3a8bee2..8c4478ca9 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -27,8 +27,6 @@ class TestDistance: config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 - Distance.__dict__["_weights"].cache = {} - return Distance() def test_add(self, dist): diff --git a/test/conftest.py b/test/conftest.py index 3107ad690..e1350b092 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,6 +4,7 @@ import os import pytest from beets.dbcore.query import Query +from beets.util import cached_classproperty def skip_marked_items(items: list[pytest.Item], marker_name: str, reason: str): @@ -41,3 +42,8 @@ def pytest_make_parametrize_id(config, val, argname): return inspect.getsource(val).split("lambda")[-1][:30] return repr(val) + + +@pytest.fixture(autouse=True) +def clear_cached_classproperty(): + cached_classproperty.cache.clear() From 5757579e275e61ff4b62a4294c94279300ca2f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 6 Oct 2025 09:01:33 +0100 Subject: [PATCH 205/301] Improve visibility of Distance tests failures --- test/autotag/test_distance.py | 17 +++++++---------- test/conftest.py | 6 ++++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 8c4478ca9..72922470b 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -17,16 +17,15 @@ _p = pytest.param class TestDistance: - @pytest.fixture(scope="class") - def config(self): - return ConfigMixin().config - - @pytest.fixture - def dist(self, config): + @pytest.fixture(autouse=True, scope="class") + def setup_config(self): + config = ConfigMixin().config config["match"]["distance_weights"]["data_source"] = 2.0 config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 + @pytest.fixture + def dist(self): return Distance() def test_add(self, dist): @@ -161,10 +160,8 @@ class TestTrackDistance: def test_track_distance(self, info, title, artist, expected_penalty): item = Item(artist=artist, title=title) - assert ( - bool(track_distance(item, info, incl_artist=True)) - == expected_penalty - ) + dist = track_distance(item, info, incl_artist=True) + assert bool(dist) == expected_penalty, dist._penalties class TestAlbumDistance: diff --git a/test/conftest.py b/test/conftest.py index e1350b092..eb46b94b0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,6 +3,7 @@ import os import pytest +from beets.autotag.distance import Distance from beets.dbcore.query import Query from beets.util import cached_classproperty @@ -44,6 +45,11 @@ def pytest_make_parametrize_id(config, val, argname): return repr(val) +def pytest_assertrepr_compare(op, left, right): + if isinstance(left, Distance) or isinstance(right, Distance): + return [f"Comparing Distance: {float(left)} {op} {float(right)}"] + + @pytest.fixture(autouse=True) def clear_cached_classproperty(): cached_classproperty.cache.clear() From f8887d48b6b3677bc7462d0bcddfa8a2da6d9c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 7 Oct 2025 14:19:26 +0100 Subject: [PATCH 206/301] Add deprecation warning for <plugin>.source_weight --- beets/metadata_plugins.py | 6 +++++- beets/plugins.py | 31 +++++++++++++++++++++++++++++++ docs/plugins/index.rst | 6 ++++++ docs/plugins/musicbrainz.rst | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 5e0d8570d..b865167e4 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -13,6 +13,7 @@ from functools import cache, cached_property from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar import unidecode +from confuse import NotFoundError from typing_extensions import NotRequired from beets.util import cached_classproperty @@ -106,7 +107,10 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): @cached_property def data_source_mismatch_penalty(self) -> float: - return self.config["data_source_mismatch_penalty"].as_number() + try: + return self.config["source_weight"].as_number() + except NotFoundError: + return self.config["data_source_mismatch_penalty"].as_number() def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/beets/plugins.py b/beets/plugins.py index 5d3e39cc7..7fa0e660a 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -225,6 +225,37 @@ class BeetsPlugin(metaclass=abc.ABCMeta): if not any(isinstance(f, PluginLogFilter) for f in self._log.filters): self._log.addFilter(PluginLogFilter(self)) + # In order to verify the config we need to make sure the plugin is fully + # configured (plugins usually add the default configuration *after* + # calling super().__init__()). + self.register_listener("pluginload", self.verify_config) + + def verify_config(self, *_, **__) -> None: + """Verify plugin configuration. + + If deprecated 'source_weight' option is explicitly set by the user, they + will see a warning in the logs. Otherwise, this must be configured by + a third party plugin, thus we raise a deprecation warning which won't be + shown to user but will be visible to plugin developers. + """ + # TODO: Remove in v3.0.0 + if ( + not hasattr(self, "data_source") + or "source_weight" not in self.config + ): + return + + message = ( + "'source_weight' configuration option is deprecated and will be" + " removed in v3.0.0. Use 'data_source_mismatch_penalty' instead" + ) + for source in self.config.root().sources: + if "source_weight" in (source.get(self.name) or {}): + if source.filename: # user config + self._log.warning(message) + else: # 3rd-party plugin config + warnings.warn(message, DeprecationWarning, stacklevel=0) + def commands(self) -> Sequence[Subcommand]: """Should return a list of beets.ui.Subcommand objects for commands that should be added to beets' CLI. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1e1ed43da..7b595ac86 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -50,6 +50,8 @@ Using Metadata Source Plugins We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: +.. _data_source_mismatch_penalty: + - **data_source_mismatch_penalty**: Penalty applied to matches during import. Any decimal number between 0 and 1. Default: ``0.5``. @@ -66,6 +68,10 @@ databases. They share the following configuration options: By default, all sources are equally preferred with each having ``data_source_mismatch_penalty`` set to ``0.5``. +- **source_weight** + + .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. + - **search_limit**: Maximum number of search results to consider. Default: ``5``. diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 110d9b92c..5ac287368 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -78,7 +78,7 @@ limited_ to one request per second. enabled +++++++ -.. deprecated:: 2.3 Add ``musicbrainz`` to the ``plugins`` list instead. +.. deprecated:: 2.4 Add ``musicbrainz`` to the ``plugins`` list instead. This option allows you to disable using MusicBrainz as a metadata source. This applies if you use plugins that fetch data from alternative sources and should From 1f62a928ec02c5ba78d012b00798465e4915731a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 8 Oct 2025 22:55:48 +0100 Subject: [PATCH 207/301] Update data source documentation --- docs/plugins/index.rst | 54 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 7b595ac86..bf5106e9a 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -52,21 +52,61 @@ databases. They share the following configuration options: .. _data_source_mismatch_penalty: -- **data_source_mismatch_penalty**: Penalty applied to matches during import. - Any decimal number between 0 and 1. Default: ``0.5``. +- **data_source_mismatch_penalty**: Penalty applied when the data source of a + match candidate differs from the original source of your existing tracks. Any + decimal number between 0.0 and 1.0. Default: ``0.5``. - Penalize this data source to prioritize others. For example, to prefer Discogs - over MusicBrainz: + This setting controls how much to penalize matches from different metadata + sources during import. The penalty is applied when beets detects that a match + candidate comes from a different data source than what appears to be the + original source of your music collection. + + .. important:: + + This setting only applies to reimports, not to first-time imports, since + ``data_source`` is unknown for new files. + + **Example configurations:** .. code-block:: yaml + # Prefer MusicBrainz over Discogs when sources don't match plugins: musicbrainz discogs musicbrainz: - data_source_mismatch_penalty: 2.0 + data_source_mismatch_penalty: 0.3 # Lower penalty = preferred + discogs: + data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred - By default, all sources are equally preferred with each having - ``data_source_mismatch_penalty`` set to ``0.5``. + .. code-block:: yaml + + # Do not penalise candidates from Discogs at all + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.5 + discogs: + data_source_mismatch_penalty: 0.0 + + .. code-block:: yaml + + # Disable cross-source penalties entirely + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.0 + discogs: + data_source_mismatch_penalty: 0.0 + + .. tip:: + + The last configuration is equivalent to setting: + + .. code-block:: yaml + + match: + distance_weights: + data_source: 0.0 # Disable data source matching - **source_weight** From 90ca0a799ac60a2b2f5a67281d715c4303664564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Thu, 9 Oct 2025 04:36:03 +0100 Subject: [PATCH 208/301] Consider unseen tracks in data source matching --- beets/autotag/distance.py | 9 ++------- docs/plugins/index.rst | 5 ----- test/autotag/test_distance.py | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 123f4b788..e5ec2debb 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -409,9 +409,7 @@ def track_distance( dist.add_expr("medium", item.disc != track_info.medium) # Plugins. - if (original := item.get("data_source")) and ( - actual := track_info.data_source - ) != original: + if (actual := track_info.data_source) != item.get("data_source"): dist.add("data_source", metadata_plugins.get_penalty(actual)) return dist @@ -529,9 +527,6 @@ def distance( dist.add("unmatched_tracks", 1.0) # Plugins. - if ( - likelies["data_source"] - and (data_source := album_info.data_source) != likelies["data_source"] - ): + if (data_source := album_info.data_source) != likelies["data_source"]: dist.add("data_source", metadata_plugins.get_penalty(data_source)) return dist diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index bf5106e9a..a877d2320 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -61,11 +61,6 @@ databases. They share the following configuration options: candidate comes from a different data source than what appears to be the original source of your music collection. - .. important:: - - This setting only applies to reimports, not to first-time imports, since - ``data_source`` is unknown for new files. - **Example configurations:** .. code-block:: yaml diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 72922470b..91003bbb9 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -330,7 +330,7 @@ class TestDataSourceDistance: _p("Original", "Other", 0.5, 1.0, MISMATCH, id="mismatch"), _p("Original", "unknown", 0.5, 1.0, MISMATCH, id="mismatch-unknown"), # noqa: E501 _p("Original", None, 0.5, 1.0, MISMATCH, id="mismatch-no-info"), - _p(None, "Other", 0.5, 1.0, MATCH, id="match-no-original"), + _p(None, "Other", 0.5, 1.0, MISMATCH, id="mismatch-no-original"), _p("unknown", "unknown", 0.5, 1.0, MATCH, id="match-unknown"), _p("Original", "Other", 1.0, 1.0, 0.25, id="mismatch-max-penalty"), _p("Original", "Other", 0.5, 5.0, 0.3125, id="mismatch-high-weight"), # noqa: E501 From 3b38045d01d400245bdb6d6fd461ec934d684a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Fri, 10 Oct 2025 20:35:35 +0100 Subject: [PATCH 209/301] Only penalize multi data sources on first import --- beets/autotag/distance.py | 15 +++++++++------ test/autotag/test_distance.py | 31 ++++++++++++++++++------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index e5ec2debb..37c6f84f4 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -345,6 +345,12 @@ class Distance: dist = string_dist(str1, str2) self.add(key, dist) + def add_data_source(self, before: str | None, after: str | None) -> None: + if before != after and ( + before or len(metadata_plugins.find_metadata_source_plugins()) > 1 + ): + self.add("data_source", metadata_plugins.get_penalty(after)) + @cache def get_track_length_grace() -> float: @@ -408,9 +414,7 @@ def track_distance( if track_info.medium and item.disc: dist.add_expr("medium", item.disc != track_info.medium) - # Plugins. - if (actual := track_info.data_source) != item.get("data_source"): - dist.add("data_source", metadata_plugins.get_penalty(actual)) + dist.add_data_source(item.get("data_source"), track_info.data_source) return dist @@ -526,7 +530,6 @@ def distance( for _ in range(len(items) - len(mapping)): dist.add("unmatched_tracks", 1.0) - # Plugins. - if (data_source := album_info.data_source) != likelies["data_source"]: - dist.add("data_source", metadata_plugins.get_penalty(data_source)) + dist.add_data_source(likelies["data_source"], album_info.data_source) + return dist diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 91003bbb9..b327bbe44 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -300,7 +300,7 @@ class TestDataSourceDistance: MISMATCH = 0.125 @pytest.fixture(autouse=True) - def setup(self, monkeypatch, penalty, weight): + def setup(self, monkeypatch, penalty, weight, multiple_data_sources): monkeypatch.setitem(Distance._weights, "data_source", weight) get_penalty.cache_clear() @@ -320,22 +320,27 @@ class TestDataSourceDistance: monkeypatch.setattr( "beets.metadata_plugins.find_metadata_source_plugins", - lambda: [OriginalPlugin(), OtherPlugin()], + lambda: ( + [OriginalPlugin(), OtherPlugin()] + if multiple_data_sources + else [OtherPlugin()] + ), ) @pytest.mark.parametrize( - "item,info,penalty,weight,expected_distance", + "item,info,penalty,weight,multiple_data_sources,expected_distance", [ - _p("Original", "Original", 0.5, 1.0, MATCH, id="match"), - _p("Original", "Other", 0.5, 1.0, MISMATCH, id="mismatch"), - _p("Original", "unknown", 0.5, 1.0, MISMATCH, id="mismatch-unknown"), # noqa: E501 - _p("Original", None, 0.5, 1.0, MISMATCH, id="mismatch-no-info"), - _p(None, "Other", 0.5, 1.0, MISMATCH, id="mismatch-no-original"), - _p("unknown", "unknown", 0.5, 1.0, MATCH, id="match-unknown"), - _p("Original", "Other", 1.0, 1.0, 0.25, id="mismatch-max-penalty"), - _p("Original", "Other", 0.5, 5.0, 0.3125, id="mismatch-high-weight"), # noqa: E501 - _p("Original", "Other", 0.0, 1.0, MATCH, id="match-no-penalty"), - _p("Original", "Other", 0.5, 0.0, MATCH, id="match-no-weight"), + _p("Original", "Original", 0.5, 1.0, True, MATCH, id="match"), + _p("Original", "Other", 0.5, 1.0, True, MISMATCH, id="mismatch"), + _p("Original", "unknown", 0.5, 1.0, True, MISMATCH, id="mismatch-unknown"), # noqa: E501 + _p("Original", None, 0.5, 1.0, True, MISMATCH, id="mismatch-no-info"), # noqa: E501 + _p(None, "Other", 0.5, 1.0, True, MISMATCH, id="mismatch-no-original-multiple-sources"), # noqa: E501 + _p(None, "Other", 0.5, 1.0, False, MATCH, id="match-no-original-but-single-source"), # noqa: E501 + _p("unknown", "unknown", 0.5, 1.0, True, MATCH, id="match-unknown"), + _p("Original", "Other", 1.0, 1.0, True, 0.25, id="mismatch-max-penalty"), # noqa: E501 + _p("Original", "Other", 0.5, 5.0, True, 0.3125, id="mismatch-high-weight"), # noqa: E501 + _p("Original", "Other", 0.0, 1.0, True, MATCH, id="match-no-penalty"), # noqa: E501 + _p("Original", "Other", 0.5, 0.0, True, MATCH, id="match-no-weight"), # noqa: E501 ], ) # fmt: skip def test_distance(self, item, info, expected_distance): From 6faa4f3ddde6494f9586a35559d1c1b2795d8949 Mon Sep 17 00:00:00 2001 From: semohr <semohr@users.noreply.github.com> Date: Sat, 11 Oct 2025 09:58:48 +0000 Subject: [PATCH 210/301] Increment version to 2.5.0 --- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f807af8c9..23278bb36 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,17 @@ Unreleased New features: +Bug fixes: + +For packagers: + +Other changes: + +2.5.0 (October 11, 2025) +------------------------ + +New features: + - :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes without storing or writing them. - :doc:`plugins/convert`: Add a config option to disable writing metadata to diff --git a/docs/conf.py b/docs/conf.py index 7465bdb27..c76f87524 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,8 +13,8 @@ copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" -version = "2.4" -release = "2.4.0" +version = "2.5" +release = "2.5.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 8338ce1c6..62b5ac25a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.4.0" +version = "2.5.0" description = "music tagger and library organizer" authors = ["Adrian Sampson <adrian@radbox.org>"] maintainers = ["Serene-Arc"] From 116357e2f6585b538d8d5b0657d9ce2b73ff6f18 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 27 May 2025 13:29:25 +0200 Subject: [PATCH 211/301] Removed outdated installation instructions. - macport: stuck on 1.6 - slackware: stuck on 1.6 - OpenBSD: stuck on 1.6 Remove twitter reference. Removed mailing list reference. --- docs/faq.rst | 51 ++++++++++-------- docs/guides/main.rst | 118 ++++++++++++++++++----------------------- docs/guides/tagger.rst | 2 - 3 files changed, 82 insertions(+), 89 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 3e527e8bc..40da1216b 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -163,31 +163,38 @@ documentation </dev/index>` pages. .. _bugs: …report a bug in beets? -~~~~~~~~~~~~~~~~~~~~~~~ +----------------------- -We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please -follow these guidelines when reporting an issue: +We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. +Please follow these guidelines when reporting an issue: -- Most importantly: if beets is crashing, please `include the traceback - <https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them - in a pastebin (e.g., `Gist <https://gist.github.com/>`__ or `Hastebin - <https://hastebin.com/>`__), especially when communicating over IRC or email. -- Turn on beets' debug output (using the -v option: for example, ``beet -v - import ...``) and include that with your bug report. Look through this verbose - output for any red flags that might point to the problem. -- If you can, try installing the latest beets source code to see if the bug is - fixed in an unreleased version. You can also look at the :doc:`latest - changelog entries </changelog>` for descriptions of the problem you're seeing. -- Try to narrow your problem down to something specific. Is a particular plugin - causing the problem? (You can disable plugins to see whether the problem goes - away.) Is a some music file or a single album leading to the crash? (Try - importing individual albums to determine which one is causing the problem.) Is - some entry in your configuration file causing it? Et cetera. -- If you do narrow the problem down to a particular audio file or album, include - it with your bug report so the developers can run tests. +- Most importantly: if beets is crashing, please `include the + traceback <https://imgur.com/jacoj>`__. Tracebacks can be more + readable if you put them in a pastebin (e.g., + `Gist <https://gist.github.com/>`__ or + `Hastebin <https://hastebin.com/>`__), especially when communicating + over IRC. +- Turn on beets' debug output (using the -v option: for example, + ``beet -v import ...``) and include that with your bug report. Look + through this verbose output for any red flags that might point to the + problem. +- If you can, try installing the latest beets source code to see if the + bug is fixed in an unreleased version. You can also look at the + :doc:`latest changelog entries </changelog>` + for descriptions of the problem you're seeing. +- Try to narrow your problem down to something specific. Is a + particular plugin causing the problem? (You can disable plugins to + see whether the problem goes away.) Is a some music file or a single + album leading to the crash? (Try importing individual albums to + determine which one is causing the problem.) Is some entry in your + configuration file causing it? Et cetera. +- If you do narrow the problem down to a particular audio file or + album, include it with your bug report so the developers can run + tests. -If you've never reported a bug before, Mozilla has some well-written `general -guidelines for good bug reports`_. +If you've never reported a bug before, Mozilla has some well-written +`general guidelines for good bug +reports`_. .. _find-config: diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 0b502bfb1..1c6454958 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -9,13 +9,36 @@ collection better. Installing ---------- -You will need Python. Beets works on Python 3.8 or later. +Beets requires Python 3.9 or later, you will need to install that first. Depending +on your operating system, you may also be able to install beets from a package +manager, or you can install it with `pip`_. -- **macOS** 11 (Big Sur) includes Python 3.8 out of the box. You can opt for a - more recent Python installing it via Homebrew_ (``brew install python3``). - There's also a MacPorts_ port. Run ``port install beets`` or ``port install - beets-full`` to include many third-party plugins. -- On **Debian or Ubuntu**, depending on the version, beets is available as an +Using pip +^^^^^^^^^ + +To use the most recent version of beets, we recommend installing it with +`pip`_, the Python package manager. If you don't have `pip`_ installed, you can +follow the instructions on the `pip installation page`_ to get it set up. + +.. code-block:: console + + pip install beets + # or, to install for the current user only: + pip install --user beets + + +.. attention:: Python 3.13 not officially supported yet! + + If you are using Python 3.13, please be aware that it is not yet officially supported yet. + You may encounter issues, and we recommend using Python 3.12 or earlier until support is confirmed. + + +Using a Package Manager +^^^^^^^^^^^^^^^^^^^^^^^ + +Depending on your operating system, you may be able to install beets using a package manager. Here are some common options: + +* On **Debian or Ubuntu**, depending on the version, beets is available as an official package (`Debian details`_, `Ubuntu details`_), so try typing: ``apt-get install beets``. But the version in the repositories might lag behind, so make sure you read the right version of these docs. If you want the @@ -55,48 +78,16 @@ You will need Python. Beets works on Python 3.8 or later. .. _macports: https://www.macports.org -.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets - -.. _openbsd: http://openports.se/audio/beets - -.. _slackbuild: https://slackbuilds.org/repository/14.2/multimedia/beets/ - -.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets - -.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets - -If you have pip_, just say ``pip install beets`` (or ``pip install --user -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/#files - -.. _pip: https://pip.pypa.io - -The best way to upgrade beets to a new version is by running ``pip install -U -beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on -new versions. - -.. _@b33ts: https://twitter.com/b33ts - -Installing by Hand on macOS 10.11 and Higher -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Starting with version 10.11 (El Capitan), macOS has a new security feature -called `System Integrity Protection`_ (SIP) that prevents you from modifying -some parts of the system. This means that some ``pip`` commands may fail with a -permissions error. (You probably *won't* run into this if you've installed -Python yourself with Homebrew_ or otherwise. You can also try MacPorts_.) - -If this happens, you can install beets for the current user only by typing ``pip -install --user beets``. If you do that, you might want to add -``~/Library/Python/3.6/bin`` to your ``$PATH``. - -.. _homebrew: https://brew.sh - -.. _system integrity protection: https://support.apple.com/en-us/HT204899 +.. _DNF package: https://packages.fedoraproject.org/pkgs/beets/ +.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets +.. _AUR: https://aur.archlinux.org/packages/beets-git/ +.. _Debian details: https://tracker.debian.org/pkg/beets +.. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets +.. _Arch extra: https://archlinux.org/packages/extra/any/beets/ +.. _Alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets +.. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets +.. _pip: https://pip.pypa.io/en/ +.. _pip installation page: https://pip.pypa.io/en/stable/installation/ Installing on Windows ~~~~~~~~~~~~~~~~~~~~~ @@ -104,17 +95,18 @@ Installing on Windows Installing beets on Windows can be tricky. Following these steps might help you get it right: -1. If you don't have it, `install Python`_ (you want at least Python 3.8). The - installer should give you the option to "add Python to PATH." Check this box. - If you do that, you can skip the next step. +1. If you don't have it, `install Python`_ (you want at least Python 3.9). The + installer should give you the option to "add Python to PATH." Check this + box. If you do that, you can skip the next step. + 2. If you haven't done so already, set your ``PATH`` environment variable to - include Python and its scripts. To do so, open the "Settings" application, - then access the "System" screen, then access the "About" tab, and then hit - "Advanced system settings" located on the right side of the screen. This - should open the "System Properties" screen, then select the "Advanced" tab, - then hit the "Environmental Variables..." button, and then look for the PATH - variable in the table. Add the following to the end of the variable's value: - ``;C:\Python38;C:\Python38\Scripts``. You may need to adjust these paths to + include Python and its scripts. To do so, open the "Settings" application, + then access the "System" screen, then access the "About" tab, and then hit + "Advanced system settings" located on the right side of the screen. This + should open the "System Properties" screen, then select the "Advanced" tab, + then hit the "Environmental Variables..." button, and then look for the PATH + variable in the table. Add the following to the end of the variable's value: + ``;C:\Python39;C:\Python39\Scripts``. You may need to adjust these paths to point to your Python installation. 3. Now install beets by running: ``pip install beets`` 4. You're all set! Type ``beet`` at the command prompt to make sure everything's @@ -126,9 +118,8 @@ the paths to Python match your system. Then double-click the file add the necessary keys to your registry. You can then right-click a directory and choose "Import with beets". -Because I don't use Windows myself, I may have missed something. If you have -trouble or you have more detail to contribute here, please direct it to `the -mailing list`_. +If you have trouble or you have more detail to contribute here, please direct it to +`the discussion board`_. .. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg @@ -348,8 +339,5 @@ blog <https://beets.io/blog/walkthrough.html>`_. Please let us know what you think of beets via `the discussion board`_ or Mastodon_. -.. _mastodon: https://fosstodon.org/@beets - .. _the discussion board: https://github.com/beetbox/beets/discussions - -.. _the mailing list: https://groups.google.com/group/beets-users +.. _mastodon: https://fosstodon.org/@beets diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index c07d5df58..f43c1608c 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -311,5 +311,3 @@ If we haven't made the process clear, please post on `the discussion board`_ and we'll try to improve this guide. .. _the discussion board: https://github.com/beetbox/beets/discussions/ - -.. _the mailing list: https://groups.google.com/group/beets-users From 103b501af790b54bc2e5c63df1f35287a8299947 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 27 May 2025 13:30:59 +0200 Subject: [PATCH 212/301] Removed mailing list ref in index.rst --- docs/index.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 2b2c2e723..870f608c7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,12 +13,10 @@ Then you can get a more detailed look at beets' features in the be interested in exploring the :doc:`plugins </plugins/index>`. If you still need help, you can drop by the ``#beets`` IRC channel on -Libera.Chat, drop by `the discussion board`_, send email to `the mailing list`_, -or `file a bug`_ in the issue tracker. Please let us know where you think this -documentation can be improved. +Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue tracker. +Please let us know where you think this documentation can be improved. .. _beets: https://beets.io/ - .. _file a bug: https://github.com/beetbox/beets/issues .. _the discussion board: https://github.com/beetbox/beets/discussions/ From 3b5eee59eef79a7236e068409301aac71beb01ae Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 27 May 2025 13:32:50 +0200 Subject: [PATCH 213/301] Added changelog entry. --- docs/changelog.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 23278bb36..fd72f4f7a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,9 @@ For packagers: Other changes: +- Removed old mailing list contact info in docs :bug:`5462` +- :doc:`guides/main`: Modernized getting started guide using tabs and dropdown menue. Installtion instructions are now more condensed and there is a subpage for additional instructions. + 2.5.0 (October 11, 2025) ------------------------ @@ -61,8 +64,6 @@ Bug fixes: configuration option has been renamed to ``data_source_mismatch_penalty`` to better reflect its purpose. :bug:`6066` -For packagers: - Other changes: - :doc:`plugins/index`: Clarify that musicbrainz must be mentioned if plugin From 81c622bcecf442f4eda4dc184d4ae7dcaf3fd389 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 27 May 2025 13:38:25 +0200 Subject: [PATCH 214/301] Removed duplicate yet. --- docs/guides/main.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 1c6454958..99fa9be91 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -29,7 +29,7 @@ follow the instructions on the `pip installation page`_ to get it set up. .. attention:: Python 3.13 not officially supported yet! - If you are using Python 3.13, please be aware that it is not yet officially supported yet. + If you are using Python 3.13, please be aware that it is not officially supported yet. You may encounter issues, and we recommend using Python 3.12 or earlier until support is confirmed. From 1aaaeb49ed595dab16350f9f3b51b06ac357e7a5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sat, 31 May 2025 21:35:57 +0200 Subject: [PATCH 215/301] Added pipx refernces --- docs/guides/main.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 99fa9be91..abb6d5b3e 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -11,14 +11,20 @@ Installing Beets requires Python 3.9 or later, you will need to install that first. Depending on your operating system, you may also be able to install beets from a package -manager, or you can install it with `pip`_. +manager, or you can install it with `pipx`_ or `pip`_. Using pip ^^^^^^^^^ To use the most recent version of beets, we recommend installing it with -`pip`_, the Python package manager. If you don't have `pip`_ installed, you can -follow the instructions on the `pip installation page`_ to get it set up. +`pipx`_, the Python package manager. If you don't have `pipx`_ installed, you can +follow the instructions on the `pipx installation page`_ to get it set up. + +.. code-block:: console + + pipx install beets + +If you prefer to use `pip`_, you can install beets with the following command: .. code-block:: console @@ -87,7 +93,8 @@ Depending on your operating system, you may be able to install beets using a pac .. _Alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets .. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets .. _pip: https://pip.pypa.io/en/ -.. _pip installation page: https://pip.pypa.io/en/stable/installation/ +.. _pipx: https://pipx.pypa.io/stable +.. _pipx installation page: https://pipx.pypa.io/stable/installation/ Installing on Windows ~~~~~~~~~~~~~~~~~~~~~ From e30772f0c1254a7d0084e5d01f3eb69817d9c25e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 8 Sep 2025 15:33:54 +0200 Subject: [PATCH 216/301] Run formatter. --- docs/faq.rst | 49 ++++++++++------------ docs/guides/main.rst | 97 ++++++++++++++++++++++++-------------------- docs/index.rst | 5 ++- 3 files changed, 77 insertions(+), 74 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 40da1216b..287dc88af 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -165,36 +165,29 @@ documentation </dev/index>` pages. …report a bug in beets? ----------------------- -We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. -Please follow these guidelines when reporting an issue: +We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please +follow these guidelines when reporting an issue: -- Most importantly: if beets is crashing, please `include the - traceback <https://imgur.com/jacoj>`__. Tracebacks can be more - readable if you put them in a pastebin (e.g., - `Gist <https://gist.github.com/>`__ or - `Hastebin <https://hastebin.com/>`__), especially when communicating - over IRC. -- Turn on beets' debug output (using the -v option: for example, - ``beet -v import ...``) and include that with your bug report. Look - through this verbose output for any red flags that might point to the - problem. -- If you can, try installing the latest beets source code to see if the - bug is fixed in an unreleased version. You can also look at the - :doc:`latest changelog entries </changelog>` - for descriptions of the problem you're seeing. -- Try to narrow your problem down to something specific. Is a - particular plugin causing the problem? (You can disable plugins to - see whether the problem goes away.) Is a some music file or a single - album leading to the crash? (Try importing individual albums to - determine which one is causing the problem.) Is some entry in your - configuration file causing it? Et cetera. -- If you do narrow the problem down to a particular audio file or - album, include it with your bug report so the developers can run - tests. +- Most importantly: if beets is crashing, please `include the traceback + <https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them + in a pastebin (e.g., `Gist <https://gist.github.com/>`__ or `Hastebin + <https://hastebin.com/>`__), especially when communicating over IRC. +- Turn on beets' debug output (using the -v option: for example, ``beet -v + import ...``) and include that with your bug report. Look through this verbose + output for any red flags that might point to the problem. +- If you can, try installing the latest beets source code to see if the bug is + fixed in an unreleased version. You can also look at the :doc:`latest + changelog entries </changelog>` for descriptions of the problem you're seeing. +- Try to narrow your problem down to something specific. Is a particular plugin + causing the problem? (You can disable plugins to see whether the problem goes + away.) Is a some music file or a single album leading to the crash? (Try + importing individual albums to determine which one is causing the problem.) Is + some entry in your configuration file causing it? Et cetera. +- If you do narrow the problem down to a particular audio file or album, include + it with your bug report so the developers can run tests. -If you've never reported a bug before, Mozilla has some well-written -`general guidelines for good bug -reports`_. +If you've never reported a bug before, Mozilla has some well-written `general +guidelines for good bug reports`_. .. _find-config: diff --git a/docs/guides/main.rst b/docs/guides/main.rst index abb6d5b3e..070ab0e2c 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -9,22 +9,22 @@ collection better. Installing ---------- -Beets requires Python 3.9 or later, you will need to install that first. Depending -on your operating system, you may also be able to install beets from a package -manager, or you can install it with `pipx`_ or `pip`_. +Beets requires Python 3.9 or later, you will need to install that first. +Depending on your operating system, you may also be able to install beets from a +package manager, or you can install it with pipx_ or pip_. -Using pip -^^^^^^^^^ +Using pip(x) +~~~~~~~~~~~~ -To use the most recent version of beets, we recommend installing it with -`pipx`_, the Python package manager. If you don't have `pipx`_ installed, you can -follow the instructions on the `pipx installation page`_ to get it set up. +To use the most recent version of beets, we recommend installing it with pipx_, +the Python package manager. If you don't have pipx_ installed, you can follow +the instructions on the `pipx installation page`_ to get it set up. .. code-block:: console pipx install beets -If you prefer to use `pip`_, you can install beets with the following command: +If you prefer to use pip_, you can install beets with the following command: .. code-block:: console @@ -32,19 +32,35 @@ If you prefer to use `pip`_, you can install beets with the following command: # or, to install for the current user only: pip install --user beets +.. attention:: -.. attention:: Python 3.13 not officially supported yet! - - If you are using Python 3.13, please be aware that it is not officially supported yet. - You may encounter issues, and we recommend using Python 3.12 or earlier until support is confirmed. + Python 3.13 is not officially supported yet! + If you are using Python 3.13, please be aware that it is not officially + supported yet. You may encounter issues, and we recommend using Python 3.12 + or earlier until support is confirmed. + +.. _pip: https://pip.pypa.io/en/ + +.. _pipx: https://pipx.pypa.io/stable + +.. _pipx installation page: https://pipx.pypa.io/stable/installation/ Using a Package Manager -^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~ -Depending on your operating system, you may be able to install beets using a package manager. Here are some common options: +Depending on your operating system, you may be able to install beets using a +package manager. Here are some common options: -* On **Debian or Ubuntu**, depending on the version, beets is available as an +.. attention:: + + Package manager installations may not provide the latest version of beets. + + Release cycles for package managers vary, and they may not always have the + most recent version of beets. If you want the latest features and fixes, + consider using pipx_ or pip_ as described above. + +- On **Debian or Ubuntu**, depending on the version, beets is available as an official package (`Debian details`_, `Ubuntu details`_), so try typing: ``apt-get install beets``. But the version in the repositories might lag behind, so make sure you read the right version of these docs. If you want the @@ -63,7 +79,6 @@ Depending on your operating system, you may be able to install beets using a pac - On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``. - On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with ``pkg_add beets``. -- For **Slackware**, there's a SlackBuild_ available. - On **Fedora** 22 or later, there's a `DNF package`_ you can install with ``sudo dnf install beets beets-plugins beets-doc``. - On **Solus**, run ``eopkg install beets``. @@ -82,38 +97,31 @@ Depending on your operating system, you may be able to install beets using a pac .. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets -.. _macports: https://www.macports.org +.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets -.. _DNF package: https://packages.fedoraproject.org/pkgs/beets/ -.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets -.. _AUR: https://aur.archlinux.org/packages/beets-git/ -.. _Debian details: https://tracker.debian.org/pkg/beets -.. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets -.. _Arch extra: https://archlinux.org/packages/extra/any/beets/ -.. _Alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets -.. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets -.. _pip: https://pip.pypa.io/en/ -.. _pipx: https://pipx.pypa.io/stable -.. _pipx installation page: https://pipx.pypa.io/stable/installation/ +.. _openbsd: http://openports.se/audio/beets + +.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets + +.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets Installing on Windows -~~~~~~~~~~~~~~~~~~~~~ ++++++++++++++++++++++ Installing beets on Windows can be tricky. Following these steps might help you get it right: 1. If you don't have it, `install Python`_ (you want at least Python 3.9). The - installer should give you the option to "add Python to PATH." Check this - box. If you do that, you can skip the next step. - + installer should give you the option to "add Python to PATH." Check this box. + If you do that, you can skip the next step. 2. If you haven't done so already, set your ``PATH`` environment variable to - include Python and its scripts. To do so, open the "Settings" application, - then access the "System" screen, then access the "About" tab, and then hit - "Advanced system settings" located on the right side of the screen. This - should open the "System Properties" screen, then select the "Advanced" tab, - then hit the "Environmental Variables..." button, and then look for the PATH - variable in the table. Add the following to the end of the variable's value: - ``;C:\Python39;C:\Python39\Scripts``. You may need to adjust these paths to + include Python and its scripts. To do so, open the "Settings" application, + then access the "System" screen, then access the "About" tab, and then hit + "Advanced system settings" located on the right side of the screen. This + should open the "System Properties" screen, then select the "Advanced" tab, + then hit the "Environmental Variables..." button, and then look for the PATH + variable in the table. Add the following to the end of the variable's value: + ``;C:\Python39;C:\Python39\Scripts``. You may need to adjust these paths to point to your Python installation. 3. Now install beets by running: ``pip install beets`` 4. You're all set! Type ``beet`` at the command prompt to make sure everything's @@ -125,8 +133,8 @@ the paths to Python match your system. Then double-click the file add the necessary keys to your registry. You can then right-click a directory and choose "Import with beets". -If you have trouble or you have more detail to contribute here, please direct it to -`the discussion board`_. +If you have trouble or you have more detail to contribute here, please direct it +to `the discussion board`_. .. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg @@ -137,7 +145,7 @@ If you have trouble or you have more detail to contribute here, please direct it .. _install python: https://python.org/download/ Installing on ARM (Raspberry Pi and similar) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +++++++++++++++++++++++++++++++++++++++++++++ Beets on ARM devices is not recommended for Linux novices. If you are comfortable with light troubleshooting in tools like ``pip``, ``make``, and @@ -346,5 +354,6 @@ blog <https://beets.io/blog/walkthrough.html>`_. Please let us know what you think of beets via `the discussion board`_ or Mastodon_. -.. _the discussion board: https://github.com/beetbox/beets/discussions .. _mastodon: https://fosstodon.org/@beets + +.. _the discussion board: https://github.com/beetbox/beets/discussions diff --git a/docs/index.rst b/docs/index.rst index 870f608c7..13e28c1c2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,10 +13,11 @@ Then you can get a more detailed look at beets' features in the be interested in exploring the :doc:`plugins </plugins/index>`. If you still need help, you can drop by the ``#beets`` IRC channel on -Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue tracker. -Please let us know where you think this documentation can be improved. +Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue +tracker. Please let us know where you think this documentation can be improved. .. _beets: https://beets.io/ + .. _file a bug: https://github.com/beetbox/beets/issues .. _the discussion board: https://github.com/beetbox/beets/discussions/ From 7caa68a1412fff902597fa1e6b3cc8513f640921 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 9 Sep 2025 12:25:05 +0200 Subject: [PATCH 217/301] Re-added macport instructions. Removed mailing list ref. Added section header for pip and pipx. Removed python 3.13 attention. --- docs/guides/main.rst | 26 +++++++++++++------------- docs/index.rst | 2 -- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 070ab0e2c..2ac2dafe1 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -13,17 +13,20 @@ Beets requires Python 3.9 or later, you will need to install that first. Depending on your operating system, you may also be able to install beets from a package manager, or you can install it with pipx_ or pip_. -Using pip(x) -~~~~~~~~~~~~ +Using ``pipx`` +~~~~~~~~~~~~~~ -To use the most recent version of beets, we recommend installing it with pipx_, -the Python package manager. If you don't have pipx_ installed, you can follow -the instructions on the `pipx installation page`_ to get it set up. +To use the most recent version of beets, we recommend installing it with pipx_. +If you don't have pipx_ installed, you can follow the instructions on the `pipx +installation page`_ to get it set up. .. code-block:: console pipx install beets +Using ``pip`` +~~~~~~~~~~~~~ + If you prefer to use pip_, you can install beets with the following command: .. code-block:: console @@ -32,14 +35,6 @@ If you prefer to use pip_, you can install beets with the following command: # or, to install for the current user only: pip install --user beets -.. attention:: - - Python 3.13 is not officially supported yet! - - If you are using Python 3.13, please be aware that it is not officially - supported yet. You may encounter issues, and we recommend using Python 3.12 - or earlier until support is confirmed. - .. _pip: https://pip.pypa.io/en/ .. _pipx: https://pipx.pypa.io/stable @@ -60,6 +55,9 @@ package manager. Here are some common options: most recent version of beets. If you want the latest features and fixes, consider using pipx_ or pip_ as described above. + Additionally, installing external beets plugins may be surprisingly + difficult when using a package manager. + - On **Debian or Ubuntu**, depending on the version, beets is available as an official package (`Debian details`_, `Ubuntu details`_), so try typing: ``apt-get install beets``. But the version in the repositories might lag @@ -84,6 +82,8 @@ package manager. Here are some common options: - On **Solus**, run ``eopkg install beets``. - On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i beets``. +- Using **MacPorts**, run ``port install beets`` or ``port install beets-full`` + to include many third-party plugins. .. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets diff --git a/docs/index.rst b/docs/index.rst index 13e28c1c2..e9dd3b34f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,8 +22,6 @@ tracker. Please let us know where you think this documentation can be improved. .. _the discussion board: https://github.com/beetbox/beets/discussions/ -.. _the mailing list: https://groups.google.com/group/beets-users - Contents -------- From 7e81f23de6be00c7f70d30afc84bec01519f9487 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 9 Sep 2025 12:52:37 +0200 Subject: [PATCH 218/301] Readded (outdated) mac instructions. No idea why they were dropped. --- docs/guides/main.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 2ac2dafe1..84b719cbf 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -105,6 +105,19 @@ package manager. Here are some common options: .. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets +Installing by Hand on macOS 10.11 and Higher +++++++++++++++++++++++++++++++++++++++++++++ + +Starting with version 10.11 (El Capitan), macOS has a new security feature +called System Integrity Protection (SIP) that prevents you from modifying some +parts of the system. This means that some pip commands may fail with a +permissions error. (You probably won't run into this if you've installed Python +yourself with Homebrew or otherwise. You can also try MacPorts.) + +If this happens, you can install beets for the current user only by typing pip +install --user beets. If you do that, you might want to add +~/Library/Python/3.6/bin to your $PATH. + Installing on Windows +++++++++++++++++++++ From 1270364796cf6190e048f2f3c66101aeee72b716 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 30 Sep 2025 19:21:21 +0200 Subject: [PATCH 219/301] Modernized getting started guide. --- docs/conf.py | 5 +- docs/guides/index.rst | 1 + docs/guides/installation.rst | 179 +++++++++++ docs/guides/main.rst | 565 +++++++++++++++++++---------------- poetry.lock | 45 ++- pyproject.toml | 7 +- 6 files changed, 540 insertions(+), 262 deletions(-) create mode 100644 docs/guides/installation.rst diff --git a/docs/conf.py b/docs/conf.py index c76f87524..057141d22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,13 +23,16 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.extlinks", + "sphinx.ext.viewcode", + "sphinx_design", + "sphinx_copybutton", ] + autosummary_generate = True exclude_patterns = ["_build"] templates_path = ["_templates"] source_suffix = {".rst": "restructuredtext", ".md": "markdown"} - pygments_style = "sphinx" # External links to the bug tracker and other sites. diff --git a/docs/guides/index.rst b/docs/guides/index.rst index 08685abba..0695e9ff8 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -9,5 +9,6 @@ guide. :maxdepth: 1 main + installation tagger advanced diff --git a/docs/guides/installation.rst b/docs/guides/installation.rst new file mode 100644 index 000000000..648a72d0b --- /dev/null +++ b/docs/guides/installation.rst @@ -0,0 +1,179 @@ +Installation +============ + +Beets requires `Python 3.9 or later`_. You can install it using package +managers, pipx_, pip_ or by using package managers. + +.. _python 3.9 or later: https://python.org/download/ + +Using ``pipx`` or ``pip`` +------------------------- + +We recommend installing with pipx_ as it isolates beets and its dependencies +from your system Python and other Python packages. This helps avoid dependency +conflicts and keeps your system clean. + +.. <!-- start-quick-install --> + +.. tab-set:: + + .. tab-item:: pipx + + .. code-block:: console + + pipx install beets + + .. tab-item:: pip + + .. code-block:: console + + pip install beets + + .. tab-item:: pip (user install) + + .. code-block:: console + + pip install --user beets + +.. <!-- end-quick-install --> + +If you don't have pipx_ installed, you can follow the instructions on the `pipx +installation page`_ to get it set up. + +.. _pip: https://pip.pypa.io/en/ + +.. _pipx: https://pipx.pypa.io/stable + +.. _pipx installation page: https://pipx.pypa.io/stable/installation/ + +Using a Package Manager +----------------------- + +Depending on your operating system, you may be able to install beets using a +package manager. Here are some common options: + +.. attention:: + + Package manager installations may not provide the latest version of beets. + + Release cycles for package managers vary, and they may not always have the + most recent version of beets. If you want the latest features and fixes, + consider using pipx_ or pip_ as described above. + + Additionally, installing external beets plugins may be surprisingly + difficult when using a package manager. + +- On **Debian or Ubuntu**, depending on the version, beets is available as an + official package (`Debian details`_, `Ubuntu details`_), so try typing: + ``apt-get install beets``. But the version in the repositories might lag + behind, so make sure you read the right version of these docs. If you want the + latest version, you can get everything you need to install with pip as + described below by running: ``apt-get install python-dev python-pip`` +- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman + -S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR, + which will probably set your computer on fire.) +- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_ + and can be installed with ``apk add beets``. +- On **Void Linux**, `beets is in the official repository <void package_>`_ and + can be installed with ``xbps-install -S beets``. +- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run + ``emerge beets`` to install. There are several USE flags available for + optional plugin dependencies. +- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``. +- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with + ``pkg_add beets``. +- On **Fedora** 22 or later, there's a `DNF package`_ you can install with + ``sudo dnf install beets beets-plugins beets-doc``. +- On **Solus**, run ``eopkg install beets``. +- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i + beets``. +- Using **MacPorts**, run ``port install beets`` or ``port install beets-full`` + to include many third-party plugins. + +.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets + +.. _arch extra: https://archlinux.org/packages/extra/any/beets/ + +.. _aur: https://aur.archlinux.org/packages/beets-git/ + +.. _debian details: https://tracker.debian.org/pkg/beets + +.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/ + +.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets + +.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets + +.. _openbsd: http://openports.se/audio/beets + +.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets + +.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets + +Installation FAQ +---------------- + +MacOS Installation +~~~~~~~~~~~~~~~~~~ + +**Q: I'm getting permission errors on macOS. What should I do?** + +Due to System Integrity Protection on macOS 10.11+, you may need to install for +your user only: + +.. code-block:: console + + pip install --user beets + +You might need to also add ``~/Library/Python/3.x/bin`` to your ``$PATH``. + +Windows Installation +~~~~~~~~~~~~~~~~~~~~ + +**Q: What's the process for installing on Windows?** + +Installing beets on Windows can be tricky. Following these steps might help you +get it right: + +1. `Install Python`_ (check "Add Python to PATH" skip to 3) +2. Ensure Python is in your ``PATH`` (add if needed): + + - Settings → System → About → Advanced system settings → Environment + Variables + - Edit "PATH" and add: `;C:\Python39;C:\Python39\Scripts` + - *Guide: [Adding Python to + PATH](https://realpython.com/add-python-to-path/)* + +3. Now install beets by running: ``pip install beets`` +4. You're all set! Type ``beet version`` in a new command prompt to verify the + installation. + +**Bonus: Windows Context Menu Integration** + +Windows users may also want to install a context menu item for importing files +into beets. Download the beets.reg_ file and open it in a text file to make sure +the paths to Python match your system. Then double-click the file add the +necessary keys to your registry. You can then right-click a directory and choose +"Import with beets". + +.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg + +.. _install pip: https://pip.pypa.io/en/stable/installing/ + +.. _install python: https://python.org/download/ + +ARM Installation +~~~~~~~~~~~~~~~~ + +**Q: Can I run beets on a Raspberry Pi or other ARM device?** + +Yes, but with some considerations: Beets on ARM devices is not recommended for +Linux novices. If you are comfortable with troubleshooting tools like ``pip``, +``make``, and binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``), you +will be fine. We have `notes for ARM`_ and an `older ARM reference`_. Beets is +generally developed on x86-64 based devices, and most plugins target that +platform as well. + +.. _notes for arm: https://github.com/beetbox/beets/discussions/4910 + +.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993 diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 84b719cbf..2b8947edb 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -1,341 +1,310 @@ Getting Started =============== -Welcome to beets_! This guide will help you begin using it to make your music -collection better. +Welcome to beets_! This guide will help get started with improving and +organizing your music collection. .. _beets: https://beets.io/ -Installing ----------- +Quick Installation +------------------ -Beets requires Python 3.9 or later, you will need to install that first. -Depending on your operating system, you may also be able to install beets from a -package manager, or you can install it with pipx_ or pip_. +Beets is distributed via PyPI_ and can be installed by most users with a single +command: -Using ``pipx`` -~~~~~~~~~~~~~~ +.. include:: installation.rst + :start-after: <!-- start-quick-install --> + :end-before: <!-- end-quick-install --> -To use the most recent version of beets, we recommend installing it with pipx_. -If you don't have pipx_ installed, you can follow the instructions on the `pipx -installation page`_ to get it set up. +.. admonition:: Need more installation options? -.. code-block:: console + Having trouble with the commands above? Looking for package manager + instructions? See the :doc:`complete installation guide + </guides/installation>` for: - pipx install beets + - Operating system specific instructions + - Package manager options + - Troubleshooting help -Using ``pip`` -~~~~~~~~~~~~~ +.. _pypi: https://pypi.org/project/beets/ -If you prefer to use pip_, you can install beets with the following command: +Basic Configuration +------------------- -.. code-block:: console +Before using beets, you'll need a configuration file. This YAML_ file tells +beets where to store your music and how to organize it. - pip install beets - # or, to install for the current user only: - pip install --user beets +While beets is highly configurable, you only need a few basic settings to get +started. -.. _pip: https://pip.pypa.io/en/ +1. **Open the config file:** + .. code-block:: console -.. _pipx: https://pipx.pypa.io/stable + beet config -e -.. _pipx installation page: https://pipx.pypa.io/stable/installation/ + This creates the file (if needed) and opens it in your default editor. + You can also find its location with ``beet config -p``. +2. **Add required settings:** + In the config file, set the ``directory`` option to the path where you + want beets to store your music files. Set the ``library`` option to the + path where you want beets to store its database file. -Using a Package Manager -~~~~~~~~~~~~~~~~~~~~~~~ + .. code-block:: yaml -Depending on your operating system, you may be able to install beets using a -package manager. Here are some common options: + directory: ~/music + library: ~/data/musiclibrary.db +3. **Choose your import style** (pick one): + Beets offers flexible import strategies to match your workflow. Choose + one of the following approaches and put one of the following in your + config file: -.. attention:: + .. tab-set:: - Package manager installations may not provide the latest version of beets. + .. tab-item:: Copy Files (Default) - Release cycles for package managers vary, and they may not always have the - most recent version of beets. If you want the latest features and fixes, - consider using pipx_ or pip_ as described above. + This is the default configuration and assumes you want to start a new organized music folder (inside ``directory`` above). During import we will *copy* cleaned-up music into that empty folder. - Additionally, installing external beets plugins may be surprisingly - difficult when using a package manager. + .. code-block:: yaml -- On **Debian or Ubuntu**, depending on the version, beets is available as an - official package (`Debian details`_, `Ubuntu details`_), so try typing: - ``apt-get install beets``. But the version in the repositories might lag - behind, so make sure you read the right version of these docs. If you want the - latest version, you can get everything you need to install with pip as - described below by running: ``apt-get install python-dev python-pip`` -- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman - -S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR, - which will probably set your computer on fire.) -- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_ - and can be installed with ``apk add beets``. -- On **Void Linux**, `beets is in the official repository <void package_>`_ and - can be installed with ``xbps-install -S beets``. -- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run - ``emerge beets`` to install. There are several USE flags available for - optional plugin dependencies. -- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``. -- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with - ``pkg_add beets``. -- On **Fedora** 22 or later, there's a `DNF package`_ you can install with - ``sudo dnf install beets beets-plugins beets-doc``. -- On **Solus**, run ``eopkg install beets``. -- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i - beets``. -- Using **MacPorts**, run ``port install beets`` or ``port install beets-full`` - to include many third-party plugins. + import: + copy: yes # Copy files to new location -.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets -.. _arch extra: https://archlinux.org/packages/extra/any/beets/ + .. tab-item:: Move Files -.. _aur: https://aur.archlinux.org/packages/beets-git/ + Start with a new empty directory, but *move* new music in instead of copying it (saving disk space). -.. _debian details: https://tracker.debian.org/pkg/beets + .. code-block:: yaml -.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/ + import: + move: yes # Move files to new location -.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets + .. tab-item:: Use Existing Structure -.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets + Keep your current directory structure; importing should never move or copy files but instead just correct the tags on music. Make sure to point ``directory`` at the place where your music is currently stored. -.. _openbsd: http://openports.se/audio/beets + .. code-block:: yaml -.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets + import: + copy: no # Use files in place -.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets + .. tab-item:: Read-Only Mode -Installing by Hand on macOS 10.11 and Higher -++++++++++++++++++++++++++++++++++++++++++++ + Keep everything exactly as-is; only track metadata in database. (Corrected tags will still be stored in beets' database, and you can use them to do renaming or tag changes later.) -Starting with version 10.11 (El Capitan), macOS has a new security feature -called System Integrity Protection (SIP) that prevents you from modifying some -parts of the system. This means that some pip commands may fail with a -permissions error. (You probably won't run into this if you've installed Python -yourself with Homebrew or otherwise. You can also try MacPorts.) + .. code-block:: yaml -If this happens, you can install beets for the current user only by typing pip -install --user beets. If you do that, you might want to add -~/Library/Python/3.6/bin to your $PATH. + import: + copy: no # Use files in place + write: no # Don't modify tags +4. **Add customization via plugins (optional):** + Beets comes with many plugins that extend its functionality. You can + enable plugins by adding a `plugins` section to your config file. -Installing on Windows -+++++++++++++++++++++ + We recommend adding at least one :ref:`Autotagger Plugin + <autotagger_extensions>` to help with fetching metadata during import. + For getting started, :doc:`MusicBrainz </plugins/musicbrainz>` is a good + choice. -Installing beets on Windows can be tricky. Following these steps might help you -get it right: + .. code-block:: yaml -1. If you don't have it, `install Python`_ (you want at least Python 3.9). The - installer should give you the option to "add Python to PATH." Check this box. - If you do that, you can skip the next step. -2. If you haven't done so already, set your ``PATH`` environment variable to - include Python and its scripts. To do so, open the "Settings" application, - then access the "System" screen, then access the "About" tab, and then hit - "Advanced system settings" located on the right side of the screen. This - should open the "System Properties" screen, then select the "Advanced" tab, - then hit the "Environmental Variables..." button, and then look for the PATH - variable in the table. Add the following to the end of the variable's value: - ``;C:\Python39;C:\Python39\Scripts``. You may need to adjust these paths to - point to your Python installation. -3. Now install beets by running: ``pip install beets`` -4. You're all set! Type ``beet`` at the command prompt to make sure everything's - in order. + plugins: + - musicbrainz # Example plugin for fetching metadata + - ... other plugins you want ... -Windows users may also want to install a context menu item for importing files -into beets. Download the beets.reg_ file and open it in a text file to make sure -the paths to Python match your system. Then double-click the file add the -necessary keys to your registry. You can then right-click a directory and choose -"Import with beets". - -If you have trouble or you have more detail to contribute here, please direct it -to `the discussion board`_. - -.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg - -.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py - -.. _install pip: https://pip.pypa.io/en/stable/installing/ - -.. _install python: https://python.org/download/ - -Installing on ARM (Raspberry Pi and similar) -++++++++++++++++++++++++++++++++++++++++++++ - -Beets on ARM devices is not recommended for Linux novices. If you are -comfortable with light troubleshooting in tools like ``pip``, ``make``, and -beets' command-line binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``), -you will probably be okay on ARM devices like the Raspberry Pi. We have `notes -for ARM`_ and an `older ARM reference`_. Beets is generally developed on x86-64 -based devices, and most plugins target that platform as well. - -.. _notes for arm: https://github.com/beetbox/beets/discussions/4910 - -.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993 - -Configuring ------------ - -You'll want to set a few basic options before you start using beets. The -:doc:`configuration </reference/config>` is stored in a text file. You can show -its location by running ``beet config -p``, though it may not exist yet. Run -``beet config -e`` to edit the configuration in your favorite text editor. The -file will start out empty, but here's good place to start: - -:: - - directory: ~/music - library: ~/data/musiclibrary.db - -Change that first path to a directory where you'd like to keep your music. Then, -for ``library``, choose a good place to keep a database file that keeps an index -of your music. (The config's format is YAML_. You'll want to configure your text -editor to use spaces, not real tabs, for indentation. Also, ``~`` means your -home directory in these paths, even on Windows.) - -The default configuration assumes you want to start a new organized music folder -(that ``directory`` above) and that you'll *copy* cleaned-up music into that -empty folder using beets' ``import`` command (see below). But you can configure -beets to behave many other ways: - -- Start with a new empty directory, but *move* new music in instead of copying - it (saving disk space). Put this in your config file: - - :: - - import: - move: yes - -- Keep your current directory structure; importing should never move or copy - files but instead just correct the tags on music. Put the line ``copy: no`` - under the ``import:`` heading in your config file to disable any copying or - renaming. Make sure to point ``directory`` at the place where your music is - currently stored. -- Keep your current directory structure and *do not* correct files' tags: leave - files completely unmodified on your disk. (Corrected tags will still be stored - in beets' database, and you can use them to do renaming or tag changes later.) - Put this in your config file: - - :: - - import: - copy: no - write: no - - to disable renaming and tag-writing. - -There are other configuration options you can set here, including the directory -and file naming scheme. See :doc:`/reference/config` for a full reference. + You can find a list of available plugins in the :doc:`plugins index + </plugins/index>`. .. _yaml: https://yaml.org/ -To check that you've set up your configuration how you want it, you can type -``beet version`` to see a list of enabled plugins or ``beet config`` to get a -complete listing of your current configuration. +To validate that you've set up your configuration and it is valid YAML, you can +type ``beet version`` to see a list of enabled plugins or ``beet config`` to get +a complete listing of your current configuration. -Importing Your Library ----------------------- +.. dropdown:: Full configuration file -The next step is to import your music files into the beets library database. -Because this can involve modifying files and moving them around, data loss is -always a possibility, so now would be a good time to make sure you have a recent -backup of all your music. We'll wait. + Here's a sample configuration file that includes the settings mentioned above: -There are two good ways to bring your existing library into beets. You can -either: (a) quickly bring all your files with all their current metadata into -beets' database, or (b) use beets' highly-refined autotagger to find canonical -metadata for every album you import. Option (a) is really fast, but option (b) -makes sure all your songs' tags are exactly right from the get-go. The point -about speed bears repeating: using the autotagger on a large library can take a -very long time, and it's an interactive process. So set aside a good chunk of -time if you're going to go that route. For more on the interactive tagging -process, see :doc:`tagger`. + .. code-block:: yaml -If you've got time and want to tag all your music right once and for all, do -this: + directory: ~/music + library: ~/data/musiclibrary.db -:: + import: + move: yes # Move files to new location + # copy: no # Use files in place + # write: no # Don't modify tags - $ beet import /path/to/my/music + plugins: + - musicbrainz # Example plugin for fetching metadata + # - ... other plugins you want ... -(Note that by default, this command will *copy music into the directory you -specified above*. If you want to use your current directory structure, set the -``import.copy`` config option.) To take the fast, un-autotagged path, just say: + You can copy and paste this into your config file and modify it as needed. -:: +.. admonition:: Ready for more? - $ beet import -A /my/huge/mp3/library + For a complete reference of all configuration options, see the + :doc:`configuration reference </reference/config>`. -Note that you just need to add ``-A`` for "don't autotag". +Importing Your Music +-------------------- -Adding More Music ------------------ +Now you're ready to import your music into beets! -If you've ripped or... otherwise obtained some new music, you can add it with -the ``beet import`` command, the same way you imported your library. Like so: +.. important:: -:: + Importing can modify and move your music files. **Make sure you have a + recent backup** before proceeding. - $ beet import ~/some_great_album +Choose Your Import Method +~~~~~~~~~~~~~~~~~~~~~~~~~ -This will attempt to autotag the new album (interactively) and add it to your -library. There are, of course, more options for this command---just type ``beet -help import`` to see what's available. +There are two good ways to bring your *existing* library into beets database. + +.. tab-set:: + + .. tab-item:: Autotag (Recommended) + + This method uses beets' autotagger to find canonical metadata for every album you import. It may take a while, especially for large libraries, and it's an interactive process. But it ensures all your songs' tags are exactly right from the get-go. + + .. code-block:: console + + beet import /a/chunk/of/my/library + + .. warning:: + + The point about speed bears repeating: using the autotagger on a large library can take a + very long time, and it's an interactive process. So set aside a good chunk of + time if you're going to go that route. + + We also recommend importing smaller batches of music at a time (e.g., a few albums) to make the process more manageable. For more on the interactive tagging + process, see :doc:`tagger`. + + + .. tab-item:: Quick Import + + This method quickly brings all your files with all their current metadata into beets' database without any changes. It's really fast, but it doesn't clean up or correct any tags. + + To use this method, run: + + .. code-block:: console + + beet import -A /my/huge/mp3/library + + The ``-A`` flag skips autotagging and uses your files' current metadata. + +.. admonition:: More Import Options + + The ``beet import`` command has many options to customize its behavior. For + a full list, type ``beet help import`` or see the :ref:`import command + reference <import-cmd>`. + +Adding More Music Later +~~~~~~~~~~~~~~~~~~~~~~~ + +When you acquire new music, use the same ``beet import`` command to add it to +your library: + +.. code-block:: console + + beet import ~/new_totally_not_ripped_album + +This will apply the same autotagging process to your new additions. For +alternative import behaviors, consult the options mentioned above. Seeing Your Music ----------------- -If you want to query your music library, the ``beet list`` (shortened to ``beet -ls``) command is for you. You give it a :doc:`query string </reference/query>`, -which is formatted something like a Google search, and it gives you a list of -songs. Thus: +Once you've imported music into beets, you'll want to explore and query your +library. Beets provides several commands for searching, browsing, and getting +statistics about your collection. -:: +Basic Searching +~~~~~~~~~~~~~~~ + +The ``beet list`` command (shortened to ``beet ls``) lets you search your music +library using :doc:`query string </reference/query>` similar to web searches: + +.. code-block:: console $ beet ls the magnetic fields The Magnetic Fields - Distortion - Three-Way - The Magnetic Fields - Distortion - California Girls + The Magnetic Fields - Dist The Magnetic Fields - Distortion - Old Fools + +.. code-block:: console + $ beet ls hissing gronlandic of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit + +.. code-block:: console + $ beet ls bird The Knife - The Knife - Bird The Mae Shi - Terrorbird - Revelation Six + +By default, search terms match against :ref:`common attributes <keywordquery>` +of songs, and multiple terms are combined with AND logic (a track must match +*all* criteria). + +Searching Specific Fields +~~~~~~~~~~~~~~~~~~~~~~~~~ + +To narrow a search term to a particular metadata field, prefix the term with the +field name followed by a colon. For example, ``album:bird`` searches for "bird" +only in the "album" field of your songs. For more details, see +:doc:`/reference/query/`. + +.. code-block:: console + $ beet ls album:bird The Mae Shi - Terrorbird - Revelation Six -By default, a search term will match any of a handful of :ref:`common attributes -<keywordquery>` of songs. (They're also implicitly joined by ANDs: a track must -match *all* criteria in order to match the query.) To narrow a search term to a -particular metadata field, just put the field before the term, separated by a : -character. So ``album:bird`` only looks for ``bird`` in the "album" field of -your songs. (Need to know more? :doc:`/reference/query/` will answer all your -questions.) +This searches only the ``album`` field for the term ``bird``. + +Searching for Albums +~~~~~~~~~~~~~~~~~~~~ The ``beet list`` command also has an ``-a`` option, which searches for albums instead of songs: -:: +.. code-block:: console $ beet ls -a forever Bon Iver - For Emma, Forever Ago Freezepop - Freezepop Forever +Custom Output Formatting +~~~~~~~~~~~~~~~~~~~~~~~~ + There's also an ``-f`` option (for *format*) that lets you specify what gets displayed in the results of a search: -:: +.. code-block:: console $ beet ls -a forever -f "[$format] $album ($year) - $artist - $title" [MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume [AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme -In the format option, field references like ``$format`` and ``$year`` are filled -in with data from each result. You can see a full list of available fields by -running ``beet fields``. +In the format string, field references like ``$format``, ``$year``, ``$album``, +etc., are replaced with data from each result. -Beets also has a ``stats`` command, just in case you want to see how much music -you have: +.. dropdown:: Available fields for formatting -:: + To see all available fields you can use in custom formats, run: + + .. code-block:: console + + beet fields + + This will display a comprehensive list of metadata fields available for your music. + +Library Statistics +~~~~~~~~~~~~~~~~~~ + +Beets can also show you statistics about your music collection: + +.. code-block:: console $ beet stats Tracks: 13019 @@ -344,29 +313,107 @@ you have: Artists: 548 Albums: 1094 +.. admonition:: Ready for more advanced queries? + + The ``beet list`` command has many additional options for sorting, limiting + results, and more complex queries. For a complete reference, run: + + .. code-block:: console + + beet help list + + Or see the :ref:`list command reference <list-cmd>`. + Keep Playing ------------ -This is only the beginning of your long and prosperous journey with beets. To -keep learning, take a look at :doc:`advanced` for a sampling of what else is -possible. You'll also want to glance over the :doc:`/reference/cli` page for a -more detailed description of all of beets' functionality. (Like deleting music! -That's important.) +Congratulations! You've now mastered the basics of beets. But this is only the +beginning, beets has many more powerful features to explore. -Also, check out :doc:`beets' plugins </plugins/index>`. The real power of beets -is in its extensibility---with plugins, beets can do almost anything for your -music collection. +Continue Your Learning Journey +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can always get help using the ``beet help`` command. The plain ``beet help`` -command lists all the available commands; then, for example, ``beet help -import`` gives more specific help about the ``import`` command. +*I was there to push people beyond what's expected of them.* -If you need more of a walkthrough, you can read an illustrated one `on the beets -blog <https://beets.io/blog/walkthrough.html>`_. +.. grid:: 2 + :gutter: 3 -Please let us know what you think of beets via `the discussion board`_ or -Mastodon_. + .. grid-item-card:: :octicon:`zap` Advanced Techniques + :link: advanced + :link-type: doc -.. _mastodon: https://fosstodon.org/@beets + Explore sophisticated beets workflows including: -.. _the discussion board: https://github.com/beetbox/beets/discussions + - Advanced tagging strategies + - Complex import scenarios + - Custom metadata management + - Workflow automation + + .. grid-item-card:: :octicon:`terminal` Command Reference + :link: /reference/cli + :link-type: doc + + Comprehensive guide to all beets commands: + + - Complete command syntax + - All available options + - Usage examples + - **Important operations like deleting music** + + .. grid-item-card:: :octicon:`plug` Plugin Ecosystem + :link: /plugins/index + :link-type: doc + + Discover beets' true power through plugins: + + - Metadata fetching from multiple sources + - Audio analysis and processing + - Streaming service integration + - Custom export formats + + .. grid-item-card:: :octicon:`question` Illustrated Walkthrough + :link: https://beets.io/blog/walkthrough.html + :link-type: url + + Visual, step-by-step guide covering: + + - Real-world import examples + - Screenshots of interactive tagging + - Common workflow patterns + - Troubleshooting tips + +.. admonition:: Need Help? + + Remember you can always use ``beet help`` to see all available commands, or + ``beet help [command]`` for detailed help on specific commands. + +Join the Community +~~~~~~~~~~~~~~~~~~ + +We'd love to hear about your experience with beets! + +.. grid:: 2 + :gutter: 2 + + .. grid-item-card:: :octicon:`comment-discussion` Discussion Board + :link: https://github.com/beetbox/beets/discussions + :link-type: url + + - Ask questions + - Share tips and tricks + - Discuss feature ideas + - Get help from other users + + .. grid-item-card:: :octicon:`git-pull-request` Developer Resources + :link: https://github.com/beetbox/beets + :link-type: url + + - Contribute code + - Report issues + - Review pull requests + - Join development discussions + +.. admonition:: Found a Bug? + + If you encounter any issues, please report them on our `GitHub Issues page + <https://github.com/beetbox/beets/issues>`_. diff --git a/poetry.lock b/poetry.lock index 8c109f930..0be47723f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3216,6 +3216,49 @@ docs = ["sphinxcontrib-websupport"] lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +description = "Add a copy button to each of your code cells." +optional = false +python-versions = ">=3.7" +files = [ + {file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"}, + {file = "sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e"}, +] + +[package.dependencies] +sphinx = ">=1.8" + +[package.extras] +code-style = ["pre-commit (==2.12.1)"] +rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"] + +[[package]] +name = "sphinx-design" +version = "0.6.1" +description = "A sphinx extension for designing beautiful, view size responsive web components." +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c"}, + {file = "sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632"}, +] + +[package.dependencies] +sphinx = ">=6,<9" + +[package.extras] +code-style = ["pre-commit (>=3,<4)"] +rtd = ["myst-parser (>=2,<4)"] +testing = ["defusedxml", "myst-parser (>=2,<4)", "pytest (>=8.3,<9.0)", "pytest-cov", "pytest-regressions"] +testing-no-myst = ["defusedxml", "pytest (>=8.3,<9.0)", "pytest-cov", "pytest-regressions"] +theme-furo = ["furo (>=2024.7.18,<2024.8.0)"] +theme-im = ["sphinx-immaterial (>=0.12.2,<0.13.0)"] +theme-pydata = ["pydata-sphinx-theme (>=0.15.2,<0.16.0)"] +theme-rtd = ["sphinx-rtd-theme (>=2.0,<3.0)"] +theme-sbt = ["sphinx-book-theme (>=1.1,<2.0)"] + [[package]] name = "sphinx-lint" version = "1.0.0" @@ -3629,4 +3672,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "faea27878ce1ca3f1335fd83e027b289351c51c73550bda72bf501a9c82166f7" +content-hash = "caa669bfb1ff913d528553de4bc4f420279e6bc9b119d39c4db616041576266d" diff --git a/pyproject.toml b/pyproject.toml index 62b5ac25a..1babdadd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,11 @@ click = ">=8.1.7" packaging = ">=24.0" tomli = ">=2.0.1" + +[tool.poetry.group.docs.dependencies] +sphinx-design = "^0.6.1" +sphinx-copybutton = "^0.5.2" + [tool.poetry.extras] # inline comments note required external / non-python dependencies absubmit = ["requests"] # extractor binary from https://acousticbrainz.org/download @@ -129,7 +134,7 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0 chroma = ["pyacoustid"] # chromaprint or fpcalc # convert # ffmpeg -docs = ["pydata-sphinx-theme", "sphinx", "sphinx-lint"] +docs = ["pydata-sphinx-theme", "sphinx", "sphinx-lint", "sphinx-design", "sphinx-copybutton"] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick embyupdate = ["requests"] From 3a6769d3b9a51ef0b1241b1d54520c3015b237d1 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Wed, 1 Oct 2025 14:48:31 +0200 Subject: [PATCH 220/301] Set sphinx dependencies as optional --- poetry.lock | 8 ++++---- pyproject.toml | 7 ++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0be47723f..6f0523a42 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3220,7 +3220,7 @@ test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools name = "sphinx-copybutton" version = "0.5.2" description = "Add a copy button to each of your code cells." -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"}, @@ -3238,7 +3238,7 @@ rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"] name = "sphinx-design" version = "0.6.1" description = "A sphinx extension for designing beautiful, view size responsive web components." -optional = false +optional = true python-versions = ">=3.9" files = [ {file = "sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c"}, @@ -3650,7 +3650,7 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] chroma = ["pyacoustid"] discogs = ["python3-discogs-client"] -docs = ["pydata-sphinx-theme", "sphinx"] +docs = ["pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] embedart = ["Pillow"] embyupdate = ["requests"] fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"] @@ -3672,4 +3672,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "caa669bfb1ff913d528553de4bc4f420279e6bc9b119d39c4db616041576266d" +content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" diff --git a/pyproject.toml b/pyproject.toml index 1babdadd3..3a355418c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,8 @@ soco = { version = "*", optional = true } pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } +sphinx-design = { version = "^0.6.1", optional = true } +sphinx-copybutton = { version = "^0.5.2", optional = true } [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -119,11 +121,6 @@ click = ">=8.1.7" packaging = ">=24.0" tomli = ">=2.0.1" - -[tool.poetry.group.docs.dependencies] -sphinx-design = "^0.6.1" -sphinx-copybutton = "^0.5.2" - [tool.poetry.extras] # inline comments note required external / non-python dependencies absubmit = ["requests"] # extractor binary from https://acousticbrainz.org/download From 32fdad1411a671076385ebf9bd484c3cedc722b5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sat, 11 Oct 2025 13:55:29 +0200 Subject: [PATCH 221/301] Enhanced changelog entry. --- docs/changelog.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fd72f4f7a..4f32093fe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,8 +15,11 @@ For packagers: Other changes: -- Removed old mailing list contact info in docs :bug:`5462` -- :doc:`guides/main`: Modernized getting started guide using tabs and dropdown menue. Installtion instructions are now more condensed and there is a subpage for additional instructions. +- Removed outdated mailing list contact information from the documentation + (:bug:`5462`). +- :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed + sections and dropdown menus. Installation instructions have been streamlined, + and a new subpage now provides additional setup details. 2.5.0 (October 11, 2025) ------------------------ From dd9917d3f36935631d305cc1022d6d94efafb64b Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sat, 11 Oct 2025 14:27:44 +0200 Subject: [PATCH 222/301] Removed yaml hyperlink. Changed dropdown naming. Use full console param instead of short form. --- docs/guides/main.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 2b8947edb..b2d1aa00d 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -31,8 +31,8 @@ command: Basic Configuration ------------------- -Before using beets, you'll need a configuration file. This YAML_ file tells -beets where to store your music and how to organize it. +Before using beets, you'll need a configuration file. This YAML file tells beets +where to store your music and how to organize it. While beets is highly configurable, you only need a few basic settings to get started. @@ -121,7 +121,7 @@ To validate that you've set up your configuration and it is valid YAML, you can type ``beet version`` to see a list of enabled plugins or ``beet config`` to get a complete listing of your current configuration. -.. dropdown:: Full configuration file +.. dropdown:: Minimal configuration Here's a sample configuration file that includes the settings mentioned above: @@ -189,9 +189,9 @@ There are two good ways to bring your *existing* library into beets database. .. code-block:: console - beet import -A /my/huge/mp3/library + beet import --noautotag /my/huge/mp3/library - The ``-A`` flag skips autotagging and uses your files' current metadata. + The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata. .. admonition:: More Import Options From dcec32794227bbd5a613660f579a391af13ad591 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Sat, 11 Oct 2025 14:32:35 +0200 Subject: [PATCH 223/301] Developer Resources card now links to doc page. --- docs/guides/main.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index b2d1aa00d..48b248927 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -405,8 +405,8 @@ We'd love to hear about your experience with beets! - Get help from other users .. grid-item-card:: :octicon:`git-pull-request` Developer Resources - :link: https://github.com/beetbox/beets - :link-type: url + :link: /dev/index + :link-type: doc - Contribute code - Report issues From 37a5f9cb156e33690cac37f77cebd103bdbecbcf Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 20:47:51 +0200 Subject: [PATCH 224/301] Add custom feat words --- beets/plugins.py | 6 ++- beetsplug/ftintitle.py | 57 +++++++++++++++++++-------- test/plugins/test_ftintitle.py | 71 +++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 7fa0e660a..397c33822 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -632,13 +632,15 @@ def send(event: EventType, **arguments: Any) -> list[Any]: ] -def feat_tokens(for_artist: bool = True) -> str: +def feat_tokens( + for_artist: bool = True, custom_feat_words: list[str] = [] +) -> str: """Return a regular expression that matches phrases like "featuring" that separate a main artist or a song title from secondary artists. The `for_artist` option determines whether the regex should be suitable for matching artist fields (the default) or title fields. """ - feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + custom_feat_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index e17d7bc1c..b1331e893 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: def split_on_feat( - artist: str, for_artist: bool = True + artist: str, for_artist: bool = True, custom_feat_words: list[str] = [] ) -> tuple[str, str | None]: """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main @@ -35,7 +35,9 @@ def split_on_feat( may be a string or None if none is present. """ # split on the first "feat". - regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE) + regex = re.compile( + plugins.feat_tokens(for_artist, custom_feat_words), re.IGNORECASE + ) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: return parts[0], None @@ -44,18 +46,22 @@ def split_on_feat( return parts -def contains_feat(title: str) -> bool: +def contains_feat(title: str, custom_feat_words: list[str] = []) -> bool: """Determine whether the title contains a "featured" marker.""" return bool( re.search( - plugins.feat_tokens(for_artist=False), + plugins.feat_tokens( + for_artist=False, custom_feat_words=custom_feat_words + ), title, flags=re.IGNORECASE, ) ) -def find_feat_part(artist: str, albumartist: str | None) -> str | None: +def find_feat_part( + artist: str, albumartist: str | None, custom_feat_words: list[str] = [] +) -> str | None: """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ @@ -69,20 +75,24 @@ def find_feat_part(artist: str, albumartist: str | None) -> str | None: # featured artist. if albumartist_split[1] != "": # Extract the featured artist from the right-hand side. - _, feat_part = split_on_feat(albumartist_split[1]) + _, feat_part = split_on_feat( + albumartist_split[1], custom_feat_words=custom_feat_words + ) return feat_part # Otherwise, if there's nothing on the right-hand side, # look for a featuring artist on the left-hand side. else: - lhs, _ = split_on_feat(albumartist_split[0]) + lhs, _ = split_on_feat( + albumartist_split[0], custom_feat_words=custom_feat_words + ) if lhs: return lhs # Fall back to conservative handling of the track artist without relying # on albumartist, which covers compilations using a 'Various Artists' # albumartist and album tracks by a guest artist featuring a third artist. - _, feat_part = split_on_feat(artist, False) + _, feat_part = split_on_feat(artist, False, custom_feat_words) return feat_part @@ -96,6 +106,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "drop": False, "format": "feat. {}", "keep_in_artist": False, + "custom_feat_words": [], } ) @@ -120,10 +131,13 @@ class FtInTitlePlugin(plugins.BeetsPlugin): self.config.set_args(opts) drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) + custom_feat_words = self.config["custom_feat_words"].get(list) write = ui.should_write() for item in lib.items(args): - if self.ft_in_title(item, drop_feat, keep_in_artist_field): + if self.ft_in_title( + item, drop_feat, keep_in_artist_field, custom_feat_words + ): item.store() if write: item.try_write() @@ -135,9 +149,12 @@ class FtInTitlePlugin(plugins.BeetsPlugin): """Import hook for moving featuring artist automatically.""" drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) + custom_feat_words = self.config["custom_feat_words"].get(list) for item in task.imported_items(): - if self.ft_in_title(item, drop_feat, keep_in_artist_field): + if self.ft_in_title( + item, drop_feat, keep_in_artist_field, custom_feat_words + ): item.store() def update_metadata( @@ -146,6 +163,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): feat_part: str, drop_feat: bool, keep_in_artist_field: bool, + custom_feat_words: list[str], ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. @@ -158,17 +176,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "artist: {.artist} (Not changing due to keep_in_artist)", item ) else: - track_artist, _ = split_on_feat(item.artist) + track_artist, _ = split_on_feat( + item.artist, custom_feat_words=custom_feat_words + ) self._log.info("artist: {0.artist} -> {1}", item, track_artist) item.artist = track_artist if item.artist_sort: # Just strip the featured artist from the sort name. - item.artist_sort, _ = split_on_feat(item.artist_sort) + item.artist_sort, _ = split_on_feat( + item.artist_sort, custom_feat_words=custom_feat_words + ) # Only update the title if it does not already contain a featured # artist and if we do not drop featuring information. - if not drop_feat and not contains_feat(item.title): + if not drop_feat and not contains_feat(item.title, custom_feat_words): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) new_title = f"{item.title} {new_format}" @@ -180,6 +202,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item: Item, drop_feat: bool, keep_in_artist_field: bool, + custom_feat_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -196,19 +219,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if albumartist and artist == albumartist: return False - _, featured = split_on_feat(artist) + _, featured = split_on_feat(artist, custom_feat_words=custom_feat_words) if not featured: return False self._log.info("{.filepath}", item) # Attempt to find the featured artist. - feat_part = find_feat_part(artist, albumartist) + feat_part = find_feat_part(artist, albumartist, custom_feat_words) if not feat_part: self._log.info("no featuring artists found") return False # If we have a featuring artist, move it to the title. - self.update_metadata(item, feat_part, drop_feat, keep_in_artist_field) + self.update_metadata( + item, feat_part, drop_feat, keep_in_artist_field, custom_feat_words + ) return True diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 005318b11..466a95e4f 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -38,13 +38,15 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( - env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, Union[str, bool]]] + env: FtInTitlePluginFunctional, + cfg: Optional[Dict[str, Union[str, bool, list[str]]]], ) -> None: cfg = {} if cfg is None else cfg defaults = { "drop": False, "auto": True, "keep_in_artist": False, + "custom_feat_words": [], } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) @@ -170,11 +172,44 @@ def add_item( ("Alice ft Bob", "Song 1"), id="keep-in-artist-drop-from-title", ), + # ---- custom_feat_words variants ---- + pytest.param( + {"format": "featuring {}", "custom_feat_words": ["med"]}, + ("ftintitle",), + ("Alice med Bob", "Song 1", "Alice"), + ("Alice", "Song 1 featuring Bob"), + id="custom-feat-words", + ), + pytest.param( + { + "format": "featuring {}", + "keep_in_artist": True, + "custom_feat_words": ["med"], + }, + ("ftintitle",), + ("Alice med Bob", "Song 1", "Alice"), + ("Alice med Bob", "Song 1 featuring Bob"), + id="custom-feat-words-keep-in-artists", + ), + pytest.param( + { + "format": "featuring {}", + "keep_in_artist": True, + "custom_feat_words": ["med"], + }, + ( + "ftintitle", + "-d", + ), + ("Alice med Bob", "Song 1", "Alice"), + ("Alice med Bob", "Song 1"), + id="custom-feat-words-keep-in-artists-drop-from-title", + ), ], ) def test_ftintitle_functional( env: FtInTitlePluginFunctional, - cfg: Optional[Dict[str, Union[str, bool]]], + cfg: Optional[Dict[str, Union[str, bool, list[str]]]], cmd_args: Tuple[str, ...], given: Tuple[str, str, Optional[str]], expected: Tuple[str, str], @@ -256,3 +291,35 @@ def test_split_on_feat( ) def test_contains_feat(given: str, expected: bool) -> None: assert ftintitle.contains_feat(given) is expected + + +@pytest.mark.parametrize( + "given,custom_feat_words,expected", + [ + ("Alice ft. Bob", [], True), + ("Alice feat. Bob", [], True), + ("Alice feat Bob", [], True), + ("Alice featuring Bob", [], True), + ("Alice (ft. Bob)", [], True), + ("Alice (feat. Bob)", [], True), + ("Alice [ft. Bob]", [], True), + ("Alice [feat. Bob]", [], True), + ("Alice defeat Bob", [], False), + ("Aliceft.Bob", [], False), + ("Alice (defeat Bob)", [], False), + ("Live and Let Go", [], False), + ("Come With Me", [], False), + ("Alice x Bob", ["x"], True), + ("Alice x Bob", ["X"], True), + ("Alice och Xavier", ["x"], False), + ("Alice ft. Xavier", ["x"], True), + ("Alice med Carol", ["med"], True), + ("Alice med Carol", [], False), + ], +) +def test_custom_feat_words( + given: str, custom_feat_words: Optional[list[str]], expected: bool +) -> None: + if custom_feat_words is None: + custom_feat_words = [] + assert ftintitle.contains_feat(given, custom_feat_words) is expected From 992938f0ae70631f0124642a1d07c63b7ba72973 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 20:58:38 +0200 Subject: [PATCH 225/301] Add documentation --- docs/plugins/ftintitle.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 90b89ae89..733c50510 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,6 +28,8 @@ file. The available options are: - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. +- **custom_feat_word**: Add custom words to the feat list; any words you add will + also be treated as "feat" tokens. Default: ``[]``. Running Manually ---------------- From e90738a6e27d53e13964d4c7de191d56dd1e80f5 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 21:09:17 +0200 Subject: [PATCH 226/301] Added changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f32093fe..f5c96d598 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,7 @@ Unreleased ---------- New features: +- Added argument for custom feat. words in ftintitle. Bug fixes: From 51c971f089a0f16debccf10122869c5a278a0ed2 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 21:38:13 +0200 Subject: [PATCH 227/301] Fix sourcery-ai comments --- beets/plugins.py | 6 ++++-- beetsplug/ftintitle.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 397c33822..65c181388 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -633,14 +633,16 @@ def send(event: EventType, **arguments: Any) -> list[Any]: def feat_tokens( - for_artist: bool = True, custom_feat_words: list[str] = [] + for_artist: bool = True, custom_feat_words: list[str] | None = None ) -> str: """Return a regular expression that matches phrases like "featuring" that separate a main artist or a song title from secondary artists. The `for_artist` option determines whether the regex should be suitable for matching artist fields (the default) or title fields. """ - feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + custom_feat_words + feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + if isinstance(custom_feat_words, list): + feat_words += custom_feat_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index b1331e893..6837732b2 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -27,7 +27,9 @@ if TYPE_CHECKING: def split_on_feat( - artist: str, for_artist: bool = True, custom_feat_words: list[str] = [] + artist: str, + for_artist: bool = True, + custom_feat_words: list[str] | None = None, ) -> tuple[str, str | None]: """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main @@ -46,7 +48,9 @@ def split_on_feat( return parts -def contains_feat(title: str, custom_feat_words: list[str] = []) -> bool: +def contains_feat( + title: str, custom_feat_words: list[str] | None = None +) -> bool: """Determine whether the title contains a "featured" marker.""" return bool( re.search( @@ -60,7 +64,9 @@ def contains_feat(title: str, custom_feat_words: list[str] = []) -> bool: def find_feat_part( - artist: str, albumartist: str | None, custom_feat_words: list[str] = [] + artist: str, + albumartist: str | None, + custom_feat_words: list[str] | None = None, ) -> str | None: """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. From af09e58fb07ca6363b849f610f93915ebeea2801 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 21:40:22 +0200 Subject: [PATCH 228/301] Add new line after New features: --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index f5c96d598..d696f98ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,7 @@ Unreleased ---------- New features: + - Added argument for custom feat. words in ftintitle. Bug fixes: From b95a17d8d35562cca9d3353d23202e0cbdba1314 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 22:40:27 +0200 Subject: [PATCH 229/301] remove feat from custom_feat_words --- beets/plugins.py | 6 ++--- beetsplug/ftintitle.py | 46 ++++++++++++++++------------------ test/plugins/test_ftintitle.py | 22 ++++++++-------- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 65c181388..b96a3703c 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -633,7 +633,7 @@ def send(event: EventType, **arguments: Any) -> list[Any]: def feat_tokens( - for_artist: bool = True, custom_feat_words: list[str] | None = None + for_artist: bool = True, custom_words: list[str] | None = None ) -> str: """Return a regular expression that matches phrases like "featuring" that separate a main artist or a song title from secondary artists. @@ -641,8 +641,8 @@ def feat_tokens( suitable for matching artist fields (the default) or title fields. """ feat_words = ["ft", "featuring", "feat", "feat.", "ft."] - if isinstance(custom_feat_words, list): - feat_words += custom_feat_words + if isinstance(custom_words, list): + feat_words += custom_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 6837732b2..ef9b763cf 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: def split_on_feat( artist: str, for_artist: bool = True, - custom_feat_words: list[str] | None = None, + custom_words: list[str] | None = None, ) -> tuple[str, str | None]: """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main @@ -38,7 +38,7 @@ def split_on_feat( """ # split on the first "feat". regex = re.compile( - plugins.feat_tokens(for_artist, custom_feat_words), re.IGNORECASE + plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE ) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: @@ -48,15 +48,11 @@ def split_on_feat( return parts -def contains_feat( - title: str, custom_feat_words: list[str] | None = None -) -> bool: +def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: """Determine whether the title contains a "featured" marker.""" return bool( re.search( - plugins.feat_tokens( - for_artist=False, custom_feat_words=custom_feat_words - ), + plugins.feat_tokens(for_artist=False, custom_words=custom_words), title, flags=re.IGNORECASE, ) @@ -66,7 +62,7 @@ def contains_feat( def find_feat_part( artist: str, albumartist: str | None, - custom_feat_words: list[str] | None = None, + custom_words: list[str] | None = None, ) -> str | None: """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. @@ -82,7 +78,7 @@ def find_feat_part( if albumartist_split[1] != "": # Extract the featured artist from the right-hand side. _, feat_part = split_on_feat( - albumartist_split[1], custom_feat_words=custom_feat_words + albumartist_split[1], custom_words=custom_words ) return feat_part @@ -90,7 +86,7 @@ def find_feat_part( # look for a featuring artist on the left-hand side. else: lhs, _ = split_on_feat( - albumartist_split[0], custom_feat_words=custom_feat_words + albumartist_split[0], custom_words=custom_words ) if lhs: return lhs @@ -98,7 +94,7 @@ def find_feat_part( # Fall back to conservative handling of the track artist without relying # on albumartist, which covers compilations using a 'Various Artists' # albumartist and album tracks by a guest artist featuring a third artist. - _, feat_part = split_on_feat(artist, False, custom_feat_words) + _, feat_part = split_on_feat(artist, False, custom_words) return feat_part @@ -112,7 +108,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "drop": False, "format": "feat. {}", "keep_in_artist": False, - "custom_feat_words": [], + "custom_words": [], } ) @@ -137,12 +133,12 @@ class FtInTitlePlugin(plugins.BeetsPlugin): self.config.set_args(opts) drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) - custom_feat_words = self.config["custom_feat_words"].get(list) + custom_words = self.config["custom_words"].get(list) write = ui.should_write() for item in lib.items(args): if self.ft_in_title( - item, drop_feat, keep_in_artist_field, custom_feat_words + item, drop_feat, keep_in_artist_field, custom_words ): item.store() if write: @@ -155,11 +151,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin): """Import hook for moving featuring artist automatically.""" drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) - custom_feat_words = self.config["custom_feat_words"].get(list) + custom_words = self.config["custom_words"].get(list) for item in task.imported_items(): if self.ft_in_title( - item, drop_feat, keep_in_artist_field, custom_feat_words + item, drop_feat, keep_in_artist_field, custom_words ): item.store() @@ -169,7 +165,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): feat_part: str, drop_feat: bool, keep_in_artist_field: bool, - custom_feat_words: list[str], + custom_words: list[str], ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. @@ -183,7 +179,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): ) else: track_artist, _ = split_on_feat( - item.artist, custom_feat_words=custom_feat_words + item.artist, custom_words=custom_words ) self._log.info("artist: {0.artist} -> {1}", item, track_artist) item.artist = track_artist @@ -191,12 +187,12 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if item.artist_sort: # Just strip the featured artist from the sort name. item.artist_sort, _ = split_on_feat( - item.artist_sort, custom_feat_words=custom_feat_words + item.artist_sort, custom_words=custom_words ) # Only update the title if it does not already contain a featured # artist and if we do not drop featuring information. - if not drop_feat and not contains_feat(item.title, custom_feat_words): + if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) new_title = f"{item.title} {new_format}" @@ -208,7 +204,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item: Item, drop_feat: bool, keep_in_artist_field: bool, - custom_feat_words: list[str], + custom_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -225,14 +221,14 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if albumartist and artist == albumartist: return False - _, featured = split_on_feat(artist, custom_feat_words=custom_feat_words) + _, featured = split_on_feat(artist, custom_words=custom_words) if not featured: return False self._log.info("{.filepath}", item) # Attempt to find the featured artist. - feat_part = find_feat_part(artist, albumartist, custom_feat_words) + feat_part = find_feat_part(artist, albumartist, custom_words) if not feat_part: self._log.info("no featuring artists found") @@ -240,6 +236,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, feat_part, drop_feat, keep_in_artist_field, custom_feat_words + item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 466a95e4f..30b414948 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -46,7 +46,7 @@ def set_config( "drop": False, "auto": True, "keep_in_artist": False, - "custom_feat_words": [], + "custom_words": [], } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) @@ -172,9 +172,9 @@ def add_item( ("Alice ft Bob", "Song 1"), id="keep-in-artist-drop-from-title", ), - # ---- custom_feat_words variants ---- + # ---- custom_words variants ---- pytest.param( - {"format": "featuring {}", "custom_feat_words": ["med"]}, + {"format": "featuring {}", "custom_words": ["med"]}, ("ftintitle",), ("Alice med Bob", "Song 1", "Alice"), ("Alice", "Song 1 featuring Bob"), @@ -184,7 +184,7 @@ def add_item( { "format": "featuring {}", "keep_in_artist": True, - "custom_feat_words": ["med"], + "custom_words": ["med"], }, ("ftintitle",), ("Alice med Bob", "Song 1", "Alice"), @@ -195,7 +195,7 @@ def add_item( { "format": "featuring {}", "keep_in_artist": True, - "custom_feat_words": ["med"], + "custom_words": ["med"], }, ( "ftintitle", @@ -294,7 +294,7 @@ def test_contains_feat(given: str, expected: bool) -> None: @pytest.mark.parametrize( - "given,custom_feat_words,expected", + "given,custom_words,expected", [ ("Alice ft. Bob", [], True), ("Alice feat. Bob", [], True), @@ -317,9 +317,9 @@ def test_contains_feat(given: str, expected: bool) -> None: ("Alice med Carol", [], False), ], ) -def test_custom_feat_words( - given: str, custom_feat_words: Optional[list[str]], expected: bool +def test_custom_words( + given: str, custom_words: Optional[list[str]], expected: bool ) -> None: - if custom_feat_words is None: - custom_feat_words = [] - assert ftintitle.contains_feat(given, custom_feat_words) is expected + if custom_words is None: + custom_words = [] + assert ftintitle.contains_feat(given, custom_words) is expected From 717809c52c198e426335ef9be2f4ea082e5c1662 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 22:40:44 +0200 Subject: [PATCH 230/301] Better custom_words documentation --- docs/plugins/ftintitle.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 733c50510..6528b61cd 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,8 +28,8 @@ file. The available options are: - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. -- **custom_feat_word**: Add custom words to the feat list; any words you add will - also be treated as "feat" tokens. Default: ``[]``. +- **custom_words**: List of additional words that will be treated as a marker for + artist features. Default: ``[]``. Running Manually ---------------- From 0f0e38b0bfea5f31fd3f3e4b2463d39a0555d096 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Sun, 12 Oct 2025 22:40:55 +0200 Subject: [PATCH 231/301] Add link in changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d696f98ee..e6a81ab14 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Unreleased New features: -- Added argument for custom feat. words in ftintitle. +- :doc:`plugins/fitintitle`: Added argument for custom feat. words in ftintitle. Bug fixes: From 33b350a612aa80e1b2ab01e4f76cee6b76af66b1 Mon Sep 17 00:00:00 2001 From: Michael Krieger <phyre@phyre.me> Date: Mon, 15 Sep 2025 18:50:47 -0400 Subject: [PATCH 232/301] Adds a zero_disc_if_single_disc to the zero plugin Adds a zero_disc_number_if_single_disc boolean to the zero plugin for writing to files. Adds the logic that, if disctotal is set and there is only one disc in disctotal, that the disc is not set. This keeps tags cleaner, only using disc on multi-disc albums. The disctotal is not touched, particularly as this is not usually displayed in most clients. The field is removed only for writing the tags, but the disc number is maintained in the database to avoid breaking anything that may depend on a disc number or avoid possible loops or failed logic. --- beetsplug/zero.py | 7 ++++++- docs/changelog.rst | 3 +++ docs/plugins/zero.rst | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index bce3b1a72..e65dd8286 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin): "fields": [], "keep_fields": [], "update_database": False, + "zero_disc_if_single_disc": False, } ) @@ -123,8 +124,12 @@ class ZeroPlugin(BeetsPlugin): """ fields_set = False + if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool) and item.disctotal == 1: + self._log.debug("disc: {.disc} -> None", item) + tags["disc"] = None + if not self.fields_to_progs: - self._log.warning("no fields, nothing to do") + self._log.warning("no fields list to remove") return False for field, progs in self.fields_to_progs.items(): diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f32093fe..f5109a9b4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,9 @@ Unreleased ---------- New features: +* :doc:`plugins/zero`: Add new configuration option, + ``zero_disc_if_single_disc``, to allow zeroing the disc number on + write for single-disc albums. Defaults to False. Bug fixes: diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 6ed9427d9..88903a389 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -31,6 +31,8 @@ to nullify and the conditions for nullifying them: ``keep_fields``---not both! - To conditionally filter a field, use ``field: [regexp, regexp]`` to specify regular expressions. +- Set ``zero_disc_if_single_disc`` to ``True`` to zero the disc number field + only if the album contains a disctotal count and is a single disc. - By default this plugin only affects files' tags; the beets database is left unchanged. To update the tags in the database, set the ``update_database`` option to true. From 5fc15bcfa4814418c2256e0db1b062b571ac9268 Mon Sep 17 00:00:00 2001 From: Michael Krieger <phyre@phyre.me> Date: Mon, 15 Sep 2025 19:00:06 -0400 Subject: [PATCH 233/301] Misc formatting changes --- beetsplug/zero.py | 7 ++++--- docs/changelog.rst | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index e65dd8286..c8ef9f855 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -124,9 +124,10 @@ class ZeroPlugin(BeetsPlugin): """ fields_set = False - if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool) and item.disctotal == 1: - self._log.debug("disc: {.disc} -> None", item) - tags["disc"] = None + if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool): + if item.disctotal == 1: + self._log.debug("disc: {.disc} -> None", item) + tags["disc"] = None if not self.fields_to_progs: self._log.warning("no fields list to remove") diff --git a/docs/changelog.rst b/docs/changelog.rst index f5109a9b4..ac3af6257 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,9 +8,10 @@ Unreleased ---------- New features: -* :doc:`plugins/zero`: Add new configuration option, - ``zero_disc_if_single_disc``, to allow zeroing the disc number on - write for single-disc albums. Defaults to False. + +- :doc:`plugins/zero`: Add new configuration option, + ``zero_disc_if_single_disc``, to allow zeroing the disc number on write for + single-disc albums. Defaults to False. Bug fixes: From b1c87cd98c2f7287af40fee8560234b4d0adec4e Mon Sep 17 00:00:00 2001 From: Michael Krieger <phyre@phyre.me> Date: Tue, 16 Sep 2025 10:04:24 -0400 Subject: [PATCH 234/301] Change parameter name, add return, add tests Change the parameter name to omit_single_disc (vs previously zero_disc_if_single_disc) Add return of 'fields_set' so that, if triggered by the command line `beets zero`, it will still effect the item.write. Added tests. --- beetsplug/zero.py | 7 +++--- docs/changelog.rst | 6 ++--- docs/plugins/zero.rst | 4 ++-- test/plugins/test_zero.py | 48 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index c8ef9f855..c957a27d3 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -41,7 +41,7 @@ class ZeroPlugin(BeetsPlugin): "fields": [], "keep_fields": [], "update_database": False, - "zero_disc_if_single_disc": False, + "omit_single_disc": False, } ) @@ -124,14 +124,15 @@ class ZeroPlugin(BeetsPlugin): """ fields_set = False - if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool): + if "disc" in tags and self.config["omit_single_disc"].get(bool): if item.disctotal == 1: + fields_set = True self._log.debug("disc: {.disc} -> None", item) tags["disc"] = None if not self.fields_to_progs: self._log.warning("no fields list to remove") - return False + return fields_set for field, progs in self.fields_to_progs.items(): if field in tags: diff --git a/docs/changelog.rst b/docs/changelog.rst index ac3af6257..773c6cc67 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,9 +9,9 @@ Unreleased New features: -- :doc:`plugins/zero`: Add new configuration option, - ``zero_disc_if_single_disc``, to allow zeroing the disc number on write for - single-disc albums. Defaults to False. +- :doc:`plugins/zero`: Add new configuration option, ``omit_single_disc``, to + allow zeroing the disc number on write for single-disc albums. Defaults to + False. Bug fixes: diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 88903a389..50b51797e 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -31,8 +31,8 @@ to nullify and the conditions for nullifying them: ``keep_fields``---not both! - To conditionally filter a field, use ``field: [regexp, regexp]`` to specify regular expressions. -- Set ``zero_disc_if_single_disc`` to ``True`` to zero the disc number field - only if the album contains a disctotal count and is a single disc. +- Set ``omit_single_disc`` to ``True`` to zero the disc number field only if the + album contains a disctotal count and is a single disc. - By default this plugin only affects files' tags; the beets database is left unchanged. To update the tags in the database, set the ``update_database`` option to true. diff --git a/test/plugins/test_zero.py b/test/plugins/test_zero.py index 51913c8e0..b08bf0dca 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -249,6 +249,54 @@ class ZeroPluginTest(PluginTestCase): assert "id" not in z.fields_to_progs + def test_omit_single_disc_with_tags_single(self): + item = self.add_item_fixture( + disctotal=1, disc=1, comments="test comment" + ) + item.write() + with self.configure_plugin( + {"omit_single_disc": True, "fields": ["comments"]} + ): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.comments is None + assert mf.disc == 0 + + def test_omit_single_disc_with_tags_multi(self): + item = self.add_item_fixture( + disctotal=4, disc=1, comments="test comment" + ) + item.write() + with self.configure_plugin( + {"omit_single_disc": True, "fields": ["comments"]} + ): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.comments is None + assert mf.disc == 1 + + def test_omit_single_disc_only_change_single(self): + item = self.add_item_fixture(disctotal=1, disc=1) + item.write() + + with self.configure_plugin({"omit_single_disc": True}): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.disc == 0 + + def test_omit_single_disc_only_change_multi(self): + item = self.add_item_fixture(disctotal=4, disc=1) + item.write() + + with self.configure_plugin({"omit_single_disc": True}): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.disc == 1 + def test_empty_query_n_response_no_changes(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" From dc133087847b676ed987a7ea8191ffe3b566af17 Mon Sep 17 00:00:00 2001 From: Michael Krieger <phyre@phyre.me> Date: Tue, 16 Sep 2025 11:55:34 -0400 Subject: [PATCH 235/301] Remove tests. Update docs. Remove unnecessary return Remove tests. Update docs. Remove unnecessary return. --- beetsplug/zero.py | 1 - docs/plugins/zero.rst | 5 ++-- test/plugins/test_zero.py | 48 --------------------------------------- 3 files changed, 3 insertions(+), 51 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index c957a27d3..ab1bfa5ca 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -132,7 +132,6 @@ class ZeroPlugin(BeetsPlugin): if not self.fields_to_progs: self._log.warning("no fields list to remove") - return fields_set for field, progs in self.fields_to_progs.items(): if field in tags: diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 50b51797e..bf134e664 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -31,8 +31,9 @@ to nullify and the conditions for nullifying them: ``keep_fields``---not both! - To conditionally filter a field, use ``field: [regexp, regexp]`` to specify regular expressions. -- Set ``omit_single_disc`` to ``True`` to zero the disc number field only if the - album contains a disctotal count and is a single disc. +- Set ``omit_single_disc`` to ``True`` to omit writing the ``disc`` number for + albums with only a single disc (``disctotal == 1``). By default, beets will + number the disc even if the album contains only one disc in total. - By default this plugin only affects files' tags; the beets database is left unchanged. To update the tags in the database, set the ``update_database`` option to true. diff --git a/test/plugins/test_zero.py b/test/plugins/test_zero.py index b08bf0dca..51913c8e0 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -249,54 +249,6 @@ class ZeroPluginTest(PluginTestCase): assert "id" not in z.fields_to_progs - def test_omit_single_disc_with_tags_single(self): - item = self.add_item_fixture( - disctotal=1, disc=1, comments="test comment" - ) - item.write() - with self.configure_plugin( - {"omit_single_disc": True, "fields": ["comments"]} - ): - item.write() - - mf = MediaFile(syspath(item.path)) - assert mf.comments is None - assert mf.disc == 0 - - def test_omit_single_disc_with_tags_multi(self): - item = self.add_item_fixture( - disctotal=4, disc=1, comments="test comment" - ) - item.write() - with self.configure_plugin( - {"omit_single_disc": True, "fields": ["comments"]} - ): - item.write() - - mf = MediaFile(syspath(item.path)) - assert mf.comments is None - assert mf.disc == 1 - - def test_omit_single_disc_only_change_single(self): - item = self.add_item_fixture(disctotal=1, disc=1) - item.write() - - with self.configure_plugin({"omit_single_disc": True}): - item.write() - - mf = MediaFile(syspath(item.path)) - assert mf.disc == 0 - - def test_omit_single_disc_only_change_multi(self): - item = self.add_item_fixture(disctotal=4, disc=1) - item.write() - - with self.configure_plugin({"omit_single_disc": True}): - item.write() - - mf = MediaFile(syspath(item.path)) - assert mf.disc == 1 - def test_empty_query_n_response_no_changes(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" From df8cd23ae7979a82fdcf8d0b31a2dec8676dc43b Mon Sep 17 00:00:00 2001 From: Michael Krieger <phyre@phyre.me> Date: Tue, 16 Sep 2025 11:57:50 -0400 Subject: [PATCH 236/301] Add back tests as they were. Add back tests as they were. --- test/plugins/test_zero.py | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/plugins/test_zero.py b/test/plugins/test_zero.py index 51913c8e0..b08bf0dca 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -249,6 +249,54 @@ class ZeroPluginTest(PluginTestCase): assert "id" not in z.fields_to_progs + def test_omit_single_disc_with_tags_single(self): + item = self.add_item_fixture( + disctotal=1, disc=1, comments="test comment" + ) + item.write() + with self.configure_plugin( + {"omit_single_disc": True, "fields": ["comments"]} + ): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.comments is None + assert mf.disc == 0 + + def test_omit_single_disc_with_tags_multi(self): + item = self.add_item_fixture( + disctotal=4, disc=1, comments="test comment" + ) + item.write() + with self.configure_plugin( + {"omit_single_disc": True, "fields": ["comments"]} + ): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.comments is None + assert mf.disc == 1 + + def test_omit_single_disc_only_change_single(self): + item = self.add_item_fixture(disctotal=1, disc=1) + item.write() + + with self.configure_plugin({"omit_single_disc": True}): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.disc == 0 + + def test_omit_single_disc_only_change_multi(self): + item = self.add_item_fixture(disctotal=4, disc=1) + item.write() + + with self.configure_plugin({"omit_single_disc": True}): + item.write() + + mf = MediaFile(syspath(item.path)) + assert mf.disc == 1 + def test_empty_query_n_response_no_changes(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" From d01f960e4f565325d13446f16a245be2d6609a53 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 13 Oct 2025 17:10:38 +0200 Subject: [PATCH 237/301] Fixed an issue where the poetry-dynamic-versioning-plugin was not used in release artifacts. Also adds a test_release workflow which allows to create the release distribution. --- .github/workflows/test_release.yaml | 43 +++++++++++++++++++++++++++++ extra/release.py | 4 --- pyproject.toml | 5 ++-- 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test_release.yaml diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml new file mode 100644 index 000000000..fd1c79c03 --- /dev/null +++ b/.github/workflows/test_release.yaml @@ -0,0 +1,43 @@ +name: Make a Beets Release artifacts + +on: + workflow_dispatch: + inputs: + version: + description: 'Version of the new release, just as a number with no prepended "v"' + required: true + +env: + PYTHON_VERSION: 3.9 + NEW_VERSION: ${{ inputs.version }} + NEW_TAG: v${{ inputs.version }} + +jobs: + increment-version: + name: Bump version, commit and create tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Python tools + uses: BrandonLWhite/pipx-install-action@v1.0.3 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: poetry + + - name: Install dependencies + run: poetry install --with=release --extras=docs + + - name: Bump project version + run: poe bump "${{ env.NEW_VERSION }}" + + - name: Build a binary wheel and a source tarball + run: poe build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions-test + path: dist/ diff --git a/extra/release.py b/extra/release.py index b47de8966..650c2c40b 100755 --- a/extra/release.py +++ b/extra/release.py @@ -170,10 +170,6 @@ Other changes: UpdateVersionCallable = Callable[[str, Version], str] FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ - ( - PYPROJECT, - lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), - ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] diff --git a/pyproject.toml b/pyproject.toml index 3a355418c..b21d0d17a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.5.0" +version = "0.0.0" description = "music tagger and library organizer" authors = ["Adrian Sampson <adrian@radbox.org>"] maintainers = ["Serene-Arc"] @@ -36,6 +36,7 @@ include = [ # extra files to include in the sdist ] exclude = ["docs/_build", "docs/modd.conf", "docs/**/*.css"] + [tool.poetry.urls] Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" @@ -173,7 +174,7 @@ build-backend = "poetry_dynamic_versioning.backend" [tool.pipx-install] poethepoet = ">=0.26" -poetry = ">=1.8,<2" +poetry = { version = ">=1.8,<2", inject = {"poetry-dynamic-versioning[plugin]" = ">=1.9.1" }} [tool.poe.tasks.build] help = "Build the package" From 4ea37b4579f245f5d1d591615f7a91656442764c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Mon, 13 Oct 2025 17:18:00 +0200 Subject: [PATCH 238/301] Added changelog entry fixed action to use sha. --- .github/workflows/test_release.yaml | 4 ++-- docs/changelog.rst | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml index fd1c79c03..af960021d 100644 --- a/.github/workflows/test_release.yaml +++ b/.github/workflows/test_release.yaml @@ -1,4 +1,4 @@ -name: Make a Beets Release artifacts +name: Create a beets release artifact (testing only) on: workflow_dispatch: @@ -21,7 +21,7 @@ jobs: with: fetch-depth: 0 - name: Install Python tools - uses: BrandonLWhite/pipx-install-action@v1.0.3 + uses: BrandonLWhite/pipx-install-action@1b697df89b675eb31d19417e53b4c066d21650d7 # v1.1.0 - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/docs/changelog.rst b/docs/changelog.rst index 773c6cc67..5f4afe58d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,9 @@ Bug fixes: For packagers: +- Fixed dynamic versioning install not disabled for source distribution builds. + :bug:`6089` + Other changes: - Removed outdated mailing list contact information from the documentation From ac31bee4ca6fd8e3f1da0981772a6038e6b1130d Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 10:21:47 +0200 Subject: [PATCH 239/301] Reverted placeholder. --- extra/release.py | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/extra/release.py b/extra/release.py index 650c2c40b..b47de8966 100755 --- a/extra/release.py +++ b/extra/release.py @@ -170,6 +170,10 @@ Other changes: UpdateVersionCallable = Callable[[str, Version], str] FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ + ( + PYPROJECT, + lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), + ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] diff --git a/pyproject.toml b/pyproject.toml index b21d0d17a..b6c846a54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "0.0.0" +version = "2.5.0" description = "music tagger and library organizer" authors = ["Adrian Sampson <adrian@radbox.org>"] maintainers = ["Serene-Arc"] From 7f15a4608137ccb5ad7e0fcfe25210454736def8 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 10:34:08 +0200 Subject: [PATCH 240/301] Added perms to flow. --- .github/workflows/test_release.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml index af960021d..9efde5e64 100644 --- a/.github/workflows/test_release.yaml +++ b/.github/workflows/test_release.yaml @@ -1,4 +1,6 @@ name: Create a beets release artifact (testing only) +permissions: + contents: write on: workflow_dispatch: From febb1d2e08c819c03fb914e713fb7ff8155e1eb2 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 11:43:27 +0200 Subject: [PATCH 241/301] Removed test release file. --- .github/workflows/test_release.yaml | 45 ----------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/test_release.yaml diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml deleted file mode 100644 index 9efde5e64..000000000 --- a/.github/workflows/test_release.yaml +++ /dev/null @@ -1,45 +0,0 @@ -name: Create a beets release artifact (testing only) -permissions: - contents: write - -on: - workflow_dispatch: - inputs: - version: - description: 'Version of the new release, just as a number with no prepended "v"' - required: true - -env: - PYTHON_VERSION: 3.9 - NEW_VERSION: ${{ inputs.version }} - NEW_TAG: v${{ inputs.version }} - -jobs: - increment-version: - name: Bump version, commit and create tag - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Install Python tools - uses: BrandonLWhite/pipx-install-action@1b697df89b675eb31d19417e53b4c066d21650d7 # v1.1.0 - - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - cache: poetry - - - name: Install dependencies - run: poetry install --with=release --extras=docs - - - name: Bump project version - run: poe bump "${{ env.NEW_VERSION }}" - - - name: Build a binary wheel and a source tarball - run: poe build - - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions-test - path: dist/ From 31488e79dae45edd67467512d7b1a4935000c0ca Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 12:47:27 +0200 Subject: [PATCH 242/301] Removed additional linebreaks. --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6c846a54..dd67fa65c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ include = [ # extra files to include in the sdist ] exclude = ["docs/_build", "docs/modd.conf", "docs/**/*.css"] - [tool.poetry.urls] Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" @@ -159,7 +158,6 @@ web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" - [tool.poetry-dynamic-versioning] enable = true vcs = "git" From 320ebf6a205041dd5062082c631c01026342cc17 Mon Sep 17 00:00:00 2001 From: Jacob Danell <jacob@emberlight.se> Date: Tue, 14 Oct 2025 14:07:45 +0200 Subject: [PATCH 243/301] Fix misspelling --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6a81ab14..d4d90ebe5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Unreleased New features: -- :doc:`plugins/fitintitle`: Added argument for custom feat. words in ftintitle. +- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. Bug fixes: From 83858cd7ca0ed7f0ed3f464a0262ad48d76377da Mon Sep 17 00:00:00 2001 From: Jacob Danell <jacob@emberlight.se> Date: Tue, 14 Oct 2025 14:08:30 +0200 Subject: [PATCH 244/301] Fixed too long text line --- docs/plugins/ftintitle.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 6528b61cd..1a95d03a8 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,8 +28,8 @@ file. The available options are: - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. -- **custom_words**: List of additional words that will be treated as a marker for - artist features. Default: ``[]``. +- **custom_words**: List of additional words that will be treated as a marker + for artist features. Default: ``[]``. Running Manually ---------------- From 75a945d3d3e83e6c0d4276c510f266582b7d7ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 15:14:55 +0100 Subject: [PATCH 245/301] Initialise the last plugin class found in the plugin namespace --- beets/plugins.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/beets/plugins.py b/beets/plugins.py index 7fa0e660a..b866081ff 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -422,6 +422,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None: Attempts to import the plugin module, locate the appropriate plugin class within it, and return an instance. Handles import failures gracefully and logs warnings for missing plugins or loading errors. + + Note we load the *last* plugin class found in the plugin namespace. This + allows plugins to define helper classes that inherit from BeetsPlugin + without those being loaded as the main plugin class. + + Returns None if the plugin could not be loaded for any reason. """ try: try: @@ -429,7 +435,7 @@ def _get_plugin(name: str) -> BeetsPlugin | None: except Exception as exc: raise PluginImportError(name) from exc - for obj in namespace.__dict__.values(): + for obj in reversed(namespace.__dict__.values()): if ( inspect.isclass(obj) and not isinstance( From 7fa9a30b896d6fc65d574a80667e61b41d8e4385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 16:17:29 +0100 Subject: [PATCH 246/301] Add note regarding the last plugin class --- docs/changelog.rst | 8 ++++---- docs/conf.py | 1 + docs/dev/plugins/autotagger.rst | 6 +++--- docs/dev/plugins/index.rst | 10 ++++++++-- docs/reference/config.rst | 8 ++++---- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5f4afe58d..bdf9babba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,7 +66,7 @@ Bug fixes: - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` - :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by - an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033` + an import of another |BeetsPlugin| class. :bug:`6033` - :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor regexps, allow for more cases, add some logging), add tests. - Metadata source plugins: Fixed data source penalty calculation that was @@ -188,8 +188,8 @@ For plugin developers: art sources might need to be adapted. - We split the responsibilities of plugins into two base classes - 1. :class:`beets.plugins.BeetsPlugin` is the base class for all plugins, any - plugin needs to inherit from this class. + 1. |BeetsPlugin| is the base class for all plugins, any plugin needs to + inherit from this class. 2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in the beets repo are opted into this class where applicable. If you are @@ -5072,7 +5072,7 @@ BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin list of plugin names) and ``pluginpath`` (a colon-separated list of directories to search beyond ``sys.path``). Plugins are just Python modules under the ``beetsplug`` namespace package containing subclasses of - ``beets.plugins.BeetsPlugin``. See `the beetsplug directory`_ for examples or + |BeetsPlugin|. See `the beetsplug directory`_ for examples or :doc:`/plugins/index` for instructions. - As a consequence of adding album art, the database was significantly refactored to keep track of some information at an album (rather than item) diff --git a/docs/conf.py b/docs/conf.py index 057141d22..a027b3005 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,6 +82,7 @@ man_pages = [ rst_epilog = """ .. |Album| replace:: :class:`~beets.library.models.Album` .. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo` +.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin` .. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession` .. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask` .. |Item| replace:: :class:`~beets.library.models.Item` diff --git a/docs/dev/plugins/autotagger.rst b/docs/dev/plugins/autotagger.rst index 1cae5295e..8b6df6fb5 100644 --- a/docs/dev/plugins/autotagger.rst +++ b/docs/dev/plugins/autotagger.rst @@ -95,9 +95,9 @@ starting points include: Migration guidance ------------------ -Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should -be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed -in **beets v3.0.0**. +Older metadata plugins that extend |BeetsPlugin| should be migrated to +:py:class:`MetadataSourcePlugin`. Legacy support will be removed in **beets +v3.0.0**. .. seealso:: diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index d258e7df6..a8feb32d9 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -40,8 +40,8 @@ or your plugin subpackage anymore. The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to -extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For -instance, a minimal plugin without any functionality would look like this: +extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal +plugin without any functionality would look like this: .. code-block:: python @@ -52,6 +52,12 @@ instance, a minimal plugin without any functionality would look like this: class MyAwesomePlugin(BeetsPlugin): pass +.. attention:: + + If your plugin is composed of intermediate |BeetsPlugin| subclasses, make + sure that your plugin is defined *last* in the namespace. We only load the + last subclass of |BeetsPlugin| we find in your plugin namespace. + To use your new plugin, you need to package [3]_ your plugin and install it into your ``beets`` (virtual) environment. To enable your plugin, add it it to the beets configuration diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 30582d12c..eae9deb21 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -77,10 +77,10 @@ pluginpath ~~~~~~~~~~ Directories to search for plugins. Each Python file or directory in a plugin -path represents a plugin and should define a subclass of :class:`BeetsPlugin`. A -plugin can then be loaded by adding the filename to the ``plugins`` -configuration. The plugin path can either be a single string or a list of -strings---so, if you have multiple paths, format them as a YAML list like so: +path represents a plugin and should define a subclass of |BeetsPlugin|. A plugin +can then be loaded by adding the plugin name to the ``plugins`` configuration. +The plugin path can either be a single string or a list of strings---so, if you +have multiple paths, format them as a YAML list like so: :: From 13f40de5bb1b7ac775d23ca3144b06c8949cced9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 16:21:33 +0100 Subject: [PATCH 247/301] Make _verify_config method private to remove it from the docs --- beets/plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index b866081ff..a8e803efd 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -228,9 +228,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): # In order to verify the config we need to make sure the plugin is fully # configured (plugins usually add the default configuration *after* # calling super().__init__()). - self.register_listener("pluginload", self.verify_config) + self.register_listener("pluginload", self._verify_config) - def verify_config(self, *_, **__) -> None: + def _verify_config(self, *_, **__) -> None: """Verify plugin configuration. If deprecated 'source_weight' option is explicitly set by the user, they From fbc12a358c8dff4c449f6a59861c20208fd868fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 16:35:53 +0100 Subject: [PATCH 248/301] Add changelog note --- docs/changelog.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bdf9babba..6d08d6bdb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,9 @@ New features: Bug fixes: +- |BeetsPlugin|: load the last plugin class defined in the plugin namespace. + :bug:`6093` + For packagers: - Fixed dynamic versioning install not disabled for source distribution builds. @@ -23,7 +26,7 @@ For packagers: Other changes: - Removed outdated mailing list contact information from the documentation - (:bug:`5462`). + :bug:`5462`. - :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed sections and dropdown menus. Installation instructions have been streamlined, and a new subpage now provides additional setup details. From f33c030ebb9a20f4e94f4d6a1dbd9c1eb205baf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 16:53:57 +0100 Subject: [PATCH 249/301] Convert replacements and Include URLs for :class: refs in release notes --- extra/release.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/extra/release.py b/extra/release.py index b47de8966..afa762baf 100755 --- a/extra/release.py +++ b/extra/release.py @@ -19,6 +19,8 @@ from packaging.version import Version, parse from sphinx.ext import intersphinx from typing_extensions import TypeAlias +from docs.conf import rst_epilog + BASE = Path(__file__).parent.parent.absolute() PYPROJECT = BASE / "pyproject.toml" CHANGELOG = BASE / "docs" / "changelog.rst" @@ -104,11 +106,21 @@ def create_rst_replacements() -> list[Replacement]: plugins = "|".join( r.split("/")[-1] for r in refs if r.startswith("plugins/") ) + explicit_replacements = dict( + line.removeprefix(".. ").split(" replace:: ") + for line in filter(None, rst_epilog.splitlines()) + ) return [ - # Replace Sphinx :ref: and :doc: directives by documentation URLs + # Replace explicitly defined substitutions from rst_epilog + # |BeetsPlugin| -> :class:`beets.plugins.BeetsPlugin` + ( + r"\|\w[^ ]*\|", + lambda m: explicit_replacements.get(m[0], m[0]), + ), + # Replace Sphinx directives by documentation URLs, e.g., # :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html) ( - r":(?:ref|doc):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+", + r":(?:ref|doc|class):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+", lambda m: make_ref_link(m[2], m[1]), ), # Convert command references to documentation URLs From 670c300625b0dd7ef7a5c62c3ae2106ed5dfbf15 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 17:15:02 +0200 Subject: [PATCH 250/301] Fixed issue with legacy plugin copy not copying properties. Also added test for it --- beets/plugins.py | 25 +++++++++++++++---------- docs/changelog.rst | 2 ++ test/test_plugins.py | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index a8e803efd..9c7a93b7f 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -22,7 +22,7 @@ import re import sys import warnings from collections import defaultdict -from functools import wraps +from functools import cached_property, wraps from importlib import import_module from pathlib import Path from types import GenericAlias @@ -192,15 +192,20 @@ class BeetsPlugin(metaclass=abc.ABCMeta): stacklevel=3, ) - for name, method in inspect.getmembers( - MetadataSourcePlugin, - predicate=lambda f: ( - inspect.isfunction(f) - and f.__name__ not in MetadataSourcePlugin.__abstractmethods__ - and not hasattr(cls, f.__name__) - ), - ): - setattr(cls, name, method) + abstracts = MetadataSourcePlugin.__abstractmethods__ + + for name, method in inspect.getmembers(MetadataSourcePlugin): + # Skip if already defined in the subclass + if hasattr(cls, name) or name in abstracts: + continue + + # Copy functions, methods, and properties + if ( + inspect.isfunction(method) + or inspect.ismethod(method) + or isinstance(method, cached_property) + ): + setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" diff --git a/docs/changelog.rst b/docs/changelog.rst index 6d08d6bdb..88c157ec5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,8 @@ For packagers: - Fixed dynamic versioning install not disabled for source distribution builds. :bug:`6089` +- Fixed issue with legacy metadata plugins not copying properties from the base + class. Other changes: diff --git a/test/test_plugins.py b/test/test_plugins.py index df338f924..07bbf0966 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -523,3 +523,23 @@ class TestImportPlugin(PluginMixin): assert "PluginImportError" not in caplog.text, ( f"Plugin '{plugin_name}' has issues during import." ) + + +class TestDeprecationCopy: + # TODO: remove this test in Beets 3.0.0 + def test_legacy_metadata_plugin_deprecation(self): + """Test that a MetadataSourcePlugin with 'legacy' data_source + raises a deprecation warning and all function and properties are + copied from the base class. + """ + with pytest.warns(DeprecationWarning, match="LegacyMetadataPlugin"): + + class LegacyMetadataPlugin(plugins.BeetsPlugin): + data_source = "legacy" + + # Assert all methods are present + assert hasattr(LegacyMetadataPlugin, "albums_for_ids") + assert hasattr(LegacyMetadataPlugin, "tracks_for_ids") + assert hasattr(LegacyMetadataPlugin, "data_source_mismatch_penalty") + assert hasattr(LegacyMetadataPlugin, "_extract_id") + assert hasattr(LegacyMetadataPlugin, "get_artist") From f339d8a4d381e5bac50cf6bb3e69cdf126d1e495 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 17:40:03 +0200 Subject: [PATCH 251/301] slight simplification. --- beets/plugins.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 9c7a93b7f..5e7ac6f96 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -192,20 +192,29 @@ class BeetsPlugin(metaclass=abc.ABCMeta): stacklevel=3, ) - abstracts = MetadataSourcePlugin.__abstractmethods__ - - for name, method in inspect.getmembers(MetadataSourcePlugin): - # Skip if already defined in the subclass - if hasattr(cls, name) or name in abstracts: - continue - - # Copy functions, methods, and properties - if ( - inspect.isfunction(method) - or inspect.ismethod(method) - or isinstance(method, cached_property) - ): - setattr(cls, name, method) + for name, method in inspect.getmembers( + MetadataSourcePlugin, + predicate=lambda f: ( + ( + isinstance(f, cached_property) + and f.attrname is not None + and not hasattr(BeetsPlugin, f.attrname) + ) + or ( + isinstance(f, property) + and f.fget is not None + and f.fget.__name__ is not None + and not hasattr(BeetsPlugin, f.fget.__name__) + ) + or ( + inspect.isfunction(f) + and f.__name__ + not in MetadataSourcePlugin.__abstractmethods__ + and not hasattr(BeetsPlugin, f.__name__) + ) + ), + ): + setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" From 365ff6b0303804cd1408c6f2e250eb22f5ad8b0c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 18:50:52 +0200 Subject: [PATCH 252/301] Added test additions --- test/autotag/test_distance.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index b327bbe44..213d32956 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -11,6 +11,7 @@ from beets.autotag.distance import ( ) from beets.library import Item from beets.metadata_plugins import MetadataSourcePlugin, get_penalty +from beets.plugins import BeetsPlugin from beets.test.helper import ConfigMixin _p = pytest.param @@ -310,8 +311,13 @@ class TestDataSourceDistance: def candidates(self, *args, **kwargs): ... def item_candidates(self, *args, **kwargs): ... - class OriginalPlugin(TestMetadataSourcePlugin): - pass + # We use BeetsPlugin here to check if our compatibility layer + # for pre 2.4.0 MetadataPlugins is working as expected + # TODO: Replace BeetsPlugin with TestMetadataSourcePlugin in v3.0.0 + with pytest.deprecated_call(): + + class OriginalPlugin(BeetsPlugin): + data_source = "Original" class OtherPlugin(TestMetadataSourcePlugin): @property @@ -332,6 +338,7 @@ class TestDataSourceDistance: [ _p("Original", "Original", 0.5, 1.0, True, MATCH, id="match"), _p("Original", "Other", 0.5, 1.0, True, MISMATCH, id="mismatch"), + _p("Other", "Original", 0.5, 1.0, True, MISMATCH, id="mismatch"), _p("Original", "unknown", 0.5, 1.0, True, MISMATCH, id="mismatch-unknown"), # noqa: E501 _p("Original", None, 0.5, 1.0, True, MISMATCH, id="mismatch-no-info"), # noqa: E501 _p(None, "Other", 0.5, 1.0, True, MISMATCH, id="mismatch-no-original-multiple-sources"), # noqa: E501 From 391ca4ca26fe5a7946bdcbfecec4f02e740d6393 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr <sebastian@mohrenclan.de> Date: Tue, 14 Oct 2025 19:45:56 +0200 Subject: [PATCH 253/301] Yet some more simplification. --- beets/plugins.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 5e7ac6f96..678d653b4 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -192,24 +192,21 @@ class BeetsPlugin(metaclass=abc.ABCMeta): stacklevel=3, ) + method: property | cached_property[Any] | Callable[..., Any] for name, method in inspect.getmembers( MetadataSourcePlugin, - predicate=lambda f: ( + predicate=lambda f: ( # type: ignore[arg-type] ( - isinstance(f, cached_property) - and f.attrname is not None - and not hasattr(BeetsPlugin, f.attrname) - ) - or ( - isinstance(f, property) - and f.fget is not None - and f.fget.__name__ is not None - and not hasattr(BeetsPlugin, f.fget.__name__) + isinstance(f, (property, cached_property)) + and not hasattr( + BeetsPlugin, + getattr(f, "attrname", None) or f.fget.__name__, # type: ignore[union-attr] + ) ) or ( inspect.isfunction(f) and f.__name__ - not in MetadataSourcePlugin.__abstractmethods__ + and not getattr(f, "__isabstractmethod__", False) and not hasattr(BeetsPlugin, f.__name__) ) ), From efe1a67e849c7fff673dd7a9248bf196811ec2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 23:38:01 +0100 Subject: [PATCH 254/301] Revert "Fix dynamic versioning plugin not correctly installed in workflow (#6094)" This reverts commit dc9b498ee89fa6286f5eb741a068eedccda637d6, reversing changes made to 77842b72d73ec46dcee0b9d44dc3eeef145fc59f. --- docs/changelog.rst | 2 -- pyproject.toml | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 88c157ec5..8f28e8d1c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,8 +20,6 @@ Bug fixes: For packagers: -- Fixed dynamic versioning install not disabled for source distribution builds. - :bug:`6089` - Fixed issue with legacy metadata plugins not copying properties from the base class. diff --git a/pyproject.toml b/pyproject.toml index dd67fa65c..3a355418c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,6 +158,7 @@ web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" + [tool.poetry-dynamic-versioning] enable = true vcs = "git" @@ -172,7 +173,7 @@ build-backend = "poetry_dynamic_versioning.backend" [tool.pipx-install] poethepoet = ">=0.26" -poetry = { version = ">=1.8,<2", inject = {"poetry-dynamic-versioning[plugin]" = ">=1.9.1" }} +poetry = ">=1.8,<2" [tool.poe.tasks.build] help = "Build the package" From 61cbc39c4aa18fd2a2f81e55934b51b4ebf8e752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 23:39:27 +0100 Subject: [PATCH 255/301] Revert "Add git commit suffix to __version__ for development installs (#5967)" --- .gitignore | 3 --- beets/__init__.py | 6 +----- beets/_version.py | 7 ------- docs/changelog.rst | 3 +++ extra/release.py | 6 ++++++ pyproject.toml | 13 ++----------- 6 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 beets/_version.py diff --git a/.gitignore b/.gitignore index 102e1c3e4..90ef7387d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,3 @@ ENV/ # pyright pyrightconfig.json - -# Versioning -beets/_version.py diff --git a/beets/__init__.py b/beets/__init__.py index 5f4c6657d..bdb19b579 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,10 +17,9 @@ from sys import stderr import confuse -# Version management using poetry-dynamic-versioning -from ._version import __version__, __version_tuple__ from .util import deprecate_imports +__version__ = "2.5.0" __author__ = "Adrian Sampson <adrian@radbox.org>" @@ -55,6 +54,3 @@ class IncludeLazyConfig(confuse.LazyConfig): config = IncludeLazyConfig("beets", __name__) - - -__all__ = ["__version__", "__version_tuple__", "config"] diff --git a/beets/_version.py b/beets/_version.py deleted file mode 100644 index 4dea56035..000000000 --- a/beets/_version.py +++ /dev/null @@ -1,7 +0,0 @@ -# This file is auto-generated during the build process. -# Do not edit this file directly. -# Placeholders are replaced during substitution. -# Run `git update-index --assume-unchanged beets/_version.py` -# to ignore local changes to this file. -__version__ = "0.0.0" -__version_tuple__ = (0, 0, 0) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8f28e8d1c..a76e69bb9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,9 @@ For packagers: - Fixed issue with legacy metadata plugins not copying properties from the base class. +- Reverted the following: When installing ``beets`` via git or locally the + version string now reflects the current git branch and commit hash. + :bug:`6089` Other changes: diff --git a/extra/release.py b/extra/release.py index afa762baf..d4ebb950f 100755 --- a/extra/release.py +++ b/extra/release.py @@ -186,6 +186,12 @@ FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ PYPROJECT, lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), ), + ( + BASE / "beets" / "__init__.py", + lambda text, new: re.sub( + r"(?<=__version__ = )[^\n]+", f'"{new}"', text + ), + ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] diff --git a/pyproject.toml b/pyproject.toml index 3a355418c..2570330c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,18 +158,9 @@ web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" - -[tool.poetry-dynamic-versioning] -enable = true -vcs = "git" -format = "{base}.dev{distance}+{commit}" - -[tool.poetry-dynamic-versioning.files."beets/_version.py"] -persistent-substitution = true - [build-system] -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] -build-backend = "poetry_dynamic_versioning.backend" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" [tool.pipx-install] poethepoet = ">=0.26" From c1877b7cf5371f5399fbf80f5e9e64bd119b9917 Mon Sep 17 00:00:00 2001 From: snejus <snejus@users.noreply.github.com> Date: Tue, 14 Oct 2025 22:51:15 +0000 Subject: [PATCH 256/301] Increment version to 2.5.1 --- beets/__init__.py | 2 +- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 2 +- pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index bdb19b579..d448d8c49 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -19,7 +19,7 @@ import confuse from .util import deprecate_imports -__version__ = "2.5.0" +__version__ = "2.5.1" __author__ = "Adrian Sampson <adrian@radbox.org>" diff --git a/docs/changelog.rst b/docs/changelog.rst index a76e69bb9..a90c1920b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,17 @@ Unreleased New features: +Bug fixes: + +For packagers: + +Other changes: + +2.5.1 (October 14, 2025) +------------------------ + +New features: + - :doc:`plugins/zero`: Add new configuration option, ``omit_single_disc``, to allow zeroing the disc number on write for single-disc albums. Defaults to False. diff --git a/docs/conf.py b/docs/conf.py index a027b3005..c2cecc510 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" version = "2.5" -release = "2.5.0" +release = "2.5.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 2570330c6..0058c7f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.5.0" +version = "2.5.1" description = "music tagger and library organizer" authors = ["Adrian Sampson <adrian@radbox.org>"] maintainers = ["Serene-Arc"] From 8613b3573c14d8bdf082c218f6aeb456307b5d57 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 14 Sep 2025 09:03:32 +0200 Subject: [PATCH 257/301] lastgenre: Refactor final genre apply - Move item and genre apply to separate helper functions. Have one function for each to not overcomplicate implementation! - Use a decorator log_and_pretend that logs and does the right thing depending on wheter --pretend was passed or not. - Sets --force (internally) automatically if --pretend is given (this is a behavirol change needing discussion) --- beetsplug/lastgenre/__init__.py | 102 ++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1da5ecde4..301c94377 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -24,6 +24,7 @@ https://gist.github.com/1241307 import os import traceback +from functools import wraps from pathlib import Path from typing import Union @@ -76,6 +77,28 @@ def find_parents(candidate, branches): return [candidate] +def log_and_pretend(apply_func): + """Decorator that logs genre assignments and conditionally applies changes + based on pretend mode.""" + + @wraps(apply_func) + def wrapper(self, obj, label, genre): + obj_type = type(obj).__name__.lower() + attr_name = "album" if obj_type == "album" else "title" + msg = ( + f'genre for {obj_type} "{getattr(obj, attr_name)}" ' + f"({label}): {genre}" + ) + if self.config["pretend"]: + self._log.info(f"Pretend: {msg}") + return None + + self._log.info(msg) + return apply_func(self, obj, label, genre) + + return wrapper + + # Main plugin logic. WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") @@ -101,6 +124,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): "prefer_specific": False, "title_case": True, "extended_debug": False, + "pretend": False, } ) self.setup() @@ -459,6 +483,21 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Beets plugin hooks and CLI. + @log_and_pretend + def _apply_album_genre(self, obj, label, genre): + """Apply genre to an Album object, with logging and pretend mode support.""" + obj.genre = genre + if "track" in self.sources: + obj.store(inherit=False) + else: + obj.store() + + @log_and_pretend + def _apply_item_genre(self, obj, label, genre): + """Apply genre to an Item object, with logging and pretend mode support.""" + obj.genre = genre + obj.store() + def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( @@ -527,64 +566,37 @@ class LastGenrePlugin(plugins.BeetsPlugin): def lastgenre_func(lib, opts, args): write = ui.should_write() - pretend = getattr(opts, "pretend", False) self.config.set_args(opts) + if opts.pretend: + self.config["force"].set(True) if opts.album: # Fetch genres for whole albums for album in lib.albums(args): - album_genre, src = self._get_genre(album) - prefix = "Pretend: " if pretend else "" - self._log.info( - '{}genre for album "{.album}" ({}): {}', - prefix, - album, - src, - album_genre, - ) - if not pretend: - album.genre = album_genre - if "track" in self.sources: - album.store(inherit=False) - else: - album.store() + album_genre, label = self._get_genre(album) + self._apply_album_genre(album, label, album_genre) for item in album.items(): # If we're using track-level sources, also look up each # track on the album. if "track" in self.sources: - item_genre, src = self._get_genre(item) - self._log.info( - '{}genre for track "{.title}" ({}): {}', - prefix, - item, - src, - item_genre, - ) - if not pretend: - item.genre = item_genre - item.store() + item_genre, label = self._get_genre(item) + + if not item_genre: + self._log.info( + 'No genre found for track "{0.title}"', + item, + ) + else: + self._apply_item_genre(item, label, item_genre) + if write: + item.try_write() - if write and not pretend: - item.try_write() else: - # Just query singletons, i.e. items that are not part of - # an album + # Just query single tracks or singletons for item in lib.items(args): - item_genre, src = self._get_genre(item) - prefix = "Pretend: " if pretend else "" - self._log.info( - '{}genre for track "{0.title}" ({1}): {}', - prefix, - item, - src, - item_genre, - ) - if not pretend: - item.genre = item_genre - item.store() - if write and not pretend: - item.try_write() + singleton_genre, label = self._get_genre(item) + self._apply_item_genre(item, label, singleton_genre) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] From 1acec39525ba9a91a652974ecc1ce1ff8e5986c8 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Wed, 17 Sep 2025 07:16:57 +0200 Subject: [PATCH 258/301] lastgenre: Use apply methods during import --- beetsplug/lastgenre/__init__.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 301c94377..b67a4476f 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -605,34 +605,21 @@ class LastGenrePlugin(plugins.BeetsPlugin): """Event hook called when an import task finishes.""" if task.is_album: album = task.album - album.genre, src = self._get_genre(album) - self._log.debug( - 'genre for album "{0.album}" ({1}): {0.genre}', album, src - ) + album_genre, label = self._get_genre(album) + self._apply_album_genre(album, label, album_genre) - # If we're using track-level sources, store the album genre only, - # then also look up individual track genres. + # If we're using track-level sources, store the album genre only (this + # happened in _apply_album_genre already), then also look up individual + # track genres. if "track" in self.sources: - album.store(inherit=False) for item in album.items(): - item.genre, src = self._get_genre(item) - self._log.debug( - 'genre for track "{0.title}" ({1}): {0.genre}', - item, - src, - ) - item.store() - # Store the album genre and inherit to tracks. - else: - album.store() + item_genre, label = self._get_genre(item) + self._apply_item_genre(item, label, item_genre) else: item = task.item - item.genre, src = self._get_genre(item) - self._log.debug( - 'genre for track "{0.title}" ({1}): {0.genre}', item, src - ) - item.store() + item_genre, label = self._get_genre(item) + self._apply_item_genre(item, label, item_genre) def _tags_for(self, obj, min_weight=None): """Core genre identification routine. From d617e6719919d98db79bee426b718630598f7f10 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sun, 21 Sep 2025 08:07:49 +0200 Subject: [PATCH 259/301] lastgenre: Fix test_pretend_option only one arg is passed to the info log anymore. --- test/plugins/test_lastgenre.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index d6df42f97..c3d4984d7 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -158,7 +158,8 @@ class LastGenrePluginTest(BeetsTestCase): mock_get_genre.assert_called_once() assert any( - call.args[1] == "Pretend: " for call in log_info.call_args_list + call.args[0].startswith("Pretend:") + for call in log_info.call_args_list ) # Verify that try_write was never called (file operations skipped) From 654c14490e1b3f00ca1a49bc110b90563f2bf7bc Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 25 Sep 2025 07:22:59 +0200 Subject: [PATCH 260/301] lastgenre: Refactor test_pretend to pytest --- test/plugins/test_lastgenre.py | 86 +++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index c3d4984d7..91dd7c282 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -14,7 +14,7 @@ """Tests for the 'lastgenre' plugin.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest @@ -131,44 +131,6 @@ class LastGenrePluginTest(BeetsTestCase): "math rock", ] - def test_pretend_option_skips_library_updates(self): - item = self.create_item( - album="Pretend Album", - albumartist="Pretend Artist", - artist="Pretend Artist", - title="Pretend Track", - genre="Original Genre", - ) - album = self.lib.add_album([item]) - - command = self.plugin.commands()[0] - opts, args = command.parser.parse_args(["--pretend"]) - - with patch.object(lastgenre.ui, "should_write", return_value=True): - with patch.object( - self.plugin, - "_get_genre", - return_value=("Mock Genre", "mock stage"), - ) as mock_get_genre: - with patch.object(self.plugin._log, "info") as log_info: - # Mock try_write to verify it's never called in pretend mode - with patch.object(item, "try_write") as mock_try_write: - command.func(self.lib, opts, args) - - mock_get_genre.assert_called_once() - - assert any( - call.args[0].startswith("Pretend:") - for call in log_info.call_args_list - ) - - # Verify that try_write was never called (file operations skipped) - mock_try_write.assert_not_called() - - stored_album = self.lib.get_album(album.id) - assert stored_album.genre == "Original Genre" - assert stored_album.items()[0].genre == "Original Genre" - def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) @@ -210,6 +172,52 @@ class LastGenrePluginTest(BeetsTestCase): assert res == ["ambient", "electronic"] +def test_pretend_option_skips_library_updates(mocker): + """Test that pretend mode logs actions but skips library updates.""" + + # Setup + test_case = BeetsTestCase() + test_case.setUp() + plugin = lastgenre.LastGenrePlugin() + item = test_case.create_item( + album="Album", + albumartist="Artist", + artist="Artist", + title="Track", + genre="Original Genre", + ) + album = test_case.lib.add_album([item]) + command = plugin.commands()[0] + opts, args = command.parser.parse_args(["--pretend"]) + + # Mocks + mocker.patch.object(lastgenre.ui, "should_write", return_value=True) + mock_get_genre = mocker.patch.object( + plugin, "_get_genre", return_value=("New Genre", "log label") + ) + mock_log = mocker.patch.object(plugin._log, "info") + mock_write = mocker.patch.object(item, "try_write") + + # Run lastgenre + command.func(test_case.lib, opts, args) + mock_get_genre.assert_called_once() + + # Test logging + assert any( + call.args[0].startswith("Pretend:") for call in mock_log.call_args_list + ) + + # Test file operations should be skipped + mock_write.assert_not_called() + + # Test database should remain unchanged + stored_album = test_case.lib.get_album(album.id) + assert stored_album.genre == "Original Genre" + assert stored_album.items()[0].genre == "Original Genre" + + test_case.tearDown() + + @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", [ From 65f5dd579b08395559e0a55db412d8774e47c7fa Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 25 Sep 2025 10:18:59 +0200 Subject: [PATCH 261/301] Add pytest-mock to poetry test dependencies group --- poetry.lock | 19 ++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 6f0523a42..a8196cb1e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2453,6 +2453,23 @@ Werkzeug = "*" [package.extras] docs = ["Sphinx", "sphinx-rtd-theme"] +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3672,4 +3689,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" +content-hash = "8ed50b90e399bace64062c38f784f9c7bcab2c2b7c0728cfe0a9ee78ea1fd902" diff --git a/pyproject.toml b/pyproject.toml index 0058c7f9b..5eb82f6c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" +pytest-mock = "^3.15.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" From c2d5c1f17c7416763b412099ee5ccd9b32102e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 03:28:46 +0100 Subject: [PATCH 262/301] Update test --- poetry.lock | 19 +------- pyproject.toml | 1 - test/plugins/test_lastgenre.py | 84 ++++++++++++++-------------------- 3 files changed, 36 insertions(+), 68 deletions(-) diff --git a/poetry.lock b/poetry.lock index a8196cb1e..6f0523a42 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2453,23 +2453,6 @@ Werkzeug = "*" [package.extras] docs = ["Sphinx", "sphinx-rtd-theme"] -[[package]] -name = "pytest-mock" -version = "3.15.1" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, - {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3689,4 +3672,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "8ed50b90e399bace64062c38f784f9c7bcab2c2b7c0728cfe0a9ee78ea1fd902" +content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" diff --git a/pyproject.toml b/pyproject.toml index 5eb82f6c7..0058c7f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,6 @@ rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" -pytest-mock = "^3.15.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 91dd7c282..151f122a6 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -14,16 +14,18 @@ """Tests for the 'lastgenre' plugin.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from beets.test import _common -from beets.test.helper import BeetsTestCase +from beets.test.helper import PluginTestCase from beetsplug import lastgenre -class LastGenrePluginTest(BeetsTestCase): +class LastGenrePluginTest(PluginTestCase): + plugin = "lastgenre" + def setUp(self): super().setUp() self.plugin = lastgenre.LastGenrePlugin() @@ -131,6 +133,36 @@ class LastGenrePluginTest(BeetsTestCase): "math rock", ] + @patch("beets.ui.should_write", Mock(return_value=True)) + @patch( + "beetsplug.lastgenre.LastGenrePlugin._get_genre", + Mock(return_value=("Mock Genre", "mock stage")), + ) + def test_pretend_option_skips_library_updates(self): + item = self.create_item( + album="Pretend Album", + albumartist="Pretend Artist", + artist="Pretend Artist", + title="Pretend Track", + genre="Original Genre", + ) + album = self.lib.add_album([item]) + + def unexpected_store(*_, **__): + raise AssertionError("Unexpected store call") + + # Verify that try_write was never called (file operations skipped) + with ( + patch("beetsplug.lastgenre.Item.store", unexpected_store), + self.assertLogs() as logs, + ): + self.run_command("lastgenre", "--pretend") + + assert "Mock Genre" in str(logs.output) + album.load() + assert album.genre == "Original Genre" + assert album.items()[0].genre == "Original Genre" + def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) @@ -172,52 +204,6 @@ class LastGenrePluginTest(BeetsTestCase): assert res == ["ambient", "electronic"] -def test_pretend_option_skips_library_updates(mocker): - """Test that pretend mode logs actions but skips library updates.""" - - # Setup - test_case = BeetsTestCase() - test_case.setUp() - plugin = lastgenre.LastGenrePlugin() - item = test_case.create_item( - album="Album", - albumartist="Artist", - artist="Artist", - title="Track", - genre="Original Genre", - ) - album = test_case.lib.add_album([item]) - command = plugin.commands()[0] - opts, args = command.parser.parse_args(["--pretend"]) - - # Mocks - mocker.patch.object(lastgenre.ui, "should_write", return_value=True) - mock_get_genre = mocker.patch.object( - plugin, "_get_genre", return_value=("New Genre", "log label") - ) - mock_log = mocker.patch.object(plugin._log, "info") - mock_write = mocker.patch.object(item, "try_write") - - # Run lastgenre - command.func(test_case.lib, opts, args) - mock_get_genre.assert_called_once() - - # Test logging - assert any( - call.args[0].startswith("Pretend:") for call in mock_log.call_args_list - ) - - # Test file operations should be skipped - mock_write.assert_not_called() - - # Test database should remain unchanged - stored_album = test_case.lib.get_album(album.id) - assert stored_album.genre == "Original Genre" - assert stored_album.items()[0].genre == "Original Genre" - - test_case.tearDown() - - @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", [ From ee289844ede14abd1dc4f3476e9d7d425efceee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 14 Oct 2025 03:34:11 +0100 Subject: [PATCH 263/301] Add _process_album and _process_item methods --- beetsplug/lastgenre/__init__.py | 61 ++++++++++++++------------------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index b67a4476f..80374b962 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -498,6 +498,27 @@ class LastGenrePlugin(plugins.BeetsPlugin): obj.genre = genre obj.store() + def _process_item(self, item: Item, write: bool = False): + genre, label = self._get_genre(item) + + if genre: + self._apply_item_genre(item, label, genre) + if write and not self.config["pretend"]: + item.try_write() + else: + self._log.info('No genre found for track "{.title}"', item) + + def _process_album(self, album: Album, write: bool = False): + album_genre, label = self._get_genre(album) + self._apply_album_genre(album, label, album_genre) + + # If we're using track-level sources, store the album genre only (this + # happened in _apply_album_genre already), then also look up individual + # track genres. + if "track" in self.sources: + for item in album.items(): + self._process_item(item, write=write) + def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( @@ -573,30 +594,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): if opts.album: # Fetch genres for whole albums for album in lib.albums(args): - album_genre, label = self._get_genre(album) - self._apply_album_genre(album, label, album_genre) - - for item in album.items(): - # If we're using track-level sources, also look up each - # track on the album. - if "track" in self.sources: - item_genre, label = self._get_genre(item) - - if not item_genre: - self._log.info( - 'No genre found for track "{0.title}"', - item, - ) - else: - self._apply_item_genre(item, label, item_genre) - if write: - item.try_write() - + self._process_album(album, write=write) else: # Just query single tracks or singletons for item in lib.items(args): - singleton_genre, label = self._get_genre(item) - self._apply_item_genre(item, label, singleton_genre) + self._process_item(item, write=write) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] @@ -604,22 +606,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): def imported(self, session, task): """Event hook called when an import task finishes.""" if task.is_album: - album = task.album - album_genre, label = self._get_genre(album) - self._apply_album_genre(album, label, album_genre) - - # If we're using track-level sources, store the album genre only (this - # happened in _apply_album_genre already), then also look up individual - # track genres. - if "track" in self.sources: - for item in album.items(): - item_genre, label = self._get_genre(item) - self._apply_item_genre(item, label, item_genre) - + self._process_album(task.album) else: - item = task.item - item_genre, label = self._get_genre(item) - self._apply_item_genre(item, label, item_genre) + self._process_item(task.item) def _tags_for(self, obj, min_weight=None): """Core genre identification routine. From 0aac7315c3056b6292dc00140093b41a464ce0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 15 Oct 2025 02:54:24 +0100 Subject: [PATCH 264/301] lastgenre: refactor genre processing with singledispatch Replace the log_and_pretend decorator with a more robust implementation using singledispatchmethod. This simplifies the genre application logic by consolidating logging and processing into dedicated methods. Key changes: - Remove log_and_pretend decorator in favor of explicit dispatch - Add _fetch_and_log_genre method to centralize genre fetching and logging - Log user-configured full object representation instead of specific attributes - Introduce _process singledispatchmethod with type-specific handlers - Use LibModel type hint for broader compatibility - Simplify command handler by removing duplicate album/item logic - Replace manual genre application with try_sync for consistency --- beetsplug/lastgenre/__init__.py | 118 +++++++++++--------------------- 1 file changed, 41 insertions(+), 77 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 80374b962..8bd33ff5d 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -22,11 +22,13 @@ The scraper script used is available here: https://gist.github.com/1241307 """ +from __future__ import annotations + import os import traceback -from functools import wraps +from functools import singledispatchmethod from pathlib import Path -from typing import Union +from typing import TYPE_CHECKING, Union import pylast import yaml @@ -35,6 +37,9 @@ from beets import config, library, plugins, ui from beets.library import Album, Item from beets.util import plurality, unique_list +if TYPE_CHECKING: + from beets.library import LibModel + LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) PYLAST_EXCEPTIONS = ( @@ -77,28 +82,6 @@ def find_parents(candidate, branches): return [candidate] -def log_and_pretend(apply_func): - """Decorator that logs genre assignments and conditionally applies changes - based on pretend mode.""" - - @wraps(apply_func) - def wrapper(self, obj, label, genre): - obj_type = type(obj).__name__.lower() - attr_name = "album" if obj_type == "album" else "title" - msg = ( - f'genre for {obj_type} "{getattr(obj, attr_name)}" ' - f"({label}): {genre}" - ) - if self.config["pretend"]: - self._log.info(f"Pretend: {msg}") - return None - - self._log.info(msg) - return apply_func(self, obj, label, genre) - - return wrapper - - # Main plugin logic. WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") @@ -345,7 +328,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): return self.config["separator"].as_str().join(formatted) - def _get_existing_genres(self, obj: Union[Album, Item]) -> list[str]: + def _get_existing_genres(self, obj: LibModel) -> list[str]: """Return a list of genres for this Item or Album. Empty string genres are removed.""" separator = self.config["separator"].get() @@ -366,9 +349,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): combined = old + new return self._resolve_genres(combined) - def _get_genre( - self, obj: Union[Album, Item] - ) -> tuple[Union[str, None], ...]: + def _get_genre(self, obj: LibModel) -> tuple[Union[str, None], ...]: """Get the final genre string for an Album or Item object. `self.sources` specifies allowed genre sources. Starting with the first @@ -483,41 +464,36 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Beets plugin hooks and CLI. - @log_and_pretend - def _apply_album_genre(self, obj, label, genre): - """Apply genre to an Album object, with logging and pretend mode support.""" - obj.genre = genre + def _fetch_and_log_genre(self, obj: LibModel) -> None: + """Fetch genre and log it.""" + self._log.info(str(obj)) + obj.genre, label = self._get_genre(obj) + self._log.info("Resolved ({}): {}", label, obj.genre) + + @singledispatchmethod + def _process(self, obj: LibModel, write: bool) -> None: + """Process an object, dispatching to the appropriate method.""" + raise NotImplementedError + + @_process.register + def _process_track(self, obj: Item, write: bool) -> None: + """Process a single track/item.""" + self._fetch_and_log_genre(obj) + if not self.config["pretend"]: + obj.try_sync(write=write, move=False) + + @_process.register + def _process_album(self, obj: Album, write: bool) -> None: + """Process an entire album.""" + self._fetch_and_log_genre(obj) if "track" in self.sources: - obj.store(inherit=False) - else: - obj.store() + for item in obj.items(): + self._process(item, write) - @log_and_pretend - def _apply_item_genre(self, obj, label, genre): - """Apply genre to an Item object, with logging and pretend mode support.""" - obj.genre = genre - obj.store() - - def _process_item(self, item: Item, write: bool = False): - genre, label = self._get_genre(item) - - if genre: - self._apply_item_genre(item, label, genre) - if write and not self.config["pretend"]: - item.try_write() - else: - self._log.info('No genre found for track "{.title}"', item) - - def _process_album(self, album: Album, write: bool = False): - album_genre, label = self._get_genre(album) - self._apply_album_genre(album, label, album_genre) - - # If we're using track-level sources, store the album genre only (this - # happened in _apply_album_genre already), then also look up individual - # track genres. - if "track" in self.sources: - for item in album.items(): - self._process_item(item, write=write) + if not self.config["pretend"]: + obj.try_sync( + write=write, move=False, inherit="track" not in self.sources + ) def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") @@ -586,29 +562,17 @@ class LastGenrePlugin(plugins.BeetsPlugin): lastgenre_cmd.parser.set_defaults(album=True) def lastgenre_func(lib, opts, args): - write = ui.should_write() self.config.set_args(opts) - if opts.pretend: - self.config["force"].set(True) - if opts.album: - # Fetch genres for whole albums - for album in lib.albums(args): - self._process_album(album, write=write) - else: - # Just query single tracks or singletons - for item in lib.items(args): - self._process_item(item, write=write) + method = lib.albums if opts.album else lib.items + for obj in method(args): + self._process(obj, write=ui.should_write()) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] def imported(self, session, task): - """Event hook called when an import task finishes.""" - if task.is_album: - self._process_album(task.album) - else: - self._process_item(task.item) + self._process(task.album if task.is_album else task.item, write=False) def _tags_for(self, obj, min_weight=None): """Core genre identification routine. From 88011a7c659c70d471e31b5e432c74dccb1f182a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Wed, 15 Oct 2025 11:14:26 +0100 Subject: [PATCH 265/301] Show genre change using show_model_changes --- beets/ui/__init__.py | 6 ++++-- beetsplug/lastgenre/__init__.py | 4 +++- test/plugins/test_lastgenre.py | 9 +++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index e0c1bb486..60e201448 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1078,7 +1078,9 @@ def _field_diff(field, old, old_fmt, new, new_fmt): return f"{oldstr} -> {newstr}" -def show_model_changes(new, old=None, fields=None, always=False): +def show_model_changes( + new, old=None, fields=None, always=False, print_obj: bool = True +): """Given a Model object, print a list of changes from its pristine version stored in the database. Return a boolean indicating whether any changes were found. @@ -1117,7 +1119,7 @@ def show_model_changes(new, old=None, fields=None, always=False): ) # Print changes. - if changes or always: + if print_obj and (changes or always): print_(format(old)) if changes: print_("\n".join(changes)) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 8bd33ff5d..902cef9ef 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -468,7 +468,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): """Fetch genre and log it.""" self._log.info(str(obj)) obj.genre, label = self._get_genre(obj) - self._log.info("Resolved ({}): {}", label, obj.genre) + self._log.debug("Resolved ({}): {}", label, obj.genre) + + ui.show_model_changes(obj, fields=["genre"], print_obj=False) @singledispatchmethod def _process(self, obj: LibModel, write: bool) -> None: diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 151f122a6..12ff30f8e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -152,13 +152,10 @@ class LastGenrePluginTest(PluginTestCase): raise AssertionError("Unexpected store call") # Verify that try_write was never called (file operations skipped) - with ( - patch("beetsplug.lastgenre.Item.store", unexpected_store), - self.assertLogs() as logs, - ): - self.run_command("lastgenre", "--pretend") + with patch("beetsplug.lastgenre.Item.store", unexpected_store): + output = self.run_with_output("lastgenre", "--pretend") - assert "Mock Genre" in str(logs.output) + assert "Mock Genre" in output album.load() assert album.genre == "Original Genre" assert album.items()[0].genre == "Original Genre" From 472aa12767340eb4e079213f5bae4001034ba5a6 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Thu, 16 Oct 2025 18:49:14 +0200 Subject: [PATCH 266/301] Add main functionality --- beetsplug/ftintitle.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index ef9b763cf..c10fdada5 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -108,6 +108,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "drop": False, "format": "feat. {}", "keep_in_artist": False, + "skip_if_artist_and_album_artists_is_the_same": True, "custom_words": [], } ) @@ -133,12 +134,19 @@ class FtInTitlePlugin(plugins.BeetsPlugin): self.config.set_args(opts) drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) + skip_if_artist_and_album_artists_is_the_same = self.config[ + "skip_if_artist_and_album_artists_is_the_same" + ].get(bool) custom_words = self.config["custom_words"].get(list) write = ui.should_write() for item in lib.items(args): if self.ft_in_title( - item, drop_feat, keep_in_artist_field, custom_words + item, + drop_feat, + keep_in_artist_field, + skip_if_artist_and_album_artists_is_the_same, + custom_words, ): item.store() if write: @@ -151,11 +159,18 @@ class FtInTitlePlugin(plugins.BeetsPlugin): """Import hook for moving featuring artist automatically.""" drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) + skip_if_artist_and_album_artists_is_the_same = self.config[ + "skip_if_artist_and_album_artists_is_the_same" + ].get(bool) custom_words = self.config["custom_words"].get(list) for item in task.imported_items(): if self.ft_in_title( - item, drop_feat, keep_in_artist_field, custom_words + item, + drop_feat, + keep_in_artist_field, + skip_if_artist_and_album_artists_is_the_same, + custom_words, ): item.store() @@ -204,6 +219,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item: Item, drop_feat: bool, keep_in_artist_field: bool, + skip_if_artist_and_album_artists_is_the_same: bool, custom_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move @@ -218,7 +234,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # Check whether there is a featured artist on this track and the # artist field does not exactly match the album artist field. In # that case, we attempt to move the featured artist to the title. - if albumartist and artist == albumartist: + if ( + skip_if_artist_and_album_artists_is_the_same + and albumartist + and artist == albumartist + ): return False _, featured = split_on_feat(artist, custom_words=custom_words) From f275835cd374d3f4e2759bc392fec41d4797100d Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Thu, 16 Oct 2025 18:49:24 +0200 Subject: [PATCH 267/301] Add test --- test/plugins/test_ftintitle.py | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 30b414948..f4fb898ac 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -205,6 +205,46 @@ def add_item( ("Alice med Bob", "Song 1"), id="custom-feat-words-keep-in-artists-drop-from-title", ), + # ---- skip_if_artist_and_album_artists_is_the_same variants ---- + pytest.param( + { + "format": "feat. {}", + "skip_if_artist_and_album_artists_is_the_same": True, + }, + ("ftintitle",), + ("Alice feat. Bob", "Song 1", "Alice"), + ("Alice", "Song 1 feat. Bob"), + id="skip-if-artist-and-album-artists-is-the-same-different-match", + ), + pytest.param( + { + "format": "feat. {}", + "skip_if_artist_and_album_artists_is_the_same": False, + }, + ("ftintitle",), + ("Alice feat. Bob", "Song 1", "Alice"), + ("Alice", "Song 1 feat. Bob"), + id="skip-if-artist-and-album-artists-is-the-same-different-match-b", + ), + pytest.param( + { + "format": "feat. {}", + "skip_if_artist_and_album_artists_is_the_same": True, + }, + ("ftintitle",), + ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), + ("Alice feat. Bob", "Song 1"), + id="skip-if-artist-and-album-artists-is-the-same-matching-match", + ), + pytest.param( + { + "format": "feat. {}", + }, + ("ftintitle",), + ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), + ("Alice", "Song 1 feat. Bob"), + id="skip-if-artist-and-album-artists-is-the-same-matching-match-b", + ), ], ) def test_ftintitle_functional( From 6d2d663d3e3c3ab119b39eda7fae418715e47b12 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Thu, 16 Oct 2025 18:49:32 +0200 Subject: [PATCH 268/301] Add documentation --- docs/plugins/ftintitle.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1a95d03a8..7da56fbc7 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,6 +28,8 @@ file. The available options are: - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. +- **skip_if_artist_and_album_artists_is_the_same**: If the artist and the album + artist is the same, skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. From 022d7625d2e5cf6639acced28a37ef5fc0611aa2 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Thu, 16 Oct 2025 18:49:39 +0200 Subject: [PATCH 269/301] Add changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a8fc539b..00460e8ea 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ Unreleased New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. +- :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and + album artist is the same in ftintitle. Bug fixes: From 9b33575a70c68b99de8ca618cf800f3d5112f141 Mon Sep 17 00:00:00 2001 From: Ember Light <49758407+EmberLightVFX@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:01:17 +0200 Subject: [PATCH 270/301] Update docs/changelog.rst Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 00460e8ea..c6fbf098b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,7 @@ New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and - album artist is the same in ftintitle. + album artist are the same in ftintitle. Bug fixes: From adb5b293f047f6924699c7a3aa2e0af44da8ff09 Mon Sep 17 00:00:00 2001 From: Ember Light <49758407+EmberLightVFX@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:01:29 +0200 Subject: [PATCH 271/301] Update docs/plugins/ftintitle.rst Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- docs/plugins/ftintitle.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 7da56fbc7..3b5d3ca85 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -29,7 +29,7 @@ file. The available options are: useful if you still want to be able to search for features in the artist field. Default: ``no``. - **skip_if_artist_and_album_artists_is_the_same**: If the artist and the album - artist is the same, skip the ftintitle processing. Default: ``yes``. + artist are the same, skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. From ca8df30ec36124f17413eb44609362769aace693 Mon Sep 17 00:00:00 2001 From: Ember Light <jacob@emberlight.se> Date: Thu, 16 Oct 2025 19:06:56 +0200 Subject: [PATCH 272/301] Add missing test parameter --- test/plugins/test_ftintitle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index f4fb898ac..9fc771e89 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -239,6 +239,7 @@ def add_item( pytest.param( { "format": "feat. {}", + "skip_if_artist_and_album_artists_is_the_same": False, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), From a938449b2922acf2747e3587321bf1108fe00512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 12 Oct 2025 00:17:19 +0100 Subject: [PATCH 273/301] Add Sphinx extension for configuration value documentation Create a custom Sphinx extension to document configuration values with a simplified syntax. It is based on the `confval` but takes less space when rendered. The extension provides: - A `conf` directive for documenting individual configuration values with optional type and default parameters - A `conf` role for cross-referencing configuration values - Automatic formatting of default values in the signature - A custom domain that handles indexing and cross-references For example, if we have .. conf:: search_limit :default: 5 We refer to this configuration option with :conf:`plugins.discogs:search_limit`. The extension is loaded by adding the docs/extensions directory to the Python path and registering it in the Sphinx extensions list. --- docs/conf.py | 6 ++ docs/extensions/conf.py | 142 ++++++++++++++++++++++++++++++++++++++++ poetry.lock | 15 ++++- pyproject.toml | 15 ++++- 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 docs/extensions/conf.py diff --git a/docs/conf.py b/docs/conf.py index c2cecc510..8d2bae130 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,6 +6,11 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import sys +from pathlib import Path + +# Add custom extensions directory to path +sys.path.insert(0, str(Path(__file__).parent / "extensions")) project = "beets" AUTHOR = "Adrian Sampson" @@ -26,6 +31,7 @@ extensions = [ "sphinx.ext.viewcode", "sphinx_design", "sphinx_copybutton", + "conf", ] autosummary_generate = True diff --git a/docs/extensions/conf.py b/docs/extensions/conf.py new file mode 100644 index 000000000..308d28be2 --- /dev/null +++ b/docs/extensions/conf.py @@ -0,0 +1,142 @@ +"""Sphinx extension for simple configuration value documentation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx import addnodes +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain, ObjType +from sphinx.roles import XRefRole +from sphinx.util.nodes import make_refnode + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from docutils.nodes import Element + from docutils.parsers.rst.states import Inliner + from sphinx.addnodes import desc_signature, pending_xref + from sphinx.application import Sphinx + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + from sphinx.util.typing import ExtensionMetadata, OptionSpec + + +class Conf(ObjectDescription[str]): + """Directive for documenting a single configuration value.""" + + option_spec: ClassVar[OptionSpec] = { + "default": directives.unchanged, + } + + def handle_signature(self, sig: str, signode: desc_signature) -> str: + """Process the directive signature (the config name).""" + signode += addnodes.desc_name(sig, sig) + + # Add default value if provided + if "default" in self.options: + signode += nodes.Text(" ") + default_container = nodes.inline("", "") + default_container += nodes.Text("(default: ") + default_container += nodes.literal("", self.options["default"]) + default_container += nodes.Text(")") + signode += default_container + + return sig + + def add_target_and_index( + self, name: str, sig: str, signode: desc_signature + ) -> None: + """Add cross-reference target and index entry.""" + target = f"conf-{name}" + if target not in self.state.document.ids: + signode["ids"].append(target) + self.state.document.note_explicit_target(signode) + + # A unique full name which includes the document name + index_name = f"{self.env.docname.replace('/', '.')}:{name}" + # Register with the conf domain + domain = self.env.get_domain("conf") + domain.data["objects"][index_name] = (self.env.docname, target) + + # Add to index + self.indexnode["entries"].append( + ("single", f"{name} (configuration value)", target, "", None) + ) + + +class ConfDomain(Domain): + """Domain for simple configuration values.""" + + name = "conf" + label = "Simple Configuration" + object_types = {"conf": ObjType("conf", "conf")} + directives = {"conf": Conf} + roles = {"conf": XRefRole()} + initial_data: dict[str, Any] = {"objects": {}} + + def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]: + """Return an iterable of object tuples for the inventory.""" + for name, (docname, targetname) in self.data["objects"].items(): + # Remove the document name prefix for display + display_name = name.split(":")[-1] + yield (name, display_name, "conf", docname, targetname, 1) + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> Element | None: + if entry := self.data["objects"].get(target): + docname, targetid = entry + return make_refnode( + builder, fromdocname, docname, targetid, contnode + ) + + return None + + +# sphinx.util.typing.RoleFunction +def conf_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: Inliner, + /, + options: dict[str, Any] | None = None, + content: Sequence[str] = (), +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Role for referencing configuration values.""" + node = addnodes.pending_xref( + "", + refdomain="conf", + reftype="conf", + reftarget=text, + refwarn=True, + **(options or {}), + ) + node += nodes.literal(text, text.split(":")[-1]) + return [node], [] + + +def setup(app: Sphinx) -> ExtensionMetadata: + app.add_domain(ConfDomain) + + # register a top-level directive so users can use ".. conf:: ..." + app.add_directive("conf", Conf) + + # Register role with short name + app.add_role("conf", conf_role) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/poetry.lock b/poetry.lock index 6f0523a42..615598d67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3473,6 +3473,17 @@ files = [ [package.dependencies] types-html5lib = "*" +[[package]] +name = "types-docutils" +version = "0.22.2.20251006" +description = "Typing stubs for docutils" +optional = false +python-versions = ">=3.9" +files = [ + {file = "types_docutils-0.22.2.20251006-py3-none-any.whl", hash = "sha256:1e61afdeb4fab4ae802034deea3e853ced5c9b5e1d156179000cb68c85daf384"}, + {file = "types_docutils-0.22.2.20251006.tar.gz", hash = "sha256:c36c0459106eda39e908e9147bcff9dbd88535975cde399433c428a517b9e3b2"}, +] + [[package]] name = "types-flask-cors" version = "6.0.0.20250520" @@ -3650,7 +3661,7 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] chroma = ["pyacoustid"] discogs = ["python3-discogs-client"] -docs = ["pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] +docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] embedart = ["Pillow"] embyupdate = ["requests"] fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"] @@ -3672,4 +3683,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" +content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f" diff --git a/pyproject.toml b/pyproject.toml index 0058c7f9b..b546b4dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,10 +77,11 @@ resampy = { version = ">=0.4.3", optional = true } requests-oauthlib = { version = ">=0.6.1", optional = true } soco = { version = "*", optional = true } +docutils = { version = ">=0.20.1", optional = true } pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } -sphinx-design = { version = "^0.6.1", optional = true } -sphinx-copybutton = { version = "^0.5.2", optional = true } +sphinx-design = { version = ">=0.6.1", optional = true } +sphinx-copybutton = { version = ">=0.5.2", optional = true } [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -109,6 +110,7 @@ sphinx-lint = ">=1.0.0" [tool.poetry.group.typing.dependencies] mypy = "*" types-beautifulsoup4 = "*" +types-docutils = ">=0.22.2.20251006" types-mock = "*" types-Flask-Cors = "*" types-Pillow = "*" @@ -131,7 +133,14 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0 chroma = ["pyacoustid"] # chromaprint or fpcalc # convert # ffmpeg -docs = ["pydata-sphinx-theme", "sphinx", "sphinx-lint", "sphinx-design", "sphinx-copybutton"] +docs = [ + "docutils", + "pydata-sphinx-theme", + "sphinx", + "sphinx-lint", + "sphinx-design", + "sphinx-copybutton", +] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick embyupdate = ["requests"] From 498b14ee1d50edfead49efe190335b0fc6ffd496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 12 Oct 2025 00:19:08 +0100 Subject: [PATCH 274/301] Convert autotagger plugin docs to use conf role --- beetsplug/discogs.py | 2 +- docs/plugins/deezer.rst | 20 +- docs/plugins/discogs.rst | 132 +++++++----- docs/plugins/index.rst | 60 +----- docs/plugins/musicbrainz.rst | 201 ++++++++---------- .../plugins/shared_metadata_source_config.rst | 65 ++++++ docs/plugins/spotify.rst | 100 +++++---- 7 files changed, 305 insertions(+), 275 deletions(-) create mode 100644 docs/plugins/shared_metadata_source_config.rst diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 874eab6ec..be1cf97fa 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -132,9 +132,9 @@ class DiscogsPlugin(MetadataSourcePlugin): "user_token": "", "separator": ", ", "index_tracks": False, - "featured_string": "Feat.", "append_style_genre": False, "strip_disambiguation": True, + "featured_string": "Feat.", "anv": { "artist_credit": True, "artist": False, diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 96ed34652..d44a565ce 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -35,15 +35,23 @@ Default .. code-block:: yaml deezer: + search_query_ascii: no data_source_mismatch_penalty: 0.5 search_limit: 5 - search_query_ascii: no -- **search_query_ascii**: If set to ``yes``, the search query will be converted - to ASCII before being sent to Deezer. Converting searches to ASCII can enhance - search results in some cases, but in general, it is not recommended. For - instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 - album:4x4`` (notice ``×!=x``). Default: ``no``. +.. conf:: search_query_ascii + :default: no + + If enabled, the search query will be converted to ASCII before being sent to + Deezer. Converting searches to ASCII can enhance search results in some cases, + but in general, it is not recommended. For instance, ``artist:deadmau5 + album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice + ``×!=x``). + +.. include:: ./shared_metadata_source_config.rst + +Commands +-------- The ``deezer`` plugin provides an additional command ``deezerupdate`` to update the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 64b68248d..780042026 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -71,67 +71,93 @@ Default .. code-block:: yaml discogs: - data_source_mismatch_penalty: 0.5 - search_limit: 5 apikey: REDACTED apisecret: REDACTED tokenfile: discogs_token.json - user_token: REDACTED + user_token: index_tracks: no append_style_genre: no separator: ', ' strip_disambiguation: yes - -- **index_tracks**: Index tracks (see the `Discogs guidelines`_) along with - headers, mark divisions between distinct works on the same release or within - works. When enabled, beets will incorporate the names of the divisions - containing each track into the imported track's title. Default: ``no``. - - For example, importing `divisions album`_ would result in track names like: - - .. code-block:: text - - Messiah, Part I: No.1: Sinfony - Messiah, Part II: No.22: Chorus- Behold The Lamb Of God - Athalia, Act I, Scene I: Sinfonia - - whereas with ``index_tracks`` disabled you'd get: - - .. code-block:: text - - No.1: Sinfony - No.22: Chorus- Behold The Lamb Of God - Sinfonia - - This option is useful when importing classical music. - -- **append_style_genre**: Appends the Discogs style (if found) to the genre tag. - This can be useful if you want more granular genres to categorize your music. - For example, a release in Discogs might have a genre of "Electronic" and a - style of "Techno": enabling this setting would set the genre to be - "Electronic, Techno" (assuming default separator of ``", "``) instead of just - "Electronic". Default: ``False`` -- **separator**: How to join multiple genre and style values from Discogs into a - string. Default: ``", "`` -- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct - artists and labels with the same name. If you'd like to use the discogs - disambiguation in your tags, you can disable it. Default: ``True`` -- **featured_string**: Configure the string used for noting featured artists. - Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.`` -- **anv**: These configuration option are dedicated to handling Artist Name - Variations (ANVs). Sometimes a release credits artists differently compared to - the majority of their work. For example, "Basement Jaxx" may be credited as - "Tha Jaxx" or "The Basement Jaxx".You can select any combination of these - config options to control where beets writes and stores the variation credit. - The default, shown below, writes variations to the artist_credit field. - -.. code-block:: yaml - - discogs: + featured_string: Feat. anv: - artist_credit: True - artist: False - album_artist: False + artist_credit: yes + artist: no + album_artist: no + data_source_mismatch_penalty: 0.5 + search_limit: 5 + +.. conf:: index_tracks + :default: no + + Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions + between distinct works on the same release or within works. When enabled, + beets will incorporate the names of the divisions containing each track into the + imported track's title. + + For example, importing `divisions album`_ would result in track names like: + + .. code-block:: text + + Messiah, Part I: No.1: Sinfony + Messiah, Part II: No.22: Chorus- Behold The Lamb Of God + Athalia, Act I, Scene I: Sinfonia + + whereas with ``index_tracks`` disabled you'd get: + + .. code-block:: text + + No.1: Sinfony + No.22: Chorus- Behold The Lamb Of God + Sinfonia + + This option is useful when importing classical music. + +.. conf:: append_style_genre + :default: no + + Appends the Discogs style (if found) to the genre tag. This can be useful if + you want more granular genres to categorize your music. For example, + a release in Discogs might have a genre of "Electronic" and a style of + "Techno": enabling this setting would set the genre to be "Electronic, + Techno" (assuming default separator of ``", "``) instead of just + "Electronic". + +.. conf:: separator + :default: ", " + + How to join multiple genre and style values from Discogs into a string. + +.. conf:: strip_disambiguation + :default: yes + + Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with + the same name. If you'd like to use the Discogs disambiguation in your tags, + you can disable this option. + +.. conf:: featured_string + :default: Feat. + + Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``. + +.. conf:: anv + + This configuration option is dedicated to handling Artist Name + Variations (ANVs). Sometimes a release credits artists differently compared to + the majority of their work. For example, "Basement Jaxx" may be credited as + "Tha Jaxx" or "The Basement Jaxx". You can select any combination of these + config options to control where beets writes and stores the variation credit. + The default, shown below, writes variations to the artist_credit field. + + .. code-block:: yaml + + discogs: + anv: + artist_credit: yes + artist: no + album_artist: no + +.. include:: ./shared_metadata_source_config.rst .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index a877d2320..2c9d94dfd 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -50,65 +50,7 @@ Using Metadata Source Plugins We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: -.. _data_source_mismatch_penalty: - -- **data_source_mismatch_penalty**: Penalty applied when the data source of a - match candidate differs from the original source of your existing tracks. Any - decimal number between 0.0 and 1.0. Default: ``0.5``. - - This setting controls how much to penalize matches from different metadata - sources during import. The penalty is applied when beets detects that a match - candidate comes from a different data source than what appears to be the - original source of your music collection. - - **Example configurations:** - - .. code-block:: yaml - - # Prefer MusicBrainz over Discogs when sources don't match - plugins: musicbrainz discogs - - musicbrainz: - data_source_mismatch_penalty: 0.3 # Lower penalty = preferred - discogs: - data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred - - .. code-block:: yaml - - # Do not penalise candidates from Discogs at all - plugins: musicbrainz discogs - - musicbrainz: - data_source_mismatch_penalty: 0.5 - discogs: - data_source_mismatch_penalty: 0.0 - - .. code-block:: yaml - - # Disable cross-source penalties entirely - plugins: musicbrainz discogs - - musicbrainz: - data_source_mismatch_penalty: 0.0 - discogs: - data_source_mismatch_penalty: 0.0 - - .. tip:: - - The last configuration is equivalent to setting: - - .. code-block:: yaml - - match: - distance_weights: - data_source: 0.0 # Disable data source matching - -- **source_weight** - - .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. - -- **search_limit**: Maximum number of search results to consider. Default: - ``5``. +.. include:: ./shared_metadata_source_config.rst .. toctree:: :hidden: diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 5ac287368..00c553d8b 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -26,8 +26,6 @@ Default .. code-block:: yaml musicbrainz: - data_source_mismatch_penalty: 0.5 - search_limit: 5 host: musicbrainz.org https: no ratelimit: 1 @@ -41,122 +39,107 @@ Default deezer: no beatport: no tidal: no + data_source_mismatch_penalty: 0.5 + search_limit: 5 -You can instruct beets to use `your own MusicBrainz database -<https://musicbrainz.org/doc/MusicBrainz_Server/Setup>`__ instead of the +.. conf:: host + :default: musicbrainz.org -`main server`_. Use the ``host``, ``https`` and ``ratelimit`` options under a -``musicbrainz:`` header, like so + The Web server hostname (and port, optionally) that will be contacted by beets. + You can use this to configure beets to use `your own MusicBrainz database + <https://musicbrainz.org/doc/MusicBrainz_Server/Setup>`__ instead of the + `main server`_. -.. code-block:: yaml + The server must have search indices enabled (see `Building search indexes`_). - musicbrainz: - host: localhost:5000 - https: no - ratelimit: 100 + Example: -The ``host`` key, of course, controls the Web server hostname (and port, -optionally) that will be contacted by beets (default: musicbrainz.org). The -``https`` key makes the client use HTTPS instead of HTTP. This setting applies -only to custom servers. The official MusicBrainz server always uses HTTPS. -(Default: no.) The server must have search indices enabled (see `Building search -indexes`_). + .. code-block:: yaml -The ``ratelimit`` option, an integer, controls the number of Web service -requests per second (default: 1). **Do not change the rate limit setting** if -you're using the main MusicBrainz server---on this public server, you're -limited_ to one request per second. + musicbrainz: + host: localhost:5000 + +.. conf:: https + :default: no + + Makes the client use HTTPS instead of HTTP. This setting applies only to custom + servers. The official MusicBrainz server always uses HTTPS. + +.. conf:: ratelimit + :default: 1 + + Controls the number of Web service requests per second. + + **Do not change the rate limit setting** if you're using the main MusicBrainz + server---on this public server, you're limited_ to one request per second. + +.. conf:: ratelimit_interval + :default: 1.0 + + The time interval (in seconds) for the rate limit. + +.. conf:: enabled + :default: yes + + .. deprecated:: 2.4 Add ``musicbrainz`` to the ``plugins`` list instead. + +.. conf:: extra_tags + :default: [] + + By default, beets will use only the artist, album, and track count to query + MusicBrainz. Additional tags to be queried can be supplied with the + ``extra_tags`` setting. + + This setting should improve the autotagger results if the metadata with the + given tags match the metadata returned by MusicBrainz. + + Note that the only tags supported by this setting are: ``barcode``, + ``catalognum``, ``country``, ``label``, ``media``, and ``year``. + + Example: + + .. code-block:: yaml + + musicbrainz: + extra_tags: [barcode, catalognum, country, label, media, year] + +.. conf:: genres + :default: no + + Use MusicBrainz genre tags to populate (and replace if it's already set) the + ``genre`` tag. This will make it a list of all the genres tagged for the release + and the release-group on MusicBrainz, separated by "; " and sorted by the total + number of votes. + +.. conf:: external_ids + + **Default** + + .. code-block:: yaml + + musicbrainz: + external_ids: + discogs: no + spotify: no + bandcamp: no + beatport: no + deezer: no + tidal: no + + Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz + importer to look for links to related metadata sources. If such a link is + available the release ID will be extracted from the URL provided and imported to + the beets library. + + The library fields of the corresponding :ref:`autotagger_extensions` are used to + save the data as flexible attributes (``discogs_album_id``, ``bandcamp_album_id``, ``spotify_album_id``, + ``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports + existing data will be overwritten. + +.. include:: ./shared_metadata_source_config.rst .. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup .. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting .. _main server: https://musicbrainz.org/ - -.. _musicbrainz.enabled: - -enabled -+++++++ - -.. deprecated:: 2.4 Add ``musicbrainz`` to the ``plugins`` list instead. - -This option allows you to disable using MusicBrainz as a metadata source. This -applies if you use plugins that fetch data from alternative sources and should -make the import process quicker. - -Default: ``yes``. - -.. _search_limit: - -search_limit -++++++++++++ - -The number of matches returned when sending search queries to the MusicBrainz -server. - -Default: ``5``. - -searchlimit -+++++++++++ - -.. deprecated:: 2.4 Use `search_limit`_. - -.. _extra_tags: - -extra_tags -++++++++++ - -By default, beets will use only the artist, album, and track count to query -MusicBrainz. Additional tags to be queried can be supplied with the -``extra_tags`` setting. For example - -.. code-block:: yaml - - musicbrainz: - extra_tags: [barcode, catalognum, country, label, media, year] - -This setting should improve the autotagger results if the metadata with the -given tags match the metadata returned by MusicBrainz. - -Note that the only tags supported by this setting are the ones listed in the -above example. - -Default: ``[]`` - -.. _genres: - -genres -++++++ - -Use MusicBrainz genre tags to populate (and replace if it's already set) the -``genre`` tag. This will make it a list of all the genres tagged for the release -and the release-group on MusicBrainz, separated by "; " and sorted by the total -number of votes. Default: ``no`` - -.. _musicbrainz.external_ids: - -external_ids -++++++++++++ - -Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz -importer to look for links to related metadata sources. If such a link is -available the release ID will be extracted from the URL provided and imported to -the beets library - -.. code-block:: yaml - - musicbrainz: - external_ids: - discogs: yes - spotify: yes - bandcamp: yes - beatport: yes - deezer: yes - tidal: yes - -The library fields of the corresponding :ref:`autotagger_extensions` are used to -save the data (``discogs_albumid``, ``bandcamp_album_id``, ``spotify_album_id``, -``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports -existing data will be overwritten. - -The default of all options is ``no``. diff --git a/docs/plugins/shared_metadata_source_config.rst b/docs/plugins/shared_metadata_source_config.rst new file mode 100644 index 000000000..609c7afd2 --- /dev/null +++ b/docs/plugins/shared_metadata_source_config.rst @@ -0,0 +1,65 @@ +.. _data_source_mismatch_penalty: + +.. conf:: data_source_mismatch_penalty + :default: 0.5 + + Penalty applied when the data source of a + match candidate differs from the original source of your existing tracks. Any + decimal number between 0.0 and 1.0 + + This setting controls how much to penalize matches from different metadata + sources during import. The penalty is applied when beets detects that a match + candidate comes from a different data source than what appears to be the + original source of your music collection. + + **Example configurations:** + + .. code-block:: yaml + + # Prefer MusicBrainz over Discogs when sources don't match + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.3 # Lower penalty = preferred + discogs: + data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred + + .. code-block:: yaml + + # Do not penalise candidates from Discogs at all + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.5 + discogs: + data_source_mismatch_penalty: 0.0 + + .. code-block:: yaml + + # Disable cross-source penalties entirely + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.0 + discogs: + data_source_mismatch_penalty: 0.0 + + .. tip:: + + The last configuration is equivalent to setting: + + .. code-block:: yaml + + match: + distance_weights: + data_source: 0.0 # Disable data source matching + +.. conf:: source_weight + :default: 0.5 + + .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. + +.. conf:: search_limit + :default: 5 + + Maximum number of search results to return. diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index b72f22f20..f0d6ac2ef 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -73,8 +73,6 @@ Default .. code-block:: yaml spotify: - data_source_mismatch_penalty: 0.5 - search_limit: 5 mode: list region_filter: show_failures: no @@ -84,59 +82,67 @@ Default client_id: REDACTED client_secret: REDACTED tokenfile: spotify_token.json + data_source_mismatch_penalty: 0.5 + search_limit: 5 -- **mode**: One of the following: +.. conf:: mode + :default: list - - ``list``: Print out the playlist as a list of links. This list can then - be pasted in to a new or existing Spotify playlist. - - ``open``: This mode actually sends a link to your default browser with - instructions to open Spotify with the playlist you created. Until this - has been tested on all platforms, it will remain optional. + Controls how the playlist is output: - Default: ``list``. + - ``list``: Print out the playlist as a list of links. This list can then + be pasted in to a new or existing Spotify playlist. + - ``open``: This mode actually sends a link to your default browser with + instructions to open Spotify with the playlist you created. Until this + has been tested on all platforms, it will remain optional. -- **region_filter**: A two-character country abbreviation, to limit results to - that market. Default: None. -- **show_failures**: List each lookup that does not return a Spotify ID (and - therefore cannot be added to a playlist). Default: ``no``. -- **tiebreak**: How to choose the track if there is more than one identical - result. For example, there might be multiple releases of the same album. The - options are ``popularity`` and ``first`` (to just choose the first match - returned). Default: ``popularity``. -- **regex**: An array of regex transformations to perform on the - track/album/artist fields before sending them to Spotify. Can be useful for - changing certain abbreviations, like ft. -> feat. See the examples below. - Default: None. -- **search_query_ascii**: If set to ``yes``, the search query will be converted - to ASCII before being sent to Spotify. Converting searches to ASCII can - enhance search results in some cases, but in general, it is not recommended. - For instance ``artist:deadmau5 album:4×4`` will be converted to - ``artist:deadmau5 album:4x4`` (notice ``×!=x``). Default: ``no``. +.. conf:: region_filter + :default: -Here's an example: + A two-character country abbreviation, to limit results to that market. -:: +.. conf:: show_failures + :default: no - spotify: - data_source_mismatch_penalty: 0.7 - mode: open - region_filter: US - show_failures: on - tiebreak: first - search_query_ascii: no + List each lookup that does not return a Spotify ID (and therefore cannot be + added to a playlist). - regex: [ - { - field: "albumartist", # Field in the item object to regex. - search: "Something", # String to look for. - replace: "Replaced" # Replacement value. - }, - { - field: "title", - search: "Something Else", - replace: "AlsoReplaced" - } - ] +.. conf:: tiebreak + :default: popularity + + How to choose the candidate if there is more than one identical result. For + example, there might be multiple releases of the same album. + + - ``popularity``: pick the more popular candidate + - ``first``: pick the first candidate + +.. conf:: regex + :default: [] + + An array of regex transformations to perform on the track/album/artist fields + before sending them to Spotify. Can be useful for changing certain + abbreviations, like ft. -> feat. For example: + + .. code-block:: yaml + + regex: + - field: albumartist + search: Something + replace: Replaced + - field: title + search: Something Else + replace: AlsoReplaced + +.. conf:: search_query_ascii + :default: no + + If enabled, the search query will be converted to ASCII before being sent to + Spotify. Converting searches to ASCII can enhance search results in some + cases, but in general, it is not recommended. For instance, + ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 + album:4x4`` (notice ``×!=x``). + +.. include:: ./shared_metadata_source_config.rst Obtaining Track Popularity and Audio Features from Spotify ---------------------------------------------------------- From e87235117037deb3d958172940f95d6e955d8f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 12 Oct 2025 00:20:46 +0100 Subject: [PATCH 275/301] Add references to configuration values in the changelog --- docs/changelog.rst | 82 +++++++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a8fc539b..669f1eb50 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -56,12 +56,13 @@ New features: without storing or writing them. - :doc:`plugins/convert`: Add a config option to disable writing metadata to converted files. -- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle - stripping discogs numeric disambiguation on artist and label fields. +- :doc:`plugins/discogs`: New config option + :conf:`plugins.discogs:strip_disambiguation` to toggle stripping discogs + numeric disambiguation on artist and label fields. - :doc:`plugins/discogs` Added support for featured artists. :bug:`6038` -- :doc:`plugins/discogs` New configuration option `featured_string` to change - the default string used to join featured artists. The default string is - `Feat.`. +- :doc:`plugins/discogs` New configuration option + :conf:`plugins.discogs:featured_string` to change the default string used to + join featured artists. The default string is `Feat.`. - :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags. :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to @@ -89,9 +90,10 @@ Bug fixes: - :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor regexps, allow for more cases, add some logging), add tests. - Metadata source plugins: Fixed data source penalty calculation that was - incorrectly applied during import matching. The ``source_weight`` - configuration option has been renamed to ``data_source_mismatch_penalty`` to - better reflect its purpose. :bug:`6066` + incorrectly applied during import matching. The + :conf:`plugins.index:source_weight` configuration option has been renamed to + :conf:`plugins.index:data_source_mismatch_penalty` to better reflect its + purpose. :bug:`6066` Other changes: @@ -137,12 +139,13 @@ New features: separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``, but if you've customized your ``plugins`` list in your configuration, you'll need to explicitly add ``musicbrainz`` to continue using this functionality. - Configuration option ``musicbrainz.enabled`` has thus been deprecated. - :bug:`2686` :bug:`4605` + Configuration option :conf:`plugins.musicbrainz:enabled` has thus been + deprecated. :bug:`2686` :bug:`4605` - :doc:`plugins/web`: Show notifications when a track plays. This uses the Media Session API to customize media notifications. -- :doc:`plugins/discogs`: Add configurable ``search_limit`` option to limit the - number of results returned by the Discogs metadata search queries. +- :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit` + option to limit the number of results returned by the Discogs metadata search + queries. - :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving singletons by their Discogs ID. :bug:`4661` - :doc:`plugins/replace`: Add new plugin. @@ -157,12 +160,13 @@ New features: be played for it to be counted as played instead of skipped. - :doc:`plugins/web`: Display artist and album as part of the search results. - :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option - ``search_limit`` to limit the number of results returned by search queries. + :conf:`plugins.index:search_limit` to limit the number of results returned by + search queries. Bug fixes: - :doc:`plugins/musicbrainz`: fix regression where user configured - ``extra_tags`` have been read incorrectly. :bug:`5788` + :conf:`plugins.musicbrainz:extra_tags` have been read incorrectly. :bug:`5788` - tests: Fix library tests failing on Windows when run from outside ``D:/``. :bug:`5802` - Fix an issue where calling ``Library.add`` would cause the ``database_change`` @@ -194,9 +198,10 @@ Bug fixes: For packagers: -- Optional ``extra_tags`` parameter has been removed from - ``BeetsPlugin.candidates`` method signature since it is never passed in. If - you override this method in your plugin, feel free to remove this parameter. +- Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed + from ``BeetsPlugin.candidates`` method signature since it is never passed in. + If you override this method in your plugin, feel free to remove this + parameter. - Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every python version. @@ -552,8 +557,9 @@ New features: :bug:`4348` - Create the parental directories for database if they do not exist. :bug:`3808` :bug:`4327` -- :ref:`musicbrainz-config`: a new :ref:`musicbrainz.enabled` option allows - disabling the MusicBrainz metadata source during the autotagging process +- :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option + allows disabling the MusicBrainz metadata source during the autotagging + process - :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101` - Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``. @@ -586,8 +592,8 @@ New features: :bug:`4561` :bug:`4600` - :ref:`musicbrainz-config`: MusicBrainz release pages often link to related metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When - enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be - extracted from those URL's and imported to the library. :bug:`4220` + enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's + will be extracted from those URL's and imported to the library. :bug:`4220` - :doc:`/plugins/convert`: Add support for generating m3u8 playlists together with converted media files. :bug:`4373` - Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809` @@ -941,8 +947,9 @@ Other new things: - ``beet remove`` now also allows interactive selection of items from the query, similar to ``beet modify``. -- Enable HTTPS for MusicBrainz by default and add configuration option ``https`` - for custom servers. See :ref:`musicbrainz-config` for more details. +- Enable HTTPS for MusicBrainz by default and add configuration option + :conf:`plugins.musicbrainz:https` for custom servers. See + :ref:`musicbrainz-config` for more details. - :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the right local path from MPD information. - :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on @@ -962,8 +969,8 @@ Other new things: server. - :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between token- and password-based authentication based on the server version. -- A new :ref:`extra_tags` configuration option lets you use more metadata in - MusicBrainz queries to further narrow the search. +- A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use + more metadata in MusicBrainz queries to further narrow the search. - A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets. - :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality`` option that controls the quality of the image output when the image is @@ -1017,9 +1024,9 @@ Other new things: (and now deprecated) separate ``host``, ``port``, and ``contextpath`` config options. As a consequence, the plugin can now talk to Subsonic over HTTPS. Thanks to :user:`jef`. :bug:`3449` -- :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation - of work names and intra-work divisions into imported track titles. Thanks to - :user:`cole-miller`. :bug:`3459` +- :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option + enables incorporation of work names and intra-work divisions into imported + track titles. Thanks to :user:`cole-miller`. :bug:`3459` - :doc:`/plugins/web`: The query API now interprets backslashes as path separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567` - ``beet import`` now handles tar archives with bzip2 or gzip compression. @@ -1033,9 +1040,9 @@ Other new things: :user:`logan-arens`. :bug:`2947` - There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins to load. -- A new :ref:`genres` option fetches genre information from MusicBrainz. This - functionality depends on functionality that is currently unreleased in the - python-musicbrainzngs_ library: see PR `#266 +- A new :conf:`plugins.musicbrainz:genres` option fetches genre information from + MusicBrainz. This functionality depends on functionality that is currently + unreleased in the python-musicbrainzngs_ library: see PR `#266 <https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to :user:`aereaux`. - :doc:`/plugins/replaygain`: Analysis now happens in parallel using the @@ -1075,9 +1082,10 @@ Fixes: :bug:`3867` - :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be redacted even when ``include_paths`` option is set. :bug:`3866` -- :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that - sometimes caused the index to be discarded. Also, remove the extra semicolon - that was added when there is no index track. +- :doc:`/plugins/discogs`: Fixed a bug with the + :conf:`plugins.discogs:index_tracks` option that sometimes caused the index to + be discarded. Also, remove the extra semicolon that was added when there is no + index track. - :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method rather the ``GET`` method. Also includes better exception handling, response parsing, and tests. @@ -2693,9 +2701,9 @@ Major new features and bigger changes: analysis tool. Thanks to :user:`jmwatte`. :bug:`1343` - A new ``filesize`` field on items indicates the number of bytes in the file. :bug:`1291` -- A new :ref:`search_limit` configuration option allows you to specify how many - search results you wish to see when looking up releases at MusicBrainz during - import. :bug:`1245` +- A new :conf:`plugins.index:search_limit` configuration option allows you to + specify how many search results you wish to see when looking up releases at + MusicBrainz during import. :bug:`1245` - The importer now records the data source for a match in a new flexible attribute ``data_source`` on items and albums. :bug:`1311` - The colors used in the terminal interface are now configurable via the new From 861504d5f6068896f0d9ef120619475334b8fa8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 12 Oct 2025 00:28:44 +0100 Subject: [PATCH 276/301] Make sure conf references are converted properly in release notes --- extra/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/release.py b/extra/release.py index d4ebb950f..e16814960 100755 --- a/extra/release.py +++ b/extra/release.py @@ -120,7 +120,7 @@ def create_rst_replacements() -> list[Replacement]: # Replace Sphinx directives by documentation URLs, e.g., # :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html) ( - r":(?:ref|doc|class):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+", + r":(?:ref|doc|class|conf):`+(?:([^`<]+)<)?/?([\w.:/_-]+)>?`+", lambda m: make_ref_link(m[2], m[1]), ), # Convert command references to documentation URLs From 9519d47d57e35291d2956e761441baaf49876fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 12 Oct 2025 18:39:37 +0100 Subject: [PATCH 277/301] Convert Python 2 URLs to Python 3 --- docs/dev/plugins/other/logging.rst | 2 +- docs/plugins/export.rst | 2 +- docs/plugins/play.rst | 2 +- docs/reference/config.rst | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/dev/plugins/other/logging.rst b/docs/dev/plugins/other/logging.rst index 1c4ce4838..a26f0c4c0 100644 --- a/docs/dev/plugins/other/logging.rst +++ b/docs/dev/plugins/other/logging.rst @@ -13,7 +13,7 @@ str.format-style string formatting. So you can write logging calls like this: .. _pep 3101: https://www.python.org/dev/peps/pep-3101/ -.. _standard python logging module: https://docs.python.org/2/library/logging.html +.. _standard python logging module: https://docs.python.org/3/library/logging.html When beets is in verbose mode, plugin messages are prefixed with the plugin name to make them easier to see. diff --git a/docs/plugins/export.rst b/docs/plugins/export.rst index a5fa78617..b8e14ef22 100644 --- a/docs/plugins/export.rst +++ b/docs/plugins/export.rst @@ -70,7 +70,7 @@ These options match the options from the `Python csv module`_. .. _python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params -.. _python json module: https://docs.python.org/2/library/json.html#basic-usage +.. _python json module: https://docs.python.org/3/library/json.html#basic-usage The default options look like this: diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 2bc825773..f4b07ac52 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -123,4 +123,4 @@ until they are externally wiped could be an issue for privacy or storage reasons. If this is the case for you, you might want to use the ``raw`` config option described above. -.. _tempfile.tempdir: https://docs.python.org/2/library/tempfile.html#tempfile.tempdir +.. _tempfile.tempdir: https://docs.python.org/3/library/tempfile.html#tempfile.tempdir diff --git a/docs/reference/config.rst b/docs/reference/config.rst index eae9deb21..b4874416c 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -376,7 +376,7 @@ terminal_encoding ~~~~~~~~~~~~~~~~~ The text encoding, as `known to Python -<https://docs.python.org/2/library/codecs.html#standard-encodings>`__, to use +<https://docs.python.org/3/library/codecs.html#standard-encodings>`__, to use for messages printed to the standard output. It's also used to read messages from the standard input. By default, this is determined automatically from the locale environment variables. From d83402fc65e9eef8b6230fd08208a1e8d8dd36fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 19 Oct 2025 01:46:32 +0100 Subject: [PATCH 278/301] Add a changelog note --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 669f1eb50..0fc0ee477 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,6 +46,10 @@ Other changes: - :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed sections and dropdown menus. Installation instructions have been streamlined, and a new subpage now provides additional setup details. +- Documentation: introduced a new role ``conf`` for documenting configuration + options. This role provides consistent formatting and creates references + automatically. Applied it to :doc:`plugins/deezer`, :doc:`plugins/discogs`, + :doc:`plugins/musicbrainz` and :doc:`plugins/spotify` plugins documentation. 2.5.0 (October 11, 2025) ------------------------ From e61ecb449675c766f920d04a26a32af06e2e3fb1 Mon Sep 17 00:00:00 2001 From: Martin Atukunda <matlads@gmail.com> Date: Fri, 10 Oct 2025 08:35:56 +0300 Subject: [PATCH 279/301] fix(github/workflows): update to checkout v5, and setup-python v6. * also run ci against python 3.13, which is default in debian trixie. --- .github/workflows/changelog_reminder.yaml | 2 +- .github/workflows/ci.yaml | 10 +++++----- .github/workflows/integration_test.yaml | 4 ++-- .github/workflows/lint.yml | 18 +++++++++--------- .github/workflows/make_release.yaml | 12 ++++++------ 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/changelog_reminder.yaml b/.github/workflows/changelog_reminder.yaml index a9c26c1f5..380d89996 100644 --- a/.github/workflows/changelog_reminder.yaml +++ b/.github/workflows/changelog_reminder.yaml @@ -10,7 +10,7 @@ jobs: check_changes: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get all updated Python files id: changed-python-files diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80826f468..f1623e8a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,17 +20,17 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - name: Setup Python with poetry caching # poetry cache requires poetry to already be installed, weirdly - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: poetry @@ -90,10 +90,10 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get the coverage report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: coverage-report diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index f88864c48..8c7e44d7a 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -7,10 +7,10 @@ jobs: test_integration: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.9 cache: poetry diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8fdfa94e5..dcc5d0f12 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,7 +24,7 @@ jobs: changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }} changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get changed docs files id: changed-doc-files uses: tj-actions/changed-files@v46 @@ -56,10 +56,10 @@ jobs: name: Check formatting needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -77,10 +77,10 @@ jobs: name: Check linting needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -97,10 +97,10 @@ jobs: name: Check types with mypy needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -120,10 +120,10 @@ jobs: name: Check docs needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index b18dded8d..5a8abe5bb 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -17,10 +17,10 @@ jobs: name: Bump version, commit and create tag runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -45,13 +45,13 @@ jobs: outputs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ env.NEW_TAG }} - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -92,7 +92,7 @@ jobs: id-token: write steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ @@ -107,7 +107,7 @@ jobs: CHANGELOG: ${{ needs.build.outputs.changelog }} steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ From 3ccc91d4d478d6c2625babdff9d3c5e11146a822 Mon Sep 17 00:00:00 2001 From: Martin Atukunda <matlads@gmail.com> Date: Thu, 16 Oct 2025 09:47:59 +0300 Subject: [PATCH 280/301] Drop 3.13 from python-version for now. --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f1623e8a5..fa6e9a7be 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} From 1275ccf8c1e6fcd54217ee82059fb493ee8b9129 Mon Sep 17 00:00:00 2001 From: cvx35isl <127420554+cvx35isl@users.noreply.github.com> Date: Sun, 19 Oct 2025 08:38:20 +0200 Subject: [PATCH 281/301] =?UTF-8?q?play=20plugin:=20$playlist=20marker=20f?= =?UTF-8?q?or=20precise=20control=20where=20the=20playlist=20=E2=80=A6=20(?= =?UTF-8?q?#4728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …file is placed in the command ## Description see included doc; placing the playlist filename at the end of command just isn't working for all players I have this in use with `mpv` Co-authored-by: cvx35isl <cvx35isl@users.noreply.github.com> Co-authored-by: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> --- beetsplug/play.py | 21 +++++++++++++++++++++ docs/changelog.rst | 4 ++++ docs/plugins/play.rst | 9 +++++++++ test/plugins/test_play.py | 13 +++++++++++++ 4 files changed, 47 insertions(+) diff --git a/beetsplug/play.py b/beetsplug/play.py index 35b4b1f76..8fb146213 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -28,6 +28,11 @@ from beets.util import get_temp_filename # If this is missing, they're placed at the end. ARGS_MARKER = "$args" +# Indicate where the playlist file (with absolute path) should be inserted into +# the command string. If this is missing, its placed at the end, but before +# arguments. +PLS_MARKER = "$playlist" + def play( command_str, @@ -132,8 +137,23 @@ class PlayPlugin(BeetsPlugin): return open_args = self._playlist_or_paths(paths) + open_args_str = [ + p.decode("utf-8") for p in self._playlist_or_paths(paths) + ] command_str = self._command_str(opts.args) + if PLS_MARKER in command_str: + if not config["play"]["raw"]: + command_str = command_str.replace( + PLS_MARKER, "".join(open_args_str) + ) + self._log.debug( + "command altered by PLS_MARKER to: {}", command_str + ) + open_args = [] + else: + command_str = command_str.replace(PLS_MARKER, " ") + # Check if the selection exceeds configured threshold. If True, # cancel, otherwise proceed with play command. if opts.yes or not self._exceeds_threshold( @@ -162,6 +182,7 @@ class PlayPlugin(BeetsPlugin): return paths else: return [self._create_tmp_playlist(paths)] + return [shlex.quote(self._create_tmp_playlist(paths))] def _exceeds_threshold( self, selection, command_str, open_args, item_type="track" diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fc0ee477..5c6224de9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ Unreleased New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. +- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist + filepath into the command calling the player program. Bug fixes: @@ -71,6 +73,8 @@ New features: :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to specify where the variations are written. :bug:`3354` +- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist + filepath into the command calling the player program. Bug fixes: diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index f4b07ac52..f06eb4cb3 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -107,6 +107,15 @@ string, use ``$args`` to indicate where to insert them. For example: indicates that you need to insert extra arguments before specifying the playlist. +Some players require a different syntax. For example, with ``mpv`` the optional +``$playlist`` variable can be used to match the syntax of the ``--playlist`` +option: + +:: + + play: + command: mpv $args --playlist=$playlist + The ``--yes`` (or ``-y``) flag to the ``play`` command will skip the warning message if you choose to play more items than the **warning_threshold** value usually allows. diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index 293a50a20..b184db63f 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -105,6 +105,19 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): open_mock.assert_called_once_with([self.item.path], "echo") + def test_pls_marker(self, open_mock): + self.config["play"]["command"] = ( + "echo --some params --playlist=$playlist --some-more params" + ) + + self.run_command("play", "nice") + + open_mock.assert_called_once + + commandstr = open_mock.call_args_list[0][0][1] + assert commandstr.startswith("echo --some params --playlist=") + assert commandstr.endswith(" --some-more params") + def test_not_found(self, open_mock): self.run_command("play", "not found") From 39aadf709932a2a5ad2e9f69378a09b50fe9b78c Mon Sep 17 00:00:00 2001 From: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> Date: Sun, 19 Oct 2025 08:50:25 +0200 Subject: [PATCH 282/301] Remove duplicate changelog entry (play plugin) --- docs/changelog.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5c6224de9..449ca6dd3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,8 +73,6 @@ New features: :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to specify where the variations are written. :bug:`3354` -- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist - filepath into the command calling the player program. Bug fixes: From 8a24518c4c0bdbcde5d60e35599d742274752904 Mon Sep 17 00:00:00 2001 From: Konstantin <78656278+amogus07@users.noreply.github.com> Date: Sun, 19 Oct 2025 10:06:16 +0200 Subject: [PATCH 283/301] use `Generic` instead of `Any` for `cached_classproperty` --- beets/util/__init__.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 0f2ef5b97..fc05e4997 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -47,6 +47,7 @@ from typing import ( NamedTuple, TypeVar, Union, + cast, ) from unidecode import unidecode @@ -1052,7 +1053,7 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None: pool.join() -class cached_classproperty: +class cached_classproperty(Generic[T]): """Descriptor implementing cached class properties. Provides class-level dynamic property behavior where the getter function is @@ -1060,9 +1061,9 @@ class cached_classproperty: instance properties, this operates on the class rather than instances. """ - cache: ClassVar[dict[tuple[Any, str], Any]] = {} + cache: ClassVar[dict[tuple[type[object], str], object]] = {} - name: str + name: str = "" # Ideally, we would like to use `Callable[[type[T]], Any]` here, # however, `mypy` is unable to see this as a **class** property, and thinks @@ -1078,21 +1079,21 @@ class cached_classproperty: # "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]" # # Therefore, we just use `Any` here, which is not ideal, but works. - def __init__(self, getter: Callable[[Any], Any]) -> None: + def __init__(self, getter: Callable[..., T]) -> None: """Initialize the descriptor with the property getter function.""" - self.getter = getter + self.getter: Callable[..., T] = getter - def __set_name__(self, owner: Any, name: str) -> None: + def __set_name__(self, owner: object, name: str) -> None: """Capture the attribute name this descriptor is assigned to.""" self.name = name - def __get__(self, instance: Any, owner: type[Any]) -> Any: + def __get__(self, instance: object, owner: type[object]) -> T: """Compute and cache if needed, and return the property value.""" - key = owner, self.name + key: tuple[type[object], str] = owner, self.name if key not in self.cache: self.cache[key] = self.getter(owner) - return self.cache[key] + return cast(T, self.cache[key]) class LazySharedInstance(Generic[T]): From d7138062639ed237f0ce92dfcead9459f292efbb Mon Sep 17 00:00:00 2001 From: Konstantin <78656278+amogus07@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:07:17 +0200 Subject: [PATCH 284/301] fix transaction context manager signature --- .gitignore | 3 +++ beets/dbcore/db.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 90ef7387d..138965b22 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ ENV/ # pyright pyrightconfig.json + +# Pyrefly +pyrefly.toml diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 192cfac70..4bcc8e9c1 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -940,10 +940,10 @@ class Transaction: def __exit__( self, - exc_type: type[Exception], - exc_value: Exception, - traceback: TracebackType, - ): + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: """Complete a transaction. This must be the most recently entered but not yet exited transaction. If it is the last active transaction, the database updates are committed. From 12f2a1f6943d65487c02bf29d0fb129456176bad Mon Sep 17 00:00:00 2001 From: Konstantin <78656278+amogus07@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:12:27 +0200 Subject: [PATCH 285/301] fix mypy error --- beets/dbcore/db.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 4bcc8e9c1..afae6e906 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -965,6 +965,8 @@ class Transaction: ): raise DBCustomFunctionError() + return None + def query( self, statement: str, subvals: Sequence[SQLiteType] = () ) -> list[sqlite3.Row]: From 027b775fcd802e3011366434812353ed5d2bdee6 Mon Sep 17 00:00:00 2001 From: Jacob Danell <jacob@emberlight.se> Date: Mon, 20 Oct 2025 15:22:27 +0200 Subject: [PATCH 286/301] Change arg name --- beetsplug/ftintitle.py | 24 +++++++++--------------- docs/plugins/ftintitle.rst | 2 +- test/plugins/test_ftintitle.py | 10 +++++----- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index c10fdada5..dd681a972 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -108,7 +108,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "drop": False, "format": "feat. {}", "keep_in_artist": False, - "skip_if_artist_and_album_artists_is_the_same": True, + "preserve_album_artist": True, "custom_words": [], } ) @@ -134,9 +134,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin): self.config.set_args(opts) drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) - skip_if_artist_and_album_artists_is_the_same = self.config[ - "skip_if_artist_and_album_artists_is_the_same" - ].get(bool) + preserve_album_artist = self.config["preserve_album_artist"].get( + bool + ) custom_words = self.config["custom_words"].get(list) write = ui.should_write() @@ -145,7 +145,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item, drop_feat, keep_in_artist_field, - skip_if_artist_and_album_artists_is_the_same, + preserve_album_artist, custom_words, ): item.store() @@ -159,9 +159,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): """Import hook for moving featuring artist automatically.""" drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) - skip_if_artist_and_album_artists_is_the_same = self.config[ - "skip_if_artist_and_album_artists_is_the_same" - ].get(bool) + preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) for item in task.imported_items(): @@ -169,7 +167,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item, drop_feat, keep_in_artist_field, - skip_if_artist_and_album_artists_is_the_same, + preserve_album_artist, custom_words, ): item.store() @@ -219,7 +217,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item: Item, drop_feat: bool, keep_in_artist_field: bool, - skip_if_artist_and_album_artists_is_the_same: bool, + preserve_album_artist: bool, custom_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move @@ -234,11 +232,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # Check whether there is a featured artist on this track and the # artist field does not exactly match the album artist field. In # that case, we attempt to move the featured artist to the title. - if ( - skip_if_artist_and_album_artists_is_the_same - and albumartist - and artist == albumartist - ): + if preserve_album_artist and albumartist and artist == albumartist: return False _, featured = split_on_feat(artist, custom_words=custom_words) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 3b5d3ca85..b0e69af88 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,7 +28,7 @@ file. The available options are: - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. -- **skip_if_artist_and_album_artists_is_the_same**: If the artist and the album +- **preserve_album_artist**: If the artist and the album artist are the same, skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 9fc771e89..56c82b9d2 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -205,11 +205,11 @@ def add_item( ("Alice med Bob", "Song 1"), id="custom-feat-words-keep-in-artists-drop-from-title", ), - # ---- skip_if_artist_and_album_artists_is_the_same variants ---- + # ---- preserve_album_artist variants ---- pytest.param( { "format": "feat. {}", - "skip_if_artist_and_album_artists_is_the_same": True, + "preserve_album_artist": True, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice"), @@ -219,7 +219,7 @@ def add_item( pytest.param( { "format": "feat. {}", - "skip_if_artist_and_album_artists_is_the_same": False, + "preserve_album_artist": False, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice"), @@ -229,7 +229,7 @@ def add_item( pytest.param( { "format": "feat. {}", - "skip_if_artist_and_album_artists_is_the_same": True, + "preserve_album_artist": True, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), @@ -239,7 +239,7 @@ def add_item( pytest.param( { "format": "feat. {}", - "skip_if_artist_and_album_artists_is_the_same": False, + "preserve_album_artist": False, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), From bb541e22c3d0c7d736066fa71b8ef2cf67a8e830 Mon Sep 17 00:00:00 2001 From: Jacob Danell <jacob@emberlight.se> Date: Mon, 20 Oct 2025 15:28:33 +0200 Subject: [PATCH 287/301] Lint the docs --- docs/plugins/ftintitle.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index b0e69af88..1d2ec5c20 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,8 +28,8 @@ file. The available options are: - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. -- **preserve_album_artist**: If the artist and the album - artist are the same, skip the ftintitle processing. Default: ``yes``. +- **preserve_album_artist**: If the artist and the album artist are the same, + skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. From a8204f8cde013f705c9139bc1d232344e8136835 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 13 Sep 2025 13:25:57 +0200 Subject: [PATCH 288/301] lastgenre: -vvv tuning log helper, remove -d Replace extended_debug config and CLI option with -vvv and add a helper function. --- beetsplug/lastgenre/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 902cef9ef..3b04e65d6 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -106,7 +106,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): "separator": ", ", "prefer_specific": False, "title_case": True, - "extended_debug": False, "pretend": False, } ) @@ -162,6 +161,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize + def _tunelog(self, msg, *args, **kwargs): + """Log tuning messages at DEBUG level when verbosity level is high enough.""" + if config["verbose"].as_number() >= 3: + self._log.debug(msg, *args, **kwargs) + @property def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', @@ -293,8 +297,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): self._genre_cache[key] = self.fetch_genre(method(*args)) genre = self._genre_cache[key] - if self.config["extended_debug"]: - self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre) + self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) return genre def fetch_album_genre(self, obj): @@ -554,13 +557,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): dest="album", help="match albums instead of items (default)", ) - lastgenre_cmd.parser.add_option( - "-d", - "--debug", - action="store_true", - dest="extended_debug", - help="extended last.fm debug logging", - ) lastgenre_cmd.parser.set_defaults(album=True) def lastgenre_func(lib, opts, args): From bf507cd5d4865fa2011612f745461a65e9d62e6f Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 23 Oct 2025 07:55:43 +0200 Subject: [PATCH 289/301] Changelog for lastgenre tuning log #6007 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index cce30a284..8de4eb385 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,10 @@ New features: album artist are the same in ftintitle. - :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist filepath into the command calling the player program. +- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed + to receive extra verbose logging around last.fm results and how they are + resolved. The ``extended_debug`` config setting and ``--debug`` option + have been removed. Bug fixes: From 4b1e5056d57b97682d8595d7cbf7a7cb9f4ecd30 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 23 Oct 2025 18:42:23 +0200 Subject: [PATCH 290/301] lastgenre: Document tuning log -vvv --- docs/plugins/lastgenre.rst | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 230694b06..ace7caaf0 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -197,11 +197,6 @@ file. The available options are: internal whitelist, or ``no`` to consider all genres valid. Default: ``yes``. - **title_case**: Convert the new tags to TitleCase before saving. Default: ``yes``. -- **extended_debug**: Add additional debug logging messages that show what - last.fm tags were fetched for tracks, albums and artists. This is done before - any canonicalization and whitelist filtering is applied. It's useful for - tuning the plugin's settings and understanding how it works, but it can be - quite verbose. Default: ``no``. Running Manually ---------------- @@ -219,3 +214,13 @@ or store any changes. To disable automatic genre fetching on import, set the ``auto`` config option to false. + +Tuning Logs +----------- + +To enable tuning logs, run ``beet -vvv lastgenre ...`` or ``beet -vvv import +...``. This enables additional messages at the ``DEBUG`` log level, showing for +example what data was received from last.fm at each stage of genre fetching +(artist, album, and track levels) before any canonicalization or whitelist +filtering is applied. Tuning logs are useful for adjusting the plugin’s settings +and understanding its behavior, though they can be quite verbose. From 1ea3879aae171285274e9c6bdb30f4613a4cde6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 26 Oct 2025 20:00:52 +0000 Subject: [PATCH 291/301] Upgrade librosa and audioread --- .github/workflows/ci.yaml | 14 +- poetry.lock | 555 ++++++++++++++++++++++++++++++-------- pyproject.toml | 16 +- 3 files changed, 473 insertions(+), 112 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa6e9a7be..8c1530bad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} @@ -39,7 +39,17 @@ jobs: if: matrix.platform == 'ubuntu-latest' run: | sudo apt update - sudo apt install --yes --no-install-recommends ffmpeg gobject-introspection gstreamer1.0-plugins-base python3-gst-1.0 libcairo2-dev libgirepository-2.0-dev pandoc imagemagick + sudo apt install --yes --no-install-recommends \ + ffmpeg \ + gobject-introspection \ + gstreamer1.0-plugins-base \ + python3-gst-1.0 \ + libcairo2-dev \ + libgirepository-2.0-dev \ + libopenblas-dev \ + llvm-20-dev \ + pandoc \ + imagemagick - name: Get changed lyrics files id: lyrics-update diff --git a/poetry.lock b/poetry.lock index 615598d67..813ef6466 100644 --- a/poetry.lock +++ b/poetry.lock @@ -63,18 +63,82 @@ files = [ ] [[package]] -name = "audioread" -version = "3.0.1" -description = "Multi-library, cross-platform audio decoding." +name = "audioop-lts" +version = "0.2.2" +description = "LTS Port of Python audioop" optional = true -python-versions = ">=3.6" +python-versions = ">=3.13" files = [ - {file = "audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33"}, - {file = "audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd"}, + {file = "audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0"}, ] +[[package]] +name = "audioread" +version = "3.1.0" +description = "Multi-library, cross-platform audio decoding." +optional = true +python-versions = ">=3.9" +files = [ + {file = "audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4"}, + {file = "audioread-3.1.0.tar.gz", hash = "sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190"}, +] + +[package.dependencies] +standard-aifc = {version = "*", markers = "python_version >= \"3.13\""} +standard-sunau = {version = "*", markers = "python_version >= \"3.13\""} + [package.extras] -test = ["tox"] +gi = ["pygobject (>=3.54.2,<4.0.0)"] +mad = ["pymad[mad] (>=0.11.3,<0.12.0)"] +test = ["pytest (>=8.4.2)", "pytest-cov (>=7.0.0)"] [[package]] name = "babel" @@ -1096,13 +1160,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "joblib" -version = "1.5.1" +version = "1.5.2" description = "Lightweight pipelining with Python functions" optional = true python-versions = ">=3.9" files = [ - {file = "joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a"}, - {file = "joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444"}, + {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, + {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, ] [[package]] @@ -1281,33 +1345,35 @@ typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [[package]] name = "librosa" -version = "0.10.2.post1" +version = "0.11.0" description = "Python module for audio and music processing" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "librosa-0.10.2.post1-py3-none-any.whl", hash = "sha256:dc882750e8b577a63039f25661b7e39ec4cfbacc99c1cffba666cd664fb0a7a0"}, - {file = "librosa-0.10.2.post1.tar.gz", hash = "sha256:cd99f16717cbcd1e0983e37308d1db46a6f7dfc2e396e5a9e61e6821e44bd2e7"}, + {file = "librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1"}, + {file = "librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908"}, ] [package.dependencies] audioread = ">=2.1.9" decorator = ">=4.3.0" -joblib = ">=0.14" -lazy-loader = ">=0.1" +joblib = ">=1.0" +lazy_loader = ">=0.1" msgpack = ">=1.0" numba = ">=0.51.0" -numpy = ">=1.20.3,<1.22.0 || >1.22.0,<1.22.1 || >1.22.1,<1.22.2 || >1.22.2" +numpy = ">=1.22.3" pooch = ">=1.1" -scikit-learn = ">=0.20.0" -scipy = ">=1.2.0" +scikit-learn = ">=1.1.0" +scipy = ">=1.6.0" soundfile = ">=0.12.1" soxr = ">=0.3.2" -typing-extensions = ">=4.1.1" +standard-aifc = {version = "*", markers = "python_version >= \"3.13\""} +standard-sunau = {version = "*", markers = "python_version >= \"3.13\""} +typing_extensions = ">=4.1.1" [package.extras] display = ["matplotlib (>=3.5.0)"] -docs = ["ipython (>=7.0)", "matplotlib (>=3.5.0)", "mir-eval (>=0.5)", "numba (>=0.51)", "numpydoc", "presets", "sphinx (!=1.3.1)", "sphinx-copybutton (>=0.5.2)", "sphinx-gallery (>=0.7)", "sphinx-multiversion (>=0.2.3)", "sphinx-rtd-theme (>=1.2.0)", "sphinxcontrib-svg2pdfconverter"] +docs = ["ipython (>=7.0)", "matplotlib (>=3.5.0)", "mir_eval (>=0.5)", "numba (>=0.51)", "numpydoc", "presets", "sphinx (!=1.3.1)", "sphinx-copybutton (>=0.5.2)", "sphinx-gallery (>=0.7)", "sphinx-multiversion (>=0.2.3)", "sphinx_rtd_theme (>=1.2.0)", "sphinxcontrib-googleanalytics (>=0.4)", "sphinxcontrib-svg2pdfconverter"] tests = ["matplotlib (>=3.5.0)", "packaging (>=20.0)", "pytest", "pytest-cov", "pytest-mpl", "resampy (>=0.2.2)", "samplerate", "types-decorator"] [[package]] @@ -1340,6 +1406,36 @@ files = [ {file = "llvmlite-0.43.0.tar.gz", hash = "sha256:ae2b5b5c3ef67354824fb75517c8db5fbe93bc02cd9671f3c62271626bc041d5"}, ] +[[package]] +name = "llvmlite" +version = "0.45.1" +description = "lightweight wrapper around basic LLVM functionality" +optional = true +python-versions = ">=3.10" +files = [ + {file = "llvmlite-0.45.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:1b1af0c910af0978aa55fa4f60bbb3e9f39b41e97c2a6d94d199897be62ba07a"}, + {file = "llvmlite-0.45.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02a164db2d79088bbd6e0d9633b4fe4021d6379d7e4ac7cc85ed5f44b06a30c5"}, + {file = "llvmlite-0.45.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2d47f34e4029e6df3395de34cc1c66440a8d72712993a6e6168db228686711b"}, + {file = "llvmlite-0.45.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7319e5f9f90720578a7f56fbc805bdfb4bc071b507c7611f170d631c3c0f1e0"}, + {file = "llvmlite-0.45.1-cp310-cp310-win_amd64.whl", hash = "sha256:4edb62e685867799e336723cb9787ec6598d51d0b1ed9af0f38e692aa757e898"}, + {file = "llvmlite-0.45.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:60f92868d5d3af30b4239b50e1717cb4e4e54f6ac1c361a27903b318d0f07f42"}, + {file = "llvmlite-0.45.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98baab513e19beb210f1ef39066288784839a44cd504e24fff5d17f1b3cf0860"}, + {file = "llvmlite-0.45.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3adc2355694d6a6fbcc024d59bb756677e7de506037c878022d7b877e7613a36"}, + {file = "llvmlite-0.45.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f3377a6db40f563058c9515dedcc8a3e562d8693a106a28f2ddccf2c8fcf6ca"}, + {file = "llvmlite-0.45.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9c272682d91e0d57f2a76c6d9ebdfccc603a01828cdbe3d15273bdca0c3363a"}, + {file = "llvmlite-0.45.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e"}, + {file = "llvmlite-0.45.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1a53f4b74ee9fd30cb3d27d904dadece67a7575198bd80e687ee76474620735f"}, + {file = "llvmlite-0.45.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b3796b1b1e1c14dcae34285d2f4ea488402fbd2c400ccf7137603ca3800864f"}, + {file = "llvmlite-0.45.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:779e2f2ceefef0f4368548685f0b4adde34e5f4b457e90391f570a10b348d433"}, + {file = "llvmlite-0.45.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e6c9949baf25d9aa9cd7cf0f6d011b9ca660dd17f5ba2b23bdbdb77cc86b116"}, + {file = "llvmlite-0.45.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:d9ea9e6f17569a4253515cc01dade70aba536476e3d750b2e18d81d7e670eb15"}, + {file = "llvmlite-0.45.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c9f3cadee1630ce4ac18ea38adebf2a4f57a89bd2740ce83746876797f6e0bfb"}, + {file = "llvmlite-0.45.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:57c48bf2e1083eedbc9406fb83c4e6483017879714916fe8be8a72a9672c995a"}, + {file = "llvmlite-0.45.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aa3dfceda4219ae39cf18806c60eeb518c1680ff834b8b311bd784160b9ce40"}, + {file = "llvmlite-0.45.1-cp313-cp313-win_amd64.whl", hash = "sha256:080e6f8d0778a8239cd47686d402cb66eb165e421efa9391366a9b7e5810a38b"}, + {file = "llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32"}, +] + [[package]] name = "lxml" version = "6.0.0" @@ -1555,70 +1651,73 @@ test = ["pytest", "pytest-cov"] [[package]] name = "msgpack" -version = "1.1.1" +version = "1.1.2" description = "MessagePack serializer" optional = true -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, - {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338"}, - {file = "msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd"}, - {file = "msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752"}, - {file = "msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295"}, - {file = "msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, - {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, - {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5"}, - {file = "msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323"}, - {file = "msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6"}, - {file = "msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142"}, - {file = "msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478"}, - {file = "msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57"}, - {file = "msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084"}, - {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, + {file = "msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2"}, + {file = "msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f"}, + {file = "msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9"}, + {file = "msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e"}, + {file = "msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e"}, + {file = "msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68"}, + {file = "msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620"}, + {file = "msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029"}, + {file = "msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b"}, + {file = "msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794"}, + {file = "msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c"}, + {file = "msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9"}, + {file = "msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2"}, + {file = "msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717"}, + {file = "msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b"}, + {file = "msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27"}, + {file = "msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea5405c46e690122a76531ab97a079e184c0daf491e588592d6a23d3e32af99e"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fba231af7a933400238cb357ecccf8ab5d51535ea95d94fc35b7806218ff844"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8f6e7d30253714751aa0b0c84ae28948e852ee7fb0524082e6716769124bc23"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94fd7dc7d8cb0a54432f296f2246bc39474e017204ca6f4ff345941d4ed285a7"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:350ad5353a467d9e3b126d8d1b90fe05ad081e2e1cef5753f8c345217c37e7b8"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6bde749afe671dc44893f8d08e83bf475a1a14570d67c4bb5cec5573463c8833"}, + {file = "msgpack-1.1.2-cp39-cp39-win32.whl", hash = "sha256:ad09b984828d6b7bb52d1d1d0c9be68ad781fa004ca39216c8a1e63c0f34ba3c"}, + {file = "msgpack-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:67016ae8c8965124fdede9d3769528ad8284f14d635337ffa6a713a580f6c030"}, + {file = "msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e"}, ] [[package]] @@ -1758,6 +1857,40 @@ files = [ llvmlite = "==0.43.*" numpy = ">=1.22,<2.1" +[[package]] +name = "numba" +version = "0.62.1" +description = "compiling Python code using LLVM" +optional = true +python-versions = ">=3.10" +files = [ + {file = "numba-0.62.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a323df9d36a0da1ca9c592a6baaddd0176d9f417ef49a65bb81951dce69d941a"}, + {file = "numba-0.62.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1e1f4781d3f9f7c23f16eb04e76ca10b5a3516e959634bd226fc48d5d8e7a0a"}, + {file = "numba-0.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14432af305ea68627a084cd702124fd5d0c1f5b8a413b05f4e14757202d1cf6c"}, + {file = "numba-0.62.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f180922adf159ae36c2fe79fb94ffaa74cf5cb3688cb72dba0a904b91e978507"}, + {file = "numba-0.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:f41834909d411b4b8d1c68f745144136f21416547009c1e860cc2098754b4ca7"}, + {file = "numba-0.62.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:f43e24b057714e480fe44bc6031de499e7cf8150c63eb461192caa6cc8530bc8"}, + {file = "numba-0.62.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57cbddc53b9ee02830b828a8428757f5c218831ccc96490a314ef569d8342b7b"}, + {file = "numba-0.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:604059730c637c7885386521bb1b0ddcbc91fd56131a6dcc54163d6f1804c872"}, + {file = "numba-0.62.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6c540880170bee817011757dc9049dba5a29db0c09b4d2349295991fe3ee55f"}, + {file = "numba-0.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:03de6d691d6b6e2b76660ba0f38f37b81ece8b2cc524a62f2a0cfae2bfb6f9da"}, + {file = "numba-0.62.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:1b743b32f8fa5fff22e19c2e906db2f0a340782caf024477b97801b918cf0494"}, + {file = "numba-0.62.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:90fa21b0142bcf08ad8e32a97d25d0b84b1e921bc9423f8dda07d3652860eef6"}, + {file = "numba-0.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ef84d0ac19f1bf80431347b6f4ce3c39b7ec13f48f233a48c01e2ec06ecbc59"}, + {file = "numba-0.62.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9315cc5e441300e0ca07c828a627d92a6802bcbf27c5487f31ae73783c58da53"}, + {file = "numba-0.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:44e3aa6228039992f058f5ebfcfd372c83798e9464297bdad8cc79febcf7891e"}, + {file = "numba-0.62.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b72489ba8411cc9fdcaa2458d8f7677751e94f0109eeb53e5becfdc818c64afb"}, + {file = "numba-0.62.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:44a1412095534a26fb5da2717bc755b57da5f3053965128fe3dc286652cc6a92"}, + {file = "numba-0.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c9460b9e936c5bd2f0570e20a0a5909ee6e8b694fd958b210e3bde3a6dba2d7"}, + {file = "numba-0.62.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:728f91a874192df22d74e3fd42c12900b7ce7190b1aad3574c6c61b08313e4c5"}, + {file = "numba-0.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:bbf3f88b461514287df66bc8d0307e949b09f2b6f67da92265094e8fa1282dd8"}, + {file = "numba-0.62.1.tar.gz", hash = "sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161"}, +] + +[package.dependencies] +llvmlite = "==0.45.*" +numpy = ">=1.22,<2.4" + [[package]] name = "numpy" version = "2.0.2" @@ -1812,6 +1945,89 @@ files = [ {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, ] +[[package]] +name = "numpy" +version = "2.3.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +files = [ + {file = "numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb"}, + {file = "numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f"}, + {file = "numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36"}, + {file = "numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032"}, + {file = "numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7"}, + {file = "numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda"}, + {file = "numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0"}, + {file = "numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a"}, + {file = "numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1"}, + {file = "numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996"}, + {file = "numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef"}, + {file = "numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e"}, + {file = "numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a"}, + {file = "numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16"}, + {file = "numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786"}, + {file = "numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc"}, + {file = "numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32"}, + {file = "numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e"}, + {file = "numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7"}, + {file = "numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953"}, + {file = "numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37"}, + {file = "numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd"}, + {file = "numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646"}, + {file = "numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d"}, + {file = "numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6"}, + {file = "numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7"}, + {file = "numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0"}, + {file = "numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f"}, + {file = "numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64"}, + {file = "numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb"}, + {file = "numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c"}, + {file = "numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b"}, + {file = "numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7"}, + {file = "numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2"}, + {file = "numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52"}, + {file = "numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26"}, + {file = "numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc"}, + {file = "numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9"}, + {file = "numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252"}, + {file = "numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e"}, + {file = "numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0"}, + {file = "numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0"}, + {file = "numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f"}, + {file = "numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d"}, + {file = "numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6"}, + {file = "numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f"}, + {file = "numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a"}, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -3056,6 +3272,84 @@ dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pyde doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.12.0)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0)", "sphinx-design (>=0.4.0)"] test = ["array-api-strict", "asv", "gmpy2", "hypothesis (>=6.30)", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +[[package]] +name = "scipy" +version = "1.16.2" +description = "Fundamental algorithms for scientific computing in Python" +optional = true +python-versions = ">=3.11" +files = [ + {file = "scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92"}, + {file = "scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e"}, + {file = "scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173"}, + {file = "scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d"}, + {file = "scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2"}, + {file = "scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9"}, + {file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3"}, + {file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88"}, + {file = "scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa"}, + {file = "scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:89d6c100fa5c48472047632e06f0876b3c4931aac1f4291afc81a3644316bb0d"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ca748936cd579d3f01928b30a17dc474550b01272d8046e3e1ee593f23620371"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fac4f8ce2ddb40e2e3d0f7ec36d2a1e7f92559a2471e59aec37bd8d9de01fec0"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:033570f1dcefd79547a88e18bccacff025c8c647a330381064f561d43b821232"}, + {file = "scipy-1.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea3421209bf00c8a5ef2227de496601087d8f638a2363ee09af059bd70976dc1"}, + {file = "scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f"}, + {file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e9feab931bd2aea4a23388c962df6468af3d808ddf2d40f94a81c5dc38f32ef"}, + {file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03dfc75e52f72cf23ec2ced468645321407faad8f0fe7b1f5b49264adbc29cb1"}, + {file = "scipy-1.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:0ce54e07bbb394b417457409a64fd015be623f36e330ac49306433ffe04bc97e"}, + {file = "scipy-1.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a8ffaa4ac0df81a0b94577b18ee079f13fecdb924df3328fc44a7dc5ac46851"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925"}, + {file = "scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9"}, + {file = "scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7"}, + {file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb"}, + {file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e"}, + {file = "scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c"}, + {file = "scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4"}, + {file = "scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21"}, + {file = "scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7"}, + {file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8"}, + {file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472"}, + {file = "scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351"}, + {file = "scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:567e77755019bb7461513c87f02bb73fb65b11f049aaaa8ca17cfaa5a5c45d77"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:17d9bb346194e8967296621208fcdfd39b55498ef7d2f376884d5ac47cec1a70"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0a17541827a9b78b777d33b623a6dcfe2ef4a25806204d08ead0768f4e529a88"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:d7d4c6ba016ffc0f9568d012f5f1eb77ddd99412aea121e6fa8b4c3b7cbad91f"}, + {file = "scipy-1.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9702c4c023227785c779cba2e1d6f7635dbb5b2e0936cdd3a4ecb98d78fd41eb"}, + {file = "scipy-1.16.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1cdf0ac28948d225decdefcc45ad7dd91716c29ab56ef32f8e0d50657dffcc7"}, + {file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70327d6aa572a17c2941cdfb20673f82e536e91850a2e4cb0c5b858b690e1548"}, + {file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5221c0b2a4b58aa7c4ed0387d360fd90ee9086d383bb34d9f2789fafddc8a936"}, + {file = "scipy-1.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:f5a85d7b2b708025af08f060a496dd261055b617d776fc05a1a1cc69e09fe9ff"}, + {file = "scipy-1.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:2cc73a33305b4b24556957d5857d6253ce1e2dcd67fa0ff46d87d1670b3e1e1d"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:9ea2a3fed83065d77367775d689401a703d0f697420719ee10c0780bcab594d8"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7280d926f11ca945c3ef92ba960fa924e1465f8d07ce3a9923080363390624c4"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8afae1756f6a1fe04636407ef7dbece33d826a5d462b74f3d0eb82deabefd831"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:5c66511f29aa8d233388e7416a3f20d5cae7a2744d5cee2ecd38c081f4e861b3"}, + {file = "scipy-1.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efe6305aeaa0e96b0ccca5ff647a43737d9a092064a3894e46c414db84bc54ac"}, + {file = "scipy-1.16.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f3a337d9ae06a1e8d655ee9d8ecb835ea5ddcdcbd8d23012afa055ab014f374"}, + {file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bab3605795d269067d8ce78a910220262711b753de8913d3deeaedb5dded3bb6"}, + {file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b0348d8ddb55be2a844c518cd8cc8deeeb8aeba707cf834db5758fc89b476a2c"}, + {file = "scipy-1.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:26284797e38b8a75e14ea6631d29bda11e76ceaa6ddb6fdebbfe4c4d90faf2f9"}, + {file = "scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779"}, + {file = "scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b"}, +] + +[package.dependencies] +numpy = ">=1.25.2,<2.6" + +[package.extras] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] +doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] +test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] + [[package]] name = "six" version = "1.17.0" @@ -3145,32 +3439,37 @@ files = [ [[package]] name = "soxr" -version = "0.5.0.post1" +version = "1.0.0" description = "High quality, one-dimensional sample-rate conversion library" optional = true python-versions = ">=3.9" files = [ - {file = "soxr-0.5.0.post1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:7406d782d85f8cf64e66b65e6b7721973de8a1dc50b9e88bc2288c343a987484"}, - {file = "soxr-0.5.0.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa0a382fb8d8e2afed2c1642723b2d2d1b9a6728ff89f77f3524034c8885b8c9"}, - {file = "soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b01d3efb95a2851f78414bcd00738b0253eec3f5a1e5482838e965ffef84969"}, - {file = "soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc049b0a151a65aa75b92f0ac64bb2dba785d16b78c31c2b94e68c141751d6d"}, - {file = "soxr-0.5.0.post1-cp310-cp310-win_amd64.whl", hash = "sha256:97f269bc26937c267a2ace43a77167d0c5c8bba5a2b45863bb6042b5b50c474e"}, - {file = "soxr-0.5.0.post1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6fb77b626773a966e3d8f6cb24f6f74b5327fa5dc90f1ff492450e9cdc03a378"}, - {file = "soxr-0.5.0.post1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:39e0f791ba178d69cd676485dbee37e75a34f20daa478d90341ecb7f6d9d690f"}, - {file = "soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f0b558f445ba4b64dbcb37b5f803052eee7d93b1dbbbb97b3ec1787cb5a28eb"}, - {file = "soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca6903671808e0a6078b0d146bb7a2952b118dfba44008b2aa60f221938ba829"}, - {file = "soxr-0.5.0.post1-cp311-cp311-win_amd64.whl", hash = "sha256:c4d8d5283ed6f5efead0df2c05ae82c169cfdfcf5a82999c2d629c78b33775e8"}, - {file = "soxr-0.5.0.post1-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:fef509466c9c25f65eae0ce1e4b9ac9705d22c6038c914160ddaf459589c6e31"}, - {file = "soxr-0.5.0.post1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4704ba6b13a3f1e41d12acf192878384c1c31f71ce606829c64abdf64a8d7d32"}, - {file = "soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd052a66471a7335b22a6208601a9d0df7b46b8d087dce4ff6e13eed6a33a2a1"}, - {file = "soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f16810dd649ab1f433991d2a9661e9e6a116c2b4101039b53b3c3e90a094fc"}, - {file = "soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6"}, - {file = "soxr-0.5.0.post1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:c5af7b355959061beb90a1d73c4834ece4549f07b708f8c73c088153cec29935"}, - {file = "soxr-0.5.0.post1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e1dda616fc797b1507b65486f3116ed2c929f13c722922963dd419d64ada6c07"}, - {file = "soxr-0.5.0.post1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94de2812368e98cb42b4eaeddf8ee1657ecc19bd053f8e67b9b5aa12a3592012"}, - {file = "soxr-0.5.0.post1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c8e9c980637e03d3f345a4fd81d56477a58c294fb26205fa121bc4eb23d9d01"}, - {file = "soxr-0.5.0.post1-cp39-cp39-win_amd64.whl", hash = "sha256:7e71b0b0db450f36de70f1047505231db77a713f8c47df9342582ae8a4b828f2"}, - {file = "soxr-0.5.0.post1.tar.gz", hash = "sha256:7092b9f3e8a416044e1fa138c8172520757179763b85dc53aa9504f4813cff73"}, + {file = "soxr-1.0.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:b876a3156f67c76aef0cff1084eaf4088d9ca584bb569cb993f89a52ec5f399f"}, + {file = "soxr-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d3b957a7b0cc19ae6aa45d40b2181474e53a8dd00efd7bce6bcf4e60e020892"}, + {file = "soxr-1.0.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89685faedebc45af71f08f9957b61cc6143bc94ba43fe38e97067f81e272969"}, + {file = "soxr-1.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d255741b2f0084fd02d4a2ddd77cd495be9e7e7b6f9dba1c9494f86afefac65b"}, + {file = "soxr-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:158a4a9055958c4b95ef91dbbe280cabb00946b5423b25a9b0ce31bd9e0a271e"}, + {file = "soxr-1.0.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:28e19d74a5ef45c0d7000f3c70ec1719e89077379df2a1215058914d9603d2d8"}, + {file = "soxr-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8dc69fc18884e53b72f6141fdf9d80997edbb4fec9dc2942edcb63abbe0d023"}, + {file = "soxr-1.0.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f15450e6f65f22f02fcd4c5a9219c873b1e583a73e232805ff160c759a6b586"}, + {file = "soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f73f57452f9df37b4de7a4052789fcbd474a5b28f38bba43278ae4b489d4384"}, + {file = "soxr-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f417c3d69236051cf5a1a7bad7c4bff04eb3d8fcaa24ac1cb06e26c8d48d8dc"}, + {file = "soxr-1.0.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:abecf4e39017f3fadb5e051637c272ae5778d838e5c3926a35db36a53e3a607f"}, + {file = "soxr-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:e973d487ee46aa8023ca00a139db6e09af053a37a032fe22f9ff0cc2e19c94b4"}, + {file = "soxr-1.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ce273cca101aff3d8c387db5a5a41001ba76ef1837883438d3c652507a9ccc"}, + {file = "soxr-1.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f2a69686f2856d37823bbb7b78c3d44904f311fe70ba49b893af11d6b6047b"}, + {file = "soxr-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:2a3b77b115ae7c478eecdbd060ed4f61beda542dfb70639177ac263aceda42a2"}, + {file = "soxr-1.0.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:392a5c70c04eb939c9c176bd6f654dec9a0eaa9ba33d8f1024ed63cf68cdba0a"}, + {file = "soxr-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc41a1027ba46777186f26a8fba7893be913383414135577522da2fcc684490"}, + {file = "soxr-1.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:449acd1dfaf10f0ce6dfd75c7e2ef984890df94008765a6742dafb42061c1a24"}, + {file = "soxr-1.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38b35c99e408b8f440c9376a5e1dd48014857cd977c117bdaa4304865ae0edd0"}, + {file = "soxr-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a39b519acca2364aa726b24a6fd55acf29e4c8909102e0b858c23013c38328e5"}, + {file = "soxr-1.0.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:c120775b7d0ef9e974a5797a4695861e88653f7ecd0a2a532f089bc4452ba130"}, + {file = "soxr-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e59e5f648bd6144e79a6e0596aa486218876293f5ddce3ca84b9d8f8aa34d6d"}, + {file = "soxr-1.0.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb86c342862697dbd4a44043f275e5196f2d2c49dca374c78f19b7893988675d"}, + {file = "soxr-1.0.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2a4fadd88207c2991fb08c29fc189e7b2e298b598a94ea1747e42c8acb7a01"}, + {file = "soxr-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:c7f5ace8f04f924b21caedeeb69f2a7b3d83d2d436639498c08b2cebe181af14"}, + {file = "soxr-1.0.0.tar.gz", hash = "sha256:e07ee6c1d659bc6957034f4800c60cb8b98de798823e34d2a2bba1caa85a4509"}, ] [package.dependencies] @@ -3371,6 +3670,46 @@ lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] +[[package]] +name = "standard-aifc" +version = "3.13.0" +description = "Standard library aifc redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"}, + {file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"}, +] + +[package.dependencies] +audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} +standard-chunk = {version = "*", markers = "python_version >= \"3.13\""} + +[[package]] +name = "standard-chunk" +version = "3.13.0" +description = "Standard library chunk redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"}, + {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, +] + +[[package]] +name = "standard-sunau" +version = "3.13.0" +description = "Standard library sunau redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622"}, + {file = "standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908"}, +] + +[package.dependencies] +audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} + [[package]] name = "tabulate" version = "0.9.0" @@ -3569,13 +3908,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] @@ -3683,4 +4022,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f" +content-hash = "0482f412ae22099662d8f991b9e6f8074bd8bbbddd3964f704046e10d5920619" diff --git a/pyproject.toml b/pyproject.toml index b546b4dc2..7851f078b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ @@ -49,7 +50,10 @@ jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" musicbrainzngs = ">=0.4" -numpy = ">=1.24.4" +numpy = [ + { python = "<3.14", version = ">=2.0.2" }, + { python = ">=3.14", version = ">=2.3.4" }, +] platformdirs = ">=3.5.0" pyyaml = "*" typing_extensions = "*" @@ -60,7 +64,15 @@ dbus-python = { version = "*", optional = true } flask = { version = "*", optional = true } flask-cors = { version = "*", optional = true } langdetect = { version = "*", optional = true } -librosa = { version = "^0.10.2.post1", optional = true } +librosa = { version = ">=0.11", optional = true } +scipy = [ # for librosa + { python = "<3.14", version = ">=1.13.1", optional = true }, + { python = ">=3.14", version = ">=1.16.1", optional = true }, +] +numba = [ # for librosa + { python = "<3.14", version = ">=0.60", optional = true }, + { python = ">=3.14", version = ">=0.62.1", optional = true }, +] mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } py7zr = { version = "*", optional = true } From 3eb68ef830447d91b10a928823edb3c50cf73f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 26 Oct 2025 20:37:08 +0000 Subject: [PATCH 292/301] Use cross-platform shutil.get_terminal_size to get term_width This fixes Python 3.14 incompatibility. --- .github/workflows/ci.yaml | 2 +- beets/ui/__init__.py | 26 +++++--------------------- pyproject.toml | 1 + 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8c1530bad..45352c2a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 60e201448..fe980bb5c 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -23,8 +23,8 @@ import errno import optparse import os.path import re +import shutil import sqlite3 -import struct import sys import textwrap import traceback @@ -699,27 +699,11 @@ def get_replacements(): return replacements -def term_width(): +@cache +def term_width() -> int: """Get the width (columns) of the terminal.""" - fallback = config["ui"]["terminal_width"].get(int) - - # The fcntl and termios modules are not available on non-Unix - # platforms, so we fall back to a constant. - try: - import fcntl - import termios - except ImportError: - return fallback - - try: - buf = fcntl.ioctl(0, termios.TIOCGWINSZ, " " * 4) - except OSError: - return fallback - try: - height, width = struct.unpack("hh", buf) - except struct.error: - return fallback - return width + columns, _ = shutil.get_terminal_size(fallback=(0, 0)) + return columns if columns else config["ui"]["terminal_width"].get(int) def split_into_lines(string, width_tuple): diff --git a/pyproject.toml b/pyproject.toml index 7851f078b..eb80cfe9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ From 77dffd551db0c41fba8571a690c62ecbcb808027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 26 Oct 2025 21:07:25 +0000 Subject: [PATCH 293/301] Add a note in the changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8de4eb385..e6eba65df 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,7 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. +- Added support for Python 3.13 and 3.14. Bug fixes: From ec141dbfd6079209e994d0a4ad97fedcc3a2ab1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Sun, 26 Oct 2025 23:04:17 +0000 Subject: [PATCH 294/301] Explicitly wrap partial with staticmethod for Py3.14 --- beetsplug/lyrics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index d245d6a14..4c35d8a2e 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -745,7 +745,9 @@ class Translator(RequestHandler): TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate" LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$") SEPARATOR = " | " - remove_translations = partial(re.compile(r" / [^\n]+").sub, "") + remove_translations = staticmethod( + partial(re.compile(r" / [^\n]+").sub, "") + ) _log: Logger api_key: str From e30f7fbe9c75d11863c0e773871f631d8c69c98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 27 Oct 2025 08:45:19 +0000 Subject: [PATCH 295/301] Try env var --- .github/workflows/ci.yaml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45352c2a5..119115fb1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,11 +46,15 @@ jobs: python3-gst-1.0 \ libcairo2-dev \ libgirepository-2.0-dev \ - libopenblas-dev \ - llvm-20-dev \ pandoc \ imagemagick + if [[ "${{ matrix.python-version }}" == '3.14' ]]; then + sudo apt install --yes --no-install-recommends libopenblas-dev llvm-20-dev clang-20 + sudo update-alternatives --install /usr/bin/llvm-config llvm-config /usr/bin/llvm-config-20 200 + sudo update-alternatives --set llvm-config /usr/bin/llvm-config-20 + fi + - name: Get changed lyrics files id: lyrics-update uses: tj-actions/changed-files@v46 @@ -67,7 +71,14 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage + shell: bash run: | + if [[ "${{ matrix.python-version }}" == '3.14' ]]; then + export CC=gcc + export CXX=g++ + export LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm + fi + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test From fdc6d6e7879c15699dd8a9a262a1325bf3184f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 27 Oct 2025 08:55:08 +0000 Subject: [PATCH 296/301] Revert "Try env var" This reverts commit e30f7fbe9c75d11863c0e773871f631d8c69c98f. --- .github/workflows/ci.yaml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 119115fb1..45352c2a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,15 +46,11 @@ jobs: python3-gst-1.0 \ libcairo2-dev \ libgirepository-2.0-dev \ + libopenblas-dev \ + llvm-20-dev \ pandoc \ imagemagick - if [[ "${{ matrix.python-version }}" == '3.14' ]]; then - sudo apt install --yes --no-install-recommends libopenblas-dev llvm-20-dev clang-20 - sudo update-alternatives --install /usr/bin/llvm-config llvm-config /usr/bin/llvm-config-20 200 - sudo update-alternatives --set llvm-config /usr/bin/llvm-config-20 - fi - - name: Get changed lyrics files id: lyrics-update uses: tj-actions/changed-files@v46 @@ -71,14 +67,7 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage - shell: bash run: | - if [[ "${{ matrix.python-version }}" == '3.14' ]]; then - export CC=gcc - export CXX=g++ - export LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm - fi - poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test From e76665bcfb3a943e0811d665dd9f6329e823703d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Mon, 27 Oct 2025 09:30:18 +0000 Subject: [PATCH 297/301] Do not support 3.14 for now, until we drop 3.9 in a couple of days --- .github/workflows/ci.yaml | 4 +--- docs/changelog.rst | 2 +- poetry.lock | 2 +- pyproject.toml | 13 ++++++------- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45352c2a5..e8a532956 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} @@ -46,8 +46,6 @@ jobs: python3-gst-1.0 \ libcairo2-dev \ libgirepository-2.0-dev \ - libopenblas-dev \ - llvm-20-dev \ pandoc \ imagemagick diff --git a/docs/changelog.rst b/docs/changelog.rst index e6eba65df..1dabbc58a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,7 +18,7 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. -- Added support for Python 3.13 and 3.14. +- Added support for Python 3.13. Bug fixes: diff --git a/poetry.lock b/poetry.lock index 813ef6466..ca58cc732 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4022,4 +4022,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "0482f412ae22099662d8f991b9e6f8074bd8bbbddd3964f704046e10d5920619" +content-hash = "d3a1dc19299b117259ac790773ebef872a0b5a2e318b8a36da0918f3bbc54fb8" diff --git a/pyproject.toml b/pyproject.toml index eb80cfe9e..a0b09c1ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ @@ -52,8 +51,8 @@ lap = ">=0.5.12" mediafile = ">=0.12.0" musicbrainzngs = ">=0.4" numpy = [ - { python = "<3.14", version = ">=2.0.2" }, - { python = ">=3.14", version = ">=2.3.4" }, + { python = "<3.13", version = ">=2.0.2" }, + { python = ">=3.13", version = ">=2.3.4" }, ] platformdirs = ">=3.5.0" pyyaml = "*" @@ -67,12 +66,12 @@ flask-cors = { version = "*", optional = true } langdetect = { version = "*", optional = true } librosa = { version = ">=0.11", optional = true } scipy = [ # for librosa - { python = "<3.14", version = ">=1.13.1", optional = true }, - { python = ">=3.14", version = ">=1.16.1", optional = true }, + { python = "<3.13", version = ">=1.13.1", optional = true }, + { python = ">=3.13", version = ">=1.16.1", optional = true }, ] numba = [ # for librosa - { python = "<3.14", version = ">=0.60", optional = true }, - { python = ">=3.14", version = ">=0.62.1", optional = true }, + { python = "<3.13", version = ">=0.60", optional = true }, + { python = ">=3.13", version = ">=0.62.1", optional = true }, ] mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } From cbd74b31679cbbf733cb74e0694b6f23ef8dd7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com> Date: Tue, 28 Oct 2025 10:25:13 +0000 Subject: [PATCH 298/301] Update confuse --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index ca58cc732..568b20d7d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -639,13 +639,13 @@ files = [ [[package]] name = "confuse" -version = "2.0.1" -description = "Painless YAML configuration." +version = "2.1.0" +description = "Painless YAML config files" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" files = [ - {file = "confuse-2.0.1-py3-none-any.whl", hash = "sha256:9b9e5bbc70e2cb9b318bcab14d917ec88e21bf1b724365e3815eb16e37aabd2a"}, - {file = "confuse-2.0.1.tar.gz", hash = "sha256:7379a2ad49aaa862b79600cc070260c1b7974d349f4fa5e01f9afa6c4dd0611f"}, + {file = "confuse-2.1.0-py3-none-any.whl", hash = "sha256:502be1299aa6bf7c48f7719f56795720c073fb28550c0c7a37394366c9d30316"}, + {file = "confuse-2.1.0.tar.gz", hash = "sha256:abb9674a99c7a6efaef84e2fc84403ecd2dd304503073ff76ea18ed4176e218d"}, ] [package.dependencies] @@ -4022,4 +4022,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "d3a1dc19299b117259ac790773ebef872a0b5a2e318b8a36da0918f3bbc54fb8" +content-hash = "be135ccdcad615804f5fc96290d5d8e6ad51a244599356133c2b68bb030f640f" diff --git a/pyproject.toml b/pyproject.toml index a0b09c1ca..78e85286b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" python = ">=3.9,<4" colorama = { version = "*", markers = "sys_platform == 'win32'" } -confuse = ">=1.5.0" +confuse = ">=2.1.0" jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" From f6ba5bcf01b6112ca95d8d8e46be594ba5aaa7d3 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 23 Oct 2025 22:06:07 +0200 Subject: [PATCH 299/301] docs: Move "Handling Paths" to "Developers" chapter --- CONTRIBUTING.rst | 25 ------------------------- docs/dev/index.rst | 1 + docs/dev/paths.rst | 24 ++++++++++++++++++++++++ 3 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 docs/dev/paths.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ee963ab46..d19a376b3 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -286,31 +286,6 @@ according to the specifications required by the project. Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent documentation formatting and check for any issues. -Handling Paths -~~~~~~~~~~~~~~ - -A great deal of convention deals with the handling of **paths**. Paths are -stored internally—in the database, for instance—as byte strings (i.e., ``bytes`` -instead of ``str`` in Python 3). This is because POSIX operating systems’ path -names are only reliably usable as byte strings—operating systems typically -recommend but do not require that filenames use a given encoding, so violations -of any reported encoding are inevitable. On Windows, the strings are always -encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here -are some guidelines to follow: - -- If you have a Unicode path or you’re not sure whether something is Unicode or - not, pass it through ``bytestring_path`` function in the ``beets.util`` module - to convert it to bytes. -- Pass every path name through the ``syspath`` function (also in ``beets.util``) - before sending it to any *operating system* file operation (``open``, for - example). This is necessary to use long filenames (which, maddeningly, must be - Unicode) on Windows. This allows us to consistently store bytes in the - database but use the native encoding rule on both POSIX and Windows. -- Similarly, the ``displayable_path`` utility function converts bytestring paths - to a Unicode string for displaying to the user. Every time you want to print - out a string to the terminal or log it with the ``logging`` module, feed it - through this function. - Editor Settings ~~~~~~~~~~~~~~~ diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 7bd0ba709..f22aa8c56 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -18,6 +18,7 @@ configuration files, respectively. plugins/index library + paths importer cli ../api/index diff --git a/docs/dev/paths.rst b/docs/dev/paths.rst new file mode 100644 index 000000000..136414edb --- /dev/null +++ b/docs/dev/paths.rst @@ -0,0 +1,24 @@ +Handling Paths +============== + +A great deal of convention deals with the handling of **paths**. Paths are +stored internally—in the database, for instance—as byte strings (i.e., ``bytes`` +instead of ``str`` in Python 3). This is because POSIX operating systems’ path +names are only reliably usable as byte strings—operating systems typically +recommend but do not require that filenames use a given encoding, so violations +of any reported encoding are inevitable. On Windows, the strings are always +encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here +are some guidelines to follow: + +- If you have a Unicode path or you’re not sure whether something is Unicode or + not, pass it through ``bytestring_path`` function in the ``beets.util`` module + to convert it to bytes. +- Pass every path name through the ``syspath`` function (also in ``beets.util``) + before sending it to any *operating system* file operation (``open``, for + example). This is necessary to use long filenames (which, maddeningly, must be + Unicode) on Windows. This allows us to consistently store bytes in the + database but use the native encoding rule on both POSIX and Windows. +- Similarly, the ``displayable_path`` utility function converts bytestring paths + to a Unicode string for displaying to the user. Every time you want to print + out a string to the terminal or log it with the ``logging`` module, feed it + through this function. From d283a35a1055296355131b10747cca6e276d8f71 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Thu, 23 Oct 2025 22:13:17 +0200 Subject: [PATCH 300/301] docs: Rewrite Handling Paths chapter (pathlib) --- docs/dev/paths.rst | 80 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/dev/paths.rst b/docs/dev/paths.rst index 136414edb..a593580f6 100644 --- a/docs/dev/paths.rst +++ b/docs/dev/paths.rst @@ -1,24 +1,64 @@ Handling Paths ============== -A great deal of convention deals with the handling of **paths**. Paths are -stored internally—in the database, for instance—as byte strings (i.e., ``bytes`` -instead of ``str`` in Python 3). This is because POSIX operating systems’ path -names are only reliably usable as byte strings—operating systems typically -recommend but do not require that filenames use a given encoding, so violations -of any reported encoding are inevitable. On Windows, the strings are always -encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here -are some guidelines to follow: +``pathlib`` provides a clean, cross-platform API for working with filesystem +paths. -- If you have a Unicode path or you’re not sure whether something is Unicode or - not, pass it through ``bytestring_path`` function in the ``beets.util`` module - to convert it to bytes. -- Pass every path name through the ``syspath`` function (also in ``beets.util``) - before sending it to any *operating system* file operation (``open``, for - example). This is necessary to use long filenames (which, maddeningly, must be - Unicode) on Windows. This allows us to consistently store bytes in the - database but use the native encoding rule on both POSIX and Windows. -- Similarly, the ``displayable_path`` utility function converts bytestring paths - to a Unicode string for displaying to the user. Every time you want to print - out a string to the terminal or log it with the ``logging`` module, feed it - through this function. +Use the ``.filepath`` property on ``Item`` and ``Album`` library objects to +access paths as ``pathlib.Path`` objects. This produces a readable, native +representation suitable for printing, logging, or further processing. + +Normalize paths using ``Path(...).expanduser().resolve()``, which expands ``~`` +and resolves symlinks. + +Cross-platform differences—such as path separators, Unicode handling, and +long-path support (Windows) are automatically managed by ``pathlib``. + +When storing paths in the database, however, convert them to bytes with +``bytestring_path()``. Paths in Beets are currently stored as bytes, although +there are plans to eventually store ``pathlib.Path`` objects directly. To access +media file paths in their stored form, use the ``.path`` property on ``Item`` +and ``Album``. + +Legacy utilities +---------------- + +Historically, Beets used custom utilities to ensure consistent behavior across +Linux, macOS, and Windows before ``pathlib`` became reliable: + +- ``syspath()``: worked around Windows Unicode and long-path limitations by + converting to a system-safe string (adding the ``\\?\`` prefix where needed). +- ``normpath()``: normalized slashes and removed ``./`` or ``..`` parts but did + not expand ``~``. +- ``bytestring_path()``: converted paths to bytes for database storage (still + used for that purpose today). +- ``displayable_path()``: converted byte paths to Unicode for display or + logging. + +These functions remain safe to use in legacy code, but new code should rely +solely on ``pathlib.Path``. + +Examples +-------- + +Old style + +.. code-block:: python + + displayable_path(item.path) + normpath("~/Music/../Artist") + syspath(path) + +New style + +.. code-block:: python + + item.filepath + Path("~/Music/../Artist").expanduser().resolve() + Path(path) + +When storing paths in the database + +.. code-block:: python + + path_bytes = bytestring_path(Path("/some/path/to/file.mp3")) From 528d5e67e551719c4fcb5e5dcdf1c1ad2854cc55 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Tue, 28 Oct 2025 07:32:39 +0100 Subject: [PATCH 301/301] docs: Changelog for Handling Paths move/rewrite --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1dabbc58a..749ddf005 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,10 @@ For packagers: Other changes: +- The documentation chapter :doc:`dev/paths` has been moved to the "For + Developers" section and revised to reflect current best practices (pathlib + usage). + 2.5.1 (October 14, 2025) ------------------------