Add Steam embed thumbnail support
All checks were successful
Test and build Docker image / docker (push) Successful in 25s
All checks were successful
Test and build Docker image / docker (push) Successful in 25s
This commit is contained in:
parent
6acc27865a
commit
fc50aed740
9 changed files with 371 additions and 8 deletions
|
|
@ -38,6 +38,7 @@ class CustomEmbed:
|
|||
thumbnail_url: str = ""
|
||||
footer_text: str = ""
|
||||
footer_icon_url: str = ""
|
||||
show_steam_game_icon_in_thumbnail: bool = False
|
||||
|
||||
|
||||
def try_to_replace(custom_message: str, template: str, replace_with: str) -> str:
|
||||
|
|
@ -398,7 +399,7 @@ def save_embed(reader: Reader, feed: Feed, embed: CustomEmbed) -> None:
|
|||
feed: The feed to set the tag in.
|
||||
embed: The embed to set.
|
||||
"""
|
||||
embed_dict: dict[str, str | int] = {
|
||||
embed_dict: dict[str, str | int | bool] = {
|
||||
"title": embed.title,
|
||||
"description": embed.description,
|
||||
"color": embed.color,
|
||||
|
|
@ -409,6 +410,7 @@ def save_embed(reader: Reader, feed: Feed, embed: CustomEmbed) -> None:
|
|||
"thumbnail_url": embed.thumbnail_url,
|
||||
"footer_text": embed.footer_text,
|
||||
"footer_icon_url": embed.footer_icon_url,
|
||||
"show_steam_game_icon_in_thumbnail": embed.show_steam_game_icon_in_thumbnail,
|
||||
}
|
||||
reader.set_tag(feed, "embed", json.dumps(embed_dict)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
|
@ -428,7 +430,7 @@ def get_embed(reader: Reader, feed: Feed) -> CustomEmbed:
|
|||
if embed:
|
||||
if not isinstance(embed, str):
|
||||
return get_embed_data(embed) # pyright: ignore[reportArgumentType]
|
||||
embed_data: dict[str, str | int] = json.loads(embed)
|
||||
embed_data: dict[str, str | int | bool] = json.loads(embed)
|
||||
return get_embed_data(embed_data)
|
||||
|
||||
return CustomEmbed(
|
||||
|
|
@ -442,10 +444,22 @@ def get_embed(reader: Reader, feed: Feed) -> CustomEmbed:
|
|||
thumbnail_url="",
|
||||
footer_text="",
|
||||
footer_icon_url="",
|
||||
show_steam_game_icon_in_thumbnail=False,
|
||||
)
|
||||
|
||||
|
||||
def get_embed_data(embed_data: dict[str, str | int]) -> CustomEmbed:
|
||||
def coerce_embed_bool(value: object) -> bool:
|
||||
"""Normalize stored embed booleans from JSON or form-like values."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "on", "yes"}
|
||||
return False
|
||||
|
||||
|
||||
def get_embed_data(embed_data: dict[str, str | int | bool]) -> CustomEmbed:
|
||||
"""Get embed data from embed_data.
|
||||
|
||||
Args:
|
||||
|
|
@ -464,6 +478,9 @@ def get_embed_data(embed_data: dict[str, str | int]) -> CustomEmbed:
|
|||
thumbnail_url: str = str(embed_data.get("thumbnail_url", ""))
|
||||
footer_text: str = str(embed_data.get("footer_text", ""))
|
||||
footer_icon_url: str = str(embed_data.get("footer_icon_url", ""))
|
||||
show_steam_game_icon_in_thumbnail: bool = coerce_embed_bool(
|
||||
embed_data.get("show_steam_game_icon_in_thumbnail", False),
|
||||
)
|
||||
|
||||
return CustomEmbed(
|
||||
title=title,
|
||||
|
|
@ -476,4 +493,5 @@ def get_embed_data(embed_data: dict[str, str | int]) -> CustomEmbed:
|
|||
thumbnail_url=thumbnail_url,
|
||||
footer_text=footer_text,
|
||||
footer_icon_url=footer_icon_url,
|
||||
show_steam_game_icon_in_thumbnail=show_steam_game_icon_in_thumbnail,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import re
|
|||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
|
@ -186,6 +187,123 @@ def extract_domain(url: str) -> str: # noqa: PLR0911
|
|||
return "Other"
|
||||
|
||||
|
||||
STEAM_STORE_APP_ID_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
("feeds", "news", "app"),
|
||||
("news", "app"),
|
||||
("app",),
|
||||
)
|
||||
|
||||
|
||||
def is_steam_feed_url(url: str) -> bool:
|
||||
"""Return whether a feed URL belongs to Steam."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed_url: ParseResult = urlparse(url)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
normalized_netloc: str = parsed_url.netloc.lower().removeprefix("www.")
|
||||
return normalized_netloc in frozenset({"store.steampowered.com", "steamcommunity.com"})
|
||||
|
||||
|
||||
def _extract_steam_app_id_from_path(normalized_netloc: str, path_segments: list[str]) -> str | None:
|
||||
if normalized_netloc == "store.steampowered.com":
|
||||
for prefix in STEAM_STORE_APP_ID_PATH_PREFIXES:
|
||||
prefix_length: int = len(prefix)
|
||||
if len(path_segments) > prefix_length and tuple(path_segments[:prefix_length]) == prefix:
|
||||
app_id: str = path_segments[prefix_length]
|
||||
if app_id.isdigit():
|
||||
return app_id
|
||||
|
||||
if normalized_netloc == "steamcommunity.com" and len(path_segments) > 1:
|
||||
app_type: str = path_segments[0]
|
||||
app_id = path_segments[1]
|
||||
if app_type in frozenset({"games", "app"}) and app_id.isdigit():
|
||||
return app_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_steam_app_id_from_query(parsed_url: ParseResult) -> str | None:
|
||||
query_params: dict[str, list[str]] = parse_qs(parsed_url.query)
|
||||
for key in ("appid", "app_id", "appids"):
|
||||
for value in query_params.get(key, []):
|
||||
cleaned_value: str = value.strip()
|
||||
if cleaned_value.isdigit():
|
||||
return cleaned_value
|
||||
return None
|
||||
|
||||
|
||||
def extract_steam_app_id_from_url(url: str) -> str | None:
|
||||
"""Extract a Steam application ID from a Steam URL when present.
|
||||
|
||||
Args:
|
||||
url: The Steam URL to inspect.
|
||||
|
||||
Returns:
|
||||
str | None: The application ID when present in the path or query string, otherwise None.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_url: ParseResult = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
normalized_netloc: str = parsed_url.netloc.lower().removeprefix("www.")
|
||||
path_segments: list[str] = [segment for segment in parsed_url.path.split("/") if segment]
|
||||
return _extract_steam_app_id_from_path(normalized_netloc, path_segments) or _extract_steam_app_id_from_query(
|
||||
parsed_url
|
||||
)
|
||||
|
||||
|
||||
def get_local_steam_game_icon_file(app_id: str) -> WebhookFile | None:
|
||||
"""Return a local Steam game icon file for Discord upload when available."""
|
||||
icon_path: Path = Path(__file__).resolve().parent.parent / "icons" / f"{app_id}.png"
|
||||
if not icon_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
icon_bytes: bytes = icon_path.read_bytes()
|
||||
except OSError:
|
||||
logger.exception("Failed to read local Steam icon for app %s from %s", app_id, icon_path)
|
||||
return None
|
||||
|
||||
if not icon_bytes:
|
||||
logger.warning("Local Steam icon file is empty for app %s: %s", app_id, icon_path)
|
||||
return None
|
||||
|
||||
content_hash: str = hashlib.sha256(icon_bytes).hexdigest()[:12]
|
||||
return WebhookFile(
|
||||
filename=f"steam-app-{app_id}-{content_hash}.png",
|
||||
content=icon_bytes,
|
||||
)
|
||||
|
||||
|
||||
def get_steam_game_thumbnail(entry: Entry) -> tuple[str | None, WebhookFile | None]:
|
||||
"""Return the preferred Steam thumbnail source for an entry.
|
||||
|
||||
Returns:
|
||||
tuple[str | None, WebhookFile | None]: Thumbnail URL and optional uploaded file.
|
||||
"""
|
||||
feed_url: str = str(getattr(entry.feed, "url", "") or "")
|
||||
if not is_steam_feed_url(feed_url):
|
||||
return None, None
|
||||
|
||||
app_id: str | None = extract_steam_app_id_from_url(feed_url) or extract_steam_app_id_from_url(str(entry.link or ""))
|
||||
if not app_id:
|
||||
return None, None
|
||||
|
||||
local_icon_file: WebhookFile | None = get_local_steam_game_icon_file(app_id)
|
||||
if local_icon_file:
|
||||
return f"attachment://{local_icon_file.filename}", local_icon_file
|
||||
|
||||
return f"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_sm_120.jpg", None
|
||||
|
||||
|
||||
def send_entry_to_discord(entry: Entry, reader: Reader) -> str | None:
|
||||
"""Send a single entry to Discord.
|
||||
|
||||
|
|
@ -1719,6 +1837,10 @@ def create_embed_webhook( # noqa: C901, PLR0912
|
|||
custom_embed: CustomEmbed = replace_tags_in_embed(feed=feed, entry=entry, reader=reader)
|
||||
media_gallery_image_limit: int = get_feed_media_gallery_image_limit(reader, feed)
|
||||
webhook_text_length_limit: int = get_feed_webhook_text_length_limit(reader, feed)
|
||||
steam_game_thumbnail_url: str | None = None
|
||||
steam_game_thumbnail_file: WebhookFile | None = None
|
||||
if custom_embed.show_steam_game_icon_in_thumbnail:
|
||||
steam_game_thumbnail_url, steam_game_thumbnail_file = get_steam_game_thumbnail(entry)
|
||||
if media_gallery_image_limit == 0:
|
||||
custom_embed.image_url = ""
|
||||
custom_embed.thumbnail_url = ""
|
||||
|
|
@ -1771,8 +1893,9 @@ def create_embed_webhook( # noqa: C901, PLR0912
|
|||
icon_url=custom_embed.author_icon_url,
|
||||
)
|
||||
|
||||
if custom_embed.thumbnail_url:
|
||||
discord_embed.set_thumbnail(url=custom_embed.thumbnail_url)
|
||||
embed_thumbnail_url: str = steam_game_thumbnail_url or custom_embed.thumbnail_url
|
||||
if embed_thumbnail_url:
|
||||
discord_embed.set_thumbnail(url=embed_thumbnail_url)
|
||||
|
||||
if custom_embed.image_url:
|
||||
discord_embed.set_image(url=custom_embed.image_url)
|
||||
|
|
@ -1786,6 +1909,9 @@ def create_embed_webhook( # noqa: C901, PLR0912
|
|||
if custom_embed.footer_icon_url and not custom_embed.footer_text:
|
||||
discord_embed.set_footer(text="-", icon_url=custom_embed.footer_icon_url)
|
||||
|
||||
if steam_game_thumbnail_file:
|
||||
webhook.add_file(file=steam_game_thumbnail_file.content, filename=steam_game_thumbnail_file.filename)
|
||||
|
||||
webhook.add_embed(discord_embed)
|
||||
return webhook
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ from discord_rss_bot.feeds import get_feed_media_gallery_image_limit
|
|||
from discord_rss_bot.feeds import get_feed_webhook_text_length_limit
|
||||
from discord_rss_bot.feeds import get_screenshot_layout
|
||||
from discord_rss_bot.feeds import get_sent_webhook_records
|
||||
from discord_rss_bot.feeds import is_steam_feed_url
|
||||
from discord_rss_bot.feeds import send_entry_to_discord
|
||||
from discord_rss_bot.feeds import send_to_discord
|
||||
from discord_rss_bot.feeds import update_feed_and_collect_modified_entries
|
||||
|
|
@ -1221,7 +1222,7 @@ async def get_embed_page(
|
|||
# Get previous data, this is used when creating the form.
|
||||
embed: CustomEmbed = get_embed(reader, feed)
|
||||
|
||||
context: dict[str, Request | Feed | str | Entry | CustomEmbed] = {
|
||||
context: dict[str, Request | Feed | str | Entry | CustomEmbed | bool] = {
|
||||
"request": request,
|
||||
"feed": feed,
|
||||
"title": embed.title,
|
||||
|
|
@ -1234,6 +1235,7 @@ async def get_embed_page(
|
|||
"author_icon_url": embed.author_icon_url,
|
||||
"footer_text": embed.footer_text,
|
||||
"footer_icon_url": embed.footer_icon_url,
|
||||
"show_steam_game_icon_in_thumbnail": embed.show_steam_game_icon_in_thumbnail,
|
||||
}
|
||||
if custom_embed := get_embed(reader, feed):
|
||||
context["custom_embed"] = custom_embed
|
||||
|
|
@ -1258,6 +1260,7 @@ async def post_embed( # noqa: C901
|
|||
author_icon_url: Annotated[str, Form()] = "",
|
||||
footer_text: Annotated[str, Form()] = "",
|
||||
footer_icon_url: Annotated[str, Form()] = "",
|
||||
show_steam_game_icon_in_thumbnail: Annotated[bool, Form()] = False,
|
||||
) -> RedirectResponse:
|
||||
"""Set the embed settings.
|
||||
|
||||
|
|
@ -1273,6 +1276,7 @@ async def post_embed( # noqa: C901
|
|||
author_icon_url: The author icon url of the embed.
|
||||
footer_text: The footer text of the embed.
|
||||
footer_icon_url: The footer icon url of the embed.
|
||||
show_steam_game_icon_in_thumbnail: Whether to use the Steam game icon as the embed thumbnail.
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
|
|
@ -1303,6 +1307,8 @@ async def post_embed( # noqa: C901
|
|||
custom_embed.footer_text = footer_text
|
||||
if footer_icon_url != custom_embed.footer_icon_url:
|
||||
custom_embed.footer_icon_url = footer_icon_url
|
||||
if show_steam_game_icon_in_thumbnail != custom_embed.show_steam_game_icon_in_thumbnail:
|
||||
custom_embed.show_steam_game_icon_in_thumbnail = show_steam_game_icon_in_thumbnail
|
||||
|
||||
# Save the data.
|
||||
save_embed(reader, feed, custom_embed)
|
||||
|
|
@ -1855,6 +1861,7 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
|
|||
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
|
||||
"is_steam_feed": is_steam_feed_url(feed.url),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="feed.html", context=context)
|
||||
|
||||
|
|
@ -1921,6 +1928,7 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
|
|||
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
|
||||
"is_steam_feed": is_steam_feed_url(feed.url),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="feed.html", context=context)
|
||||
|
||||
|
|
|
|||
|
|
@ -245,6 +245,17 @@
|
|||
<label for="thumbnail_url" class="col-sm-6 col-form-label">Thumbnail</label>
|
||||
<input name="thumbnail_url" type="text" class="form-control bg-dark border-dark text-muted"
|
||||
id="thumbnail_url" {% if thumbnail_url %} value="{{- thumbnail_url -}}" {% endif %} />
|
||||
<div class="form-check mt-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="show_steam_game_icon_in_thumbnail"
|
||||
name="show_steam_game_icon_in_thumbnail"
|
||||
value="true"
|
||||
{% if show_steam_game_icon_in_thumbnail %}checked{% endif %}
|
||||
/>
|
||||
<label class="form-check-label" for="show_steam_game_icon_in_thumbnail"> Use Steam game icon as thumbnail when available </label>
|
||||
</div>
|
||||
<label for="footer_text" class="col-sm-6 col-form-label">Footer text</label>
|
||||
<input name="footer_text" type="text" class="form-control bg-dark border-dark text-muted"
|
||||
id="footer_text" {% if footer_text %} value="{{- footer_text -}}" {% endif %} />
|
||||
|
|
|
|||
|
|
@ -338,6 +338,9 @@
|
|||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_steam_feed %}
|
||||
<div class="form-text mt-2">Steam feeds can enable thumbnails in embed settings.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<section class="mt-4 pt-3 border-top border-secondary-subtle">
|
||||
<h3 class="h6 text-uppercase text-muted mb-3">Feed URL</h3>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue