diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index dd681a972..cec22af3f 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -59,6 +59,89 @@ def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: ) +# Default keywords that indicate remix/edit/version content +DEFAULT_BRACKET_KEYWORDS = [ + "abridged", + "acapella", + "club", + "demo", + "edit", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +] + + +def find_bracket_position( + title: str, keywords: list[str] | None = None +) -> int | None: + """Find the position of the first opening bracket that contains + remix/edit-related keywords and has a matching closing bracket. + + Args: + title: The title to search in. + keywords: List of keywords to match. If None, uses DEFAULT_BRACKET_KEYWORDS. + If an empty list, matches any bracket content (not just keywords). + + Returns: + The position of the opening bracket, or None if no match found. + """ + if keywords is None: + keywords = DEFAULT_BRACKET_KEYWORDS + + # If keywords is empty, match any bracket content + if not keywords: + pattern = None + else: + # Build regex pattern with word boundaries + keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) + pattern = re.compile(keyword_pattern, re.IGNORECASE) + + # Bracket pairs (opening, closing) + bracket_pairs = [("(", ")"), ("[", "]"), ("<", ">"), ("{", "}")] + + # Track the earliest valid bracket position + earliest_pos = None + + for open_char, close_char in bracket_pairs: + pos = 0 + while True: + # Find next opening bracket + open_pos = title.find(open_char, pos) + if open_pos == -1: + break + + # Find matching closing bracket + close_pos = title.find(close_char, open_pos + 1) + if close_pos == -1: + break + + # Extract content between brackets + content = title[open_pos + 1 : close_pos] + + # Check if content matches: if pattern is None (empty keywords), + # match any content; otherwise check for keywords + if pattern is None or pattern.search(content): + if earliest_pos is None or open_pos < earliest_pos: + earliest_pos = open_pos + + # Continue searching from after this closing bracket + pos = close_pos + 1 + + return earliest_pos + + def find_feat_part( artist: str, albumartist: str | None, @@ -110,6 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], + "bracket_keywords": DEFAULT_BRACKET_KEYWORDS, } ) @@ -138,6 +222,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): bool ) custom_words = self.config["custom_words"].get(list) + bracket_keywords = self.config["bracket_keywords"].get(list) write = ui.should_write() for item in lib.items(args): @@ -147,6 +232,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, + bracket_keywords, ): item.store() if write: @@ -161,6 +247,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) + bracket_keywords = self.config["bracket_keywords"].get(list) for item in task.imported_items(): if self.ft_in_title( @@ -169,6 +256,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, + bracket_keywords, ): item.store() @@ -179,6 +267,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat: bool, keep_in_artist_field: bool, custom_words: list[str], + bracket_keywords: list[str] | None = None, ) -> 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. @@ -208,7 +297,14 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) - new_title = f"{item.title} {new_format}" + # Insert before the first bracket containing remix/edit keywords + bracket_pos = find_bracket_position(item.title, bracket_keywords) + if bracket_pos is not None: + title_before = item.title[:bracket_pos].rstrip() + title_after = item.title[bracket_pos:] + new_title = f"{title_before} {new_format} {title_after}" + else: + new_title = f"{item.title} {new_format}" self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -219,6 +315,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field: bool, preserve_album_artist: bool, custom_words: list[str], + bracket_keywords: list[str] | None = None, ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -250,6 +347,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, feat_part, drop_feat, keep_in_artist_field, custom_words + item, + feat_part, + drop_feat, + keep_in_artist_field, + custom_words, + bracket_keywords, ) return True diff --git a/docs/changelog.rst b/docs/changelog.rst index c5a0dab53..c3591b80e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,13 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. +- :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets + containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead + of being appended at the end. This improves formatting for titles like "Song 1 + (Carol Remix) ft. Bob" which becomes "Song 1 ft. Bob (Carol Remix)". A variety + of brackets are supported and a new ``bracket_keywords`` configuration option + allows customizing the keywords. Setting ``bracket_keywords`` to an empty list + matches any bracket content regardless of keywords. Bug fixes: diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b4259666d..b2e2bad9b 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -246,6 +246,49 @@ def add_item( ("Alice", "Song 1 feat. Bob"), id="skip-if-artist-and-album-artists-is-the-same-matching-match-b", ), + # ---- titles with brackets/parentheses ---- + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Carol Remix)"), + id="title-with-brackets-insert-before", + ), + pytest.param( + {"format": "ft. {}", "keep_in_artist": True}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), + ("Alice ft. Bob", "Song 1 ft. Bob (Carol Remix)"), + id="title-with-brackets-keep-in-artist", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Remix) (Live)", "Alice"), + ("Alice", "Song 1 ft. Bob (Remix) (Live)"), + id="title-with-multiple-brackets-uses-first-with-keyword", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Arbitrary)", "Alice"), + ("Alice", "Song 1 (Arbitrary) ft. Bob"), + id="title-with-brackets-no-keyword-appends", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 [Edit]", "Alice"), + ("Alice", "Song 1 ft. Bob [Edit]"), + id="title-with-square-brackets-keyword", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 ", "Alice"), + ("Alice", "Song 1 ft. Bob "), + id="title-with-angle-brackets-keyword", + ), ], ) def test_ftintitle_functional( @@ -312,6 +355,69 @@ def test_split_on_feat( assert ftintitle.split_on_feat(given) == expected +@pytest.mark.parametrize( + "given,expected", + [ + # different braces and keywords + ("Song (Remix)", 5), + ("Song [Version]", 5), + ("Song {Extended Mix}", 5), + ("Song ", 5), + # two keyword clauses + ("Song (Remix) (Live)", 5), + # brace insensitivity + ("Song (Live) [Remix]", 5), + ("Song [Edit] (Remastered)", 5), + # negative cases + ("Song", None), # no clause + ("Song (Arbitrary)", None), # no keyword + ("Song (", None), # no matching brace or keyword + ("Song (Live", None), # no matching brace with keyword + # one keyword clause, one non-keyword clause + ("Song (Live) (Arbitrary)", 5), + ("Song (Arbitrary) (Remix)", 17), + # nested brackets - same type + ("Song (Remix (Extended))", 5), + ("Song [Arbitrary [Description]]", None), + # nested brackets - different types + ("Song (Remix [Extended])", 5), + # nested - returns outer start position despite inner keyword + ("Song [Arbitrary {Extended}]", 5), + ("Song {Live }", 5), + ("Song ", 5), + ("Song [Live]", 5), + ("Song (Version) ", 5), + ("Song (Arbitrary [Description])", None), + ("Song [Description (Arbitrary)]", None), + ], +) +def test_find_bracket_position(given: str, expected: int | None) -> None: + assert ftintitle.find_bracket_position(given) == expected + + +@pytest.mark.parametrize( + "given,keywords,expected", + [ + ("Song (Live)", ["live"], 5), + ("Song (Live)", None, 5), + ("Song (Arbitrary)", None, None), + ("Song (Concert)", ["concert"], 5), + ("Song (Concert)", None, None), + ("Song (Remix)", ["custom"], None), + ("Song (Custom)", ["custom"], 5), + ("Song (Live)", [], 5), + ("Song (Anything)", [], 5), + ("Song (Remix)", [], 5), + ("Song", [], None), + ("Song (", [], None), + ], +) +def test_find_bracket_position_custom_keywords( + given: str, keywords: list[str] | None, expected: int | None +) -> None: + assert ftintitle.find_bracket_position(given, keywords) == expected + + @pytest.mark.parametrize( "given,expected", [