diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 44f17bc4e..cf30e83f4 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -146,32 +146,19 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have keywords, require one of them to appear in the bracket text. # If kw == "", the lookahead becomes true and we match any bracket content. - kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" - + kw = rf"\b(?={kw_inner})\b" if kw_inner else "" return re.compile( rf""" - (?: # Match ONE bracketed segment of any supported type - \( # "(" - (?=[^)]*{kw}) # Lookahead: keyword must appear before closing ")" - # - if kw == "", this is always true - [^)]* # Consume bracket content (no nested ")" handling) - \) # ")" - - | \[ # "[" - (?=[^\]]*{kw}) # Lookahead - [^\]]* # Consume content up to first "]" - \] # "]" - - | < # "<" - (?=[^>]*{kw}) # Lookahead - [^>]* # Consume content up to first ">" - > # ">" - - | \x7B # Literal open brace - (?=[^\x7D]*{kw}) # Lookahead - [^\x7D]* # Consume content up to first close brace - \x7D # Literal close brace - ) # End bracketed segment alternation + (?: # non-capturing group for the split + \s*? # optional whitespace before brackets + (?= # any bracket containing a keyword + \([^)]*{kw}.*?\) + | \[[^]]*{kw}.*?\] + | <[^>]*{kw}.*? > + | \{{[^}}]*{kw}.*?\}} + | $ # or the end of the string + ) + ) """, re.IGNORECASE | re.VERBOSE, ) @@ -290,7 +277,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() formatted = feat_format.format(feat_part) - new_title = FtInTitlePlugin.insert_ft_into_title( + new_title = self.insert_ft_into_title( item.title, formatted, self.bracket_keywords ) self._log.info("title: {.title} -> {}", item, new_title) @@ -349,19 +336,16 @@ class FtInTitlePlugin(plugins.BeetsPlugin): m: re.Match[str] | None = pattern.search(title) return m.start() if m else None - @staticmethod + @classmethod def insert_ft_into_title( - title: str, feat_part: str, keywords: list[str] | None = None + cls, title: str, feat_part: str, keywords: list[str] | None = None ) -> str: """Insert featured artist before the first bracket containing remix/edit keywords if present. """ - if ( - bracket_pos := FtInTitlePlugin.find_bracket_position( - title, keywords - ) - ) is not None: - title_before = title[:bracket_pos].rstrip() - title_after = title[bracket_pos:] - return f"{title_before} {feat_part} {title_after}" - return f"{title} {feat_part}" + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) + ) + pattern = cls._bracket_position_pattern(normalized) + parts = pattern.split(title, maxsplit=1) + return f" {feat_part} ".join(parts).strip() diff --git a/pyproject.toml b/pyproject.toml index 24cf21b33..06552f124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -322,6 +322,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] "test/test_util.py" = ["E501"] +"test/plugins/test_ftintitle.py" = ["E501"] [tool.ruff.lint.isort] split-on-trailing-comma = false diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index abba22d11..b21ac1c7f 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -335,55 +335,57 @@ def test_split_on_feat( [ ## default keywords # different braces and keywords - ("Song (Remix)", None, 5), - ("Song [Version]", None, 5), - ("Song {Extended Mix}", None, 5), - ("Song ", None, 5), + ("Song (Remix)", None, "Song ft. Bob (Remix)"), + ("Song [Version]", None, "Song ft. Bob [Version]"), + ("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"), + ("Song ", None, "Song ft. Bob "), # two keyword clauses - ("Song (Remix) (Live)", None, 5), + ("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"), # brace insensitivity - ("Song (Live) [Remix]", None, 5), - ("Song [Edit] (Remastered)", None, 5), + ("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"), + ("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"), # negative cases - ("Song", None, None), # no clause - ("Song (Arbitrary)", None, None), # no keyword - ("Song (", None, None), # no matching brace or keyword - ("Song (Live", None, None), # no matching brace with keyword + ("Song", None, "Song ft. Bob"), # no clause + ("Song (Arbitrary)", None, "Song (Arbitrary) ft. Bob"), # no keyword + ("Song (", None, "Song ( ft. Bob"), # no matching brace or keyword + ("Song (Live", None, "Song (Live ft. Bob"), # no matching brace with keyword # one keyword clause, one non-keyword clause - ("Song (Live) (Arbitrary)", None, 5), - ("Song (Arbitrary) (Remix)", None, 17), + ("Song (Live) (Arbitrary)", None, "Song ft. Bob (Live) (Arbitrary)"), + ("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"), # nested brackets - same type - ("Song (Remix (Extended))", None, 5), - ("Song [Arbitrary [Description]]", None, None), + ("Song (Remix (Extended))", None, "Song ft. Bob (Remix (Extended))"), + ("Song [Arbitrary [Description]]", None, "Song [Arbitrary [Description]] ft. Bob"), # nested brackets - different types - ("Song (Remix [Extended])", None, 5), + ("Song (Remix [Extended])", None, "Song ft. Bob (Remix [Extended])"), # nested - returns outer start position despite inner keyword - ("Song [Arbitrary {Extended}]", None, 5), - ("Song {Live }", None, 5), - ("Song ", None, 5), - ("Song [Live]", None, 5), - ("Song (Version) ", None, 5), - ("Song (Arbitrary [Description])", None, None), - ("Song [Description (Arbitrary)]", None, None), + ("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"), + ("Song {Live }", None, "Song ft. Bob {Live }"), + ("Song ", None, "Song ft. Bob "), + ("Song [Live]", None, "Song ft. Bob [Live]"), + ("Song (Version) ", None, "Song ft. Bob (Version) "), + ("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"), + ("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"), ## custom keywords - ("Song (Live)", ["live"], 5), - ("Song (Concert)", ["concert"], 5), - ("Song (Remix)", ["custom"], None), - ("Song (Custom)", ["custom"], 5), - ("Song", [], None), - ("Song (", [], None), + ("Song (Live)", ["live"], "Song ft. Bob (Live)"), + ("Song (Concert)", ["concert"], "Song ft. Bob (Concert)"), + ("Song (Remix)", ["custom"], "Song (Remix) ft. Bob"), + ("Song (Custom)", ["custom"], "Song ft. Bob (Custom)"), + ("Song", [], "Song ft. Bob"), + ("Song (", [], "Song ( ft. Bob"), # Multi-word keyword tests - ("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word - ("Song (Club Remix)", ["club mix"], None), # Negative: no match + ("Song (Club Mix)", ["club mix"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match ], -) -def test_find_bracket_position( +) # fmt: skip +def test_insert_ft_into_title( given: str, keywords: list[str] | None, - expected: int | None, + expected: str, ) -> None: assert ( - ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords) + ftintitle.FtInTitlePlugin.insert_ft_into_title( + given, "ft. Bob", keywords + ) == expected )