1
0

presence_sources.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. """Animes, musiques et jeux réels pour le statut Discord, lus sur AniList, Deezer et Steam."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. from dataclasses import asdict, dataclass
  7. from pathlib import Path
  8. import aiohttp
  9. logger = logging.getLogger("chatbot.presence")
  10. ANILIST_URL = "https://graphql.anilist.co"
  11. ANILIST_QUERY = """
  12. query ($page: Int) {
  13. Page(page: $page, perPage: 50) {
  14. media(type: ANIME, sort: POPULARITY_DESC, isAdult: false) {
  15. title { romaji } episodes duration nextAiringEpisode { episode }
  16. }
  17. }
  18. }
  19. """
  20. ANIME_PAGES = 10 # 10 pages de 50 : le top 500 des animes les plus populaires
  21. # Les tops Deezer contiennent 100 titres.
  22. DEEZER_TRACKS_URL = "https://api.deezer.com/playlist/{}/tracks?limit=100"
  23. STEAM_URL = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
  24. DEFAULT_TRACK_DURATION = 180 # secondes, si Deezer n'indique pas la durée
  25. DEFAULT_EPISODE_DURATION = 24 # minutes, si AniList n'indique pas la durée
  26. TIMEOUT = aiohttp.ClientTimeout(total=30)
  27. class SourceError(Exception):
  28. """Lecture d'une source impossible. Le message ne contient jamais la clé Steam."""
  29. @dataclass(frozen=True)
  30. class Anime:
  31. title: str
  32. episodes: int | None # épisodes déjà sortis (None : inconnu) ; 1 pour un film
  33. duration: int # minutes par épisode
  34. @dataclass(frozen=True)
  35. class Track:
  36. title: str
  37. artist: str
  38. duration: int # secondes
  39. async def get_json(session: aiohttp.ClientSession, method: str, url: str, **kwargs) -> dict:
  40. try:
  41. async with session.request(method, url, **kwargs) as response:
  42. if response.status in (401, 403):
  43. raise SourceError(f"accès refusé (HTTP {response.status})")
  44. if response.status != 200:
  45. raise SourceError(f"réponse HTTP {response.status}")
  46. return await response.json(content_type=None)
  47. except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc:
  48. # Pas de str(exc) : l'adresse de la requête, qu'il peut contenir, porte la clé Steam.
  49. raise SourceError(type(exc).__name__) from None
  50. class PresenceSources:
  51. """Listes téléchargées au démarrage puis une fois par jour, gardées en cache sur le disque.
  52. Si une source ne répond pas, sa liste précédente (ou celle du cache) est conservée."""
  53. def __init__(self, cache_file: Path, deezer_playlists: tuple[int, ...],
  54. steam_api_key: str | None, steam_id: str | None):
  55. self.cache_file = cache_file
  56. self.deezer_playlists = deezer_playlists
  57. self.steam_api_key = steam_api_key
  58. self.steam_id = steam_id
  59. self.anime: list[Anime] = []
  60. self.music: list[Track] = []
  61. self.games: list[str] = []
  62. @property
  63. def steam_enabled(self) -> bool:
  64. return bool(self.steam_api_key and self.steam_id)
  65. def load_cache(self) -> None:
  66. try:
  67. data = json.loads(self.cache_file.read_text(encoding="utf-8"))
  68. self.anime = [Anime(**a) for a in data.get("anime", [])]
  69. self.music = [Track(**t) for t in data.get("music", [])]
  70. if self.steam_enabled:
  71. self.games = list(data.get("games", []))
  72. except FileNotFoundError:
  73. pass
  74. except (OSError, ValueError, TypeError) as exc:
  75. logger.warning("Cache des statuts %s illisible, ignoré : %s", self.cache_file, exc)
  76. def save_cache(self) -> None:
  77. data = {"anime": [asdict(a) for a in self.anime], "music": [asdict(t) for t in self.music], "games": self.games}
  78. try:
  79. self.cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
  80. except OSError as exc:
  81. logger.warning("Impossible d'écrire le cache des statuts %s : %s", self.cache_file, exc)
  82. async def refresh(self) -> None:
  83. sources = [("anime", "Animes (AniList)", self.fetch_anime), ("music", "Musiques (Deezer)", self.fetch_music)]
  84. if self.steam_enabled:
  85. sources.append(("games", "Jeux (Steam)", self.fetch_games))
  86. async with aiohttp.ClientSession(timeout=TIMEOUT) as session:
  87. for attr, label, fetch in sources:
  88. try:
  89. items = await fetch(session)
  90. if not items:
  91. raise SourceError("liste vide")
  92. except SourceError as exc:
  93. logger.warning("%s : lecture impossible (%s), %d élément(s) conservé(s).",
  94. label, exc, len(getattr(self, attr)))
  95. else:
  96. setattr(self, attr, items)
  97. logger.info("%s : %d élément(s).", label, len(items))
  98. self.save_cache()
  99. @staticmethod
  100. async def fetch_anime(session: aiohttp.ClientSession) -> list[Anime]:
  101. anime: dict[str, Anime] = {}
  102. for page in range(1, ANIME_PAGES + 1):
  103. data = await get_json(session, "POST", ANILIST_URL,
  104. json={"query": ANILIST_QUERY, "variables": {"page": page}})
  105. try:
  106. media = data["data"]["Page"]["media"]
  107. except (KeyError, TypeError):
  108. raise SourceError("réponse inattendue") from None
  109. for m in media:
  110. title = (m.get("title") or {}).get("romaji")
  111. if not title:
  112. continue
  113. # Anime en cours de diffusion : seuls les épisodes déjà sortis comptent.
  114. episodes = m.get("episodes")
  115. if episodes is None and m.get("nextAiringEpisode"):
  116. episodes = m["nextAiringEpisode"]["episode"] - 1 or None
  117. anime[title] = Anime(title, episodes, m.get("duration") or DEFAULT_EPISODE_DURATION)
  118. return list(anime.values())
  119. async def fetch_music(self, session: aiohttp.ClientSession) -> list[Track]:
  120. tracks: dict[int, Track] = {} # par identifiant Deezer : un titre présent dans deux tops ne compte qu'une fois
  121. for playlist in self.deezer_playlists:
  122. data = await get_json(session, "GET", DEEZER_TRACKS_URL.format(playlist))
  123. if "error" in data: # Deezer signale ses erreurs avec un code HTTP 200
  124. raise SourceError(f"playlist {playlist} : {data['error'].get('message', 'erreur')}")
  125. for t in data.get("data", []):
  126. if t.get("id") and t.get("title") and t.get("artist", {}).get("name"):
  127. tracks[t["id"]] = Track(t["title"], t["artist"]["name"], t.get("duration") or DEFAULT_TRACK_DURATION)
  128. return list(tracks.values())
  129. async def fetch_games(self, session: aiohttp.ClientSession) -> list[str]:
  130. params = {"key": self.steam_api_key, "steamid": self.steam_id, "include_appinfo": "1",
  131. "include_played_free_games": "1", "format": "json"}
  132. data = await get_json(session, "GET", STEAM_URL, params=params)
  133. games = data.get("response", {}).get("games")
  134. if not games:
  135. raise SourceError("bibliothèque vide ou privée : « Détails des jeux » doit être public dans le profil Steam")
  136. return sorted({g["name"] for g in games if g.get("name")})