diff options
author | 2022-06-04 11:24:02 +0400 | |
---|---|---|
committer | 2022-06-04 11:24:02 +0400 | |
commit | 6402e8c014ee77952e72937dd2a91c1a023ca068 (patch) | |
tree | ba36d1ff7ff6ec29f78fbb2e801791bc6adcd747 | |
parent | Merge pull request #2179 from Autonymic/docs/clean-help (diff) | |
parent | Add Explicit Namespaces To Branding Caches (diff) |
Merge pull request #2153 from python-discord/multiple-banners
Add Support For Multiple Banners
-rw-r--r-- | bot/exts/backend/branding/_cog.py | 130 | ||||
-rw-r--r-- | bot/exts/backend/branding/_repository.py | 11 |
2 files changed, 70 insertions, 71 deletions
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index e55aa1995..ff2704835 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -1,6 +1,7 @@ import asyncio import contextlib import random +import types import typing as t from datetime import timedelta from enum import Enum @@ -104,19 +105,24 @@ class Branding(commands.Cog): """ # RedisCache[ - # "daemon_active": bool | If True, daemon starts on start-up. Controlled via commands. - # "event_path": str | Current event's path in the branding repo. - # "event_description": str | Current event's Markdown description. - # "event_duration": str | Current event's human-readable date range. - # "banner_hash": str | SHA of the currently applied banner. - # "icons_hash": str | Compound SHA of all icons in current rotation. - # "last_rotation_timestamp": float | POSIX UTC timestamp. + # "daemon_active": bool | If True, daemon starts on start-up. Controlled via commands. + # "event_path": str | Current event's path in the branding repo. + # "event_description": str | Current event's Markdown description. + # "event_duration": str | Current event's human-readable date range. + # "banners_hash": str | Compound SHA of all banners in the current rotation. + # "icons_hash": str | Compound SHA of all icons in current rotation. + # "last_icon_rotation_timestamp": float | POSIX UTC timestamp. + # "last_banner_rotation_timestamp": float | POSIX UTC timestamp. # ] cache_information = RedisCache() - # Icons in current rotation. Keys (str) are download URLs, values (int) track the amount of times each - # icon has been used in the current rotation. - cache_icons = RedisCache() + # Icons and banners in current rotation. + # Keys (str) are download URLs, values (int) track the amount of times each + # asset has been used in the current rotation. + asset_caches = types.MappingProxyType({ + AssetType.ICON: RedisCache(namespace="Branding.icon_cache"), + AssetType.BANNER: RedisCache(namespace="Branding.banner_cache") + }) # All available event names & durations. Cached by the daemon nightly; read by the calendar command. cache_events = RedisCache() @@ -164,107 +170,92 @@ class Branding(commands.Cog): log.trace("Asset uploaded successfully.") return True - async def apply_banner(self, banner: RemoteObject) -> bool: + async def rotate_assets(self, asset_type: AssetType) -> bool: """ - Apply `banner` to the guild and cache its hash if successful. + Choose and apply the next-up asset in rotation. - Banners should always be applied via this method to ensure that the last hash is cached. - - Return a boolean indicating whether the application was successful. - """ - success = await self.apply_asset(AssetType.BANNER, banner.download_url) - - if success: - await self.cache_information.set("banner_hash", banner.sha) - - return success - - async def rotate_icons(self) -> bool: - """ - Choose and apply the next-up icon in rotation. - - We keep track of the amount of times each icon has been used. The values in `cache_icons` can be understood - to be iteration IDs. When an icon is chosen & applied, we bump its count, pushing it into the next iteration. + We keep track of the amount of times each asset has been used. The values in the cache can be understood + to be iteration IDs. When an asset is chosen & applied, we bump its count, pushing it into the next iteration. Once the current iteration (lowest count in the cache) depletes, we move onto the next iteration. - In the case that there is only 1 icon in the rotation and has already been applied, do nothing. + In the case that there is only 1 asset in the rotation and has already been applied, do nothing. - Return a boolean indicating whether a new icon was applied successfully. + Return a boolean indicating whether a new asset was applied successfully. """ - log.debug("Rotating icons.") + log.debug(f"Rotating {asset_type.value}s.") - state = await self.cache_icons.to_dict() - log.trace(f"Total icons in rotation: {len(state)}.") + state = await self.asset_caches[asset_type].to_dict() + log.trace(f"Total {asset_type.value}s in rotation: {len(state)}.") if not state: # This would only happen if rotation not initiated, but we can handle gracefully. - log.warning("Attempted icon rotation with an empty icon cache. This indicates wrong logic.") + log.warning(f"Attempted {asset_type.value} rotation with an empty cache. This indicates wrong logic.") return False if len(state) == 1 and 1 in state.values(): - log.debug("Aborting icon rotation: only 1 icon is available and has already been applied.") + log.debug(f"Aborting {asset_type.value} rotation: only 1 asset is available and has already been applied.") return False current_iteration = min(state.values()) # Choose iteration to draw from. options = [download_url for download_url, times_used in state.items() if times_used == current_iteration] - log.trace(f"Choosing from {len(options)} icons in iteration {current_iteration}.") - next_icon = random.choice(options) + log.trace(f"Choosing from {len(options)} {asset_type.value}s in iteration {current_iteration}.") + next_asset = random.choice(options) - success = await self.apply_asset(AssetType.ICON, next_icon) + success = await self.apply_asset(asset_type, next_asset) if success: - await self.cache_icons.increment(next_icon) # Push the icon into the next iteration. + await self.asset_caches[asset_type].increment(next_asset) # Push the asset into the next iteration. timestamp = Arrow.utcnow().timestamp() - await self.cache_information.set("last_rotation_timestamp", timestamp) + await self.cache_information.set(f"last_{asset_type.value}_rotation_timestamp", timestamp) return success - async def maybe_rotate_icons(self) -> None: + async def maybe_rotate_assets(self, asset_type: AssetType) -> None: """ - Call `rotate_icons` if the configured amount of time has passed since last rotation. + Call `rotate_assets` if the configured amount of time has passed since last rotation. We offset the calculated time difference into the future to avoid off-by-a-little-bit errors. Because there is work to be done before the timestamp is read and written, the next read will likely commence slightly under 24 hours after the last write. """ - log.debug("Checking whether it's time for icons to rotate.") + log.debug(f"Checking whether it's time for {asset_type.value}s to rotate.") - last_rotation_timestamp = await self.cache_information.get("last_rotation_timestamp") + last_rotation_timestamp = await self.cache_information.get(f"last_{asset_type.value}_rotation_timestamp") if last_rotation_timestamp is None: # Maiden case ~ never rotated. - await self.rotate_icons() + await self.rotate_assets(asset_type) return last_rotation = Arrow.utcfromtimestamp(last_rotation_timestamp) difference = (Arrow.utcnow() - last_rotation) + timedelta(minutes=5) - log.trace(f"Icons last rotated at {last_rotation} (difference: {difference}).") + log.trace(f"{asset_type.value.title()}s last rotated at {last_rotation} (difference: {difference}).") if difference.days >= BrandingConfig.cycle_frequency: - await self.rotate_icons() + await self.rotate_assets(asset_type) - async def initiate_icon_rotation(self, available_icons: t.List[RemoteObject]) -> None: + async def initiate_rotation(self, asset_type: AssetType, available_assets: list[RemoteObject]) -> None: """ - Set up a new icon rotation. + Set up a new asset rotation. - This function should be called whenever available icons change. This is generally the case when we enter + This function should be called whenever available asset groups change. This is generally the case when we enter a new event, but potentially also when the assets of an on-going event change. In such cases, a reset - of `cache_icons` is necessary, because it contains download URLs which may have gotten stale. + of the cache is necessary, because it contains download URLs which may have gotten stale. - This function does not upload a new icon! + This function does not upload a new asset! """ - log.debug("Initiating new icon rotation.") + log.debug(f"Initiating new {asset_type.value} rotation.") - await self.cache_icons.clear() + await self.asset_caches[asset_type].clear() - new_state = {icon.download_url: 0 for icon in available_icons} - await self.cache_icons.update(new_state) + new_state = {asset.download_url: 0 for asset in available_assets} + await self.asset_caches[asset_type].update(new_state) - log.trace(f"Icon rotation initiated for {len(new_state)} icons.") + log.trace(f"{asset_type.value.title()} rotation initiated for {len(new_state)} assets.") - await self.cache_information.set("icons_hash", compound_hash(available_icons)) + await self.cache_information.set(f"{asset_type.value}s_hash", compound_hash(available_assets)) async def send_info_embed(self, channel_id: int, *, is_notification: bool) -> None: """ @@ -316,10 +307,12 @@ class Branding(commands.Cog): """ log.info(f"Entering event: '{event.path}'.") - banner_success = await self.apply_banner(event.banner) # Only one asset ~ apply directly. + # Prepare and apply new icon and banner rotations + await self.initiate_rotation(AssetType.ICON, event.icons) + await self.initiate_rotation(AssetType.BANNER, event.banners) - await self.initiate_icon_rotation(event.icons) # Prepare a new rotation. - icon_success = await self.rotate_icons() # Apply an icon from the new rotation. + icon_success = await self.rotate_assets(AssetType.ICON) + banner_success = await self.rotate_assets(AssetType.BANNER) # This will only be False in the case of a manual same-event re-synchronisation. event_changed = event.path != await self.cache_information.get("event_path") @@ -454,16 +447,19 @@ class Branding(commands.Cog): log.trace("Daemon main: event has not changed, checking for change in assets.") - if new_event.banner.sha != await self.cache_information.get("banner_hash"): + if compound_hash(new_event.banners) != await self.cache_information.get("banners_hash"): log.debug("Daemon main: detected banner change.") - await self.apply_banner(new_event.banner) + await self.initiate_rotation(AssetType.BANNER, new_event.banners) + await self.rotate_assets(AssetType.BANNER) + else: + await self.maybe_rotate_assets(AssetType.BANNER) if compound_hash(new_event.icons) != await self.cache_information.get("icons_hash"): log.debug("Daemon main: detected icon change.") - await self.initiate_icon_rotation(new_event.icons) - await self.rotate_icons() + await self.initiate_rotation(AssetType.ICON, new_event.icons) + await self.rotate_assets(AssetType.ICON) else: - await self.maybe_rotate_icons() + await self.maybe_rotate_assets(AssetType.ICON) @tasks.loop(hours=24) async def daemon_loop(self) -> None: diff --git a/bot/exts/backend/branding/_repository.py b/bot/exts/backend/branding/_repository.py index d88ea67f3..e14f0a1ef 100644 --- a/bot/exts/backend/branding/_repository.py +++ b/bot/exts/backend/branding/_repository.py @@ -64,8 +64,8 @@ class Event(t.NamedTuple): path: str # Path from repo root where event lives. This is the event's identity. meta: MetaFile - banner: RemoteObject - icons: t.List[RemoteObject] + banners: list[RemoteObject] + icons: list[RemoteObject] def __str__(self) -> str: return f"<Event at '{self.path}'>" @@ -163,21 +163,24 @@ class BrandingRepository: """ contents = await self.fetch_directory(directory.path) - missing_assets = {"meta.md", "banner.png", "server_icons"} - contents.keys() + missing_assets = {"meta.md", "server_icons", "banners"} - contents.keys() if missing_assets: raise BrandingMisconfiguration(f"Directory is missing following assets: {missing_assets}") server_icons = await self.fetch_directory(contents["server_icons"].path, types=("file",)) + banners = await self.fetch_directory(contents["banners"].path, types=("file",)) if len(server_icons) == 0: raise BrandingMisconfiguration("Found no server icons!") + if len(banners) == 0: + raise BrandingMisconfiguration("Found no server banners!") meta_bytes = await self.fetch_file(contents["meta.md"].download_url) meta_file = self.parse_meta_file(meta_bytes) - return Event(directory.path, meta_file, contents["banner.png"], list(server_icons.values())) + return Event(directory.path, meta_file, list(banners.values()), list(server_icons.values())) async def get_events(self) -> t.List[Event]: """ |