Add support for Atom feeds

This commit is contained in:
Joakim Hellsén 2026-03-10 07:51:55 +01:00
commit 6c22559fb5
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
16 changed files with 293 additions and 0 deletions

View file

@ -23,6 +23,43 @@
{% endblock title %}
</title>
{% include "includes/meta_tags.html" %}
<!-- Feed discovery links -->
<!-- Read {% url 'twitch:docs_rss' %} for more details on available feeds -->
<link rel="alternate"
type="application/rss+xml"
title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/rss+xml"
title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" />
<link rel="alternate"
type="application/rss+xml"
title="Newly added organizations (RSS)"
href="{% url 'twitch:organization_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added organizations (Atom)"
href="{% url 'twitch:organization_feed_atom' %}" />
<link rel="alternate"
type="application/rss+xml"
title="Newly added reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{# Allow child templates to inject page-specific alternates into the head #}
{% block extra_head %}
{% endblock extra_head %}
<style>
html {
color-scheme: light dark;

View file

@ -4,6 +4,18 @@
{% block title %}
{{ campaign.clean_name }}
{% endblock title %}
{% block extra_head %}
{% if campaign and campaign.game %}
<link rel="alternate"
type="application/rss+xml"
title="{{ campaign.game.display_name }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ campaign.game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" />
{% endif %}
{% endblock extra_head %}
{% block content %}
<!-- Campaign Title -->
<h1>
@ -71,6 +83,8 @@
{% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}"
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}"
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
{% endif %}
</div>
{% if allowed_channels %}

View file

@ -5,6 +5,16 @@
{% block title %}
Drop Campaigns - Twitch Drops Tracker
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<header>
@ -13,6 +23,8 @@
<div>
<a href="{% url 'twitch:campaign_feed' %}"
title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
title="Export campaigns as CSV">[csv]</a>
<a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"

View file

@ -4,6 +4,16 @@
{% block title %}
Twitch drops
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<h1>Twitch Drops</h1>
@ -16,6 +26,9 @@ Hover over the end time to see the exact date and time.
<a href="{% url 'twitch:campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for campaigns</a>
&nbsp;|&nbsp;
<a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for campaigns">Atom feed for campaigns</a>
</div>
{% if campaigns_by_game %}
{% for game_id, game_data in campaigns_by_game.items %}

View file

@ -7,6 +7,11 @@
<main>
<h1>RSS Feeds Documentation</h1>
<p>This page lists all available RSS feeds for TTVDrops.</p>
<p>
Note: Atom feeds are also available for the same resources under the
<code>/atom/</code> endpoints (links labeled "Atom" are shown next to RSS links).
Both RSS and Atom formats are supported and served in parallel for backward compatibility.
</p>
<section>
<h2>Global RSS Feeds</h2>
<p>These feeds contain all items across the entire site:</p>
@ -17,8 +22,16 @@
<p>{{ feed.description }}</p>
<p>
<a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a>
{% if feed.atom_url %}
&nbsp;|&nbsp;
<a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a>
{% endif %}
</p>
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
{% if feed.example_xml_atom %}
<h4>Atom example</h4>
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
{% endif %}
</li>
{% endfor %}
</ul>
@ -35,13 +48,22 @@
<p>{{ feed.description }}</p>
<p>
Endpoint: <code>{{ feed.url }}</code>
{% if feed.atom_url %}&nbsp;|&nbsp; Atom: <code>{{ feed.atom_url }}</code>{% endif %}
</p>
{% if feed.has_sample %}
<p>
<a href="{{ feed.url }}">View a live example</a>
{% if feed.atom_url %}
&nbsp;|&nbsp;
<a href="{{ feed.atom_url }}">View Atom example</a>
{% endif %}
</p>
{% endif %}
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
{% if feed.example_xml_atom %}
<h4>Atom example</h4>
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
{% endif %}
</li>
{% endfor %}
</ul>

View file

@ -3,6 +3,18 @@
{% block title %}
{{ game.display_name }}
{% endblock title %}
{% block extra_head %}
{% if game %}
<link rel="alternate"
type="application/rss+xml"
title="{{ game.display_name }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
{% endif %}
{% endblock extra_head %}
{% block content %}
<!-- Game Title -->
<h1>
@ -13,6 +25,9 @@
<div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">RSS feed for {{ game.display_name }} campaigns</a>
&nbsp;|&nbsp;
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">Atom feed for {{ game.display_name }} campaigns</a>
</div>
<!-- Game image -->
{% if game.box_art_best_url %}

View file

@ -3,6 +3,16 @@
{% block title %}
Games - Grid View
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<header>
@ -10,6 +20,8 @@
<div>
<a href="{% url 'twitch:games_list' %}" title="View games as list">[list]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}"

View file

@ -2,12 +2,24 @@
{% block title %}
Games - List View
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<h1>Games List</h1>
<div>
<a href="{% url 'twitch:games_grid' %}" title="View games as grid">[grid]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}"

View file

@ -9,6 +9,8 @@
<a href="{% url 'twitch:organization_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all organizations">RSS feed for organizations</a>
<a href="{% url 'twitch:organization_feed_atom' %}"
title="Atom feed for all organizations">[atom]</a>
</div>
<!-- Export Options -->
<div style="margin-bottom: 1rem; display: flex; gap: 1rem;">

View file

@ -2,6 +2,20 @@
{% block title %}
{{ organization.name }}
{% endblock title %}
{% block extra_head %}
{% if games %}
{% for game in games %}
<link rel="alternate"
type="application/rss+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
{% endfor %}
{% endif %}
{% endblock extra_head %}
{% block content %}
<h1 id="org-name">{{ organization.name }}</h1>
<theader>

View file

@ -4,6 +4,16 @@
{% block title %}
{{ reward_campaign.name }}
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<!-- Campaign Title -->
{% if reward_campaign.brand %}
@ -24,6 +34,8 @@
<a href="{% url 'twitch:reward_campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a>
</div>
<!-- Campaign Summary -->
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}

View file

@ -3,6 +3,16 @@
{% block title %}
Reward Campaigns - Twitch Drops Tracker
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<h1 id="page-title">Reward Campaigns (Quest Rewards)</h1>
<p>Browse all available quest reward campaigns</p>
@ -11,6 +21,8 @@
<a href="{% url 'twitch:reward_campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a>
</div>
<!-- Filter Form -->
<form id="filter-form"

View file

@ -920,3 +920,34 @@ class RewardCampaignFeed(Feed):
def item_author_name(self, item: RewardCampaign) -> str:
"""Return the author name for the reward campaign."""
return item.get_feed_author_name()
# Atom feed variants: reuse existing logic but switch the feed generator to Atom
class OrganizationAtomFeed(OrganizationRSSFeed):
"""Atom feed for latest organizations (reuses OrganizationRSSFeed)."""
feed_type = feedgenerator.Atom1Feed
class GameAtomFeed(GameFeed):
"""Atom feed for newly added games (reuses GameFeed)."""
feed_type = feedgenerator.Atom1Feed
class DropCampaignAtomFeed(DropCampaignFeed):
"""Atom feed for latest drop campaigns (reuses DropCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
class GameCampaignAtomFeed(GameCampaignFeed):
"""Atom feed for latest drop campaigns for a specific game (reuses GameCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
class RewardCampaignAtomFeed(RewardCampaignFeed):
"""Atom feed for latest reward campaigns (reuses RewardCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed

View file

@ -95,6 +95,32 @@ class RSSFeedTestCase(TestCase):
assert 'length="42"' in content
assert 'type="image/png"' in content
def test_organization_atom_feed(self) -> None:
"""Test organization Atom feed returns 200 and Atom XML."""
url: str = reverse("twitch:organization_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/atom+xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "<entry" in content or "<entry" in content
def test_game_atom_feed(self) -> None:
"""Test game Atom feed returns 200 and contains expected content."""
url: str = reverse("twitch:game_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/atom+xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "Owned by Test Organization." in content
expected_atom_link: str = reverse(
"twitch:game_campaign_feed",
args=[self.game.twitch_id],
)
assert expected_atom_link in content
# Atom should include box art URL somewhere in content
assert "https://example.com/box.png" in content
def test_game_feed_enclosure_helpers(self) -> None:
"""Helper methods should return values from model fields."""
feed = GameFeed()

View file

@ -3,10 +3,15 @@ from typing import TYPE_CHECKING
from django.urls import path
from twitch import views
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING:
@ -105,4 +110,22 @@ urlpatterns: list[URLPattern] = [
RewardCampaignFeed(),
name="reward_campaign_feed",
),
# Atom feeds (added alongside RSS to preserve backward compatibility)
path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"),
path("atom/games/", GameAtomFeed(), name="game_feed_atom"),
path(
"atom/games/<str:twitch_id>/campaigns/",
GameCampaignAtomFeed(),
name="game_campaign_feed_atom",
),
path(
"atom/organizations/",
OrganizationAtomFeed(),
name="organization_feed_atom",
),
path(
"atom/reward-campaigns/",
RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom",
),
]

View file

@ -38,10 +38,15 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel
from twitch.models import ChatBadge
@ -1808,30 +1813,46 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
)
return ""
show_atom: bool = bool(request.GET.get("show_atom"))
feeds: list[dict[str, str]] = [
{
"title": "All Organizations",
"description": "Latest organizations added to TTVDrops",
"url": absolute(reverse("twitch:organization_feed")),
"atom_url": absolute(reverse("twitch:organization_feed_atom")),
"example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom
else "",
},
{
"title": "All Games",
"description": "Latest games added to TTVDrops",
"url": absolute(reverse("twitch:game_feed")),
"atom_url": absolute(reverse("twitch:game_feed_atom")),
"example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
},
{
"title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games",
"url": absolute(reverse("twitch:campaign_feed")),
"atom_url": absolute(reverse("twitch:campaign_feed_atom")),
"example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom
else "",
},
{
"title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("twitch:reward_campaign_feed")),
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")),
"example_xml": render_feed(RewardCampaignFeed()),
"example_xml_atom": render_feed(RewardCampaignAtomFeed())
if show_atom
else "",
},
]
@ -1851,10 +1872,25 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
if sample_game
else absolute("/rss/games/<game_id>/campaigns/")
),
"atom_url": (
absolute(
reverse(
"twitch:game_campaign_feed_atom",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/atom/games/<game_id>/campaigns/")
),
"has_sample": bool(sample_game),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
if sample_game
else "",
"example_xml_atom": (
render_feed(GameCampaignAtomFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
},
]