Commit graph

13418 commits

Author SHA1 Message Date
m_igashi
5ea41b3fbb refactor: simplify CommandBackend with SUPPORTED_FORMATS_BY_TOOL
- Add Tool type alias and SUPPORTED_FORMATS_BY_TOOL class variable
- Refactor __init__ to use shutil.which() and set cmd_name early
- Simplify format_supported() to use dictionary lookup
2026-01-17 02:10:29 +01:00
m_igashi
683da049a0 style: format replaygain.rst with docstrfmt 2026-01-16 16:20:45 +01:00
m_igashi
179dc7d070 style: remove trailing whitespace from blank line 2026-01-16 16:06:17 +01:00
Jesse Pinkman
dc117046a9
Merge branch 'master' into add-mp3rgain-support 2026-01-16 15:55:23 +01:00
Šarūnas Nejus
6c2c46091b
Respect no_convert and never_convert_lossy_files in convert plugin (#6286)
Given that @frigginbrownie's #5556 PR received some thumb ups but they
haven't responded since a while ago, I'm creating this PR to merge their
fix (I could not commit in the PR branch since I have no permissions to
push to their fork).

Supersedes: #5556 

Copying @frigginbrownie description from #5556:

According to the docs, the auto_keep function will "Convert your files
automatically on import to dest but import the non transcoded version."
This is true but not 100% accurate. In cases where no conversion is
required (say, importing lossy files where there's no need to convert),
auto_keep will copy the files to dest.

This behavior results in duplicate files being created on import when
the auto_keep function is set to yes - a lossy file will be imported
into the default directory (say /music) and then copied to the dest
location (say /transcodes).

This is ideal if you wish to have all music formats in your default
directory (lossy and lossless) and all lossy files (original imports and
transcodes) in a secondary directory (say /lossy).

But what if you want a separate directory of all music you've
transcoded? auto_keep won't provide that, as it copies lossy files to
the dest location. In addition, if the dest is set to the same location
as default directory, auto_keep will copy lossy files into the same
directory that beets previously imported files into, resulting in the
directory having two files for each file in an album. If you use paths
(say to have singletons imported into /music/singles), auto_keep will
import the file into the path location, then copy the file to the dest,
creating directories to match the path.

Unlike with the auto option or using "beet convert", auto_keep does not
follow the never_convert_lossy_files or no_convert options and will not
validate whether files need to be converted or copied on import to dest
- it transcodes or it copies, no questions asked.

This change updates the auto_convert_keep function to filter items using
should_transcode. This way, if the user sets never_convert_lossy_files
to no or no_convert: 'format:mp3', lossy files will not be copied to the
dest, while lossless files will be converted to the dest (perfect for a
seperate /transcodes directory). If the user sets
never_convert_lossy_files to yes, lossy files will to be copied to the
dest and lossless files will be converted to the dest (perfect for a
/lossy directory). In turn, this change makes behavior consistent with
"beet convert" and the auto option.
2026-01-15 15:58:35 +00:00
Šarūnas Nejus
bfb24da51c
Add note to the changelog 2026-01-15 15:53:06 +00:00
frigginbrownie
1ff254215a
Update convert.py 2026-01-15 15:46:01 +00:00
Šarūnas Nejus
679cfc93ed
Fix 'from_scratch': delete all tags before writing new tags to file (#5828)
Fixes #3706.

### Issue
Comment tags are written to file even if option 'from_scratch' is used.
The same tags are not written to the file if imported together with
other files as album. Therefore 'from_scratch' is not working as
described in the documentation.

### Solution
1. Add test: Adapt the function from the 'regular' import class and
insert it in the class for the singleton import test.
2. Fix bug : Add check for 'from_scratch' option. If used, clear
metadata before applying 'new' metadata with autotag.
3. No documentation change needed. Option now works as described in the
documentation.
4. Add changelog.
2026-01-15 15:43:19 +00:00
rdy2go
fdfeb35076 add changelog for and to resolve PR #5828 2026-01-15 16:07:54 +01:00
rdy2go
445ad02399
Merge branch 'beetbox:master' into master 2026-01-15 16:04:46 +01:00
m_igashi
ebd0e70012 Add mp3rgain support to ReplayGain command backend
mp3rgain is a modern Rust rewrite of mp3gain that provides:
- CLI-compatible drop-in replacement for mp3gain
- Support for both MP3 and AAC/M4A formats (like aacgain)
- Fixes for CVE-2021-34085 (Critical, CVSS 9.8) and CVE-2019-18359 (Medium)
- Memory-safe implementation in Rust
- Works on modern systems (Windows 11, macOS Apple Silicon)

Changes:
- Add mp3rgain to the command search list (prioritized first)
- Update format_supported() with more robust command name detection
  using os.path.basename() and startswith() instead of substring matching
- Update documentation with installation instructions

See: https://github.com/M-Igashi/mp3rgain
2026-01-14 01:47:45 +01:00
Šarūnas Nejus
b3c42a3350
Enable ruff's future-annotations and RUF* rules (#6245)
## Summary

This PR updates typing and linting across the codebase and enables
stricter `ruff` checks for Python 3.10:

1. Enable `tool.ruff.lint.future-annotations`
Very handy feature released in `0.13.0`: if required, it _automatically_
adds `from __future__ import annotations` and moves relevant imports
under `if TYPE_CHECKING`:

   ```py
   # before (runtime import)
   from beets.library import Library

   # after
   from __future__ import annotations

   from typing import TYPE_CHECKING

   if TYPE_CHECKING:
       from beets.library import Library
   ```

2. Set `tool.ruff.target-version = "py310"`

   This enforced PEP 604 unions in the codebase:

   ```py
   # before
   SQLiteType = Union[str, bytes, float, int, memoryview, None]

   # after
   SQLiteType = str | bytes | float | int | memoryview | None
   ```

3. Enable `RUF*` family of checks

   - Remove unused `# noqa`s
   - Ignore unused unpacked variables

     ```py
     # before
     likelies, consensus = util.get_most_common_tags(self.items)

     # after
     likelies, _ = util.get_most_common_tags(self.items)
     ```
   - Avoid list materialization
     ```py
     # before
     for part in parts + [","]:

     # after
     for part in [*parts, ","]:
     ```

- And, most importantly, **RUF012**: use `ClassVar` for mutable class
attributes

- This underlined our messy `BeetsPlugin.template_*` attributes design,
where I have now defined `BeetsPluginMeta` to make a clear distinction
between class and instance attributes. @semohr and @asardaes I saw you
had a discussion regarding these earlier - we will need to revisit this
at some point to sort it out for good.
- It also revealed a legitimate issue in `metasync.MetaSource` where
`item_types` were initialised as an instance attribute (but luckily
never used).
2026-01-13 21:02:32 +00:00
Šarūnas Nejus
c9625f8fb3
Update git blame ignore revs 2026-01-13 20:55:40 +00:00
Šarūnas Nejus
1c20e4bd4e
Address RUF012 2026-01-13 20:55:40 +00:00
Šarūnas Nejus
c52656fb0a
Enable RUF rules 2026-01-13 20:55:40 +00:00
Šarūnas Nejus
078ffc1c57
Configure ruff for py310 2026-01-13 20:55:40 +00:00
Šarūnas Nejus
b964d8b7eb
Configure future-annotations 2026-01-13 20:55:40 +00:00
Š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