From 1d239d6e27c4f4ce4ab36566af4569cf294042dd Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Wed, 12 Nov 2025 07:03:30 -0600 Subject: [PATCH 01/13] feat(ftintitle): Insert featured artist before track variant --- beetsplug/ftintitle.py | 106 ++++++++++++++++++++++++++++++++- docs/changelog.rst | 7 +++ test/plugins/test_ftintitle.py | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) 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", [ From 62e1a41ff2e6242cdae54ce63236af0e6c105514 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:19:12 -0600 Subject: [PATCH 02/13] chore(ftintitle): add 'edition' to keyword defaults --- beetsplug/ftintitle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index cec22af3f..9702bf9a5 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -66,6 +66,7 @@ DEFAULT_BRACKET_KEYWORDS = [ "club", "demo", "edit", + "edition", "extended", "instrumental", "live", From 15daebb55f9b356cdd2ac5687d7af5c4ef53f67b Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:31:40 -0600 Subject: [PATCH 03/13] test(ftintitle): mock import task to exercise import hooks --- test/plugins/test_ftintitle.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b2e2bad9b..a6be02b3b 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,9 +15,11 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator +from typing import cast import pytest +from beets.importer import ImportSession, ImportTask from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -68,6 +70,16 @@ def add_item( ) +class DummyImportTask: + """Minimal stand-in for ImportTask used to exercise import hooks.""" + + def __init__(self, items: list[Item]) -> None: + self._items = items + + def imported_items(self) -> list[Item]: + return self._items + + @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ @@ -312,6 +324,31 @@ def test_ftintitle_functional( assert item["title"] == expected_title +def test_imported_stage_moves_featured_artist( + env: FtInTitlePluginFunctional, +) -> None: + """The import-stage hook should fetch config settings and process items.""" + set_config(env, None) + plugin = ftintitle.FtInTitlePlugin() + item = add_item( + env, + "/imported-hook", + "Alice feat. Bob", + "Song 1 (Carol Remix)", + "Various Artists", + ) + task = DummyImportTask([item]) + + plugin.imported( + cast(ImportSession, None), + cast(ImportTask, task), + ) + item.load() + + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 feat. Bob (Carol Remix)" + + @pytest.mark.parametrize( "artist,albumartist,expected", [ From a9ed637c407c6788e0510e75fa98261c9afb31ad Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:31:49 -0600 Subject: [PATCH 04/13] docs(ftintitle): add bracket_keywords --- docs/plugins/ftintitle.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1d2ec5c20..fef1bc9bf 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -32,6 +32,18 @@ file. The available options are: skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. +- **bracket_keywords**: Controls where the featuring text is inserted when the + title includes bracketed qualifiers such as ``(Remix)`` or ``[Live]``. + FtInTitle inserts the new text before the first bracket whose contents match + any of these keywords. Supply a list of words to fine-tune the behavior or set + the list to ``[]`` to match *any* bracket regardless of its contents. Default: + + :: + + ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", + "instrumental", "live", "mix", "radio", "release", "remastered" + "remastered", "remix", "rmx", "unabridged", "unreleased", + "version", "vip"] Running Manually ---------------- From 3dd3bf5640eebb0da282edae4169e8a636b1aeac Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 22:34:43 -0600 Subject: [PATCH 05/13] fix: address sourcery comments --- beetsplug/ftintitle.py | 13 ++++++++----- docs/plugins/ftintitle.rst | 2 +- test/plugins/test_ftintitle.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) 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( From 2aa949e5a0f9da3e079296b376f0b8e72d3da70b Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 16 Nov 2025 19:48:46 -0600 Subject: [PATCH 06/13] fix(fitintitle): simplify keyword_pattern using map() instead of list comprehension --- beetsplug/ftintitle.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index d3d600958..4d0821593 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -106,9 +106,7 @@ def find_bracket_position( pattern = None else: # 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) + keyword_pattern = rf"\b{'|'.join(map(re.escape, keywords))}\b" pattern = re.compile(keyword_pattern, re.IGNORECASE) # Bracket pairs (opening, closing) From 50e55f85f4fd231d0023353355faed32cb007611 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 16 Nov 2025 19:54:27 -0600 Subject: [PATCH 07/13] fix(ftintitle): prune find_bracket_position docstring --- beetsplug/ftintitle.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 4d0821593..629e58f17 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -89,14 +89,6 @@ def find_bracket_position( ) -> 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 From 3051af9eb64c60f7334c29fdf58013055cb245b3 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Mon, 17 Nov 2025 12:56:19 -0600 Subject: [PATCH 08/13] fix: abstract insert_ft_into_title, move bracket_keywords and find_bracket_position inside plugin --- beetsplug/ftintitle.py | 117 ++++++++++++++------------------- test/plugins/test_ftintitle.py | 37 +++++++++-- 2 files changed, 79 insertions(+), 75 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 629e58f17..06c5e69be 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,6 +17,7 @@ from __future__ import annotations import re +from functools import cached_property from typing import TYPE_CHECKING from beets import plugins, ui @@ -84,58 +85,6 @@ DEFAULT_BRACKET_KEYWORDS = [ ] -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. - """ - 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 to support multi-word keywords/phrases. - keyword_pattern = rf"\b{'|'.join(map(re.escape, keywords))}\b" - 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)) 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 - - return earliest_pos - - def find_feat_part( artist: str, albumartist: str | None, @@ -176,6 +125,10 @@ def find_feat_part( class FtInTitlePlugin(plugins.BeetsPlugin): + @cached_property + def bracket_keywords(self) -> list[str] | None: + return self.config["bracket_keywords"].as_str_seq() + def __init__(self) -> None: super().__init__() @@ -216,7 +169,6 @@ 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): @@ -226,7 +178,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, - bracket_keywords, ): item.store() if write: @@ -241,7 +192,6 @@ 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( @@ -250,7 +200,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, - bracket_keywords, ): item.store() @@ -261,7 +210,6 @@ 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. @@ -290,15 +238,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # artist and if we do not drop featuring information. 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) - # 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}" + formatted = feat_format.format(feat_part) + new_title = self.insert_ft_into_title(item.title, formatted) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -309,7 +250,6 @@ 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. @@ -346,6 +286,47 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat, keep_in_artist_field, custom_words, - bracket_keywords, ) return True + + def find_bracket_position( + self, + title: str, + ) -> int | None: + """Find the position of the first opening bracket that contains + remix/edit-related keywords and has a matching closing bracket. + """ + keywords = self.bracket_keywords + + # If keywords is empty, match any bracket content + if not keywords: + keyword_ptn = ".*?" + else: + # Build regex supporting keywords/multi-word phrases. + keyword_ptn = rf"\b{'|'.join(map(re.escape, keywords))}\b" + + pattern = re.compile( + rf""" + \(.*?({keyword_ptn}).*?\) | + \[.*?({keyword_ptn}).*?\] | + <.*?({keyword_ptn}).*?> | + \{{.*?({keyword_ptn}).*?}} + """, + re.IGNORECASE | re.VERBOSE, + ) + + return m.start() if (m := pattern.search(title)) else None + + def insert_ft_into_title( + self, + title: str, + feat_part: str, + ) -> str: + """Insert featured artist before the first bracket containing + remix/edit keywords if present. + """ + if (bracket_pos := self.find_bracket_position(title)) 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}" diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 065f23ade..73853e6c3 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,7 +15,7 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator -from typing import cast +from typing import TypeAlias, cast import pytest @@ -24,6 +24,8 @@ from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle +ConfigValue: TypeAlias = str | bool | list[str] + class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" @@ -41,7 +43,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( env: FtInTitlePluginFunctional, - cfg: dict[str, str | bool | list[str]] | None, + cfg: dict[str, ConfigValue] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -49,11 +51,21 @@ def set_config( "auto": True, "keep_in_artist": False, "custom_words": [], + "bracket_keywords": ftintitle.DEFAULT_BRACKET_KEYWORDS.copy(), } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) +def build_plugin( + env: FtInTitlePluginFunctional, + cfg: dict[str, ConfigValue] | None = None, +) -> ftintitle.FtInTitlePlugin: + """Instantiate plugin with provided config applied first.""" + set_config(env, cfg) + return ftintitle.FtInTitlePlugin() + + def add_item( env: FtInTitlePluginFunctional, path: str, @@ -427,7 +439,7 @@ def test_split_on_feat( ("Song (Live", None), # no matching brace with keyword # one keyword clause, one non-keyword clause ("Song (Live) (Arbitrary)", 5), - ("Song (Arbitrary) (Remix)", 17), + ("Song (Arbitrary) (Remix)", 5), # nested brackets - same type ("Song (Remix (Extended))", 5), ("Song [Arbitrary [Description]]", None), @@ -443,8 +455,13 @@ def test_split_on_feat( ("Song [Description (Arbitrary)]", None), ], ) -def test_find_bracket_position(given: str, expected: int | None) -> None: - assert ftintitle.find_bracket_position(given) == expected +def test_find_bracket_position( + env: FtInTitlePluginFunctional, + given: str, + expected: int | None, +) -> None: + plugin = build_plugin(env) + assert plugin.find_bracket_position(given) == expected @pytest.mark.parametrize( @@ -468,9 +485,15 @@ def test_find_bracket_position(given: str, expected: int | None) -> None: ], ) def test_find_bracket_position_custom_keywords( - given: str, keywords: list[str] | None, expected: int | None + env: FtInTitlePluginFunctional, + given: str, + keywords: list[str] | None, + expected: int | None, ) -> None: - assert ftintitle.find_bracket_position(given, keywords) == expected + cfg: dict[str, ConfigValue] | None + cfg = None if keywords is None else {"bracket_keywords": keywords} + plugin = build_plugin(env, cfg) + assert plugin.find_bracket_position(given) == expected @pytest.mark.parametrize( From 84d37b820a9b80259185da9be172a0d6aa85fe8f Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 14 Dec 2025 18:15:08 -0600 Subject: [PATCH 09/13] fix: inline default bracket_keywords instead of defining/cloning constant --- beetsplug/ftintitle.py | 48 +++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 06c5e69be..a81c58574 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -60,31 +60,6 @@ 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", - "edition", - "extended", - "instrumental", - "live", - "mix", - "radio", - "release", - "remaster", - "remastered", - "remix", - "rmx", - "unabridged", - "unreleased", - "version", - "vip", -] - - def find_feat_part( artist: str, albumartist: str | None, @@ -140,7 +115,28 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(), + "bracket_keywords": [ + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", + ], } ) From ef40d1ac536bd979392acd4236f08203242c8616 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 14 Dec 2025 18:19:38 -0600 Subject: [PATCH 10/13] fix: revert needless whitespace change --- beetsplug/ftintitle.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index a81c58574..e6c8c897a 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -277,11 +277,7 @@ 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 ) return True From 00792922b58b6a68d4c5e5fc32416ea926a98290 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 20 Dec 2025 02:19:54 -0600 Subject: [PATCH 11/13] fix: address remaining review comments --- beetsplug/ftintitle.py | 140 +++++++++++++++--------- test/plugins/test_ftintitle.py | 194 ++++++++------------------------- 2 files changed, 130 insertions(+), 204 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index e6c8c897a..b8bd3e261 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,7 +17,7 @@ from __future__ import annotations import re -from functools import cached_property +from functools import cached_property, lru_cache from typing import TYPE_CHECKING from beets import plugins, ui @@ -99,11 +99,78 @@ def find_feat_part( return feat_part +DEFAULT_BRACKET_KEYWORDS: tuple[str, ...] = ( + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +) + + class FtInTitlePlugin(plugins.BeetsPlugin): @cached_property - def bracket_keywords(self) -> list[str] | None: + def bracket_keywords(self) -> list[str]: return self.config["bracket_keywords"].as_str_seq() + @staticmethod + @lru_cache(maxsize=256) + def _bracket_position_pattern(keywords: tuple[str, ...]) -> re.Pattern[str]: + """ + Build a compiled regex to find the first bracketed segment that contains + any of the provided keywords. + + Cached by keyword tuple to avoid recompiling on every track/title. + """ + kw_inner = "|".join(map(re.escape, keywords)) + + # If we have keywords, require one of them to appear in the bracket text. + # If kw == "", the lookahead becomes trivially true and we match any bracket content. + 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 + """, + re.IGNORECASE | re.VERBOSE, + ) + def __init__(self) -> None: super().__init__() @@ -115,28 +182,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": [ - "abridged", - "acapella", - "club", - "demo", - "edit", - "edition", - "extended", - "instrumental", - "live", - "mix", - "radio", - "release", - "remaster", - "remastered", - "remix", - "rmx", - "unabridged", - "unreleased", - "version", - "vip", - ], + "bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS), } ) @@ -235,7 +281,9 @@ 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 = self.insert_ft_into_title(item.title, formatted) + new_title = FtInTitlePlugin.insert_ft_into_title( + item.title, formatted, self.bracket_keywords + ) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -281,43 +329,29 @@ class FtInTitlePlugin(plugins.BeetsPlugin): ) return True + @staticmethod def find_bracket_position( - self, - title: str, + 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. - """ - keywords = self.bracket_keywords - - # If keywords is empty, match any bracket content - if not keywords: - keyword_ptn = ".*?" - else: - # Build regex supporting keywords/multi-word phrases. - keyword_ptn = rf"\b{'|'.join(map(re.escape, keywords))}\b" - - pattern = re.compile( - rf""" - \(.*?({keyword_ptn}).*?\) | - \[.*?({keyword_ptn}).*?\] | - <.*?({keyword_ptn}).*?> | - \{{.*?({keyword_ptn}).*?}} - """, - re.IGNORECASE | re.VERBOSE, + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) ) + pattern = FtInTitlePlugin._bracket_position_pattern(normalized) + m: re.Match[str] | None = pattern.search(title) + return m.start() if m else None - return m.start() if (m := pattern.search(title)) else None - + @staticmethod def insert_ft_into_title( - self, - title: str, - feat_part: str, + 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 := self.find_bracket_position(title)) is not None: + 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}" diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 73853e6c3..9d6b54a93 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,11 +15,10 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator -from typing import TypeAlias, cast +from typing import TypeAlias import pytest -from beets.importer import ImportSession, ImportTask from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -51,21 +50,11 @@ def set_config( "auto": True, "keep_in_artist": False, "custom_words": [], - "bracket_keywords": ftintitle.DEFAULT_BRACKET_KEYWORDS.copy(), } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) -def build_plugin( - env: FtInTitlePluginFunctional, - cfg: dict[str, ConfigValue] | None = None, -) -> ftintitle.FtInTitlePlugin: - """Instantiate plugin with provided config applied first.""" - set_config(env, cfg) - return ftintitle.FtInTitlePlugin() - - def add_item( env: FtInTitlePluginFunctional, path: str, @@ -82,16 +71,6 @@ def add_item( ) -class DummyImportTask: - """Minimal stand-in for ImportTask used to exercise import hooks.""" - - def __init__(self, items: list[Item]) -> None: - self._items = items - - def imported_items(self) -> list[Item]: - return self._items - - @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ @@ -272,61 +251,18 @@ class DummyImportTask: ), # ---- 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", - ), - # multi-word keyword - pytest.param( - {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + {"format": "ft. {}", "bracket_keywords": ["mix"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), ("Alice", "Song 1 ft. Bob (Club Mix)"), - id="multi-word-keyword-positive-match", + id="ft-inserted-before-matching-bracket-keyword", ), pytest.param( - {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + {"format": "ft. {}", "bracket_keywords": ["nomatch"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), ("Alice", "Song 1 (Club Remix) ft. Bob"), - id="multi-word-keyword-negative-no-match", + id="ft-inserted-at-end-no-bracket-keyword-match", ), ], ) @@ -351,31 +287,6 @@ def test_ftintitle_functional( assert item["title"] == expected_title -def test_imported_stage_moves_featured_artist( - env: FtInTitlePluginFunctional, -) -> None: - """The import-stage hook should fetch config settings and process items.""" - set_config(env, None) - plugin = ftintitle.FtInTitlePlugin() - item = add_item( - env, - "/imported-hook", - "Alice feat. Bob", - "Song 1 (Carol Remix)", - "Various Artists", - ) - task = DummyImportTask([item]) - - plugin.imported( - cast(ImportSession, None), - cast(ImportTask, task), - ) - item.load() - - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 feat. Bob (Carol Remix)" - - @pytest.mark.parametrize( "artist,albumartist,expected", [ @@ -419,64 +330,46 @@ 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)", 5), - # 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( - env: FtInTitlePluginFunctional, - given: str, - expected: int | None, -) -> None: - plugin = build_plugin(env) - assert plugin.find_bracket_position(given) == expected - - @pytest.mark.parametrize( "given,keywords,expected", [ + ## default keywords + # different braces and keywords + ("Song (Remix)", None, 5), + ("Song [Version]", None, 5), + ("Song {Extended Mix}", None, 5), + ("Song ", None, 5), + # two keyword clauses + ("Song (Remix) (Live)", None, 5), + # brace insensitivity + ("Song (Live) [Remix]", None, 5), + ("Song [Edit] (Remastered)", None, 5), + # 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 + # one keyword clause, one non-keyword clause + ("Song (Live) (Arbitrary)", None, 5), + ("Song (Arbitrary) (Remix)", None, 17), + # nested brackets - same type + ("Song (Remix (Extended))", None, 5), + ("Song [Arbitrary [Description]]", None, None), + # nested brackets - different types + ("Song (Remix [Extended])", None, 5), + # 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), + ## custom keywords ("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), # Multi-word keyword tests @@ -484,16 +377,15 @@ def test_find_bracket_position( ("Song (Club Remix)", ["club mix"], None), # Negative: no match ], ) -def test_find_bracket_position_custom_keywords( - env: FtInTitlePluginFunctional, +def test_find_bracket_position( given: str, keywords: list[str] | None, expected: int | None, ) -> None: - cfg: dict[str, ConfigValue] | None - cfg = None if keywords is None else {"bracket_keywords": keywords} - plugin = build_plugin(env, cfg) - assert plugin.find_bracket_position(given) == expected + assert ( + ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords) + == expected + ) @pytest.mark.parametrize( From c0c7a9df8f487b7ca029a9f44800090c4a0e850f Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 20 Dec 2025 02:34:15 -0600 Subject: [PATCH 12/13] fix: line length --- beetsplug/ftintitle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index eafc9e191..44f17bc4e 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -145,7 +145,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): kw_inner = "|".join(map(re.escape, keywords)) # If we have keywords, require one of them to appear in the bracket text. - # If kw == "", the lookahead becomes trivially true and we match any bracket content. + # If kw == "", the lookahead becomes true and we match any bracket content. kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" return re.compile( From b14755df881b46a5fcce1ada4d5d895c5e54a331 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Thu, 1 Jan 2026 15:39:17 -0600 Subject: [PATCH 13/13] fix(ftintitle): remaining opportunities for improvement --- beetsplug/ftintitle.py | 56 ++++++++++---------------- pyproject.toml | 1 + test/plugins/test_ftintitle.py | 72 +++++++++++++++++----------------- 3 files changed, 58 insertions(+), 71 deletions(-) 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 )