mirror of
https://github.com/beetbox/beets.git
synced 2025-12-07 17:16:07 +01:00
feat(FtInTitle): support tracks by artists != album artist
This commit is contained in:
parent
159f43cf46
commit
f0a6059685
3 changed files with 89 additions and 30 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue