|
4 | 4 | import logging |
5 | 5 | import re |
6 | 6 | from datetime import datetime, timedelta, timezone |
7 | | -from typing import Generator, Literal, Optional, Tuple, Union |
| 7 | +from typing import AsyncGenerator, Literal, Optional, Tuple, Union |
8 | 8 |
|
9 | 9 | import aiohttp |
10 | 10 | import config # type: ignore |
|
21 | 21 |
|
22 | 22 | mclient = pymongo.MongoClient(config.mongoURI) |
23 | 23 |
|
24 | | -GIANTBOMB_NSW_ID = 157 |
25 | 24 | AUTO_SYNC = False |
26 | 25 | SEARCH_RATIO_THRESHOLD = 50 |
27 | 26 |
|
28 | 27 |
|
| 28 | +class DekuDeals: |
| 29 | + def __init__(self, api_key): |
| 30 | + self.ENDPOINT = 'https://www.dekudeals.com/api/rNS/games' |
| 31 | + self.api_key = api_key |
| 32 | + |
| 33 | + async def fetch_games(self, platform: str): |
| 34 | + offset = 0 |
| 35 | + |
| 36 | + for _ in range(1, 3): # change this to 1000 later or something |
| 37 | + async with aiohttp.ClientSession() as session: |
| 38 | + headers = {'User-Agent': 'MechaBowser (+https://github.com/rNintendoSwitch/MechaBowser)'} |
| 39 | + params = {'api_key': self.api_key, 'offset': offset} |
| 40 | + |
| 41 | + if platform: |
| 42 | + params['platform'] = platform |
| 43 | + |
| 44 | + async with session.get(self.ENDPOINT, params=params, headers=headers) as resp: |
| 45 | + resp_json = await resp.json() |
| 46 | + |
| 47 | + for item in resp_json['games']: |
| 48 | + yield item |
| 49 | + |
| 50 | + offset += len(resp_json['games']) |
| 51 | + |
| 52 | + if len(resp_json['games']) < 100: |
| 53 | + break # no more results expected |
| 54 | + |
| 55 | + |
29 | 56 | class Games(commands.Cog, name='Games'): |
30 | 57 | def __init__(self, bot): |
31 | 58 | self.bot = bot |
| 59 | + self.DekuDeals = DekuDeals(config.dekudeals) |
32 | 60 | self.db = mclient.bowser.games |
33 | 61 |
|
| 62 | + self.last_sync = { |
| 63 | + 'part': {'at': None, 'count': {'games': 0, 'releases': 0}, 'running': False}, |
| 64 | + 'full': {'at': None, 'count': {'games': 0, 'releases': 0}, 'running': False}, |
| 65 | + } |
| 66 | + |
| 67 | + # TODO uncomment |
34 | 68 | # Ensure indices exist |
35 | | - self.db.create_index([("date_last_updated", pymongo.DESCENDING)]) |
36 | | - self.db.create_index([("guid", pymongo.ASCENDING)], unique=True) |
37 | | - self.db.create_index([("game.id", pymongo.ASCENDING)]) |
| 69 | + # self.db.create_index([("deku_id", pymongo.ASCENDING)], unique=True) |
38 | 70 |
|
39 | 71 | # Generate the pipeline |
40 | 72 | self.pipeline = [ |
@@ -62,6 +94,90 @@ def __init__(self, bot): |
62 | 94 | ] |
63 | 95 | self.aggregatePipeline = list(self.db.aggregate(self.pipeline)) |
64 | 96 |
|
| 97 | + if AUTO_SYNC: |
| 98 | + self.sync_db.start() |
| 99 | + |
| 100 | + async def cog_unload(self): |
| 101 | + if AUTO_SYNC: |
| 102 | + self.sync_db.cancel() |
| 103 | + |
| 104 | + @tasks.loop(hours=1) |
| 105 | + async def sync_db(self, force_full: bool = False) -> Tuple[int, str]: |
| 106 | + # If last full sync was more then a day ago (or on restart/forced), preform a new full sync |
| 107 | + day_ago = datetime.now(tz=timezone.utc) - timedelta(days=1) |
| 108 | + full = force_full or ((self.last_sync['full']['at'] < day_ago) if self.last_sync['full']['at'] else True) |
| 109 | + |
| 110 | + if not full: |
| 111 | + try: |
| 112 | + latest_doc = self.db.find().sort("date_last_updated", pymongo.DESCENDING).limit(1).next() |
| 113 | + after = latest_doc['date_last_updated'] |
| 114 | + except StopIteration: |
| 115 | + full = True # Do full sync if we're having issues getting latest updated |
| 116 | + |
| 117 | + detail_str = '(full)' if full else f'(partial after {after})' |
| 118 | + logging.info(f'[Games] Syncing games database {detail_str}...') |
| 119 | + self.last_sync['full' if full else 'part']['running'] = True |
| 120 | + |
| 121 | + if full: |
| 122 | + # Flag items so we can detect if they are not updated. |
| 123 | + self.db.update_many({}, {'$set': {'_full_sync_updated': False}}) |
| 124 | + |
| 125 | + count = {} |
| 126 | + for type, path in [('game', 'games'), ('release', 'releases')]: |
| 127 | + count[path] = 0 |
| 128 | + try: |
| 129 | + async for game in self.GiantBomb.fetch_items(path, None if full else after): |
| 130 | + if full: |
| 131 | + game['_full_sync_updated'] = True |
| 132 | + |
| 133 | + self.update_item_in_db(type, game) |
| 134 | + count[path] += 1 |
| 135 | + |
| 136 | + except aiohttp.ClientResponseError as e: |
| 137 | + if e.status in [429, 420]: # Giantbomb uses 420 as ratelimiting |
| 138 | + logging.error('[Games] Ratelimited with GiantBomb, attempting retry at next loop') |
| 139 | + return |
| 140 | + |
| 141 | + except Exception as e: |
| 142 | + logging.error(f'[Games] Exception while syncing games: {e}') |
| 143 | + raise |
| 144 | + |
| 145 | + if full: |
| 146 | + self.db.delete_many({'_full_sync_updated': False}) # If items were not updated, delete them |
| 147 | + |
| 148 | + logging.info(f'[Games] Finished syncing {count["games"]} games and {count["releases"]} releases {detail_str}') |
| 149 | + self.last_sync['full' if full else 'part'] = { |
| 150 | + 'at': datetime.now(tz=timezone.utc), |
| 151 | + 'count': count, |
| 152 | + 'running': False, |
| 153 | + } |
| 154 | + self.aggregatePipeline = list(self.db.aggregate(self.pipeline)) |
| 155 | + |
| 156 | + return count, detail_str |
| 157 | + |
| 158 | + def update_item_in_db(self, type: Literal['game', 'release'], game: dict): |
| 159 | + if type not in ['game', 'release']: |
| 160 | + raise ValueError(f'invalid type: {type}') |
| 161 | + |
| 162 | + date_keys = { |
| 163 | + 'game': ['date_added', 'date_last_updated', 'original_release_date'], |
| 164 | + 'release': ['date_added', 'date_last_updated', 'release_date'], |
| 165 | + } |
| 166 | + |
| 167 | + for key in date_keys[type]: # Parse dates |
| 168 | + if game[key]: |
| 169 | + game[key] = parser.parse(game[key]) |
| 170 | + |
| 171 | + if type == 'game' and game['aliases']: |
| 172 | + game['aliases'] = game['aliases'].splitlines() |
| 173 | + |
| 174 | + if type == 'release': |
| 175 | + game['_gameid'] = game['game']['id'] |
| 176 | + |
| 177 | + game['_type'] = type |
| 178 | + |
| 179 | + return self.db.replace_one({'guid': game['guid']}, game, upsert=True) |
| 180 | + |
65 | 181 | def search(self, query: str) -> Optional[dict]: |
66 | 182 | match = {'guid': None, 'score': None, 'name': None} |
67 | 183 | for game in self.aggregatePipeline: |
@@ -175,6 +291,26 @@ class GamesCommand(app_commands.Group): |
175 | 291 |
|
176 | 292 | games_group = GamesCommand(name='game', description='Find out information about games for the Nintendo Switch!') |
177 | 293 |
|
| 294 | + async def _games_search_autocomplete(self, interaction: discord.Interaction, current: str): |
| 295 | + if current: |
| 296 | + game = self.search(current) |
| 297 | + |
| 298 | + else: |
| 299 | + # Current textbox is empty |
| 300 | + return [] |
| 301 | + |
| 302 | + if game: |
| 303 | + return [app_commands.Choice(name=game['name'], value=game['guid'])] |
| 304 | + |
| 305 | + else: |
| 306 | + return [] |
| 307 | + |
| 308 | + @app_commands.guilds(discord.Object(id=config.nintendoswitch)) |
| 309 | + class GamesCommand(app_commands.Group): |
| 310 | + pass |
| 311 | + |
| 312 | + games_group = GamesCommand(name='game', description='Find out information about games for the Nintendo Switch!') |
| 313 | + |
178 | 314 | @games_group.command(name='search') |
179 | 315 | @app_commands.describe(query='The term you want to search for a game') |
180 | 316 | @app_commands.checks.cooldown(2, 60, key=lambda i: (i.guild_id, i.user.id)) |
@@ -219,6 +355,8 @@ async def _games_search(self, interaction: discord.Interaction, query: str): |
219 | 355 |
|
220 | 356 | embed.set_footer(text=f'{result["score"] }% confident{alias_str} ⯁ Entry last updated') |
221 | 357 |
|
| 358 | + # TODO: publishers/developers |
| 359 | + |
222 | 360 | # Build release date line |
223 | 361 | if self.parse_expected_release_date(game): |
224 | 362 | game_desc = f'\n**Expected Release Date:** {self.parse_expected_release_date(game, True)}' |
@@ -311,12 +449,24 @@ async def _games_info(self, interaction: discord.Interaction): |
311 | 449 | embed.add_field(name='Games Stored', value=game_count, inline=True) |
312 | 450 | embed.add_field(name='Releases Stored', value=release_count, inline=True) |
313 | 451 |
|
| 452 | + # TODO: Replace with new DekuDeals logic |
| 453 | + # https://github.com/rNintendoSwitch/MechaBowser/blob/47ea5ba33bd2345356d7c0bd49c6b0ad7599f01c/modules/games.py#L558 |
314 | 454 | newest_update_game = self.db.find_one(sort=[("date_last_updated", -1)]) |
315 | 455 | last_sync = int(newest_update_game['date_last_updated'].timestamp()) |
316 | 456 |
|
317 | 457 | embed.add_field(name=f'Last Sync', value=f'<t:{last_sync}:R>', inline=False) |
318 | 458 |
|
319 | 459 | return await interaction.followup.send(embed=embed) |
| 460 | + |
| 461 | + # called by core.py |
| 462 | + async def games_sync(self, interaction: discord.Interaction, full: bool): |
| 463 | + '''Force a database sync''' |
| 464 | + await interaction.response.send_message('Running sync...') |
| 465 | + |
| 466 | + c, detail = await self.sync_db(full) |
| 467 | + message = f'{config.greenTick} Finished syncing {c["games"]} games and {c["releases"]} releases {detail}' |
| 468 | + return await interaction.edit_original_response(content=message) |
| 469 | + |
320 | 470 |
|
321 | 471 |
|
322 | 472 | async def setup(bot): |
|
0 commit comments