## 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
Often last.fm does not find artist genres for delimiter-separated artist
names (eg. "Artist One, Artist Two") or where multiple artists are
combined with "concatenation words" like "and" , "+", "featuring" and so
on.
This fix gathers each artist's last.fm genre separately by using Beets'
mutli-valued `albumartists` field to improve the likeliness of finding
genres in the artist genre fetching stage.
Refactoring was done along the existing genre fetching helper functions
(`fetch_album_genre`, `fetch_track_genre`, ...):
- last.fm can be asked for genre for these combinations of metadata:
- albumartist/album
- artist/track
- artist
- Instead of passing `Album` or `Item` objects directly to these
helpers., generalize them and pass the (string) metadata directly.
- Passing "what's to fetch" in the callers instead of hiding it in the
methods also helps readability in `_get_genre()`
- And reduces the requirement at hand for another additional method (or
adaptation) to support "multi-albumartist genre fetching"
In case the albumartist genre can't be found (often due to variations of
artist-combination wording issues, eg "featuring", "+", "&" and so on)
use the albumartists list field, fetch a genre for each artist
separately and concatenate them.
Reduce fetcher methods to 3: last.fm can be asked for
for a genre for these combinations of metadata:
- albumartist/album
- artist/track
- artist
Passing them in the callers instead of hiding it in the
methods also helps readability in _get_genre().
## 1. **Refactored UI diffs**
Using the following command:
```sh
beet modify turn page data_source= 'play_count!' hello=hi comp=1 mb_albumid=https://bandcamp
```
### Before
<img width="613" height="260" alt="before"
src="https://github.com/user-attachments/assets/785c4b73-69e4-4c60-b4dd-d114ee3170a1"
/>
* New field additions have been shown in red
* No difference in formatting between
- field _removal_ (`field!`)
- and it being reset to an empty string (`field=`)
### After
<img width="640" height="256" alt="after"
src="https://github.com/user-attachments/assets/89b036d7-a074-494b-a5e1-b1bf5100d454"
/>
* Now, the field name is colored in red or green whenever it's added or
removed
## 2. Small improvements in `Model` types:
* Added `NotFoundError` and `Model.get_fresh_from_db` for those cases
where `Database._get` must return a non-optional instance of `Model`
* Added cached `Model.db` property to dedupe `Model._check_db` calls.
## 3. Added a global autouse model-level `config` fixture.
Introduce NotFoundError and a Model.get_fresh_from_db helper that reloads
an object from the database and raises when missing. Use it to simplify
Model.load and UI change detection.
# Generalize some of common tagging functionality to `Info` and `Match`
base classes
This PR centralises some common tagging functionality between singletons
and albums allowing to simplify `ChangeRepresentation` and importing
functionality. This is prep work for a larger PR which refactors and
simplifies the entire tagging workflow.
## Changes
- **Core type changes**: Changed `mapping` parameter from `Mapping[Item,
TrackInfo]` to `list[tuple[Item, TrackInfo]]` in `apply_metadata()`,
`distance()`, and `assign_items()` functions
- **Match dataclasses**: Converted `AlbumMatch` and `TrackMatch` from
`NamedTuple` to `@dataclass`, introducing base `Match` class with common
functionality
- **New properties**: Added `name` cached property to `Info` class for
unified name access
- **ChangeRepresentation refactor**: Converted to `@dataclass` with lazy
property evaluation, replacing `cur_album`/`cur_title` with unified
`cur_name` field
- **UI improvements**: Simplified display logic by using
`match.info.name` instead of type-specific field checks
- **Parameter renaming**: Renamed `search_album`/`search_title`
parameters to `search_name` for consistency across singleton and album
workflows
The changes maintain backward compatibility in behavior while improving
type safety and code clarity.