From b5216a06f489b5da8c1e66b86603eedfc9d15856 Mon Sep 17 00:00:00 2001 From: Vrihub Date: Sat, 15 Jun 2024 20:52:55 +0200 Subject: [PATCH 001/103] 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/103] 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/103] 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/103] 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 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 005/103] 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 006/103] 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 007/103] 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 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 008/103] 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 009/103] 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 010/103] 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 011/103] 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 012/103] 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 dd57c0da2d8fb5a0ecfb1be0b8b767e1eb33ec3e Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Sat, 27 Sep 2025 10:42:29 -0700 Subject: [PATCH 013/103] 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 014/103] 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 015/103] 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 016/103] 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 017/103] 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 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 018/103] 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 019/103] 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 020/103] 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 021/103] 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 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 022/103] =?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 023/103] 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 f5acdec2b104b84b0b02000a2bacfc0962a4965f Mon Sep 17 00:00:00 2001 From: Henry <henryoberholtzer@gmail.com> Date: Fri, 3 Oct 2025 14:44:22 -0700 Subject: [PATCH 024/103] 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 025/103] 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 026/103] 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 027/103] 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 028/103] 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 029/103] 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 030/103] 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 031/103] 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 032/103] 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 033/103] 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 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 034/103] 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 035/103] 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 036/103] 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 037/103] 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 038/103] 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 039/103] 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 040/103] 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 041/103] 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 042/103] 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 043/103] 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 044/103] 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 045/103] 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 046/103] 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 047/103] 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 048/103] 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 049/103] 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 050/103] 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 051/103] 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 052/103] 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 053/103] 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 054/103] 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 055/103] 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 056/103] 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 057/103] 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 058/103] 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 059/103] 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 060/103] 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 061/103] 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 062/103] 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 063/103] 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 064/103] 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 065/103] 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 066/103] 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 067/103] 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 068/103] 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 069/103] 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 070/103] 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 071/103] 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 072/103] 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 073/103] 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 074/103] 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 075/103] 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 076/103] 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 077/103] 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 078/103] 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 079/103] 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 080/103] 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 081/103] 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 082/103] 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 083/103] 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 084/103] 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 085/103] 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 086/103] 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 087/103] 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 088/103] 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 089/103] 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 090/103] 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 091/103] 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 092/103] 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 093/103] 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 094/103] 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 095/103] 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 096/103] 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 097/103] 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 098/103] 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 099/103] 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 100/103] 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 101/103] 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 102/103] 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 103/103] 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"