diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index b3c653682..6f85b1397 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -302,6 +302,20 @@ 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 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: + 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: """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. @@ -321,7 +335,10 @@ 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 +381,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] 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, albumtype=album_data["album_type"], va=len(album_data["artists"]) == 1 @@ -388,7 +407,10 @@ 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 +423,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] 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, index=track_data["track_number"], medium=track_data["disc_number"], diff --git a/docs/changelog.rst b/docs/changelog.rst index 475e56634..6d37a64a4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,8 @@ 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. Bug fixes: diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index bc55485c6..6e322ca0b 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -249,3 +249,61 @@ class SpotifyPluginTest(PluginTestCase): query = params["q"][0] assert query.isascii() + + @responses.activate + 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"multiartist_album.json" + ) + with open(json_file, "rb") as f: + album_response_body = f.read() + + responses.add( + responses.GET, + f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", + 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", + ) + + 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"] + 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/multiartist_album.json b/test/rsrc/spotify/multiartist_album.json new file mode 100644 index 000000000..9aef25f10 --- /dev/null +++ b/test/rsrc/spotify/multiartist_album.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": "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_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 +} 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" +}