From 1d239d6e27c4f4ce4ab36566af4569cf294042dd Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Wed, 12 Nov 2025 07:03:30 -0600 Subject: [PATCH 01/33] feat(ftintitle): Insert featured artist before track variant --- beetsplug/ftintitle.py | 106 ++++++++++++++++++++++++++++++++- docs/changelog.rst | 7 +++ test/plugins/test_ftintitle.py | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index dd681a972..cec22af3f 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -59,6 +59,89 @@ def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: ) +# Default keywords that indicate remix/edit/version content +DEFAULT_BRACKET_KEYWORDS = [ + "abridged", + "acapella", + "club", + "demo", + "edit", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +] + + +def find_bracket_position( + title: str, keywords: list[str] | None = None +) -> int | None: + """Find the position of the first opening bracket that contains + remix/edit-related keywords and has a matching closing bracket. + + Args: + title: The title to search in. + keywords: List of keywords to match. If None, uses DEFAULT_BRACKET_KEYWORDS. + If an empty list, matches any bracket content (not just keywords). + + Returns: + The position of the opening bracket, or None if no match found. + """ + if keywords is None: + keywords = DEFAULT_BRACKET_KEYWORDS + + # If keywords is empty, match any bracket content + if not keywords: + pattern = None + else: + # Build regex pattern with word boundaries + keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) + pattern = re.compile(keyword_pattern, re.IGNORECASE) + + # Bracket pairs (opening, closing) + bracket_pairs = [("(", ")"), ("[", "]"), ("<", ">"), ("{", "}")] + + # Track the earliest valid bracket position + earliest_pos = None + + for open_char, close_char in bracket_pairs: + pos = 0 + while True: + # Find next opening bracket + open_pos = title.find(open_char, pos) + if open_pos == -1: + break + + # Find matching closing bracket + close_pos = title.find(close_char, open_pos + 1) + if close_pos == -1: + break + + # Extract content between brackets + content = title[open_pos + 1 : close_pos] + + # Check if content matches: if pattern is None (empty keywords), + # match any content; otherwise check for keywords + if pattern is None or pattern.search(content): + if earliest_pos is None or open_pos < earliest_pos: + earliest_pos = open_pos + + # Continue searching from after this closing bracket + pos = close_pos + 1 + + return earliest_pos + + def find_feat_part( artist: str, albumartist: str | None, @@ -110,6 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], + "bracket_keywords": DEFAULT_BRACKET_KEYWORDS, } ) @@ -138,6 +222,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): bool ) custom_words = self.config["custom_words"].get(list) + bracket_keywords = self.config["bracket_keywords"].get(list) write = ui.should_write() for item in lib.items(args): @@ -147,6 +232,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, + bracket_keywords, ): item.store() if write: @@ -161,6 +247,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) + bracket_keywords = self.config["bracket_keywords"].get(list) for item in task.imported_items(): if self.ft_in_title( @@ -169,6 +256,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, + bracket_keywords, ): item.store() @@ -179,6 +267,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat: bool, keep_in_artist_field: bool, custom_words: list[str], + bracket_keywords: list[str] | None = None, ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. @@ -208,7 +297,14 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) - new_title = f"{item.title} {new_format}" + # Insert before the first bracket containing remix/edit keywords + bracket_pos = find_bracket_position(item.title, bracket_keywords) + if bracket_pos is not None: + title_before = item.title[:bracket_pos].rstrip() + title_after = item.title[bracket_pos:] + new_title = f"{title_before} {new_format} {title_after}" + else: + new_title = f"{item.title} {new_format}" self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -219,6 +315,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field: bool, preserve_album_artist: bool, custom_words: list[str], + bracket_keywords: list[str] | None = None, ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -250,6 +347,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, feat_part, drop_feat, keep_in_artist_field, custom_words + item, + feat_part, + drop_feat, + keep_in_artist_field, + custom_words, + bracket_keywords, ) return True diff --git a/docs/changelog.rst b/docs/changelog.rst index c5a0dab53..c3591b80e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,13 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. +- :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets + containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead + of being appended at the end. This improves formatting for titles like "Song 1 + (Carol Remix) ft. Bob" which becomes "Song 1 ft. Bob (Carol Remix)". A variety + of brackets are supported and a new ``bracket_keywords`` configuration option + allows customizing the keywords. Setting ``bracket_keywords`` to an empty list + matches any bracket content regardless of keywords. Bug fixes: diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b4259666d..b2e2bad9b 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -246,6 +246,49 @@ def add_item( ("Alice", "Song 1 feat. Bob"), id="skip-if-artist-and-album-artists-is-the-same-matching-match-b", ), + # ---- titles with brackets/parentheses ---- + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Carol Remix)"), + id="title-with-brackets-insert-before", + ), + pytest.param( + {"format": "ft. {}", "keep_in_artist": True}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), + ("Alice ft. Bob", "Song 1 ft. Bob (Carol Remix)"), + id="title-with-brackets-keep-in-artist", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Remix) (Live)", "Alice"), + ("Alice", "Song 1 ft. Bob (Remix) (Live)"), + id="title-with-multiple-brackets-uses-first-with-keyword", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Arbitrary)", "Alice"), + ("Alice", "Song 1 (Arbitrary) ft. Bob"), + id="title-with-brackets-no-keyword-appends", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 [Edit]", "Alice"), + ("Alice", "Song 1 ft. Bob [Edit]"), + id="title-with-square-brackets-keyword", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 ", "Alice"), + ("Alice", "Song 1 ft. Bob "), + id="title-with-angle-brackets-keyword", + ), ], ) def test_ftintitle_functional( @@ -312,6 +355,69 @@ def test_split_on_feat( assert ftintitle.split_on_feat(given) == expected +@pytest.mark.parametrize( + "given,expected", + [ + # different braces and keywords + ("Song (Remix)", 5), + ("Song [Version]", 5), + ("Song {Extended Mix}", 5), + ("Song ", 5), + # two keyword clauses + ("Song (Remix) (Live)", 5), + # brace insensitivity + ("Song (Live) [Remix]", 5), + ("Song [Edit] (Remastered)", 5), + # negative cases + ("Song", None), # no clause + ("Song (Arbitrary)", None), # no keyword + ("Song (", None), # no matching brace or keyword + ("Song (Live", None), # no matching brace with keyword + # one keyword clause, one non-keyword clause + ("Song (Live) (Arbitrary)", 5), + ("Song (Arbitrary) (Remix)", 17), + # nested brackets - same type + ("Song (Remix (Extended))", 5), + ("Song [Arbitrary [Description]]", None), + # nested brackets - different types + ("Song (Remix [Extended])", 5), + # nested - returns outer start position despite inner keyword + ("Song [Arbitrary {Extended}]", 5), + ("Song {Live }", 5), + ("Song ", 5), + ("Song [Live]", 5), + ("Song (Version) ", 5), + ("Song (Arbitrary [Description])", None), + ("Song [Description (Arbitrary)]", None), + ], +) +def test_find_bracket_position(given: str, expected: int | None) -> None: + assert ftintitle.find_bracket_position(given) == expected + + +@pytest.mark.parametrize( + "given,keywords,expected", + [ + ("Song (Live)", ["live"], 5), + ("Song (Live)", None, 5), + ("Song (Arbitrary)", None, None), + ("Song (Concert)", ["concert"], 5), + ("Song (Concert)", None, None), + ("Song (Remix)", ["custom"], None), + ("Song (Custom)", ["custom"], 5), + ("Song (Live)", [], 5), + ("Song (Anything)", [], 5), + ("Song (Remix)", [], 5), + ("Song", [], None), + ("Song (", [], None), + ], +) +def test_find_bracket_position_custom_keywords( + given: str, keywords: list[str] | None, expected: int | None +) -> None: + assert ftintitle.find_bracket_position(given, keywords) == expected + + @pytest.mark.parametrize( "given,expected", [ From 62e1a41ff2e6242cdae54ce63236af0e6c105514 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:19:12 -0600 Subject: [PATCH 02/33] chore(ftintitle): add 'edition' to keyword defaults --- beetsplug/ftintitle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index cec22af3f..9702bf9a5 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -66,6 +66,7 @@ DEFAULT_BRACKET_KEYWORDS = [ "club", "demo", "edit", + "edition", "extended", "instrumental", "live", From 15daebb55f9b356cdd2ac5687d7af5c4ef53f67b Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:31:40 -0600 Subject: [PATCH 03/33] test(ftintitle): mock import task to exercise import hooks --- test/plugins/test_ftintitle.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b2e2bad9b..a6be02b3b 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,9 +15,11 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator +from typing import cast import pytest +from beets.importer import ImportSession, ImportTask from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -68,6 +70,16 @@ def add_item( ) +class DummyImportTask: + """Minimal stand-in for ImportTask used to exercise import hooks.""" + + def __init__(self, items: list[Item]) -> None: + self._items = items + + def imported_items(self) -> list[Item]: + return self._items + + @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ @@ -312,6 +324,31 @@ def test_ftintitle_functional( assert item["title"] == expected_title +def test_imported_stage_moves_featured_artist( + env: FtInTitlePluginFunctional, +) -> None: + """The import-stage hook should fetch config settings and process items.""" + set_config(env, None) + plugin = ftintitle.FtInTitlePlugin() + item = add_item( + env, + "/imported-hook", + "Alice feat. Bob", + "Song 1 (Carol Remix)", + "Various Artists", + ) + task = DummyImportTask([item]) + + plugin.imported( + cast(ImportSession, None), + cast(ImportTask, task), + ) + item.load() + + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 feat. Bob (Carol Remix)" + + @pytest.mark.parametrize( "artist,albumartist,expected", [ From a9ed637c407c6788e0510e75fa98261c9afb31ad Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:31:49 -0600 Subject: [PATCH 04/33] docs(ftintitle): add bracket_keywords --- docs/plugins/ftintitle.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1d2ec5c20..fef1bc9bf 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -32,6 +32,18 @@ file. The available options are: skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. +- **bracket_keywords**: Controls where the featuring text is inserted when the + title includes bracketed qualifiers such as ``(Remix)`` or ``[Live]``. + FtInTitle inserts the new text before the first bracket whose contents match + any of these keywords. Supply a list of words to fine-tune the behavior or set + the list to ``[]`` to match *any* bracket regardless of its contents. Default: + + :: + + ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", + "instrumental", "live", "mix", "radio", "release", "remastered" + "remastered", "remix", "rmx", "unabridged", "unreleased", + "version", "vip"] Running Manually ---------------- From 3dd3bf5640eebb0da282edae4169e8a636b1aeac Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 22:34:43 -0600 Subject: [PATCH 05/33] fix: address sourcery comments --- beetsplug/ftintitle.py | 13 ++++++++----- docs/plugins/ftintitle.rst | 2 +- test/plugins/test_ftintitle.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 9702bf9a5..d3d600958 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -105,7 +105,9 @@ def find_bracket_position( if not keywords: pattern = None else: - # Build regex pattern with word boundaries + # Build regex pattern to support multi-word keywords/phrases. + # Each keyword/phrase is escaped and surrounded by word boundaries at + # start and end, matching phrases like "club mix" as a whole. keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) pattern = re.compile(keyword_pattern, re.IGNORECASE) @@ -133,9 +135,10 @@ def find_bracket_position( # Check if content matches: if pattern is None (empty keywords), # match any content; otherwise check for keywords - if pattern is None or pattern.search(content): - if earliest_pos is None or open_pos < earliest_pos: - earliest_pos = open_pos + if (pattern is None or pattern.search(content)) and ( + earliest_pos is None or open_pos < earliest_pos + ): + earliest_pos = open_pos # Continue searching from after this closing bracket pos = close_pos + 1 @@ -194,7 +197,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": DEFAULT_BRACKET_KEYWORDS, + "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(), } ) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index fef1bc9bf..347e2792d 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -41,7 +41,7 @@ file. The available options are: :: ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", - "instrumental", "live", "mix", "radio", "release", "remastered" + "instrumental", "live", "mix", "radio", "release", "remaster", "remastered", "remix", "rmx", "unabridged", "unreleased", "version", "vip"] diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index a6be02b3b..065f23ade 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -301,6 +301,21 @@ class DummyImportTask: ("Alice", "Song 1 ft. Bob "), id="title-with-angle-brackets-keyword", ), + # multi-word keyword + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Club Mix)"), + id="multi-word-keyword-positive-match", + ), + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), + ("Alice", "Song 1 (Club Remix) ft. Bob"), + id="multi-word-keyword-negative-no-match", + ), ], ) def test_ftintitle_functional( @@ -447,6 +462,9 @@ def test_find_bracket_position(given: str, expected: int | None) -> None: ("Song (Remix)", [], 5), ("Song", [], None), ("Song (", [], None), + # Multi-word keyword tests + ("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], None), # Negative: no match ], ) def test_find_bracket_position_custom_keywords( From 2aa949e5a0f9da3e079296b376f0b8e72d3da70b Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 16 Nov 2025 19:48:46 -0600 Subject: [PATCH 06/33] fix(fitintitle): simplify keyword_pattern using map() instead of list comprehension --- beetsplug/ftintitle.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index d3d600958..4d0821593 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -106,9 +106,7 @@ def find_bracket_position( pattern = None else: # Build regex pattern to support multi-word keywords/phrases. - # Each keyword/phrase is escaped and surrounded by word boundaries at - # start and end, matching phrases like "club mix" as a whole. - keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) + keyword_pattern = rf"\b{'|'.join(map(re.escape, keywords))}\b" pattern = re.compile(keyword_pattern, re.IGNORECASE) # Bracket pairs (opening, closing) From 50e55f85f4fd231d0023353355faed32cb007611 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 16 Nov 2025 19:54:27 -0600 Subject: [PATCH 07/33] fix(ftintitle): prune find_bracket_position docstring --- beetsplug/ftintitle.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 4d0821593..629e58f17 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -89,14 +89,6 @@ def find_bracket_position( ) -> int | None: """Find the position of the first opening bracket that contains remix/edit-related keywords and has a matching closing bracket. - - Args: - title: The title to search in. - keywords: List of keywords to match. If None, uses DEFAULT_BRACKET_KEYWORDS. - If an empty list, matches any bracket content (not just keywords). - - Returns: - The position of the opening bracket, or None if no match found. """ if keywords is None: keywords = DEFAULT_BRACKET_KEYWORDS From 3051af9eb64c60f7334c29fdf58013055cb245b3 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Mon, 17 Nov 2025 12:56:19 -0600 Subject: [PATCH 08/33] fix: abstract insert_ft_into_title, move bracket_keywords and find_bracket_position inside plugin --- beetsplug/ftintitle.py | 117 ++++++++++++++------------------- test/plugins/test_ftintitle.py | 37 +++++++++-- 2 files changed, 79 insertions(+), 75 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 629e58f17..06c5e69be 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,6 +17,7 @@ from __future__ import annotations import re +from functools import cached_property from typing import TYPE_CHECKING from beets import plugins, ui @@ -84,58 +85,6 @@ DEFAULT_BRACKET_KEYWORDS = [ ] -def find_bracket_position( - title: str, keywords: list[str] | None = None -) -> int | None: - """Find the position of the first opening bracket that contains - remix/edit-related keywords and has a matching closing bracket. - """ - if keywords is None: - keywords = DEFAULT_BRACKET_KEYWORDS - - # If keywords is empty, match any bracket content - if not keywords: - pattern = None - else: - # Build regex pattern to support multi-word keywords/phrases. - keyword_pattern = rf"\b{'|'.join(map(re.escape, keywords))}\b" - pattern = re.compile(keyword_pattern, re.IGNORECASE) - - # Bracket pairs (opening, closing) - bracket_pairs = [("(", ")"), ("[", "]"), ("<", ">"), ("{", "}")] - - # Track the earliest valid bracket position - earliest_pos = None - - for open_char, close_char in bracket_pairs: - pos = 0 - while True: - # Find next opening bracket - open_pos = title.find(open_char, pos) - if open_pos == -1: - break - - # Find matching closing bracket - close_pos = title.find(close_char, open_pos + 1) - if close_pos == -1: - break - - # Extract content between brackets - content = title[open_pos + 1 : close_pos] - - # Check if content matches: if pattern is None (empty keywords), - # match any content; otherwise check for keywords - if (pattern is None or pattern.search(content)) and ( - earliest_pos is None or open_pos < earliest_pos - ): - earliest_pos = open_pos - - # Continue searching from after this closing bracket - pos = close_pos + 1 - - return earliest_pos - - def find_feat_part( artist: str, albumartist: str | None, @@ -176,6 +125,10 @@ def find_feat_part( class FtInTitlePlugin(plugins.BeetsPlugin): + @cached_property + def bracket_keywords(self) -> list[str] | None: + return self.config["bracket_keywords"].as_str_seq() + def __init__(self) -> None: super().__init__() @@ -216,7 +169,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): bool ) custom_words = self.config["custom_words"].get(list) - bracket_keywords = self.config["bracket_keywords"].get(list) write = ui.should_write() for item in lib.items(args): @@ -226,7 +178,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, - bracket_keywords, ): item.store() if write: @@ -241,7 +192,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) - bracket_keywords = self.config["bracket_keywords"].get(list) for item in task.imported_items(): if self.ft_in_title( @@ -250,7 +200,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, - bracket_keywords, ): item.store() @@ -261,7 +210,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat: bool, keep_in_artist_field: bool, custom_words: list[str], - bracket_keywords: list[str] | None = None, ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. @@ -290,15 +238,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # artist and if we do not drop featuring information. if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() - new_format = feat_format.format(feat_part) - # Insert before the first bracket containing remix/edit keywords - bracket_pos = find_bracket_position(item.title, bracket_keywords) - if bracket_pos is not None: - title_before = item.title[:bracket_pos].rstrip() - title_after = item.title[bracket_pos:] - new_title = f"{title_before} {new_format} {title_after}" - else: - new_title = f"{item.title} {new_format}" + formatted = feat_format.format(feat_part) + new_title = self.insert_ft_into_title(item.title, formatted) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -309,7 +250,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field: bool, preserve_album_artist: bool, custom_words: list[str], - bracket_keywords: list[str] | None = None, ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -346,6 +286,47 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat, keep_in_artist_field, custom_words, - bracket_keywords, ) return True + + def find_bracket_position( + self, + title: str, + ) -> int | None: + """Find the position of the first opening bracket that contains + remix/edit-related keywords and has a matching closing bracket. + """ + keywords = self.bracket_keywords + + # If keywords is empty, match any bracket content + if not keywords: + keyword_ptn = ".*?" + else: + # Build regex supporting keywords/multi-word phrases. + keyword_ptn = rf"\b{'|'.join(map(re.escape, keywords))}\b" + + pattern = re.compile( + rf""" + \(.*?({keyword_ptn}).*?\) | + \[.*?({keyword_ptn}).*?\] | + <.*?({keyword_ptn}).*?> | + \{{.*?({keyword_ptn}).*?}} + """, + re.IGNORECASE | re.VERBOSE, + ) + + return m.start() if (m := pattern.search(title)) else None + + def insert_ft_into_title( + self, + title: str, + feat_part: str, + ) -> str: + """Insert featured artist before the first bracket containing + remix/edit keywords if present. + """ + if (bracket_pos := self.find_bracket_position(title)) is not None: + title_before = title[:bracket_pos].rstrip() + title_after = title[bracket_pos:] + return f"{title_before} {feat_part} {title_after}" + return f"{title} {feat_part}" diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 065f23ade..73853e6c3 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,7 +15,7 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator -from typing import cast +from typing import TypeAlias, cast import pytest @@ -24,6 +24,8 @@ from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle +ConfigValue: TypeAlias = str | bool | list[str] + class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" @@ -41,7 +43,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( env: FtInTitlePluginFunctional, - cfg: dict[str, str | bool | list[str]] | None, + cfg: dict[str, ConfigValue] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -49,11 +51,21 @@ def set_config( "auto": True, "keep_in_artist": False, "custom_words": [], + "bracket_keywords": ftintitle.DEFAULT_BRACKET_KEYWORDS.copy(), } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) +def build_plugin( + env: FtInTitlePluginFunctional, + cfg: dict[str, ConfigValue] | None = None, +) -> ftintitle.FtInTitlePlugin: + """Instantiate plugin with provided config applied first.""" + set_config(env, cfg) + return ftintitle.FtInTitlePlugin() + + def add_item( env: FtInTitlePluginFunctional, path: str, @@ -427,7 +439,7 @@ def test_split_on_feat( ("Song (Live", None), # no matching brace with keyword # one keyword clause, one non-keyword clause ("Song (Live) (Arbitrary)", 5), - ("Song (Arbitrary) (Remix)", 17), + ("Song (Arbitrary) (Remix)", 5), # nested brackets - same type ("Song (Remix (Extended))", 5), ("Song [Arbitrary [Description]]", None), @@ -443,8 +455,13 @@ def test_split_on_feat( ("Song [Description (Arbitrary)]", None), ], ) -def test_find_bracket_position(given: str, expected: int | None) -> None: - assert ftintitle.find_bracket_position(given) == expected +def test_find_bracket_position( + env: FtInTitlePluginFunctional, + given: str, + expected: int | None, +) -> None: + plugin = build_plugin(env) + assert plugin.find_bracket_position(given) == expected @pytest.mark.parametrize( @@ -468,9 +485,15 @@ def test_find_bracket_position(given: str, expected: int | None) -> None: ], ) def test_find_bracket_position_custom_keywords( - given: str, keywords: list[str] | None, expected: int | None + env: FtInTitlePluginFunctional, + given: str, + keywords: list[str] | None, + expected: int | None, ) -> None: - assert ftintitle.find_bracket_position(given, keywords) == expected + cfg: dict[str, ConfigValue] | None + cfg = None if keywords is None else {"bracket_keywords": keywords} + plugin = build_plugin(env, cfg) + assert plugin.find_bracket_position(given) == expected @pytest.mark.parametrize( From 84d37b820a9b80259185da9be172a0d6aa85fe8f Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 14 Dec 2025 18:15:08 -0600 Subject: [PATCH 09/33] fix: inline default bracket_keywords instead of defining/cloning constant --- beetsplug/ftintitle.py | 48 +++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 06c5e69be..a81c58574 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -60,31 +60,6 @@ def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: ) -# Default keywords that indicate remix/edit/version content -DEFAULT_BRACKET_KEYWORDS = [ - "abridged", - "acapella", - "club", - "demo", - "edit", - "edition", - "extended", - "instrumental", - "live", - "mix", - "radio", - "release", - "remaster", - "remastered", - "remix", - "rmx", - "unabridged", - "unreleased", - "version", - "vip", -] - - def find_feat_part( artist: str, albumartist: str | None, @@ -140,7 +115,28 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(), + "bracket_keywords": [ + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", + ], } ) From ef40d1ac536bd979392acd4236f08203242c8616 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 14 Dec 2025 18:19:38 -0600 Subject: [PATCH 10/33] fix: revert needless whitespace change --- beetsplug/ftintitle.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index a81c58574..e6c8c897a 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -277,11 +277,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, - feat_part, - drop_feat, - keep_in_artist_field, - custom_words, + item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True From 00792922b58b6a68d4c5e5fc32416ea926a98290 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 20 Dec 2025 02:19:54 -0600 Subject: [PATCH 11/33] fix: address remaining review comments --- beetsplug/ftintitle.py | 140 +++++++++++++++--------- test/plugins/test_ftintitle.py | 194 ++++++++------------------------- 2 files changed, 130 insertions(+), 204 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index e6c8c897a..b8bd3e261 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,7 +17,7 @@ from __future__ import annotations import re -from functools import cached_property +from functools import cached_property, lru_cache from typing import TYPE_CHECKING from beets import plugins, ui @@ -99,11 +99,78 @@ def find_feat_part( return feat_part +DEFAULT_BRACKET_KEYWORDS: tuple[str, ...] = ( + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +) + + class FtInTitlePlugin(plugins.BeetsPlugin): @cached_property - def bracket_keywords(self) -> list[str] | None: + def bracket_keywords(self) -> list[str]: return self.config["bracket_keywords"].as_str_seq() + @staticmethod + @lru_cache(maxsize=256) + def _bracket_position_pattern(keywords: tuple[str, ...]) -> re.Pattern[str]: + """ + Build a compiled regex to find the first bracketed segment that contains + any of the provided keywords. + + Cached by keyword tuple to avoid recompiling on every track/title. + """ + kw_inner = "|".join(map(re.escape, keywords)) + + # If we have keywords, require one of them to appear in the bracket text. + # If kw == "", the lookahead becomes trivially true and we match any bracket content. + kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" + + return re.compile( + rf""" + (?: # Match ONE bracketed segment of any supported type + \( # "(" + (?=[^)]*{kw}) # Lookahead: keyword must appear before closing ")" + # - if kw == "", this is always true + [^)]* # Consume bracket content (no nested ")" handling) + \) # ")" + + | \[ # "[" + (?=[^\]]*{kw}) # Lookahead + [^\]]* # Consume content up to first "]" + \] # "]" + + | < # "<" + (?=[^>]*{kw}) # Lookahead + [^>]* # Consume content up to first ">" + > # ">" + + | \x7B # Literal open brace + (?=[^\x7D]*{kw}) # Lookahead + [^\x7D]* # Consume content up to first close brace + \x7D # Literal close brace + ) # End bracketed segment alternation + """, + re.IGNORECASE | re.VERBOSE, + ) + def __init__(self) -> None: super().__init__() @@ -115,28 +182,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": [ - "abridged", - "acapella", - "club", - "demo", - "edit", - "edition", - "extended", - "instrumental", - "live", - "mix", - "radio", - "release", - "remaster", - "remastered", - "remix", - "rmx", - "unabridged", - "unreleased", - "version", - "vip", - ], + "bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS), } ) @@ -235,7 +281,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() formatted = feat_format.format(feat_part) - new_title = self.insert_ft_into_title(item.title, formatted) + new_title = FtInTitlePlugin.insert_ft_into_title( + item.title, formatted, self.bracket_keywords + ) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -281,43 +329,29 @@ class FtInTitlePlugin(plugins.BeetsPlugin): ) return True + @staticmethod def find_bracket_position( - self, - title: str, + title: str, keywords: list[str] | None = None ) -> int | None: - """Find the position of the first opening bracket that contains - remix/edit-related keywords and has a matching closing bracket. - """ - keywords = self.bracket_keywords - - # If keywords is empty, match any bracket content - if not keywords: - keyword_ptn = ".*?" - else: - # Build regex supporting keywords/multi-word phrases. - keyword_ptn = rf"\b{'|'.join(map(re.escape, keywords))}\b" - - pattern = re.compile( - rf""" - \(.*?({keyword_ptn}).*?\) | - \[.*?({keyword_ptn}).*?\] | - <.*?({keyword_ptn}).*?> | - \{{.*?({keyword_ptn}).*?}} - """, - re.IGNORECASE | re.VERBOSE, + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) ) + pattern = FtInTitlePlugin._bracket_position_pattern(normalized) + m: re.Match[str] | None = pattern.search(title) + return m.start() if m else None - return m.start() if (m := pattern.search(title)) else None - + @staticmethod def insert_ft_into_title( - self, - title: str, - feat_part: str, + title: str, feat_part: str, keywords: list[str] | None = None ) -> str: """Insert featured artist before the first bracket containing remix/edit keywords if present. """ - if (bracket_pos := self.find_bracket_position(title)) is not None: + if ( + bracket_pos := FtInTitlePlugin.find_bracket_position( + title, keywords + ) + ) is not None: title_before = title[:bracket_pos].rstrip() title_after = title[bracket_pos:] return f"{title_before} {feat_part} {title_after}" diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 73853e6c3..9d6b54a93 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,11 +15,10 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator -from typing import TypeAlias, cast +from typing import TypeAlias import pytest -from beets.importer import ImportSession, ImportTask from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -51,21 +50,11 @@ def set_config( "auto": True, "keep_in_artist": False, "custom_words": [], - "bracket_keywords": ftintitle.DEFAULT_BRACKET_KEYWORDS.copy(), } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) -def build_plugin( - env: FtInTitlePluginFunctional, - cfg: dict[str, ConfigValue] | None = None, -) -> ftintitle.FtInTitlePlugin: - """Instantiate plugin with provided config applied first.""" - set_config(env, cfg) - return ftintitle.FtInTitlePlugin() - - def add_item( env: FtInTitlePluginFunctional, path: str, @@ -82,16 +71,6 @@ def add_item( ) -class DummyImportTask: - """Minimal stand-in for ImportTask used to exercise import hooks.""" - - def __init__(self, items: list[Item]) -> None: - self._items = items - - def imported_items(self) -> list[Item]: - return self._items - - @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ @@ -272,61 +251,18 @@ class DummyImportTask: ), # ---- titles with brackets/parentheses ---- pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), - ("Alice", "Song 1 ft. Bob (Carol Remix)"), - id="title-with-brackets-insert-before", - ), - pytest.param( - {"format": "ft. {}", "keep_in_artist": True}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), - ("Alice ft. Bob", "Song 1 ft. Bob (Carol Remix)"), - id="title-with-brackets-keep-in-artist", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Remix) (Live)", "Alice"), - ("Alice", "Song 1 ft. Bob (Remix) (Live)"), - id="title-with-multiple-brackets-uses-first-with-keyword", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Arbitrary)", "Alice"), - ("Alice", "Song 1 (Arbitrary) ft. Bob"), - id="title-with-brackets-no-keyword-appends", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 [Edit]", "Alice"), - ("Alice", "Song 1 ft. Bob [Edit]"), - id="title-with-square-brackets-keyword", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 ", "Alice"), - ("Alice", "Song 1 ft. Bob "), - id="title-with-angle-brackets-keyword", - ), - # multi-word keyword - pytest.param( - {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + {"format": "ft. {}", "bracket_keywords": ["mix"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), ("Alice", "Song 1 ft. Bob (Club Mix)"), - id="multi-word-keyword-positive-match", + id="ft-inserted-before-matching-bracket-keyword", ), pytest.param( - {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + {"format": "ft. {}", "bracket_keywords": ["nomatch"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), ("Alice", "Song 1 (Club Remix) ft. Bob"), - id="multi-word-keyword-negative-no-match", + id="ft-inserted-at-end-no-bracket-keyword-match", ), ], ) @@ -351,31 +287,6 @@ def test_ftintitle_functional( assert item["title"] == expected_title -def test_imported_stage_moves_featured_artist( - env: FtInTitlePluginFunctional, -) -> None: - """The import-stage hook should fetch config settings and process items.""" - set_config(env, None) - plugin = ftintitle.FtInTitlePlugin() - item = add_item( - env, - "/imported-hook", - "Alice feat. Bob", - "Song 1 (Carol Remix)", - "Various Artists", - ) - task = DummyImportTask([item]) - - plugin.imported( - cast(ImportSession, None), - cast(ImportTask, task), - ) - item.load() - - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 feat. Bob (Carol Remix)" - - @pytest.mark.parametrize( "artist,albumartist,expected", [ @@ -419,64 +330,46 @@ def test_split_on_feat( assert ftintitle.split_on_feat(given) == expected -@pytest.mark.parametrize( - "given,expected", - [ - # different braces and keywords - ("Song (Remix)", 5), - ("Song [Version]", 5), - ("Song {Extended Mix}", 5), - ("Song ", 5), - # two keyword clauses - ("Song (Remix) (Live)", 5), - # brace insensitivity - ("Song (Live) [Remix]", 5), - ("Song [Edit] (Remastered)", 5), - # negative cases - ("Song", None), # no clause - ("Song (Arbitrary)", None), # no keyword - ("Song (", None), # no matching brace or keyword - ("Song (Live", None), # no matching brace with keyword - # one keyword clause, one non-keyword clause - ("Song (Live) (Arbitrary)", 5), - ("Song (Arbitrary) (Remix)", 5), - # nested brackets - same type - ("Song (Remix (Extended))", 5), - ("Song [Arbitrary [Description]]", None), - # nested brackets - different types - ("Song (Remix [Extended])", 5), - # nested - returns outer start position despite inner keyword - ("Song [Arbitrary {Extended}]", 5), - ("Song {Live }", 5), - ("Song ", 5), - ("Song [Live]", 5), - ("Song (Version) ", 5), - ("Song (Arbitrary [Description])", None), - ("Song [Description (Arbitrary)]", None), - ], -) -def test_find_bracket_position( - env: FtInTitlePluginFunctional, - given: str, - expected: int | None, -) -> None: - plugin = build_plugin(env) - assert plugin.find_bracket_position(given) == expected - - @pytest.mark.parametrize( "given,keywords,expected", [ + ## default keywords + # different braces and keywords + ("Song (Remix)", None, 5), + ("Song [Version]", None, 5), + ("Song {Extended Mix}", None, 5), + ("Song ", None, 5), + # two keyword clauses + ("Song (Remix) (Live)", None, 5), + # brace insensitivity + ("Song (Live) [Remix]", None, 5), + ("Song [Edit] (Remastered)", None, 5), + # negative cases + ("Song", None, None), # no clause + ("Song (Arbitrary)", None, None), # no keyword + ("Song (", None, None), # no matching brace or keyword + ("Song (Live", None, None), # no matching brace with keyword + # one keyword clause, one non-keyword clause + ("Song (Live) (Arbitrary)", None, 5), + ("Song (Arbitrary) (Remix)", None, 17), + # nested brackets - same type + ("Song (Remix (Extended))", None, 5), + ("Song [Arbitrary [Description]]", None, None), + # nested brackets - different types + ("Song (Remix [Extended])", None, 5), + # nested - returns outer start position despite inner keyword + ("Song [Arbitrary {Extended}]", None, 5), + ("Song {Live }", None, 5), + ("Song ", None, 5), + ("Song [Live]", None, 5), + ("Song (Version) ", None, 5), + ("Song (Arbitrary [Description])", None, None), + ("Song [Description (Arbitrary)]", None, None), + ## custom keywords ("Song (Live)", ["live"], 5), - ("Song (Live)", None, 5), - ("Song (Arbitrary)", None, None), ("Song (Concert)", ["concert"], 5), - ("Song (Concert)", None, None), ("Song (Remix)", ["custom"], None), ("Song (Custom)", ["custom"], 5), - ("Song (Live)", [], 5), - ("Song (Anything)", [], 5), - ("Song (Remix)", [], 5), ("Song", [], None), ("Song (", [], None), # Multi-word keyword tests @@ -484,16 +377,15 @@ def test_find_bracket_position( ("Song (Club Remix)", ["club mix"], None), # Negative: no match ], ) -def test_find_bracket_position_custom_keywords( - env: FtInTitlePluginFunctional, +def test_find_bracket_position( given: str, keywords: list[str] | None, expected: int | None, ) -> None: - cfg: dict[str, ConfigValue] | None - cfg = None if keywords is None else {"bracket_keywords": keywords} - plugin = build_plugin(env, cfg) - assert plugin.find_bracket_position(given) == expected + assert ( + ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords) + == expected + ) @pytest.mark.parametrize( From c0c7a9df8f487b7ca029a9f44800090c4a0e850f Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 20 Dec 2025 02:34:15 -0600 Subject: [PATCH 12/33] fix: line length --- beetsplug/ftintitle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index eafc9e191..44f17bc4e 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -145,7 +145,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): kw_inner = "|".join(map(re.escape, keywords)) # If we have keywords, require one of them to appear in the bracket text. - # If kw == "", the lookahead becomes trivially true and we match any bracket content. + # If kw == "", the lookahead becomes true and we match any bracket content. kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" return re.compile( From c1e36e52a865940ad9319d7716c35c07260bf496 Mon Sep 17 00:00:00 2001 From: Alexandre Detiste Date: Thu, 1 Jan 2026 01:49:17 +0100 Subject: [PATCH 13/33] drop extraneous dependency on old external "mock" --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bc694de90..e7eebd3a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,6 @@ beautifulsoup4 = "*" codecov = ">=2.1.13" flask = "*" langdetect = "*" -mock = "*" pylast = "*" pytest = "*" pytest-cov = "*" @@ -125,7 +124,6 @@ sphinx-lint = ">=1.0.0" mypy = "*" types-beautifulsoup4 = "*" types-docutils = ">=0.22.2.20251006" -types-mock = "*" types-Flask-Cors = "*" types-Pillow = "*" types-PyYAML = "*" From d6da6cda7ebcfb8b999bf716c92bf0b5f41ad80c Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 1 Jan 2026 15:46:06 +0000 Subject: [PATCH 14/33] Update poetry.lock after removing mock --- poetry.lock | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8e489b4ed..dbd3ecf3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1731,22 +1731,6 @@ mutagen = ">=1.46" [package.extras] test = ["tox"] -[[package]] -name = "mock" -version = "5.2.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, - {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - [[package]] name = "msgpack" version = "1.1.2" @@ -4063,17 +4047,6 @@ files = [ {file = "types_html5lib-1.1.11.20251014.tar.gz", hash = "sha256:cc628d626e0111a2426a64f5f061ecfd113958b69ff6b3dc0eaaed2347ba9455"}, ] -[[package]] -name = "types-mock" -version = "5.2.0.20250924" -description = "Typing stubs for mock" -optional = false -python-versions = ">=3.9" -files = [ - {file = "types_mock-5.2.0.20250924-py3-none-any.whl", hash = "sha256:23617ffb4cf948c085db69ec90bd474afbce634ef74995045ae0a5748afbe57d"}, - {file = "types_mock-5.2.0.20250924.tar.gz", hash = "sha256:953197543b4183f00363e8e626f6c7abea1a3f7a4dd69d199addb70b01b6bb35"}, -] - [[package]] name = "types-pillow" version = "10.2.0.20240822" @@ -4226,4 +4199,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "8cf2ad0e6a842511e1215720a63bfdf9d5f49345410644cbb0b5fd8fb74f50d2" +content-hash = "45c7dc4ec30f4460a09554d0ec0ebcafebff097386e005e29e12830d16d223dd" From afc26fa58f15a18f1d672eb1b2432027d7b6ad35 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 1 Jan 2026 15:50:37 +0000 Subject: [PATCH 15/33] Add packaging note about mock dependency removal --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 49402bad7..d3d9f3f6a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,7 @@ For plugin developers: For packagers: - The minimum supported Python version is now 3.10. +- An unused dependency on ``mock`` has been removed. Other changes: From b14755df881b46a5fcce1ada4d5d895c5e54a331 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Thu, 1 Jan 2026 15:39:17 -0600 Subject: [PATCH 16/33] fix(ftintitle): remaining opportunities for improvement --- beetsplug/ftintitle.py | 56 ++++++++++---------------- pyproject.toml | 1 + test/plugins/test_ftintitle.py | 72 +++++++++++++++++----------------- 3 files changed, 58 insertions(+), 71 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 44f17bc4e..cf30e83f4 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -146,32 +146,19 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have keywords, require one of them to appear in the bracket text. # If kw == "", the lookahead becomes true and we match any bracket content. - kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" - + kw = rf"\b(?={kw_inner})\b" if kw_inner else "" return re.compile( rf""" - (?: # Match ONE bracketed segment of any supported type - \( # "(" - (?=[^)]*{kw}) # Lookahead: keyword must appear before closing ")" - # - if kw == "", this is always true - [^)]* # Consume bracket content (no nested ")" handling) - \) # ")" - - | \[ # "[" - (?=[^\]]*{kw}) # Lookahead - [^\]]* # Consume content up to first "]" - \] # "]" - - | < # "<" - (?=[^>]*{kw}) # Lookahead - [^>]* # Consume content up to first ">" - > # ">" - - | \x7B # Literal open brace - (?=[^\x7D]*{kw}) # Lookahead - [^\x7D]* # Consume content up to first close brace - \x7D # Literal close brace - ) # End bracketed segment alternation + (?: # non-capturing group for the split + \s*? # optional whitespace before brackets + (?= # any bracket containing a keyword + \([^)]*{kw}.*?\) + | \[[^]]*{kw}.*?\] + | <[^>]*{kw}.*? > + | \{{[^}}]*{kw}.*?\}} + | $ # or the end of the string + ) + ) """, re.IGNORECASE | re.VERBOSE, ) @@ -290,7 +277,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() formatted = feat_format.format(feat_part) - new_title = FtInTitlePlugin.insert_ft_into_title( + new_title = self.insert_ft_into_title( item.title, formatted, self.bracket_keywords ) self._log.info("title: {.title} -> {}", item, new_title) @@ -349,19 +336,16 @@ class FtInTitlePlugin(plugins.BeetsPlugin): m: re.Match[str] | None = pattern.search(title) return m.start() if m else None - @staticmethod + @classmethod def insert_ft_into_title( - title: str, feat_part: str, keywords: list[str] | None = None + cls, title: str, feat_part: str, keywords: list[str] | None = None ) -> str: """Insert featured artist before the first bracket containing remix/edit keywords if present. """ - if ( - bracket_pos := FtInTitlePlugin.find_bracket_position( - title, keywords - ) - ) is not None: - title_before = title[:bracket_pos].rstrip() - title_after = title[bracket_pos:] - return f"{title_before} {feat_part} {title_after}" - return f"{title} {feat_part}" + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) + ) + pattern = cls._bracket_position_pattern(normalized) + parts = pattern.split(title, maxsplit=1) + return f" {feat_part} ".join(parts).strip() diff --git a/pyproject.toml b/pyproject.toml index 24cf21b33..06552f124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -322,6 +322,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] "test/test_util.py" = ["E501"] +"test/plugins/test_ftintitle.py" = ["E501"] [tool.ruff.lint.isort] split-on-trailing-comma = false diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index abba22d11..b21ac1c7f 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -335,55 +335,57 @@ def test_split_on_feat( [ ## default keywords # different braces and keywords - ("Song (Remix)", None, 5), - ("Song [Version]", None, 5), - ("Song {Extended Mix}", None, 5), - ("Song ", None, 5), + ("Song (Remix)", None, "Song ft. Bob (Remix)"), + ("Song [Version]", None, "Song ft. Bob [Version]"), + ("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"), + ("Song ", None, "Song ft. Bob "), # two keyword clauses - ("Song (Remix) (Live)", None, 5), + ("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"), # brace insensitivity - ("Song (Live) [Remix]", None, 5), - ("Song [Edit] (Remastered)", None, 5), + ("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"), + ("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"), # negative cases - ("Song", None, None), # no clause - ("Song (Arbitrary)", None, None), # no keyword - ("Song (", None, None), # no matching brace or keyword - ("Song (Live", None, None), # no matching brace with keyword + ("Song", None, "Song ft. Bob"), # no clause + ("Song (Arbitrary)", None, "Song (Arbitrary) ft. Bob"), # no keyword + ("Song (", None, "Song ( ft. Bob"), # no matching brace or keyword + ("Song (Live", None, "Song (Live ft. Bob"), # no matching brace with keyword # one keyword clause, one non-keyword clause - ("Song (Live) (Arbitrary)", None, 5), - ("Song (Arbitrary) (Remix)", None, 17), + ("Song (Live) (Arbitrary)", None, "Song ft. Bob (Live) (Arbitrary)"), + ("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"), # nested brackets - same type - ("Song (Remix (Extended))", None, 5), - ("Song [Arbitrary [Description]]", None, None), + ("Song (Remix (Extended))", None, "Song ft. Bob (Remix (Extended))"), + ("Song [Arbitrary [Description]]", None, "Song [Arbitrary [Description]] ft. Bob"), # nested brackets - different types - ("Song (Remix [Extended])", None, 5), + ("Song (Remix [Extended])", None, "Song ft. Bob (Remix [Extended])"), # nested - returns outer start position despite inner keyword - ("Song [Arbitrary {Extended}]", None, 5), - ("Song {Live }", None, 5), - ("Song ", None, 5), - ("Song [Live]", None, 5), - ("Song (Version) ", None, 5), - ("Song (Arbitrary [Description])", None, None), - ("Song [Description (Arbitrary)]", None, None), + ("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"), + ("Song {Live }", None, "Song ft. Bob {Live }"), + ("Song ", None, "Song ft. Bob "), + ("Song [Live]", None, "Song ft. Bob [Live]"), + ("Song (Version) ", None, "Song ft. Bob (Version) "), + ("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"), + ("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"), ## custom keywords - ("Song (Live)", ["live"], 5), - ("Song (Concert)", ["concert"], 5), - ("Song (Remix)", ["custom"], None), - ("Song (Custom)", ["custom"], 5), - ("Song", [], None), - ("Song (", [], None), + ("Song (Live)", ["live"], "Song ft. Bob (Live)"), + ("Song (Concert)", ["concert"], "Song ft. Bob (Concert)"), + ("Song (Remix)", ["custom"], "Song (Remix) ft. Bob"), + ("Song (Custom)", ["custom"], "Song ft. Bob (Custom)"), + ("Song", [], "Song ft. Bob"), + ("Song (", [], "Song ( ft. Bob"), # Multi-word keyword tests - ("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word - ("Song (Club Remix)", ["club mix"], None), # Negative: no match + ("Song (Club Mix)", ["club mix"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match ], -) -def test_find_bracket_position( +) # fmt: skip +def test_insert_ft_into_title( given: str, keywords: list[str] | None, - expected: int | None, + expected: str, ) -> None: assert ( - ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords) + ftintitle.FtInTitlePlugin.insert_ft_into_title( + given, "ft. Bob", keywords + ) == expected ) From 523fa6ceaf1b0bc67d097d9fc88d6de058b1f4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 04:04:02 +0000 Subject: [PATCH 17/33] Move MusicBrainzAPI to a shared util --- beetsplug/_utils/musicbrainz.py | 122 +++++++++++++++++++++++ beetsplug/mbpseudo.py | 2 +- beetsplug/musicbrainz.py | 130 +++---------------------- test/plugins/test_mbpseudo.py | 2 +- test/plugins/test_musicbrainz.py | 95 ++---------------- test/plugins/utils/test_musicbrainz.py | 82 ++++++++++++++++ 6 files changed, 229 insertions(+), 204 deletions(-) create mode 100644 beetsplug/_utils/musicbrainz.py create mode 100644 test/plugins/utils/test_musicbrainz.py diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py new file mode 100644 index 000000000..3327269b2 --- /dev/null +++ b/beetsplug/_utils/musicbrainz.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import operator +from dataclasses import dataclass, field +from functools import cached_property, singledispatchmethod +from itertools import groupby +from typing import TYPE_CHECKING, Any + +from requests_ratelimiter import LimiterMixin + +from beets import config + +from .requests import RequestHandler, TimeoutAndRetrySession + +if TYPE_CHECKING: + from .._typing import JSONDict + + +class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): + pass + + +@dataclass +class MusicBrainzAPI(RequestHandler): + api_host: str = field(init=False) + rate_limit: float = field(init=False) + + def __post_init__(self) -> None: + mb_config = config["musicbrainz"] + mb_config.add( + { + "host": "musicbrainz.org", + "https": False, + "ratelimit": 1, + "ratelimit_interval": 1, + } + ) + + hostname = mb_config["host"].as_str() + if hostname == "musicbrainz.org": + self.api_host, self.rate_limit = "https://musicbrainz.org", 1.0 + else: + https = mb_config["https"].get(bool) + self.api_host = f"http{'s' if https else ''}://{hostname}" + self.rate_limit = ( + mb_config["ratelimit"].get(int) + / mb_config["ratelimit_interval"].as_number() + ) + + def create_session(self) -> LimiterTimeoutSession: + return LimiterTimeoutSession(per_second=self.rate_limit) + + def get_entity( + self, entity: str, includes: list[str] | None = None, **kwargs + ) -> JSONDict: + if includes: + kwargs["inc"] = "+".join(includes) + + return self._group_relations( + self.get_json( + f"{self.api_host}/ws/2/{entity}", + params={**kwargs, "fmt": "json"}, + ) + ) + + def get_release(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"release/{id_}", **kwargs) + + def get_recording(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"recording/{id_}", **kwargs) + + def browse_recordings(self, **kwargs) -> list[JSONDict]: + return self.get_entity("recording", **kwargs)["recordings"] + + @singledispatchmethod + @classmethod + def _group_relations(cls, data: Any) -> Any: + """Normalize MusicBrainz 'relations' into type-keyed fields recursively. + + This helper rewrites payloads that use a generic 'relations' list into + a structure that is easier to consume downstream. When a mapping + contains 'relations', those entries are regrouped by their 'target-type' + and stored under keys like '-relations'. The original + 'relations' key is removed to avoid ambiguous access patterns. + + The transformation is applied recursively so that nested objects and + sequences are normalized consistently, while non-container values are + left unchanged. + """ + return data + + @_group_relations.register(list) + @classmethod + def _(cls, data: list[Any]) -> list[Any]: + return [cls._group_relations(i) for i in data] + + @_group_relations.register(dict) + @classmethod + def _(cls, data: JSONDict) -> JSONDict: + for k, v in list(data.items()): + if k == "relations": + get_target_type = operator.methodcaller("get", "target-type") + for target_type, group in groupby( + sorted(v, key=get_target_type), get_target_type + ): + relations = [ + {k: v for k, v in item.items() if k != "target-type"} + for item in group + ] + data[f"{target_type}-relations"] = cls._group_relations( + relations + ) + data.pop("relations") + else: + data[k] = cls._group_relations(v) + return data + + +class MusicBrainzAPIMixin: + @cached_property + def mb_api(self) -> MusicBrainzAPI: + return MusicBrainzAPI() diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index b61af2cc7..30ef2e428 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -141,7 +141,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): if (ids := self._intercept_mb_release(release)) and ( album_id := self._extract_id(ids[0]) ): - raw_pseudo_release = self.api.get_release(album_id) + raw_pseudo_release = self.mb_api.get_release(album_id) pseudo_release = super().album_info(raw_pseudo_release) if self.config["custom_tags_only"].get(bool): diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8cab1786b..38097b2ce 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -16,17 +16,14 @@ from __future__ import annotations -import operator from collections import Counter from contextlib import suppress -from dataclasses import dataclass -from functools import cached_property, singledispatchmethod -from itertools import groupby, product +from functools import cached_property +from itertools import product from typing import TYPE_CHECKING, Any from urllib.parse import urljoin from confuse.exceptions import NotFoundError -from requests_ratelimiter import LimiterMixin import beets import beets.autotag.hooks @@ -35,11 +32,8 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id -from ._utils.requests import ( - HTTPNotFoundError, - RequestHandler, - TimeoutAndRetrySession, -) +from ._utils.musicbrainz import MusicBrainzAPIMixin +from ._utils.requests import HTTPNotFoundError if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -103,86 +97,6 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 -class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): - pass - - -@dataclass -class MusicBrainzAPI(RequestHandler): - api_host: str - rate_limit: float - - def create_session(self) -> LimiterTimeoutSession: - return LimiterTimeoutSession(per_second=self.rate_limit) - - def get_entity( - self, entity: str, inc_list: list[str] | None = None, **kwargs - ) -> JSONDict: - if inc_list: - kwargs["inc"] = "+".join(inc_list) - - return self._group_relations( - self.get_json( - f"{self.api_host}/ws/2/{entity}", - params={**kwargs, "fmt": "json"}, - ) - ) - - def get_release(self, id_: str) -> JSONDict: - return self.get_entity(f"release/{id_}", inc_list=RELEASE_INCLUDES) - - def get_recording(self, id_: str) -> JSONDict: - return self.get_entity(f"recording/{id_}", inc_list=TRACK_INCLUDES) - - def browse_recordings(self, **kwargs) -> list[JSONDict]: - kwargs.setdefault("limit", BROWSE_CHUNKSIZE) - kwargs.setdefault("inc_list", BROWSE_INCLUDES) - return self.get_entity("recording", **kwargs)["recordings"] - - @singledispatchmethod - @classmethod - def _group_relations(cls, data: Any) -> Any: - """Normalize MusicBrainz 'relations' into type-keyed fields recursively. - - This helper rewrites payloads that use a generic 'relations' list into - a structure that is easier to consume downstream. When a mapping - contains 'relations', those entries are regrouped by their 'target-type' - and stored under keys like '-relations'. The original - 'relations' key is removed to avoid ambiguous access patterns. - - The transformation is applied recursively so that nested objects and - sequences are normalized consistently, while non-container values are - left unchanged. - """ - return data - - @_group_relations.register(list) - @classmethod - def _(cls, data: list[Any]) -> list[Any]: - return [cls._group_relations(i) for i in data] - - @_group_relations.register(dict) - @classmethod - def _(cls, data: JSONDict) -> JSONDict: - for k, v in list(data.items()): - if k == "relations": - get_target_type = operator.methodcaller("get", "target-type") - for target_type, group in groupby( - sorted(v, key=get_target_type), get_target_type - ): - relations = [ - {k: v for k, v in item.items() if k != "target-type"} - for item in group - ] - data[f"{target_type}-relations"] = cls._group_relations( - relations - ) - data.pop("relations") - else: - data[k] = cls._group_relations(v) - return data - - def _preferred_alias( aliases: list[JSONDict], languages: list[str] | None = None ) -> JSONDict | None: @@ -405,25 +319,11 @@ def _merge_pseudo_and_actual_album( return merged -class MusicBrainzPlugin(MetadataSourcePlugin): +class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): @cached_property def genres_field(self) -> str: return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s" - @cached_property - def api(self) -> MusicBrainzAPI: - hostname = self.config["host"].as_str() - if hostname == "musicbrainz.org": - hostname, rate_limit = "https://musicbrainz.org", 1.0 - else: - https = self.config["https"].get(bool) - hostname = f"http{'s' if https else ''}://{hostname}" - rate_limit = ( - self.config["ratelimit"].get(int) - / self.config["ratelimit_interval"].as_number() - ) - return MusicBrainzAPI(hostname, rate_limit) - def __init__(self): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. @@ -431,10 +331,6 @@ class MusicBrainzPlugin(MetadataSourcePlugin): super().__init__() self.config.add( { - "host": "musicbrainz.org", - "https": False, - "ratelimit": 1, - "ratelimit_interval": 1, "genres": False, "genres_tag": "genre", "external_ids": { @@ -589,7 +485,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): for i in range(0, ntracks, BROWSE_CHUNKSIZE): self._log.debug("Retrieving tracks starting at {}", i) recording_list.extend( - self.api.browse_recordings(release=release["id"], offset=i) + self.mb_api.browse_recordings( + release=release["id"], offset=i + ) ) track_map = {r["id"]: r for r in recording_list} for medium in release["media"]: @@ -861,7 +759,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug( "Searching for MusicBrainz {}s with: {!r}", query_type, query ) - return self.api.get_entity( + return self.mb_api.get_entity( query_type, query=query, limit=self.config["search_limit"].get() )[f"{query_type}s"] @@ -901,7 +799,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", album_id) return None - res = self.api.get_release(albumid) + res = self.mb_api.get_release(albumid, includes=RELEASE_INCLUDES) # resolve linked release relations actual_res = None @@ -914,7 +812,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): rel["type"] == "transl-tracklisting" and rel["direction"] == "backward" ): - actual_res = self.api.get_release(rel["release"]["id"]) + actual_res = self.mb_api.get_release( + rel["release"]["id"], includes=RELEASE_INCLUDES + ) # release is potentially a pseudo release release = self.album_info(res) @@ -937,6 +837,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin): return None with suppress(HTTPNotFoundError): - return self.track_info(self.api.get_recording(trackid)) + return self.track_info( + self.mb_api.get_recording(trackid, includes=TRACK_INCLUDES) + ) return None diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index a98a59248..6b382ab16 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -94,7 +94,7 @@ class TestMBPseudoMixin(PluginMixin): @pytest.fixture(autouse=True) def patch_get_release(self, monkeypatch, pseudo_release: JSONDict): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", lambda _, album_id: deepcopy( {pseudo_release["id"]: pseudo_release}[album_id] ), diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 30b9f7d1a..199b62ab6 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -863,7 +863,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -907,7 +907,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -951,7 +951,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -1004,7 +1004,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -1055,7 +1055,7 @@ class TestMusicBrainzPlugin(PluginMixin): def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_json", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"recordings": [self.RECORDING]}, ) @@ -1066,11 +1066,11 @@ class TestMusicBrainzPlugin(PluginMixin): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_json", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", lambda *_, **__: { "title": "hi", "id": self.mbid, @@ -1099,84 +1099,3 @@ class TestMusicBrainzPlugin(PluginMixin): assert len(candidates) == 1 assert candidates[0].tracks[0].track_id == self.RECORDING["id"] assert candidates[0].album == "hi" - - -def test_group_relations(): - raw_release = { - "id": "r1", - "relations": [ - {"target-type": "artist", "type": "vocal", "name": "A"}, - {"target-type": "url", "type": "streaming", "url": "http://s"}, - {"target-type": "url", "type": "purchase", "url": "http://p"}, - { - "target-type": "work", - "type": "performance", - "work": { - "relations": [ - { - "artist": {"name": "幾田りら"}, - "target-type": "artist", - "type": "composer", - }, - { - "target-type": "url", - "type": "lyrics", - "url": { - "resource": "https://utaten.com/lyric/tt24121002/" - }, - }, - { - "artist": {"name": "幾田りら"}, - "target-type": "artist", - "type": "lyricist", - }, - { - "target-type": "url", - "type": "lyrics", - "url": { - "resource": "https://www.uta-net.com/song/366579/" - }, - }, - ], - "title": "百花繚乱", - "type": "Song", - }, - }, - ], - } - - assert musicbrainz.MusicBrainzAPI._group_relations(raw_release) == { - "id": "r1", - "artist-relations": [{"type": "vocal", "name": "A"}], - "url-relations": [ - {"type": "streaming", "url": "http://s"}, - {"type": "purchase", "url": "http://p"}, - ], - "work-relations": [ - { - "type": "performance", - "work": { - "artist-relations": [ - {"type": "composer", "artist": {"name": "幾田りら"}}, - {"type": "lyricist", "artist": {"name": "幾田りら"}}, - ], - "url-relations": [ - { - "type": "lyrics", - "url": { - "resource": "https://utaten.com/lyric/tt24121002/" - }, - }, - { - "type": "lyrics", - "url": { - "resource": "https://www.uta-net.com/song/366579/" - }, - }, - ], - "title": "百花繚乱", - "type": "Song", - }, - }, - ], - } diff --git a/test/plugins/utils/test_musicbrainz.py b/test/plugins/utils/test_musicbrainz.py new file mode 100644 index 000000000..291f50eb5 --- /dev/null +++ b/test/plugins/utils/test_musicbrainz.py @@ -0,0 +1,82 @@ +from beetsplug._utils.musicbrainz import MusicBrainzAPI + + +def test_group_relations(): + raw_release = { + "id": "r1", + "relations": [ + {"target-type": "artist", "type": "vocal", "name": "A"}, + {"target-type": "url", "type": "streaming", "url": "http://s"}, + {"target-type": "url", "type": "purchase", "url": "http://p"}, + { + "target-type": "work", + "type": "performance", + "work": { + "relations": [ + { + "artist": {"name": "幾田りら"}, + "target-type": "artist", + "type": "composer", + }, + { + "target-type": "url", + "type": "lyrics", + "url": { + "resource": "https://utaten.com/lyric/tt24121002/" + }, + }, + { + "artist": {"name": "幾田りら"}, + "target-type": "artist", + "type": "lyricist", + }, + { + "target-type": "url", + "type": "lyrics", + "url": { + "resource": "https://www.uta-net.com/song/366579/" + }, + }, + ], + "title": "百花繚乱", + "type": "Song", + }, + }, + ], + } + + assert MusicBrainzAPI._group_relations(raw_release) == { + "id": "r1", + "artist-relations": [{"type": "vocal", "name": "A"}], + "url-relations": [ + {"type": "streaming", "url": "http://s"}, + {"type": "purchase", "url": "http://p"}, + ], + "work-relations": [ + { + "type": "performance", + "work": { + "artist-relations": [ + {"type": "composer", "artist": {"name": "幾田りら"}}, + {"type": "lyricist", "artist": {"name": "幾田りら"}}, + ], + "url-relations": [ + { + "type": "lyrics", + "url": { + "resource": "https://utaten.com/lyric/tt24121002/" + }, + }, + { + "type": "lyrics", + "url": { + "resource": "https://www.uta-net.com/song/366579/" + }, + }, + ], + "title": "百花繚乱", + "type": "Song", + }, + }, + ], + } From af96c3244e101321cead4b5a61c20a58aecb4690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 05:37:53 +0000 Subject: [PATCH 18/33] Add a minimal test for listenbrainz --- beetsplug/listenbrainz.py | 4 ++- test/plugins/test_listenbrainz.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/plugins/test_listenbrainz.py diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index 2aa4e7ad6..3729001b1 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -5,10 +5,12 @@ import datetime import musicbrainzngs import requests -from beets import config, ui +from beets import __version__, config, ui from beets.plugins import BeetsPlugin from beetsplug.lastimport import process_tracks +musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") + class ListenBrainzPlugin(BeetsPlugin): """A Beets plugin for interacting with ListenBrainz.""" diff --git a/test/plugins/test_listenbrainz.py b/test/plugins/test_listenbrainz.py new file mode 100644 index 000000000..fa6c4fbab --- /dev/null +++ b/test/plugins/test_listenbrainz.py @@ -0,0 +1,55 @@ +import pytest + +from beets.test.helper import ConfigMixin +from beetsplug.listenbrainz import ListenBrainzPlugin + + +class TestListenBrainzPlugin(ConfigMixin): + @pytest.fixture(scope="class") + def plugin(self): + self.config["listenbrainz"]["token"] = "test_token" + self.config["listenbrainz"]["username"] = "test_user" + return ListenBrainzPlugin() + + @pytest.mark.parametrize( + "search_response, expected_id", + [ + ( + {"recording-count": "1", "recording-list": [{"id": "id1"}]}, + "id1", + ), + ({"recording-count": "0"}, None), + ], + ids=["found", "not_found"], + ) + def test_get_mb_recording_id( + self, monkeypatch, plugin, search_response, expected_id + ): + monkeypatch.setattr( + "musicbrainzngs.search_recordings", lambda *_, **__: search_response + ) + track = {"track_metadata": {"track_name": "S", "release_name": "A"}} + + assert plugin.get_mb_recording_id(track) == expected_id + + def test_get_track_info(self, monkeypatch, plugin): + monkeypatch.setattr( + "musicbrainzngs.get_recording_by_id", + lambda *_, **__: { + "recording": { + "title": "T", + "artist-credit": [], + "release-list": [{"title": "Al", "date": "2023-01"}], + } + }, + ) + + assert plugin.get_track_info([{"identifier": "id1"}]) == [ + { + "identifier": "id1", + "title": "T", + "artist": None, + "album": "Al", + "year": "2023", + } + ] From 36964e433ea733f620abaf9071180d56a835d21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 15:32:13 +0000 Subject: [PATCH 19/33] Migrate listenbrainz plugin to use our MusicBrainzAPI implementation --- beetsplug/_utils/musicbrainz.py | 24 ++++++++++++++++++++++- beetsplug/listenbrainz.py | 29 +++++++++++++--------------- beetsplug/musicbrainz.py | 12 ++---------- docs/plugins/listenbrainz.rst | 13 +++++++------ poetry.lock | 3 +-- pyproject.toml | 1 - test/plugins/test_listenbrainz.py | 32 ++++++++++++------------------- 7 files changed, 58 insertions(+), 56 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 3327269b2..63ffd4aa3 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -8,13 +8,15 @@ from typing import TYPE_CHECKING, Any from requests_ratelimiter import LimiterMixin -from beets import config +from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: from .._typing import JSONDict +log = logging.getLogger(__name__) + class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): pass @@ -63,6 +65,26 @@ class MusicBrainzAPI(RequestHandler): ) ) + def search_entity( + self, entity: str, filters: dict[str, str], **kwargs + ) -> list[JSONDict]: + """Search for MusicBrainz entities matching the given filters. + + * Query is constructed by combining the provided filters using AND logic + * Each filter key-value pair is formatted as 'key:"value"' unless + - 'key' is empty, in which case only the value is used, '"value"' + - 'value' is empty, in which case the filter is ignored + * Values are lowercased and stripped of whitespace. + """ + query = " AND ".join( + ":".join(filter(None, (k, f'"{_v}"'))) + for k, v in filters.items() + if (_v := v.lower().strip()) + ) + log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query) + kwargs["query"] = query + return self.get_entity(entity, **kwargs)[f"{entity}s"] + def get_release(self, id_: str, **kwargs) -> JSONDict: return self.get_entity(f"release/{id_}", **kwargs) diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index 3729001b1..d054a00cc 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -2,17 +2,16 @@ import datetime -import musicbrainzngs import requests -from beets import __version__, config, ui +from beets import config, ui from beets.plugins import BeetsPlugin from beetsplug.lastimport import process_tracks -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +from ._utils.musicbrainz import MusicBrainzAPIMixin -class ListenBrainzPlugin(BeetsPlugin): +class ListenBrainzPlugin(MusicBrainzAPIMixin, BeetsPlugin): """A Beets plugin for interacting with ListenBrainz.""" ROOT = "http://api.listenbrainz.org/1/" @@ -131,17 +130,16 @@ class ListenBrainzPlugin(BeetsPlugin): ) return tracks - def get_mb_recording_id(self, track): + def get_mb_recording_id(self, track) -> str | None: """Returns the MusicBrainz recording ID for a track.""" - resp = musicbrainzngs.search_recordings( - query=track["track_metadata"].get("track_name"), - release=track["track_metadata"].get("release_name"), - strict=True, + results = self.mb_api.search_entity( + "recording", + { + "": track["track_metadata"].get("track_name"), + "release": track["track_metadata"].get("release_name"), + }, ) - if resp.get("recording-count") == "1": - return resp.get("recording-list")[0].get("id") - else: - return None + return next((r["id"] for r in results), None) def get_playlists_createdfor(self, username): """Returns a list of playlists created by a user.""" @@ -209,17 +207,16 @@ class ListenBrainzPlugin(BeetsPlugin): track_info = [] for track in tracks: identifier = track.get("identifier") - resp = musicbrainzngs.get_recording_by_id( + recording = self.mb_api.get_recording( identifier, includes=["releases", "artist-credits"] ) - recording = resp.get("recording") title = recording.get("title") artist_credit = recording.get("artist-credit", []) if artist_credit: artist = artist_credit[0].get("artist", {}).get("name") else: artist = None - releases = recording.get("release-list", []) + releases = recording.get("releases", []) if releases: album = releases[0].get("title") date = releases[0].get("date") diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 38097b2ce..990f21351 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -751,17 +751,9 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): using the provided criteria. Handles API errors by converting them into MusicBrainzAPIError exceptions with contextual information. """ - query = " AND ".join( - f'{k}:"{_v}"' - for k, v in filters.items() - if (_v := v.lower().strip()) + return self.mb_api.search_entity( + query_type, filters, limit=self.config["search_limit"].get() ) - self._log.debug( - "Searching for MusicBrainz {}s with: {!r}", query_type, query - ) - return self.mb_api.get_entity( - query_type, query=query, limit=self.config["search_limit"].get() - )[f"{query_type}s"] def candidates( self, diff --git a/docs/plugins/listenbrainz.rst b/docs/plugins/listenbrainz.rst index 17926e878..ceff0e800 100644 --- a/docs/plugins/listenbrainz.rst +++ b/docs/plugins/listenbrainz.rst @@ -6,15 +6,16 @@ ListenBrainz Plugin The ListenBrainz plugin for beets allows you to interact with the ListenBrainz service. -Installation ------------- +Configuration +------------- -To use the ``listenbrainz`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``listenbrainz`` extra +To enable the ListenBrainz plugin, add the following to your beets configuration +file (config.yaml_): -.. code-block:: bash +.. code-block:: yaml - pip install "beets[listenbrainz]" + plugins: + - listenbrainz You can then configure the plugin by providing your Listenbrainz token (see intructions here_) and username: diff --git a/poetry.lock b/poetry.lock index dbd3ecf3d..60cbceebd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4180,7 +4180,6 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] -listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] @@ -4199,4 +4198,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "45c7dc4ec30f4460a09554d0ec0ebcafebff097386e005e29e12830d16d223dd" +content-hash = "d9141a482e4990a4466a121a59deaeaf46e5613ff0af315f277110935e391e63" diff --git a/pyproject.toml b/pyproject.toml index bd46d3026..ed0059610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,7 +163,6 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] -listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] diff --git a/test/plugins/test_listenbrainz.py b/test/plugins/test_listenbrainz.py index fa6c4fbab..b94cff219 100644 --- a/test/plugins/test_listenbrainz.py +++ b/test/plugins/test_listenbrainz.py @@ -6,41 +6,33 @@ from beetsplug.listenbrainz import ListenBrainzPlugin class TestListenBrainzPlugin(ConfigMixin): @pytest.fixture(scope="class") - def plugin(self): + def plugin(self) -> ListenBrainzPlugin: self.config["listenbrainz"]["token"] = "test_token" self.config["listenbrainz"]["username"] = "test_user" return ListenBrainzPlugin() @pytest.mark.parametrize( "search_response, expected_id", - [ - ( - {"recording-count": "1", "recording-list": [{"id": "id1"}]}, - "id1", - ), - ({"recording-count": "0"}, None), - ], + [([{"id": "id1"}], "id1"), ([], None)], ids=["found", "not_found"], ) def test_get_mb_recording_id( - self, monkeypatch, plugin, search_response, expected_id + self, plugin, requests_mock, search_response, expected_id ): - monkeypatch.setattr( - "musicbrainzngs.search_recordings", lambda *_, **__: search_response + requests_mock.get( + "/ws/2/recording", json={"recordings": search_response} ) track = {"track_metadata": {"track_name": "S", "release_name": "A"}} assert plugin.get_mb_recording_id(track) == expected_id - def test_get_track_info(self, monkeypatch, plugin): - monkeypatch.setattr( - "musicbrainzngs.get_recording_by_id", - lambda *_, **__: { - "recording": { - "title": "T", - "artist-credit": [], - "release-list": [{"title": "Al", "date": "2023-01"}], - } + def test_get_track_info(self, plugin, requests_mock): + requests_mock.get( + "/ws/2/recording/id1?inc=releases%2Bartist-credits", + json={ + "title": "T", + "artist-credit": [], + "releases": [{"title": "Al", "date": "2023-01"}], }, ) From 741f5c4be1cac6fcc2252f4653a0a92f2b40302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 15:57:18 +0000 Subject: [PATCH 20/33] parentwork: simplify work retrieval and tests --- beetsplug/parentwork.py | 77 +++++++++--------- test/plugins/test_parentwork.py | 138 +++++++++++--------------------- 2 files changed, 83 insertions(+), 132 deletions(-) diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index eb2fd8f11..6fa4bfbdb 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -16,56 +16,51 @@ and work composition date """ +from __future__ import annotations + +from typing import Any + import musicbrainzngs -from beets import ui +from beets import __version__, ui from beets.plugins import BeetsPlugin - -def direct_parent_id(mb_workid, work_date=None): - """Given a Musicbrainz work id, find the id one of the works the work is - part of and the first composition date it encounters. - """ - work_info = musicbrainzngs.get_work_by_id( - mb_workid, includes=["work-rels", "artist-rels"] - ) - if "artist-relation-list" in work_info["work"] and work_date is None: - for artist in work_info["work"]["artist-relation-list"]: - if artist["type"] == "composer": - if "end" in artist.keys(): - work_date = artist["end"] - - if "work-relation-list" in work_info["work"]: - for direct_parent in work_info["work"]["work-relation-list"]: - if ( - direct_parent["type"] == "parts" - and direct_parent.get("direction") == "backward" - ): - direct_id = direct_parent["work"]["id"] - return direct_id, work_date - return None, work_date +musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") -def work_parent_id(mb_workid): - """Find the parent work id and composition date of a work given its id.""" - work_date = None - while True: - new_mb_workid, work_date = direct_parent_id(mb_workid, work_date) - if not new_mb_workid: - return mb_workid, work_date - mb_workid = new_mb_workid - return mb_workid, work_date - - -def find_parentwork_info(mb_workid): +def find_parentwork_info(mb_workid: str) -> tuple[dict[str, Any], str | None]: """Get the MusicBrainz information dict about a parent work, including the artist relations, and the composition date for a work's parent work. """ - parent_id, work_date = work_parent_id(mb_workid) - work_info = musicbrainzngs.get_work_by_id( - parent_id, includes=["artist-rels"] - ) - return work_info, work_date + work_date = None + + parent_id: str | None = mb_workid + + while parent_id: + current_id = parent_id + work_info = musicbrainzngs.get_work_by_id( + current_id, includes=["work-rels", "artist-rels"] + )["work"] + work_date = work_date or next( + ( + end + for a in work_info.get("artist-relation-list", []) + if a["type"] == "composer" and (end := a.get("end")) + ), + None, + ) + parent_id = next( + ( + w["work"]["id"] + for w in work_info.get("work-relation-list", []) + if w["type"] == "parts" and w["direction"] == "backward" + ), + None, + ) + + return musicbrainzngs.get_work_by_id( + current_id, includes=["artist-rels"] + ), work_date class ParentWorkPlugin(BeetsPlugin): diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 1abe25709..809387bbc 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -14,74 +14,13 @@ """Tests for the 'parentwork' plugin.""" -from unittest.mock import patch +from typing import Any +from unittest.mock import Mock, patch import pytest from beets.library import Item from beets.test.helper import PluginTestCase -from beetsplug import parentwork - -work = { - "work": { - "id": "1", - "title": "work", - "work-relation-list": [ - {"type": "parts", "direction": "backward", "work": {"id": "2"}} - ], - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } -} -dp_work = { - "work": { - "id": "2", - "title": "directparentwork", - "work-relation-list": [ - {"type": "parts", "direction": "backward", "work": {"id": "3"}} - ], - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } -} -p_work = { - "work": { - "id": "3", - "title": "parentwork", - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } -} - - -def mock_workid_response(mbid, includes): - if mbid == "1": - return work - elif mbid == "2": - return dp_work - elif mbid == "3": - return p_work @pytest.mark.integration_test @@ -134,36 +73,57 @@ class ParentWorkIntegrationTest(PluginTestCase): item.load() assert item["mb_parentworkid"] == "XXX" - # test different cases, still with Matthew Passion Ouverture or Mozart - # requiem - def test_direct_parent_work_real(self): - mb_workid = "2e4a3668-458d-3b2a-8be2-0b08e0d8243a" - assert ( - "f04b42df-7251-4d86-a5ee-67cfa49580d1" - == parentwork.direct_parent_id(mb_workid)[0] - ) - assert ( - "45afb3b2-18ac-4187-bc72-beb1b1c194ba" - == parentwork.work_parent_id(mb_workid)[0] - ) +def mock_workid_response(mbid, includes): + works: list[dict[str, Any]] = [ + { + "id": "1", + "title": "work", + "work-relation-list": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "2"}, + } + ], + }, + { + "id": "2", + "title": "directparentwork", + "work-relation-list": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "3"}, + } + ], + }, + { + "id": "3", + "title": "parentwork", + }, + ] + + return { + "work": { + **next(w for w in works if mbid == w["id"]), + "artist-relation-list": [ + { + "type": "composer", + "artist": { + "name": "random composer", + "sort-name": "composer, random", + }, + } + ], + } + } +@patch("musicbrainzngs.get_work_by_id", Mock(side_effect=mock_workid_response)) class ParentWorkTest(PluginTestCase): plugin = "parentwork" - def setUp(self): - """Set up configuration""" - super().setUp() - self.patcher = patch( - "musicbrainzngs.get_work_by_id", side_effect=mock_workid_response - ) - self.patcher.start() - - def tearDown(self): - super().tearDown() - self.patcher.stop() - def test_normal_case(self): item = Item(path="/file", mb_workid="1", parentwork_workid_current="1") item.add(self.lib) @@ -204,7 +164,3 @@ class ParentWorkTest(PluginTestCase): item.load() assert item["mb_parentworkid"] == "XXX" - - def test_direct_parent_work(self): - assert "2" == parentwork.direct_parent_id("1")[0] - assert "3" == parentwork.work_parent_id("1")[0] From a33371b6efb4daddb1db59ccb3fd7479e7916626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 16:45:15 +0000 Subject: [PATCH 21/33] Migrate parentwork to use MusicBrainzAPI --- .github/workflows/ci.yaml | 4 +- beetsplug/_utils/musicbrainz.py | 3 + beetsplug/parentwork.py | 110 +++++++++++++++----------------- docs/plugins/parentwork.rst | 10 --- poetry.lock | 3 +- pyproject.toml | 1 - test/plugins/test_parentwork.py | 97 ++++++++++++++-------------- 7 files changed, 106 insertions(+), 122 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 520a368ef..bfd05c718 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,7 +66,7 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage run: | - poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test - if: ${{ env.IS_MAIN_PYTHON == 'true' }} @@ -74,7 +74,7 @@ jobs: env: LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }} run: | - poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork + poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe docs poe test-with-coverage diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 63ffd4aa3..cd58a8f54 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -91,6 +91,9 @@ class MusicBrainzAPI(RequestHandler): def get_recording(self, id_: str, **kwargs) -> JSONDict: return self.get_entity(f"recording/{id_}", **kwargs) + def get_work(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"work/{id_}", **kwargs) + def browse_recordings(self, **kwargs) -> list[JSONDict]: return self.get_entity("recording", **kwargs)["recordings"] diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index 6fa4bfbdb..15fcdefa8 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -20,50 +20,15 @@ from __future__ import annotations from typing import Any -import musicbrainzngs +import requests -from beets import __version__, ui +from beets import ui from beets.plugins import BeetsPlugin -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +from ._utils.musicbrainz import MusicBrainzAPIMixin -def find_parentwork_info(mb_workid: str) -> tuple[dict[str, Any], str | None]: - """Get the MusicBrainz information dict about a parent work, including - the artist relations, and the composition date for a work's parent work. - """ - work_date = None - - parent_id: str | None = mb_workid - - while parent_id: - current_id = parent_id - work_info = musicbrainzngs.get_work_by_id( - current_id, includes=["work-rels", "artist-rels"] - )["work"] - work_date = work_date or next( - ( - end - for a in work_info.get("artist-relation-list", []) - if a["type"] == "composer" and (end := a.get("end")) - ), - None, - ) - parent_id = next( - ( - w["work"]["id"] - for w in work_info.get("work-relation-list", []) - if w["type"] == "parts" and w["direction"] == "backward" - ), - None, - ) - - return musicbrainzngs.get_work_by_id( - current_id, includes=["artist-rels"] - ), work_date - - -class ParentWorkPlugin(BeetsPlugin): +class ParentWorkPlugin(MusicBrainzAPIMixin, BeetsPlugin): def __init__(self): super().__init__() @@ -125,14 +90,13 @@ class ParentWorkPlugin(BeetsPlugin): parentwork_info = {} composer_exists = False - if "artist-relation-list" in work_info["work"]: - for artist in work_info["work"]["artist-relation-list"]: - if artist["type"] == "composer": - composer_exists = True - parent_composer.append(artist["artist"]["name"]) - parent_composer_sort.append(artist["artist"]["sort-name"]) - if "end" in artist.keys(): - parentwork_info["parentwork_date"] = artist["end"] + for artist in work_info.get("artist-relations", []): + if artist["type"] == "composer": + composer_exists = True + parent_composer.append(artist["artist"]["name"]) + parent_composer_sort.append(artist["artist"]["sort-name"]) + if "end" in artist.keys(): + parentwork_info["parentwork_date"] = artist["end"] parentwork_info["parent_composer"] = ", ".join(parent_composer) parentwork_info["parent_composer_sort"] = ", ".join( @@ -144,16 +108,14 @@ class ParentWorkPlugin(BeetsPlugin): "no composer for {}; add one at " "https://musicbrainz.org/work/{}", item, - work_info["work"]["id"], + work_info["id"], ) - parentwork_info["parentwork"] = work_info["work"]["title"] - parentwork_info["mb_parentworkid"] = work_info["work"]["id"] + parentwork_info["parentwork"] = work_info["title"] + parentwork_info["mb_parentworkid"] = work_info["id"] - if "disambiguation" in work_info["work"]: - parentwork_info["parentwork_disambig"] = work_info["work"][ - "disambiguation" - ] + if "disambiguation" in work_info: + parentwork_info["parentwork_disambig"] = work_info["disambiguation"] else: parentwork_info["parentwork_disambig"] = None @@ -185,9 +147,9 @@ class ParentWorkPlugin(BeetsPlugin): work_changed = item.parentwork_workid_current != item.mb_workid if force or not hasparent or work_changed: try: - work_info, work_date = find_parentwork_info(item.mb_workid) - except musicbrainzngs.musicbrainz.WebServiceError as e: - self._log.debug("error fetching work: {}", e) + work_info, work_date = self.find_parentwork_info(item.mb_workid) + except requests.exceptions.RequestException: + self._log.debug("error fetching work", item, exc_info=True) return parent_info = self.get_info(item, work_info) parent_info["parentwork_workid_current"] = item.mb_workid @@ -228,3 +190,37 @@ class ParentWorkPlugin(BeetsPlugin): "parentwork_date", ], ) + + def find_parentwork_info( + self, mb_workid: str + ) -> tuple[dict[str, Any], str | None]: + """Get the MusicBrainz information dict about a parent work, including + the artist relations, and the composition date for a work's parent work. + """ + work_date = None + + parent_id: str | None = mb_workid + + while parent_id: + current_id = parent_id + work_info = self.mb_api.get_work( + current_id, includes=["work-rels", "artist-rels"] + ) + work_date = work_date or next( + ( + end + for a in work_info.get("artist-relations", []) + if a["type"] == "composer" and (end := a.get("end")) + ), + None, + ) + parent_id = next( + ( + w["work"]["id"] + for w in work_info.get("work-relations", []) + if w["type"] == "parts" and w["direction"] == "backward" + ), + None, + ) + + return work_info, work_date diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst index e015bed68..21b774120 100644 --- a/docs/plugins/parentwork.rst +++ b/docs/plugins/parentwork.rst @@ -38,16 +38,6 @@ This plugin adds seven tags: to keep track of recordings whose works have changed. - **parentwork_date**: The composition date of the parent work. -Installation ------------- - -To use the ``parentwork`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``parentwork`` extra - -.. code-block:: bash - - pip install "beets[parentwork]" - Configuration ------------- diff --git a/poetry.lock b/poetry.lock index 60cbceebd..067fcf93c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4185,7 +4185,6 @@ mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] -parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = ["PyGObject"] @@ -4198,4 +4197,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "d9141a482e4990a4466a121a59deaeaf46e5613ff0af315f277110935e391e63" +content-hash = "dbe3785cbffd71f2ca758872f7654522228d6155c76a8f003bec22f03c8eada3" diff --git a/pyproject.toml b/pyproject.toml index ed0059610..658602484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,6 @@ mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] -parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = [ diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 809387bbc..2218e9fd6 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -14,9 +14,6 @@ """Tests for the 'parentwork' plugin.""" -from typing import Any -from unittest.mock import Mock, patch - import pytest from beets.library import Item @@ -74,56 +71,56 @@ class ParentWorkIntegrationTest(PluginTestCase): assert item["mb_parentworkid"] == "XXX" -def mock_workid_response(mbid, includes): - works: list[dict[str, Any]] = [ - { - "id": "1", - "title": "work", - "work-relation-list": [ - { - "type": "parts", - "direction": "backward", - "work": {"id": "2"}, - } - ], - }, - { - "id": "2", - "title": "directparentwork", - "work-relation-list": [ - { - "type": "parts", - "direction": "backward", - "work": {"id": "3"}, - } - ], - }, - { - "id": "3", - "title": "parentwork", - }, - ] - - return { - "work": { - **next(w for w in works if mbid == w["id"]), - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } - } - - -@patch("musicbrainzngs.get_work_by_id", Mock(side_effect=mock_workid_response)) class ParentWorkTest(PluginTestCase): plugin = "parentwork" + @pytest.fixture(autouse=True) + def patch_works(self, requests_mock): + requests_mock.get( + "/ws/2/work/1?inc=work-rels%2Bartist-rels", + json={ + "id": "1", + "title": "work", + "work-relations": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "2"}, + } + ], + }, + ) + requests_mock.get( + "/ws/2/work/2?inc=work-rels%2Bartist-rels", + json={ + "id": "2", + "title": "directparentwork", + "work-relations": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "3"}, + } + ], + }, + ) + requests_mock.get( + "/ws/2/work/3?inc=work-rels%2Bartist-rels", + json={ + "id": "3", + "title": "parentwork", + "artist-relations": [ + { + "type": "composer", + "artist": { + "name": "random composer", + "sort-name": "composer, random", + }, + } + ], + }, + ) + def test_normal_case(self): item = Item(path="/file", mb_workid="1", parentwork_workid_current="1") item.add(self.lib) From d346daf48eedd7f3b8ba81b1b139fa98b6bccb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 23 Dec 2025 00:28:12 +0000 Subject: [PATCH 22/33] missing: add tests for --album flag --- beetsplug/missing.py | 4 ++- test/plugins/test_missing.py | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/plugins/test_missing.py diff --git a/beetsplug/missing.py b/beetsplug/missing.py index cbdda4599..2f883ee27 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -21,7 +21,7 @@ from collections.abc import Iterator import musicbrainzngs from musicbrainzngs.musicbrainz import MusicBrainzError -from beets import config, metadata_plugins +from beets import __version__, config, metadata_plugins from beets.dbcore import types from beets.library import Album, Item, Library from beets.plugins import BeetsPlugin @@ -29,6 +29,8 @@ from beets.ui import Subcommand, print_ MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" +musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") + def _missing_count(album): """Return number of missing items in `album`.""" diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py new file mode 100644 index 000000000..841d5c358 --- /dev/null +++ b/test/plugins/test_missing.py @@ -0,0 +1,58 @@ +import uuid + +import pytest + +from beets.library import Album +from beets.test.helper import PluginMixin, TestHelper + + +@pytest.fixture +def helper(): + helper = TestHelper() + helper.setup_beets() + + yield helper + + helper.teardown_beets() + + +class TestMissingAlbums(PluginMixin): + plugin = "missing" + album_in_lib = Album( + album="Album", + albumartist="Artist", + mb_albumartistid=str(uuid.uuid4()), + mb_albumid="album", + ) + + @pytest.mark.parametrize( + "release_from_mb,expected_output", + [ + pytest.param( + {"id": "other", "title": "Other Album"}, + "Artist - Other Album\n", + id="missing", + ), + pytest.param( + {"id": album_in_lib.mb_albumid, "title": album_in_lib.album}, + "", + marks=pytest.mark.xfail( + reason="album in lib should not be reported as missing. Needs fixing." + ), + id="not missing", + ), + ], + ) + def test_missing_artist_albums( + self, monkeypatch, helper, release_from_mb, expected_output + ): + helper.lib.add(self.album_in_lib) + monkeypatch.setattr( + "musicbrainzngs.browse_release_groups", + lambda **__: {"release-group-list": [release_from_mb]}, + ) + + with self.configure_plugin({}): + assert ( + helper.run_with_output("missing", "--album") == expected_output + ) From 9349ad7551e6b5a05c45cd5a8c366eb52f994f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 23 Dec 2025 00:41:29 +0000 Subject: [PATCH 23/33] Migrate missing to use MusicBrainzAPI --- beetsplug/_utils/musicbrainz.py | 3 +++ beetsplug/missing.py | 21 ++++++++++----------- docs/plugins/missing.rst | 10 ---------- poetry.lock | 3 +-- pyproject.toml | 1 - test/plugins/test_missing.py | 13 ++++++++----- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index cd58a8f54..aa86cccbb 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -97,6 +97,9 @@ class MusicBrainzAPI(RequestHandler): def browse_recordings(self, **kwargs) -> list[JSONDict]: return self.get_entity("recording", **kwargs)["recordings"] + def browse_release_groups(self, **kwargs) -> list[JSONDict]: + return self.get_entity("release-group", **kwargs)["release-groups"] + @singledispatchmethod @classmethod def _group_relations(cls, data: Any) -> Any: diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 2f883ee27..63a7bae22 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -18,18 +18,17 @@ from collections import defaultdict from collections.abc import Iterator -import musicbrainzngs -from musicbrainzngs.musicbrainz import MusicBrainzError +import requests -from beets import __version__, config, metadata_plugins +from beets import config, metadata_plugins from beets.dbcore import types from beets.library import Album, Item, Library from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ -MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" +from ._utils.musicbrainz import MusicBrainzAPIMixin -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" def _missing_count(album): @@ -87,7 +86,7 @@ def _item(track_info, album_info, album_id): ) -class MissingPlugin(BeetsPlugin): +class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): """List missing tracks""" album_types = { @@ -191,19 +190,19 @@ class MissingPlugin(BeetsPlugin): calculating_total = self.config["total"].get() for (artist, artist_id), album_ids in album_ids_by_artist.items(): try: - resp = musicbrainzngs.browse_release_groups(artist=artist_id) - except MusicBrainzError as err: + resp = self.mb_api.browse_release_groups(artist=artist_id) + except requests.exceptions.RequestException: self._log.info( - "Couldn't fetch info for artist '{}' ({}) - '{}'", + "Couldn't fetch info for artist '{}' ({})", artist, artist_id, - err, + exc_info=True, ) continue missing_titles = [ f"{artist} - {rg['title']}" - for rg in resp["release-group-list"] + for rg in resp if rg["id"] not in album_ids ] diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index f6962f337..d286e43cc 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -5,16 +5,6 @@ This plugin adds a new command, ``missing`` or ``miss``, which finds and lists missing tracks for albums in your collection. Each album requires one network call to album data source. -Installation ------------- - -To use the ``missing`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``missing`` extra - -.. code-block:: bash - - pip install "beets[missing]" - Usage ----- diff --git a/poetry.lock b/poetry.lock index 067fcf93c..e8cc4e905 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4183,7 +4183,6 @@ lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] -missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] reflink = ["reflink"] @@ -4197,4 +4196,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "dbe3785cbffd71f2ca758872f7654522228d6155c76a8f003bec22f03c8eada3" +content-hash = "a18c3047f4f395841e785ed146af3505974839ab23eccdde34a7738e216f0277" diff --git a/pyproject.toml b/pyproject.toml index 658602484..62224c8d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,7 +166,6 @@ lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] -missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] reflink = ["reflink"] diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py index 841d5c358..d12f2b4cf 100644 --- a/test/plugins/test_missing.py +++ b/test/plugins/test_missing.py @@ -37,19 +37,22 @@ class TestMissingAlbums(PluginMixin): {"id": album_in_lib.mb_albumid, "title": album_in_lib.album}, "", marks=pytest.mark.xfail( - reason="album in lib should not be reported as missing. Needs fixing." + reason=( + "Album in lib must not be reported as missing." + " Needs fixing." + ) ), id="not missing", ), ], ) def test_missing_artist_albums( - self, monkeypatch, helper, release_from_mb, expected_output + self, requests_mock, helper, release_from_mb, expected_output ): helper.lib.add(self.album_in_lib) - monkeypatch.setattr( - "musicbrainzngs.browse_release_groups", - lambda **__: {"release-group-list": [release_from_mb]}, + requests_mock.get( + f"/ws/2/release-group?artist={self.album_in_lib.mb_albumartistid}", + json={"release-groups": [release_from_mb]}, ) with self.configure_plugin({}): From 143cd70e2feba34c5e9fbf6a6984a88c4aafddec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 23 Dec 2025 03:28:12 +0000 Subject: [PATCH 24/33] mbcollection: Add tests --- beetsplug/mbcollection.py | 4 +- test/plugins/test_mbcollection.py | 149 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 test/plugins/test_mbcollection.py diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 2f9ef709e..376222382 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -101,9 +101,9 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): offset = 0 albums_in_collection, release_count = _fetch(offset) - for i in range(0, release_count, FETCH_CHUNK_SIZE): - albums_in_collection += _fetch(offset)[0] + for i in range(FETCH_CHUNK_SIZE, release_count, FETCH_CHUNK_SIZE): offset += FETCH_CHUNK_SIZE + albums_in_collection += _fetch(offset)[0] return albums_in_collection diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py new file mode 100644 index 000000000..edf37538d --- /dev/null +++ b/test/plugins/test_mbcollection.py @@ -0,0 +1,149 @@ +import uuid +from contextlib import nullcontext as does_not_raise + +import pytest + +from beets.library import Album +from beets.test.helper import ConfigMixin +from beets.ui import UserError +from beetsplug import mbcollection + + +class TestMbCollectionAPI: + """Tests for the low-level MusicBrainz API wrapper functions.""" + + def test_submit_albums_batches(self, monkeypatch): + chunks_received = [] + + def mock_add(collection_id, chunk): + chunks_received.append(chunk) + + monkeypatch.setattr( + "musicbrainzngs.add_releases_to_collection", mock_add + ) + + # Chunk size is 200. Create 250 IDs. + ids = [f"id{i}" for i in range(250)] + mbcollection.submit_albums("coll_id", ids) + + # Verify behavioral outcome: 2 batches were sent + assert len(chunks_received) == 2 + assert len(chunks_received[0]) == 200 + assert len(chunks_received[1]) == 50 + + +class TestMbCollectionPlugin(ConfigMixin): + """Tests for the MusicBrainzCollectionPlugin class methods.""" + + COLLECTION_ID = str(uuid.uuid4()) + + @pytest.fixture + def plugin(self, monkeypatch): + # Prevent actual auth call during init + monkeypatch.setattr("musicbrainzngs.auth", lambda *a, **k: None) + + self.config["musicbrainz"]["user"] = "testuser" + self.config["musicbrainz"]["pass"] = "testpass" + + plugin = mbcollection.MusicBrainzCollectionPlugin() + plugin.config["collection"] = self.COLLECTION_ID + return plugin + + @pytest.mark.parametrize( + "user_collections,expectation", + [ + ( + [], + pytest.raises( + UserError, match=r"no collections exist for user" + ), + ), + ( + [{"id": "c1", "entity-type": "event"}], + pytest.raises(UserError, match=r"No release collection found."), + ), + ( + [{"id": "c1", "entity-type": "release"}], + pytest.raises(UserError, match=r"invalid collection ID"), + ), + ( + [{"id": COLLECTION_ID, "entity-type": "release"}], + does_not_raise(), + ), + ], + ) + def test_get_collection_validation( + self, plugin, monkeypatch, user_collections, expectation + ): + mock_resp = {"collection-list": user_collections} + monkeypatch.setattr("musicbrainzngs.get_collections", lambda: mock_resp) + + with expectation: + plugin._get_collection() + + def test_get_albums_in_collection_pagination(self, plugin, monkeypatch): + fetched_offsets = [] + + def mock_get_releases(collection_id, limit, offset): + fetched_offsets.append(offset) + count = 150 + # Return IDs based on offset to verify order/content + start = offset + end = min(offset + limit, count) + return { + "collection": { + "release-count": count, + "release-list": [ + {"id": f"r{i}"} for i in range(start, end) + ], + } + } + + monkeypatch.setattr( + "musicbrainzngs.get_releases_in_collection", mock_get_releases + ) + + albums = plugin._get_albums_in_collection("cid") + assert len(albums) == 150 + assert fetched_offsets == [0, 100] + assert albums[0] == "r0" + assert albums[149] == "r149" + + def test_update_album_list_filtering(self, plugin, monkeypatch): + ids_submitted = [] + + def mock_submit(_, album_ids): + ids_submitted.extend(album_ids) + + monkeypatch.setattr("beetsplug.mbcollection.submit_albums", mock_submit) + monkeypatch.setattr(plugin, "_get_collection", lambda: "cid") + + albums = [ + Album(mb_albumid="invalid-id"), + Album(mb_albumid="00000000-0000-0000-0000-000000000001"), + ] + + plugin.update_album_list(None, albums) + # Behavior: only valid UUID was submitted + assert ids_submitted == ["00000000-0000-0000-0000-000000000001"] + + def test_remove_missing(self, plugin, monkeypatch): + removed_ids = [] + + def mock_remove(_, chunk): + removed_ids.extend(chunk) + + monkeypatch.setattr( + "musicbrainzngs.remove_releases_from_collection", mock_remove + ) + monkeypatch.setattr( + plugin, + "_get_albums_in_collection", + lambda _: ["r1", "r2", "r3"], + ) + + lib_albums = [Album(mb_albumid="r1"), Album(mb_albumid="r2")] + + plugin.remove_missing("cid", lib_albums) + # Behavior: only 'r3' (missing from library) was removed from collection + assert removed_ids == ["r3"] From 92352574aaa4fdc85996b8f796b760833cdc6279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 03:12:09 +0000 Subject: [PATCH 25/33] Migrate mbcollection to use MusicBrainzAPI --- beetsplug/_utils/musicbrainz.py | 17 ++- beetsplug/_utils/requests.py | 9 ++ beetsplug/mbcollection.py | 180 +++++++++++++++++++----------- docs/plugins/mbcollection.rst | 15 +-- poetry.lock | 14 +-- pyproject.toml | 2 - test/plugins/conftest.py | 22 ++++ test/plugins/test_mbcollection.py | 104 ++++++++--------- test/plugins/utils/__init__.py | 0 9 files changed, 206 insertions(+), 157 deletions(-) create mode 100644 test/plugins/conftest.py create mode 100644 test/plugins/utils/__init__.py diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index aa86cccbb..17a83dd9b 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -13,6 +13,8 @@ from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: + from requests import Response + from .._typing import JSONDict log = logging.getLogger(__name__) @@ -49,9 +51,19 @@ class MusicBrainzAPI(RequestHandler): / mb_config["ratelimit_interval"].as_number() ) + @cached_property + def api_root(self) -> str: + return f"{self.api_host}/ws/2" + def create_session(self) -> LimiterTimeoutSession: return LimiterTimeoutSession(per_second=self.rate_limit) + def request(self, *args, **kwargs) -> Response: + """Ensure all requests specify JSON response format by default.""" + kwargs.setdefault("params", {}) + kwargs["params"]["fmt"] = "json" + return super().request(*args, **kwargs) + def get_entity( self, entity: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: @@ -59,10 +71,7 @@ class MusicBrainzAPI(RequestHandler): kwargs["inc"] = "+".join(includes) return self._group_relations( - self.get_json( - f"{self.api_host}/ws/2/{entity}", - params={**kwargs, "fmt": "json"}, - ) + self.get_json(f"{self.api_root}/{entity}", params=kwargs) ) def search_entity( diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 1cb4f6c2b..b8ac541e9 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -155,6 +155,7 @@ class RequestHandler: except requests.exceptions.HTTPError as e: if beets_error := self.status_to_error(e.response.status_code): raise beets_error(response=e.response) from e + raise def request(self, *args, **kwargs) -> requests.Response: @@ -170,6 +171,14 @@ class RequestHandler: """Perform HTTP GET request with automatic error handling.""" return self.request("get", *args, **kwargs) + def put(self, *args, **kwargs) -> requests.Response: + """Perform HTTP PUT request with automatic error handling.""" + return self.request("put", *args, **kwargs) + + def delete(self, *args, **kwargs) -> requests.Response: + """Perform HTTP DELETE request with automatic error handling.""" + return self.request("delete", *args, **kwargs) + def get_json(self, *args, **kwargs): """Fetch and parse JSON data from an HTTP endpoint.""" return self.get(*args, **kwargs).json() diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 376222382..83e78ca69 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -13,48 +13,112 @@ # included in all copies or substantial portions of the Software. +from __future__ import annotations + import re +from dataclasses import dataclass, field +from functools import cached_property +from typing import TYPE_CHECKING -import musicbrainzngs +from requests.auth import HTTPDigestAuth -from beets import config, ui +from beets import __version__, config, ui from beets.plugins import BeetsPlugin from beets.ui import Subcommand +from ._utils.musicbrainz import MusicBrainzAPI + +if TYPE_CHECKING: + from collections.abc import Iterator + + from requests import Response + + from ._typing import JSONDict + SUBMISSION_CHUNK_SIZE = 200 FETCH_CHUNK_SIZE = 100 UUID_REGEX = r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$" -def mb_call(func, *args, **kwargs): - """Call a MusicBrainz API function and catch exceptions.""" - try: - return func(*args, **kwargs) - except musicbrainzngs.AuthenticationError: - raise ui.UserError("authentication with MusicBrainz failed") - except (musicbrainzngs.ResponseError, musicbrainzngs.NetworkError) as exc: - raise ui.UserError(f"MusicBrainz API error: {exc}") - except musicbrainzngs.UsageError: - raise ui.UserError("MusicBrainz credentials missing") +@dataclass +class MusicBrainzUserAPI(MusicBrainzAPI): + auth: HTTPDigestAuth = field(init=False) + + @cached_property + def user(self) -> str: + return config["musicbrainz"]["user"].as_str() + + def __post_init__(self) -> None: + super().__post_init__() + config["musicbrainz"]["pass"].redact = True + self.auth = HTTPDigestAuth( + self.user, config["musicbrainz"]["pass"].as_str() + ) + + def request(self, *args, **kwargs) -> Response: + kwargs.setdefault("params", {}) + kwargs["params"]["client"] = f"beets-{__version__}" + kwargs["auth"] = self.auth + return super().request(*args, **kwargs) + + def get_collections(self) -> list[JSONDict]: + return self.get_entity( + "collection", editor=self.user, includes=["user-collections"] + ).get("collections", []) -def submit_albums(collection_id, release_ids): +@dataclass +class MBCollection: + data: JSONDict + mb_api: MusicBrainzUserAPI + + @property + def id(self) -> str: + return self.data["id"] + + @property + def release_count(self) -> int: + return self.data["release-count"] + + @property + def releases_url(self) -> str: + return f"{self.mb_api.api_root}/collection/{self.id}/releases" + + @property + def releases(self) -> list[JSONDict]: + offsets = list(range(0, self.release_count, FETCH_CHUNK_SIZE)) + return [r for offset in offsets for r in self.get_releases(offset)] + + def get_releases(self, offset: int) -> list[JSONDict]: + return self.mb_api.get_json( + self.releases_url, + params={"limit": FETCH_CHUNK_SIZE, "offset": offset}, + )["releases"] + + @staticmethod + def get_id_chunks(id_list: list[str]) -> Iterator[list[str]]: + for i in range(0, len(id_list), SUBMISSION_CHUNK_SIZE): + yield id_list[i : i + SUBMISSION_CHUNK_SIZE] + + def add_releases(self, releases: list[str]) -> None: + for chunk in self.get_id_chunks(releases): + self.mb_api.put(f"{self.releases_url}/{'%3B'.join(chunk)}") + + def remove_releases(self, releases: list[str]) -> None: + for chunk in self.get_id_chunks(releases): + self.mb_api.delete(f"{self.releases_url}/{'%3B'.join(chunk)}") + + +def submit_albums(collection: MBCollection, release_ids): """Add all of the release IDs to the indicated collection. Multiple requests are made if there are many release IDs to submit. """ - for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): - chunk = release_ids[i : i + SUBMISSION_CHUNK_SIZE] - mb_call(musicbrainzngs.add_releases_to_collection, collection_id, chunk) + collection.add_releases(release_ids) class MusicBrainzCollectionPlugin(BeetsPlugin): def __init__(self): super().__init__() - config["musicbrainz"]["pass"].redact = True - musicbrainzngs.auth( - config["musicbrainz"]["user"].as_str(), - config["musicbrainz"]["pass"].as_str(), - ) self.config.add( { "auto": False, @@ -65,47 +129,34 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): if self.config["auto"]: self.import_stages = [self.imported] - def _get_collection(self): - collections = mb_call(musicbrainzngs.get_collections) - if not collections["collection-list"]: + @cached_property + def mb_api(self) -> MusicBrainzUserAPI: + return MusicBrainzUserAPI() + + def _get_collection(self) -> MBCollection: + if not (collections := self.mb_api.get_collections()): raise ui.UserError("no collections exist for user") # Get all release collection IDs, avoiding event collections - collection_ids = [ - x["id"] - for x in collections["collection-list"] - if x["entity-type"] == "release" - ] - if not collection_ids: + if not ( + collection_by_id := { + c["id"]: c for c in collections if c["entity-type"] == "release" + } + ): raise ui.UserError("No release collection found.") # Check that the collection exists so we can present a nice error - collection = self.config["collection"].as_str() - if collection: - if collection not in collection_ids: - raise ui.UserError(f"invalid collection ID: {collection}") - return collection + if collection_id := self.config["collection"].as_str(): + if not (collection := collection_by_id.get(collection_id)): + raise ui.UserError(f"invalid collection ID: {collection_id}") + else: + # No specified collection. Just return the first collection ID + collection = next(iter(collection_by_id.values())) - # No specified collection. Just return the first collection ID - return collection_ids[0] + return MBCollection(collection, self.mb_api) - def _get_albums_in_collection(self, id): - def _fetch(offset): - res = mb_call( - musicbrainzngs.get_releases_in_collection, - id, - limit=FETCH_CHUNK_SIZE, - offset=offset, - )["collection"] - return [x["id"] for x in res["release-list"]], res["release-count"] - - offset = 0 - albums_in_collection, release_count = _fetch(offset) - for i in range(FETCH_CHUNK_SIZE, release_count, FETCH_CHUNK_SIZE): - offset += FETCH_CHUNK_SIZE - albums_in_collection += _fetch(offset)[0] - - return albums_in_collection + def _get_albums_in_collection(self, collection: MBCollection) -> set[str]: + return {r["id"] for r in collection.releases} def commands(self): mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") @@ -120,17 +171,10 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): mbupdate.func = self.update_collection return [mbupdate] - def remove_missing(self, collection_id, lib_albums): + def remove_missing(self, collection: MBCollection, lib_albums): lib_ids = {x.mb_albumid for x in lib_albums} - albums_in_collection = self._get_albums_in_collection(collection_id) - remove_me = list(set(albums_in_collection) - lib_ids) - for i in range(0, len(remove_me), FETCH_CHUNK_SIZE): - chunk = remove_me[i : i + FETCH_CHUNK_SIZE] - mb_call( - musicbrainzngs.remove_releases_from_collection, - collection_id, - chunk, - ) + albums_in_collection = self._get_albums_in_collection(collection) + collection.remove_releases(list(albums_in_collection - lib_ids)) def update_collection(self, lib, opts, args): self.config.set_args(opts) @@ -144,7 +188,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): def update_album_list(self, lib, album_list, remove_missing=False): """Update the MusicBrainz collection from a list of Beets albums""" - collection_id = self._get_collection() + collection = self._get_collection() # Get a list of all the album IDs. album_ids = [] @@ -157,8 +201,8 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): self._log.info("skipping invalid MBID: {}", aid) # Submit to MusicBrainz. - self._log.info("Updating MusicBrainz collection {}...", collection_id) - submit_albums(collection_id, album_ids) + self._log.info("Updating MusicBrainz collection {}...", collection.id) + submit_albums(collection, album_ids) if remove_missing: - self.remove_missing(collection_id, lib.albums()) + self.remove_missing(collection, lib.albums()) self._log.info("...MusicBrainz collection updated.") diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index ffa86f330..87efcd6d5 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -6,18 +6,9 @@ maintain your `music collection`_ list there. .. _music collection: https://musicbrainz.org/doc/Collections -Installation ------------- - -To use the ``mbcollection`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``mbcollection`` extra - -.. code-block:: bash - - pip install "beets[mbcollection]" - -Then, add your MusicBrainz username and password to your :doc:`configuration -file ` under a ``musicbrainz`` section: +To begin, just enable the ``mbcollection`` plugin in your configuration (see +:ref:`using-plugins`). Then, add your MusicBrainz username and password to your +:doc:`configuration file ` under a ``musicbrainz`` section: :: diff --git a/poetry.lock b/poetry.lock index e8cc4e905..47c07e14f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1818,17 +1818,6 @@ check = ["check-manifest", "flake8", "flake8-black", "isort (>=5.0.3)", "pygment test = ["coverage[toml] (>=5.2)", "coveralls (>=2.1.1)", "hypothesis", "pyannotate", "pytest", "pytest-cov"] type = ["mypy", "mypy-extensions"] -[[package]] -name = "musicbrainzngs" -version = "0.7.1" -description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, - {file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"}, -] - [[package]] name = "mutagen" version = "1.47.0" @@ -4181,7 +4170,6 @@ kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] -mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] @@ -4196,4 +4184,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "a18c3047f4f395841e785ed146af3505974839ab23eccdde34a7738e216f0277" +content-hash = "cd53b70a9cd746a88e80e04e67e0b010a0e5b87f745be94e901a9fd08619771a" diff --git a/pyproject.toml b/pyproject.toml index 62224c8d8..8b608a45e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,6 @@ scipy = [ # for librosa { python = "<3.13", version = ">=1.13.1", optional = true }, { python = ">=3.13", version = ">=1.16.1", optional = true }, ] -musicbrainzngs = { version = ">=0.4", optional = true } numba = [ # for librosa { python = "<3.13", version = ">=0.60", optional = true }, { python = ">=3.13", version = ">=0.62.1", optional = true }, @@ -164,7 +163,6 @@ kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] -mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] diff --git a/test/plugins/conftest.py b/test/plugins/conftest.py new file mode 100644 index 000000000..7e443004c --- /dev/null +++ b/test/plugins/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import requests + +if TYPE_CHECKING: + from requests_mock import Mocker + + +@pytest.fixture +def requests_mock(requests_mock, monkeypatch) -> Mocker: + """Use plain session wherever MB requests are mocked. + + This avoids rate limiting requests to speed up tests. + """ + monkeypatch.setattr( + "beetsplug._utils.musicbrainz.MusicBrainzAPI.create_session", + lambda _: requests.Session(), + ) + return requests_mock diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py index edf37538d..93dbcab64 100644 --- a/test/plugins/test_mbcollection.py +++ b/test/plugins/test_mbcollection.py @@ -1,3 +1,4 @@ +import re import uuid from contextlib import nullcontext as does_not_raise @@ -9,27 +10,27 @@ from beets.ui import UserError from beetsplug import mbcollection +@pytest.fixture +def collection(): + return mbcollection.MBCollection( + {"id": str(uuid.uuid4()), "release-count": 150} + ) + + class TestMbCollectionAPI: """Tests for the low-level MusicBrainz API wrapper functions.""" - def test_submit_albums_batches(self, monkeypatch): - chunks_received = [] - - def mock_add(collection_id, chunk): - chunks_received.append(chunk) - - monkeypatch.setattr( - "musicbrainzngs.add_releases_to_collection", mock_add - ) - + def test_submit_albums_batches(self, collection, requests_mock): # Chunk size is 200. Create 250 IDs. ids = [f"id{i}" for i in range(250)] - mbcollection.submit_albums("coll_id", ids) + requests_mock.put( + f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[:200])}" + ) + requests_mock.put( + f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[200:])}" + ) - # Verify behavioral outcome: 2 batches were sent - assert len(chunks_received) == 2 - assert len(chunks_received[0]) == 200 - assert len(chunks_received[1]) == 50 + mbcollection.submit_albums(collection, ids) class TestMbCollectionPlugin(ConfigMixin): @@ -38,10 +39,7 @@ class TestMbCollectionPlugin(ConfigMixin): COLLECTION_ID = str(uuid.uuid4()) @pytest.fixture - def plugin(self, monkeypatch): - # Prevent actual auth call during init - monkeypatch.setattr("musicbrainzngs.auth", lambda *a, **k: None) - + def plugin(self): self.config["musicbrainz"]["user"] = "testuser" self.config["musicbrainz"]["pass"] = "testpass" @@ -73,50 +71,42 @@ class TestMbCollectionPlugin(ConfigMixin): ], ) def test_get_collection_validation( - self, plugin, monkeypatch, user_collections, expectation + self, plugin, requests_mock, user_collections, expectation ): - mock_resp = {"collection-list": user_collections} - monkeypatch.setattr("musicbrainzngs.get_collections", lambda: mock_resp) + requests_mock.get( + "/ws/2/collection", json={"collections": user_collections} + ) with expectation: plugin._get_collection() - def test_get_albums_in_collection_pagination(self, plugin, monkeypatch): - fetched_offsets = [] - - def mock_get_releases(collection_id, limit, offset): - fetched_offsets.append(offset) - count = 150 - # Return IDs based on offset to verify order/content - start = offset - end = min(offset + limit, count) - return { - "collection": { - "release-count": count, - "release-list": [ - {"id": f"r{i}"} for i in range(start, end) - ], - } - } - - monkeypatch.setattr( - "musicbrainzngs.get_releases_in_collection", mock_get_releases + def test_get_albums_in_collection_pagination( + self, plugin, requests_mock, collection + ): + releases = [{"id": str(i)} for i in range(collection.release_count)] + requests_mock.get( + re.compile( + rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=0.*" + ), + json={"releases": releases[:100]}, + ) + requests_mock.get( + re.compile( + rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=100.*" + ), + json={"releases": releases[100:]}, ) - albums = plugin._get_albums_in_collection("cid") - assert len(albums) == 150 - assert fetched_offsets == [0, 100] - assert albums[0] == "r0" - assert albums[149] == "r149" + plugin._get_albums_in_collection(collection) - def test_update_album_list_filtering(self, plugin, monkeypatch): + def test_update_album_list_filtering(self, plugin, collection, monkeypatch): ids_submitted = [] def mock_submit(_, album_ids): ids_submitted.extend(album_ids) monkeypatch.setattr("beetsplug.mbcollection.submit_albums", mock_submit) - monkeypatch.setattr(plugin, "_get_collection", lambda: "cid") + monkeypatch.setattr(plugin, "_get_collection", lambda: collection) albums = [ Album(mb_albumid="invalid-id"), @@ -127,23 +117,21 @@ class TestMbCollectionPlugin(ConfigMixin): # Behavior: only valid UUID was submitted assert ids_submitted == ["00000000-0000-0000-0000-000000000001"] - def test_remove_missing(self, plugin, monkeypatch): + def test_remove_missing( + self, plugin, monkeypatch, requests_mock, collection + ): removed_ids = [] def mock_remove(_, chunk): removed_ids.extend(chunk) - monkeypatch.setattr( - "musicbrainzngs.remove_releases_from_collection", mock_remove + requests_mock.delete( + re.compile(rf".*/ws/2/collection/{collection.id}/releases/r3") ) monkeypatch.setattr( - plugin, - "_get_albums_in_collection", - lambda _: ["r1", "r2", "r3"], + plugin, "_get_albums_in_collection", lambda _: {"r1", "r2", "r3"} ) lib_albums = [Album(mb_albumid="r1"), Album(mb_albumid="r2")] - plugin.remove_missing("cid", lib_albums) - # Behavior: only 'r3' (missing from library) was removed from collection - assert removed_ids == ["r3"] + plugin.remove_missing(collection, lib_albums) diff --git a/test/plugins/utils/__init__.py b/test/plugins/utils/__init__.py new file mode 100644 index 000000000..e69de29bb From b49d71cb6987580167fc9f80576d6d90cf6ebe6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 03:13:37 +0000 Subject: [PATCH 26/33] mbcollection: slight refactor --- beetsplug/mbcollection.py | 69 ++++++------- test/plugins/test_mbcollection.py | 165 +++++++++++++++--------------- 2 files changed, 118 insertions(+), 116 deletions(-) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 83e78ca69..95ceb3fcf 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -18,7 +18,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from requests.auth import HTTPDigestAuth @@ -29,15 +29,16 @@ from beets.ui import Subcommand from ._utils.musicbrainz import MusicBrainzAPI if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator from requests import Response + from beets.importer import ImportSession, ImportTask + from beets.library import Album, Library + from ._typing import JSONDict -SUBMISSION_CHUNK_SIZE = 200 -FETCH_CHUNK_SIZE = 100 -UUID_REGEX = r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$" +UUID_PAT = re.compile(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") @dataclass @@ -69,6 +70,9 @@ class MusicBrainzUserAPI(MusicBrainzAPI): @dataclass class MBCollection: + SUBMISSION_CHUNK_SIZE: ClassVar[int] = 200 + FETCH_CHUNK_SIZE: ClassVar[int] = 100 + data: JSONDict mb_api: MusicBrainzUserAPI @@ -86,19 +90,19 @@ class MBCollection: @property def releases(self) -> list[JSONDict]: - offsets = list(range(0, self.release_count, FETCH_CHUNK_SIZE)) + offsets = list(range(0, self.release_count, self.FETCH_CHUNK_SIZE)) return [r for offset in offsets for r in self.get_releases(offset)] def get_releases(self, offset: int) -> list[JSONDict]: return self.mb_api.get_json( self.releases_url, - params={"limit": FETCH_CHUNK_SIZE, "offset": offset}, + params={"limit": self.FETCH_CHUNK_SIZE, "offset": offset}, )["releases"] - @staticmethod - def get_id_chunks(id_list: list[str]) -> Iterator[list[str]]: - for i in range(0, len(id_list), SUBMISSION_CHUNK_SIZE): - yield id_list[i : i + SUBMISSION_CHUNK_SIZE] + @classmethod + def get_id_chunks(cls, id_list: list[str]) -> Iterator[list[str]]: + for i in range(0, len(id_list), cls.SUBMISSION_CHUNK_SIZE): + yield id_list[i : i + cls.SUBMISSION_CHUNK_SIZE] def add_releases(self, releases: list[str]) -> None: for chunk in self.get_id_chunks(releases): @@ -117,7 +121,7 @@ def submit_albums(collection: MBCollection, release_ids): class MusicBrainzCollectionPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( { @@ -133,7 +137,8 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): def mb_api(self) -> MusicBrainzUserAPI: return MusicBrainzUserAPI() - def _get_collection(self) -> MBCollection: + @cached_property + def collection(self) -> MBCollection: if not (collections := self.mb_api.get_collections()): raise ui.UserError("no collections exist for user") @@ -155,9 +160,6 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): return MBCollection(collection, self.mb_api) - def _get_albums_in_collection(self, collection: MBCollection) -> set[str]: - return {r["id"] for r in collection.releases} - def commands(self): mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") mbupdate.parser.add_option( @@ -171,38 +173,33 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): mbupdate.func = self.update_collection return [mbupdate] - def remove_missing(self, collection: MBCollection, lib_albums): - lib_ids = {x.mb_albumid for x in lib_albums} - albums_in_collection = self._get_albums_in_collection(collection) - collection.remove_releases(list(albums_in_collection - lib_ids)) - - def update_collection(self, lib, opts, args): + def update_collection(self, lib: Library, opts, args) -> None: self.config.set_args(opts) remove_missing = self.config["remove"].get(bool) self.update_album_list(lib, lib.albums(), remove_missing) - def imported(self, session, task): + def imported(self, session: ImportSession, task: ImportTask) -> None: """Add each imported album to the collection.""" if task.is_album: - self.update_album_list(session.lib, [task.album]) + self.update_album_list( + session.lib, [task.album], remove_missing=False + ) - def update_album_list(self, lib, album_list, remove_missing=False): + def update_album_list( + self, lib: Library, albums: Iterable[Album], remove_missing: bool + ) -> None: """Update the MusicBrainz collection from a list of Beets albums""" - collection = self._get_collection() + collection = self.collection # Get a list of all the album IDs. - album_ids = [] - for album in album_list: - aid = album.mb_albumid - if aid: - if re.match(UUID_REGEX, aid): - album_ids.append(aid) - else: - self._log.info("skipping invalid MBID: {}", aid) + album_ids = [id_ for a in albums if UUID_PAT.match(id_ := a.mb_albumid)] # Submit to MusicBrainz. self._log.info("Updating MusicBrainz collection {}...", collection.id) - submit_albums(collection, album_ids) + collection.add_releases(album_ids) if remove_missing: - self.remove_missing(collection, lib.albums()) + lib_ids = {x.mb_albumid for x in lib.albums()} + albums_in_collection = {r["id"] for r in collection.releases} + collection.remove_releases(list(albums_in_collection - lib_ids)) + self._log.info("...MusicBrainz collection updated.") diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py index 93dbcab64..adfadc103 100644 --- a/test/plugins/test_mbcollection.py +++ b/test/plugins/test_mbcollection.py @@ -5,47 +5,31 @@ from contextlib import nullcontext as does_not_raise import pytest from beets.library import Album -from beets.test.helper import ConfigMixin +from beets.test.helper import PluginMixin, TestHelper from beets.ui import UserError from beetsplug import mbcollection -@pytest.fixture -def collection(): - return mbcollection.MBCollection( - {"id": str(uuid.uuid4()), "release-count": 150} - ) - - -class TestMbCollectionAPI: - """Tests for the low-level MusicBrainz API wrapper functions.""" - - def test_submit_albums_batches(self, collection, requests_mock): - # Chunk size is 200. Create 250 IDs. - ids = [f"id{i}" for i in range(250)] - requests_mock.put( - f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[:200])}" - ) - requests_mock.put( - f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[200:])}" - ) - - mbcollection.submit_albums(collection, ids) - - -class TestMbCollectionPlugin(ConfigMixin): +class TestMbCollectionPlugin(PluginMixin, TestHelper): """Tests for the MusicBrainzCollectionPlugin class methods.""" + plugin = "mbcollection" + COLLECTION_ID = str(uuid.uuid4()) - @pytest.fixture - def plugin(self): + @pytest.fixture(autouse=True) + def setup_config(self): self.config["musicbrainz"]["user"] = "testuser" self.config["musicbrainz"]["pass"] = "testpass" + self.config["mbcollection"]["collection"] = self.COLLECTION_ID - plugin = mbcollection.MusicBrainzCollectionPlugin() - plugin.config["collection"] = self.COLLECTION_ID - return plugin + @pytest.fixture(autouse=True) + def helper(self): + self.setup_beets() + + yield self + + self.teardown_beets() @pytest.mark.parametrize( "user_collections,expectation", @@ -69,69 +53,90 @@ class TestMbCollectionPlugin(ConfigMixin): does_not_raise(), ), ], + ids=["no collections", "no release collections", "invalid ID", "valid"], ) def test_get_collection_validation( - self, plugin, requests_mock, user_collections, expectation + self, requests_mock, user_collections, expectation ): requests_mock.get( "/ws/2/collection", json={"collections": user_collections} ) with expectation: - plugin._get_collection() + mbcollection.MusicBrainzCollectionPlugin().collection - def test_get_albums_in_collection_pagination( - self, plugin, requests_mock, collection - ): - releases = [{"id": str(i)} for i in range(collection.release_count)] + def test_mbupdate(self, helper, requests_mock, monkeypatch): + """Verify mbupdate sync of a MusicBrainz collection with the library. + + This test ensures that the command: + - fetches collection releases using paginated requests, + - submits releases that exist locally but are missing from the remote + collection + - and removes releases from the remote collection that are not in the + local library. Small chunk sizes are forced to exercise pagination and + batching logic. + """ + for mb_albumid in [ + # already present in remote collection + "in_collection1", + "in_collection2", + # two new albums not in remote collection + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + ]: + helper.lib.add(Album(mb_albumid=mb_albumid)) + + # The relevant collection requests_mock.get( - re.compile( - rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=0.*" - ), - json={"releases": releases[:100]}, - ) - requests_mock.get( - re.compile( - rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=100.*" - ), - json={"releases": releases[100:]}, + "/ws/2/collection", + json={ + "collections": [ + { + "id": self.COLLECTION_ID, + "entity-type": "release", + "release-count": 3, + } + ] + }, ) - plugin._get_albums_in_collection(collection) - - def test_update_album_list_filtering(self, plugin, collection, monkeypatch): - ids_submitted = [] - - def mock_submit(_, album_ids): - ids_submitted.extend(album_ids) - - monkeypatch.setattr("beetsplug.mbcollection.submit_albums", mock_submit) - monkeypatch.setattr(plugin, "_get_collection", lambda: collection) - - albums = [ - Album(mb_albumid="invalid-id"), - Album(mb_albumid="00000000-0000-0000-0000-000000000001"), - ] - - plugin.update_album_list(None, albums) - # Behavior: only valid UUID was submitted - assert ids_submitted == ["00000000-0000-0000-0000-000000000001"] - - def test_remove_missing( - self, plugin, monkeypatch, requests_mock, collection - ): - removed_ids = [] - - def mock_remove(_, chunk): - removed_ids.extend(chunk) - - requests_mock.delete( - re.compile(rf".*/ws/2/collection/{collection.id}/releases/r3") - ) + collection_releases = f"/ws/2/collection/{self.COLLECTION_ID}/releases" + # Force small fetch chunk to require multiple paged requests. monkeypatch.setattr( - plugin, "_get_albums_in_collection", lambda _: {"r1", "r2", "r3"} + "beetsplug.mbcollection.MBCollection.FETCH_CHUNK_SIZE", 2 + ) + # 3 releases are fetched in two pages. + requests_mock.get( + re.compile(rf".*{collection_releases}\b.*&offset=0.*"), + json={ + "releases": [{"id": "in_collection1"}, {"id": "not_in_library"}] + }, + ) + requests_mock.get( + re.compile(rf".*{collection_releases}\b.*&offset=2.*"), + json={"releases": [{"id": "in_collection2"}]}, ) - lib_albums = [Album(mb_albumid="r1"), Album(mb_albumid="r2")] + # Force small submission chunk + monkeypatch.setattr( + "beetsplug.mbcollection.MBCollection.SUBMISSION_CHUNK_SIZE", 1 + ) + # so that releases are added using two requests + requests_mock.put( + re.compile( + rf".*{collection_releases}/00000000-0000-0000-0000-000000000001" + ) + ) + requests_mock.put( + re.compile( + rf".*{collection_releases}/00000000-0000-0000-0000-000000000002" + ) + ) + # and finally, one release is removed + requests_mock.delete( + re.compile(rf".*{collection_releases}/not_in_library") + ) - plugin.remove_missing(collection, lib_albums) + helper.run_command("mbupdate", "--remove") + + assert requests_mock.call_count == 6 From 34d993c043175179712f033a9a0b14a0c0d496ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 03:28:50 +0000 Subject: [PATCH 27/33] Add a changelog note --- docs/changelog.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b9e21aae9..0e2f757dc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -109,6 +109,15 @@ Other changes: unavailable, enabling ``importorskip`` usage in pytest setup. - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. +- Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom + MusicBrainz client implementation and updated relevant plugins accordingly: + + - :doc:`plugins/listenbrainz` + - :doc:`plugins/mbcollection` + - :doc:`plugins/mbpseudo` + - :doc:`plugins/missing` + - :doc:`plugins/musicbrainz` + - :doc:`plugins/parentwork` 2.5.1 (October 14, 2025) ------------------------ From 1447f49b72e6481ffe1c65d9b041c67ccf53df65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 21:42:48 +0000 Subject: [PATCH 28/33] Add some documentation to musicbrainz api mixins --- beetsplug/_utils/musicbrainz.py | 31 ++++++++++++++++++++++- beetsplug/mbcollection.py | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 17a83dd9b..47a2550f0 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -1,3 +1,13 @@ +"""Helpers for communicating with the MusicBrainz webservice. + +Provides rate-limited HTTP session and convenience methods to fetch and +normalize API responses. + +This module centralizes request handling and response shaping so callers can +work with consistently structured data without embedding HTTP or rate-limit +logic throughout the codebase. +""" + from __future__ import annotations import operator @@ -21,11 +31,22 @@ log = logging.getLogger(__name__) class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): - pass + """HTTP session that enforces rate limits.""" @dataclass class MusicBrainzAPI(RequestHandler): + """High-level interface to the MusicBrainz WS/2 API. + + Responsibilities: + - Configure the API host and request rate from application configuration. + - Offer helpers to fetch common entity types and to run searches. + - Normalize MusicBrainz responses so relation lists are grouped by target + type for easier downstream consumption. + + Documentation: https://musicbrainz.org/doc/MusicBrainz_API + """ + api_host: str = field(init=False) rate_limit: float = field(init=False) @@ -67,6 +88,12 @@ class MusicBrainzAPI(RequestHandler): def get_entity( self, entity: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: + """Retrieve and normalize data from the API entity endpoint. + + If requested, includes are appended to the request. The response is + passed through a normalizer that groups relation entries by their + target type so that callers receive a consistently structured mapping. + """ if includes: kwargs["inc"] = "+".join(includes) @@ -154,6 +181,8 @@ class MusicBrainzAPI(RequestHandler): class MusicBrainzAPIMixin: + """Mixin that provides a cached MusicBrainzAPI helper instance.""" + @cached_property def mb_api(self) -> MusicBrainzAPI: return MusicBrainzAPI() diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 95ceb3fcf..25f16228a 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -43,6 +43,19 @@ UUID_PAT = re.compile(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") @dataclass class MusicBrainzUserAPI(MusicBrainzAPI): + """MusicBrainz API client with user authentication. + + In order to retrieve private user collections and modify them, we need to + authenticate the requests with the user's MusicBrainz credentials. + + See documentation for authentication details: + https://musicbrainz.org/doc/MusicBrainz_API#Authentication + + Note that the documentation misleadingly states HTTP 'basic' authentication, + and I had to reverse-engineer musicbrainzngs to discover that it actually + uses HTTP 'digest' authentication. + """ + auth: HTTPDigestAuth = field(init=False) @cached_property @@ -57,12 +70,18 @@ class MusicBrainzUserAPI(MusicBrainzAPI): ) def request(self, *args, **kwargs) -> Response: + """Authenticate and include required client param in all requests.""" kwargs.setdefault("params", {}) kwargs["params"]["client"] = f"beets-{__version__}" kwargs["auth"] = self.auth return super().request(*args, **kwargs) def get_collections(self) -> list[JSONDict]: + """Get all collections for the authenticated user. + + Note that both URL parameters must be included to retrieve private + collections. + """ return self.get_entity( "collection", editor=self.user, includes=["user-collections"] ).get("collections", []) @@ -70,6 +89,13 @@ class MusicBrainzUserAPI(MusicBrainzAPI): @dataclass class MBCollection: + """Representation of a user's MusicBrainz collection. + + Provides convenient, chunked operations for retrieving releases and updating + the collection via the MusicBrainz web API. Fetch and submission limits are + controlled by class-level constants to avoid oversized requests. + """ + SUBMISSION_CHUNK_SIZE: ClassVar[int] = 200 FETCH_CHUNK_SIZE: ClassVar[int] = 100 @@ -78,22 +104,31 @@ class MBCollection: @property def id(self) -> str: + """Unique identifier assigned to the collection by MusicBrainz.""" return self.data["id"] @property def release_count(self) -> int: + """Total number of releases recorded in the collection.""" return self.data["release-count"] @property def releases_url(self) -> str: + """Complete API endpoint URL for listing releases in this collection.""" return f"{self.mb_api.api_root}/collection/{self.id}/releases" @property def releases(self) -> list[JSONDict]: + """Retrieve all releases in the collection, fetched in successive pages. + + The fetch is performed in chunks and returns a flattened sequence of + release records. + """ offsets = list(range(0, self.release_count, self.FETCH_CHUNK_SIZE)) return [r for offset in offsets for r in self.get_releases(offset)] def get_releases(self, offset: int) -> list[JSONDict]: + """Fetch a single page of releases beginning at a given position.""" return self.mb_api.get_json( self.releases_url, params={"limit": self.FETCH_CHUNK_SIZE, "offset": offset}, @@ -101,15 +136,24 @@ class MBCollection: @classmethod def get_id_chunks(cls, id_list: list[str]) -> Iterator[list[str]]: + """Yield successive sublists of identifiers sized for safe submission. + + Splits a long sequence of identifiers into batches that respect the + service's submission limits to avoid oversized requests. + """ for i in range(0, len(id_list), cls.SUBMISSION_CHUNK_SIZE): yield id_list[i : i + cls.SUBMISSION_CHUNK_SIZE] def add_releases(self, releases: list[str]) -> None: + """Add releases to the collection in batches.""" for chunk in self.get_id_chunks(releases): + # Need to escape semicolons: https://github.com/psf/requests/issues/6990 self.mb_api.put(f"{self.releases_url}/{'%3B'.join(chunk)}") def remove_releases(self, releases: list[str]) -> None: + """Remove releases from the collection in chunks.""" for chunk in self.get_id_chunks(releases): + # Need to escape semicolons: https://github.com/psf/requests/issues/6990 self.mb_api.delete(f"{self.releases_url}/{'%3B'.join(chunk)}") From 55b9c1c145954c8be9f2e4792068287c7e3f4a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 22:19:13 +0000 Subject: [PATCH 29/33] Retry on server errors too --- beetsplug/_utils/requests.py | 15 +++++++++++++-- test/plugins/utils/test_request_handler.py | 13 +++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index b8ac541e9..313ed13b4 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -67,7 +67,7 @@ class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): * default beets User-Agent header * default request timeout - * automatic retries on transient connection errors + * automatic retries on transient connection or server errors * raises exceptions for HTTP error status codes """ @@ -75,7 +75,18 @@ class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): super().__init__(*args, **kwargs) self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" - retry = Retry(connect=2, total=2, backoff_factor=1) + retry = Retry( + connect=2, + total=2, + backoff_factor=1, + # Retry on server errors + status_forcelist=[ + HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, + ], + ) adapter = HTTPAdapter(max_retries=retry) self.mount("https://", adapter) self.mount("http://", adapter) diff --git a/test/plugins/utils/test_request_handler.py b/test/plugins/utils/test_request_handler.py index c17a9387b..6887283dc 100644 --- a/test/plugins/utils/test_request_handler.py +++ b/test/plugins/utils/test_request_handler.py @@ -48,11 +48,20 @@ class TestRequestHandlerRetry: assert response.status_code == HTTPStatus.OK @pytest.mark.parametrize( - "last_response", [ConnectionResetError], ids=["conn_error"] + "last_response", + [ + ConnectionResetError, + HTTPResponse( + body=io.BytesIO(b"Server Error"), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + preload_content=False, + ), + ], + ids=["conn_error", "server_error"], ) def test_retry_exhaustion(self, request_handler): """Verify that the handler raises an error after exhausting retries.""" with pytest.raises( - requests.exceptions.ConnectionError, match="Max retries exceeded" + requests.exceptions.RequestException, match="Max retries exceeded" ): request_handler.get("http://example.com/api") From 59b02bc49b60ae41a040b51fc4bf783804f876b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:20:44 +0000 Subject: [PATCH 30/33] Type MusicBrainzAPI properly --- beetsplug/_utils/musicbrainz.py | 140 +++++++++++++++++++++++++++----- beetsplug/listenbrainz.py | 2 +- beetsplug/mbcollection.py | 21 ++--- beetsplug/musicbrainz.py | 2 +- 4 files changed, 129 insertions(+), 36 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 47a2550f0..2fc821df9 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -12,17 +12,20 @@ from __future__ import annotations import operator from dataclasses import dataclass, field -from functools import cached_property, singledispatchmethod +from functools import cached_property, singledispatchmethod, wraps from itertools import groupby -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar from requests_ratelimiter import LimiterMixin +from typing_extensions import NotRequired, Unpack from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: + from collections.abc import Callable + from requests import Response from .._typing import JSONDict @@ -34,11 +37,80 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): """HTTP session that enforces rate limits.""" +Entity = Literal[ + "area", + "artist", + "collection", + "event", + "genre", + "instrument", + "label", + "place", + "recording", + "release", + "release-group", + "series", + "work", + "url", +] + + +class LookupKwargs(TypedDict, total=False): + includes: NotRequired[list[str]] + + +class PagingKwargs(TypedDict, total=False): + limit: NotRequired[int] + offset: NotRequired[int] + + +class SearchKwargs(PagingKwargs): + query: NotRequired[str] + + +class BrowseKwargs(LookupKwargs, PagingKwargs, total=False): + pass + + +class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False): + artist: NotRequired[str] + collection: NotRequired[str] + release: NotRequired[str] + + +class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False): + work: NotRequired[str] + + +P = ParamSpec("P") +R = TypeVar("R") + + +def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]: + required = frozenset(keys) + + def deco(func: Callable[P, R]) -> Callable[P, R]: + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + # kwargs is a real dict at runtime; safe to inspect here + if not required & kwargs.keys(): + required_str = ", ".join(sorted(required)) + raise ValueError( + f"At least one of {required_str} filter is required" + ) + return func(*args, **kwargs) + + return wrapper + + return deco + + @dataclass class MusicBrainzAPI(RequestHandler): """High-level interface to the MusicBrainz WS/2 API. Responsibilities: + - Configure the API host and request rate from application configuration. - Offer helpers to fetch common entity types and to run searches. - Normalize MusicBrainz responses so relation lists are grouped by target @@ -85,10 +157,10 @@ class MusicBrainzAPI(RequestHandler): kwargs["params"]["fmt"] = "json" return super().request(*args, **kwargs) - def get_entity( - self, entity: str, includes: list[str] | None = None, **kwargs + def _get_resource( + self, resource: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: - """Retrieve and normalize data from the API entity endpoint. + """Retrieve and normalize data from the API resource endpoint. If requested, includes are appended to the request. The response is passed through a normalizer that groups relation entries by their @@ -98,11 +170,22 @@ class MusicBrainzAPI(RequestHandler): kwargs["inc"] = "+".join(includes) return self._group_relations( - self.get_json(f"{self.api_root}/{entity}", params=kwargs) + self.get_json(f"{self.api_root}/{resource}", params=kwargs) ) - def search_entity( - self, entity: str, filters: dict[str, str], **kwargs + def _lookup( + self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs] + ) -> JSONDict: + return self._get_resource(f"{entity}/{id_}", **kwargs) + + def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]: + return self._get_resource(entity, **kwargs).get(f"{entity}s", []) + + def search( + self, + entity: Entity, + filters: dict[str, str], + **kwargs: Unpack[SearchKwargs], ) -> list[JSONDict]: """Search for MusicBrainz entities matching the given filters. @@ -119,22 +202,41 @@ class MusicBrainzAPI(RequestHandler): ) log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query) kwargs["query"] = query - return self.get_entity(entity, **kwargs)[f"{entity}s"] + return self._get_resource(entity, **kwargs)[f"{entity}s"] - def get_release(self, id_: str, **kwargs) -> JSONDict: - return self.get_entity(f"release/{id_}", **kwargs) + def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict: + """Retrieve a release by its MusicBrainz ID.""" + return self._lookup("release", id_, **kwargs) - def get_recording(self, id_: str, **kwargs) -> JSONDict: - return self.get_entity(f"recording/{id_}", **kwargs) + def get_recording( + self, id_: str, **kwargs: Unpack[LookupKwargs] + ) -> JSONDict: + """Retrieve a recording by its MusicBrainz ID.""" + return self._lookup("recording", id_, **kwargs) - def get_work(self, id_: str, **kwargs) -> JSONDict: - return self.get_entity(f"work/{id_}", **kwargs) + def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict: + """Retrieve a work by its MusicBrainz ID.""" + return self._lookup("work", id_, **kwargs) - def browse_recordings(self, **kwargs) -> list[JSONDict]: - return self.get_entity("recording", **kwargs)["recordings"] + @require_one_of("artist", "collection", "release", "work") + def browse_recordings( + self, **kwargs: Unpack[BrowseRecordingsKwargs] + ) -> list[JSONDict]: + """Browse recordings related to the given entities. - def browse_release_groups(self, **kwargs) -> list[JSONDict]: - return self.get_entity("release-group", **kwargs)["release-groups"] + At least one of artist, collection, release, or work must be provided. + """ + return self._browse("recording", **kwargs) + + @require_one_of("artist", "collection", "release") + def browse_release_groups( + self, **kwargs: Unpack[BrowseReleaseGroupsKwargs] + ) -> list[JSONDict]: + """Browse release groups related to the given entities. + + At least one of artist, collection, or release must be provided. + """ + return self._get_resource("release-group", **kwargs)["release-groups"] @singledispatchmethod @classmethod diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index d054a00cc..fa73bd6b8 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -132,7 +132,7 @@ class ListenBrainzPlugin(MusicBrainzAPIMixin, BeetsPlugin): def get_mb_recording_id(self, track) -> str | None: """Returns the MusicBrainz recording ID for a track.""" - results = self.mb_api.search_entity( + results = self.mb_api.search( "recording", { "": track["track_metadata"].get("track_name"), diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 25f16228a..f89670dd3 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -58,15 +58,12 @@ class MusicBrainzUserAPI(MusicBrainzAPI): auth: HTTPDigestAuth = field(init=False) - @cached_property - def user(self) -> str: - return config["musicbrainz"]["user"].as_str() - def __post_init__(self) -> None: super().__post_init__() config["musicbrainz"]["pass"].redact = True self.auth = HTTPDigestAuth( - self.user, config["musicbrainz"]["pass"].as_str() + config["musicbrainz"]["user"].as_str(), + config["musicbrainz"]["pass"].as_str(), ) def request(self, *args, **kwargs) -> Response: @@ -76,15 +73,9 @@ class MusicBrainzUserAPI(MusicBrainzAPI): kwargs["auth"] = self.auth return super().request(*args, **kwargs) - def get_collections(self) -> list[JSONDict]: - """Get all collections for the authenticated user. - - Note that both URL parameters must be included to retrieve private - collections. - """ - return self.get_entity( - "collection", editor=self.user, includes=["user-collections"] - ).get("collections", []) + def browse_collections(self) -> list[JSONDict]: + """Get all collections for the authenticated user.""" + return self._browse("collection") @dataclass @@ -183,7 +174,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): @cached_property def collection(self) -> MBCollection: - if not (collections := self.mb_api.get_collections()): + if not (collections := self.mb_api.browse_collections()): raise ui.UserError("no collections exist for user") # Get all release collection IDs, avoiding event collections diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 990f21351..3e194c067 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -751,7 +751,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): using the provided criteria. Handles API errors by converting them into MusicBrainzAPIError exceptions with contextual information. """ - return self.mb_api.search_entity( + return self.mb_api.search( query_type, filters, limit=self.config["search_limit"].get() ) From d4b00ab4f47785c24fa03a14fb1bf3e1ad4e5d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:21:22 +0000 Subject: [PATCH 31/33] Add request handler utils to the docs --- beetsplug/_utils/requests.py | 15 +- docs/_templates/autosummary/class.rst | 11 + docs/api/index.rst | 1 + docs/api/plugin_utilities.rst | 16 + docs/changelog.rst | 24 +- docs/conf.py | 13 + poetry.lock | 403 +++++++++++++++++++++++++- pyproject.toml | 2 + 8 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 docs/api/plugin_utilities.rst diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 313ed13b4..92d52c9d6 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -113,18 +113,20 @@ class RequestHandler: subclasses. Usage: - Subclass and override :class:`RequestHandler.session_type`, + Subclass and override :class:`RequestHandler.create_session`, :class:`RequestHandler.explicit_http_errors` or :class:`RequestHandler.status_to_error()` to customize behavior. - Use - * :class:`RequestHandler.get_json()` to get JSON response data - * :class:`RequestHandler.get()` to get HTTP response object - * :class:`RequestHandler.request()` to invoke arbitrary HTTP methods + Use - Feel free to define common methods that are used in multiple plugins. + - :class:`RequestHandler.get_json()` to get JSON response data + - :class:`RequestHandler.get()` to get HTTP response object + - :class:`RequestHandler.request()` to invoke arbitrary HTTP methods + + Feel free to define common methods that are used in multiple plugins. """ + #: List of custom exceptions to be raised for specific status codes. explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ HTTPNotFoundError ] @@ -138,7 +140,6 @@ class RequestHandler: @cached_property def session(self) -> TimeoutAndRetrySession: - """Lazily initialize and cache the HTTP session.""" return self.create_session() def status_to_error( diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index 586b207b7..3259e9279 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -25,3 +25,14 @@ {% endblock %} .. rubric:: {{ _('Methods definition') }} + +{% if objname in related_typeddicts %} +Related TypedDicts +------------------ + +{% for typeddict in related_typeddicts[objname] %} +.. autotypeddict:: {{ typeddict }} + :show-inheritance: + +{% endfor %} +{% endif %} diff --git a/docs/api/index.rst b/docs/api/index.rst index edec5fe96..a1ecc4f72 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -6,4 +6,5 @@ API Reference :titlesonly: plugins + plugin_utilities database diff --git a/docs/api/plugin_utilities.rst b/docs/api/plugin_utilities.rst new file mode 100644 index 000000000..8c4355a43 --- /dev/null +++ b/docs/api/plugin_utilities.rst @@ -0,0 +1,16 @@ +Plugin Utilities +================ + +.. currentmodule:: beetsplug._utils.requests + +.. autosummary:: + :toctree: generated/ + + RequestHandler + +.. currentmodule:: beetsplug._utils.musicbrainz + +.. autosummary:: + :toctree: generated/ + + MusicBrainzAPI diff --git a/docs/changelog.rst b/docs/changelog.rst index 0e2f757dc..dda437b40 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -91,6 +91,21 @@ For plugin developers: - A new plugin event, ``album_matched``, is sent when an album that is being imported has been matched to its metadata and the corresponding distance has been calculated. +- Added a reusable requests handler which can be used by plugins to make HTTP + requests with built-in retry and backoff logic. It uses beets user-agent and + configures timeouts. See :class:`~beetsplug._utils.requests.RequestHandler` + for documentation. +- Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom + MusicBrainz client implementation and updated relevant plugins accordingly: + + - :doc:`plugins/listenbrainz` + - :doc:`plugins/mbcollection` + - :doc:`plugins/mbpseudo` + - :doc:`plugins/missing` + - :doc:`plugins/musicbrainz` + - :doc:`plugins/parentwork` + + See :class:`~beetsplug._utils.musicbrainz.MusicBrainzAPI` for documentation. For packagers: @@ -109,15 +124,6 @@ Other changes: unavailable, enabling ``importorskip`` usage in pytest setup. - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. -- Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom - MusicBrainz client implementation and updated relevant plugins accordingly: - - - :doc:`plugins/listenbrainz` - - :doc:`plugins/mbcollection` - - :doc:`plugins/mbpseudo` - - :doc:`plugins/missing` - - :doc:`plugins/musicbrainz` - - :doc:`plugins/parentwork` 2.5.1 (October 14, 2025) ------------------------ diff --git a/docs/conf.py b/docs/conf.py index 8d2bae130..c04e034ab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,9 +32,22 @@ extensions = [ "sphinx_design", "sphinx_copybutton", "conf", + "sphinx_toolbox.more_autodoc.autotypeddict", ] autosummary_generate = True +autosummary_context = { + "related_typeddicts": { + "MusicBrainzAPI": [ + "beetsplug._utils.musicbrainz.LookupKwargs", + "beetsplug._utils.musicbrainz.SearchKwargs", + "beetsplug._utils.musicbrainz.BrowseKwargs", + "beetsplug._utils.musicbrainz.BrowseRecordingsKwargs", + "beetsplug._utils.musicbrainz.BrowseReleaseGroupsKwargs", + ], + } +} +autodoc_member_order = "bysource" exclude_patterns = ["_build"] templates_path = ["_templates"] source_suffix = {".rst": "restructuredtext", ".md": "markdown"} diff --git a/poetry.lock b/poetry.lock index 47c07e14f..5a0832399 100644 --- a/poetry.lock +++ b/poetry.lock @@ -49,6 +49,42 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0)"] +[[package]] +name = "apeye" +version = "1.4.1" +description = "Handy tools for working with URLs and APIs." +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "apeye-1.4.1-py3-none-any.whl", hash = "sha256:44e58a9104ec189bf42e76b3a7fe91e2b2879d96d48e9a77e5e32ff699c9204e"}, + {file = "apeye-1.4.1.tar.gz", hash = "sha256:14ea542fad689e3bfdbda2189a354a4908e90aee4bf84c15ab75d68453d76a36"}, +] + +[package.dependencies] +apeye-core = ">=1.0.0b2" +domdf-python-tools = ">=2.6.0" +platformdirs = ">=2.3.0" +requests = ">=2.24.0" + +[package.extras] +all = ["cachecontrol[filecache] (>=0.12.6)", "lockfile (>=0.12.2)"] +limiter = ["cachecontrol[filecache] (>=0.12.6)", "lockfile (>=0.12.2)"] + +[[package]] +name = "apeye-core" +version = "1.1.5" +description = "Core (offline) functionality for the apeye library." +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "apeye_core-1.1.5-py3-none-any.whl", hash = "sha256:dc27a93f8c9e246b3b238c5ea51edf6115ab2618ef029b9f2d9a190ec8228fbf"}, + {file = "apeye_core-1.1.5.tar.gz", hash = "sha256:5de72ed3d00cc9b20fea55e54b7ab8f5ef8500eb33a5368bc162a5585e238a55"}, +] + +[package.dependencies] +domdf-python-tools = ">=2.6.0" +idna = ">=2.5" + [[package]] name = "appdirs" version = "1.4.4" @@ -138,6 +174,20 @@ gi = ["pygobject (>=3.54.2,<4.0.0)"] mad = ["pymad[mad] (>=0.11.3,<0.12.0)"] test = ["pytest (>=8.4.2)", "pytest-cov (>=7.0.0)"] +[[package]] +name = "autodocsumm" +version = "0.2.14" +description = "Extended sphinx autodoc including automatic autosummaries" +optional = true +python-versions = ">=3.7" +files = [ + {file = "autodocsumm-0.2.14-py3-none-any.whl", hash = "sha256:3bad8717fc5190802c60392a7ab04b9f3c97aa9efa8b3780b3d81d615bfe5dc0"}, + {file = "autodocsumm-0.2.14.tar.gz", hash = "sha256:2839a9d4facc3c4eccd306c08695540911042b46eeafcdc3203e6d0bab40bc77"}, +] + +[package.dependencies] +Sphinx = ">=4.0,<9.0" + [[package]] name = "babel" version = "2.17.0" @@ -405,6 +455,27 @@ files = [ [package.dependencies] cffi = ">=1.0.0" +[[package]] +name = "cachecontrol" +version = "0.14.4" +description = "httplib2 caching for requests" +optional = true +python-versions = ">=3.10" +files = [ + {file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"}, + {file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + [[package]] name = "certifi" version = "2025.10.5" @@ -795,6 +866,24 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cssutils" +version = "2.11.1" +description = "A CSS Cascading Style Sheets library for Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1"}, + {file = "cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["cssselect", "importlib-resources", "jaraco.test (>=5.1)", "lxml", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + [[package]] name = "dbus-python" version = "1.4.0" @@ -820,6 +909,21 @@ files = [ {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, ] +[[package]] +name = "dict2css" +version = "0.3.0.post1" +description = "A μ-library for constructing cascading style sheets from Python dictionaries." +optional = true +python-versions = ">=3.6" +files = [ + {file = "dict2css-0.3.0.post1-py3-none-any.whl", hash = "sha256:f006a6b774c3e31869015122ae82c491fd25e7de4a75607a62aa3e798f837e0d"}, + {file = "dict2css-0.3.0.post1.tar.gz", hash = "sha256:89c544c21c4ca7472c3fffb9d37d3d926f606329afdb751dc1de67a411b70719"}, +] + +[package.dependencies] +cssutils = ">=2.2.0" +domdf-python-tools = ">=2.2.0" + [[package]] name = "docstrfmt" version = "1.11.1" @@ -860,6 +964,25 @@ files = [ {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] +[[package]] +name = "domdf-python-tools" +version = "3.10.0" +description = "Helpful functions for Python 🐍 🛠️" +optional = true +python-versions = ">=3.6" +files = [ + {file = "domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36"}, + {file = "domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298"}, +] + +[package.dependencies] +natsort = ">=7.0.1" +typing-extensions = ">=3.7.4.1" + +[package.extras] +all = ["pytz (>=2019.1)"] +dates = ["pytz (>=2019.1)"] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -877,6 +1000,17 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "filelock" +version = "3.20.2" +description = "A platform independent file lock." +optional = true +python-versions = ">=3.10" +files = [ + {file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"}, + {file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"}, +] + [[package]] name = "filetype" version = "1.2.0" @@ -937,6 +1071,27 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "html5lib" +version = "1.1" +description = "HTML parser based on the WHATWG HTML specification" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"}, + {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, +] + +[package.dependencies] +six = ">=1.9" +webencodings = "*" + +[package.extras] +all = ["chardet (>=2.2)", "genshi", "lxml"] +chardet = ["chardet (>=2.2)"] +genshi = ["genshi"] +lxml = ["lxml"] + [[package]] name = "httpcore" version = "1.0.9" @@ -1731,6 +1886,17 @@ mutagen = ">=1.46" [package.extras] test = ["tox"] +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = true +python-versions = ">=3.9" +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -1900,6 +2066,21 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "natsort" +version = "8.4.0" +description = "Simple yet flexible natural sorting in Python." +optional = true +python-versions = ">=3.7" +files = [ + {file = "natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c"}, + {file = "natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581"}, +] + +[package.extras] +fast = ["fastnumbers (>=2.0.0)"] +icu = ["PyICU (>=1.0.0)"] + [[package]] name = "numba" version = "0.62.1" @@ -3292,6 +3473,94 @@ files = [ {file = "roman-5.1.tar.gz", hash = "sha256:3a86572e9bc9183e771769601189e5fa32f1620ffeceebb9eca836affb409986"}, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.16" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = true +python-versions = ">=3.8" +files = [ + {file = "ruamel.yaml-0.18.16-py3-none-any.whl", hash = "sha256:048f26d64245bae57a4f9ef6feb5b552a386830ef7a826f235ffb804c59efbba"}, + {file = "ruamel.yaml-0.18.16.tar.gz", hash = "sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = true +python-versions = ">=3.9" +files = [ + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win32.whl", hash = "sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win_amd64.whl", hash = "sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe"}, + {file = "ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600"}, +] + [[package]] name = "ruff" version = "0.14.3" @@ -3680,6 +3949,24 @@ docs = ["sphinxcontrib-websupport"] lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.0.1" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +optional = true +python-versions = ">=3.10" +files = [ + {file = "sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a"}, + {file = "sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55"}, +] + +[package.dependencies] +sphinx = ">=8.1.3" + +[package.extras] +docs = ["furo (>=2024.8.6)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "defusedxml (>=0.7.1)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "sphobjinv (>=2.3.1.2)", "typing-extensions (>=4.12.2)"] + [[package]] name = "sphinx-copybutton" version = "0.5.2" @@ -3723,6 +4010,22 @@ theme-pydata = ["pydata-sphinx-theme (>=0.15.2,<0.16.0)"] theme-rtd = ["sphinx-rtd-theme (>=2.0,<3.0)"] theme-sbt = ["sphinx-book-theme (>=1.1,<2.0)"] +[[package]] +name = "sphinx-jinja2-compat" +version = "0.4.1" +description = "Patches Jinja2 v3 to restore compatibility with earlier Sphinx versions." +optional = true +python-versions = ">=3.6" +files = [ + {file = "sphinx_jinja2_compat-0.4.1-py3-none-any.whl", hash = "sha256:64ca0d46f0d8029fbe69ea612793a55e6ef0113e1bba4a85d402158c09f17a14"}, + {file = "sphinx_jinja2_compat-0.4.1.tar.gz", hash = "sha256:0188f0802d42c3da72997533b55a00815659a78d3f81d4b4747b1fb15a5728e6"}, +] + +[package.dependencies] +jinja2 = ">=2.10" +markupsafe = ">=1" +standard-imghdr = {version = "3.10.14", markers = "python_version >= \"3.13\""} + [[package]] name = "sphinx-lint" version = "1.0.1" @@ -3741,6 +4044,80 @@ regex = "*" [package.extras] tests = ["pytest", "pytest-cov"] +[[package]] +name = "sphinx-prompt" +version = "1.9.0" +description = "Sphinx directive to add unselectable prompt" +optional = true +python-versions = ">=3.10" +files = [ + {file = "sphinx_prompt-1.9.0-py3-none-any.whl", hash = "sha256:fd731446c03f043d1ff6df9f22414495b23067c67011cc21658ea8d36b3575fc"}, + {file = "sphinx_prompt-1.9.0.tar.gz", hash = "sha256:471b3c6d466dce780a9b167d9541865fd4e9a80ed46e31b06a52a0529ae995a1"}, +] + +[package.dependencies] +certifi = "*" +docutils = "*" +idna = "*" +pygments = "*" +Sphinx = ">=8.0.0,<9.0.0" +urllib3 = "*" + +[[package]] +name = "sphinx-tabs" +version = "3.4.5" +description = "Tabbed views for Sphinx" +optional = true +python-versions = "~=3.7" +files = [ + {file = "sphinx-tabs-3.4.5.tar.gz", hash = "sha256:ba9d0c1e3e37aaadd4b5678449eb08176770e0fc227e769b6ce747df3ceea531"}, + {file = "sphinx_tabs-3.4.5-py3-none-any.whl", hash = "sha256:92cc9473e2ecf1828ca3f6617d0efc0aa8acb06b08c56ba29d1413f2f0f6cf09"}, +] + +[package.dependencies] +docutils = "*" +pygments = "*" +sphinx = "*" + +[package.extras] +code-style = ["pre-commit (==2.13.0)"] +testing = ["bs4", "coverage", "pygments", "pytest (>=7.1,<8)", "pytest-cov", "pytest-regressions", "rinohtype"] + +[[package]] +name = "sphinx-toolbox" +version = "4.1.1" +description = "Box of handy tools for Sphinx 🧰 📔" +optional = true +python-versions = ">=3.7" +files = [ + {file = "sphinx_toolbox-4.1.1-py3-none-any.whl", hash = "sha256:1ee2616091453430ffe41e8371e0ddd22a5c1f504ba2dfb306f50870f3f7672a"}, + {file = "sphinx_toolbox-4.1.1.tar.gz", hash = "sha256:1bb1750bf9e1f72a54161b0867caf3b6bf2ee216ecb9f8c519f0a9348824954a"}, +] + +[package.dependencies] +apeye = ">=0.4.0" +autodocsumm = ">=0.2.0" +beautifulsoup4 = ">=4.9.1" +cachecontrol = {version = ">=0.13.0", extras = ["filecache"]} +dict2css = ">=0.2.3" +docutils = ">=0.16" +domdf-python-tools = ">=2.9.0" +filelock = ">=3.8.0" +html5lib = ">=1.1" +roman = ">4.0" +"ruamel.yaml" = ">=0.16.12,<=0.18.16" +sphinx = ">=3.2.0" +sphinx-autodoc-typehints = ">=1.11.1" +sphinx-jinja2-compat = ">=0.1.0" +sphinx-prompt = ">=1.1.0" +sphinx-tabs = ">=1.2.1,<3.4.7" +tabulate = ">=0.8.7" +typing-extensions = ">=3.7.4.3,<3.10.0.1 || >3.10.0.1" + +[package.extras] +all = ["coincidence (>=0.4.3)", "pygments (>=2.7.4,<=2.13.0)"] +testing = ["coincidence (>=0.4.3)", "pygments (>=2.7.4,<=2.13.0)"] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -3861,6 +4238,17 @@ files = [ {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, ] +[[package]] +name = "standard-imghdr" +version = "3.10.14" +description = "Standard library imghdr redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_imghdr-3.10.14-py3-none-any.whl", hash = "sha256:cdf6883163349624dee9a81d2853a20260337c4cd41c04e99c082e01833a08e2"}, + {file = "standard_imghdr-3.10.14.tar.gz", hash = "sha256:2598fe2e7c540dbda34b233295e10957ab8dc8ac6f3bd9eaa8d38be167232e52"}, +] + [[package]] name = "standard-sunau" version = "3.13.0" @@ -4122,6 +4510,17 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = true +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + [[package]] name = "werkzeug" version = "3.1.3" @@ -4161,7 +4560,7 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] chroma = ["pyacoustid"] discogs = ["python3-discogs-client"] -docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] +docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx-toolbox"] embedart = ["Pillow"] embyupdate = ["requests"] fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"] @@ -4184,4 +4583,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "cd53b70a9cd746a88e80e04e67e0b010a0e5b87f745be94e901a9fd08619771a" +content-hash = "8a1714daca55eab559558f2d4bd63d4857686eb607bf4b24f1ea6dbd412e6641" diff --git a/pyproject.toml b/pyproject.toml index 8b608a45e..dbfc2715b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } +sphinx-toolbox = { version = ">=4.1.0", optional = true } titlecase = { version = "^2.4.1", optional = true } [tool.poetry.group.test.dependencies] @@ -151,6 +152,7 @@ docs = [ "sphinx-lint", "sphinx-design", "sphinx-copybutton", + "sphinx-toolbox", ] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick From a4058218283709edb192be2c31147693e5d690ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:23:55 +0000 Subject: [PATCH 32/33] Fix changelog formatting --- docs/changelog.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index dda437b40..13dd15737 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,23 +20,23 @@ New features: - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and album artist are the same in ftintitle. - :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist - filepath into the command calling the player program. -- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed - to receive extra verbose logging around last.fm results and how they are - resolved. The ``extended_debug`` config setting and ``--debug`` option - have been removed. + filepath into the command calling the player program. +- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed to + receive extra verbose logging around last.fm results and how they are + resolved. The ``extended_debug`` config setting and ``--debug`` option have + been removed. - :doc:`plugins/importsource`: Added new plugin that tracks original import paths and optionally suggests removing source files when items are removed from the library. - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive - MusicBrainz pseudo-releases as recommendations during import. + MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. - :doc:`/plugins/convert`: ``force`` can be passed to override checks like no_convert, never_convert_lossy_files, same format, and max_bitrate -- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to - resolve differences in metadata source styles. +- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to resolve + differences in metadata source styles. - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, - saving all contributing artists to the respective fields. + saving all contributing artists to the respective fields. - :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead of being appended at the end. This improves formatting for titles like "Song 1 From a801afd8b6b17db7af499a109b88101f312ef084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:24:38 +0000 Subject: [PATCH 33/33] Update git blame ignore revs --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index c8cb065f5..7aea1f81a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -85,3 +85,5 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 a59e41a88365e414db3282658d2aa456e0b3468a # pyupgrade Python 3.10 301637a1609831947cb5dd90270ed46c24b1ab1b +# Fix changelog formatting +658b184c59388635787b447983ecd3a575f4fe56