mirror of
https://github.com/beetbox/beets.git
synced 2026-02-28 10:15:23 +01:00
feat(ftintitle): Insert featured artist before track variant clause (#6159)
## 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
```yaml
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. Artist` → `Song ft. Artist (Remix)`
- `Song (Live) [Remix] ft. Artist` → `Song 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. Artist` → `Song (Arbitrary) ft. Artist`
- **Nested brackets**: Correctly handles nested brackets of same and
different types
- `Song (Remix [Extended]) ft. Artist` → `Song ft. Artist (Remix
[Extended])`
- **Multiple brackets**: Picks the earliest bracket containing keywords
- `Song (Live) (Remix) ft. Artist` → `Song 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
This commit is contained in:
commit
ea2e7bf997
5 changed files with 188 additions and 3 deletions
|
|
@ -17,6 +17,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import cached_property, lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beets import config, plugins, ui
|
||||
|
|
@ -26,6 +27,30 @@ if TYPE_CHECKING:
|
|||
from beets.library import Album, Item
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def split_on_feat(
|
||||
artist: str,
|
||||
for_artist: bool = True,
|
||||
|
|
@ -104,6 +129,40 @@ def _album_artist_no_feat(album: Album) -> str:
|
|||
|
||||
|
||||
class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||
@cached_property
|
||||
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 true and we match any bracket content.
|
||||
kw = rf"\b(?={kw_inner})\b" if kw_inner else ""
|
||||
return re.compile(
|
||||
rf"""
|
||||
(?: # 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,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
|
|
@ -115,6 +174,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
"keep_in_artist": False,
|
||||
"preserve_album_artist": True,
|
||||
"custom_words": [],
|
||||
"bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -216,8 +276,10 @@ 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)
|
||||
new_title = f"{item.title} {new_format}"
|
||||
formatted = feat_format.format(feat_part)
|
||||
new_title = self.insert_ft_into_title(
|
||||
item.title, formatted, self.bracket_keywords
|
||||
)
|
||||
self._log.info("title: {.title} -> {}", item, new_title)
|
||||
item.title = new_title
|
||||
|
||||
|
|
@ -262,3 +324,28 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
item, feat_part, drop_feat, keep_in_artist_field, custom_words
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def find_bracket_position(
|
||||
title: str, keywords: list[str] | None = None
|
||||
) -> int | None:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def insert_ft_into_title(
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ New features:
|
|||
resolve differences in metadata source styles.
|
||||
- :doc:`plugins/spotify`: Added support for multi-artist albums and tracks,
|
||||
saving all contributing artists to the respective fields.
|
||||
- :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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "remaster",
|
||||
"remastered", "remix", "rmx", "unabridged", "unreleased",
|
||||
"version", "vip"]
|
||||
|
||||
Path Template Values
|
||||
--------------------
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ ignore = [
|
|||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"beets/**" = ["PT"]
|
||||
"test/plugins/test_ftintitle.py" = ["E501"]
|
||||
"test/test_util.py" = ["E501"]
|
||||
"test/ui/test_field_diff.py" = ["E501"]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"""Tests for the 'ftintitle' plugin."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import TypeAlias
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ from beets.library.models import Album, Item
|
|||
from beets.test.helper import PluginTestCase
|
||||
from beetsplug import ftintitle
|
||||
|
||||
ConfigValue: TypeAlias = str | bool | list[str]
|
||||
|
||||
|
||||
class FtInTitlePluginFunctional(PluginTestCase):
|
||||
plugin = "ftintitle"
|
||||
|
|
@ -39,7 +42,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 = {
|
||||
|
|
@ -246,6 +249,21 @@ 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. {}", "bracket_keywords": ["mix"]},
|
||||
("ftintitle",),
|
||||
("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"),
|
||||
("Alice", "Song 1 ft. Bob (Club Mix)"),
|
||||
id="ft-inserted-before-matching-bracket-keyword",
|
||||
),
|
||||
pytest.param(
|
||||
{"format": "ft. {}", "bracket_keywords": ["nomatch"]},
|
||||
("ftintitle",),
|
||||
("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"),
|
||||
("Alice", "Song 1 (Club Remix) ft. Bob"),
|
||||
id="ft-inserted-at-end-no-bracket-keyword-match",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_ftintitle_functional(
|
||||
|
|
@ -312,6 +330,66 @@ def test_split_on_feat(
|
|||
assert ftintitle.split_on_feat(given) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given,keywords,expected",
|
||||
[
|
||||
## default keywords
|
||||
# different braces and keywords
|
||||
("Song (Remix)", None, "Song ft. Bob (Remix)"),
|
||||
("Song [Version]", None, "Song ft. Bob [Version]"),
|
||||
("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"),
|
||||
("Song <Instrumental>", None, "Song ft. Bob <Instrumental>"),
|
||||
# two keyword clauses
|
||||
("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"),
|
||||
# brace insensitivity
|
||||
("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"),
|
||||
("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"),
|
||||
# negative cases
|
||||
("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, "Song ft. Bob (Live) (Arbitrary)"),
|
||||
("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"),
|
||||
# nested brackets - same type
|
||||
("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, "Song ft. Bob (Remix [Extended])"),
|
||||
# nested - returns outer start position despite inner keyword
|
||||
("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"),
|
||||
("Song {Live <Arbitrary>}", None, "Song ft. Bob {Live <Arbitrary>}"),
|
||||
("Song <Remaster (Arbitrary)>", None, "Song ft. Bob <Remaster (Arbitrary)>"),
|
||||
("Song <Extended> [Live]", None, "Song ft. Bob <Extended> [Live]"),
|
||||
("Song (Version) <Live>", None, "Song ft. Bob (Version) <Live>"),
|
||||
("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"),
|
||||
("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"),
|
||||
## custom keywords
|
||||
("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"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word
|
||||
("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match
|
||||
],
|
||||
) # fmt: skip
|
||||
def test_insert_ft_into_title(
|
||||
given: str,
|
||||
keywords: list[str] | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert (
|
||||
ftintitle.FtInTitlePlugin.insert_ft_into_title(
|
||||
given, "ft. Bob", keywords
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given,expected",
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in a new issue