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
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)

View file

@ -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.

View file

@ -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")