diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index f0f088099..e17d7bc1c 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -26,14 +26,16 @@ if TYPE_CHECKING: from beets.library import Item -def split_on_feat(artist: str) -> tuple[str, str | None]: +def split_on_feat( + artist: str, for_artist: bool = True +) -> tuple[str, str | None]: """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main artist, which is always a string, and the featuring artist, which may be a string or None if none is present. """ # split on the first "feat". - regex = re.compile(plugins.feat_tokens(), re.IGNORECASE) + regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: return parts[0], None @@ -53,32 +55,35 @@ def contains_feat(title: str) -> bool: ) -def find_feat_part(artist: str, albumartist: str) -> str | None: +def find_feat_part(artist: str, albumartist: str | None) -> str | None: """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ - # Look for the album artist in the artist field. If it's not - # present, give up. - albumartist_split = artist.split(albumartist, 1) - if len(albumartist_split) <= 1: - return None + # Handle a wider variety of extraction cases if the album artist is + # contained within the track artist. + if albumartist and albumartist in artist: + albumartist_split = artist.split(albumartist, 1) - # If the last element of the split (the right-hand side of the - # album artist) is nonempty, then it probably contains the - # featured artist. - elif albumartist_split[1] != "": - # Extract the featured artist from the right-hand side. - _, feat_part = split_on_feat(albumartist_split[1]) - return feat_part + # If the last element of the split (the right-hand side of the + # album artist) is nonempty, then it probably contains the + # featured artist. + if albumartist_split[1] != "": + # Extract the featured artist from the right-hand side. + _, feat_part = split_on_feat(albumartist_split[1]) + return feat_part - # Otherwise, if there's nothing on the right-hand side, look for a - # featuring artist on the left-hand side. - else: - lhs, rhs = split_on_feat(albumartist_split[0]) - if lhs: - return lhs + # Otherwise, if there's nothing on the right-hand side, + # look for a featuring artist on the left-hand side. + else: + lhs, _ = split_on_feat(albumartist_split[0]) + if lhs: + return lhs - return None + # Fall back to conservative handling of the track artist without relying + # on albumartist, which covers compilations using a 'Various Artists' + # albumartist and album tracks by a guest artist featuring a third artist. + _, feat_part = split_on_feat(artist, False) + return feat_part class FtInTitlePlugin(plugins.BeetsPlugin): @@ -153,8 +158,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "artist: {.artist} (Not changing due to keep_in_artist)", item ) else: - self._log.info("artist: {0.artist} -> {0.albumartist}", item) - item.artist = item.albumartist + track_artist, _ = split_on_feat(item.artist) + self._log.info("artist: {0.artist} -> {1}", item, track_artist) + item.artist = track_artist if item.artist_sort: # Just strip the featured artist from the sort name. @@ -187,7 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # Check whether there is a featured artist on this track and the # artist field does not exactly match the album artist field. In # that case, we attempt to move the featured artist to the title. - if not albumartist or albumartist == artist: + if albumartist and artist == albumartist: return False _, featured = split_on_feat(artist) diff --git a/docs/changelog.rst b/docs/changelog.rst index 63a8fe339..c2d6ea1cf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -161,6 +161,9 @@ Other changes: Autogenerated API references are now located in the ``docs/api`` subdirectory. - :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each case is shown on separate lines. +- :doc:`/plugins/ftintitle`: Process items whose albumartist is not contained in + the artist field, including compilations using Various Artists as an + albumartist and album tracks by guest artists featuring a third artist. - Refactored library.py file by splitting it into multiple modules within the beets/library directory. - Added a test to check that all plugins can be imported without errors. diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 572431b45..e049fe32a 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -40,6 +40,43 @@ class FtInTitlePluginFunctional(PluginTestCase): self.config["ftintitle"]["auto"] = auto self.config["ftintitle"]["keep_in_artist"] = keep_in_artist + def test_functional_no_featured_artist(self): + item = self._ft_add_item("/", "Alice", "Song 1", "Alice") + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_no_albumartist(self): + self._ft_set_config("feat {0}") + item = self._ft_add_item("/", "Alice ft. Bob", "Song 1", None) + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 feat Bob" + + def test_functional_no_albumartist_no_feature(self): + item = self._ft_add_item("/", "Alice", "Song 1", None) + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_guest_artist(self): + self._ft_set_config("featuring {0}") + item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 featuring Bob" + + def test_functional_guest_artist_no_feature(self): + item = self._ft_add_item("/", "Alice", "Song 1", "George") + self.run_command("ftintitle") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + def test_functional_drop(self): item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") self.run_command("ftintitle", "-d") @@ -47,12 +84,25 @@ class FtInTitlePluginFunctional(PluginTestCase): assert item["artist"] == "Alice" assert item["title"] == "Song 1" - def test_functional_not_found(self): + def test_functional_drop_no_featured_artist(self): + item = self._ft_add_item("/", "Alice", "Song 1", "Alice") + self.run_command("ftintitle", "-d") + item.load() + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_drop_guest_artist(self): item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George") self.run_command("ftintitle", "-d") item.load() - # item should be unchanged - assert item["artist"] == "Alice ft Bob" + assert item["artist"] == "Alice" + assert item["title"] == "Song 1" + + def test_functional_drop_guest_artist_no_feature(self): + item = self._ft_add_item("/", "Alice", "Song 1", "George") + self.run_command("ftintitle", "-d") + item.load() + assert item["artist"] == "Alice" assert item["title"] == "Song 1" def test_functional_custom_format(self): @@ -147,7 +197,7 @@ class FtInTitlePluginTest(unittest.TestCase): { "artist": "Alice ft. Carol", "album_artist": "Bob", - "feat_part": None, + "feat_part": "Carol", }, ] @@ -155,7 +205,7 @@ class FtInTitlePluginTest(unittest.TestCase): feat_part = ftintitle.find_feat_part( test_case["artist"], test_case["album_artist"] ) - assert feat_part == test_case["feat_part"] + assert feat_part == test_case["feat_part"], f"failed: {test_case}" def test_split_on_feat(self): parts = ftintitle.split_on_feat("Alice ft. Bob")