""" Song.link / Odesli API Client - FREE Song.link provides a free API to map music URLs across different streaming services. Given a Spotify URL/ID, it can find equivalent tracks on: - Tidal - Qobuz - Amazon Music - Deezer - Apple Music - YouTube Music - SoundCloud API Documentation: https://linktree.docs.apiary.io/ Rate Limit: ~10 requests per minute (handled automatically) """ import logging import time from dataclasses import dataclass import requests logger = logging.getLogger(__name__) # Song.link API base URL SONGLINK_API_BASE = "https://api.song.link/v1-alpha.1" @dataclass class PlatformLink: """Link to a track on a specific platform""" platform: str url: str entity_type: str # track, album, playlist id: str | None = None native_uri: str | None = None @dataclass class CrossPlatformLinks: """Cross-platform links for a single track""" spotify_id: str isrc: str | None links: dict[str, PlatformLink] # Convenience properties @property def tidal_url(self) -> str | None: return self.links.get("tidal", {}).url if "tidal" in self.links else None @property def qobuz_url(self) -> str | None: return self.links.get("qobuz", {}).url if "qobuz" in self.links else None @property def amazon_url(self) -> str | None: return ( self.links.get("amazonMusic", {}).url if "amazonMusic" in self.links else None ) @property def deezer_url(self) -> str | None: return self.links.get("deezer", {}).url if "deezer" in self.links else None @property def apple_url(self) -> str | None: return ( self.links.get("appleMusic", {}).url if "appleMusic" in self.links else None ) @property def youtube_url(self) -> str | None: return self.links.get("youtube", {}).url if "youtube" in self.links else None @property def youtube_music_url(self) -> str | None: return ( self.links.get("youtubeMusic", {}).url if "youtubeMusic" in self.links else None ) @property def soundcloud_url(self) -> str | None: return ( self.links.get("soundcloud", {}).url if "soundcloud" in self.links else None ) @dataclass class TrackAvailability: """Track availability across platforms""" spotify_id: str isrc: str | None = None tidal: bool = False qobuz: bool = False amazon: bool = False deezer: bool = False apple: bool = False youtube: bool = False youtube_music: bool = False soundcloud: bool = False tidal_url: str | None = None qobuz_url: str | None = None amazon_url: str | None = None deezer_url: str | None = None apple_url: str | None = None youtube_url: str | None = None youtube_music_url: str | None = None soundcloud_url: str | None = None class SongLinkClient: """ Song.link API Client - FREE Maps Spotify tracks to other streaming services. Rate limited to ~10 requests per minute. """ # Platform name mapping PLATFORM_NAMES = { "spotify": "Spotify", "tidal": "Tidal", "qobuz": "Qobuz", "amazonMusic": "Amazon Music", "deezer": "Deezer", "appleMusic": "Apple Music", "youtube": "YouTube", "youtubeMusic": "YouTube Music", "soundcloud": "SoundCloud", "napster": "Napster", "pandora": "Pandora", } def __init__(self): self.session = requests.Session() self.session.headers.update( { "User-Agent": "SwingMusic/1.0 (https://github.com/geoffrey45/swingmusic)", "Accept": "application/json", } ) # Rate limiting self._last_request_time = 0 self._request_count = 0 self._count_reset_time = time.time() self._min_request_interval = 7.0 # 7 seconds between requests self._max_requests_per_minute = 9 # Stay under 10/min limit def _rate_limit(self) -> None: """Handle rate limiting""" now = time.time() # Reset counter every minute if now - self._count_reset_time >= 60: self._request_count = 0 self._count_reset_time = now # Check if we've hit the per-minute limit if self._request_count >= self._max_requests_per_minute: wait_time = 60 - (now - self._count_reset_time) if wait_time > 0: logger.debug(f"Song.link rate limit reached, waiting {wait_time:.1f}s") time.sleep(wait_time) self._request_count = 0 self._count_reset_time = time.time() # Ensure minimum interval between requests elapsed = now - self._last_request_time if elapsed < self._min_request_interval: wait_time = self._min_request_interval - elapsed time.sleep(wait_time) self._last_request_time = time.time() self._request_count += 1 def _make_request(self, url: str, params: dict = None) -> dict | None: """Make a rate-limited request to Song.link API""" self._rate_limit() try: response = self.session.get(url, params=params, timeout=30) if response.status_code == 429: # Rate limited - wait and retry once retry_after = int(response.headers.get("Retry-After", 15)) logger.warning(f"Song.link rate limited, waiting {retry_after}s") time.sleep(retry_after) self._rate_limit() response = self.session.get(url, params=params, timeout=30) if response.status_code != 200: logger.error(f"Song.link API error: HTTP {response.status_code}") return None return response.json() except requests.exceptions.Timeout: logger.error("Song.link API timeout") return None except requests.exceptions.RequestException as e: logger.error(f"Song.link API request error: {e}") return None except Exception as e: logger.error(f"Song.link API error: {e}") return None def get_links_from_spotify_url( self, spotify_url: str, region: str = "US" ) -> CrossPlatformLinks | None: """ Get cross-platform links from a Spotify URL. Args: spotify_url: Full Spotify URL (e.g., https://open.spotify.com/track/xxx) region: Country code for region-specific availability Returns: CrossPlatformLinks object with links to all available platforms """ params = {"url": spotify_url} if region: params["userCountry"] = region url = f"{SONGLINK_API_BASE}/links" data = self._make_request(url, params) if not data: return None return self._parse_response(data) def get_links_from_spotify_id( self, spotify_id: str, item_type: str = "track", region: str = "US" ) -> CrossPlatformLinks | None: """ Get cross-platform links from a Spotify ID. Args: spotify_id: Spotify track/album ID item_type: Type of item (track, album, playlist) region: Country code for region-specific availability Returns: CrossPlatformLinks object with links to all available platforms """ spotify_url = f"https://open.spotify.com/{item_type}/{spotify_id}" return self.get_links_from_spotify_url(spotify_url, region) def check_availability( self, spotify_id: str, item_type: str = "track", region: str = "US" ) -> TrackAvailability: """ Check track availability across platforms. Args: spotify_id: Spotify track ID item_type: Type of item (track, album) region: Country code Returns: TrackAvailability with boolean flags for each platform """ links = self.get_links_from_spotify_id(spotify_id, item_type, region) if not links: return TrackAvailability(spotify_id=spotify_id) return TrackAvailability( spotify_id=spotify_id, isrc=links.isrc, tidal=links.tidal_url is not None, qobuz=links.qobuz_url is not None, amazon=links.amazon_url is not None, deezer=links.deezer_url is not None, apple=links.apple_url is not None, youtube=links.youtube_url is not None, youtube_music=links.youtube_music_url is not None, soundcloud=links.soundcloud_url is not None, tidal_url=links.tidal_url, qobuz_url=links.qobuz_url, amazon_url=links.amazon_url, deezer_url=links.deezer_url, apple_url=links.apple_url, youtube_url=links.youtube_url, youtube_music_url=links.youtube_music_url, soundcloud_url=links.soundcloud_url, ) def get_isrc_from_spotify(self, spotify_id: str, region: str = "US") -> str | None: """ Get ISRC (International Standard Recording Code) from Spotify ID. Uses Deezer as intermediary since they provide ISRC in their API. Args: spotify_id: Spotify track ID region: Country code Returns: ISRC code if found, None otherwise """ links = self.get_links_from_spotify_id(spotify_id, "track", region) if links and links.isrc: return links.isrc # Try to get ISRC from Deezer if links and links.deezer_url: return self._get_isrc_from_deezer_url(links.deezer_url) return None def _get_isrc_from_deezer_url(self, deezer_url: str) -> str | None: """Extract ISRC from Deezer API using track URL""" try: # Extract track ID from Deezer URL track_id = deezer_url.split("/track/")[-1].split("?")[0] response = self.session.get( f"https://api.deezer.com/track/{track_id}", timeout=10 ) if response.status_code == 200: data = response.json() return data.get("isrc") except Exception as e: logger.debug(f"Failed to get ISRC from Deezer: {e}") return None def _parse_response(self, data: dict) -> CrossPlatformLinks: """Parse Song.link API response into CrossPlatformLinks""" links = {} isrc = None spotify_id = None # Extract entity unique IDs (contains ISRC) entity_ids = data.get("entitiesByUniqueId", {}) for entity_id, entity_data in entity_ids.items(): # Extract ISRC from Deezer entity if available if ( "DEEZER" in entity_id.upper() or entity_data.get("apiProvider") == "deezer" ): isrc = entity_data.get("nativeId") # Extract Spotify ID if entity_data.get("apiProvider") == "spotify": spotify_id = entity_data.get("nativeId") # Extract platform links links_by_platform = data.get("linksByPlatform", {}) for platform, link_data in links_by_platform.items(): entity_key = link_data.get("entityUniqueId", "") entity_info = entity_ids.get(entity_key, {}) links[platform] = PlatformLink( platform=platform, url=link_data.get("url", ""), entity_type=link_data.get("type", "track"), id=entity_info.get("nativeId"), native_uri=entity_info.get("nativeUri"), ) # Fallback: get Spotify ID from URL if not spotify_id: page_url = data.get("pageUrl", "") if "spotify.com" in page_url: parts = page_url.split("/") if len(parts) > 4: spotify_id = parts[-1].split("?")[0] return CrossPlatformLinks( spotify_id=spotify_id or "", isrc=isrc, links=links, ) def get_streaming_urls(self, spotify_id: str, region: str = "US") -> dict[str, str]: """ Get streaming URLs for all available platforms. Args: spotify_id: Spotify track ID region: Country code Returns: Dict mapping platform names to URLs """ links = self.get_links_from_spotify_id(spotify_id, "track", region) if not links: return {} return { platform: link.url for platform, link in links.links.items() if link.url } # Singleton instance _songlink_client: SongLinkClient | None = None def get_songlink_client() -> SongLinkClient: """Get or create the singleton Song.link client""" global _songlink_client if _songlink_client is None: _songlink_client = SongLinkClient() return _songlink_client