From 62256adf4e08ee011cd9595cc79daa8c0511b364 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 10:52:50 -0800 Subject: [PATCH 1/5] support multiple artists for spotify and improve multiartist support for lastgenre --- beetsplug/lastgenre/__init__.py | 63 ++++++++++++++++++++++++++++++--- beetsplug/spotify.py | 33 +++++++++++++---- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ea0ab951a..0f04b49c3 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -302,23 +302,76 @@ class LastGenrePlugin(plugins.BeetsPlugin): def fetch_album_genre(self, obj): """Return raw album genres from Last.fm for this Item or Album.""" - return self._last_lookup( + genre = self._last_lookup( "album", LASTFM.get_album, obj.albumartist, obj.album ) + if genre: + return genre + + # If no genres found for the joint 'albumartist', try the individual + # album artists if available in 'albumartists'. + if obj.albumartists and len(obj.albumartists) > 1: + for albumartist in obj.albumartists: + genre = self._last_lookup( + "album", LASTFM.get_album, albumartist, obj.album + ) + + if genre: + return genre + + return genre def fetch_album_artist_genre(self, obj): """Return raw album artist genres from Last.fm for this Item or Album.""" - return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) + genres = self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) + if genres: + return genres - def fetch_artist_genre(self, item): + # If not genres found for the joint 'albumartist', try the individual + # album artists if available in 'albumartists'. + if obj.ablumartists and len(obj.albumartists) > 1: + for albumartist in obj.albumartists: + genre = self._last_lookup( + "artist", LASTFM.get_artist, albumartist + ) + + if genre: + return genre + return genres + + def fetch_artist_genre(self, obj): """Returns raw track artist genres from Last.fm for this Item.""" - return self._last_lookup("artist", LASTFM.get_artist, item.artist) + genres = self._last_lookup("artist", LASTFM.get_artist, obj.artist) + if genres: + return genres + + # If not genres found for the joint 'artist', try the individual + # album artists if available in 'artists'. + if obj.artists and len(obj.artists) > 1: + for artist in obj.artists: + genre = self._last_lookup("artist", LASTFM.get_artist, artist) + if genre: + return genre + return genres def fetch_track_genre(self, obj): """Returns raw track genres from Last.fm for this Item.""" - return self._last_lookup( + genres = self._last_lookup( "track", LASTFM.get_track, obj.artist, obj.title ) + if genres: + return genres + + # If not genres found for the joint 'artist', try the individual + # album artists if available in 'artists'. + if obj.artists and len(obj.artists) > 1: + for artist in obj.artists: + genre = self._last_lookup( + "track", LASTFM.get_track, artist, obj.title + ) + if genre: + return genre + return genres # Main processing: _get_genre() and helpers. diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index b3c653682..ee5069e38 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -302,6 +302,21 @@ class SpotifyPlugin( self._log.error("Request failed. Error: {}", e) raise APIError("Request failed.") + def _multi_artist_credit( + self, artists: list[dict[str | int, str]] + ) -> tuple[list[str], list[str]]: + """Given a list representing an ``artist``, accumulate data into a pair + of lists: the first being the artist names, and the second being the + artist IDs. + """ + artist_names = [] + artist_ids = [] + for artist in artists: + name, id = self.get_artist([artist]) + artist_names.append(name) + artist_ids.append(id) + return artist_names, artist_ids + def album_for_id(self, album_id: str) -> AlbumInfo | None: """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. @@ -321,7 +336,8 @@ class SpotifyPlugin( if album_data["name"] == "": self._log.debug("Album removed from Spotify: {}", album_id) return None - artist, artist_id = self.get_artist(album_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit(album_data["artists"]) + artist = ", ".join(artists_names) date_parts = [ int(part) for part in album_data["release_date"].split("-") @@ -364,8 +380,10 @@ class SpotifyPlugin( album_id=spotify_id, spotify_album_id=spotify_id, artist=artist, - artist_id=artist_id, - spotify_artist_id=artist_id, + artist_id=artists_ids[0], + spotify_artist_id=artists_ids[0], + artists=artists_names, + artists_ids=artists_ids, tracks=tracks, albumtype=album_data["album_type"], va=len(album_data["artists"]) == 1 @@ -388,7 +406,8 @@ class SpotifyPlugin( :returns: TrackInfo object for track """ - artist, artist_id = self.get_artist(track_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit(track_data["artists"]) + artist = ", ".join(artists_names) # Get album information for spotify tracks try: @@ -401,8 +420,10 @@ class SpotifyPlugin( spotify_track_id=track_data["id"], artist=artist, album=album, - artist_id=artist_id, - spotify_artist_id=artist_id, + artist_id=artists_ids[0], + spotify_artist_id=artists_ids[0], + artists=artists_names, + artists_ids=artists_ids, length=track_data["duration_ms"] / 1000, index=track_data["track_number"], medium=track_data["disc_number"], From 963a9692ccd5d3fed56b31deb67bebc32e71d180 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 11:54:12 -0800 Subject: [PATCH 2/5] added tests for multi-artist spotify and lastgenre changes --- beetsplug/spotify.py | 8 +- docs/changelog.rst | 5 + test/plugins/test_lastgenre.py | 50 ++++++- test/plugins/test_spotify.py | 24 +++ test/rsrc/spotify/multi_artist_request.json | 154 ++++++++++++++++++++ 5 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 test/rsrc/spotify/multi_artist_request.json diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ee5069e38..5cc4a836c 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -336,7 +336,9 @@ class SpotifyPlugin( if album_data["name"] == "": self._log.debug("Album removed from Spotify: {}", album_id) return None - artists_names, artists_ids = self._multi_artist_credit(album_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit( + album_data["artists"] + ) artist = ", ".join(artists_names) date_parts = [ @@ -406,7 +408,9 @@ class SpotifyPlugin( :returns: TrackInfo object for track """ - artists_names, artists_ids = self._multi_artist_credit(track_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit( + track_data["artists"] + ) artist = ", ".join(artists_names) # Get album information for spotify tracks diff --git a/docs/changelog.rst b/docs/changelog.rst index 475e56634..f42dc838a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,11 @@ New features: 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/spotify`: Added support for multi-artist albums and tracks, + saving all contributing artists to the respective fields. +- :doc:`plugins/lastgenre`: If looking up a multi-artist album or track, + fallback to searching the individual artists for genres when no results + are found for the combined artist string. Bug fixes: diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 12ff30f8e..c47a54e03 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -546,24 +546,25 @@ class LastGenrePluginTest(PluginTestCase): def test_get_genre(config_values, item_genre, mock_genres, expected_result): """Test _get_genre with various configurations.""" - def mock_fetch_track_genre(self, obj=None): + def mock_fetch_track_genre(obj=None): return mock_genres["track"] - def mock_fetch_album_genre(self, obj): + def mock_fetch_album_genre(obj): return mock_genres["album"] - def mock_fetch_artist_genre(self, obj): + def mock_fetch_artist_genre(obj): return mock_genres["artist"] + # Initialize plugin instance and item + plugin = lastgenre.LastGenrePlugin() + # Mock the last.fm fetchers. When whitelist enabled, we can assume only # whitelisted genres get returned, the plugin's _resolve_genre method # ensures it. - lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre - lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre - lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre + plugin.fetch_track_genre = mock_fetch_track_genre + plugin.fetch_album_genre = mock_fetch_album_genre + plugin.fetch_artist_genre = mock_fetch_artist_genre - # Initialize plugin instance and item - plugin = lastgenre.LastGenrePlugin() # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree @@ -573,3 +574,36 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): # Run res = plugin._get_genre(item) assert res == expected_result + + +def test_multiartist_fallback(): + def mock_lookup(entity, method, *args): + # Only response for the first artist, e.g. no results for the joint + # artist + if entity == "album" and args[0] == "Project Skylate": + return ["Electronic"] + return [] + + plugin = lastgenre.LastGenrePlugin() + plugin._last_lookup = mock_lookup + plugin.config.set( + { + "force": True, + "keep_existing": False, + "source": "album", + "whitelist": True, + "canonical": False, + "count": 5, + } + ) + plugin.setup() + + res = plugin._get_genre( + _common.item( + albumartist="Project Skylate & Sugar Shrill", + albumartists=["Project Skylate", "Sugar Shrill"], + artist="Project Skylate & Sugar Shrill", + artists=["Project Skylate", "Sugar Shrill"], + ) + ) + assert res == ("Electronic", "album, whitelist") diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index bc55485c6..6f90887c0 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -249,3 +249,27 @@ class SpotifyPluginTest(PluginTestCase): query = params["q"][0] assert query.isascii() + + @responses.activate + def test_multi_artist_album(self): + """Tests if plugin is able to map multiple artists in an album""" + + # Mock the Spotify 'Get Album' call + json_file = os.path.join( + _common.RSRC, b"spotify", b"multi_artist_request.json" + ) + with open(json_file, "rb") as f: + response_body = f.read() + + responses.add( + responses.GET, + f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", + body=response_body, + status=200, + content_type="application/json", + ) + + album_info = self.spotify.album_for_id("0yhKyyjyKXWUieJ4w1IAEa") + assert album_info is not None + assert album_info.artist == "Project Skylate, Sugar Shrill" + assert album_info.artists == ["Project Skylate", "Sugar Shrill"] diff --git a/test/rsrc/spotify/multi_artist_request.json b/test/rsrc/spotify/multi_artist_request.json new file mode 100644 index 000000000..8efbc5eef --- /dev/null +++ b/test/rsrc/spotify/multi_artist_request.json @@ -0,0 +1,154 @@ +{ + "album_type": "single", + "total_tracks": 1, + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", + "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", + "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", + "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", + "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", + "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", + "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", + "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", + "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", + "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", + "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", + "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", + "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", + "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", + "LY", "TJ", "VE", "ET", "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/0yhKyyjyKXWUieJ4w1IAEa" + }, + "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa", + "id": "0yhKyyjyKXWUieJ4w1IAEa", + "images": [ + { + "url": "https://i.scdn.co/image/ab67616d0000b2739a26f5e04909c87cead97c77", + "height": 640, + "width": 640 + }, + { + "url": "https://i.scdn.co/image/ab67616d00001e029a26f5e04909c87cead97c77", + "height": 300, + "width": 300 + }, + { + "url": "https://i.scdn.co/image/ab67616d000048519a26f5e04909c87cead97c77", + "height": 64, + "width": 64 + } + ], + "name": "Akiba Night", + "release_date": "2017-12-22", + "release_date_precision": "day", + "type": "album", + "uri": "spotify:album:0yhKyyjyKXWUieJ4w1IAEa", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "6m8MRXIVKb6wQaPlBIDMr1", + "name": "Project Skylate", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "4kkAIoQmNT5xEoNH5BuQLe", + "name": "Sugar Shrill", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "tracks": { + "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa/tracks?offset=0&limit=50", + "limit": 50, + "next": null, + "offset": 0, + "previous": null, + "total": 1, + "items": [ + { + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "6m8MRXIVKb6wQaPlBIDMr1", + "name": "Project Skylate", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "4kkAIoQmNT5xEoNH5BuQLe", + "name": "Sugar Shrill", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", + "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", + "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", + "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", + "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", + "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", + "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", + "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", + "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", + "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", + "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", + "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", + "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", + "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", + "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", + "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK" + ], + "disc_number": 1, + "duration_ms": 225268, + "explicit": false, + "external_urls": { + "spotify": "https://open.spotify.com/track/6sjZfVJworBX6TqyjkxIJ1" + }, + "href": "https://api.spotify.com/v1/tracks/6sjZfVJworBX6TqyjkxIJ1", + "id": "6sjZfVJworBX6TqyjkxIJ1", + "name": "Akiba Nights", + "preview_url": "https://p.scdn.co/mp3-preview/a1c6c0c71f42caff0b19d988849602fefbf7754a?cid=4e414367a1d14c75a5c5129a627fcab8", + "track_number": 1, + "type": "track", + "uri": "spotify:track:6sjZfVJworBX6TqyjkxIJ1", + "is_local": false + } + ] + }, + "copyrights": [ + { + "text": "2017 Sugar Shrill", + "type": "C" + }, + { + "text": "2017 Project Skylate", + "type": "P" + } + ], + "external_ids": { + "upc": "5057728789361" + }, + "genres": [], + "label": "Project Skylate", + "popularity": 21 +} From 01e0aeb662f43d3a0c450661d3e4b44230d64560 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 12:20:05 -0800 Subject: [PATCH 3/5] address linter and ai comments from pr --- beetsplug/lastgenre/__init__.py | 8 ++++---- beetsplug/spotify.py | 14 ++++++++------ docs/changelog.rst | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 0f04b49c3..3873f5f93 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -327,9 +327,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres: return genres - # If not genres found for the joint 'albumartist', try the individual + # If no genres found for the joint 'albumartist', try the individual # album artists if available in 'albumartists'. - if obj.ablumartists and len(obj.albumartists) > 1: + if obj.albumartists and len(obj.albumartists) > 1: for albumartist in obj.albumartists: genre = self._last_lookup( "artist", LASTFM.get_artist, albumartist @@ -345,7 +345,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres: return genres - # If not genres found for the joint 'artist', try the individual + # If no genres found for the joint 'artist', try the individual # album artists if available in 'artists'. if obj.artists and len(obj.artists) > 1: for artist in obj.artists: @@ -362,7 +362,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres: return genres - # If not genres found for the joint 'artist', try the individual + # If no genres found for the joint 'artist', try the individual # album artists if available in 'artists'. if obj.artists and len(obj.artists) > 1: for artist in obj.artists: diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 5cc4a836c..08cf86fd9 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -304,14 +304,16 @@ class SpotifyPlugin( def _multi_artist_credit( self, artists: list[dict[str | int, str]] - ) -> tuple[list[str], list[str]]: - """Given a list representing an ``artist``, accumulate data into a pair + ) -> tuple[list[str], list[str | None]]: + """Given a list of artist dictionaries, accumulate data into a pair of lists: the first being the artist names, and the second being the artist IDs. """ artist_names = [] artist_ids = [] for artist in artists: + # Still use the get_artist helper to handle the artical + # normalization for each individual artist. name, id = self.get_artist([artist]) artist_names.append(name) artist_ids.append(id) @@ -382,8 +384,8 @@ class SpotifyPlugin( album_id=spotify_id, spotify_album_id=spotify_id, artist=artist, - artist_id=artists_ids[0], - spotify_artist_id=artists_ids[0], + artist_id=artists_ids[0] if len(artists_ids) > 0 else None, + spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, artists_ids=artists_ids, tracks=tracks, @@ -424,8 +426,8 @@ class SpotifyPlugin( spotify_track_id=track_data["id"], artist=artist, album=album, - artist_id=artists_ids[0], - spotify_artist_id=artists_ids[0], + artist_id=artists_ids[0] if len(artists_ids) > 0 else None, + spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, artists_ids=artists_ids, length=track_data["duration_ms"] / 1000, diff --git a/docs/changelog.rst b/docs/changelog.rst index f42dc838a..f1de4220d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,7 +34,7 @@ New features: - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, saving all contributing artists to the respective fields. - :doc:`plugins/lastgenre`: If looking up a multi-artist album or track, - fallback to searching the individual artists for genres when no results + fall back to searching the individual artists for genres when no results are found for the combined artist string. Bug fixes: From 9cbbad19f80dc69fd4626be3052ebd0233b9a40a Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 15:57:23 -0800 Subject: [PATCH 4/5] remove changes for lastgenre as there was an existing PR for that work --- beetsplug/lastgenre/__init__.py | 63 +++------------------------------ docs/changelog.rst | 3 -- test/plugins/test_lastgenre.py | 50 +++++--------------------- 3 files changed, 13 insertions(+), 103 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3873f5f93..ea0ab951a 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -302,76 +302,23 @@ class LastGenrePlugin(plugins.BeetsPlugin): def fetch_album_genre(self, obj): """Return raw album genres from Last.fm for this Item or Album.""" - genre = self._last_lookup( + return self._last_lookup( "album", LASTFM.get_album, obj.albumartist, obj.album ) - if genre: - return genre - - # If no genres found for the joint 'albumartist', try the individual - # album artists if available in 'albumartists'. - if obj.albumartists and len(obj.albumartists) > 1: - for albumartist in obj.albumartists: - genre = self._last_lookup( - "album", LASTFM.get_album, albumartist, obj.album - ) - - if genre: - return genre - - return genre def fetch_album_artist_genre(self, obj): """Return raw album artist genres from Last.fm for this Item or Album.""" - genres = self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) - if genres: - return genres + return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) - # If no genres found for the joint 'albumartist', try the individual - # album artists if available in 'albumartists'. - if obj.albumartists and len(obj.albumartists) > 1: - for albumartist in obj.albumartists: - genre = self._last_lookup( - "artist", LASTFM.get_artist, albumartist - ) - - if genre: - return genre - return genres - - def fetch_artist_genre(self, obj): + def fetch_artist_genre(self, item): """Returns raw track artist genres from Last.fm for this Item.""" - genres = self._last_lookup("artist", LASTFM.get_artist, obj.artist) - if genres: - return genres - - # If no genres found for the joint 'artist', try the individual - # album artists if available in 'artists'. - if obj.artists and len(obj.artists) > 1: - for artist in obj.artists: - genre = self._last_lookup("artist", LASTFM.get_artist, artist) - if genre: - return genre - return genres + return self._last_lookup("artist", LASTFM.get_artist, item.artist) def fetch_track_genre(self, obj): """Returns raw track genres from Last.fm for this Item.""" - genres = self._last_lookup( + return self._last_lookup( "track", LASTFM.get_track, obj.artist, obj.title ) - if genres: - return genres - - # If no genres found for the joint 'artist', try the individual - # album artists if available in 'artists'. - if obj.artists and len(obj.artists) > 1: - for artist in obj.artists: - genre = self._last_lookup( - "track", LASTFM.get_track, artist, obj.title - ) - if genre: - return genre - return genres # Main processing: _get_genre() and helpers. diff --git a/docs/changelog.rst b/docs/changelog.rst index f1de4220d..6d37a64a4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -33,9 +33,6 @@ New features: 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. -- :doc:`plugins/lastgenre`: If looking up a multi-artist album or track, - fall back to searching the individual artists for genres when no results - are found for the combined artist string. Bug fixes: diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index c47a54e03..12ff30f8e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -546,25 +546,24 @@ class LastGenrePluginTest(PluginTestCase): def test_get_genre(config_values, item_genre, mock_genres, expected_result): """Test _get_genre with various configurations.""" - def mock_fetch_track_genre(obj=None): + def mock_fetch_track_genre(self, obj=None): return mock_genres["track"] - def mock_fetch_album_genre(obj): + def mock_fetch_album_genre(self, obj): return mock_genres["album"] - def mock_fetch_artist_genre(obj): + def mock_fetch_artist_genre(self, obj): return mock_genres["artist"] - # Initialize plugin instance and item - plugin = lastgenre.LastGenrePlugin() - # Mock the last.fm fetchers. When whitelist enabled, we can assume only # whitelisted genres get returned, the plugin's _resolve_genre method # ensures it. - plugin.fetch_track_genre = mock_fetch_track_genre - plugin.fetch_album_genre = mock_fetch_album_genre - plugin.fetch_artist_genre = mock_fetch_artist_genre + lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre + lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre + lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre + # Initialize plugin instance and item + plugin = lastgenre.LastGenrePlugin() # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree @@ -574,36 +573,3 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): # Run res = plugin._get_genre(item) assert res == expected_result - - -def test_multiartist_fallback(): - def mock_lookup(entity, method, *args): - # Only response for the first artist, e.g. no results for the joint - # artist - if entity == "album" and args[0] == "Project Skylate": - return ["Electronic"] - return [] - - plugin = lastgenre.LastGenrePlugin() - plugin._last_lookup = mock_lookup - plugin.config.set( - { - "force": True, - "keep_existing": False, - "source": "album", - "whitelist": True, - "canonical": False, - "count": 5, - } - ) - plugin.setup() - - res = plugin._get_genre( - _common.item( - albumartist="Project Skylate & Sugar Shrill", - albumartists=["Project Skylate", "Sugar Shrill"], - artist="Project Skylate & Sugar Shrill", - artists=["Project Skylate", "Sugar Shrill"], - ) - ) - assert res == ("Electronic", "album, whitelist") From a7170fae45bd15eaeccf87ef4035990180a5b1d3 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Thu, 18 Dec 2025 16:23:58 -0800 Subject: [PATCH 5/5] expand tests to include check for track artists --- beetsplug/spotify.py | 9 +- test/plugins/test_spotify.py | 44 +++++- ...st_request.json => multiartist_album.json} | 8 +- test/rsrc/spotify/multiartist_track.json | 131 ++++++++++++++++++ 4 files changed, 177 insertions(+), 15 deletions(-) rename test/rsrc/spotify/{multi_artist_request.json => multiartist_album.json} (97%) create mode 100644 test/rsrc/spotify/multiartist_track.json diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 08cf86fd9..6f85b1397 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -304,7 +304,7 @@ class SpotifyPlugin( def _multi_artist_credit( self, artists: list[dict[str | int, str]] - ) -> tuple[list[str], list[str | None]]: + ) -> tuple[list[str], list[str]]: """Given a list of artist dictionaries, accumulate data into a pair of lists: the first being the artist names, and the second being the artist IDs. @@ -312,11 +312,8 @@ class SpotifyPlugin( artist_names = [] artist_ids = [] for artist in artists: - # Still use the get_artist helper to handle the artical - # normalization for each individual artist. - name, id = self.get_artist([artist]) - artist_names.append(name) - artist_ids.append(id) + artist_names.append(artist["name"]) + artist_ids.append(artist["id"]) return artist_names, artist_ids def album_for_id(self, album_id: str) -> AlbumInfo | None: diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 6f90887c0..6e322ca0b 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -251,20 +251,36 @@ class SpotifyPluginTest(PluginTestCase): assert query.isascii() @responses.activate - def test_multi_artist_album(self): - """Tests if plugin is able to map multiple artists in an album""" + def test_multiartist_album_and_track(self): + """Tests if plugin is able to map multiple artists in an album and + track info correctly""" # Mock the Spotify 'Get Album' call json_file = os.path.join( - _common.RSRC, b"spotify", b"multi_artist_request.json" + _common.RSRC, b"spotify", b"multiartist_album.json" ) with open(json_file, "rb") as f: - response_body = f.read() + album_response_body = f.read() responses.add( responses.GET, f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", - body=response_body, + body=album_response_body, + status=200, + content_type="application/json", + ) + + # Mock the Spotify 'Get Track' call + json_file = os.path.join( + _common.RSRC, b"spotify", b"multiartist_track.json" + ) + with open(json_file, "rb") as f: + track_response_body = f.read() + + responses.add( + responses.GET, + f"{spotify.SpotifyPlugin.track_url}6sjZfVJworBX6TqyjkxIJ1", + body=track_response_body, status=200, content_type="application/json", ) @@ -273,3 +289,21 @@ class SpotifyPluginTest(PluginTestCase): assert album_info is not None assert album_info.artist == "Project Skylate, Sugar Shrill" assert album_info.artists == ["Project Skylate", "Sugar Shrill"] + assert album_info.artist_id == "6m8MRXIVKb6wQaPlBIDMr1" + assert album_info.artists_ids == [ + "6m8MRXIVKb6wQaPlBIDMr1", + "4kkAIoQmNT5xEoNH5BuQLe", + ] + + assert len(album_info.tracks) == 1 + assert album_info.tracks[0].artist == "Foo, Bar" + assert album_info.tracks[0].artists == ["Foo", "Bar"] + assert album_info.tracks[0].artist_id == "12345" + assert album_info.tracks[0].artists_ids == ["12345", "67890"] + + track_info = self.spotify.track_for_id("6sjZfVJworBX6TqyjkxIJ1") + assert track_info is not None + assert track_info.artist == "Foo, Bar" + assert track_info.artists == ["Foo", "Bar"] + assert track_info.artist_id == "12345" + assert track_info.artists_ids == ["12345", "67890"] diff --git a/test/rsrc/spotify/multi_artist_request.json b/test/rsrc/spotify/multiartist_album.json similarity index 97% rename from test/rsrc/spotify/multi_artist_request.json rename to test/rsrc/spotify/multiartist_album.json index 8efbc5eef..9aef25f10 100644 --- a/test/rsrc/spotify/multi_artist_request.json +++ b/test/rsrc/spotify/multiartist_album.json @@ -83,8 +83,8 @@ "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" }, "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", - "id": "6m8MRXIVKb6wQaPlBIDMr1", - "name": "Project Skylate", + "id": "12345", + "name": "Foo", "type": "artist", "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" }, @@ -93,8 +93,8 @@ "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" }, "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", - "id": "4kkAIoQmNT5xEoNH5BuQLe", - "name": "Sugar Shrill", + "id": "67890", + "name": "Bar", "type": "artist", "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" } diff --git a/test/rsrc/spotify/multiartist_track.json b/test/rsrc/spotify/multiartist_track.json new file mode 100644 index 000000000..e77acee9e --- /dev/null +++ b/test/rsrc/spotify/multiartist_track.json @@ -0,0 +1,131 @@ +{ + "album": { + "album_type": "single", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "6m8MRXIVKb6wQaPlBIDMr1", + "name": "Project Skylate", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "4kkAIoQmNT5xEoNH5BuQLe", + "name": "Sugar Shrill", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", + "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", + "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", + "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", + "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", + "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", + "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", + "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", + "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", + "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", + "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", + "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", + "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", + "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", + "LY", "TJ", "VE", "ET", "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/0yhKyyjyKXWUieJ4w1IAEa" + }, + "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa", + "id": "0yhKyyjyKXWUieJ4w1IAEa", + "images": [ + { + "url": "https://i.scdn.co/image/ab67616d0000b2739a26f5e04909c87cead97c77", + "width": 640, + "height": 640 + }, + { + "url": "https://i.scdn.co/image/ab67616d00001e029a26f5e04909c87cead97c77", + "width": 300, + "height": 300 + }, + { + "url": "https://i.scdn.co/image/ab67616d000048519a26f5e04909c87cead97c77", + "width": 64, + "height": 64 + } + ], + "name": "Akiba Night", + "release_date": "2017-12-22", + "release_date_precision": "day", + "total_tracks": 1, + "type": "album", + "uri": "spotify:album:0yhKyyjyKXWUieJ4w1IAEa" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "12345", + "name": "Foo", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "67890", + "name": "Bar", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", + "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", + "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", + "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", + "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", + "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", + "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", + "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", + "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", + "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", + "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", + "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", + "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", + "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", + "LY", "TJ", "VE", "ET", "XK" + ], + "disc_number": 1, + "duration_ms": 225268, + "explicit": false, + "external_ids": { + "isrc": "GB-SMU-45-66095" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/6sjZfVJworBX6TqyjkxIJ1" + }, + "href": "https://api.spotify.com/v1/tracks/6sjZfVJworBX6TqyjkxIJ1", + "id": "6sjZfVJworBX6TqyjkxIJ1", + "is_local": false, + "name": "Akiba Nights", + "popularity": 29, + "preview_url": "https://p.scdn.co/mp3-preview/a1c6c0c71f42caff0b19d988849602fefbf7754a?cid=4e414367a1d14c75a5c5129a627fcab8", + "track_number": 1, + "type": "track", + "uri": "spotify:track:6sjZfVJworBX6TqyjkxIJ1" +}