From dd6cb538ac343c837348a0e4b404bc994ed97db1 Mon Sep 17 00:00:00 2001 From: dhruvravii <122979040+dhruvravii@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:38:54 +0530 Subject: [PATCH] Fix: Spotify plugin unable to recognize Chinese and Japanese albums. (#5705) Fixes an issue where each spotify query was converted to ascii before sending. Adds a new config option to enable legacy behaviour. A file called japanese_track_request.json was made to mimic the Spotify API response since I don't have the credentials. Entries in that will need to be modified with the actual entries. Co-authored-by: Sebastian Mohr Co-authored-by: Sebastian Mohr <39738318+semohr@users.noreply.github.com> Co-authored-by: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> --- beetsplug/spotify.py | 12 ++- docs/changelog.rst | 7 +- docs/plugins/spotify.rst | 8 ++ test/plugins/test_spotify.py | 79 +++++++++++++++- test/rsrc/spotify/japanese_track_request.json | 89 +++++++++++++++++++ 5 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 test/rsrc/spotify/japanese_track_request.json diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 9d285928a..76ceeed68 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -106,6 +106,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): "client_id": "4e414367a1d14c75a5c5129a627fcab8", "client_secret": "f82bdc09b2254f1a8286815d02fd46dc", "tokenfile": "spotify_token.json", + "search_query_ascii": False, } ) self.config["client_id"].redact = True @@ -388,9 +389,8 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): track.medium_total = medium_total return track - @staticmethod def _construct_search_query( - filters: dict[str, str], keywords: str = "" + self, filters: dict[str, str], keywords: str = "" ) -> str: """Construct a query string with the specified filters and keywords to be provided to the Spotify Search API @@ -407,7 +407,11 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): query = " ".join([q for q in query_components if q]) if not isinstance(query, str): query = query.decode("utf8") - return unidecode.unidecode(query) + + if self.config["search_query_ascii"].get(): + query = unidecode.unidecode(query) + + return query def _search_api( self, @@ -424,6 +428,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): :param keywords: (Optional) Query keywords to use. """ query = self._construct_search_query(keywords=keywords, filters=filters) + self._log.debug(f"Searching {self.data_source} for '{query}'") try: response = self._handle_response( @@ -560,6 +565,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): query = self._construct_search_query( keywords=keywords, filters=query_filters ) + failures.append(query) continue diff --git a/docs/changelog.rst b/docs/changelog.rst index 88b82e4da..1baa54011 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,7 +39,12 @@ Bug fixes: :bug:`5797` * :doc:`plugins/musicbrainz`: Fix the MusicBrainz search not taking into account the album/recording aliases - +* :doc:`/plugins/spotify`: Fix the issue with that every query to spotify was + ascii encoded. This resulted in bad matches for queries that contained special + e.g. non latin characters as 盗作. If you want to keep the legacy behavior + set the config option ``spotify.search_query_ascii: yes``. + :bug:`5699` + For packagers: * Optional ``extra_tags`` parameter has been removed from diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 233d00726..c5aff8ef3 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -83,6 +83,13 @@ in config.yaml under the ``spotify:`` section: track/album/artist fields before sending them to Spotify. Can be useful for changing certain abbreviations, like ft. -> feat. See the examples below. Default: None. +- **search_query_ascii**: If set to ``yes``, the search query will be converted to + ASCII before being sent to Spotify. Converting searches to ASCII can + enhance search results in some cases, but in general, it is not recommended. + For instance `artist:deadmau5 album:4×4` will be converted to + `artist:deadmau5 album:4x4` (notice `×!=x`). + Default: ``no``. + Here's an example:: @@ -92,6 +99,7 @@ Here's an example:: region_filter: US show_failures: on tiebreak: first + search_query_ascii: no regex: [ { diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index a2336df10..a2fb26f4b 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -7,7 +7,7 @@ import responses from beets.library import Item from beets.test import _common -from beets.test.helper import BeetsTestCase +from beets.test.helper import PluginTestCase from beetsplug import spotify @@ -23,10 +23,11 @@ def _params(url): return parse_qs(urlparse(url).query) -class SpotifyPluginTest(BeetsTestCase): +class SpotifyPluginTest(PluginTestCase): + plugin = "spotify" + @responses.activate def setUp(self): - super().setUp() responses.add( responses.POST, spotify.SpotifyPlugin.oauth_token_url, @@ -39,6 +40,7 @@ class SpotifyPluginTest(BeetsTestCase): "scope": "", }, ) + super().setUp() self.spotify = spotify.SpotifyPlugin() opts = ArgumentsMock("list", False) self.spotify._parse_opts(opts) @@ -176,3 +178,74 @@ class SpotifyPluginTest(BeetsTestCase): results = self.spotify._match_library_tracks(self.lib, "Happy") assert 1 == len(results) assert "6NPVjNh8Jhru9xOmyQigds" == results[0]["id"] + + @responses.activate + def test_japanese_track(self): + """Ensure non-ASCII characters remain unchanged in search queries""" + + # Path to the mock JSON file for the Japanese track + json_file = os.path.join( + _common.RSRC, b"spotify", b"japanese_track_request.json" + ) + + # Load the mock JSON response + with open(json_file, "rb") as f: + response_body = f.read() + + # Mock Spotify Search API response + responses.add( + responses.GET, + spotify.SpotifyPlugin.search_url, + body=response_body, + status=200, + content_type="application/json", + ) + + # Create a mock item with Japanese metadata + item = Item( + mb_trackid="56789", + album="盗作", + albumartist="ヨルシカ", + title="思想犯", + length=10, + ) + item.add(self.lib) + + # Search without ascii encoding + + with self.configure_plugin( + { + "search_query_ascii": False, + } + ): + assert self.spotify.config["search_query_ascii"].get() is False + # Call the method to match library tracks + results = self.spotify._match_library_tracks(self.lib, item.title) + + # Assertions to verify results + assert results is not None + assert 1 == len(results) + assert results[0]["name"] == item.title + assert results[0]["artists"][0]["name"] == item.albumartist + assert results[0]["album"]["name"] == item.album + + # Verify search query parameters + params = _params(responses.calls[0].request.url) + query = params["q"][0] + assert item.title in query + assert f"artist:{item.albumartist}" in query + assert f"album:{item.album}" in query + assert not query.isascii() + + # Is not found in the library if ascii encoding is enabled + with self.configure_plugin( + { + "search_query_ascii": True, + } + ): + assert self.spotify.config["search_query_ascii"].get() is True + results = self.spotify._match_library_tracks(self.lib, item.title) + params = _params(responses.calls[1].request.url) + query = params["q"][0] + + assert query.isascii() diff --git a/test/rsrc/spotify/japanese_track_request.json b/test/rsrc/spotify/japanese_track_request.json new file mode 100644 index 000000000..04559588e --- /dev/null +++ b/test/rsrc/spotify/japanese_track_request.json @@ -0,0 +1,89 @@ +{ + "tracks":{ + "href":"https://api.spotify.com/v1/search?query=Happy+album%3ADespicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20&type=track", + "items":[ + { + "album":{ + "album_type":"compilation", + "available_markets":[ + "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", + "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", + "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", + "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", + "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", + "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", + "SK", "SV", "TR", "TW", "US", "UY" + ], + "external_urls":{ + "spotify":"https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" + }, + "href":"https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", + "id":"5l3zEmMrOhOzG8d8s83GOL", + "images":[ + { + "height":640, + "width":640, + "url":"https://i.scdn.co/image/cb7905340c132365bbaee3f17498f062858382e8" + }, + { + "height":300, + "width":300, + "url":"https://i.scdn.co/image/af369120f0b20099d6784ab31c88256113f10ffb" + }, + { + "height":64, + "width":64, + "url":"https://i.scdn.co/image/9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8" + } + ], + "name":"盗作", + "type":"album", + "uri":"spotify:album:5l3zEmMrOhOzG8d8s83GOL" + }, + "artists":[ + { + "external_urls":{ + "spotify":"https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" + }, + "href":"https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", + "id":"2RdwBSPQiwcmiDo9kixcl8", + "name":"ヨルシカ", + "type":"artist", + "uri":"spotify:artist:2RdwBSPQiwcmiDo9kixcl8" + } + ], + "available_markets":[ + "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", + "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", + "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", + "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", + "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", + "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", + "SK", "SV", "TR", "TW", "US", "UY" + ], + "disc_number":1, + "duration_ms":233305, + "explicit":false, + "external_ids":{ + "isrc":"USQ4E1300686" + }, + "external_urls":{ + "spotify":"https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" + }, + "href":"https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", + "id":"6NPVjNh8Jhru9xOmyQigds", + "name":"思想犯", + "popularity":89, + "preview_url":"https://p.scdn.co/mp3-preview/6b00000be293e6b25f61c33e206a0c522b5cbc87", + "track_number":4, + "type":"track", + "uri":"spotify:track:6NPVjNh8Jhru9xOmyQigds" + } + ], + "limit":20, + "next":null, + "offset":0, + "previous":null, + "total":1 + } +}