Commit graph

13397 commits

Author SHA1 Message Date
Šarūnas Nejus
bd319c2c43
db: disable DQS on Python >= 3.12 (#5235)
cf. https://github.com/beetbox/beets/issues/4709, let's see how badly
this breaks CI
2026-01-13 13:53:45 +00:00
wisp3rwind
7685e9439a db: disable DQS on Python >= 3.12 2026-01-13 13:48:23 +00:00
Šarūnas Nejus
265d513251
Change link for beets-usertag (#5756)
The beets-usertag plugin was originally created by igordetigor, but has
been unmaintained for some time now. Recently, I decided to add some
features to it and, with Ingo's blessing, publish on PyPI. He's also
okay with me replacing the link in the plugins list with my fork.
2026-01-13 13:44:42 +00:00
Šarūnas Nejus
0efce4a86b
Merge branch 'master' into usertag 2026-01-13 13:38:56 +00:00
Šarūnas Nejus
4ff6b39ecb
Fix bug in fetching preferred release event. (#6279)
With the changes to how data is fetched from MusicBrainz, empty releases
are now `None` instead of an empty dict.
2026-01-13 13:21:24 +00:00
David Logie
3ea4bb7941 Fix bug in fetching preferred release event.
With the changes to how data is fetched from MusicBrainz, empty releases
are now `None` instead of an empty dict.
2026-01-13 13:16:09 +00:00
Šarūnas Nejus
f63585fe31
Remove expired Spotify credentials and log error for missing configuration (#6271)
Looks like our hard-coded API credentials expired. This PR removes
expired credentials and makes it clear to the user that they must
provide their credentials.

Fixes #6270
2026-01-11 19:02:37 +00:00
Alok Saboo
cff631f9c9 updated credentials 2026-01-11 09:20:03 -05:00
Henry Oberholtzer
c04fc95e59 Merge #6213 - Fix ftintitle plugin to prioritize explicit featuring tokens 2026-01-08 12:21:00 -08:00
Henry Oberholtzer
f7b05cb7db ftintitle: fix changelog conflict 2026-01-08 12:20:25 -08:00
Šarūnas Nejus
a3c2e383f4
Merge branch 'master' into spotify 2026-01-08 00:23:15 +00:00
Alok Saboo
dd3ecec579 Updated Spotify API credentials 2026-01-07 18:54:39 -05:00
Šarūnas Nejus
ad2ff1f97e
Fix fetchart colors broken by 67e668d81f (#6273)
Fetchart colors were broken by 67e668d81f.
Since that commit fetchart displays this, with escape codes instead of
colorized output:
```
fetchart: Alasdair Fraser & Natalie Haas - Meridians: �[37mhas album art�[39;49;00m
```

This fixes it by using `ui.print_` instead of `Logger`. This seems to be
in line with other plugins such as play:

1a899cc92a/beetsplug/play.py (L135)
2026-01-07 22:41:44 +00:00
Aidan Epstein
b53aff9b15 Fix fetchart colors broken by 67e668d81f 2026-01-07 10:36:08 -08:00
Šarūnas Nejus
1a899cc92a
Drop dependency on python-musicbrainzngs (#6234)
## Replace `python-musicbrainzngs` with Custom Lightweight MusicBrainz
Client

### Core Problem Solved

**Before**: Beets depended on the external `python-musicbrainzngs`
library (v0.7.1) for all MusicBrainz API interactions. This dependency
required separate installation for multiple plugins and introduced an
abstraction layer that obscured direct HTTP semantics.

**After**: Custom **lightweight MusicBrainz client**
(`beetsplug.utils.musicbrainz`) built directly on `requests` and
`requests-ratelimiter`, eliminating the external dependency while
maintaining full API compatibility.

---

### Architecture Overview

#### **1. New MusicBrainz Client Foundation**

Created `beetsplug/utils/musicbrainz.py` with three core components:

```
MusicBrainzAPI (base client)
├─ Configuration-driven initialization (from config['musicbrainz'])
├─ Rate-limited session via LimiterTimeoutSession
├─ Generic entity fetching (get_entity, search_entity)
├─ Specialized methods (get_release, get_recording, get_work)
└─ Recursive relation grouping (group_relations)

MusicBrainzUserAPI (authenticated operations)
├─ Extends MusicBrainzAPI with HTTPDigestAuth
├─ Collection management (get_collections)
└─ User-specific API operations

MBCollection (collection manipulation)
├─ Paginated release fetching
├─ Chunked PUT operations (add_releases)
└─ Chunked DELETE operations (remove_releases)
```

#### **2. Config parsing now centralized in
`MusicBrainzAPI.__post_init__()`**.

And applies to all plugins.

#### **3. Mixin Pattern for Plugin Integration**

```python
# OLD: Plugins directly instantiated musicbrainzngs
import musicbrainzngs
musicbrainzngs.auth(user, pass)
resp = musicbrainzngs.get_recording_by_id(id, includes=['releases'])

# NEW: Plugins inherit MusicBrainzAPIMixin
class MyPlugin(MusicBrainzAPIMixin, BeetsPlugin):
    def some_method(self):
        recording = self.mbapi.get_recording(id, includes=['releases'])
```

**Affected plugins**:

- `musicbrainz` → `MusicBrainzAPIMixin`
- `listenbrainz` → `MusicBrainzAPIMixin`
- `mbcollection` → `MusicBrainzUserAPIMixin` (requires authentication)
- `missing` → `MusicBrainzAPIMixin`
- `parentwork` → `MusicBrainzAPIMixin`

---

### Plugin-Specific Refactoring

#### **`mbcollection`: From Procedural to Object-Oriented**

**Before**: Module-level functions (`submit_albums`, `mb_call`) with
error handling scattered across multiple try-except blocks.

**After**: `MBCollection` dataclass encapsulating collection operations:

```python
# OLD approach
collection_id = get_collection()
albums_in_collection = get_albums_in_collection(collection_id)
submit_albums(collection_id, album_ids)

# NEW approach
collection = self.collection  # cached property with validation
collection.add_releases(album_ids)
collection.remove_releases(removed_ids)
```

**Key improvements**:

- Eliminated `mb_call` error wrapper (errors propagate naturally from
`RequestHandler`)
- Consolidated pagination logic into `MBCollection.releases` property
- Type hints for `Library`, `ImportSession`, `ImportTask` parameters

#### **`listenbrainz`: Search Query Simplification**

**Before**: Called `musicbrainzngs.search_recordings()` with constructed
query strings.

**After**: Uses `self.mbapi.search_entity()` which handles query
formatting:

```python
# OLD
resp = musicbrainzngs.search_recordings(
    query=f'track:{track_name} AND release:{album_name}',
    strict=True
)
if resp.get('recording-count') > 1:
    return resp['recording-list'][0].get('id')

# NEW
for recording in self.mbapi.search_entity('recording', 
                                           {'track': track_name, 
                                            'release': album_name}):
    return recording['id']
return None
```

#### **`parentwork`: Inline Traversal Logic**

**Before**: Module-level functions (`direct_parent_id`,
`work_parent_id`, `find_parent_work_info`) that made sequential API
calls.

**After**: Single method `find_parent_work_info` that traverses work
hierarchy inline:

```python
def find_parent_work_info(self, mbworkid: str) -> tuple[dict, str | None]:
    workdate = None
    parent_id = mbworkid
    
    while parent_id := current_id:  # walrus operator
        workinfo = self.mbapi.get_work(current_id, includes=['work-rels', 'artist-rels'])
        workdate = workdate or extract_composer_date(workinfo)
        parent_id = find_parent_in_relations(workinfo)
    
    return workinfo, workdate
```

Eliminates three function calls per traversal level.

---

### Dependency & Installation Impact

#### **Package Dependencies**

```diff
# pyproject.toml
-musicbrainzngs = {version = ">=0.4", optional = true}

# Removed from extras groups
-listenbrainz = ["musicbrainzngs"]
-mbcollection = ["musicbrainzngs"]
-missing = ["musicbrainzngs"]
-parentwork = ["musicbrainzngs"]
```

**Result**: Four plugin extras no longer require external package
installation beyond `requests` (already a core dependency).

#### **CI Workflow**

```diff
# .github/workflows/ci.yaml
-poetry install --extras=parentwork
+poetry install  # parentwork now part of core
```

The `parentwork` extra is removed entirely since it had no other
dependencies.

#### **Documentation Updates**

Removed "Installation" sections from four plugin docs that previously
required:

```bash
pip install beets[listenbrainz]  # NO LONGER NEEDED
pip install beets[mbcollection]  # NO LONGER NEEDED
pip install beets[missing]       # NO LONGER NEEDED
pip install beets[parentwork]    # NO LONGER NEEDED
```

Plugins now work **out-of-the-box** with just `plugins: [listenbrainz,
...]` in config.

---

### Testing Improvements

#### **New Shared Fixture: `requests_mock`**

Created `test/plugins/conftest.py` with fixture that disables rate
limiting during tests:

```python
@pytest.fixture
def requests_mock(requests_mock, monkeypatch):
    """Use plain session wherever MB requests are mocked."""
    monkeypatch.setattr(
        'beetsplug.utils.musicbrainz.MusicBrainzAPI.session',
        requests.Session,
    )
    return requests_mock
```

This avoids artificial delays when mocking HTTP responses.

#### **New Plugin Test Suites**

Added comprehensive tests for previously untested plugins:

1. **`test_listenbrainz.py`**: Tests recording ID lookup and track info
fetching
2. **`test_mbcollection.py`**: Tests collection validation, pagination,
and sync operations
3. **`test_missing.py`**: Tests missing album detection logic
4. **`test/plugins/utils/test_musicbrainz.py`**: Tests `group_relations`
transformation

#### **Test Migration**

Moved `test_group_relations` from `test_musicbrainz.py` to
`test/plugins/utils/test_musicbrainz.py` (84 lines) since
`group_relations` is now a utility function.

---

### Migration Benefits

| **Aspect** | **Before** | **After** |
| ------------------------- | ------------------------------------ |
------------------------------------------- |
| **External dependencies** | `python-musicbrainzngs` (0.7.1) | None
(uses existing `requests`) |
| **Plugin installation** | `pip install beets[plugin]` required | Works
with base install |
| **API surface area** | ~50 functions in musicbrainzngs | ~10 methods
tailored to Beets |
| **Error messages** | Generic exceptions with status codes | Full HTTP
response text included |
| **Response structure** | Raw MusicBrainz JSON | Normalized with
grouped relations |
| **Code ownership** | External maintenance dependency | Direct control
over API client |
| **Test speed** | Rate-limited even with mocks | Fixture disables
limits for mocked requests |
| **Type safety** | Minimal type hints in musicbrainzngs | Full type
hints (`JSONDict`, `list[str]`) |

---

### Backward Compatibility

** Fully backward compatible**:

- All existing plugin APIs unchanged from user perspective
- Configuration keys remain identical (`musicbrainz.user`,
`musicbrainz.pass`, etc.)
- MusicBrainz API responses maintain same structure (with additional
normalization)
- Test suite passes without modification to integration tests

**Breaking changes**: None from end-user perspective.


closes #6265
2026-01-07 11:25:00 +00:00
Šarūnas Nejus
a801afd8b6
Update git blame ignore revs 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
a405821828
Fix changelog formatting 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
d4b00ab4f4
Add request handler utils to the docs 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
59b02bc49b
Type MusicBrainzAPI properly 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
55b9c1c145
Retry on server errors too 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
1447f49b72
Add some documentation to musicbrainz api mixins 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
34d993c043
Add a changelog note 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
b49d71cb69
mbcollection: slight refactor 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
92352574aa
Migrate mbcollection to use MusicBrainzAPI 2026-01-06 09:54:02 +00:00
Šarūnas Nejus
143cd70e2f
mbcollection: Add tests 2026-01-06 00:51:51 +00:00
Šarūnas Nejus
9349ad7551
Migrate missing to use MusicBrainzAPI 2026-01-06 00:51:50 +00:00
Šarūnas Nejus
d346daf48e
missing: add tests for --album flag 2026-01-06 00:51:24 +00:00
Šarūnas Nejus
a33371b6ef
Migrate parentwork to use MusicBrainzAPI 2026-01-06 00:27:36 +00:00
Šarūnas Nejus
741f5c4be1
parentwork: simplify work retrieval and tests 2026-01-06 00:27:36 +00:00
Šarūnas Nejus
36964e433e
Migrate listenbrainz plugin to use our MusicBrainzAPI implementation 2026-01-06 00:27:36 +00:00
Šarūnas Nejus
af96c3244e
Add a minimal test for listenbrainz 2026-01-06 00:26:42 +00:00
Šarūnas Nejus
523fa6ceaf
Move MusicBrainzAPI to a shared util 2026-01-06 00:26:41 +00:00
Šarūnas Nejus
ea2e7bf997
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
2026-01-04 02:57:04 +00:00
Trey Turner
714c9705c3 Merge branch 'master' into feat/ftintitle/insert_featured_artist_before_variant_clauses 2026-01-01 15:40:37 -06:00
Trey Turner
b14755df88 fix(ftintitle): remaining opportunities for improvement 2026-01-01 15:39:17 -06:00
Jack Wilsdon
afc26fa58f Add packaging note about mock dependency removal 2026-01-01 15:50:37 +00:00
Jack Wilsdon
d6da6cda7e Update poetry.lock after removing mock 2026-01-01 15:46:06 +00:00
Alexandre Detiste
c1e36e52a8 drop extraneous dependency on old external "mock" 2026-01-01 01:49:17 +01:00
J0J0 Todos
e0489097e0
lastgenre: Finalize type hints in plugin (#6239)
## Description

Add type hints to the few remaining methods and helpers that didn't have
them already.
2025-12-30 21:39:52 +01:00
J0J0 Todos
b8c7c87b41 lastgenre: Add typehints to remaining methods,
to finally reach full type hint coverage in the plugin!
2025-12-30 21:33:07 +01:00
J0J0 Todos
8dd6988077
lastgenre: Use albumartists field to improve last.fm results (#5981)
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"
2025-12-30 21:03:38 +01:00
j0j0
28dc78be95 lastgenre: Changelog for #5981 lastgenre 2025-12-30 20:57:10 +01:00
J0J0 Todos
f19d672016 lastgenre: Type hints for genre fetch methods 2025-12-30 20:57:10 +01:00
J0J0 Todos
d72307a16f lastgenre: Adapt test_get_genre function signatures 2025-12-30 20:57:10 +01:00
J0J0 Todos
a046f60c51 lastgenre: Hint mypy to Album.items()
instead of obj.items()
2025-12-30 20:57:10 +01:00
j0j0
355c9cc1b6 lastgenre: Use multi-valued albumartists field
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.
2025-12-30 20:57:10 +01:00
j0j0
40a212a2c4 lastgenre: Simplify genre fetchers
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().
2025-12-30 20:57:10 +01:00
Šarūnas Nejus
1b701c866c
fetchart: Add support for configurable fallback cover art (#6253)
Fixes discussion #6252.
2025-12-30 16:49:52 +00:00
Danny Trunk
9ddddf4c39 fetchart: Add support for configurable fallback cover art 2025-12-30 13:45:03 +01:00
Šarūnas Nejus
ed566eb14e
Fix original release id access for a pseudo release (#6250)
Fixes #6248
2025-12-29 17:11:40 +00:00