import logging from typing import Optional from django.conf import settings from spotipy.client import SpotifyException from core_images.cache import ServiceCacheMixin from core_images.cached_either import Right, Left from core_images.consts import ( SMALL_RESOLUTION, MEDIUM_RESOLUTION, NO_IMAGE_CACHE_VALUE, IMAGE_PROCESSING_CACHE_VALUE_PREFIX, IMAGE_PROCESSED_CACHE_VALUE, LARGE_RESOLUTION, ) from core_images.dsp_clients import get_spotify_client from core_images.services.internal_data.service import ( get_internal_data_service, ) from core_images.services.spotify.decorators import cache_method from core_images.tasks import create_external_image_thumbnails_task logger = logging.getLogger(__name__) class SpotifyService(ServiceCacheMixin): """Encapsulates access to the Spotify API.""" CACHE_TTL = 60 * 60 * 6 IMAGES_PROCESSING_TTL = 60 * 5 NO_IMAGE_MSG = "No image available" RESOLUTION_MAPPING = { SMALL_RESOLUTION: 100, MEDIUM_RESOLUTION: 300, LARGE_RESOLUTION: 600, } def __init__(self, client, internal_data_service): self._client = client self._internal_data_service = internal_data_service def get_image(self, results: dict, resolution: Optional[str]): if not results: return None # Based on experiments, Spotify images may have different orientation # so we have to check both height and width from the response resolution = self.RESOLUTION_MAPPING.get(resolution) for image in results.get("images", {})[::-1]: if ( resolution and image["height"] and image["height"] >= resolution ) or ( resolution and image["width"] and image["width"] >= resolution ): return image["url"] for image in results.get("images", {})[::-1]: if ( not image["height"] or image["height"] >= settings.SPOTIFY_IMAGE_MIN_SIZE ): return image["url"] return None @cache_method def album_image_url(self, album_id, resolution): try: results = self._client.album(album_id) url = self.get_image(results, resolution) except (SpotifyException, TypeError): return Left("Unable to locate a Spotify album with that ID.") if not url: return Left("That album doesn’t have an image in Spotify.") return Right(url) @cache_method def artist_image_url(self, artist_id, resolution): try: results = self._client.artist(artist_id) url = self.get_image(results, resolution) except (SpotifyException, TypeError): return Left( "Unable to locate a Spotify artist with that artist ID." ) if not url: return Left("That artist doesn’t have an image in Spotify.") return Right(url) def playlist_image_url(self, playlist_id, resolution): cache_key = ["playlist_image_url", playlist_id, resolution] url = self.cache_get(cache_key) if url == NO_IMAGE_CACHE_VALUE: return Left(self.NO_IMAGE_MSG, True) # if the value is a temporary original url we need before the task # completion, then we return it, providing a hint for all possible # upper cache layers (e.g. Cloudfront, etc.) to not use cache for it # till the scheduled conversion task will be finished elif url and url.startswith(IMAGE_PROCESSING_CACHE_VALUE_PREFIX): url = url.removeprefix(IMAGE_PROCESSING_CACHE_VALUE_PREFIX) return Right(url, True, True) # if the image is already processed and all thumbnails were created, # then the requested thumbnail would be fetched in a lightweight # manner by the internal data service, and cached with actual params elif url == IMAGE_PROCESSED_CACHE_VALUE: try: url = self._internal_data_service.get_external_image_thumbnail( "spotify_playlists", playlist_id, resolution ) except Exception: logger.exception( f"Error getting thumbnail for playlist: {playlist_id}, " f"{resolution}" ) url = None elif url: return Right(url, True) create_thumbnails = False if not url: try: results = self._client.playlist(playlist_id) url = self.get_image(results, resolution) create_thumbnails = True except (SpotifyException, TypeError): return Left( "Unable to locate a Spotify playlist with that playlist ID." # noqa ) if not url: self.cache_set(cache_key, NO_IMAGE_CACHE_VALUE) return Left("That Spotify playlist doesn’t have an image.") # If the url is new and not in cache, # then cache the original url value with special prefix # and schedule the task to fetch the image and create all # needed thumbnails for that image. # We also need to add data to store into cache on # the task completion to refresh the caches. if create_thumbnails: self.cache_set_many( { ( "playlist_image_url", playlist_id, res, ): f"{IMAGE_PROCESSING_CACHE_VALUE_PREFIX}{url}" for res in self.RESOLUTION_MAPPING.keys() }, self.IMAGES_PROCESSING_TTL, ) create_external_image_thumbnails_task.delay( "spotify_playlists", playlist_id, url, self.cache_set_many( { ( "playlist_image_url", playlist_id, res, ): IMAGE_PROCESSED_CACHE_VALUE for res in self.RESOLUTION_MAPPING.keys() }, dry_run=True, ), ) return Right(url, False, True) self.cache_set(cache_key, url) return Right(url) @cache_method def track_image_url(self, track_id, resolution): try: results = self._client.track(track_id) url = self.get_image(results["album"], resolution) except (SpotifyException, TypeError): return Left("Unable to locate a Spotify track with that track ID.") if not url: return Left("That Spotify playlist doesn’t have an image.") return Right(url) def get_spotify_service(): client = get_spotify_client() internal_data_service = get_internal_data_service() return SpotifyService( client=client, internal_data_service=internal_data_service )