fix(autotag): "featuring" pattern in string_dist matches mid-word "ft" (#6843)

## Bug

`SD_PATTERNS` in `beets/autotag/distance.py` has this entry:

```python
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
```

The `ft` alternative has no word boundary before it, so it matches `ft`
embedded inside any ordinary word followed by a space/period/colon —
`draft`, `left`, `gift`, `craft`, `soft`, `swift`, etc. — and treats
everything after the match as a low-weight "featuring artist" suffix
(weight `0.1`), effectively deleting it from the comparison.

```python
from beets.autotag.distance import string_dist
string_dist("Draft Beer", "Draft Whiskey")   # 0.05  (near-identical!)
string_dist("Left Field", "Left Symphony")   # 0.067
string_dist("Gift Ideas", "Gift Cards")      # 0.044
```

Confirmed through the actual matching pipeline (`track_distance`) too:
"Draft Beer" vs. "Draft Whiskey" scores almost as close as an exact
match, while a genuinely unrelated title correctly scores much higher.
`string_dist`/`add_string` backs nearly every fuzzy field comparison
used during `beet import` (title, artist, album, label, catalognum,
...), so this silently degrades match quality for any title/artist
containing one of these words.

## Fix

Add a word boundary before the alternation:

```python
(r"[\[\(]?\b(featuring|feat|ft)[\. :].+", 0.1),
```

Verified this doesn't regress the intended "real" cases (`"Song feat.
Artist"`, `"[feat: Artist]"`, `"(ft. Artist)"` — all still match, since
they're preceded by whitespace/bracket/string-start, which are real
boundaries) while excluding all the false-positive mid-word cases above.

## Testing

Added `test_featuring_pattern_does_not_match_mid_word` (parametrized
over draft/left/gift/craft) to `test/autotag/test_distance.py`,
asserting `string_dist(...) > 0.3` for these pairs (previously
~0.04-0.07). Verified red on `master` (`git stash` the source fix — all
4 cases fail with the near-zero distances shown above), green after.
Full `test/autotag/test_distance.py` (58 passed) and the broader
`test/autotag/` + `test/util/` + `test/autotag/test_match.py` +
`test/test_importer.py` suites (352 + 142 passed, no regressions) all
pass. `ruff check`/`ruff format --check`/`mypy` clean.

Added a changelog entry per `CONTRIBUTING.rst`'s reminder.

## Duplicate-work check

Searched open PRs for `distance.py`/"featuring" — found #6681 (Source
class refactor) also touches `distance.py`, but its diff doesn't touch
`SD_PATTERNS` or this area at all. No overlap.

---
This PR was drafted with AI assistance (Claude); I independently
reproduced the bug against the real, unmodified module (including
through the full `track_distance` matching pipeline) and verified the
fix. I reviewed the change and take responsibility for it.
This commit is contained in:
Šarūnas Nejus
2026-07-30 12:04:08 +01:00
committed by GitHub
3 changed files with 22 additions and 1 deletions

View File

@@ -35,7 +35,7 @@ SD_END_WORDS = ["the", "a", "an"]
SD_PATTERNS = [
(r"^the ", 0.1),
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
(r"[\[\(]?\b(featuring|feat|ft)\b[\. :].+", 0.1),
(r"\(.*?\)", 0.3),
(r"\[.*?\]", 0.3),
(r"(, )?(pt\.|part) .+", 0.2),

View File

@@ -23,6 +23,10 @@ Bug fixes
digits no longer restarts the numbering: ``track.10.mp3`` now yields
``track.11.mp3`` instead of ``track.1.mp3``. The counter was matched with
``\.(\d)+$``, which captures only the final digit.
- Autotagging distance calculations no longer treat ordinary words containing
"ft" (such as "draft", "left", "gift", "craft") as a "featuring artist"
suffix, which was silently making genuinely different titles/artists score as
near-identical matches.
..
For plugin developers

View File

@@ -282,6 +282,23 @@ class TestStringDistance:
def test_different_distance(self):
assert string_dist("Some String", "Totally Different") != 0.0
@pytest.mark.parametrize(
"string1, string2",
[
("Draft Beer", "Draft Whiskey"),
("Left Field", "Left Symphony"),
("Gift Ideas", "Gift Cards"),
("Craft Beer", "Craft Wine"),
],
)
def test_featuring_pattern_does_not_match_mid_word(self, string1, string2):
# The "featuring"/"feat"/"ft" pattern must not match "ft" embedded
# inside an ordinary word (draft, left, gift, craft, ...) -- doing so
# would treat everything after "ft" as a low-weight suffix and make
# genuinely different strings look almost identical instead of
# correctly registering as a large distance.
assert string_dist(string1, string2) > 0.3
@pytest.mark.parametrize(
"string1, string2, reference",
[