diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 9702bf9a5..d3d600958 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -105,7 +105,9 @@ def find_bracket_position( if not keywords: pattern = None else: - # Build regex pattern with word boundaries + # Build regex pattern to support multi-word keywords/phrases. + # Each keyword/phrase is escaped and surrounded by word boundaries at + # start and end, matching phrases like "club mix" as a whole. keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) pattern = re.compile(keyword_pattern, re.IGNORECASE) @@ -133,9 +135,10 @@ def find_bracket_position( # 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 + if (pattern is None or pattern.search(content)) and ( + earliest_pos is None or open_pos < earliest_pos + ): + earliest_pos = open_pos # Continue searching from after this closing bracket pos = close_pos + 1 @@ -194,7 +197,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": DEFAULT_BRACKET_KEYWORDS, + "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(), } ) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index fef1bc9bf..347e2792d 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -41,7 +41,7 @@ file. The available options are: :: ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", - "instrumental", "live", "mix", "radio", "release", "remastered" + "instrumental", "live", "mix", "radio", "release", "remaster", "remastered", "remix", "rmx", "unabridged", "unreleased", "version", "vip"] diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index a6be02b3b..065f23ade 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -301,6 +301,21 @@ class DummyImportTask: ("Alice", "Song 1 ft. Bob "), id="title-with-angle-brackets-keyword", ), + # multi-word keyword + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Club Mix)"), + id="multi-word-keyword-positive-match", + ), + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), + ("Alice", "Song 1 (Club Remix) ft. Bob"), + id="multi-word-keyword-negative-no-match", + ), ], ) def test_ftintitle_functional( @@ -447,6 +462,9 @@ def test_find_bracket_position(given: str, expected: int | None) -> None: ("Song (Remix)", [], 5), ("Song", [], None), ("Song (", [], None), + # Multi-word keyword tests + ("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], None), # Negative: no match ], ) def test_find_bracket_position_custom_keywords(