Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions beetsplug/ftintitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +27,30 @@
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,
Expand Down Expand Up @@ -104,6 +129,53 @@ 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"""
(?: # 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__()

Expand All @@ -115,6 +187,7 @@ def __init__(self) -> None:
"keep_in_artist": False,
"preserve_album_artist": True,
"custom_words": [],
"bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS),
}
)

Expand Down Expand Up @@ -216,8 +289,10 @@ def update_metadata(
# 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 = FtInTitlePlugin.insert_ft_into_title(
item.title, formatted, self.bracket_keywords
)
self._log.info("title: {.title} -> {}", item, new_title)
item.title = new_title

Expand Down Expand Up @@ -262,3 +337,31 @@ def ft_in_title(
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

@staticmethod
def insert_ft_into_title(
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}"
7 changes: 7 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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:

Expand Down
12 changes: 12 additions & 0 deletions docs/plugins/ftintitle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------
Expand Down
78 changes: 77 additions & 1 deletion test/plugins/test_ftintitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
"""Tests for the 'ftintitle' plugin."""

from collections.abc import Generator
from typing import TypeAlias

import pytest

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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -312,6 +330,64 @@ 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, 5),
("Song [Version]", None, 5),
("Song {Extended Mix}", None, 5),
("Song <Instrumental>", 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 <Arbitrary>}", None, 5),
("Song <Remaster (Arbitrary)>", None, 5),
("Song <Extended> [Live]", None, 5),
("Song (Version) <Live>", None, 5),
("Song (Arbitrary [Description])", None, None),
("Song [Description (Arbitrary)]", None, None),
## custom keywords
("Song (Live)", ["live"], 5),
("Song (Concert)", ["concert"], 5),
("Song (Remix)", ["custom"], None),
("Song (Custom)", ["custom"], 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(
given: str,
keywords: list[str] | None,
expected: int | None,
) -> None:
assert (
ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords)
== expected
)


@pytest.mark.parametrize(
"given,expected",
[
Expand Down
Loading