diff --git a/CHANGELOG.md b/CHANGELOG.md index c42ff5f256..09f7516247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ These changes are available on the `master` branch, but have not yet been releas - Added `Member.vr_status` property. ([#3328](https://github.com/Pycord-Development/pycord/pull/3328)) +- Added `Asset.size` and `Asset.extension` properties. + ([#3294](https://github.com/Pycord-Development/pycord/pull/3294)) ### Changed @@ -24,6 +26,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#3320](https://github.com/Pycord-Development/pycord/pull/3320)) - Fix `SyntaxWarning` about `return` in a `finally` block raised on Python 3.14+ ([#3332](https://github.com/Pycord-Development/pycord/pull/3334)) +- Fix animated asset URL extension from `.gif` to `.webp`. + ([#3294](https://github.com/Pycord-Development/pycord/pull/3294)) ### Deprecated diff --git a/discord/asset.py b/discord/asset.py index 659d6dd5eb..3bc1685541 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -156,41 +156,52 @@ class Asset(AssetMixin): "_url", "_animated", "_key", + "_extension", + "_size", ) BASE = "https://cdn.discordapp.com" - def __init__(self, state, *, url: str, key: str, animated: bool = False): + def __init__( + self, + state, + *, + url: str, + key: str, + animated: bool = False, + extension: str | None = MISSING, + size: int | None = None, + ): self._state = state self._url = url self._animated = animated self._key = key + self._size = size + + if extension is MISSING: + extension = "webp" if animated else "png" + self._extension = extension or "" @classmethod def _from_default_avatar(cls, state, index: int) -> Asset: return cls( - state, - url=f"{cls.BASE}/embed/avatars/{index}.png", - key=str(index), - animated=False, + state, url=f"{cls.BASE}/embed/avatars/{index}", key=str(index), size=1024 ) @classmethod def _from_avatar(cls, state, user_id: int, avatar: str) -> Asset: - animated = avatar.startswith("a_") - format = "gif" if animated else "png" return cls( state, - url=f"{cls.BASE}/avatars/{user_id}/{avatar}.{format}?size=1024", + url=f"{cls.BASE}/avatars/{user_id}/{avatar}", key=avatar, - animated=animated, + animated=avatar.startswith("a_"), + size=1024, ) @classmethod def _from_avatar_decoration( cls, state, user_id: int, avatar_decoration: str ) -> Asset: - animated = avatar_decoration.startswith("a_") endpoint = ( "avatar-decoration-presets" # if avatar_decoration.startswith(("v3", "v2")) @@ -198,9 +209,11 @@ def _from_avatar_decoration( ) return cls( state, - url=f"{cls.BASE}/{endpoint}/{avatar_decoration}.png?size=1024", + url=f"{cls.BASE}/{endpoint}/{avatar_decoration}", key=avatar_decoration, - animated=animated, + animated=avatar_decoration.startswith("a_"), + extension="png", + size=1024, ) @classmethod @@ -225,111 +238,110 @@ def _from_user_primary_guild_tag( """ return cls( state, - url=f"{Asset.BASE}/guild-tag-badges/{identity_guild_id}/{badge_id}.png?size=256", + url=f"{Asset.BASE}/guild-tag-badges/{identity_guild_id}/{badge_id}", key=badge_id, - animated=False, + extension="png", + size=256, ) @classmethod def _from_guild_avatar( cls, state, guild_id: int, member_id: int, avatar: str ) -> Asset: - animated = avatar.startswith("a_") - format = "gif" if animated else "png" return cls( state, - url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}.{format}?size=1024", + url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}", key=avatar, - animated=animated, + animated=avatar.startswith("a_"), + size=1024, ) @classmethod def _from_guild_banner( cls, state, guild_id: int, member_id: int, banner: str ) -> Asset: - animated = banner.startswith("a_") - format = "gif" if animated else "png" return cls( state, - url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=512", + url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}", key=banner, - animated=animated, + animated=banner.startswith("a_"), + size=512, ) @classmethod def _from_icon(cls, state, object_id: int, icon_hash: str, path: str) -> Asset: return cls( state, - url=f"{cls.BASE}/{path}-icons/{object_id}/{icon_hash}.png?size=1024", + url=f"{cls.BASE}/{path}-icons/{object_id}/{icon_hash}", key=icon_hash, - animated=False, + extension="png", + size=1024, ) @classmethod def _from_cover_image(cls, state, object_id: int, cover_image_hash: str) -> Asset: return cls( state, - url=f"{cls.BASE}/app-assets/{object_id}/store/{cover_image_hash}.png?size=1024", + url=f"{cls.BASE}/app-assets/{object_id}/store/{cover_image_hash}", key=cover_image_hash, - animated=False, + extension="png", + size=1024, ) @classmethod def _from_collectible( cls, state: ConnectionState, asset: str, animated: bool = False ) -> Asset: - name = "static.png" if not animated else "asset.webm" + name = "static" if not animated else "asset" + extension = "png" if not animated else "webm" return cls( state, url=f"{cls.BASE}/assets/collectibles/{asset}{name}", key=asset, animated=animated, + extension=extension, ) @classmethod def _from_guild_image(cls, state, guild_id: int, image: str, path: str) -> Asset: animated = False - format = "png" if path == "banners": animated = image.startswith("a_") - format = "gif" if animated else "png" - return cls( state, - url=f"{cls.BASE}/{path}/{guild_id}/{image}.{format}?size=1024", + url=f"{cls.BASE}/{path}/{guild_id}/{image}", key=image, animated=animated, + size=1024, ) @classmethod def _from_guild_icon(cls, state, guild_id: int, icon_hash: str) -> Asset: - animated = icon_hash.startswith("a_") - format = "gif" if animated else "png" return cls( state, - url=f"{cls.BASE}/icons/{guild_id}/{icon_hash}.{format}?size=1024", + url=f"{cls.BASE}/icons/{guild_id}/{icon_hash}", key=icon_hash, - animated=animated, + animated=icon_hash.startswith("a_"), + size=1024, ) @classmethod def _from_sticker_banner(cls, state, banner: int) -> Asset: return cls( state, - url=f"{cls.BASE}/app-assets/710982414301790216/store/{banner}.png", + url=f"{cls.BASE}/app-assets/710982414301790216/store/{banner}", key=str(banner), - animated=False, + extension="png", ) @classmethod def _from_user_banner(cls, state, user_id: int, banner_hash: str) -> Asset: - animated = banner_hash.startswith("a_") - format = "gif" if animated else "png" return cls( state, - url=f"{cls.BASE}/banners/{user_id}/{banner_hash}.{format}?size=512", + url=f"{cls.BASE}/banners/{user_id}/{banner_hash}", key=banner_hash, - animated=animated, + animated=banner_hash.startswith("a_"), + size=512, ) @classmethod @@ -338,9 +350,9 @@ def _from_scheduled_event_image( ) -> Asset: return cls( state, - url=f"{cls.BASE}/guild-events/{event_id}/{cover_hash}.png", + url=f"{cls.BASE}/guild-events/{event_id}/{cover_hash}", key=cover_hash, - animated=False, + extension="png", ) @classmethod @@ -349,34 +361,58 @@ def _from_soundboard_sound(cls, state, sound_id: int) -> Asset: state, url=f"{cls.BASE}/soundboard-sounds/{sound_id}", key=str(sound_id), + extension=None, ) def __str__(self) -> str: - return self._url + return self.url def __len__(self) -> int: - return len(self._url) + return len(self.url) def __repr__(self): - shorten = self._url.replace(self.BASE, "") + shorten = self.url.replace(self.BASE, "") return f"" def __eq__(self, other): - return isinstance(other, Asset) and self._url == other._url + return isinstance(other, Asset) and self.url == other.url def __hash__(self): - return hash(self._url) + return hash(self.url) @property def url(self) -> str: """Returns the underlying URL of the asset.""" - return self._url + + query: dict[str, str] = {} + if self._size is not None: + query["size"] = str(self._size) + if self._animated and self._extension == "webp": + query["animated"] = "true" + + parsed = yarl.URL(self._url) + if self._extension: + path, _ = os.path.splitext(parsed.path) + new_path = f"{path}.{self._extension}" + else: # for example soundboard + new_path = parsed.path + return str(parsed.with_path(new_path).with_query(query)) @property def key(self) -> str: """Returns the identifying key of the asset.""" return self._key + @property + def extension(self) -> str | None: + """Returns the file extension of the asset.""" + return self._extension + + @property + def size(self) -> int | None: + """Returns the size of the asset.""" + return self._size + def is_animated(self) -> bool: """Returns whether the asset is animated.""" return self._animated @@ -411,8 +447,9 @@ def replace( InvalidArgument An invalid size or format was passed. """ - url = yarl.URL(self._url) - path, _ = os.path.splitext(url.path) + + extension = self._extension + new_size = self._size if format is not MISSING: if self._animated: @@ -420,30 +457,32 @@ def replace( raise InvalidArgument( f"format must be one of {VALID_ASSET_FORMATS}" ) - url = url.with_path(f"{path}.{format}") + extension = format elif static_format is MISSING: if format not in VALID_STATIC_FORMATS: raise InvalidArgument( f"format must be one of {VALID_STATIC_FORMATS}" ) - url = url.with_path(f"{path}.{format}") - + extension = format if static_format is not MISSING and not self._animated: if static_format not in VALID_STATIC_FORMATS: raise InvalidArgument( f"static_format must be one of {VALID_STATIC_FORMATS}" ) - url = url.with_path(f"{path}.{static_format}") - + extension = static_format if size is not MISSING: if not utils.valid_icon_size(size): raise InvalidArgument("size must be a power of 2 between 16 and 4096") - url = url.with_query(size=size) - else: - url = url.with_query(url.raw_query_string) - - url = str(url) - return Asset(state=self._state, url=url, key=self._key, animated=self._animated) + new_size = size + + return Asset( + state=self._state, + url=self._url, + key=self._key, + animated=self._animated, + extension=extension, + size=new_size, + ) def with_size(self, size: int, /) -> Asset: """Returns a new asset with the specified size. @@ -466,8 +505,14 @@ def with_size(self, size: int, /) -> Asset: if not utils.valid_icon_size(size): raise InvalidArgument("size must be a power of 2 between 16 and 4096") - url = str(yarl.URL(self._url).with_query(size=size)) - return Asset(state=self._state, url=url, key=self._key, animated=self._animated) + return Asset( + state=self._state, + url=self._url, + key=self._key, + animated=self._animated, + extension=self._extension, + size=size, + ) def with_format(self, format: ValidAssetFormatTypes, /) -> Asset: """Returns a new asset with the specified format. @@ -493,11 +538,14 @@ def with_format(self, format: ValidAssetFormatTypes, /) -> Asset: raise InvalidArgument(f"format must be one of {VALID_ASSET_FORMATS}") elif format not in VALID_STATIC_FORMATS: raise InvalidArgument(f"format must be one of {VALID_STATIC_FORMATS}") - - url = yarl.URL(self._url) - path, _ = os.path.splitext(url.path) - url = str(url.with_path(f"{path}.{format}").with_query(url.raw_query_string)) - return Asset(state=self._state, url=url, key=self._key, animated=self._animated) + return Asset( + state=self._state, + url=self._url, + key=self._key, + animated=self._animated, + extension=format, + size=self._size, + ) def with_static_format(self, format: ValidStaticFormatTypes, /) -> Asset: """Returns a new asset with the specified static format.