From 59736ebfac151cac4678ff2f8aaeea657cc95829 Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Sat, 5 Sep 2026 16:49:19 +0800 Subject: [PATCH 1/2] fix(deezer): detect compilations credited to a single main artist --- beetsplug/deezer.py | 11 ++- docs/changelog.rst | 3 + test/plugins/test_deezer.py | 155 +++++++++++++++++++++++++++++++++--- 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index c4100b22ee..3c122b91cd 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -12,6 +12,7 @@ from beets.autotag import AlbumInfo, TrackInfo from beets.dbcore import types from beets.exceptions import UserError +from beets.importer.tasks import SINGLE_ARTIST_THRESH from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin VARIOUS_ARTISTS_ID = 5080 @@ -112,7 +113,15 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None: for track in tracks: track.medium_total = medium_totals[track.medium] - is_va = str(album_data["artist"]["id"]) == str(VARIOUS_ARTISTS_ID) + album_artist_id = str(album_data["artist"]["id"]) + # Deezer assigns some compilations a single "main" artist instead of + # the Various Artists entity, leaving them undetected. Treat a release + # as VA when its album artist performs on fewer tracks than the + # importer's own single-artist plurality threshold. + is_va = album_artist_id == str(VARIOUS_ARTISTS_ID) or ( + sum(t.artist_id == album_artist_id for t in tracks) + < len(tracks) * SINGLE_ARTIST_THRESH + ) if is_va: va_name = config["va_name"].as_str() artist = va_name diff --git a/docs/changelog.rst b/docs/changelog.rst index f4e299371b..d85395369c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -44,6 +44,9 @@ Bug fixes - :doc:`plugins/lyrics`: ``beet lyrics`` no longer crashes with an ``AttributeError`` on tracks that have no stored lyrics when ``force`` is enabled; a missing lyrics body is now treated as empty text. :bug:`6860` +- :doc:`plugins/deezer`: Detect compilations that Deezer credits to a single + "main" artist instead of "Various Artists", so they are tagged as such rather + than getting every track artist in the album artist field. :bug:`4057` - Flexible attributes whose names contain uppercase characters (for example ``beet import --set Tag_With_Uppercase=true``) can now be found by queries. Field names are lowercased when a query is parsed, so such attributes could diff --git a/test/plugins/test_deezer.py b/test/plugins/test_deezer.py index fb5cffaa6b..e5b57bf795 100644 --- a/test/plugins/test_deezer.py +++ b/test/plugins/test_deezer.py @@ -1,16 +1,25 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + import pytest from beets.library import Item from beetsplug.deezer import DeezerPlugin +if TYPE_CHECKING: + from requests_mock import Mocker + +JSONDict = dict[str, Any] + @pytest.fixture -def plugin(): +def plugin() -> DeezerPlugin: return DeezerPlugin() class TestSearchQuery: - def test_track_query_is_free_text(self, plugin): + def test_track_query_is_free_text(self, plugin: DeezerPlugin) -> None: query, filters = plugin.get_search_query_with_filters( "track", [Item()], "Artist", "Title", False ) @@ -18,14 +27,18 @@ def test_track_query_is_free_text(self, plugin): assert query == "Title Artist" assert filters == {} - def test_track_query_tolerates_missing_artist(self, plugin): + def test_track_query_tolerates_missing_artist( + self, plugin: DeezerPlugin + ) -> None: query, _ = plugin.get_search_query_with_filters( "track", [Item()], "", "Title", False ) assert query == "Title" - def test_album_query_filters_on_album_and_artist(self, plugin): + def test_album_query_filters_on_album_and_artist( + self, plugin: DeezerPlugin + ) -> None: query, filters = plugin.get_search_query_with_filters( "album", [Item()], "Artist", "Album", False ) @@ -33,7 +46,9 @@ def test_album_query_filters_on_album_and_artist(self, plugin): assert query == 'album:"Album" artist:"Artist"' assert filters == {} - def test_album_query_omits_artist_for_various_artists(self, plugin): + def test_album_query_omits_artist_for_various_artists( + self, plugin: DeezerPlugin + ) -> None: query, _ = plugin.get_search_query_with_filters( "album", [Item()], "Various Artists", "Album", True ) @@ -41,8 +56,21 @@ def test_album_query_omits_artist_for_various_artists(self, plugin): assert query == 'album:"Album"' +def make_track(artist_id: int, position: int) -> JSONDict: + """Build a minimal Deezer track payload credited to ``artist_id``.""" + return { + "title": f"Track {position}", + "id": 1000 + position, + "link": f"https://www.deezer.com/track/{1000 + position}", + "duration": 200, + "track_position": position, + "disk_number": 1, + "artist": {"id": artist_id, "name": f"Artist {artist_id}"}, + } + + class TestGetTrack: - def track_data(self, **fields): + def track_data(self, **fields) -> JSONDict: return { "id": 1, "title": "Title", @@ -51,7 +79,9 @@ def track_data(self, **fields): **fields, } - def test_uses_contributors_when_artist_is_missing(self, plugin): + def test_uses_contributors_when_artist_is_missing( + self, plugin: DeezerPlugin + ) -> None: track = plugin._get_track( self.track_data(contributors=[{"id": 2, "name": "Artist"}]) ) @@ -59,7 +89,9 @@ def test_uses_contributors_when_artist_is_missing(self, plugin): assert track.artist == "Artist" assert track.artist_id == "2" - def test_falls_back_to_artist_without_contributors(self, plugin): + def test_falls_back_to_artist_without_contributors( + self, plugin: DeezerPlugin + ) -> None: track = plugin._get_track( self.track_data(artist={"id": 2, "name": "Artist"}) ) @@ -67,8 +99,113 @@ def test_falls_back_to_artist_without_contributors(self, plugin): assert track.artist == "Artist" assert track.artist_id == "2" - def test_tolerates_missing_artist_and_contributors(self, plugin): + def test_tolerates_missing_artist_and_contributors( + self, plugin: DeezerPlugin + ) -> None: track = plugin._get_track(self.track_data()) assert track.artist is None assert track.artist_id is None + + +class TestVariousArtistsDetection: + def make_album( + self, album_artist_id: int, contributor_ids: list[int] + ) -> JSONDict: + """Build a minimal Deezer album payload.""" + return { + "title": "Some Album", + "link": "https://www.deezer.com/album/1", + "record_type": "album", + "label": "Some Label", + "release_date": "2017-01-01", + "artist": { + "id": album_artist_id, + "name": f"Artist {album_artist_id}", + }, + "contributors": [ + {"id": aid, "name": f"Artist {aid}"} for aid in contributor_ids + ], + "cover_xl": None, + } + + def mock_album( + self, + requests_mock: Mocker, + album_artist_id: int, + track_artist_ids: list[int], + contributor_ids: list[int] | None = None, + ) -> None: + """Mock the album and album-tracks endpoints for album id 1. + + ``contributor_ids`` overrides the album-level contributors, which + otherwise cover every artist involved. + """ + if contributor_ids is None: + contributor_ids = list( + dict.fromkeys([album_artist_id, *track_artist_ids]) + ) + requests_mock.get( + f"{DeezerPlugin.album_url}1", + json=self.make_album(album_artist_id, contributor_ids), + ) + requests_mock.get( + f"{DeezerPlugin.album_url}1/tracks", + json={ + "data": [ + make_track(aid, i) + for i, aid in enumerate(track_artist_ids, start=1) + ] + }, + ) + + def test_compilation_with_non_va_album_artist( + self, plugin: DeezerPlugin, requests_mock: Mocker + ) -> None: + # Album credited to a single "main" artist that performs on only one + # of many tracks: this is a compilation and should be flagged as VA. + self.mock_album( + requests_mock, + album_artist_id=100, + track_artist_ids=[100, 200, 300, 400, 500, 600], + ) + + album_info = plugin.album_for_id("1") + + assert album_info is not None + assert album_info.va is True + assert album_info.artist == "Various Artists" + + def test_plurality_artist_album_not_va( + self, plugin: DeezerPlugin, requests_mock: Mocker + ) -> None: + # Album whose main artist performs on 2 of 5 tracks (40%) is above + # the importer's single-artist threshold and stays single-artist. + self.mock_album( + requests_mock, + album_artist_id=100, + track_artist_ids=[100, 100, 200, 300, 400], + contributor_ids=[100], + ) + + album_info = plugin.album_for_id("1") + + assert album_info is not None + assert album_info.va is False + assert album_info.artist == "Artist 100" + + def test_single_artist_album_not_va( + self, plugin: DeezerPlugin, requests_mock: Mocker + ) -> None: + # Album whose main artist performs on every track is not a compilation. + self.mock_album( + requests_mock, + album_artist_id=100, + track_artist_ids=[100, 100, 100, 100], + ) + + album_info = plugin.album_for_id("1") + + assert album_info is not None + assert album_info.va is False + assert album_info.artist == "Artist 100" From 59e67d52f7ac5e3785ed3b8d08347b5fc129ebca Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Sun, 6 Sep 2026 14:04:15 +0800 Subject: [PATCH 2/2] tests: drop annotations except on the two helpers --- test/plugins/test_deezer.py | 59 +++++++++++-------------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/test/plugins/test_deezer.py b/test/plugins/test_deezer.py index e5b57bf795..68d469df74 100644 --- a/test/plugins/test_deezer.py +++ b/test/plugins/test_deezer.py @@ -1,25 +1,20 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any +from typing import Any import pytest from beets.library import Item from beetsplug.deezer import DeezerPlugin -if TYPE_CHECKING: - from requests_mock import Mocker - JSONDict = dict[str, Any] @pytest.fixture -def plugin() -> DeezerPlugin: +def plugin(): return DeezerPlugin() class TestSearchQuery: - def test_track_query_is_free_text(self, plugin: DeezerPlugin) -> None: + def test_track_query_is_free_text(self, plugin): query, filters = plugin.get_search_query_with_filters( "track", [Item()], "Artist", "Title", False ) @@ -27,18 +22,14 @@ def test_track_query_is_free_text(self, plugin: DeezerPlugin) -> None: assert query == "Title Artist" assert filters == {} - def test_track_query_tolerates_missing_artist( - self, plugin: DeezerPlugin - ) -> None: + def test_track_query_tolerates_missing_artist(self, plugin): query, _ = plugin.get_search_query_with_filters( "track", [Item()], "", "Title", False ) assert query == "Title" - def test_album_query_filters_on_album_and_artist( - self, plugin: DeezerPlugin - ) -> None: + def test_album_query_filters_on_album_and_artist(self, plugin): query, filters = plugin.get_search_query_with_filters( "album", [Item()], "Artist", "Album", False ) @@ -46,9 +37,7 @@ def test_album_query_filters_on_album_and_artist( assert query == 'album:"Album" artist:"Artist"' assert filters == {} - def test_album_query_omits_artist_for_various_artists( - self, plugin: DeezerPlugin - ) -> None: + def test_album_query_omits_artist_for_various_artists(self, plugin): query, _ = plugin.get_search_query_with_filters( "album", [Item()], "Various Artists", "Album", True ) @@ -70,7 +59,7 @@ def make_track(artist_id: int, position: int) -> JSONDict: class TestGetTrack: - def track_data(self, **fields) -> JSONDict: + def track_data(self, **fields): return { "id": 1, "title": "Title", @@ -79,9 +68,7 @@ def track_data(self, **fields) -> JSONDict: **fields, } - def test_uses_contributors_when_artist_is_missing( - self, plugin: DeezerPlugin - ) -> None: + def test_uses_contributors_when_artist_is_missing(self, plugin): track = plugin._get_track( self.track_data(contributors=[{"id": 2, "name": "Artist"}]) ) @@ -89,9 +76,7 @@ def test_uses_contributors_when_artist_is_missing( assert track.artist == "Artist" assert track.artist_id == "2" - def test_falls_back_to_artist_without_contributors( - self, plugin: DeezerPlugin - ) -> None: + def test_falls_back_to_artist_without_contributors(self, plugin): track = plugin._get_track( self.track_data(artist={"id": 2, "name": "Artist"}) ) @@ -99,9 +84,7 @@ def test_falls_back_to_artist_without_contributors( assert track.artist == "Artist" assert track.artist_id == "2" - def test_tolerates_missing_artist_and_contributors( - self, plugin: DeezerPlugin - ) -> None: + def test_tolerates_missing_artist_and_contributors(self, plugin): track = plugin._get_track(self.track_data()) assert track.artist is None @@ -131,11 +114,11 @@ def make_album( def mock_album( self, - requests_mock: Mocker, - album_artist_id: int, - track_artist_ids: list[int], - contributor_ids: list[int] | None = None, - ) -> None: + requests_mock, + album_artist_id, + track_artist_ids, + contributor_ids=None, + ): """Mock the album and album-tracks endpoints for album id 1. ``contributor_ids`` overrides the album-level contributors, which @@ -159,9 +142,7 @@ def mock_album( }, ) - def test_compilation_with_non_va_album_artist( - self, plugin: DeezerPlugin, requests_mock: Mocker - ) -> None: + def test_compilation_with_non_va_album_artist(self, plugin, requests_mock): # Album credited to a single "main" artist that performs on only one # of many tracks: this is a compilation and should be flagged as VA. self.mock_album( @@ -176,9 +157,7 @@ def test_compilation_with_non_va_album_artist( assert album_info.va is True assert album_info.artist == "Various Artists" - def test_plurality_artist_album_not_va( - self, plugin: DeezerPlugin, requests_mock: Mocker - ) -> None: + def test_plurality_artist_album_not_va(self, plugin, requests_mock): # Album whose main artist performs on 2 of 5 tracks (40%) is above # the importer's single-artist threshold and stays single-artist. self.mock_album( @@ -194,9 +173,7 @@ def test_plurality_artist_album_not_va( assert album_info.va is False assert album_info.artist == "Artist 100" - def test_single_artist_album_not_va( - self, plugin: DeezerPlugin, requests_mock: Mocker - ) -> None: + def test_single_artist_album_not_va(self, plugin, requests_mock): # Album whose main artist performs on every track is not a compilation. self.mock_album( requests_mock,