def _handle_api_exception(self, api_data, logger, start) -> Optional[Step]: """Return a step if an error in API response was detected. Else return None.""" error_message: Optional[str] = None if "API Timeout" in api_data.values(): error_message = "API Timeout" elif "API rate limit exceeded" in api_data.values(): error_message = "Radio France API rate limit exceeded" elif api_data.get("data") is None: error_message = "No data provided by Radio France API" elif not api_data["data"]["grid"]: error_message = "Grid provided by Radio France API is empty" if error_message: logger.error(error_message) return Step.empty_until(start, start + 90, self) if error_message else None
def get_step(self, logger: Logger, dt: datetime, channel) -> UpdateInfo: """Returns mapping containing info about current song. If music: {"type": BroadcastType.MUSIC, "artist": artist, "title": title} If ads: "type": BroadcastType.ADS Else: "type": BroadcastType.NONE Moreover, returns other metadata for postprocessing. end datetime object To sum up, here are the keys of returned mapping: - type: BroadcastType object - end: timestamp in sec - artist: str (optional) - title: str (optional) - thumbnail_src: url to thumbnail """ start = int(dt.timestamp()) try: fetched_data = self._fetch_song_metadata() except requests.exceptions.Timeout: return self._update_info(Step.empty_until(start, start + 90, self)) end = fetched_data["end"] if start > end: if not self._get_show_metadata(dt): return self._update_info(Step.empty(start, self)) end = 0 broadcast_data = { "thumbnail_src": self.station_thumbnail, "type": BroadcastType.PROGRAMME, "title": self.station_slogan, } else: title = fetched_data['title'] artist = fetched_data['singer'] thumbnail = fetched_data.get("cover") or self.station_thumbnail broadcast_data = { "title": f"{artist} • {title}", "type": BroadcastType.MUSIC, "thumbnail_src": thumbnail, "metadata": SongPayload(title=title, artist=artist) } broadcast_data.update(station=self.station_info, **self._get_show_metadata(dt)) return self._update_info( Step(start=start, end=end, broadcast=Broadcast(**broadcast_data)))
class RadioFranceStation(URLStation): API_RATE_LIMIT_EXCEEDED = 1 _station_api_name: str _grid_template = RADIO_FRANCE_GRID_TEMPLATE @property def token(self): if os.getenv("TOKEN") is None: # in case of development server load_dotenv() if os.getenv("TOKEN") is None: raise RuntimeError("No token for Radio France API found.") return os.getenv("TOKEN") @staticmethod def _notifying_update_info(step): return UpdateInfo(should_notify_update=True, step=step) def _fetch_metadata(self, start: datetime, end: datetime, retry=0, current=0, raise_exc=False) -> Dict[Any, Any]: """Fetch metadata from radiofrance open API.""" query = self._grid_template.format(start=int(start.timestamp()), end=int(end.timestamp()), station=self._station_api_name) try: rep = requests.post( url="https://openapi.radiofrance.fr/v1/graphql?x-token={}". format(self.token), json={"query": query}, timeout=4, ) except requests.exceptions.Timeout: if current < retry: return self._fetch_metadata(start, end, retry, current + 1, raise_exc) if raise_exc: raise TimeoutError("Radio France API Timeout") return {"message": "API Timeout"} data = json.loads(rep.text) return data @staticmethod def _find_current_child_show(children: List[Any], parent: Dict[str, Any], start: int) -> Tuple[Dict, int, bool]: """Return current show among children and its end timestamp. Sometimes, current timestamp is between 2 children. In this case, return parent show and next child start as end. Parameters: - children: list of dict representing radiofrance steps - parent: dict representing radiofrance step - start: datetime object representing asked timestamp Return a tuple containing: - dict representing a step - end timestamp - True if the current show is child or not """ # on enlève les enfants vides (les TrackStep que l'on ne prend pas en compte) children = filter(bool, children) # on trie dans l'ordre inverse children = sorted(children, key=lambda x: x.get("start"), reverse=True) # on initialise l'enfant suivant (par défaut le dernier) next_child = children[0] # et on parcourt la liste des enfants à l'envers for child in children: # dans certains cas, le type de step ne nous intéresse pas # et est donc vide, on passe directement au suivant # (c'est le cas des TrackSteps) if child.get("start") is None: continue # si le début du programme est à venir, on passe au précédent if child["start"] > start: next_child = child continue # au premier programme dont le début est avant la date courante # on sait qu'on est potentiellement dans le programme courant. # Il faut vérifier que l'on est encore dedans en vérifiant : if child["end"] > start: return child, int(child["end"]), True # sinon, on est dans un "trou" : on utilise donc le parent # et le début de l'enfant suivant. Cas particulier : si on est # entre la fin du dernier enfant et la fin du parent (càd l'enfant # suivant est égal à l'enfant courant), on prend la fin du parent. elif next_child == child: return parent, int(parent["end"]), False else: return parent, int(next_child["start"]), False else: # si on est ici, c'est que la boucle a parcouru tous les enfants # sans valider child["start"] < now. Autrement dit, le premier # enfant n'a pas encore commencé. On renvoie donc le parent et le # début du premier enfant (stocké dans next_child) comme end return parent, int(next_child.get("start")) or parent["end"], False def _handle_api_exception(self, api_data, logger, start) -> Optional[Step]: """Return a step if an error in API response was detected. Else return None.""" error_message: Optional[str] = None if "API Timeout" in api_data.values(): error_message = "API Timeout" elif "API rate limit exceeded" in api_data.values(): error_message = "Radio France API rate limit exceeded" elif api_data.get("data") is None: error_message = "No data provided by Radio France API" elif not api_data["data"]["grid"]: error_message = "Grid provided by Radio France API is empty" if error_message: logger.error(error_message) return Step.empty_until(start, start + 90, self) if error_message else None @staticmethod def _get_detailed_metadata(metadata: dict, parent: dict, child: dict, is_child: bool) -> dict: """Alter (add detailed information to) a copy of metadata and return it :param metadata: metadata to update (this method creates a copy and alter it) :param parent: parent broadcast :param child: child broadcast (may be identical to parent) :param is_child: if True, add parent_show_title and parent_show_link info :return: updated copy of metadata input """ detailed_metadata = metadata.copy() diffusion = child.get("diffusion") or {} show = diffusion.get("show") or {} if not is_child: diffusion_summary = diffusion.get("standFirst", "") or "" if len(diffusion_summary.strip()) == 1: diffusion_summary = "" detailed_metadata.update({ "show_link": show.get("url", ""), "link": diffusion.get("url", ""), "summary": diffusion_summary.strip(), }) return detailed_metadata parent_diffusion = parent.get("diffusion") or {} parent_show = parent_diffusion.get("show") or {} diffusion_summary = diffusion.get( "standFirst", "") or parent_diffusion.get("standFirst", "") or "" child_show_link = diffusion.get("url", "") or parent_diffusion.get( "url", "") parent_show_link = parent_show.get("url") or "" parent_show_title = parent_show.get("title") or parent["title"] # on vérifie que les infos parents ne sont pas redondantes avec les infos enfantes if (parent_show_link == child_show_link or parent_show_title.upper() in (metadata.get( "title", "").upper(), metadata.get("show_title", "").upper())): parent_show_link = "" parent_show_title = "" if len(diffusion_summary.strip()) == 1: diffusion_summary = "" detailed_metadata.update({ "show_link": show.get("url", ""), "link": diffusion.get("url", "") or parent_diffusion.get("url", ""), "summary": diffusion_summary.strip(), "parent_show_title": parent_show_title, "parent_show_link": parent_show_link, }) return detailed_metadata def _get_radiofrance_step(self, api_data: dict, dt: datetime, child_precision: bool, detailed: bool) -> Step: """Return a track step or a programme step from Radio France depending of contained metadata""" return (self._get_radiofrance_track_step(api_data, dt) if api_data.get("track") else self._get_radiofrance_programme_step( api_data, dt, child_precision, detailed)) def _get_radiofrance_programme_step(self, api_data: dict, dt: datetime, child_precision: bool, detailed: bool): """Return radio france step starting at dt. Parameters: child_precision: bool -- if True, search current child if current broadcast contains any detailed: bool -- if True, return more info in step such as summary, external links, parent broadcast """ start = max(int(api_data["start"]), int(dt.timestamp())) metadata = { "station": self.station_info, "type": BroadcastType.PROGRAMME } children = (api_data.get("children") or []) if child_precision else [] broadcast, broadcast_end, is_child = (self._find_current_child_show( children, api_data, start) if any(children) else (api_data, int(api_data["end"]), False)) diffusion = broadcast.get("diffusion") if diffusion is None: title = broadcast["title"] show_title = "" thumbnail_src = self.station_thumbnail else: show = diffusion.get("show", {}) or {} title = diffusion.get("title") or show.get("title", "") show_title = show.get("title", "") if title != show.get("title", "") else "" podcast_link = (show.get("podcast") or {}).get("itunes") thumbnail_src = music.fetch_apple_podcast_cover( podcast_link, self.station_thumbnail) metadata.update({ "title": title, "show_title": show_title, "thumbnail_src": thumbnail_src, }) if detailed: metadata = self._get_detailed_metadata(metadata, api_data, broadcast, is_child) return Step(start=start, end=broadcast_end, broadcast=Broadcast(**metadata)) def _get_radiofrance_track_step(self, api_data: dict, dt: datetime): start = max(int(api_data["start"]), int(dt.timestamp())) track_data = api_data["track"] artists = ", ".join( track_data.get("mainArtists") or track_data.get("performers")) cover_link, deezer_link = music.fetch_cover_and_link_on_deezer( self.station_thumbnail, artists, track_data.get("albumTitle")) return Step(start=start, end=api_data["end"], broadcast=Broadcast( title=f"{artists} • {track_data['title']}", type=BroadcastType.MUSIC, station=self.station_info, link=deezer_link, summary=self.station_slogan, thumbnail_src=cover_link, metadata=SongPayload( title=track_data["title"], artist=artists, album=track_data.get("album", ""), base64_cover_art=url_to_base64(cover_link)))) def get_step(self, logger: Logger, dt: datetime, channel) -> UpdateInfo: start = int(dt.timestamp()) fetched_data = self._fetch_metadata(dt, dt + timedelta(minutes=120)) if (error_step := self._handle_api_exception(fetched_data, logger, start)) is not None: return self._notifying_update_info(error_step) try: # on récupère la première émission trouvée first_show_in_grid = fetched_data["data"]["grid"][0] # si celle-ci est terminée et la suivante n'a pas encore démarrée # alors on RENVOIE une métadonnées neutre jusqu'au démarrage de l'émission # suivante if first_show_in_grid["end"] < start: next_show = fetched_data["data"]["grid"][1] return self._notifying_update_info( Step.empty_until(start, int(next_show["start"]), self)) # si l'émission n'est pas encore démarrée, on RENVOIE une métaonnée neutre # jusqu'au démarrage de celle-ci if first_show_in_grid["start"] > dt.timestamp(): return self._notifying_update_info( Step.empty_until(start, int(first_show_in_grid['start']), self)) # cas où on est dans un programme, on RENVOIE les métadonnées de ce programme return self._notifying_update_info( self._get_radiofrance_step(first_show_in_grid, dt, child_precision=True, detailed=True)) except Exception as err: logger.error(traceback.format_exc()) logger.error("Données récupérées avant l'exception : {}".format( fetched_data)) return self._notifying_update_info( Step.empty_until(start, start + 90, self))
if (error_step := self._handle_api_exception(api_data, logger, int(dt.timestamp()))) is not None: return error_step try: first_show_in_grid = api_data["data"]["grid"][0] return self._get_radiofrance_step(first_show_in_grid, dt, child_precision=True, detailed=False) except Exception as err: start = int(dt.timestamp()) logger.error(traceback.format_exc()) logger.error( "Données récupérées avant l'exception : {}".format(api_data)) return Step.empty_until(start, start + 90, self) def get_schedule(self, logger: Logger, start: datetime, end: datetime) -> List[Step]: api_data = self._fetch_metadata(start, end, retry=5, raise_exc=True) temp_end, end = start, end steps = [] grid = api_data["data"]["grid"] while grid: step_data = grid.pop(0) new_step = self._get_radiofrance_programme_step( step_data, temp_end, child_precision=False, detailed=False) steps.append(new_step) temp_end = datetime.fromtimestamp(new_step.end) return steps