Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ These changes are available on the `master` branch, but have not yet been releas
- Add `RoleColours.HOLOGRAPHIC_PRIMARY`, `RoleColours.HOLOGRAPHIC_SECONDARY`, and
`RoleColours.HOLOGRAPHIC_TERTIARY` class constants.
([#3268](https://github.com/Pycord-Development/pycord/pull/3268))
- Add `Asset.size` and `Asset.extension` properties.
([#3294](https://github.com/Pycord-Development/pycord/pull/3294))

### Fixed

Expand Down Expand Up @@ -68,6 +70,8 @@ These changes are available on the `master` branch, but have not yet been releas
([#3278](https://github.com/Pycord-Development/pycord/pull/3278))
- Fix `PartialMessage.edit()` to work with `DesignerView`.
([#3237](https://github.com/Pycord-Development/pycord/pull/3237))
- Fix animated asset url extension from `.gif` to `.webp`
([#3294](https://github.com/Pycord-Development/pycord/pull/3294))

## [2.8.0] - 2026-05-18

Expand Down
133 changes: 103 additions & 30 deletions discord/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,28 @@ 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 = None,
size: int | None = None,
):
self._state = state
self._url = url
self._animated = animated
self._key = key
self._extension = extension
self._size = size

@classmethod
def _from_default_avatar(cls, state, index: int) -> Asset:
Expand All @@ -173,17 +186,18 @@ def _from_default_avatar(cls, state, index: int) -> Asset:
url=f"{cls.BASE}/embed/avatars/{index}.png",
key=str(index),
animated=False,
extension="png",
)

@classmethod
def _from_avatar(cls, state, user_id: int, avatar: str) -> Asset:
animated = avatar.startswith("a_")
format = "gif" if animated else "png"
format = "webp" if animated else "png"
url = f"{cls.BASE}/avatars/{user_id}/{avatar}.{format}?size=1024"
if animated:
url += "&animated=true"
return cls(
state,
url=f"{cls.BASE}/avatars/{user_id}/{avatar}.{format}?size=1024",
key=avatar,
animated=animated,
state, url=url, key=avatar, animated=animated, extension=format, size=1024
)

@classmethod
Expand All @@ -201,6 +215,8 @@ def _from_avatar_decoration(
url=f"{cls.BASE}/{endpoint}/{avatar_decoration}.png?size=1024",
key=avatar_decoration,
animated=animated,
extension="png",
size=1024,
)

@classmethod
Expand Down Expand Up @@ -228,32 +244,34 @@ def _from_user_primary_guild_tag(
url=f"{Asset.BASE}/guild-tag-badges/{identity_guild_id}/{badge_id}.png?size=256",
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"
format = "webp" if animated else "png"
url = f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}.{format}?size=1024"
if animated:
url += "&animated=true"
return cls(
state,
url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}.{format}?size=1024",
key=avatar,
animated=animated,
state, url=url, key=avatar, animated=animated, extension=format, 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"
format = "webp" if animated else "png"
url = f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=512"
if animated:
url += "&animated=true"
return cls(
state,
url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=512",
key=banner,
animated=animated,
state, url=url, key=banner, animated=animated, extension=format, size=512
)

@classmethod
Expand All @@ -263,6 +281,8 @@ def _from_icon(cls, state, object_id: int, icon_hash: str, path: str) -> Asset:
url=f"{cls.BASE}/{path}-icons/{object_id}/{icon_hash}.png?size=1024",
key=icon_hash,
animated=False,
extension="png",
size=1024,
)

@classmethod
Expand All @@ -272,6 +292,8 @@ def _from_cover_image(cls, state, object_id: int, cover_image_hash: str) -> Asse
url=f"{cls.BASE}/app-assets/{object_id}/store/{cover_image_hash}.png?size=1024",
key=cover_image_hash,
animated=False,
extension="png",
size=1024,
)

@classmethod
Expand All @@ -284,6 +306,7 @@ def _from_collectible(
url=f"{cls.BASE}/assets/collectibles/{asset}{name}",
key=asset,
animated=animated,
extension="webm" if animated else "png",
)

@classmethod
Expand All @@ -292,24 +315,29 @@ def _from_guild_image(cls, state, guild_id: int, image: str, path: str) -> Asset
format = "png"
if path == "banners":
animated = image.startswith("a_")
format = "gif" if animated else "png"
format = "webp" if animated else "png"

url = f"{cls.BASE}/{path}/{guild_id}/{image}.{format}?size=1024"
if animated:
url += "&animated=true"
return cls(
state,
url=f"{cls.BASE}/{path}/{guild_id}/{image}.{format}?size=1024",
key=image,
animated=animated,
state, url=url, key=image, animated=animated, extension=format, 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"
format = "webp" if animated else "png"
url = f"{cls.BASE}/icons/{guild_id}/{icon_hash}.{format}?size=1024"
if animated:
url += "&animated=true"
return cls(
state,
url=f"{cls.BASE}/icons/{guild_id}/{icon_hash}.{format}?size=1024",
url=url,
key=icon_hash,
animated=animated,
extension=format,
size=1024,
)

@classmethod
Expand All @@ -319,17 +347,23 @@ def _from_sticker_banner(cls, state, banner: int) -> Asset:
url=f"{cls.BASE}/app-assets/710982414301790216/store/{banner}.png",
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"
format = "webp" if animated else "png"
url = f"{cls.BASE}/banners/{user_id}/{banner_hash}.{format}?size=512"
if animated:
url += "&animated=true"
return cls(
state,
url=f"{cls.BASE}/banners/{user_id}/{banner_hash}.{format}?size=512",
url=url,
key=banner_hash,
animated=animated,
extension=format,
size=512,
)

@classmethod
Expand All @@ -341,6 +375,7 @@ def _from_scheduled_event_image(
url=f"{cls.BASE}/guild-events/{event_id}/{cover_hash}.png",
key=cover_hash,
animated=False,
extension="png",
)

@classmethod
Expand All @@ -349,6 +384,8 @@ 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,
size=None,
)

def __str__(self) -> str:
Expand Down Expand Up @@ -377,6 +414,16 @@ 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
Expand Down Expand Up @@ -414,36 +461,48 @@ def replace(
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:
if format not in VALID_ASSET_FORMATS:
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)
new_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)
return Asset(
state=self._state,
url=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.
Expand All @@ -467,7 +526,14 @@ def with_size(self, size: int, /) -> Asset:
raise InvalidArgument("size must be a power of 2 between 16 and 4096")

url = str(yarl.URL(self._url).with_query(size=size))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_query replaces the entire query string rather than merging into it. See the discussion.

Suggested change
url = str(yarl.URL(self._url).with_query(size=size))
url = yarl.URL(self._url).with_query(size=size)
if self._animated:
url = url.update_query(animated="true")
url = str(url)

return Asset(state=self._state, url=url, key=self._key, animated=self._animated)
return Asset(
state=self._state,
url=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.
Expand Down Expand Up @@ -497,7 +563,14 @@ def with_format(self, format: ValidAssetFormatTypes, /) -> Asset:
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=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.
Expand Down