import random import threading import time import uuid from collections import deque from faker import Faker from app.dsp.backends.base import DSPBackend from app.dsp.enums import DSPId from app.dsp.exceptions import StreamingAPIError from app.dsp.types import ( ArtistItem, FollowedArtistsResult, PlaylistItem, PlaylistsResult, ProfileResult, RecentlyPlayedItem, RecentlyPlayedResult, SavedAlbumItem, SavedAlbumsResult, SavedTrackItem, SavedTracksResult, TokenResult, TopArtistsResult, TopTracksResult, TrackItem, ) _LATENCY_MIN_S = 0.05 # 50ms _LATENCY_MAX_S = 0.15 # 150ms class FakerDSPBackend(DSPBackend): """Simulated DSP backend for local development and testing. Each call sleeps [50, 150] ms to simulate realistic HTTP round-trip latency. When `nominal_rps` is set, calls exceeding that rate raise a 429 to simulate Spotify throttling (e.g. Songwhip=11 rps, SMF=204 rps). """ def __init__( self, dsp_id: DSPId, nominal_rps: int | None = None, ) -> None: self.dsp_id = dsp_id self._faker = Faker() self._nominal_rps = nominal_rps self._lock = threading.Lock() self._window: deque[float] = deque() def _throttle(self) -> None: time.sleep(random.uniform(_LATENCY_MIN_S, _LATENCY_MAX_S)) if not self._nominal_rps: return with self._lock: now = time.monotonic() cutoff = now - 1.0 while self._window and self._window[0] < cutoff: self._window.popleft() if len(self._window) >= self._nominal_rps: retry_after = max(0.1, round(self._window[0] + 1.0 - now, 2)) raise StreamingAPIError( "Simulated rate limit", status_code=429, retry_after=retry_after, ) self._window.append(now) def refresh_token(self, refresh_token: str) -> TokenResult: # noqa: ARG002 self._throttle() return TokenResult(access_token=f"fake_access_{uuid.uuid4().hex}") def get_profile(self, access_token: str) -> ProfileResult: # noqa: ARG002 self._throttle() return ProfileResult( email=self._faker.email(), display_name=self._faker.name(), country=self._faker.country_code(), product=random.choice(["free", "premium"]), ) def get_top_artists(self, access_token: str) -> TopArtistsResult: # noqa: ARG002 self._throttle() return TopArtistsResult( items=[ ArtistItem(id=f"artist_{uuid.uuid4().hex[:8]}") for _ in range(random.randint(10, 50)) ] ) def get_top_tracks(self, access_token: str) -> TopTracksResult: # noqa: ARG002 self._throttle() return TopTracksResult( items=[ TrackItem(id=f"track_{uuid.uuid4().hex[:8]}") for _ in range(random.randint(10, 50)) ] ) def get_recently_played( self, access_token: str, # noqa: ARG002 *, after: int | None = None, # noqa: ARG002 ) -> RecentlyPlayedResult: self._throttle() return RecentlyPlayedResult( items=[ RecentlyPlayedItem( track_id=f"track_{uuid.uuid4().hex[:8]}", played_at=self._faker.date_time_this_month().isoformat() + "Z", ) for _ in range(random.randint(10, 50)) ] ) def get_playlists(self, access_token: str) -> PlaylistsResult: # noqa: ARG002 self._throttle() return PlaylistsResult( items=[ PlaylistItem(id=f"playlist_{uuid.uuid4().hex[:8]}") for _ in range(random.randint(5, 30)) ] ) def get_saved_albums(self, access_token: str) -> SavedAlbumsResult: # noqa: ARG002 self._throttle() return SavedAlbumsResult( items=[ SavedAlbumItem( added_at=self._faker.date_time_this_year().isoformat() + "Z", album_id=f"album_{uuid.uuid4().hex[:8]}", ) for _ in range(random.randint(10, 50)) ] ) def get_saved_tracks(self, access_token: str) -> SavedTracksResult: # noqa: ARG002 self._throttle() return SavedTracksResult( items=[ SavedTrackItem( added_at=self._faker.date_time_this_year().isoformat() + "Z", track_id=f"track_{uuid.uuid4().hex[:8]}", ) for _ in range(random.randint(10, 50)) ] ) def get_followed_artists(self, access_token: str) -> FollowedArtistsResult: # noqa: ARG002 self._throttle() return FollowedArtistsResult( items=[ ArtistItem(id=f"artist_{uuid.uuid4().hex[:8]}") for _ in range(random.randint(5, 30)) ] )