from dataclasses import dataclass, field from datetime import date from typing import List, Optional from marshmallow import EXCLUDE from external_api.base.available_providers import StreamingProvider from external_api.base.analytics_models import ( TrackStreamsQuery, TrackStreamsResult, StreamingStats, DailyStreams, ArtistsQuery, ArtistFollowersQuery, ArtistFollowersResult, StreamsByDayQuery, StreamsByDayResult, StreamsData, LinkfireQuery, ProjectStreamsQuery, ) from external_api.base.clients.client import ApiClient from utils.elastic_search.query import escape_query from utils.list_utils import get_nested_attr, safe_sum from services.territory.constants import WORLDWIDE from external_api.utils import remove_none_values from external_api.tracks_search.schemas import DelphiTrackSearchQueryParams, DelphiTracksResponseSchema @dataclass class DelphiImage: uri: str width: Optional[int] height: Optional[int] class Meta: unknown = EXCLUDE @dataclass class DelphiArtistModel: artist_id: str first_name: Optional[str] last_name: Optional[str] full_name: str image: Optional[DelphiImage] apple_artist_id: Optional[int] spotify_artist_id: Optional[str] class Meta: unknown = EXCLUDE @dataclass class DelphiArtistsListResponse: count: int items: List[DelphiArtistModel] class Meta: unknown = EXCLUDE class DelphiArtistsSearchCount: count: int class Meta: unknown = EXCLUDE @dataclass class DelphiLinkfireModel: link_id: str link_url: str class Meta: unknown = EXCLUDE @dataclass class DelphiLinkfireLinksListResponse: count: int items: List[DelphiLinkfireModel] class Meta: unknown = EXCLUDE @dataclass class AmazonTracksStreamsStats: country_code: str date: str streams: Optional[int] = 0 class Meta: unknown = EXCLUDE @dataclass class AmazonTrackStreamsResponse: items: List[AmazonTracksStreamsStats] class Meta: unknown = EXCLUDE @dataclass class Engagement: lean_back: Optional[int] lean_forward: Optional[int] def streams(self): return safe_sum([self.lean_back, self.lean_forward]) class Meta: unknown = EXCLUDE @dataclass class AmazonStreamsInfo: streams: Optional[int] engagement: Optional[Engagement] def __post_init__(self): self.streams = self.engagement.streams() if self.engagement else None class Meta: unknown = EXCLUDE @dataclass class SpotifyStreamsInfo: streams: Optional[int] engagement: Optional[Engagement] def __post_init__(self): self.streams = self.engagement.streams() if self.engagement else None class Meta: unknown = EXCLUDE @dataclass class AppleStreamsInfo: streams: Optional[int] listener_engagement: Optional[Engagement] class Meta: unknown = EXCLUDE @dataclass class StreamsStatsByDay: date: str streams: int lean_back: Optional[int] lean_forward: Optional[int] amazon_streams_info: Optional[AmazonStreamsInfo] spotify_streams_info: Optional[SpotifyStreamsInfo] apple_streams_info: Optional[AppleStreamsInfo] def __post_init__(self): self.lean_back = safe_sum( [ get_nested_attr(self.amazon_streams_info, "engagement.lean_back", None), get_nested_attr(self.spotify_streams_info, "engagement.lean_back", None), get_nested_attr(self.apple_streams_info, "listener_engagement.lean_back", None), ] ) self.lean_forward = safe_sum( [ get_nested_attr(self.amazon_streams_info, "engagement.lean_forward", None), get_nested_attr(self.spotify_streams_info, "engagement.lean_forward", None), get_nested_attr(self.apple_streams_info, "listener_engagement.lean_forward", None), ] ) class Meta: unknown = EXCLUDE @dataclass class StreamsByDayResponse: items: List[StreamsStatsByDay] class Meta: unknown = EXCLUDE @dataclass class ArtistFollowersByDay: date: str followers: Optional[int] apple_followers: Optional[int] facebook_likes: Optional[int] facebook_storytellers: Optional[int] instagram_followers: Optional[int] spotify_followers: Optional[int] spotify_popularity: Optional[int] twitter_followers: Optional[int] youtube_channel_subscribers: Optional[int] youtube_channel_views: Optional[int] def __post_init__(self): followers = [ data for data in [ self.apple_followers, self.spotify_followers, self.instagram_followers, self.youtube_channel_subscribers, self.twitter_followers, ] if data is not None ] self.followers = sum(followers) if followers else None class Meta: unknown = EXCLUDE @dataclass class ArtistFollowersResponse: items: List[ArtistFollowersByDay] class Meta: unknown = EXCLUDE @dataclass class AdsPerformanceQuery: start_date: date end_date: date user_id: int dsp: Optional[List[str]] = None artist_id: Optional[str] = None campaign_id: Optional[List[str]] = None project_id: Optional[str] = None provider_id: Optional[List[int]] = None sub_category_id: Optional[List[int]] = None decibel_label_id: Optional[List[int]] = None group_by: Optional[List[str]] = None is_assigned: Optional[bool] = None is_pending: Optional[bool] = None phase_id: Optional[List[int]] = None platform: Optional[List[str]] = None sort_by: Optional[str] = None sort_order: Optional[str] = None country_code: Optional[str] = None class Meta: unknown = EXCLUDE @dataclass class AdSet: ad_set_id: str ad_set_name: str start_date: Optional[date] = None end_date: Optional[date] = None class Meta: unknown = EXCLUDE @dataclass class ArtistSimple: artist_id: str first_name: Optional[str] last_name: Optional[str] apple_artist_id: Optional[int] spotify_artist_id: Optional[str] spotify_popularity: Optional[int] full_name: Optional[str] = field(default="") # image: ImageSimple class Meta: unknown = EXCLUDE @dataclass class CampaignSimple: campaign_id: str campaign_name: str budget: Optional[float] spend: Optional[float] start_date: Optional[date] end_date: Optional[date] is_assigned: Optional[bool] is_pending: Optional[bool] class Meta: unknown = EXCLUDE @dataclass class DecibelLabel: decibel_label_id: int decibel_label_name: str class Meta: unknown = EXCLUDE @dataclass class Objective: objective_id: int objective_name: str class Meta: unknown = EXCLUDE @dataclass class DecibelProjectSimple: project_id: str project_name: str budget: Optional[float] start_date: date end_date: date class Meta: unknown = EXCLUDE @dataclass class Provider: provider_id: int provider_name: str class Meta: unknown = EXCLUDE @dataclass class Region: region_id: str country_code: str country_name: str class Meta: unknown = EXCLUDE @dataclass class Category: category_id: int sub_category_name: str class Meta: unknown = EXCLUDE @dataclass class SubCategory: sub_category_id: int sub_category_name: str class Meta: unknown = EXCLUDE @dataclass class Phase: phase_id: int phase_name: str start_date: date end_date: date class Meta: unknown = EXCLUDE @dataclass class AdDimensions: ad_set: Optional[AdSet] age_band: Optional[str] artist: Optional[ArtistSimple] campaign: Optional[CampaignSimple] chartweek: Optional[str] date: Optional[date] decibel_label: Optional[DecibelLabel] dsp: Optional[str] gender: Optional[str] month: Optional[str] objective: Optional[Objective] platform: Optional[str] project: Optional[DecibelProjectSimple] provider: Optional[Provider] region: Optional[Region] sub_category: Optional[SubCategory] category: Optional[Category] week: Optional[str] phase: Optional[Phase] linkfire: Optional[DelphiLinkfireModel] class Meta: unknown = EXCLUDE @dataclass class AdMetricsFinance: budget: Optional[float] spend: Optional[float] cp_click: Optional[float] cp_comment: Optional[float] cp_conversion: Optional[float] cp_custom_conversion: Optional[float] cp_engagement: Optional[float] cp_landing_page_view: Optional[float] cp_lead: Optional[float] cp_reaction: Optional[float] cp_thruplay: Optional[float] cp_unique_click: Optional[float] cp_unique_custom_conversion: Optional[float] cp_unique_outbound_click: Optional[float] cp_video_view: Optional[float] cpm_impressions: Optional[float] cpm_reach: Optional[float] class Meta: unknown = EXCLUDE @dataclass class AdMetricsGeneral: average_frequency: Optional[float] clicks: Optional[int] comments: Optional[int] conversion_rate: Optional[float] conversions: Optional[int] ctr: Optional[float] custom_conversions: Optional[int] engagements: Optional[int] facebook_page_likes: Optional[int] impressions: Optional[int] landing_page_views: Optional[int] leads: Optional[int] reach: Optional[int] reactions: Optional[int] unique_clicks: Optional[int] unique_ctr: Optional[float] unique_custom_conv: Optional[int] unique_outbound_clicks: Optional[int] unique_outbound_ctr: Optional[float] class Meta: unknown = EXCLUDE @dataclass class AdMetricsVideo: thruplays: Optional[int] video_views: Optional[int] video_watched_25: Optional[float] video_watched_50: Optional[float] video_watched_75: Optional[float] video_watched_100: Optional[float] class Meta: unknown = EXCLUDE @dataclass class AdMetricsLinkfire: bounce_rate: Optional[float] clicks: Optional[int] conversions: Optional[int] ctr: Optional[float] direct_to_service: Optional[int] event_rate: Optional[float] events: Optional[int] in_page_plays: Optional[int] new_sessions: Optional[int] page_views: Optional[int] platform_to_linkfire_bounce_rate: Optional[float] platform_to_linkfire_click_rate: Optional[float] platform_to_linkfire_event_rate: Optional[float] class Meta: unknown = EXCLUDE @dataclass class AdMetrics: finance: AdMetricsFinance general: AdMetricsGeneral video: AdMetricsVideo linkfire: Optional[AdMetricsLinkfire] class Meta: unknown = EXCLUDE @dataclass class AdPerformanceSummaryItem: dimensions: AdDimensions metrics: AdMetrics class Meta: unknown = EXCLUDE @dataclass class AdsPerformanceResponse: items: List[AdPerformanceSummaryItem] count: int class Meta: unknown = EXCLUDE @dataclass class ReportingDataHeathResponseStreamsValue: days: List[Optional[str]] @dataclass class ReportingDataHeathResponseStreams: streams: ReportingDataHeathResponseStreamsValue @dataclass class ReportingDataHeathResponseItem: amazon: Optional[ReportingDataHeathResponseStreams] = None apple: Optional[ReportingDataHeathResponseStreams] = None spotify: Optional[ReportingDataHeathResponseStreams] = None class Meta: unknown = EXCLUDE @dataclass class ReportingDataHeathResponseItemsListing: dsp_segment: ReportingDataHeathResponseItem @dataclass class ReportingDataHeathResponse: min_date: Optional[date] # todo get Delphi to always send us the dates max_date: Optional[date] breakdowns: ReportingDataHeathResponseItemsListing class Meta: unknown = EXCLUDE @dataclass class PerformanceMetricsDataHealthQuery: start_date: date end_date: date ads_account: Optional[List[str]] = None class Meta: unknown = EXCLUDE @dataclass class PerformanceMetricsDataHealthResponseDays: days: Optional[List[Optional[str]]] @dataclass class PerformanceMetricsDataHealthResponseBreakdownDsp: facebook: Optional[PerformanceMetricsDataHealthResponseDays] google: Optional[PerformanceMetricsDataHealthResponseDays] linkfire: Optional[PerformanceMetricsDataHealthResponseDays] @dataclass class PerformanceMetricsDataHealthResponseBreakdownRepOwner: facebook: Optional[dict] google: Optional[dict] @dataclass class PerformanceMetricsDataHealthResponseBreakdownRepOwnerAdsAccount: facebook: Optional[dict] google: Optional[dict] @dataclass class PerformanceMetricsDataHealthResponseBreakdowns: dsp: PerformanceMetricsDataHealthResponseBreakdownDsp dsp_parent_rep_owner: PerformanceMetricsDataHealthResponseBreakdownRepOwner dsp_parent_rep_owner_ads_account: PerformanceMetricsDataHealthResponseBreakdownRepOwnerAdsAccount @dataclass class PerformanceMetricsDataHealthDelphiResponse: min_date: Optional[date] # todo get Delphi to always send us the dates max_date: Optional[date] breakdowns: PerformanceMetricsDataHealthResponseBreakdowns class DelphiClient: http_client: ApiClient def __init__(self, client: ApiClient) -> None: self.http_client = client async def get_streams_by_day(self, query: StreamsByDayQuery) -> List[StreamsByDayResult]: params = { "start_date": query.start_date, "end_date": query.end_date, "include": "all", "group_by": "date", "sort_by": "date", "sort_order": "asc", "country_code": query.country_code, } if query.isrc: params["isrc"] = query.isrc endpoint = "/streams" response = await self.http_client.get(endpoint, params=params, response_type=StreamsByDayResponse) return self.__map_streams_by_day_response(response) def __map_streams_by_day_response(self, data: StreamsByDayResponse) -> List[StreamsByDayResult]: return [ StreamsByDayResult( date=item.date, streams=item.streams, leanBack=item.lean_back, leanForward=item.lean_forward, amazon=self.__map_streams_data(item, "amazon"), apple=self.__map_streams_data(item, "apple", "listener_engagement"), spotify=self.__map_streams_data(item, "spotify"), ) for item in data.items ] def __map_streams_data(self, data: StreamsStatsByDay, dsp: str, engagement: str = "engagement") -> StreamsData: return StreamsData( streams=get_nested_attr(data, "{}_streams_info.streams".format(dsp), None), leanBack=get_nested_attr(data, "{}_streams_info.{}.lean_back".format(dsp, engagement), None), leanForward=get_nested_attr(data, "{}_streams_info.{}.lean_forward".format(dsp, engagement), None), ) async def get_artist_followers(self, query: ArtistFollowersQuery) -> List[ArtistFollowersResult]: params = { "start_date": query.start_date, "end_date": query.end_date, "group_by": "date", "sort_by": "date", "sort_order": "asc", } endpoint = "/artists/{}/followers".format(query.artist_id) response = await self.http_client.get(endpoint, params=params, response_type=ArtistFollowersResponse) return self.__map_artist_followers_response(response) def __map_artist_followers_response(self, data: ArtistFollowersResponse) -> List[ArtistFollowersResult]: return [ ArtistFollowersResult( date=item.date, followers=item.followers, appleFollowers=item.apple_followers, facebookLikes=item.facebook_likes, facebookStorytellers=item.facebook_storytellers, instagramFollowers=item.instagram_followers, spotifyFollowers=item.spotify_followers, spotifyPopularity=item.spotify_popularity, twitterFollowers=item.twitter_followers, youtubeChannelSubscribers=item.youtube_channel_subscribers, youtubeChannelViews=item.youtube_channel_views, ) for item in data.items ] async def get_amazon_streams_analytics(self, query: TrackStreamsQuery) -> TrackStreamsResult: params = { "isrc": query.isrc, "start_date": query.start_date, "end_date": query.end_date, "country_code": WORLDWIDE, "group_by": "date", "dsp": "amazon", } endpoint = "/streams" streaming_stats = await self.http_client.get(endpoint, params=params, response_type=AmazonTrackStreamsResponse) return TrackStreamsResult(provider=StreamingProvider.Amazon, streams=self.map_amazon_stats(streaming_stats)) async def search_for_artists(self, query_model: ArtistsQuery) -> DelphiArtistsListResponse: params = {"limit": query_model.limit, "index": "artist"} if query_model.query: params["query"] = escape_query(query_model.query) if query_model.page: params["page"] = query_model.page endpoint = "/search" return await self.http_client.get(endpoint, params=params, response_type=DelphiArtistsListResponse) async def get_artists_search_count(self, query_model: ArtistsQuery) -> DelphiArtistsSearchCount: params = {"limit": query_model.limit, "index": "artist"} if query_model.query: params["query"] = escape_query(query_model.query) endpoint = "/search" return await self.http_client.get(endpoint, params=params, response_type=DelphiArtistsSearchCount) async def search_for_linkfire_links(self, query_model: LinkfireQuery) -> DelphiLinkfireLinksListResponse: params = {"limit": query_model.limit} if query_model.query: params["query"] = escape_query(query_model.query) endpoint = "/search/linkfire/links" return await self.http_client.get(endpoint, params=params, response_type=DelphiLinkfireLinksListResponse) async def get_artist_details(self, artist_id: str) -> DelphiArtistModel: endpoint = f"/artists/{artist_id}" return await self.http_client.get(endpoint, response_type=DelphiArtistModel) def map_amazon_stats(self, stats: AmazonTrackStreamsResponse) -> List[StreamingStats]: if len(stats.items) == 0: return [] region_code = stats.items[0].country_code daily_streams = list(map(self.map_amazon_stats_model, stats.items)) return [StreamingStats(countryCode=region_code, data=daily_streams)] def map_amazon_stats_model(self, stats_model: AmazonTracksStreamsStats) -> DailyStreams: return DailyStreams(date=stats_model.date, streams=stats_model.streams) async def get_ads_performance(self, query: AdsPerformanceQuery) -> AdsPerformanceResponse: params = remove_none_values( { "start_date": query.start_date.isoformat(), "end_date": query.end_date.isoformat(), "user_id": query.user_id, "dsp": query.dsp, # TODO: Check after API implementation "artist_id": query.artist_id, "campaign_id": query.campaign_id, "project_id": query.project_id, "provider_id": query.provider_id, "sub_category_id": query.sub_category_id, "decibel_label_id": query.decibel_label_id, "group_by": query.group_by, "is_assigned": self.__convert_bool(query.is_assigned), "is_pending": self.__convert_bool(query.is_pending), "phase_id": query.phase_id, "platform": query.platform, "sort_by": query.sort_by, "sort_order": query.sort_order, "country_code": query.country_code, } ) endpoint = "/ads/performance" return await self.http_client.get(endpoint, params=params, response_type=AdsPerformanceResponse) async def get_performance_metrics_data_health(self, query: PerformanceMetricsDataHealthQuery): endpoint = "/ads/data-health/dsp/status/analytics" params = remove_none_values( { "start_date": query.start_date.isoformat(), "end_date": query.end_date.isoformat(), "ads_account": query.ads_account } ) return await self.http_client.get( endpoint, params=params, response_type=PerformanceMetricsDataHealthDelphiResponse ) async def get_reporting_tab_data_health(self, start_date: date, end_date: date) -> ReportingDataHeathResponse: params = {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()} endpoint = "/data-health/dsp/status/analytics" return await self.http_client.get(endpoint, params=params, response_type=ReportingDataHeathResponse) def __convert_bool(self, param: Optional[bool]) -> Optional[str]: if param is None: return None return "true" if param else "false" async def get_project_streams_by_day(self, query: ProjectStreamsQuery) -> List[StreamsByDayResult]: params = { "start_date": query.start_date, "end_date": query.end_date, "include": "dsp_streams_info", "group_by": "date", "sort_by": "date", "sort_order": "asc", "country_code": query.country_code, "project_number": "GRAS_" + query.project_number, } endpoint = "/streams/project" response = await self.http_client.get(endpoint, params=params, response_type=StreamsByDayResponse) return self.__map_streams_by_day_response(response) async def search_for_tracks(self, query: DelphiTrackSearchQueryParams) -> DelphiTracksResponseSchema: params = { "product_family_no": query.product_id, "group_by": query.group_by, "sort_by": query.sort_by, "sort_order": query.sort_order } endpoint = "/tracks" response = await self.http_client.get(endpoint, params=params, response_type=DelphiTracksResponseSchema) return response