Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 151 additions & 96 deletions shazamio/api.py

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion shazamio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
113 changes: 78 additions & 35 deletions shazamio/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand All @@ -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")


Expand Down
7 changes: 7 additions & 0 deletions shazamio/interfaces/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 12 additions & 13 deletions shazamio/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion shazamio/schemas/artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class ArtistV3(BaseModel):
type: str
attributes: ArtistAttribute
relationships: ArtistRelationships
views: ArtistViews
views: Optional[ArtistViews] = None


class ArtistResponse(BaseModel):
Expand Down
38 changes: 38 additions & 0 deletions shazamio/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions tests/test_about_artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions tests/test_charts.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions tests/test_related_tracks.py
Original file line number Diff line number Diff line change
@@ -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
Loading