Add stylesheet to RSS/Atom feeds
All checks were successful
Deploy to Server / deploy (push) Successful in 10s

This commit is contained in:
Joakim Hellsén 2026-03-14 01:25:21 +01:00
commit a73fdc4e66
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 846 additions and 270 deletions

View file

@ -7,6 +7,8 @@ from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.syndication.views import Feed
from django.db.models import Prefetch
from django.db.models.query import QuerySet
from django.http.request import HttpRequest
from django.templatetags.static import static
from django.urls import reverse
from django.utils import feedgenerator
from django.utils import timezone
@ -34,6 +36,125 @@ if TYPE_CHECKING:
from twitch.models import DropBenefit
logger: logging.Logger = logging.getLogger("ttvdrops")
RSS_STYLESHEETS: list[str] = [static("rss_styles.xslt")]
class BrowserFriendlyRss201rev2Feed(feedgenerator.Rss201rev2Feed):
"""RSS 2.0 feed generator with a browser-renderable XML content type."""
content_type = "application/xml; charset=utf-8"
class BrowserFriendlyAtom1Feed(feedgenerator.Atom1Feed):
"""Atom 1.0 feed generator with an explicit browser-friendly content type."""
content_type = "application/xml; charset=utf-8"
class TTVDropsBaseFeed(Feed):
"""Base feed class that keeps XML feeds browser-friendly.
By default, Django's syndication feed framework serves feeds with a
content type of "application/rss+xml", which causes browsers to
download the feed as a file instead of displaying it. By overriding
the __call__ method to set the Content-Disposition header to "inline",
we can make browsers display the feed content directly, improving the
user experience when visiting feed URLs in a browser.
"""
feed_type = BrowserFriendlyRss201rev2Feed
feed_copyright: str = "CC0; Information wants to be free."
stylesheets: list[str] = RSS_STYLESHEETS
ttl: int = 1
_request: HttpRequest | None = None
def _absolute_url(self, url: str) -> str:
"""Build an absolute URL for feed identifiers when request context exists.
Args:
url (str): Relative or absolute URL to normalize for feed metadata.
Returns:
str: Absolute URL when request context exists, otherwise the original URL.
"""
if self._request is None:
return url
return self._request.build_absolute_uri(url)
def _absolute_stylesheet_urls(self, request: HttpRequest) -> list[str]:
"""Return stylesheet URLs as absolute HTTP URLs for browser/file compatibility."""
return [
href
if href.startswith(("http://", "https://"))
else request.build_absolute_uri(href)
for href in self.stylesheets
]
def _inject_atom_stylesheets(self, response: HttpResponse) -> None:
"""Inject xml-stylesheet processing instructions for Atom feeds.
Django emits stylesheet processing instructions for RSS feeds, but not for
Atom feeds. Browsers then show Atom summaries as escaped HTML text. By
injecting stylesheet PIs into Atom XML responses, we can transform Atom in
the browser with the same XSLT used for RSS.
"""
if not self.stylesheets:
return
encoding: str = response.charset or "utf-8"
content: str = response.content.decode(encoding)
# Detect Atom payload by XML structure/namespace so this still works even
# when served as application/xml for browser-friendliness.
if "<feed" not in content or "http://www.w3.org/2005/Atom" not in content:
return
if "<?xml-stylesheet" in content:
return
stylesheet_pis: str = "".join(
f'<?xml-stylesheet href="{href}" type="text/xsl" media="screen"?>'
for href in self.stylesheets
)
if content.startswith("<?xml"):
xml_decl_end: int = content.find("?>")
if xml_decl_end != -1:
content = (
f"{content[: xml_decl_end + 2]}{stylesheet_pis}"
f"{content[xml_decl_end + 2 :]}"
)
else:
content = f"{stylesheet_pis}{content}"
else:
content = f"{stylesheet_pis}{content}"
response.content = content.encode(encoding)
def __call__(
self,
request: HttpRequest,
*args: object,
**kwargs: object,
) -> HttpResponse:
"""Return feed response with inline content disposition for browser display."""
original_stylesheets: list[str] = self.stylesheets
self.stylesheets = self._absolute_stylesheet_urls(request)
self._request = request
try:
response: HttpResponse = super().__call__(request, *args, **kwargs)
self._inject_atom_stylesheets(response)
finally:
self.stylesheets = original_stylesheets
self._request = None
response["Content-Disposition"] = "inline"
return response
class TTVDropsAtomBaseFeed(TTVDropsBaseFeed):
"""Base class for Atom feeds with shared browser-friendly behavior."""
feed_type = BrowserFriendlyAtom1Feed
def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCampaign]:
@ -241,7 +362,7 @@ def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
if drops_data:
parts.append(
format_html(
"<p>{}</p>",
"{}",
_construct_drops_summary(drops_data, channel_name=channel_name),
),
)
@ -500,14 +621,15 @@ def _construct_drops_summary(
# MARK: /rss/organizations/
class OrganizationRSSFeed(Feed):
class OrganizationRSSFeed(TTVDropsBaseFeed):
"""RSS feed for latest organizations."""
feed_type = feedgenerator.Rss201rev2Feed
feed_type = BrowserFriendlyRss201rev2Feed
title: str = "TTVDrops Twitch Organizations"
link: str = "/organizations/"
description: str = "Latest organizations on TTVDrops"
feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(
@ -572,15 +694,19 @@ class OrganizationRSSFeed(Feed):
"""Return the author name for the organization."""
return getattr(item, "name", "Twitch")
def feed_url(self) -> str:
"""Return the absolute URL for this feed."""
return reverse("twitch:organization_feed")
# MARK: /rss/games/
class GameFeed(Feed):
class GameFeed(TTVDropsBaseFeed):
"""RSS feed for newly added games."""
title: str = "Games - TTVDrops"
title: str = "TTVDrops Twitch Games"
link: str = "/games/"
description: str = "Newly added games on TTVDrops"
feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(
@ -676,9 +802,9 @@ class GameFeed(Feed):
return timezone.now()
def item_guid(self, item: Game) -> str:
"""Return a unique identifier for each game."""
"""Return a unique identifier for each game. Use the URL to the game detail page as the GUID."""
twitch_id: str = getattr(item, "twitch_id", "unknown")
return twitch_id + "@ttvdrops.com"
return self._absolute_url(reverse("twitch:game_detail", args=[twitch_id]))
def item_author_name(self, item: Game) -> str:
"""Return the author name for the game, typically the owner organization name."""
@ -695,38 +821,36 @@ class GameFeed(Feed):
return box_art
return ""
def item_enclosure_length(self, item: Game) -> int:
"""Returns the length of the enclosure.
def item_enclosures(self, item: Game) -> list[feedgenerator.Enclosure]:
"""Return a list of enclosures for the game, including the box art if available."""
image_url: str = getattr(item, "box_art_best_url", "")
if image_url:
try:
size: int | None = getattr(item, "box_art_size_bytes", None)
length: int = int(size) if size is not None else 0
except TypeError, ValueError:
length = 0
Prefer the newly-added ``box_art_size_bytes`` field so that the RSS
feed can include an accurate ``length`` attribute. Fall back to 0 if
the value is missing or ``None``.
"""
try:
size = getattr(item, "box_art_size_bytes", None)
return int(size) if size is not None else 0
except TypeError, ValueError:
return 0
mime: str = getattr(item, "box_art_mime_type", "")
mime_type: str = mime or "image/jpeg"
def item_enclosure_mime_type(self, item: Game) -> str:
"""Returns the MIME type of the enclosure.
return [feedgenerator.Enclosure(image_url, str(length), mime_type)]
return []
Use the ``box_art_mime_type`` field when available, otherwise fall back
to a generic JPEG string (as was previously hard-coded).
"""
mime: str = getattr(item, "box_art_mime_type", "")
return mime or "image/jpeg"
def feed_url(self) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("twitch:game_feed")
# MARK: /rss/campaigns/
class DropCampaignFeed(Feed):
class DropCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for latest drop campaigns."""
title: str = "Twitch Drop Campaigns"
link: str = "/campaigns/"
description: str = "Latest Twitch drop campaigns on TTVDrops"
feed_url: str = "/rss/campaigns/"
feed_copyright: str = "Information wants to be free."
item_guid_is_permalink = True
_limit: int | None = None
def __call__(
@ -762,7 +886,8 @@ class DropCampaignFeed(Feed):
def item_title(self, item: DropCampaign) -> SafeText:
"""Return the campaign name as the item title (SafeText for RSS)."""
return SafeText(item.get_feed_title())
game_name: str = item.game.display_name if item.game else ""
return SafeText(f"{game_name}: {item.clean_name}")
def item_description(self, item: DropCampaign) -> SafeText:
"""Return a description of the campaign."""
@ -779,14 +904,22 @@ class DropCampaignFeed(Feed):
def item_link(self, item: DropCampaign) -> str:
"""Return the link to the campaign detail."""
return item.get_feed_link()
return reverse("twitch:campaign_detail", args=[item.twitch_id])
def item_guid(self, item: DropCampaign) -> str:
"""Return a unique identifier for each campaign. Use the URL to the campaign detail page as the GUID."""
return self._absolute_url(
reverse("twitch:campaign_detail", args=[item.twitch_id]),
)
def item_pubdate(self, item: DropCampaign) -> datetime.datetime:
"""Returns the publication date to the feed item.
Fallback to updated_at or now if missing.
"""
return item.get_feed_pubdate()
if item.added_at:
return item.added_at
return timezone.now()
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
"""Returns the campaign's last update time."""
@ -794,39 +927,60 @@ class DropCampaignFeed(Feed):
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
"""Returns the associated game's name as a category."""
return item.get_feed_categories()
categories: list[str] = ["twitch", "drops"]
def item_guid(self, item: DropCampaign) -> str:
"""Return a unique identifier for each campaign."""
return item.get_feed_guid()
game: Game | None = item.game
if game:
categories.append(game.get_game_name)
# Prefer direct game owners, which can be prefetched in feed querysets.
categories.extend(org.name for org in game.owners.all() if org.name)
return tuple(categories)
def item_author_name(self, item: DropCampaign) -> str:
"""Return the author name for the campaign, typically the game name."""
return item.get_feed_author_name()
game: Game | None = item.game
if game and game.display_name:
return game.display_name
def item_enclosure_url(self, item: DropCampaign) -> str:
"""Returns the URL of the campaign image for enclosure."""
return item.get_feed_enclosure_url()
return "Twitch"
def item_enclosure_length(self, item: DropCampaign) -> int:
"""Returns the length of the enclosure."""
try:
size: int | None = getattr(item, "image_size_bytes", None)
return int(size) if size is not None else 0
except TypeError, ValueError:
return 0
# Enclose
def item_enclosures(self, item: DropCampaign) -> list[feedgenerator.Enclosure]:
"""Return a list of enclosures for the drop campaign, if available.
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
"""Returns the MIME type of the enclosure."""
mime: str = getattr(item, "image_mime_type", "")
return mime or "image/jpeg"
Args:
item (DropCampaign): The drop campaign item.
Returns:
list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is
available, otherwise an empty list.
"""
image_url: str = getattr(item, "image_best_url", "")
if image_url:
try:
size: int | None = getattr(item, "image_size_bytes", None)
length: int = int(size) if size is not None else 0
except TypeError, ValueError:
length = 0
mime: str = getattr(item, "image_mime_type", "")
mime_type: str = mime or "image/jpeg"
return [feedgenerator.Enclosure(image_url, str(length), mime_type)]
return []
def feed_url(self) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("twitch:campaign_feed")
# MARK: /rss/games/<twitch_id>/campaigns/
class GameCampaignFeed(Feed):
class GameCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for the latest drop campaigns of a specific game."""
feed_copyright: str = "Information wants to be free."
item_guid_is_permalink = True
_limit: int | None = None
def __call__(
@ -876,10 +1030,6 @@ class GameCampaignFeed(Feed):
"""Return a description for the feed."""
return f"Latest drop campaigns for {obj.display_name}"
def feed_url(self, obj: Game) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("twitch:game_campaign_feed", args=[obj.twitch_id])
def items(self, obj: Game) -> list[DropCampaign]:
"""Return the latest drop campaigns for this game, ordered by most recent start date (default 200, or limited by ?limit query param)."""
limit: int = self._limit if self._limit is not None else 200
@ -892,7 +1042,8 @@ class GameCampaignFeed(Feed):
def item_title(self, item: DropCampaign) -> SafeText:
"""Return the campaign name as the item title (SafeText for RSS)."""
return SafeText(item.get_feed_title())
game_name: str = item.game.display_name if item.game else ""
return SafeText(f"{game_name}: {item.clean_name}")
def item_description(self, item: DropCampaign) -> SafeText:
"""Return a description of the campaign."""
@ -924,43 +1075,82 @@ class GameCampaignFeed(Feed):
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
"""Returns the associated game's name as a category."""
return item.get_feed_categories()
categories: list[str] = ["twitch", "drops"]
game: Game | None = item.game
if game:
categories.append(game.get_game_name)
# Prefer direct game owners, which can be prefetched in feed querysets.
categories.extend(org.name for org in game.owners.all() if org.name)
return tuple(categories)
def item_guid(self, item: DropCampaign) -> str:
"""Return a unique identifier for each campaign."""
return item.get_feed_guid()
return self._absolute_url(
reverse("twitch:campaign_detail", args=[item.twitch_id]),
)
def item_author_name(self, item: DropCampaign) -> str:
"""Return the author name for the campaign, typically the game name."""
return item.get_feed_author_name()
game: Game | None = item.game
if game and game.display_name:
return game.display_name
def item_enclosure_url(self, item: DropCampaign) -> str:
"""Returns the URL of the campaign image for enclosure."""
return item.get_feed_enclosure_url()
return "Twitch"
def item_enclosure_length(self, item: DropCampaign) -> int:
"""Returns the length of the enclosure."""
try:
size: int | None = getattr(item, "image_size_bytes", None)
return int(size) if size is not None else 0
except TypeError, ValueError:
return 0
def author_name(self, obj: Game) -> str:
"""Return the author name for the game, typically the owner organization name."""
owners_cache: list[Organization] | None = getattr(
obj,
"_prefetched_objects_cache",
{},
).get("owners")
if owners_cache:
owner: Organization = owners_cache[0]
if owner.name:
return owner.name
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
"""Returns the MIME type of the enclosure."""
mime: str = getattr(item, "image_mime_type", "")
return mime or "image/jpeg"
return "Twitch"
def item_enclosures(self, item: DropCampaign) -> list[feedgenerator.Enclosure]:
"""Return a list of enclosures for the drop campaign, if available.
Args:
item (DropCampaign): The drop campaign item.
Returns:
list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is available, otherwise an empty list.
"""
# Use image_best_url as enclosure if available
image_url: str = getattr(item, "image_best_url", "")
if image_url:
try:
size: int | None = getattr(item, "image_size_bytes", None)
length: int = int(size) if size is not None else 0
except TypeError, ValueError:
length = 0
mime: str = getattr(item, "image_mime_type", "")
mime_type: str = mime or "image/jpeg"
return [feedgenerator.Enclosure(image_url, str(length), mime_type)]
return []
def feed_url(self, obj: Game) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("twitch:game_campaign_feed", args=[obj.twitch_id])
# MARK: /rss/reward-campaigns/
class RewardCampaignFeed(Feed):
"""RSS feed for latest reward campaigns (Quest rewards)."""
class RewardCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for latest reward campaigns."""
title: str = "Twitch Reward Campaigns (Quest Rewards)"
title: str = "Twitch Reward Campaigns"
link: str = "/campaigns/"
description: str = "Latest Twitch reward campaigns (Quest rewards) on TTVDrops"
feed_url: str = "/rss/reward-campaigns/"
feed_copyright: str = "Information wants to be free."
description: str = "Latest Twitch reward campaigns on TTVDrops"
_limit: int | None = None
def __call__(
@ -998,22 +1188,83 @@ class RewardCampaignFeed(Feed):
def item_title(self, item: RewardCampaign) -> SafeText:
"""Return the reward campaign name as the item title."""
return SafeText(item.get_feed_title())
if item.brand:
return SafeText(f"{item.brand}: {item.name}")
return SafeText(item.name)
def item_description(self, item: RewardCampaign) -> SafeText:
"""Return a description of the reward campaign."""
return SafeText(item.get_feed_description())
parts: list = []
if item.summary:
parts.append(format_html("<p>{}</p>", item.summary))
if item.starts_at or item.ends_at:
start_part = (
format_html(
"Starts: {} ({})",
item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
naturaltime(item.starts_at),
)
if item.starts_at
else ""
)
end_part = (
format_html(
"Ends: {} ({})",
item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
naturaltime(item.ends_at),
)
if item.ends_at
else ""
)
if start_part and end_part:
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
elif start_part:
parts.append(format_html("<p>{}</p>", start_part))
elif end_part:
parts.append(format_html("<p>{}</p>", end_part))
if item.is_sitewide:
parts.append(
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
)
elif item.game:
parts.append(
format_html(
"<p>Game: {}</p>",
item.game.display_name or item.game.name,
),
)
if item.about_url:
parts.append(
format_html('<p><a href="{}">Learn more</a></p>', item.about_url),
)
if item.external_url:
parts.append(
format_html('<p><a href="{}">Redeem reward</a></p>', item.external_url),
)
return SafeText("".join(str(p) for p in parts))
def item_link(self, item: RewardCampaign) -> str:
"""Return the link to the reward campaign (external URL or dashboard)."""
return item.get_feed_link()
if item.external_url:
return item.external_url
return reverse("twitch:dashboard")
def item_pubdate(self, item: RewardCampaign) -> datetime.datetime:
"""Returns the publication date to the feed item.
Uses starts_at (when the reward starts). Fallback to added_at or now if missing.
"""
return item.get_feed_pubdate()
if item.starts_at:
return item.starts_at
if item.added_at:
return item.added_at
return timezone.now()
def item_updateddate(self, item: RewardCampaign) -> datetime.datetime:
"""Returns the reward campaign's last update time."""
@ -1021,43 +1272,105 @@ class RewardCampaignFeed(Feed):
def item_categories(self, item: RewardCampaign) -> tuple[str, ...]:
"""Returns the associated game's name and brand as categories."""
return item.get_feed_categories()
categories: list[str] = ["twitch", "rewards", "quests"]
if item.brand:
categories.append(item.brand)
if item.game:
categories.append(item.game.get_game_name)
return tuple(categories)
def item_guid(self, item: RewardCampaign) -> str:
"""Return a unique identifier for each reward campaign."""
return item.get_feed_guid()
return self._absolute_url(
reverse("twitch:reward_campaign_detail", args=[item.twitch_id]),
)
def item_author_name(self, item: RewardCampaign) -> str:
"""Return the author name for the reward campaign."""
return item.get_feed_author_name()
if item.brand:
return item.brand
if item.game and item.game.display_name:
return item.game.display_name
return "Twitch"
def item_enclosures(self, item: RewardCampaign) -> list[feedgenerator.Enclosure]:
"""Return a list of enclosures for the reward campaign, if available.
Args:
item (RewardCampaign): The reward campaign item.
Returns:
list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is available, otherwise an empty list.
"""
# Use image_url as enclosure if available
image_url: str = getattr(item, "image_url", "")
if image_url:
try:
size: int | None = getattr(item, "image_size_bytes", None)
length: int = int(size) if size is not None else 0
except TypeError, ValueError:
length = 0
mime: str = getattr(item, "image_mime_type", "")
mime_type: str = mime or "image/jpeg"
return [feedgenerator.Enclosure(image_url, str(length), mime_type)]
return []
def feed_url(self) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("twitch:reward_campaign_feed")
# Atom feed variants: reuse existing logic but switch the feed generator to Atom
class OrganizationAtomFeed(OrganizationRSSFeed):
class OrganizationAtomFeed(TTVDropsAtomBaseFeed, OrganizationRSSFeed):
"""Atom feed for latest organizations (reuses OrganizationRSSFeed)."""
feed_type = feedgenerator.Atom1Feed
subtitle: str = OrganizationRSSFeed.description
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("twitch:organization_feed_atom")
class GameAtomFeed(GameFeed):
class GameAtomFeed(TTVDropsAtomBaseFeed, GameFeed):
"""Atom feed for newly added games (reuses GameFeed)."""
feed_type = feedgenerator.Atom1Feed
subtitle: str = GameFeed.description
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("twitch:game_feed_atom")
class DropCampaignAtomFeed(DropCampaignFeed):
class DropCampaignAtomFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
"""Atom feed for latest drop campaigns (reuses DropCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
subtitle: str = DropCampaignFeed.description
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("twitch:campaign_feed_atom")
class GameCampaignAtomFeed(GameCampaignFeed):
class GameCampaignAtomFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
"""Atom feed for latest drop campaigns for a specific game (reuses GameCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
def feed_url(self, obj: Game) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("twitch:game_campaign_feed_atom", args=[obj.twitch_id])
class RewardCampaignAtomFeed(RewardCampaignFeed):
class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
"""Atom feed for latest reward campaigns (reuses RewardCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
subtitle: str = RewardCampaignFeed.description
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("twitch:reward_campaign_feed_atom")