Skip to content

feat(ftintitle): Insert featured artist before track variant clause#6159

Merged
snejus merged 16 commits intobeetbox:masterfrom
treyturner:feat/ftintitle/insert_featured_artist_before_variant_clauses
Jan 4, 2026
Merged

feat(ftintitle): Insert featured artist before track variant clause#6159
snejus merged 16 commits intobeetbox:masterfrom
treyturner:feat/ftintitle/insert_featured_artist_before_variant_clauses

Conversation

@treyturner
Copy link
Contributor

@treyturner treyturner commented Nov 12, 2025

Summary

This PR updates the ftintitle plugin to insert featured artist tokens before brackets containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead of always appending them at the end of the title.

Motivation

Previously, the plugin would always append featured artists at the end of titles, resulting in awkward formatting like:

  • Threshold (Myselor Remix) ft. Hallucinator

With this change, featured artists are inserted before the first bracket containing keywords, producing cleaner formatting:

  • Threshold ft. Hallucinator (Myselor Remix)

Changes

Core Functionality

  • Added find_bracket_position() function that:
    • Searches for brackets containing remix/edit-related keywords
    • Supports multiple bracket types: (), [], <>, {}
    • Only matches brackets with matching opening/closing pairs
    • Uses case-insensitive word-boundary matching for keywords
    • Returns the position of the earliest matching bracket
  • Updated update_metadata() to insert featured artists before brackets instead of appending

Configuration

  • Added new bracket_keywords configuration option:
    • Default: List of keywords including: abridged, acapella, club, demo, edit, edition, extended, instrumental, live, mix, radio, release, remaster, remastered, remix, rmx, unabridged, unreleased, version, and vip
    • Customizable: Users can override with their own keyword list
    • Empty list: Setting to [] matches any bracket content regardless of keywords

Example Configuration

ftintitle:
    bracket_keywords: ["remix", "live", "edit", "version", "extended"]

Behavior

  • Titles with keyword brackets: Featured artists are inserted before the first bracket containing keywords

    • Song (Remix) ft. ArtistSong ft. Artist (Remix)
    • Song (Live) [Remix] ft. ArtistSong ft. Artist (Live) [Remix] (picks first bracket with keyword)
  • Titles without keyword brackets: Featured artists are appended at the end (backward compatible)

    • Song (Arbitrary) ft. ArtistSong (Arbitrary) ft. Artist
  • Nested brackets: Correctly handles nested brackets of same and different types

    • Song (Remix [Extended]) ft. ArtistSong ft. Artist (Remix [Extended])
  • Multiple brackets: Picks the earliest bracket containing keywords

    • Song (Live) (Remix) ft. ArtistSong ft. Artist (Live) (Remix) (if both contain keywords, picks first)

Testing

  • Added comprehensive test coverage for:
    • Different bracket types ((), [], <>, {})
    • Nested brackets (same and different types)
    • Multiple brackets
    • Custom keywords
    • Empty keyword list behavior
    • Edge cases (unmatched brackets, no brackets, etc.)

All 112 tests pass.

Backward Compatibility

This change is backward compatible:

  • Titles without brackets continue to append featured artists at the end
  • Titles with brackets that don't contain keywords also append at the end
  • Existing configuration files continue to work (uses sensible defaults)

Documentation

  • Updated changelog with detailed description of the new feature
  • Configuration option is documented in the changelog entry

@treyturner treyturner force-pushed the feat/ftintitle/insert_featured_artist_before_variant_clauses branch from 1a4427c to 59e6e34 Compare November 12, 2025 13:04
@codecov
Copy link

codecov bot commented Nov 12, 2025

Codecov Report

❌ Patch coverage is 84.00000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.24%. Comparing base (afc26fa) to head (714c970).
⚠️ Report is 57 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/ftintitle.py 84.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6159      +/-   ##
==========================================
+ Coverage   68.22%   68.24%   +0.01%     
==========================================
  Files         138      138              
  Lines       18792    18815      +23     
  Branches     3167     3167              
==========================================
+ Hits        12821    12840      +19     
- Misses       5298     5302       +4     
  Partials      673      673              
Files with missing lines Coverage Δ
beetsplug/ftintitle.py 84.68% <84.00%> (-0.55%) ⬇️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@treyturner treyturner force-pushed the feat/ftintitle/insert_featured_artist_before_variant_clauses branch 3 times, most recently from b500a52 to 7bb39b4 Compare November 15, 2025 23:31
@treyturner treyturner marked this pull request as ready for review November 16, 2025 01:00
@treyturner treyturner requested a review from a team as a code owner November 16, 2025 01:00
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `beetsplug/ftintitle.py:105-110` </location>
<code_context>
+    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)
+
</code_context>

<issue_to_address>
**suggestion:** Word boundary usage may not match multi-word keywords as intended.

The current regex may not correctly match multi-word keywords. Please update the pattern to support phrases, or document that only single-word keywords are handled.

```suggestion
    if not keywords:
        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.
        # This allows matching phrases like "club mix" as a whole.
        keyword_pattern = "|".join(
            rf"\b{re.escape(kw)}\b" if " " not in kw else rf"\b{re.escape(kw)}\b"
            for kw in keywords
        )
        pattern = re.compile(keyword_pattern, re.IGNORECASE)
```
</issue_to_address>

### Comment 2
<location> `beetsplug/ftintitle.py:197` </location>
<code_context>
                 "keep_in_artist": False,
                 "preserve_album_artist": True,
                 "custom_words": [],
+                "bracket_keywords": DEFAULT_BRACKET_KEYWORDS,
             }
         )
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Storing DEFAULT_BRACKET_KEYWORDS directly in config may cause issues if the list is mutated.

Copy DEFAULT_BRACKET_KEYWORDS when assigning to config to prevent changes to config from affecting the global default.

```suggestion
                "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(),
```
</issue_to_address>

### Comment 3
<location> `test/plugins/test_ftintitle.py:262` </location>
<code_context>
             id="skip-if-artist-and-album-artists-is-the-same-matching-match-b",
         ),
+        # ---- titles with brackets/parentheses ----
+        pytest.param(
+            {"format": "ft. {}"},
+            ("ftintitle",),
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for unmatched or misordered brackets.

Please add tests for cases with misordered or overlapping brackets, such as "Song (Remix]" or "Song [Remix)", to verify the function handles these scenarios correctly.
</issue_to_address>

### Comment 4
<location> `test/plugins/test_ftintitle.py:83-85` </location>
<code_context>
+        return self._items
+
+
 @pytest.mark.parametrize(
     "cfg, cmd_args, given, expected",
     [
</code_context>

<issue_to_address>
**suggestion (testing):** Comprehensive coverage of bracket position detection, but missing test for multiple bracket types with keywords in both.

Please add a test case where different bracket types each contain keywords, to verify that the earliest bracket is selected regardless of bracket type.
</issue_to_address>

### Comment 5
<location> `docs/plugins/ftintitle.rst:43` </location>
<code_context>
+
+  ::
+
+      ["abridged", "acapella", "club", "demo", "edit", "edition", "extended",
+       "instrumental", "live", "mix", "radio", "release", "remastered"
+       "remastered", "remix", "rmx", "unabridged", "unreleased",
</code_context>

<issue_to_address>
**issue (bug_risk):** Missing comma between "remastered" entries in the default list.

A missing comma here will cause a syntax error in the list definition.
</issue_to_address>

### Comment 6
<location> `beetsplug/ftintitle.py:136-138` </location>
<code_context>
            if pattern is None or pattern.search(content):
                if earliest_pos is None or open_pos < earliest_pos:
                    earliest_pos = open_pos

</code_context>

<issue_to_address>
**suggestion (code-quality):** Merge nested if conditions ([`merge-nested-ifs`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/merge-nested-ifs))

```suggestion
            if (pattern is None or pattern.search(content)) and (earliest_pos is None or open_pos < earliest_pos):
                earliest_pos = open_pos

```

<br/><details><summary>Explanation</summary>Too much nesting can make code difficult to understand, and this is especially
true in Python, where there are no brackets to help out with the delineation of
different nesting levels.

Reading deeply nested code is confusing, since you have to keep track of which
conditions relate to which levels. We therefore strive to reduce nesting where
possible, and the situation where two `if` conditions can be combined using
`and` is an easy win.
</details>
</issue_to_address>

### Comment 7
<location> `beetsplug/ftintitle.py:299` </location>
<code_context>
    def update_metadata(
        self,
        item: Item,
        feat_part: str,
        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.
        If `drop_feat` is set, then do not add the artist to the title; just
        remove it from the artist field.
        """
        # In case the artist is kept, do not update the artist fields.
        if keep_in_artist_field:
            self._log.info(
                "artist: {.artist} (Not changing due to keep_in_artist)", item
            )
        else:
            track_artist, _ = split_on_feat(
                item.artist, custom_words=custom_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, custom_words=custom_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, 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}"
            self._log.info("title: {.title} -> {}", item, new_title)
            item.title = new_title

</code_context>

<issue_to_address>
**issue (code-quality):** Extract code out into method ([`extract-method`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/extract-method/))
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Member

@snejus snejus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a couple of suggestions to slightly simplify the structure

@treyturner
Copy link
Contributor Author

Added a couple of suggestions to slightly simplify the structure

Thanks, will get to these when I can!

@treyturner treyturner force-pushed the feat/ftintitle/insert_featured_artist_before_variant_clauses branch from 89a5c97 to f6cf36d Compare November 17, 2025 18:56
@treyturner treyturner force-pushed the feat/ftintitle/insert_featured_artist_before_variant_clauses branch from f6cf36d to 3051af9 Compare November 17, 2025 19:04
@treyturner
Copy link
Contributor Author

@snejus Thanks for the review, these were all good points. I took a stab at addressing them but am happy to respond to anything else you find.

@treyturner treyturner requested a review from snejus November 17, 2025 19:59
Copy link
Member

@snejus snejus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your adjustments! Another round of comments - I've covered everything now :)

@treyturner treyturner requested a review from snejus December 20, 2025 08:38
@treyturner
Copy link
Contributor Author

Ok, ready for another round I think @snejus. Sorry for the delay! The longer it took me to get back to this, the longer it was taking to resolve the comments.

Copy link
Member

@snejus snejus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a couple of suggestions.

Apologies for being so needy 😅 I just want to make sure that the code you will merge in won't ever need to get adjusted :)

@treyturner treyturner requested a review from snejus January 1, 2026 21:41
@treyturner
Copy link
Contributor Author

Oops, didn't mean to ping again if that ended up happening... Just couldn't tell if I had marked the change as (hopefully) addressed.

Copy link
Member

@snejus snejus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great, thanks for your patience @treyturner! <3

@snejus snejus merged commit ea2e7bf into beetbox:master Jan 4, 2026
17 checks passed
@JOJ0 JOJ0 added the plugin Pull requests that are plugins related label Jan 10, 2026
@treyturner treyturner deleted the feat/ftintitle/insert_featured_artist_before_variant_clauses branch January 18, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ftintitle plugin Pull requests that are plugins related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants