Compare commits

..

No commits in common. "70298fdd1ed103a7bcf1d113d2e22a6ac33ca92b" and "efed2c2f690b085bbb68e9ca9d4d01c2f4325a8d" have entirely different histories.

33 changed files with 777 additions and 649 deletions

View file

@ -1,18 +0,0 @@
from typing import Any
from typing import TypedDict
class SeoMeta(TypedDict, total=False):
"""Shared typed optional SEO metadata for template context generation."""
page_url: str | None
page_image: str | None
page_image_width: int | None
page_image_height: int | None
og_type: str
schema_data: dict[str, Any] | None
breadcrumb_schema: dict[str, Any] | None
pagination_info: list[dict[str, str]] | None
published_date: str | None
modified_date: str | None
robots_directive: str

View file

@ -2,27 +2,9 @@ from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from core.views import dashboard from core import views
from core.views import dataset_backup_download_view
from core.views import dataset_backups_view
from core.views import debug_view
from core.views import docs_rss_view
from core.views import search_view
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
@ -33,21 +15,21 @@ app_name = "core"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
# / # /
path("", dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
# /search/ # /search/
path("search/", search_view, name="search"), path("search/", views.search_view, name="search"),
# /debug/ # /debug/
path("debug/", debug_view, name="debug"), path("debug/", views.debug_view, name="debug"),
# /datasets/ # /datasets/
path("datasets/", dataset_backups_view, name="dataset_backups"), path("datasets/", views.dataset_backups_view, name="dataset_backups"),
# /datasets/download/<relative_path>/ # /datasets/download/<relative_path>/
path( path(
"datasets/download/<path:relative_path>/", "datasets/download/<path:relative_path>/",
dataset_backup_download_view, views.dataset_backup_download_view,
name="dataset_backup_download", name="dataset_backup_download",
), ),
# /docs/rss/ # /docs/rss/
path("docs/rss/", docs_rss_view, name="docs_rss"), path("docs/rss/", views.docs_rss_view, name="docs_rss"),
# RSS feeds # RSS feeds
# /rss/campaigns/ - all active campaigns # /rss/campaigns/ - all active campaigns
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
@ -56,59 +38,59 @@ urlpatterns: list[URLPattern | URLResolver] = [
# /rss/games/<twitch_id>/campaigns/ - active campaigns for a specific game # /rss/games/<twitch_id>/campaigns/ - active campaigns for a specific game
path( path(
"rss/games/<str:twitch_id>/campaigns/", "rss/games/<str:twitch_id>/campaigns/",
GameCampaignFeed(), views.GameCampaignFeed(),
name="game_campaign_feed", name="game_campaign_feed",
), ),
# /rss/organizations/ - newly added organizations # /rss/organizations/ - newly added organizations
path( path(
"rss/organizations/", "rss/organizations/",
OrganizationRSSFeed(), views.OrganizationRSSFeed(),
name="organization_feed", name="organization_feed",
), ),
# /rss/reward-campaigns/ - all active reward campaigns # /rss/reward-campaigns/ - all active reward campaigns
path( path(
"rss/reward-campaigns/", "rss/reward-campaigns/",
RewardCampaignFeed(), views.RewardCampaignFeed(),
name="reward_campaign_feed", name="reward_campaign_feed",
), ),
# Atom feeds (added alongside RSS to preserve backward compatibility) # Atom feeds (added alongside RSS to preserve backward compatibility)
path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"), path("atom/campaigns/", views.DropCampaignAtomFeed(), name="campaign_feed_atom"),
path("atom/games/", GameAtomFeed(), name="game_feed_atom"), path("atom/games/", views.GameAtomFeed(), name="game_feed_atom"),
path( path(
"atom/games/<str:twitch_id>/campaigns/", "atom/games/<str:twitch_id>/campaigns/",
view=GameCampaignAtomFeed(), views.GameCampaignAtomFeed(),
name="game_campaign_feed_atom", name="game_campaign_feed_atom",
), ),
path( path(
"atom/organizations/", "atom/organizations/",
OrganizationAtomFeed(), views.OrganizationAtomFeed(),
name="organization_feed_atom", name="organization_feed_atom",
), ),
path( path(
"atom/reward-campaigns/", "atom/reward-campaigns/",
RewardCampaignAtomFeed(), views.RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom", name="reward_campaign_feed_atom",
), ),
# Discord feeds (Atom feeds with Discord relative timestamps) # Discord feeds (Atom feeds with Discord relative timestamps)
path( path(
"discord/campaigns/", "discord/campaigns/",
DropCampaignDiscordFeed(), views.DropCampaignDiscordFeed(),
name="campaign_feed_discord", name="campaign_feed_discord",
), ),
path("discord/games/", GameDiscordFeed(), name="game_feed_discord"), path("discord/games/", views.GameDiscordFeed(), name="game_feed_discord"),
path( path(
"discord/games/<str:twitch_id>/campaigns/", "discord/games/<str:twitch_id>/campaigns/",
GameCampaignDiscordFeed(), views.GameCampaignDiscordFeed(),
name="game_campaign_feed_discord", name="game_campaign_feed_discord",
), ),
path( path(
"discord/organizations/", "discord/organizations/",
OrganizationDiscordFeed(), views.OrganizationDiscordFeed(),
name="organization_feed_discord", name="organization_feed_discord",
), ),
path( path(
"discord/reward-campaigns/", "discord/reward-campaigns/",
RewardCampaignDiscordFeed(), views.RewardCampaignDiscordFeed(),
name="reward_campaign_feed_discord", name="reward_campaign_feed_discord",
), ),
] ]

View file

@ -3,6 +3,7 @@ import json
import logging import logging
import operator import operator
from collections import OrderedDict from collections import OrderedDict
from copy import copy
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
@ -26,6 +27,21 @@ from django.utils import timezone
from kick.models import KickChannel from kick.models import KickChannel
from kick.models import KickDropCampaign from kick.models import KickDropCampaign
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet from twitch.models import ChatBadgeSet
@ -37,11 +53,13 @@ from twitch.models import RewardCampaign
from twitch.models import TimeBasedDrop from twitch.models import TimeBasedDrop
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from os import stat_result from os import stat_result
from pathlib import Path from pathlib import Path
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict
logger: logging.Logger = logging.getLogger("ttvdrops.views") logger: logging.Logger = logging.getLogger("ttvdrops.views")
@ -55,7 +73,6 @@ DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and mor
def _build_seo_context( # noqa: PLR0913, PLR0917 def _build_seo_context( # noqa: PLR0913, PLR0917
page_title: str = "ttvdrops", page_title: str = "ttvdrops",
page_description: str | None = None, page_description: str | None = None,
page_url: str | None = None,
page_image: str | None = None, page_image: str | None = None,
page_image_width: int | None = None, page_image_width: int | None = None,
page_image_height: int | None = None, page_image_height: int | None = None,
@ -72,7 +89,6 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
Args: Args:
page_title: Page title (shown in browser tab, og:title). page_title: Page title (shown in browser tab, og:title).
page_description: Page description (meta description, og:description). page_description: Page description (meta description, og:description).
page_url: Canonical absolute URL for the current page.
page_image: Image URL for og:image meta tag. page_image: Image URL for og:image meta tag.
page_image_width: Width of the image in pixels. page_image_width: Width of the image in pixels.
page_image_height: Height of the image in pixels. page_image_height: Height of the image in pixels.
@ -99,8 +115,6 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
"og_type": og_type, "og_type": og_type,
"robots_directive": robots_directive, "robots_directive": robots_directive,
} }
if page_url:
context["page_url"] = page_url
if page_image: if page_image:
context["page_image"] = page_image context["page_image"] = page_image
if page_image_width and page_image_height: if page_image_width and page_image_height:
@ -261,33 +275,177 @@ def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
# MARK: /docs/rss/ # MARK: /docs/rss/
def docs_rss_view(request: HttpRequest) -> HttpResponse: def docs_rss_view(request: HttpRequest) -> HttpResponse:
"""View for /docs/rss that lists all available feeds and explains how to use them. """View for /docs/rss that lists all available RSS feeds.
Args: Args:
request: The HTTP request object. request: The HTTP request object.
Returns: Returns:
HttpResponse: The rendered documentation page. Rendered HTML response with list of RSS feeds.
""" """
now: datetime.datetime = timezone.now()
sample_game: Game | None = ( def absolute(path: str) -> str:
Game.objects try:
.filter(drop_campaigns__start_at__lte=now, drop_campaigns__end_at__gte=now) return request.build_absolute_uri(path)
.distinct() except Exception:
.first() logger.exception("Failed to build absolute URL for %s", path)
return path
def _pretty_example(xml_str: str, max_items: int = 1) -> str:
try:
trimmed: str = xml_str.strip()
first_item: int = trimmed.find("<item")
if first_item != -1 and max_items == 1:
second_item: int = trimmed.find("<item", first_item + 5)
if second_item != -1:
end_channel: int = trimmed.find("</channel>", second_item)
if end_channel != -1:
trimmed = trimmed[:second_item] + trimmed[end_channel:]
formatted: str = trimmed.replace("><", ">\n<")
return "\n".join(line for line in formatted.splitlines() if line.strip())
except Exception:
logger.exception("Failed to pretty-print RSS example")
return xml_str
def render_feed(feed_view: Callable[..., HttpResponse], *args: object) -> str:
try:
limited_request: HttpRequest = copy(request)
# Add limit=1 to GET parameters
get_data: QueryDict = request.GET.copy()
get_data["limit"] = "1"
limited_request.GET = get_data
response: HttpResponse = feed_view(limited_request, *args)
return _pretty_example(response.content.decode("utf-8"))
except Exception:
logger.exception(
"Failed to render %s for RSS docs",
feed_view.__class__.__name__,
) )
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("core:organization_feed")),
"atom_url": absolute(reverse("core:organization_feed_atom")),
"discord_url": absolute(reverse("core:organization_feed_discord")),
"example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(OrganizationDiscordFeed())
if show_atom
else "",
},
{
"title": "All Games",
"description": "Latest games added to TTVDrops",
"url": absolute(reverse("core:game_feed")),
"atom_url": absolute(reverse("core:game_feed_atom")),
"discord_url": absolute(reverse("core:game_feed_discord")),
"example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
"example_xml_discord": render_feed(GameDiscordFeed()) if show_atom else "",
},
{
"title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games",
"url": absolute(reverse("core:campaign_feed")),
"atom_url": absolute(reverse("core:campaign_feed_atom")),
"discord_url": absolute(reverse("core:campaign_feed_discord")),
"example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(DropCampaignDiscordFeed())
if show_atom
else "",
},
{
"title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("core:reward_campaign_feed")),
"atom_url": absolute(reverse("core:reward_campaign_feed_atom")),
"discord_url": absolute(reverse("core:reward_campaign_feed_discord")),
"example_xml": render_feed(RewardCampaignFeed()),
"example_xml_atom": render_feed(RewardCampaignAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(RewardCampaignDiscordFeed())
if show_atom
else "",
},
]
sample_game: Game | None = Game.objects.order_by("-added_at").first()
sample_org: Organization | None = Organization.objects.order_by("-added_at").first()
if sample_org is None and sample_game is not None:
sample_org = sample_game.owners.order_by("-pk").first()
filtered_feeds: list[dict[str, str | bool]] = [
{
"title": "Campaigns for a Single Game",
"description": "Latest drop campaigns for one game.",
"url": (
absolute(
reverse("core:game_campaign_feed", args=[sample_game.twitch_id]),
)
if sample_game
else absolute("/rss/games/<game_id>/campaigns/")
),
"atom_url": (
absolute(
reverse(
"core:game_campaign_feed_atom",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/atom/games/<game_id>/campaigns/")
),
"discord_url": (
absolute(
reverse(
"core:game_campaign_feed_discord",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/discord/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 ""
),
"example_xml_discord": (
render_feed(GameCampaignDiscordFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
},
]
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Feed Documentation", page_title="Twitch RSS Feeds",
page_description="Documentation for the RSS feeds available on ttvdrops.lovinator.space, including how to use them and what data they contain.", page_description="RSS feeds for Twitch drops.",
page_url=request.build_absolute_uri(reverse("core:docs_rss")),
) )
return render( return render(
request, request,
"core/docs_rss.html", "twitch/docs_rss.html",
{ {
"game": sample_game, "feeds": feeds,
"filtered_feeds": filtered_feeds,
"sample_game": sample_game,
"sample_org": sample_org,
**seo_context, **seo_context,
}, },
) )
@ -432,7 +590,7 @@ def debug_view(request: HttpRequest) -> HttpResponse:
) )
context.update(seo_context) context.update(seo_context)
return render(request, "core/debug.html", context) return render(request, "twitch/debug.html", context)
# MARK: /datasets/ # MARK: /datasets/
@ -543,7 +701,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
"dataset_count": len(datasets), "dataset_count": len(datasets),
**seo_context, **seo_context,
} }
return render(request, "core/dataset_backups.html", context) return render(request, "twitch/dataset_backups.html", context)
def dataset_backup_download_view( def dataset_backup_download_view(
@ -678,11 +836,10 @@ def search_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=page_title, page_title=page_title,
page_description=page_description, page_description=page_description,
page_url=request.build_absolute_uri(reverse("core:search")),
) )
return render( return render(
request, request,
"core/search_results.html", "twitch/search_results.html",
{"query": query, "results": results, **seo_context}, {"query": query, "results": results, **seo_context},
) )
@ -794,9 +951,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch/Kick Drops", page_title="Twitch/Kick Drops",
page_description=( page_description=("Twitch and Kick drops."),
"RSS feeds, historical data, and information about Twitch and Kick drops, campaigns, rewards, and more."
),
og_type="website", og_type="website",
schema_data=website_schema, schema_data=website_schema,
) )

View file

@ -1,9 +1,6 @@
from __future__ import annotations
import json import json
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any
from typing import Literal from typing import Literal
from django.core.paginator import EmptyPage from django.core.paginator import EmptyPage
@ -27,7 +24,6 @@ if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse from django.http import HttpResponse
from core.seo import SeoMeta
from kick.models import KickChannel from kick.models import KickChannel
from kick.models import KickReward from kick.models import KickReward
@ -37,38 +33,26 @@ logger: logging.Logger = logging.getLogger("ttvdrops.kick.views")
def _build_seo_context( def _build_seo_context(
page_title: str = "Kick Drops", page_title: str = "Kick Drops",
page_description: str | None = None, page_description: str | None = None,
seo_meta: SeoMeta | None = None, og_type: str = "website",
) -> dict[str, Any]: robots_directive: str = "index, follow",
"""Build SEO context for template rendering. ) -> dict[str, str]:
"""Build minimal SEO context for template rendering.
Args: Args:
page_title: The title of the page for <title> and OG tags. page_title: The title of the page for <title> and OG tags.
page_description: Optional description for meta and OG tags. page_description: Optional description for meta and OG tags.
seo_meta: Optional typed SEO metadata. og_type: Open Graph type (default "website").
robots_directive: Value for meta robots tag (default "index, follow").
Returns: Returns:
A dictionary with SEO-related context variables. A dictionary with SEO-related context variables.
""" """
context: dict[str, Any] = { return {
"page_title": page_title, "page_title": page_title,
"page_description": page_description or "Archive of Kick drops.", "page_description": page_description or "Archive of Kick drops.",
"og_type": "website", "og_type": og_type,
"robots_directive": "index, follow", "robots_directive": robots_directive,
} }
if seo_meta:
if seo_meta.get("page_url"):
context["page_url"] = seo_meta["page_url"]
if seo_meta.get("og_type"):
context["og_type"] = seo_meta["og_type"]
if seo_meta.get("robots_directive"):
context["robots_directive"] = seo_meta["robots_directive"]
if seo_meta.get("schema_data"):
context["schema_data"] = json.dumps(seo_meta["schema_data"])
if seo_meta.get("published_date"):
context["published_date"] = seo_meta["published_date"]
if seo_meta.get("modified_date"):
context["modified_date"] = seo_meta["modified_date"]
return context
def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str: def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str:
@ -227,7 +211,6 @@ def campaign_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, str] = _build_seo_context( seo_context: dict[str, str] = _build_seo_context(
page_title=title, page_title=title,
page_description="Browse Kick drop campaigns.", page_description="Browse Kick drop campaigns.",
seo_meta={"page_url": request.build_absolute_uri(base_url)},
) )
return render( return render(
request, request,
@ -295,51 +278,9 @@ def campaign_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
}, },
]) ])
campaign_url: str = request.build_absolute_uri( seo_context: dict[str, str] = _build_seo_context(
reverse("kick:campaign_detail", args=[campaign.kick_id]),
)
campaign_event: dict[str, Any] = {
"@type": "Event",
"name": campaign.name,
"description": f"Kick drop campaign: {campaign.name}.",
"url": campaign_url,
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
"location": {"@type": "VirtualLocation", "url": "https://kick.com"},
}
if campaign.starts_at:
campaign_event["startDate"] = campaign.starts_at.isoformat()
if campaign.ends_at:
campaign_event["endDate"] = campaign.ends_at.isoformat()
if campaign.organization:
campaign_event["organizer"] = {
"@type": "Organization",
"name": campaign.organization.name,
}
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": campaign_url,
"datePublished": campaign.added_at.isoformat(),
"dateModified": campaign.updated_at.isoformat(),
}
campaign_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
campaign_event,
webpage_node,
],
}
seo_context: dict[str, Any] = _build_seo_context(
page_title=campaign.name, page_title=campaign.name,
page_description=f"Kick drop campaign: {campaign.name}.", page_description=f"Kick drop campaign: {campaign.name}.",
seo_meta={
"schema_data": campaign_schema,
"published_date": campaign.added_at.isoformat(),
"modified_date": campaign.updated_at.isoformat(),
},
) )
return render( return render(
request, request,
@ -438,27 +379,9 @@ def category_detail_view(request: HttpRequest, kick_id: int) -> HttpResponse:
}, },
]) ])
category_url: str = request.build_absolute_uri( seo_context: dict[str, str] = _build_seo_context(
reverse("kick:game_detail", args=[category.kick_id]),
)
category_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": category.name,
"description": f"Kick drop campaigns for {category.name}.",
"url": category_url,
"datePublished": category.added_at.isoformat(),
"dateModified": category.updated_at.isoformat(),
}
seo_context: dict[str, Any] = _build_seo_context(
page_title=category.name, page_title=category.name,
page_description=f"Kick drop campaigns for {category.name}.", page_description=f"Kick drop campaigns for {category.name}.",
seo_meta={
"schema_data": category_schema,
"published_date": category.added_at.isoformat(),
"modified_date": category.updated_at.isoformat(),
},
) )
return render( return render(
request, request,
@ -543,37 +466,9 @@ def organization_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse
}, },
]) ])
org_url: str = request.build_absolute_uri( seo_context: dict[str, str] = _build_seo_context(
reverse("kick:organization_detail", args=[org.kick_id]),
)
organization_node: dict[str, Any] = {
"@type": "Organization",
"name": org.name,
"url": org_url,
"description": f"Kick drop campaigns by {org.name}.",
}
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": org_url,
"datePublished": org.added_at.isoformat(),
"dateModified": org.updated_at.isoformat(),
}
org_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
organization_node,
webpage_node,
],
}
seo_context: dict[str, Any] = _build_seo_context(
page_title=org.name, page_title=org.name,
page_description=f"Kick drop campaigns by {org.name}.", page_description=f"Kick drop campaigns by {org.name}.",
seo_meta={
"schema_data": org_schema,
"published_date": org.added_at.isoformat(),
"modified_date": org.updated_at.isoformat(),
},
) )
return render( return render(
request, request,

View file

@ -48,7 +48,6 @@
title="Atom feed for Twitch campaigns">[atom]</a> title="Atom feed for Twitch campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for Twitch campaigns">[discord]</a> title="Discord feed for Twitch campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
</header> </header>
{% if campaigns_by_game %} {% if campaigns_by_game %}
@ -224,7 +223,6 @@
title="Atom feed for all Kick campaigns">[atom]</a> title="Atom feed for all Kick campaigns">[atom]</a>
<a href="{% url 'kick:campaign_feed_discord' %}" <a href="{% url 'kick:campaign_feed_discord' %}"
title="Discord feed for all Kick campaigns">[discord]</a> title="Discord feed for all Kick campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
</header> </header>
{% if kick_campaigns_by_game %} {% if kick_campaigns_by_game %}

View file

@ -1,155 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
RSS Feeds Documentation
{% endblock title %}
{% block content %}
<main>
<h1>RSS Feeds Documentation</h1>
<p>
You have three types of feeds available for Twitch drops data: RSS, Atom, and Discord.
RSS and Atom feeds are similar and can be used in any RSS reader application.
The main difference is that Atom feeds include additional metadata and support for more complex content, while RSS feeds are more widely supported by older applications.
</p>
<p>
Discord feeds are available under the <code>/discord/</code> endpoints. These are Atom feeds
that include Discord relative timestamps (e.g., <code>&lt;t:1773450272:R&gt;</code>) for dates,
making them ideal for Discord bots and integrations. Future enhancements may include Discord-specific formatting or content.
</p>
<section>
<h2>Global RSS Feeds</h2>
<table>
<thead>
<tr>
<th>Description</th>
<th>RSS</th>
<th>Atom</th>
<th>Discord</th>
</tr>
</thead>
<tbody>
<tr>
<td>New Twitch games</td>
<td>
<a href="https://ttvdrops.lovinator.space/rss/games/">https://ttvdrops.lovinator.space/rss/games/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/atom/games/">https://ttvdrops.lovinator.space/atom/games/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/games/">https://ttvdrops.lovinator.space/discord/games/</a>
</td>
</tr>
<tr>
<td>Latest Twitch drop campaigns</td>
<td>
<a href="https://ttvdrops.lovinator.space/rss/campaigns/">https://ttvdrops.lovinator.space/rss/campaigns/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/atom/campaigns/">https://ttvdrops.lovinator.space/atom/campaigns/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/campaigns/">https://ttvdrops.lovinator.space/discord/campaigns/</a>
</td>
</tr>
<tr>
<td>Latest Twitch organizations</td>
<td>
<a href="https://ttvdrops.lovinator.space/rss/organizations/">https://ttvdrops.lovinator.space/rss/organizations/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/atom/organizations/">https://ttvdrops.lovinator.space/atom/organizations/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/organizations/">https://ttvdrops.lovinator.space/discord/organizations/</a>
</td>
</tr>
<tr>
<td>Latest Twitch reward campaigns</td>
<td>
<a href="https://ttvdrops.lovinator.space/rss/reward-campaigns/">https://ttvdrops.lovinator.space/rss/reward-campaigns/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/atom/reward-campaigns/">https://ttvdrops.lovinator.space/atom/reward-campaigns/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/reward-campaigns/">https://ttvdrops.lovinator.space/discord/reward-campaigns/</a>
</td>
</tr>
<tr>
<td>Latest Kick campaigns</td>
<td>
<a href="https://ttvdrops.lovinator.space/kick/rss/campaigns/">https://ttvdrops.lovinator.space/kick/rss/campaigns/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/kick/atom/campaigns/">https://ttvdrops.lovinator.space/kick/atom/campaigns/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/campaigns/">https://ttvdrops.lovinator.space/discord/campaigns/</a>
</td>
</tr>
<tr>
<td>Latest Kick games</td>
<td>
<a href="https://ttvdrops.lovinator.space/kick/rss/games/">https://ttvdrops.lovinator.space/kick/rss/games/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/kick/atom/games/">https://ttvdrops.lovinator.space/kick/atom/games/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/games/">https://ttvdrops.lovinator.space/discord/games/</a>
</td>
</tr>
<tr>
<td>Latest Kick organizations</td>
<td>
<a href="https://ttvdrops.lovinator.space/kick/rss/organizations/">https://ttvdrops.lovinator.space/kick/rss/organizations/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/kick/atom/organizations/">https://ttvdrops.lovinator.space/kick/atom/organizations/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space/discord/organizations/">https://ttvdrops.lovinator.space/discord/organizations/</a>
</td>
</tr>
</tbody>
</table>
</section>
{% if game %}
<section>
<h2>Filtered RSS Feeds</h2>
<p>You can subscribe to RSS feeds scoped to a specific game.</p>
<table>
<thead>
<tr>
<th>Game</th>
<th>RSS</th>
<th>Atom</th>
<th>Discord</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ game.display_name }}</td>
<td>
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}">
https://ttvdrops.lovinator.space/rss/games/{{ game.twitch_id }}/
</a>
</td>
<td>
<a href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}">
https://ttvdrops.lovinator.space/atom/games/{{ game.twitch_id }}/
</a>
</td>
<td>
<a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}">
https://ttvdrops.lovinator.space/discord/games/{{ game.twitch_id }}/
</a>
</td>
</tr>
</tbody>
</table>
</section>
{% endif %}
</main>
{% endblock content %}

View file

@ -16,6 +16,9 @@
{# - robots_directive: str - robots meta content (default: "index, follow") #} {# - robots_directive: str - robots meta content (default: "index, follow") #}
{# #} {# #}
{% load static %} {% load static %}
{# Preconnect to external resources for performance #}
<link rel="preconnect" href="https://static-cdn.jtvnw.net" />
<link rel="dns-prefetch" href="https://static-cdn.jtvnw.net" />
{# Description meta tag #} {# Description meta tag #}
<meta name="description" <meta name="description"
content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" /> content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" />
@ -23,7 +26,7 @@
<meta name="robots" <meta name="robots"
content="{% firstof robots_directive 'index, follow' %}" /> content="{% firstof robots_directive 'index, follow' %}" />
{# Author and Copyright #} {# Author and Copyright #}
<meta name="author" content="TheLovinator" /> <meta name="author" content="TheLovinator1" />
<meta name="copyright" <meta name="copyright"
content="This work is dedicated to the public domain under CC0 1.0 Universal." /> content="This work is dedicated to the public domain under CC0 1.0 Universal." />
{# Open Graph tags for social sharing #} {# Open Graph tags for social sharing #}
@ -42,14 +45,10 @@
{% endif %} {% endif %}
{# Twitter Card tags for rich previews #} {# Twitter Card tags for rich previews #}
<meta name="twitter:card" <meta name="twitter:card"
content="{% if page_image %} content="{% if page_image %}summary_large_image{% else %}summary{% endif %}" />
summary_large_image
{% else %}
summary
{% endif %}" />
<meta name="twitter:title" content="{% firstof page_title 'ttvdrops' %}" /> <meta name="twitter:title" content="{% firstof page_title 'ttvdrops' %}" />
<meta name="twitter:description" <meta name="twitter:description"
content="{% firstof page_description 'ttvdrops - Twitch and Kick drops.' %}" /> content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" />
{% if page_image %}<meta name="twitter:image" content="{{ page_image }}" />{% endif %} {% if page_image %}<meta name="twitter:image" content="{{ page_image }}" />{% endif %}
{# Article dates for content pages #} {# Article dates for content pages #}
{% if published_date %}<meta property="article:published_time" content="{{ published_date }}" />{% endif %} {% if published_date %}<meta property="article:published_time" content="{{ published_date }}" />{% endif %}
@ -62,22 +61,6 @@
{% for link in pagination_info %}<link rel="{{ link.rel }}" href="{{ link.url }}" />{% endfor %} {% for link in pagination_info %}<link rel="{{ link.rel }}" href="{{ link.url }}" />{% endfor %}
{% endif %} {% endif %}
{# Schema.org JSON-LD structured data #} {# Schema.org JSON-LD structured data #}
{% if schema_data %} {% if schema_data %}<script type="application/ld+json">{{ schema_data|safe }}</script>{% endif %}
<script type="application/ld+json">
{
{
schema_data | safe
}
}
</script>
{% endif %}
{# Breadcrumb schema #} {# Breadcrumb schema #}
{% if breadcrumb_schema %} {% if breadcrumb_schema %}<script type="application/ld+json">{{ breadcrumb_schema|safe }}</script>{% endif %}
<script type="application/ld+json">
{
{
breadcrumb_schema | safe
}
}
</script>
{% endif %}

View file

@ -59,7 +59,6 @@
title="Atom feed for {{ campaign.category.name }} campaigns">[atom]</a> title="Atom feed for {{ campaign.category.name }} campaigns">[atom]</a>
<a href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}" <a href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}"
title="Discord feed for {{ campaign.category.name }} campaigns">[discord]</a> title="Discord feed for {{ campaign.category.name }} campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
{% endif %} {% endif %}
<p style="margin: 0.25rem 0; color: #666;"> <p style="margin: 0.25rem 0; color: #666;">

View file

@ -26,7 +26,6 @@
title="Atom feed for all campaigns">[atom]</a> title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'kick:campaign_feed_discord' %}" <a href="{% url 'kick:campaign_feed_discord' %}"
title="Discord feed for all campaigns">[discord]</a> title="Discord feed for all campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
<form method="get" action="{% url 'kick:campaign_list' %}"> <form method="get" action="{% url 'kick:campaign_list' %}">
<div style="display: flex; <div style="display: flex;

View file

@ -56,7 +56,6 @@
title="Atom feed for {{ category.name }} campaigns">[atom]</a> title="Atom feed for {{ category.name }} campaigns">[atom]</a>
<a href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}" <a href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}"
title="Discord feed for {{ category.name }} campaigns">[discord]</a> title="Discord feed for {{ category.name }} campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
{% if category.kick_url %} {% if category.kick_url %}
<p style="margin: 0.25rem 0;"> <p style="margin: 0.25rem 0;">

View file

@ -25,7 +25,6 @@
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'kick:game_feed_discord' %}" <a href="{% url 'kick:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a> title="Discord feed for all games">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
{% if categories %} {% if categories %}
<ul> <ul>

View file

@ -27,7 +27,6 @@
title="Atom feed for all campaigns">[atom]</a> title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'kick:campaign_feed_discord' %}" <a href="{% url 'kick:campaign_feed_discord' %}"
title="Discord feed for all campaigns">[discord]</a> title="Discord feed for all campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
<hr /> <hr />
{% if active_campaigns %} {% if active_campaigns %}

View file

@ -26,7 +26,6 @@
title="Atom feed for all organizations">[atom]</a> title="Atom feed for all organizations">[atom]</a>
<a href="{% url 'kick:organization_feed_discord' %}" <a href="{% url 'kick:organization_feed_discord' %}"
title="Discord feed for all organizations">[discord]</a> title="Discord feed for all organizations">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
{% if orgs %} {% if orgs %}
<ul> <ul>

View file

@ -7,11 +7,13 @@
<h1>{{ badge_sets.count }} Twitch Chat Badges</h1> <h1>{{ badge_sets.count }} Twitch Chat Badges</h1>
{% if badge_sets %} {% if badge_sets %}
{% for data in badge_data %} {% for data in badge_data %}
<!-- {{ data.set.set_id }} - {{ data.badges|length }} version{% if data.badges|length > 1 %}s{% endif %} -->
<h2> <h2>
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a>
</h2> </h2>
<table> <table>
{% for badge in data.badges %} {% for badge in data.badges %}
<!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} -->
<tr> <tr>
<td style="width: 40px;"> <td style="width: 40px;">
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}"> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">

View file

@ -9,6 +9,7 @@
<table> <table>
<tbody> <tbody>
{% for badge in badges %} {% for badge in badges %}
<!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} -->
<tr> <tr>
<td> <td>
<code>{{ badge.badge_id }}</code> <code>{{ badge.badge_id }}</code>
@ -47,4 +48,5 @@
{% else %} {% else %}
<p>No badges found in this set.</p> <p>No badges found in this set.</p>
{% endif %} {% endif %}
{{ set_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -45,14 +45,6 @@
</div> </div>
<!-- Campaign description --> <!-- Campaign description -->
<p>{{ campaign.description|linebreaksbr }}</p> <p>{{ campaign.description|linebreaksbr }}</p>
<small>
Published:
<time datetime="{{ campaign.added_at|date:'c' }}"
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time>
</small>
<!-- Campaign end times --> <!-- Campaign end times -->
<div> <div>
{% if campaign.end_at < now %} {% if campaign.end_at < now %}
@ -104,7 +96,6 @@
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a> title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
<a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}" <a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}"
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a> title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -115,6 +106,7 @@
<table style="border-collapse: collapse; width: 100%;"> <table style="border-collapse: collapse; width: 100%;">
<tbody> <tbody>
{% for drop in drops %} {% for drop in drops %}
<!-- {{ drop.drop.name }} - {{ drop.drop.benefits.all|join:", " }} -->
<tr> <tr>
<td> <td>
{% for benefit in drop.drop.benefits.all %} {% for benefit in drop.drop.benefits.all %}
@ -177,6 +169,7 @@
<h5>Allowed Channels</h5> <h5>Allowed Channels</h5>
<div> <div>
{% for channel in allowed_channels %} {% for channel in allowed_channels %}
<!-- {{ channel.display_name }} https://www.twitch.tv/{{ channel.display_name }} -->
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a> <a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
{% endfor %} {% endfor %}
</div> </div>
@ -188,4 +181,6 @@
Go to a participating live channel Go to a participating live channel
</a> </a>
{% endif %} {% endif %}
<!-- Campaign JSON -->
{{ campaign_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load image_tags %} {% load image_tags %}
{% load image_tags %}
{% block title %} {% block title %}
Drop Campaigns Drop Campaigns
{% endblock title %} {% endblock title %}
@ -30,7 +31,6 @@
title="Atom feed for all campaigns">[atom]</a> title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for all campaigns">[discord]</a> title="Discord feed for all campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
title="Export campaigns as CSV">[csv]</a> title="Export campaigns as CSV">[csv]</a>
<a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
@ -47,6 +47,7 @@
<select name="game"> <select name="game">
<option value="">All Games</option> <option value="">All Games</option>
{% for game in games %} {% for game in games %}
<!-- Game option with Twitch ID {{ game.twitch_id }} and display name "{{ game.display_name }}" -->
<option value="{{ game.twitch_id }}" <option value="{{ game.twitch_id }}"
{% if selected_game == game.twitch_id %}selected{% endif %}> {% if selected_game == game.twitch_id %}selected{% endif %}>
{{ game.display_name|default:game.name|default:game.slug|default:game.twitch_id }} {{ game.display_name|default:game.name|default:game.slug|default:game.twitch_id }}
@ -57,6 +58,7 @@
<select id="status" name="status"> <select id="status" name="status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
{% for status in status_options %} {% for status in status_options %}
<!-- Status option "{{ status }}" -->
<option value="{{ status }}" <option value="{{ status }}"
{% if selected_status == status %}selected{% endif %}>{{ status|title }}</option> {% if selected_status == status %}selected{% endif %}>{{ status|title }}</option>
{% endfor %} {% endfor %}
@ -69,6 +71,7 @@
{% if campaigns %} {% if campaigns %}
{% regroup campaigns by game as campaigns_by_game %} {% regroup campaigns by game as campaigns_by_game %}
{% for game_group in campaigns_by_game %} {% for game_group in campaigns_by_game %}
<!-- Game group for game "{{ game_group.grouper.display_name }}" with {{ game_group.list|length }} campaigns -->
<section> <section>
<div style="display: flex; gap: 1rem;"> <div style="display: flex; gap: 1rem;">
<div> <div>
@ -105,6 +108,9 @@
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;"> <div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign in game_group.list %} {% for campaign in game_group.list %}
<!-- Campaign "{{ campaign.clean_name }}" with Twitch ID {{ campaign.twitch_id }} -->
<!-- https://ttvdrops.lovinator.space{% url 'twitch:campaign_detail' campaign.twitch_id %} -->
<!-- https://ttvdrops.lovinator.space{{ campaign.image_best_url }} -->
<article style="display: flex; <article style="display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@ -159,6 +165,7 @@
{% endif %} {% endif %}
<!-- Pagination --> <!-- Pagination -->
{% if is_paginated %} {% if is_paginated %}
<!-- {{ page_obj.paginator.count }} total campaigns, showing {{ page_obj.start_index }} to {{ page_obj.end_index }} on page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -->
<nav style="text-align: center;"> <nav style="text-align: center;">
<div> <div>
{% if page_obj.has_previous %} {% if page_obj.has_previous %}

View file

@ -20,26 +20,22 @@
</iframe> </iframe>
<!-- Channel Info --> <!-- Channel Info -->
<p>Channel ID: {{ channel.twitch_id }}</p> <p>Channel ID: {{ channel.twitch_id }}</p>
<p>
Published:
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">{{ channel.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ channel.updated_at|date:'c' }}"
title="{{ channel.updated_at|date:'DATETIME_FORMAT' }}">{{ channel.updated_at|date:"M d, Y H:i" }}</time>
</p>
{% if active_campaigns %} {% if active_campaigns %}
<h5>Active Campaigns</h5> <h5>Active Campaigns</h5>
<table> <table>
<tbody> <tbody>
{% for campaign in active_campaigns %} {% for campaign in active_campaigns %}
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
<tr> <tr>
<td> <td>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %} {% if campaign.time_based_drops.all %}
<!-- If the campaign has time-based drops, show the benefits in a nested div --> <!-- If the campaign has time-based drops, show the benefits in a nested div -->
<div> <div>
<!-- swag swag swag {{campaign.sorted_benefits}} -->
{% for benefit in campaign.sorted_benefits %} {% for benefit in campaign.sorted_benefits %}
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
<!-- {{ benefit.image_best_url }} -->
<span title="{{ benefit.name }}"> <span title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %} {% if benefit.image_best_url or benefit.image_asset_url %}
<!-- Show the benefit image if available --> <!-- Show the benefit image if available -->
@ -81,6 +77,7 @@
<table> <table>
<tbody> <tbody>
{% for campaign in upcoming_campaigns %} {% for campaign in upcoming_campaigns %}
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
<tr> <tr>
<td> <td>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
@ -88,6 +85,7 @@
<!-- If the campaign has time-based drops, show the benefits in a nested div --> <!-- If the campaign has time-based drops, show the benefits in a nested div -->
<div> <div>
{% for benefit in campaign.sorted_benefits %} {% for benefit in campaign.sorted_benefits %}
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
<span title="{{ benefit.name }}"> <span title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %} {% if benefit.image_best_url or benefit.image_asset_url %}
<!-- Show the benefit image if available --> <!-- Show the benefit image if available -->
@ -129,6 +127,7 @@
<table> <table>
<tbody> <tbody>
{% for campaign in expired_campaigns %} {% for campaign in expired_campaigns %}
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
<tr> <tr>
<td> <td>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
@ -136,6 +135,7 @@
<!-- If the campaign has time-based drops, show the benefits in a nested div --> <!-- If the campaign has time-based drops, show the benefits in a nested div -->
<div> <div>
{% for benefit in campaign.sorted_benefits %} {% for benefit in campaign.sorted_benefits %}
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
<span title="{{ benefit.name }}"> <span title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %} {% if benefit.image_best_url or benefit.image_asset_url %}
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}" <img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
@ -173,4 +173,5 @@
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<p>No campaigns found for this channel.</p> <p>No campaigns found for this channel.</p>
{% endif %} {% endif %}
{{ channel_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -39,7 +39,6 @@
title="Atom feed for campaigns">[atom]</a> title="Atom feed for campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for campaigns">[discord]</a> title="Discord feed for campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
<hr /> <hr />
{% if campaigns_by_game %} {% if campaigns_by_game %}
@ -64,6 +63,7 @@
<div style="flex: 1; overflow-x: auto;"> <div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;"> <div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %} {% for campaign_data in game_data.campaigns %}
<!-- {{ campaign_data.campaign.name }} -->
<article style="display: flex; <article style="display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -106,6 +106,7 @@
{% if campaign_data.campaign.allow_is_enabled %} {% if campaign_data.campaign.allow_is_enabled %}
{% if campaign_data.allowed_channels %} {% if campaign_data.allowed_channels %}
{% for channel in campaign_data.allowed_channels|slice:":5" %} {% for channel in campaign_data.allowed_channels|slice:":5" %}
<!-- {{ channel.name }} -->
<li style="margin-bottom: 0.1rem;"> <li style="margin-bottom: 0.1rem;">
<a href="https://twitch.tv/{{ channel.name }}" <a href="https://twitch.tv/{{ channel.name }}"
rel="nofollow ugc" rel="nofollow ugc"
@ -131,12 +132,14 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if campaign_data.allowed_channels|length > 5 %} {% if campaign_data.allowed_channels|length > 5 %}
<!-- {{ campaign_data.allowed_channels|length }} allowed channels -->
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;"> <li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more ... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
</li> </li>
{% endif %} {% endif %}
{% else %} {% else %}
{% if campaign_data.campaign.game.twitch_directory_url %} {% if campaign_data.campaign.game.twitch_directory_url %}
<!--{{ campaign_data.campaign.game.display_name }} Twitch directory URL: {{ campaign_data.campaign.game.twitch_directory_url }} -->
<li> <li>
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}" <a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
rel="nofollow ugc" rel="nofollow ugc"
@ -145,6 +148,7 @@
</a> </a>
</li> </li>
{% else %} {% else %}
<!-- {{ campaign_data.campaign.game.display_name }} Twitch directory URL not available -->
<li>Failed to get Twitch directory URL :(</li> <li>Failed to get Twitch directory URL :(</li>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -0,0 +1,104 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
RSS Feeds Documentation
{% endblock title %}
{% block content %}
<main>
<h1>RSS Feeds Documentation</h1>
<p>This page lists all available RSS feeds for TTVDrops.</p>
<p>
Atom feeds are also available for the same resources under the
<code>/atom/</code> endpoints.
</p>
<p>
Discord feeds are available under the <code>/discord/</code> endpoints. These are Atom feeds
that include Discord relative timestamps (e.g., <code>&lt;t:1773450272:R&gt;</code>) for dates,
making them ideal for Discord bots and integrations.
</p>
<section>
<h2>Global RSS Feeds</h2>
<p>These feeds contain all items across the entire site:</p>
<ul>
{% for feed in feeds %}
<li>
<h3>{{ feed.title }}</h3>
<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 %}
{% if feed.discord_url %}
&nbsp;|&nbsp;
<a href="{{ feed.discord_url }}">Subscribe to {{ feed.title }} Discord 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 %}
{% if feed.example_xml_discord %}
<h4>Discord example</h4>
<pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
<section>
<h2>Filtered RSS Feeds</h2>
<p>
You can subscribe to RSS feeds scoped to a specific game or organization. When available, links below point to live examples; otherwise use the endpoint template.
</p>
<ul>
{% for feed in filtered_feeds %}
<li>
<h3>{{ feed.title }}</h3>
<p>{{ feed.description }}</p>
<p>
Endpoint: <code>{{ feed.url }}</code>
{% if feed.atom_url %}&nbsp;|&nbsp; Atom: <code>{{ feed.atom_url }}</code>{% endif %}
{% if feed.discord_url %}&nbsp;|&nbsp; Discord: <code>{{ feed.discord_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 %}
{% if feed.discord_url %}
&nbsp;|&nbsp;
<a href="{{ feed.discord_url }}">View Discord 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 %}
{% if feed.example_xml_discord %}
<h4>Discord example</h4>
<pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
<section>
<h2>How to Use RSS Feeds</h2>
<p>
RSS feeds allow you to stay updated with new content. You can use any RSS reader application to subscribe to these feeds.
</p>
<ul>
<li>Copy the feed URL</li>
<li>Paste it into your favorite RSS reader (Feedly, Inoreader, NetNewsWire, etc.)</li>
<li>Get automatic updates when new content is added</li>
</ul>
</section>
</main>
{% endblock content %}

View file

@ -6,6 +6,8 @@
{% block content %} {% block content %}
<h1>Emotes</h1> <h1>Emotes</h1>
{% for emote in emotes %} {% for emote in emotes %}
<!-- Emote from campaign {{ emote.campaign.name }} -->
<!-- https://ttvdrops.lovinator.space{{ emote.image_url }} -->
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}" <a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
title="{{ emote.campaign.name }}" title="{{ emote.campaign.name }}"
style="display: inline-block"> style="display: inline-block">

View file

@ -47,14 +47,6 @@
Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a> Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a>
</div> </div>
<div>Twitch slug: {{ game.slug }}</div> <div>Twitch slug: {{ game.slug }}</div>
<small>
Published:
<time datetime="{{ game.added_at|date:'c' }}"
title="{{ game.added_at|date:'DATETIME_FORMAT' }}">{{ game.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ game.updated_at|date:'c' }}"
title="{{ game.updated_at|date:'DATETIME_FORMAT' }}">{{ game.updated_at|date:"M d, Y H:i" }}</time>
</small>
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div> <div>
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}" <a href="{% url 'core:game_campaign_feed' game.twitch_id %}"
@ -63,7 +55,6 @@
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a> title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
<a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}" <a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}"
title="Discord feed for {{ game.display_name }} campaigns">[discord]</a> title="Discord feed for {{ game.display_name }} campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
</div> </div>
</div> </div>
@ -159,4 +150,5 @@
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<p id="no-campaigns-message">No campaigns found for this game.</p> <p id="no-campaigns-message">No campaigns found for this game.</p>
{% endif %} {% endif %}
{{ game_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -28,7 +28,6 @@
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'core:game_feed_discord' %}" <a href="{% url 'core:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a> title="Discord feed for all games">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a> title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}" <a href="{% url 'twitch:export_games_json' %}"

View file

@ -26,7 +26,6 @@
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'core:game_feed_discord' %}" <a href="{% url 'core:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a> title="Discord feed for all games">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a> title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}" <a href="{% url 'twitch:export_games_json' %}"

View file

@ -11,7 +11,6 @@
title="Atom feed for all organizations">[atom]</a> title="Atom feed for all organizations">[atom]</a>
<a href="{% url 'core:organization_feed_discord' %}" <a href="{% url 'core:organization_feed_discord' %}"
title="Discord feed for all organizations">[discord]</a> title="Discord feed for all organizations">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_organizations_csv' %}" <a href="{% url 'twitch:export_organizations_csv' %}"
title="Export all organizations as CSV">[csv]</a> title="Export all organizations as CSV">[csv]</a>
<a href="{% url 'twitch:export_organizations_json' %}" <a href="{% url 'twitch:export_organizations_json' %}"
@ -28,4 +27,5 @@
{% else %} {% else %}
<p>No organizations found.</p> <p>No organizations found.</p>
{% endif %} {% endif %}
{{ orgs_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -21,22 +21,14 @@
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<h1>{{ organization.name }}</h1> <h1 id="org-name">{{ organization.name }}</h1>
<p> <theader>
Published: <h2 id="games-header">Games by {{ organization.name }}</h2>
<time datetime="{{ organization.added_at|date:'c' }}" </theader>
title="{{ organization.added_at|date:'DATETIME_FORMAT' }}">{{ organization.added_at|date:"M d, Y H:i" }}</time> <table id="games-table">
· Last updated:
<time datetime="{{ organization.updated_at|date:'c' }}"
title="{{ organization.updated_at|date:'DATETIME_FORMAT' }}">{{ organization.updated_at|date:"M d, Y H:i" }}</time>
</p>
<header>
<h2>Games by {{ organization.name }}</h2>
</header>
<table>
<tbody> <tbody>
{% for game in games %} {% for game in games %}
<tr> <tr id="game-row-{{ game.twitch_id }}">
<td> <td>
<a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a> <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a>
</td> </td>
@ -44,4 +36,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<hr />
{{ org_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -37,12 +37,11 @@
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<a href="{% url 'core:reward_campaign_feed' %}" <a href="{% url 'core:reward_campaign_feed' %}"
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all reward campaigns">[rss]</a> title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'core:reward_campaign_feed_atom' %}" <a href="{% url 'core:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a> title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'core:reward_campaign_feed_discord' %}" <a href="{% url 'core:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a> title="Discord feed for all reward campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
<!-- Campaign Summary --> <!-- Campaign Summary -->
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %} {% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}
@ -165,4 +164,6 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<hr />
{{ campaign_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -27,7 +27,6 @@
title="Atom feed for all reward campaigns">[atom]</a> title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'core:reward_campaign_feed_discord' %}" <a href="{% url 'core:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a> title="Discord feed for all reward campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>
<p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p> <p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p>
<p> <p>

View file

@ -958,7 +958,7 @@ class TestChannelListView:
client: Client, client: Client,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Game detail view should no longer expose debug JSON payload in context.""" """Game detail JSON payload should use `owners` (M2M), not stale `owner`."""
org: Organization = Organization.objects.create( org: Organization = Organization.objects.create(
twitch_id="org-game-detail", twitch_id="org-game-detail",
name="Org Game Detail", name="Org Game Detail",
@ -970,10 +970,17 @@ class TestChannelListView:
) )
game.owners.add(org) game.owners.add(org)
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:game_detail", args=[game.twitch_id]) url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert "game_data" not in response.context
game_data: dict[str, Any] = response.context["game_data"]
fields: dict[str, Any] = game_data["fields"]
assert "owners" in fields
assert fields["owners"] == [org.pk]
assert "owner" not in fields
@pytest.mark.django_db @pytest.mark.django_db
def test_org_list_view(self, client: Client) -> None: def test_org_list_view(self, client: Client) -> None:
@ -1006,29 +1013,14 @@ class TestChannelListView:
@pytest.mark.django_db @pytest.mark.django_db
def test_docs_rss_view(self, client: Client) -> None: def test_docs_rss_view(self, client: Client) -> None:
"""Test docs RSS view returns 200.""" """Test docs RSS view returns 200 and has feeds in context."""
response: _MonkeyPatchedWSGIResponse = client.get(reverse("core:docs_rss")) response: _MonkeyPatchedWSGIResponse = client.get(reverse("core:docs_rss"))
assert response.status_code == 200 assert response.status_code == 200
assert "feeds" in response.context
# Add Game with running campaign to ensure it's included in the RSS feed assert "filtered_feeds" in response.context
game: Game = Game.objects.create( assert response.context["feeds"][0]["example_xml"]
twitch_id="g-rss", html: str = response.content.decode()
name="Game RSS", assert '<code class="language-xml">' in html
display_name="Game RSS",
)
DropCampaign.objects.create(
twitch_id="c-rss",
name="Campaign RSS",
game=game,
start_at=timezone.now() - timedelta(days=1),
end_at=timezone.now() + timedelta(days=1),
operation_names=["DropCampaignDetails"],
)
response = client.get(reverse("core:docs_rss"))
assert response.status_code == 200
assert "g-rss" in response.content.decode("utf-8")
@pytest.mark.django_db @pytest.mark.django_db
@ -1059,11 +1051,9 @@ class TestSEOHelperFunctions:
context: dict[str, Any] = _build_seo_context( context: dict[str, Any] = _build_seo_context(
page_title="Test Title", page_title="Test Title",
page_description="Test Description", page_description="Test Description",
seo_meta={ page_image="https://example.com/image.jpg",
"page_image": "https://example.com/image.jpg", og_type="article",
"og_type": "article", schema_data={"@context": "https://schema.org"},
"schema_data": {"@context": "https://schema.org"},
},
) )
assert context["page_title"] == "Test Title" assert context["page_title"] == "Test Title"
@ -1093,16 +1083,14 @@ class TestSEOHelperFunctions:
context: dict[str, Any] = _build_seo_context( context: dict[str, Any] = _build_seo_context(
page_title="Test", page_title="Test",
page_description="Desc", page_description="Desc",
seo_meta={ page_image="https://example.com/img.jpg",
"page_image": "https://example.com/img.jpg", og_type="article",
"og_type": "article", schema_data={},
"schema_data": {}, breadcrumb_schema=breadcrumb,
"breadcrumb_schema": breadcrumb, pagination_info=[{"rel": "next", "url": "/page/2/"}],
"pagination_info": [{"rel": "next", "url": "/page/2/"}], published_date=now.isoformat(),
"published_date": now.isoformat(), modified_date=now.isoformat(),
"modified_date": now.isoformat(), robots_directive="noindex, follow",
"robots_directive": "noindex, follow",
},
) )
# breadcrumb_schema is JSON-dumped, so parse it back # breadcrumb_schema is JSON-dumped, so parse it back
@ -1711,6 +1699,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""VideoGame schema image should be an ImageObject, not a plain URL.""" """VideoGame schema image should be an ImageObject, not a plain URL."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:game_detail", args=[game.twitch_id]) url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -1731,6 +1720,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""VideoGame ImageObject should carry creditText and copyrightNotice.""" """VideoGame ImageObject should carry creditText and copyrightNotice."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:game_detail", args=[game.twitch_id]) url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1745,6 +1735,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""VideoGame schema should omit image key when box_art is empty.""" """VideoGame schema should omit image key when box_art is empty."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
game_no_art: Game = Game.objects.create( game_no_art: Game = Game.objects.create(
twitch_id="game-no-art", twitch_id="game-no-art",
name="no_art_game", name="no_art_game",
@ -1765,6 +1756,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""VideoGame schema publisher name should match the owning organization.""" """VideoGame schema publisher name should match the owning organization."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:game_detail", args=[game.twitch_id]) url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1779,6 +1771,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""publisher.name and image.creditText should be the same value.""" """publisher.name and image.creditText should be the same value."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:game_detail", args=[game.twitch_id]) url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1791,6 +1784,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""When owner.name is empty, twitch_id is used as credit fallback.""" """When owner.name is empty, twitch_id is used as credit fallback."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
nameless_org: Organization = Organization.objects.create( nameless_org: Organization = Organization.objects.create(
twitch_id="org-nameless", twitch_id="org-nameless",
name="", name="",
@ -1818,6 +1812,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Event schema image should be an ImageObject, not a plain URL string.""" """Event schema image should be an ImageObject, not a plain URL string."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -1838,6 +1833,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Event ImageObject should carry creditText and copyrightNotice.""" """Event ImageObject should carry creditText and copyrightNotice."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1853,6 +1849,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Event schema should omit image key when campaign has no image.""" """Event schema should omit image key when campaign has no image."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
campaign_no_img: DropCampaign = DropCampaign.objects.create( campaign_no_img: DropCampaign = DropCampaign.objects.create(
twitch_id="camp-no-img", twitch_id="camp-no-img",
name="No Image Campaign", name="No Image Campaign",
@ -1874,6 +1871,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Event schema organizer name should match the owning organization.""" """Event schema organizer name should match the owning organization."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1887,6 +1885,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""organizer.name and image.creditText should be the same value.""" """organizer.name and image.creditText should be the same value."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1899,6 +1898,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""When campaign has no owning org, creditText falls back to 'Twitch'.""" """When campaign has no owning org, creditText falls back to 'Twitch'."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
game_no_owner: Game = Game.objects.create( game_no_owner: Game = Game.objects.create(
twitch_id="game-no-owner", twitch_id="game-no-owner",
name="no_owner_game", name="no_owner_game",
@ -1926,6 +1926,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""When one owner is 'Twitch Gaming' and another is not, the non-generic one is used.""" """When one owner is 'Twitch Gaming' and another is not, the non-generic one is used."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
twitch_gaming: Organization = Organization.objects.create( twitch_gaming: Organization = Organization.objects.create(
twitch_id="twitch-gaming", twitch_id="twitch-gaming",
name="Twitch Gaming", name="Twitch Gaming",
@ -1955,6 +1956,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""When the only owner is 'Twitch Gaming', it is still used (no other choice).""" """When the only owner is 'Twitch Gaming', it is still used (no other choice)."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
twitch_gaming: Organization = Organization.objects.create( twitch_gaming: Organization = Organization.objects.create(
twitch_id="twitch-gaming-solo", twitch_id="twitch-gaming-solo",
name="Twitch Gaming", name="Twitch Gaming",
@ -1979,6 +1981,7 @@ class TestImageObjectStructuredData:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Campaign schema prefers a non-generic publisher over 'Twitch Gaming'.""" """Campaign schema prefers a non-generic publisher over 'Twitch Gaming'."""
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
twitch_gaming: Organization = Organization.objects.create( twitch_gaming: Organization = Organization.objects.create(
twitch_id="twitch-gaming-camp", twitch_id="twitch-gaming-camp",
name="Twitch Gaming", name="Twitch Gaming",

View file

@ -1,5 +1,3 @@
from __future__ import annotations
import csv import csv
import datetime import datetime
import json import json
@ -14,6 +12,7 @@ from django.core.paginator import EmptyPage
from django.core.paginator import Page from django.core.paginator import Page
from django.core.paginator import PageNotAnInteger from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.core.serializers import serialize
from django.db.models import Case from django.db.models import Case
from django.db.models import Count from django.db.models import Count
from django.db.models import Prefetch from django.db.models import Prefetch
@ -27,6 +26,9 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic import ListView from django.views.generic import ListView
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
@ -42,8 +44,6 @@ if TYPE_CHECKING:
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from core.seo import SeoMeta
logger: logging.Logger = logging.getLogger("ttvdrops.views") logger: logging.Logger = logging.getLogger("ttvdrops.views")
MIN_QUERY_LENGTH_FOR_FTS = 3 MIN_QUERY_LENGTH_FOR_FTS = 3
@ -89,50 +89,66 @@ def _truncate_description(text: str, max_length: int = 160) -> str:
return text[:max_length].rsplit(" ", 1)[0] + "" return text[:max_length].rsplit(" ", 1)[0] + ""
def _build_seo_context( def _build_seo_context( # noqa: PLR0913, PLR0917
page_title: str = "ttvdrops", page_title: str = "ttvdrops",
page_description: str | None = None, page_description: str | None = None,
seo_meta: SeoMeta | None = None, page_image: str | None = None,
page_image_width: int | None = None,
page_image_height: int | None = None,
og_type: str = "website",
schema_data: dict[str, Any] | None = None,
breadcrumb_schema: dict[str, Any] | None = None,
pagination_info: list[dict[str, str]] | None = None,
published_date: str | None = None,
modified_date: str | None = None,
robots_directive: str = "index, follow",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build SEO context for template rendering. """Build SEO context for template rendering.
Args: Args:
page_title: Page title (shown in browser tab, og:title). page_title: Page title (shown in browser tab, og:title).
page_description: Page description (meta description, og:description). page_description: Page description (meta description, og:description).
seo_meta: Optional typed SEO metadata with image, schema, breadcrumb, page_image: Image URL for og:image meta tag.
pagination, OpenGraph, and date fields. page_image_width: Width of the image in pixels.
page_image_height: Height of the image in pixels.
og_type: OpenGraph type (e.g., "website", "article").
schema_data: Dict representation of Schema.org JSON-LD data.
breadcrumb_schema: Breadcrumb schema dict for navigation hierarchy.
pagination_info: List of dicts with "rel" (prev|next|first|last) and "url".
published_date: ISO 8601 published date (e.g., "2025-01-01T00:00:00Z").
modified_date: ISO 8601 modified date.
robots_directive: Robots meta content (e.g., "index, follow" or "noindex").
Returns: Returns:
Dict with SEO context variables to pass to render(). Dict with SEO context variables to pass to render().
""" """
# TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003
# consider having a single "seo_info" parameter that
# can contain all of these optional fields. This would make
# it easier to extend in the future without changing the
# function signature.
context: dict[str, Any] = { context: dict[str, Any] = {
"page_title": page_title, "page_title": page_title,
"page_description": page_description or DEFAULT_SITE_DESCRIPTION, "page_description": page_description or DEFAULT_SITE_DESCRIPTION,
"og_type": "website", "og_type": og_type,
"robots_directive": "index, follow", "robots_directive": robots_directive,
} }
if seo_meta: if page_image:
if seo_meta.get("page_url"): context["page_image"] = page_image
context["page_url"] = seo_meta["page_url"] if page_image_width and page_image_height:
if seo_meta.get("og_type"): context["page_image_width"] = page_image_width
context["og_type"] = seo_meta["og_type"] context["page_image_height"] = page_image_height
if seo_meta.get("robots_directive"): if schema_data:
context["robots_directive"] = seo_meta["robots_directive"] context["schema_data"] = json.dumps(schema_data)
if seo_meta.get("page_image"): if breadcrumb_schema:
context["page_image"] = seo_meta["page_image"] context["breadcrumb_schema"] = json.dumps(breadcrumb_schema)
if seo_meta.get("page_image_width") and seo_meta.get("page_image_height"): if pagination_info:
context["page_image_width"] = seo_meta["page_image_width"] context["pagination_info"] = pagination_info
context["page_image_height"] = seo_meta["page_image_height"] if published_date:
if seo_meta.get("schema_data"): context["published_date"] = published_date
context["schema_data"] = json.dumps(seo_meta["schema_data"]) if modified_date:
if seo_meta.get("breadcrumb_schema"): context["modified_date"] = modified_date
context["breadcrumb_schema"] = json.dumps(seo_meta["breadcrumb_schema"])
if seo_meta.get("pagination_info"):
context["pagination_info"] = seo_meta["pagination_info"]
if seo_meta.get("published_date"):
context["published_date"] = seo_meta["published_date"]
if seo_meta.get("modified_date"):
context["modified_date"] = seo_meta["modified_date"]
return context return context
@ -260,6 +276,14 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
""" """
orgs: QuerySet[Organization] = Organization.objects.all().order_by("name") orgs: QuerySet[Organization] = Organization.objects.all().order_by("name")
# Serialize all organizations
serialized_orgs: str = serialize(
"json",
orgs,
fields=("twitch_id", "name", "added_at", "updated_at"),
)
orgs_data: list[dict] = json.loads(serialized_orgs)
# CollectionPage schema for organizations list # CollectionPage schema for organizations list
collection_schema: dict[str, str] = { collection_schema: dict[str, str] = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -272,10 +296,11 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Organizations", page_title="Twitch Organizations",
page_description="List of Twitch organizations.", page_description="List of Twitch organizations.",
seo_meta={"schema_data": collection_schema}, schema_data=collection_schema,
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"orgs": orgs, "orgs": orgs,
"orgs_data": format_and_color_json(orgs_data),
**seo_context, **seo_context,
} }
@ -283,7 +308,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
# MARK: /organizations/<twitch_id>/ # MARK: /organizations/<twitch_id>/
def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914
"""Function-based view for organization detail. """Function-based view for organization detail.
Args: Args:
@ -304,6 +329,30 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue] games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
serialized_org: str = serialize(
"json",
[organization],
fields=("twitch_id", "name", "added_at", "updated_at"),
)
org_data: list[dict] = json.loads(serialized_org)
if games.exists():
serialized_games: str = serialize(
"json",
games,
fields=(
"twitch_id",
"slug",
"name",
"display_name",
"box_art",
"added_at",
"updated_at",
),
)
games_data: list[dict] = json.loads(serialized_games)
org_data[0]["fields"]["games"] = games_data
org_name: str = organization.name or organization.twitch_id org_name: str = organization.name or organization.twitch_id
games_count: int = games.count() games_count: int = games.count()
s: Literal["", "s"] = "" if games_count == 1 else "s" s: Literal["", "s"] = "" if games_count == 1 else "s"
@ -312,25 +361,13 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
url: str = request.build_absolute_uri( url: str = request.build_absolute_uri(
reverse("twitch:organization_detail", args=[organization.twitch_id]), reverse("twitch:organization_detail", args=[organization.twitch_id]),
) )
organization_node: dict[str, Any] = { org_schema: dict[str, str | dict[str, str]] = {
"@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
"name": org_name, "name": org_name,
"url": url, "url": url,
"description": org_description, "description": org_description,
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": url,
"datePublished": organization.added_at.isoformat(),
"dateModified": organization.updated_at.isoformat(),
}
org_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
organization_node,
webpage_node,
],
}
# Breadcrumb schema # Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
@ -347,16 +384,14 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=org_name, page_title=org_name,
page_description=org_description, page_description=org_description,
seo_meta={ schema_data=org_schema,
"schema_data": org_schema, breadcrumb_schema=breadcrumb_schema,
"breadcrumb_schema": breadcrumb_schema, modified_date=organization.updated_at.isoformat(),
"published_date": organization.added_at.isoformat(),
"modified_date": organization.updated_at.isoformat(),
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"organization": organization, "organization": organization,
"games": games, "games": games,
"org_data": format_and_color_json(org_data[0]),
**seo_context, **seo_context,
} }
@ -446,11 +481,8 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=title, page_title=title,
page_description=description, page_description=description,
seo_meta={ pagination_info=pagination_info,
"page_url": request.build_absolute_uri(base_url), schema_data=collection_schema,
"pagination_info": pagination_info,
"schema_data": collection_schema,
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"campaigns": campaigns, "campaigns": campaigns,
@ -467,6 +499,22 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
return render(request, "twitch/campaign_list.html", context) return render(request, "twitch/campaign_list.html", context)
def format_and_color_json(data: dict[str, Any] | list[dict] | str) -> str:
"""Format and color a JSON string for HTML display.
Args:
data: Either a dictionary, list of dictionaries, or a JSON string to format.
Returns:
str: The formatted code with HTML styles.
"""
if isinstance(data, (dict, list)):
formatted_code: str = json.dumps(data, indent=4)
else:
formatted_code = data
return highlight(formatted_code, JsonLexer(), HtmlFormatter())
def _enhance_drops_with_context( def _enhance_drops_with_context(
drops: QuerySet[TimeBasedDrop], drops: QuerySet[TimeBasedDrop],
now: datetime.datetime, now: datetime.datetime,
@ -510,7 +558,7 @@ def _enhance_drops_with_context(
# MARK: /campaigns/<twitch_id>/ # MARK: /campaigns/<twitch_id>/
def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914 def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914, PLR0915
"""Function-based view for a drop campaign detail. """Function-based view for a drop campaign detail.
Args: Args:
@ -544,6 +592,96 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
.order_by("required_minutes_watched") .order_by("required_minutes_watched")
) )
serialized_campaign: str = serialize(
"json",
[campaign],
fields=(
"twitch_id",
"name",
"description",
"details_url",
"account_link_url",
"image_url",
"start_at",
"end_at",
"allow_is_enabled",
"operation_names",
"game",
"created_at",
"updated_at",
),
)
campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign)
if drops.exists():
badge_benefit_names: set[str] = {
benefit.name
for drop in drops
for benefit in drop.benefits.all()
if benefit.distribution_type == "BADGE" and benefit.name
}
badge_descriptions_by_title: dict[str, str] = dict(
ChatBadge.objects.filter(title__in=badge_benefit_names).values_list(
"title",
"description",
),
)
serialized_drops = serialize(
"json",
drops,
fields=(
"twitch_id",
"name",
"required_minutes_watched",
"required_subs",
"start_at",
"end_at",
"added_at",
"updated_at",
),
)
drops_data: list[dict[str, Any]] = json.loads(serialized_drops)
for i, drop in enumerate(drops):
drop_benefits: list[DropBenefit] = list(drop.benefits.all())
if drop_benefits:
serialized_benefits: str = serialize(
"json",
drop_benefits,
fields=(
"twitch_id",
"name",
"image_asset_url",
"added_at",
"updated_at",
"created_at",
"entitlement_limit",
"is_ios_available",
"distribution_type",
),
)
benefits_data: list[dict[str, Any]] = json.loads(serialized_benefits)
for benefit_data in benefits_data:
fields: dict[str, Any] = benefit_data.get("fields", {})
if fields.get("distribution_type") != "BADGE":
continue
# DropBenefit doesn't have a description field; fetch it from ChatBadge when possible.
if fields.get("description"):
continue
badge_description: str | None = badge_descriptions_by_title.get(
fields.get("name", ""),
)
if badge_description:
fields["description"] = badge_description
drops_data[i]["fields"]["benefits"] = benefits_data
campaign_data[0]["fields"]["drops"] = drops_data
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now) enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now)
# Attach awarded_badge to each drop in enhanced_drops # Attach awarded_badge to each drop in enhanced_drops
@ -562,6 +700,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
"campaign": campaign, "campaign": campaign,
"now": now, "now": now,
"drops": enhanced_drops, "drops": enhanced_drops,
"campaign_data": format_and_color_json(campaign_data[0]),
"owners": list(campaign.game.owners.all()), "owners": list(campaign.game.owners.all()),
"allowed_channels": getattr(campaign, "channels_ordered", []), "allowed_channels": getattr(campaign, "channels_ordered", []),
} }
@ -585,7 +724,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
) )
# TODO(TheLovinator): If the campaign has specific allowed channels, we could list those as potential locations instead of just linking to Twitch homepage. # noqa: TD003 # TODO(TheLovinator): If the campaign has specific allowed channels, we could list those as potential locations instead of just linking to Twitch homepage. # noqa: TD003
campaign_event: dict[str, Any] = { campaign_schema: dict[str, str | dict[str, str]] = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Event", "@type": "Event",
"name": campaign_name, "name": campaign_name,
@ -599,9 +738,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
}, },
} }
if campaign.start_at: if campaign.start_at:
campaign_event["startDate"] = campaign.start_at.isoformat() campaign_schema["startDate"] = campaign.start_at.isoformat()
if campaign.end_at: if campaign.end_at:
campaign_event["endDate"] = campaign.end_at.isoformat() campaign_schema["endDate"] = campaign.end_at.isoformat()
campaign_owner: Organization | None = ( campaign_owner: Organization | None = (
_pick_owner(list(campaign.game.owners.all())) if campaign.game else None _pick_owner(list(campaign.game.owners.all())) if campaign.game else None
) )
@ -611,25 +750,17 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
else "Twitch" else "Twitch"
) )
if campaign_image: if campaign_image:
campaign_event["image"] = { campaign_schema["image"] = {
"@type": "ImageObject", "@type": "ImageObject",
"contentUrl": request.build_absolute_uri(campaign_image), "contentUrl": request.build_absolute_uri(campaign_image),
"creditText": campaign_owner_name, "creditText": campaign_owner_name,
"copyrightNotice": campaign_owner_name, "copyrightNotice": campaign_owner_name,
} }
if campaign_owner: if campaign_owner:
campaign_event["organizer"] = { campaign_schema["organizer"] = {
"@type": "Organization", "@type": "Organization",
"name": campaign_owner_name, "name": campaign_owner_name,
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": url,
"datePublished": campaign.added_at.isoformat(),
"dateModified": campaign.updated_at.isoformat(),
}
campaign_event["mainEntityOfPage"] = webpage_node
campaign_schema: dict[str, Any] = campaign_event
# Breadcrumb schema for navigation # Breadcrumb schema for navigation
# TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # noqa: TD003 # TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # noqa: TD003
@ -656,19 +787,12 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=campaign_name, page_title=campaign_name,
page_description=campaign_description, page_description=campaign_description,
seo_meta={ page_image=campaign_image,
"page_image": campaign_image, page_image_width=campaign_image_width,
"page_image_width": campaign_image_width, page_image_height=campaign_image_height,
"page_image_height": campaign_image_height, schema_data=campaign_schema,
"schema_data": campaign_schema, breadcrumb_schema=breadcrumb_schema,
"breadcrumb_schema": breadcrumb_schema, modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None,
"published_date": campaign.added_at.isoformat()
if campaign.added_at
else None,
"modified_date": campaign.updated_at.isoformat()
if campaign.updated_at
else None,
},
) )
context.update(seo_context) context.update(seo_context)
@ -763,7 +887,7 @@ class GamesGridView(ListView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Games", page_title="Twitch Games",
page_description="Twitch games that had or have Twitch drops.", page_description="Twitch games that had or have Twitch drops.",
seo_meta={"schema_data": collection_schema}, schema_data=collection_schema,
) )
context.update(seo_context) context.update(seo_context)
@ -898,6 +1022,45 @@ class GameDetailView(DetailView):
if campaign.end_at is not None and campaign.end_at < now if campaign.end_at is not None and campaign.end_at < now
] ]
serialized_game: str = serialize(
"json",
[game],
fields=(
"twitch_id",
"slug",
"name",
"display_name",
"box_art",
"owners",
"added_at",
"updated_at",
),
)
game_data: list[dict[str, Any]] = json.loads(serialized_game)
if campaigns_list:
serialized_campaigns = serialize(
"json",
campaigns_list,
fields=(
"twitch_id",
"name",
"description",
"details_url",
"account_link_url",
"image_url",
"start_at",
"end_at",
"allow_is_enabled",
"game",
"operation_names",
"added_at",
"updated_at",
),
)
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
game_data[0]["fields"]["campaigns"] = campaigns_data
owners: list[Organization] = list(game.owners.all()) owners: list[Organization] = list(game.owners.all())
game_name: str = game.display_name or game.name or game.twitch_id game_name: str = game.display_name or game.name or game.twitch_id
@ -917,10 +1080,6 @@ class GameDetailView(DetailView):
reverse("twitch:game_detail", args=[game.twitch_id]), reverse("twitch:game_detail", args=[game.twitch_id]),
), ),
} }
if game.added_at:
game_schema["datePublished"] = game.added_at.isoformat()
if game.updated_at:
game_schema["dateModified"] = game.updated_at.isoformat()
preferred_owner: Organization | None = _pick_owner(owners) preferred_owner: Organization | None = _pick_owner(owners)
owner_name: str = ( owner_name: str = (
(preferred_owner.name or preferred_owner.twitch_id) (preferred_owner.name or preferred_owner.twitch_id)
@ -955,17 +1114,12 @@ class GameDetailView(DetailView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=game_name, page_title=game_name,
page_description=game_description, page_description=game_description,
seo_meta={ page_image=game_image,
"page_image": game_image, page_image_width=game_image_width,
"page_image_width": game_image_width, page_image_height=game_image_height,
"page_image_height": game_image_height, schema_data=game_schema,
"schema_data": game_schema, breadcrumb_schema=breadcrumb_schema,
"breadcrumb_schema": breadcrumb_schema, modified_date=game.updated_at.isoformat() if game.updated_at else None,
"published_date": game.added_at.isoformat() if game.added_at else None,
"modified_date": game.updated_at.isoformat()
if game.updated_at
else None,
},
) )
context.update({ context.update({
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
@ -975,6 +1129,7 @@ class GameDetailView(DetailView):
"owners": owners, "owners": owners,
"drop_awarded_badges": drop_awarded_badges, "drop_awarded_badges": drop_awarded_badges,
"now": now, "now": now,
"game_data": format_and_color_json(game_data[0]),
**seo_context, **seo_context,
}) })
@ -1058,10 +1213,8 @@ def dashboard(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Drops", page_title="Twitch Drops",
page_description="Overview of active Twitch drop campaigns and rewards.", page_description="Overview of active Twitch drop campaigns and rewards.",
seo_meta={ og_type="website",
"og_type": "website", schema_data=website_schema,
"schema_data": website_schema,
},
) )
return render( return render(
request, request,
@ -1153,11 +1306,8 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=title, page_title=title,
page_description=description, page_description=description,
seo_meta={ pagination_info=pagination_info,
"page_url": request.build_absolute_uri(base_url), schema_data=collection_schema,
"pagination_info": pagination_info,
"schema_data": collection_schema,
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"reward_campaigns": reward_campaigns, "reward_campaigns": reward_campaigns,
@ -1194,6 +1344,28 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
msg = "No reward campaign found matching the query" msg = "No reward campaign found matching the query"
raise Http404(msg) from exc raise Http404(msg) from exc
serialized_campaign: str = serialize(
"json",
[reward_campaign],
fields=(
"twitch_id",
"name",
"brand",
"summary",
"instructions",
"external_url",
"about_url",
"reward_value_url_param",
"starts_at",
"ends_at",
"is_sitewide",
"game",
"added_at",
"updated_at",
),
)
campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign)
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
campaign_name: str = reward_campaign.name or reward_campaign.twitch_id campaign_name: str = reward_campaign.name or reward_campaign.twitch_id
@ -1203,45 +1375,29 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
else f"{campaign_name}" else f"{campaign_name}"
) )
reward_url: str = request.build_absolute_uri( campaign_schema: dict[str, str | dict[str, str]] = {
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]), "@context": "https://schema.org",
)
campaign_event: dict[str, Any] = {
"@type": "Event", "@type": "Event",
"name": campaign_name, "name": campaign_name,
"description": campaign_description, "description": campaign_description,
"url": reward_url, "url": request.build_absolute_uri(
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
),
"eventStatus": "https://schema.org/EventScheduled", "eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode", "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
"location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"}, "location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"},
} }
if reward_campaign.starts_at: if reward_campaign.starts_at:
campaign_event["startDate"] = reward_campaign.starts_at.isoformat() campaign_schema["startDate"] = reward_campaign.starts_at.isoformat()
if reward_campaign.ends_at: if reward_campaign.ends_at:
campaign_event["endDate"] = reward_campaign.ends_at.isoformat() campaign_schema["endDate"] = reward_campaign.ends_at.isoformat()
if reward_campaign.game and reward_campaign.game.owners.exists(): if reward_campaign.game and reward_campaign.game.owners.exists():
owner = reward_campaign.game.owners.first() owner = reward_campaign.game.owners.first()
campaign_event["organizer"] = { campaign_schema["organizer"] = {
"@type": "Organization", "@type": "Organization",
"name": owner.name or owner.twitch_id, "name": owner.name or owner.twitch_id,
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": reward_url,
"datePublished": reward_campaign.added_at.isoformat(),
"dateModified": reward_campaign.updated_at.isoformat(),
}
campaign_schema = {
"@context": "https://schema.org",
"@graph": [
campaign_event,
webpage_node,
],
}
# Breadcrumb schema # Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
{"name": "Home", "url": request.build_absolute_uri("/")}, {"name": "Home", "url": request.build_absolute_uri("/")},
@ -1263,16 +1419,14 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=campaign_name, page_title=campaign_name,
page_description=campaign_description, page_description=campaign_description,
seo_meta={ schema_data=campaign_schema,
"schema_data": campaign_schema, breadcrumb_schema=breadcrumb_schema,
"breadcrumb_schema": breadcrumb_schema, modified_date=reward_campaign.updated_at.isoformat(),
"published_date": reward_campaign.added_at.isoformat(),
"modified_date": reward_campaign.updated_at.isoformat(),
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"reward_campaign": reward_campaign, "reward_campaign": reward_campaign,
"now": now, "now": now,
"campaign_data": format_and_color_json(campaign_data[0]),
"is_active": reward_campaign.is_active, "is_active": reward_campaign.is_active,
**seo_context, **seo_context,
} }
@ -1352,11 +1506,8 @@ class ChannelListView(ListView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Channels", page_title="Twitch Channels",
page_description="List of Twitch channels participating in drop campaigns.", page_description="List of Twitch channels participating in drop campaigns.",
seo_meta={ pagination_info=pagination_info,
"page_url": self.request.build_absolute_uri(base_url), schema_data=collection_schema,
"pagination_info": pagination_info,
"schema_data": collection_schema,
},
) )
context.update(seo_context) context.update(seo_context)
context["search_query"] = search_query context["search_query"] = search_query
@ -1464,36 +1615,50 @@ class ChannelDetailView(DetailView):
if campaign.end_at is not None and campaign.end_at < now if campaign.end_at is not None and campaign.end_at < now
] ]
serialized_channel: str = serialize(
"json",
[channel],
fields=("twitch_id", "name", "display_name", "added_at", "updated_at"),
)
channel_data: list[dict[str, Any]] = json.loads(serialized_channel)
if campaigns_list:
serialized_campaigns: str = serialize(
"json",
campaigns_list,
fields=(
"twitch_id",
"name",
"description",
"details_url",
"account_link_url",
"image_url",
"start_at",
"end_at",
"added_at",
"updated_at",
),
)
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
channel_data[0]["fields"]["campaigns"] = campaigns_data
name: str = channel.display_name or channel.name or channel.twitch_id name: str = channel.display_name or channel.name or channel.twitch_id
total_campaigns: int = len(campaigns_list) total_campaigns: int = len(campaigns_list)
description: str = f"{name} participates in {total_campaigns} drop campaign" description: str = f"{name} participates in {total_campaigns} drop campaign"
if total_campaigns > 1: if total_campaigns > 1:
description += "s" description += "s"
channel_url: str = self.request.build_absolute_uri( channel_schema: dict[str, Any] = {
reverse("twitch:channel_detail", args=[channel.twitch_id]), "@context": "https://schema.org",
)
channel_node: dict[str, Any] = {
"@type": "BroadcastChannel", "@type": "BroadcastChannel",
"name": name, "name": name,
"description": description, "description": description,
"url": channel_url, "url": self.request.build_absolute_uri(
reverse("twitch:channel_detail", args=[channel.twitch_id]),
),
"broadcastChannelId": channel.twitch_id, "broadcastChannelId": channel.twitch_id,
"providerName": "Twitch", "providerName": "Twitch",
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": channel_url,
"datePublished": channel.added_at.isoformat(),
"dateModified": channel.updated_at.isoformat(),
}
channel_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
channel_node,
webpage_node,
],
}
# Breadcrumb schema # Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
@ -1510,22 +1675,18 @@ class ChannelDetailView(DetailView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=name, page_title=name,
page_description=description, page_description=description,
seo_meta={ schema_data=channel_schema,
"schema_data": channel_schema, breadcrumb_schema=breadcrumb_schema,
"breadcrumb_schema": breadcrumb_schema, modified_date=channel.updated_at.isoformat()
"published_date": channel.added_at.isoformat()
if channel.added_at
else None,
"modified_date": channel.updated_at.isoformat()
if channel.updated_at if channel.updated_at
else None, else None,
},
) )
context.update({ context.update({
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns, "upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns, "expired_campaigns": expired_campaigns,
"now": now, "now": now,
"channel_data": format_and_color_json(channel_data[0]),
**seo_context, **seo_context,
}) })
@ -1572,7 +1733,7 @@ def badge_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Chat Badges", page_title="Twitch Chat Badges",
page_description="List of Twitch chat badges awarded through drop campaigns.", page_description="List of Twitch chat badges awarded through drop campaigns.",
seo_meta={"schema_data": collection_schema}, schema_data=collection_schema,
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"badge_sets": badge_sets, "badge_sets": badge_sets,
@ -1642,6 +1803,34 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
).distinct() ).distinct()
badge.award_campaigns = list(campaigns) # pyright: ignore[reportAttributeAccessIssue] badge.award_campaigns = list(campaigns) # pyright: ignore[reportAttributeAccessIssue]
# Serialize for JSON display
serialized_set: str = serialize(
"json",
[badge_set],
fields=("set_id", "added_at", "updated_at"),
)
set_data: list[dict[str, Any]] = json.loads(serialized_set)
if badges:
serialized_badges: str = serialize(
"json",
badges,
fields=(
"badge_id",
"image_url_1x",
"image_url_2x",
"image_url_4x",
"title",
"description",
"click_action",
"click_url",
"added_at",
"updated_at",
),
)
badges_data: list[dict[str, Any]] = json.loads(serialized_badges)
set_data[0]["fields"]["badges"] = badges_data
badge_set_name: str = badge_set.set_id badge_set_name: str = badge_set.set_id
badge_set_description: str = f"Twitch chat badge set {badge_set_name} with {len(badges)} badge{'s' if len(badges) != 1 else ''} awarded through drop campaigns." badge_set_description: str = f"Twitch chat badge set {badge_set_name} with {len(badges)} badge{'s' if len(badges) != 1 else ''} awarded through drop campaigns."
@ -1658,11 +1847,12 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=f"Badge Set: {badge_set_name}", page_title=f"Badge Set: {badge_set_name}",
page_description=badge_set_description, page_description=badge_set_description,
seo_meta={"schema_data": badge_schema}, schema_data=badge_schema,
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"badge_set": badge_set, "badge_set": badge_set,
"badges": badges, "badges": badges,
"set_data": format_and_color_json(set_data[0]),
**seo_context, **seo_context,
} }