Commit graph

13391 commits

Author SHA1 Message Date
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
Šarūnas Nejus
f9c3aae4ed
Musicbrainz: fix original release id access for a pseudo releae 2025-12-29 17:05:32 +00:00
Šarūnas Nejus
21e6a1f757
Improve model changes colour display and db / field diff typing (#6240)
## 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.
2025-12-27 14:35:43 +00:00
Šarūnas Nejus
c807effeda
Define a shared fixture for config 2025-12-27 14:30:35 +00:00
Šarūnas Nejus
75baec611a
Improve and simplify show_model_changes 2025-12-27 14:30:35 +00:00
Šarūnas Nejus
e1e0d945f8
Add NotFoundError and Model.get_fresh_from_db; tidy DB getters
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.
2025-12-27 14:26:29 +00:00