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