feat(FtInTitle): support tracks by artists != album artist

This commit is contained in:
Trey Turner 2025-08-23 08:37:22 -05:00 committed by Šarūnas Nejus
parent 159f43cf46
commit f0a6059685
3 changed files with 89 additions and 30 deletions

View file

@ -26,14 +26,16 @@ if TYPE_CHECKING:
from beets.library import Item 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 """Given an artist string, split the "main" artist from any artist
on the right-hand side of a string like "feat". Return the main on the right-hand side of a string like "feat". Return the main
artist, which is always a string, and the featuring artist, which artist, which is always a string, and the featuring artist, which
may be a string or None if none is present. may be a string or None if none is present.
""" """
# split on the first "feat". # 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)) parts = tuple(s.strip() for s in regex.split(artist, 1))
if len(parts) == 1: if len(parts) == 1:
return parts[0], None 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 """Attempt to find featured artists in the item's artist fields and
return the results. Returns None if no featured artist found. return the results. Returns None if no featured artist found.
""" """
# Look for the album artist in the artist field. If it's not # Handle a wider variety of extraction cases if the album artist is
# present, give up. # contained within the track artist.
albumartist_split = artist.split(albumartist, 1) if albumartist and albumartist in artist:
if len(albumartist_split) <= 1: albumartist_split = artist.split(albumartist, 1)
return None
# If the last element of the split (the right-hand side of the # If the last element of the split (the right-hand side of the
# album artist) is nonempty, then it probably contains the # album artist) is nonempty, then it probably contains the
# featured artist. # featured artist.
elif albumartist_split[1] != "": if albumartist_split[1] != "":
# Extract the featured artist from the right-hand side. # 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])
return feat_part return feat_part
# Otherwise, if there's nothing on the right-hand side, look for a # Otherwise, if there's nothing on the right-hand side,
# featuring artist on the left-hand side. # look for a featuring artist on the left-hand side.
else: else:
lhs, rhs = split_on_feat(albumartist_split[0]) lhs, _ = split_on_feat(albumartist_split[0])
if lhs: if lhs:
return 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): class FtInTitlePlugin(plugins.BeetsPlugin):
@ -153,8 +158,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"artist: {.artist} (Not changing due to keep_in_artist)", item "artist: {.artist} (Not changing due to keep_in_artist)", item
) )
else: else:
self._log.info("artist: {0.artist} -> {0.albumartist}", item) track_artist, _ = split_on_feat(item.artist)
item.artist = item.albumartist self._log.info("artist: {0.artist} -> {1}", item, track_artist)
item.artist = track_artist
if item.artist_sort: if item.artist_sort:
# Just strip the featured artist from the sort name. # 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 # Check whether there is a featured artist on this track and the
# artist field does not exactly match the album artist field. In # artist field does not exactly match the album artist field. In
# that case, we attempt to move the featured artist to the title. # 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 return False
_, featured = split_on_feat(artist) _, featured = split_on_feat(artist)

View file

@ -161,6 +161,9 @@ Other changes:
Autogenerated API references are now located in the ``docs/api`` subdirectory. Autogenerated API references are now located in the ``docs/api`` subdirectory.
- :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each - :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each
case is shown on separate lines. 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 - Refactored library.py file by splitting it into multiple modules within the
beets/library directory. beets/library directory.
- Added a test to check that all plugins can be imported without errors. - Added a test to check that all plugins can be imported without errors.

View file

@ -40,6 +40,43 @@ class FtInTitlePluginFunctional(PluginTestCase):
self.config["ftintitle"]["auto"] = auto self.config["ftintitle"]["auto"] = auto
self.config["ftintitle"]["keep_in_artist"] = keep_in_artist 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): def test_functional_drop(self):
item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice") item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
self.run_command("ftintitle", "-d") self.run_command("ftintitle", "-d")
@ -47,12 +84,25 @@ class FtInTitlePluginFunctional(PluginTestCase):
assert item["artist"] == "Alice" assert item["artist"] == "Alice"
assert item["title"] == "Song 1" 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") item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "George")
self.run_command("ftintitle", "-d") self.run_command("ftintitle", "-d")
item.load() item.load()
# item should be unchanged assert item["artist"] == "Alice"
assert item["artist"] == "Alice ft Bob" 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" assert item["title"] == "Song 1"
def test_functional_custom_format(self): def test_functional_custom_format(self):
@ -147,7 +197,7 @@ class FtInTitlePluginTest(unittest.TestCase):
{ {
"artist": "Alice ft. Carol", "artist": "Alice ft. Carol",
"album_artist": "Bob", "album_artist": "Bob",
"feat_part": None, "feat_part": "Carol",
}, },
] ]
@ -155,7 +205,7 @@ class FtInTitlePluginTest(unittest.TestCase):
feat_part = ftintitle.find_feat_part( feat_part = ftintitle.find_feat_part(
test_case["artist"], test_case["album_artist"] 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): def test_split_on_feat(self):
parts = ftintitle.split_on_feat("Alice ft. Bob") parts = ftintitle.split_on_feat("Alice ft. Bob")