## Consolidate Last.fm genre fetching into a single `fetch` method
This PR simplifies the `lastgenre` client API by replacing three
separate fetch methods (`fetch_track_genre`, `fetch_album_genre`,
`fetch_artist_genre`) with a single unified `fetch(kind, obj)` method.
### What changed
**`client.py`**:
- Introduces a class-level `FETCH_METHODS` registry (`ClassVar` dict)
mapping fetch "kinds" (`"track"`, `"album"`, `"artist"`,
`"album_artist"`) to a `(pylast_method, arg_extractor)` tuple.
- Replaces the three `fetch_*` methods with a single `fetch(kind, obj)`
that dispatches via this registry.
- Removes a private `_tags_for` wrapper — its logic is inlined into the
now-public `fetch_genres`.
- Drops the workaround for a `pylast.Album.get_top_tags()` inconsistency
[fixed in 2014](https://github.com/pylast/pylast/issues/86).
**`__init__.py`**:
- All call sites updated to use `client.fetch(kind, obj)` — the client
now owns field extraction (e.g. `obj.artist`, `obj.album`), removing
that concern from the plugin layer.
**`test_lastgenre.py`**:
- Test mocking simplified: a single `monkeypatch` on
`LastFmClient.fetch` replaces three separate method patches.
### Impact
- **Reduced surface area**: one method to mock, test, and reason about
instead of three.
- **Field extraction centralised**: callers no longer need to know which
fields to pass per entity type.
- **No behaviour change** — pure refactor.
## Fix rewriting of multi-valued fields (`rewrite` / `advancedrewrite`
plugins)
**Bug:** Both `rewrite` and `advancedrewrite` plugins assumed all field
values are scalars, so list-type fields (e.g. `genres`) were not
rewritten correctly. Additionally, only the first matching rule was ever
applied to a field.
---
### What changed
**Core logic (`beetsplug/rewrite.py`):**
- Introduced a `rewrite_value` `singledispatch` function to handle both
`str` and `list[str]` values. For lists, each element is rewritten
individually.
- Extracted `apply_rewrite_rules` as a shared utility — now applies
**all** matching rules in config order (previously stopped at the first
match).
**`advancedrewrite` plugin:**
- Replaced its own inline rule-matching loop with a call to the shared
`apply_rewrite_rules`, fixing list field support there too.
**Behaviour change — rule application order:**
Previously, only the first matching rule was applied. Now, all rules run
in config order, allowing chained rewrites. For example:
```yaml
rewrite:
artist .*hendrix.*: hendrix catalog
artist .*catalog.*: Experience catalog
```
This now produces `"Experience catalog"` instead of `"hendrix catalog"`.
## Description
Adds a global and artist-specific genre ignorelist to lastgenre.
Ignorelist entries can use regex patterns or literal genre names and are
configurable per artist or globally. For config examples see submitted
docs and `_load_ignorelist()` docstring.
### Additional minor refactoring
- Fixed condition in "keep original fallback stage" to use config view
object directly via `.get()`.
- Deduplicate finding the correct artist/albumartist attribute with a
helper `_artist_for_helper`
- Prevents wrong last.fm genres based on a per artist (or global) list of regex
patterns that should be ignored.
- Genre _ignoring_ happens in two places but mainly:
- Right after fetching from last.fm
- and in _resolve_genres (via filter_valid or directly).
- As a fallback literal string matching can be used instead of
supplying a regex pattern
New methods:
- `artist_for_filter` to find out which (album)artist attribute is the
right one in a stage -> ignorelist is artist-based!
- `is_ignored` and `drop_ignored_genres`
- `load_ignorelist` uses confuse mechanisms to load patterns for each
artist and provide them to the plugin as self.ignore_patterns
Follow-up to #6471 — fixes three remaining issues with the
`listenbrainz` plugin:
- **Aggregate listen events into actual play counts.** ListenBrainz
returns individual listen events, each mapped to `playcount: 1`. Without
aggregation, the final `listenbrainz_play_count` is always 1 regardless
of actual listens.
- **Paginate through all listens.** The API defaults to 25 results per
request. Now fetches up to 1000 per page and loops via `max_ts` until
all listens are retrieved.
- **Use `recording_mbid` from `mbid_mapping` when present.** Previously
`mbid` was left as `None` when the mapping existed, falling back to
expensive MB API lookups unnecessarily.
Fixes#6469 (remaining issues after #6471)
Each song.store() was opening and committing its own SQLite transaction.
With thousands of unique tracks the WAL grows and each successive write
becomes slower. Wrapping the loop in a single transaction makes writes
O(1) per item instead of progressively slower.
- Add `beet lbimport --max=N` to cap the number of listens fetched.
- Remove the MusicBrainz API lookup from get_tracks_from_listens.
Previously, every listen without a recording_mbid in the API mapping
triggered a live MB search, causing the import to hang for hours on
large listen histories. Matching falls back to artist/title/album
which is already handled by update_play_counts.
## Description
The fetchart plugin would silently drop unknown sources defined in
config, leading to hard to debug problems.
The plugin now errors when an unknown source is configured, or when no
sources are configured.
In addition, a single string is now a valid value for `sources` to
either enable all sources with an `*`, or a single source.
Fixes: #6336