diff --git a/shazamio/api.py b/shazamio/api.py
index 6333a2a..4a7a1d5 100644
--- a/shazamio/api.py
+++ b/shazamio/api.py
@@ -1,9 +1,11 @@
import pathlib
+import re
import time
import uuid
from typing import Dict, Any, Union, List
from typing import Optional
+import aiohttp
from aiohttp_retry import ExponentialRetry
from pydub import AudioSegment
from shazamio_core import Recognizer, Signature, SearchParams
@@ -59,7 +61,7 @@ async def top_world_tracks(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Search top world tracks
+ Search top world tracks via the Shazam charts CSV endpoint.
:param limit: Determines how many songs the maximum can be in the request.
Example: If 5 is specified, the query will return no more than 5 songs.
@@ -67,56 +69,103 @@ async def top_world_tracks(
The default is 0. If you want to skip the first few songs, set this parameter to
your own.
:param proxy: Proxy server
- :return: dict tracks
+ :return: dict with 'tracks' list containing rank, title, subtitle
"""
-
- top_playlist_id = await self.geo_service.get_top()
- return await self.http_client.request(
- "GET",
- ShazamUrl.TOP_TRACKS_PLAYLIST.format(
- playlist_id=top_playlist_id,
- language=self.language,
- endpoint_country=self.endpoint_country,
- limit=limit,
- offset=offset,
- ),
+ result = await self.http_client.request_csv(
+ ShazamUrl.CHART_CSV.format(chart_path="top-200/world"),
headers=self.headers(),
proxy=proxy,
)
+ tracks = result.get("tracks", [])
+ if offset:
+ tracks = tracks[offset:]
+ if limit:
+ tracks = tracks[:limit]
+ return {"tracks": tracks}
+
+ async def _resolve_artist_name(self, artist_id: int) -> str:
+ """
+ Resolve an artist name from their Apple Music ID by fetching
+ the Shazam artist web page and extracting the name from the title tag.
+ """
+ url = f"https://www.shazam.com/artist/_/{artist_id}"
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ url,
+ headers={
+ "User-Agent": (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36"
+ ),
+ "Accept": "text/html",
+ },
+ ) as resp:
+ if resp.status != 200:
+ raise ValueError(
+ f"Could not resolve artist name for ID {artist_id}: " f"HTTP {resp.status}"
+ )
+ body = await resp.text()
+ match = re.search(r"
(.*?)\s*-\s*Shazam", body)
+ if not match:
+ raise ValueError(
+ f"Could not extract artist name from Shazam page for ID {artist_id}"
+ )
+ return match.group(1).strip()
async def artist_about(
self,
- artist_id: int,
+ artist_id: int = 0,
query: Optional[ArtistQuery] = None,
proxy: Optional[str] = None,
+ artist_name: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Retrieving information from an artist profile
+ Retrieving information from an artist profile via Apple Music catalog search.
+
+ The direct artist lookup endpoint (amapi/v1/catalog/{country}/artists/{id})
+ was shut down by Shazam. This method now resolves the artist name from the
+ Shazam web page and searches via the catalog search API.
- :param artist_id: Artist number. Example (203347991)
- :param query: Foo
- https://www.shazam.com/artist/203347991/
+ :param artist_id: Artist number. Example (203347991). The name will be
+ resolved automatically from the Shazam artist page.
+ :param query: Optional ArtistQuery with views and extend parameters.
:param proxy: Proxy server
- :return: dict about artist
+ :param artist_name: Optional artist name. If provided, skips the name
+ resolution step and searches directly.
+ :return: dict about artist (compatible with Serialize.artist_v2)
"""
+ if not artist_name:
+ if not artist_id:
+ raise ValueError("Either artist_id or artist_name must be provided.")
+ artist_name = await self._resolve_artist_name(artist_id)
+ params = {}
if query:
pg = ArtistQueryGenerator(source=query)
- params_dict = pg.params()
- else:
- params_dict = {}
+ params = pg.params()
+
+ url = ShazamUrl.SEARCH_AMAPI.format(
+ endpoint_country=self.endpoint_country,
+ query=artist_name,
+ limit=1,
+ offset=0,
+ types="artists",
+ )
- return await self.http_client.request(
+ # Add extend params if present in the query
+ if params.get("extend"):
+ url += f"&extend={params['extend']}"
+
+ result = await self.http_client.request(
"GET",
- ShazamUrl.SEARCH_ARTIST_V2.format(
- endpoint_country=self.endpoint_country,
- artist_id=artist_id,
- ),
- params=params_dict,
+ url,
headers=self.headers(),
proxy=proxy,
)
+ # Return in {"data": [...]} format matching the old response structure
+ artists_data = result.get("results", {}).get("artists", {}).get("data", [])
+ return {"data": artists_data}
+
async def track_about(
self,
track_id: int,
@@ -149,7 +198,7 @@ async def top_country_tracks(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Get the best tracks by country code
+ Get the best tracks by country code via Shazam charts CSV endpoint.
https://www.shazam.com/charts/discovery/netherlands
:param country_code: ISO 3166-3 alpha-2 code. Example: RU,NL,UA
@@ -159,25 +208,23 @@ async def top_country_tracks(
The default is 0. If you want to skip the first few songs, set this parameter to
your own.
:param proxy: Proxy server
- :return: dict songs
+ :return: dict with 'tracks' list containing rank, title, subtitle
"""
- country_playlist_id = await self.geo_service.get_country_playlist(
+ country_url_name = await self.geo_service.get_country_url_name(
country=CountryCode(country_code),
)
- return await self.http_client.request(
- "GET",
- ShazamUrl.TOP_TRACKS_PLAYLIST.format(
- playlist_id=country_playlist_id,
- language=self.language,
- endpoint_country=self.endpoint_country,
- country_code=country_code,
- limit=limit,
- offset=offset,
- ),
+ result = await self.http_client.request_csv(
+ ShazamUrl.CHART_CSV.format(chart_path=f"top-200/{country_url_name}"),
headers=self.headers(),
proxy=proxy,
)
+ tracks = result.get("tracks", [])
+ if offset:
+ tracks = tracks[offset:]
+ if limit:
+ tracks = tracks[:limit]
+ return {"tracks": tracks}
async def top_city_tracks(
self,
@@ -188,7 +235,7 @@ async def top_city_tracks(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Retrieving information from an artist profile
+ Get top tracks for a specific city via Shazam charts CSV endpoint.
https://www.shazam.com/charts/top-50/russia/moscow
:param country_code: ISO 3166-3 alpha-2 code. Example: RU,NL,UA
@@ -201,26 +248,24 @@ async def top_city_tracks(
your own.
:param proxy: Proxy server
- :return: dict songs
+ :return: dict with 'tracks' list containing rank, title, subtitle
"""
- city_playlist_id = await self.geo_service.get_city_playlist(
+ city_url_path = await self.geo_service.get_city_url_name(
country=CountryCode(country_code),
city=city_name,
)
- return await self.http_client.request(
- "GET",
- ShazamUrl.TOP_TRACKS_PLAYLIST.format(
- playlist_id=city_playlist_id,
- language=self.language,
- endpoint_country=self.endpoint_country,
- country_code=country_code,
- limit=limit,
- offset=offset,
- ),
+ result = await self.http_client.request_csv(
+ ShazamUrl.CHART_CSV.format(chart_path=f"top-50/{city_url_path}"),
headers=self.headers(),
proxy=proxy,
)
+ tracks = result.get("tracks", [])
+ if offset:
+ tracks = tracks[offset:]
+ if limit:
+ tracks = tracks[:limit]
+ return {"tracks": tracks}
async def top_world_genre_tracks(
self,
@@ -230,7 +275,7 @@ async def top_world_genre_tracks(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Get world tracks by certain genre
+ Get world tracks by certain genre via Shazam charts CSV endpoint.
https://www.shazam.com/charts/genre/world/rock
:param genre: Genre urlName from https://www.shazam.com/services/charts/locations
@@ -240,25 +285,24 @@ async def top_world_genre_tracks(
The default is 0. If you want to skip the first few songs, set this parameter
to your own.
:param proxy: Proxy server
- :return: dict songs
+ :return: dict with 'tracks' list containing rank, title, subtitle
"""
if isinstance(genre, str):
genre = GenreMusic(genre)
- genre_playlist_id = await self.geo_service.get_genre(genre=genre)
- return await self.http_client.request(
- "GET",
- ShazamUrl.TOP_TRACKS_PLAYLIST.format(
- playlist_id=genre_playlist_id,
- language=self.language,
- endpoint_country=self.endpoint_country,
- limit=limit,
- offset=offset,
- ),
+ genre_url_name = await self.geo_service.get_genre_url_name(genre=genre)
+ result = await self.http_client.request_csv(
+ ShazamUrl.CHART_CSV.format(chart_path=f"genre/world/{genre_url_name}"),
headers=self.headers(),
proxy=proxy,
)
+ tracks = result.get("tracks", [])
+ if offset:
+ tracks = tracks[offset:]
+ if limit:
+ tracks = tracks[:limit]
+ return {"tracks": tracks}
async def top_country_genre_tracks(
self,
@@ -269,7 +313,7 @@ async def top_country_genre_tracks(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- The best tracks by a genre in the country
+ The best tracks by a genre in the country via Shazam charts CSV endpoint.
https://www.shazam.com/charts/genre/spain/hip-hop-rap
:param country_code: ISO 3166-3 alpha-2 code. Example: RU,NL,UA
:param genre: Genre name or ID:
@@ -285,29 +329,27 @@ async def top_country_genre_tracks(
The default is 0. If you want to skip the first few songs, set this parameter to
your own.
:param proxy: Proxy server
- :return: dict songs
+ :return: dict with 'tracks' list containing rank, title, subtitle
"""
if isinstance(genre, str):
genre = GenreMusic(genre)
- genre_playlist_id = await self.geo_service.get_genre_from_country(
+ country_genre_url_path = await self.geo_service.get_country_genre_url_name(
country=CountryCode(country_code),
genre=genre,
)
- return await self.http_client.request(
- "GET",
- ShazamUrl.TOP_TRACKS_PLAYLIST.format(
- playlist_id=genre_playlist_id,
- language=self.language,
- endpoint_country=self.endpoint_country,
- country_code=country_code,
- limit=limit,
- offset=offset,
- ),
+ result = await self.http_client.request_csv(
+ ShazamUrl.CHART_CSV.format(chart_path=f"genre/{country_genre_url_path}"),
headers=self.headers(),
proxy=proxy,
)
+ tracks = result.get("tracks", [])
+ if offset:
+ tracks = tracks[offset:]
+ if limit:
+ tracks = tracks[:limit]
+ return {"tracks": tracks}
async def related_tracks(
self,
@@ -350,7 +392,7 @@ async def search_artist(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Search all artists by prefix or fullname
+ Search all artists by prefix or fullname via Apple Music catalog search.
:param query: Artist name or search prefix
:param limit: Determines how many artists the maximum can be in the request.
Example: If 5 is specified, the query will return no more than 5 artists.
@@ -358,16 +400,16 @@ async def search_artist(
The default is 0. If you want to skip the first few songs, set this parameter to
your own.
:param proxy: Proxy server
- :return: dict artists
+ :return: dict artists (Apple Music catalog format)
"""
return await self.http_client.request(
"GET",
- ShazamUrl.SEARCH_ARTIST.format(
- language=self.language,
+ ShazamUrl.SEARCH_AMAPI.format(
endpoint_country=self.endpoint_country,
limit=limit,
offset=offset,
query=query,
+ types="artists",
),
headers=self.headers(),
proxy=proxy,
@@ -381,7 +423,7 @@ async def search_track(
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
- Search all tracks by prefix
+ Search all tracks by prefix via Apple Music catalog search.
:param query: Track full title or prefix title
:param limit: Determines how many songs the maximum can be in the request.
Example: If 5 is specified, the query will return no more than 5 songs.
@@ -389,30 +431,32 @@ async def search_track(
The default is 0. If you want to skip the first few songs, set this parameter to
your own.
:param proxy: Proxy server
- :return: dict songs
+ :return: dict songs (Apple Music catalog format)
"""
return await self.http_client.request(
"GET",
- ShazamUrl.SEARCH_MUSIC.format(
- language=self.language,
+ ShazamUrl.SEARCH_AMAPI.format(
endpoint_country=self.endpoint_country,
limit=limit,
offset=offset,
query=query,
+ types="songs",
),
headers=self.headers(),
proxy=proxy,
)
+ @deprecated("The listening counter API has been shut down by Shazam")
async def listening_counter(
self,
track_id: int,
proxy: Optional[str] = None,
) -> Dict[str, Any]:
"""
+ DEPRECATED: This endpoint has been shut down by Shazam (returns 404).
+
Returns the total track listener counter.
:param track_id: Track number. Example: (559284007)
- https://www.shazam.com/track/559284007/rampampam
:param proxy: Proxy server
:return: The data dictionary that contains the listen counter.
"""
@@ -427,15 +471,17 @@ async def listening_counter(
proxy=proxy,
)
+ @deprecated("The listening counter API has been shut down by Shazam")
async def listening_counter_many(
self,
track_ids: List[int],
proxy: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
+ DEPRECATED: This endpoint has been shut down by Shazam (returns 404).
+
Returns the total track listener counter.
:param track_ids: Track numbers (list). Example: ([559284007])
- https://www.shazam.com/track/559284007/rampampam
:param proxy: Proxy server
:return: The data dictionary that contains the listen counter.
"""
@@ -447,6 +493,10 @@ async def listening_counter_many(
proxy=proxy,
)
+ @deprecated(
+ "The direct artist albums API has been shut down by Shazam. "
+ "Use search_artist() with include[artists]=albums instead."
+ )
async def artist_albums(
self,
artist_id: int,
@@ -455,14 +505,13 @@ async def artist_albums(
proxy: Optional[str] = None,
):
"""
- Get all albums of a specific artist
+ DEPRECATED: The direct amapi endpoint has been shut down by Shazam (returns 405).
+
+ Get all albums of a specific artist.
:param artist_id: Artist number. Example (203347991)
- :param limit: Determines how many songs the maximum can be in the request.
- Example: If 5 is specified, the query will return no more than 5 songs.
- :param offset: A parameter that determines with which song to display the request.
- The default is 0. If you want to skip the first few songs, set this parameter to
- your own.
+ :param limit: Max number of results.
+ :param offset: Starting position.
:param proxy: Proxy server
:return: dict albums
"""
@@ -479,13 +528,19 @@ async def artist_albums(
proxy=proxy,
)
+ @deprecated(
+ "The direct album info API has been shut down by Shazam. "
+ "Use search_track() to find album information instead."
+ )
async def search_album(
self,
album_id: int,
proxy: Optional[str] = None,
):
"""
- Get album info by id
+ DEPRECATED: The direct amapi endpoint has been shut down by Shazam (returns 405).
+
+ Get album info by id.
:param album_id: Album number. Example (203347991)
:param proxy: Proxy server
diff --git a/shazamio/client.py b/shazamio/client.py
index 0925103..0db7be6 100644
--- a/shazamio/client.py
+++ b/shazamio/client.py
@@ -7,7 +7,7 @@
from shazamio.exceptions import BadMethod, FailedDecodeJson
from shazamio.interfaces.client import HTTPClientInterface
from shazamio.loggers import request as request_logger
-from shazamio.utils import validate_json
+from shazamio.utils import validate_csv, validate_json
class HTTPClient(HTTPClientInterface):
@@ -61,3 +61,17 @@ async def request(
raise e
else:
raise BadMethod("Accept only GET/POST")
+
+ async def request_csv(
+ self,
+ url: str,
+ **kwargs,
+ ) -> Dict[str, Any]:
+ """Fetch a CSV endpoint and parse the response into a dict."""
+ async with RetryClient(
+ retry_options=self.retry_options,
+ raise_for_status=False,
+ trace_configs=[self.trace_config],
+ ) as client:
+ async with client.get(url, **kwargs) as resp:
+ return await validate_csv(resp)
diff --git a/shazamio/converter.py b/shazamio/converter.py
index 769a704..675e8c5 100644
--- a/shazamio/converter.py
+++ b/shazamio/converter.py
@@ -13,29 +13,91 @@
class GeoService:
def __init__(self, client: HTTPClientInterface):
self.client = client
+ self._locations_cache = None
- async def get_country_playlist(self, country: CountryCode) -> str:
+ async def _get_locations(self):
+ """Fetch and cache the locations data."""
+ if self._locations_cache is None:
+ self._locations_cache = await self.client.request(
+ "GET", ShazamUrl.LOCATIONS, "application/json"
+ )
+ return self._locations_cache
+
+ async def get_country_url_name(self, country: CountryCode) -> str:
"""
- Return Country playlistID from country name
- :param country: - Country code, format: ISO 3166-3 alpha-2 code. Example: RU,NL,UA
- :return: City ID
+ Return the URL-friendly country name from country code.
+ :param country: ISO 3166-3 alpha-2 code. Example: RU, NL, ES
+ :return: URL name (e.g. 'spain', 'russia')
"""
+ data = await self._get_locations()
+ for response_country in data["countries"]:
+ if country == response_country["id"]:
+ return response_country["urlName"]
+ raise BadCountryName("Country not found, check country code")
- data = await self.client.request("GET", ShazamUrl.LOCATIONS, "application/json")
+ async def get_city_url_name(self, country: CountryCode, city: str) -> str:
+ """
+ Return the URL-friendly city name from country code and city name.
+ :param country: ISO 3166-3 alpha-2 code
+ :param city: City name (e.g. 'Moscow', 'Barcelona')
+ :return: URL name (e.g. 'moscow', 'barcelona')
+ """
+ data = await self._get_locations()
for response_country in data["countries"]:
if country == response_country["id"]:
- return response_country["listid"]
- raise BadCountryName("Country not found, check city name")
+ country_url_name = response_country["urlName"]
+ for response_city in response_country["cities"]:
+ if city == response_city["name"]:
+ return f"{country_url_name}/{response_city['urlName']}"
+ raise BadCityName("City not found, check city name")
- async def get_city_playlist(self, country: CountryCode, city: str) -> str:
+ async def get_genre_url_name(self, genre: GenreMusic) -> str:
"""
- Return playlistID from country name and city name.
- :param country: - Country name
- :param city: - City name
- :return: City ID
+ Return the URL-friendly genre name for global genre charts.
+ :param genre: Genre enum value
+ :return: URL name (e.g. 'rock', 'hip-hop-rap')
"""
+ data = await self._get_locations()
+ global_data = data.get("global")
+ if not global_data:
+ raise BadParseData("Global key not found in shazam locations")
+ global_genres = global_data.get("genres")
+ if not global_genres:
+ raise BadParseData("Genres key not found in shazam locations")
+ for response_genre in global_genres:
+ if genre.value == response_genre["urlName"]:
+ return response_genre["urlName"]
+ raise BadParseData("Genre not found, check genre name")
- data = await self.client.request("GET", ShazamUrl.LOCATIONS, "application/json")
+ async def get_country_genre_url_name(self, country: CountryCode, genre: GenreMusic) -> str:
+ """
+ Return URL path for country-specific genre charts.
+ :param country: ISO 3166-3 alpha-2 code
+ :param genre: Genre enum value
+ :return: URL path (e.g. 'spain/hip-hop-rap')
+ """
+ data = await self._get_locations()
+ for response_country in data["countries"]:
+ if country == response_country["id"]:
+ country_url_name = response_country["urlName"]
+ country_genres = response_country.get("genres")
+ if not country_genres:
+ raise BadParseData("Genres key not found for this country")
+ for response_genre in country_genres:
+ if genre.value == response_genre["urlName"]:
+ return f"{country_url_name}/{response_genre['urlName']}"
+ raise BadParseData("Genre not found for this country")
+
+ # Legacy methods kept for backward compatibility
+ async def get_country_playlist(self, country: CountryCode) -> str:
+ data = await self._get_locations()
+ for response_country in data["countries"]:
+ if country == response_country["id"]:
+ return response_country["listid"]
+ raise BadCountryName("Country not found, check country code")
+
+ async def get_city_playlist(self, country: CountryCode, city: str) -> str:
+ data = await self._get_locations()
for response_country in data["countries"]:
if country == response_country["id"]:
for response_city in response_country["cities"]:
@@ -44,57 +106,38 @@ async def get_city_playlist(self, country: CountryCode, city: str) -> str:
raise BadCityName("City not found, check city name")
async def get_genre(self, genre: GenreMusic) -> str:
- """
- Return Global Genre playlistID from country name and city name.
- :param genre: - Genre urlName from https://www.shazam.com/services/charts/locations
- :return: City ID
- """
-
- data = await self.client.request("GET", ShazamUrl.LOCATIONS, "application/json")
+ data = await self._get_locations()
global_data = data.get("global")
if not global_data:
raise BadParseData("Global key not found in shazam locations")
-
global_genres = global_data.get("genres")
if not global_genres:
raise BadParseData("Genres key not found in shazam locations")
-
for response_genre in global_genres:
if genre.value == response_genre["urlName"]:
return response_genre["listid"]
-
raise BadCityName("Genre not found, check genre name")
async def get_top(self) -> str:
- data = await self.client.request("GET", ShazamUrl.LOCATIONS, "application/json")
+ data = await self._get_locations()
global_data = data.get("global")
if not global_data:
raise BadParseData("Global key not found in shazam locations")
-
top = global_data.get("top")
if not top:
raise BadParseData("Top key not found in shazam locations")
return top["listid"]
async def get_genre_from_country(self, country: CountryCode, genre: GenreMusic) -> str:
- """
- Return Global Genre playlistID from country name and genre urlName from https://www.shazam.com/services/charts/locations
- :param country: - Country code, format: ISO 3166-3 alpha-2 code. Example: RU,NL,UA
- :param genre: - Genre urlName from https://www.shazam.com/services/charts/locations
- :return: City ID
- """
-
- data = await self.client.request("GET", ShazamUrl.LOCATIONS, "application/json")
+ data = await self._get_locations()
for response_country in data["countries"]:
if country == response_country["id"]:
global_genres = response_country.get("genres")
if not global_genres:
raise BadParseData("Genres key not found in shazam locations")
-
for response_genre in global_genres:
if genre.value == response_genre["urlName"]:
return response_genre["listid"]
-
raise BadCityName("Genre not found, check genre name")
diff --git a/shazamio/interfaces/client.py b/shazamio/interfaces/client.py
index 61aa1d9..5900a8b 100644
--- a/shazamio/interfaces/client.py
+++ b/shazamio/interfaces/client.py
@@ -12,3 +12,10 @@ async def request(
**kwargs,
) -> Union[List[Any], Dict[str, Any]]:
raise NotImplementedError
+
+ async def request_csv(
+ self,
+ url: str,
+ **kwargs,
+ ) -> Dict[str, Any]:
+ raise NotImplementedError
diff --git a/shazamio/misc.py b/shazamio/misc.py
index 3164d19..6c388cc 100644
--- a/shazamio/misc.py
+++ b/shazamio/misc.py
@@ -11,26 +11,25 @@ class ShazamUrl:
)
ABOUT_TRACK = (
"https://www.shazam.com/discovery/v5/{language}/{endpoint_country}/web/-/track"
- "/{track_id}?shazamapiversion=v3&video=v3 "
- )
- TOP_TRACKS_PLAYLIST = (
- "https://www.shazam.com/services/amapi/v1/catalog/{endpoint_country}"
- "/playlists/{playlist_id}/tracks?limit={limit}&offset={offset}&"
- "l={language}&relate[songs]=artists,music-videos"
+ "/{track_id}?shazamapiversion=v3&video=v3"
)
LOCATIONS = "https://www.shazam.com/services/charts/locations"
RELATED_SONGS = (
"https://cdn.shazam.com/shazam/v3/{language}/{endpoint_country}/web/-/tracks"
"/track-similarities-id-{track_id}?startFrom={offset}&pageSize={limit}&connected=&channel="
)
- SEARCH_ARTIST = (
- "https://www.shazam.com/services/search/v4/{language}/{endpoint_country}/web"
- "/search?term={query}&limit={limit}&offset={offset}&types=artists"
- )
- SEARCH_MUSIC = (
- "https://www.shazam.com/services/search/v3/{language}/{endpoint_country}/web"
- "/search?query={query}&numResults={limit}&offset={offset}&types=songs"
+
+ # New amapi search endpoint (replaces old search/v3 and search/v4)
+ SEARCH_AMAPI = (
+ "https://www.shazam.com/services/amapi/v1/catalog/{endpoint_country}"
+ "/search?term={query}&limit={limit}&offset={offset}&types={types}"
)
+
+ # Chart CSV endpoint (replaces broken amapi playlist endpoint)
+ CHART_CSV = "https://www.shazam.com/services/charts/csv/{chart_path}"
+
+ # Deprecated URLs kept for reference (all return 405 or 404 as of 2025)
+ # The amapi proxy for direct resource access has been shut down by Shazam.
LISTENING_COUNTER = "https://www.shazam.com/services/count/v2/web/track/{}"
LISTENING_COUNTER_MANY = "https://www.shazam.com/services/count/v2/web/track"
diff --git a/shazamio/schemas/artists.py b/shazamio/schemas/artists.py
index 31b40fd..b5295e8 100644
--- a/shazamio/schemas/artists.py
+++ b/shazamio/schemas/artists.py
@@ -87,7 +87,7 @@ class ArtistV3(BaseModel):
type: str
attributes: ArtistAttribute
relationships: ArtistRelationships
- views: ArtistViews
+ views: Optional[ArtistViews] = None
class ArtistResponse(BaseModel):
diff --git a/shazamio/utils.py b/shazamio/utils.py
index 121c878..c794bb2 100644
--- a/shazamio/utils.py
+++ b/shazamio/utils.py
@@ -1,6 +1,9 @@
+import csv
+import io
import pathlib
from enum import Enum
from io import BytesIO
+from typing import Any
from typing import Dict
from typing import List
from typing import Optional
@@ -25,6 +28,41 @@ async def validate_json(resp: aiohttp.ClientResponse, content_type: str = "appli
raise FailedDecodeJson("Failed to decode json") from e
+async def validate_csv(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
+ """Parse a Shazam chart CSV response into a dict with a 'tracks' list."""
+ text = await resp.text()
+ reader = csv.reader(io.StringIO(text))
+ rows = list(reader)
+
+ # CSV format:
+ # Row 0: empty or BOM
+ # Row 1: date header like "Friday, 13 February 2026 [performance over the past 7 days]"
+ # Row 2: column headers "Rank,Artist,Title"
+ # Row 3+: data rows
+ tracks = []
+ data_started = False
+ for row in rows:
+ if not row or len(row) < 3:
+ continue
+ if row[0] == "Rank":
+ data_started = True
+ continue
+ if data_started:
+ try:
+ rank = int(row[0])
+ tracks.append(
+ {
+ "rank": rank,
+ "subtitle": row[1],
+ "title": row[2],
+ }
+ )
+ except (ValueError, IndexError):
+ continue
+
+ return {"tracks": tracks}
+
+
async def get_file_bytes(file: FileT) -> bytes:
async with aiofiles.open(file, mode="rb") as f:
return await f.read()
diff --git a/tests/test_about_artist.py b/tests/test_about_artist.py
index 99c5c4a..a88976f 100644
--- a/tests/test_about_artist.py
+++ b/tests/test_about_artist.py
@@ -31,3 +31,13 @@ async def test_about_artist():
serialized = Serialize.artist_v2(about_artist)
assert serialized.data[0].attributes.name == "Markul"
assert "Hip-Hop/Rap" in serialized.data[0].attributes.genre_names
+
+
+@pytest.mark.asyncio
+async def test_about_artist_by_name():
+ shazam = Shazam()
+ about_artist = await shazam.artist_about(artist_name="Markul")
+
+ serialized = Serialize.artist_v2(about_artist)
+ assert serialized.data[0].attributes.name == "Markul"
+ assert "Hip-Hop/Rap" in serialized.data[0].attributes.genre_names
diff --git a/tests/test_charts.py b/tests/test_charts.py
new file mode 100644
index 0000000..f20cd62
--- /dev/null
+++ b/tests/test_charts.py
@@ -0,0 +1,67 @@
+import pytest
+
+from shazamio import Shazam, GenreMusic
+
+
+@pytest.mark.asyncio
+async def test_top_world_tracks():
+ shazam = Shazam()
+ result = await shazam.top_world_tracks(limit=5)
+ assert "tracks" in result
+ tracks = result["tracks"]
+ assert len(tracks) == 5
+ for track in tracks:
+ assert "rank" in track
+ assert "title" in track
+ assert "subtitle" in track
+
+
+@pytest.mark.asyncio
+async def test_top_country_tracks():
+ shazam = Shazam()
+ result = await shazam.top_country_tracks(country_code="ES", limit=4)
+ assert "tracks" in result
+ tracks = result["tracks"]
+ assert len(tracks) == 4
+ for track in tracks:
+ assert "rank" in track
+ assert "title" in track
+ assert "subtitle" in track
+
+
+@pytest.mark.asyncio
+async def test_top_city_tracks():
+ shazam = Shazam()
+ result = await shazam.top_city_tracks(
+ country_code="RU",
+ city_name="Moscow",
+ limit=5,
+ )
+ assert "tracks" in result
+ tracks = result["tracks"]
+ assert len(tracks) == 5
+
+
+@pytest.mark.asyncio
+async def test_top_world_genre_tracks():
+ shazam = Shazam()
+ result = await shazam.top_world_genre_tracks(
+ genre=GenreMusic.ROCK,
+ limit=10,
+ )
+ assert "tracks" in result
+ tracks = result["tracks"]
+ assert len(tracks) == 10
+
+
+@pytest.mark.asyncio
+async def test_top_country_genre_tracks():
+ shazam = Shazam()
+ result = await shazam.top_country_genre_tracks(
+ country_code="ES",
+ genre=GenreMusic.HIP_HOP_RAP,
+ limit=4,
+ )
+ assert "tracks" in result
+ tracks = result["tracks"]
+ assert len(tracks) == 4
diff --git a/tests/test_related_tracks.py b/tests/test_related_tracks.py
new file mode 100644
index 0000000..f8b5915
--- /dev/null
+++ b/tests/test_related_tracks.py
@@ -0,0 +1,11 @@
+import pytest
+
+from shazamio import Shazam
+
+
+@pytest.mark.asyncio
+async def test_related_tracks():
+ shazam = Shazam()
+ result = await shazam.related_tracks(track_id=546891609, limit=5)
+ assert "tracks" in result
+ assert len(result["tracks"]) > 0
diff --git a/tests/test_search.py b/tests/test_search.py
new file mode 100644
index 0000000..e321c31
--- /dev/null
+++ b/tests/test_search.py
@@ -0,0 +1,31 @@
+import pytest
+
+from shazamio import Shazam
+
+
+@pytest.mark.asyncio
+async def test_search_track():
+ shazam = Shazam()
+ result = await shazam.search_track(query="Lil", limit=5)
+ assert "results" in result
+ assert "songs" in result["results"]
+ songs = result["results"]["songs"]["data"]
+ assert len(songs) > 0
+ for song in songs:
+ assert "id" in song
+ assert "attributes" in song
+ assert "artistName" in song["attributes"]
+
+
+@pytest.mark.asyncio
+async def test_search_artist():
+ shazam = Shazam()
+ result = await shazam.search_artist(query="Lil", limit=5)
+ assert "results" in result
+ assert "artists" in result["results"]
+ artists = result["results"]["artists"]["data"]
+ assert len(artists) > 0
+ for artist in artists:
+ assert "id" in artist
+ assert "attributes" in artist
+ assert "name" in artist["attributes"]
diff --git a/tests/test_track_about.py b/tests/test_track_about.py
new file mode 100644
index 0000000..8c5d130
--- /dev/null
+++ b/tests/test_track_about.py
@@ -0,0 +1,11 @@
+import pytest
+
+from shazamio import Shazam
+
+
+@pytest.mark.asyncio
+async def test_track_about():
+ shazam = Shazam()
+ result = await shazam.track_about(track_id=552406075)
+ assert result["title"] == "Ale jazz!"
+ assert "key" in result