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