diff --git a/core/seo.py b/core/seo.py deleted file mode 100644 index abd2183..0000000 --- a/core/seo.py +++ /dev/null @@ -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 diff --git a/core/urls.py b/core/urls.py index 51bcf42..6966557 100644 --- a/core/urls.py +++ b/core/urls.py @@ -2,27 +2,9 @@ from typing import TYPE_CHECKING from django.urls import path -from core.views import dashboard -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 core import views 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 if TYPE_CHECKING: from django.urls.resolvers import URLPattern @@ -33,21 +15,21 @@ app_name = "core" urlpatterns: list[URLPattern | URLResolver] = [ # / - path("", dashboard, name="dashboard"), + path("", views.dashboard, name="dashboard"), # /search/ - path("search/", search_view, name="search"), + path("search/", views.search_view, name="search"), # /debug/ - path("debug/", debug_view, name="debug"), + path("debug/", views.debug_view, name="debug"), # /datasets/ - path("datasets/", dataset_backups_view, name="dataset_backups"), + path("datasets/", views.dataset_backups_view, name="dataset_backups"), # /datasets/download// path( "datasets/download//", - dataset_backup_download_view, + views.dataset_backup_download_view, name="dataset_backup_download", ), # /docs/rss/ - path("docs/rss/", docs_rss_view, name="docs_rss"), + path("docs/rss/", views.docs_rss_view, name="docs_rss"), # RSS feeds # /rss/campaigns/ - all active campaigns path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), @@ -56,59 +38,59 @@ urlpatterns: list[URLPattern | URLResolver] = [ # /rss/games//campaigns/ - active campaigns for a specific game path( "rss/games//campaigns/", - GameCampaignFeed(), + views.GameCampaignFeed(), name="game_campaign_feed", ), # /rss/organizations/ - newly added organizations path( "rss/organizations/", - OrganizationRSSFeed(), + views.OrganizationRSSFeed(), name="organization_feed", ), # /rss/reward-campaigns/ - all active reward campaigns path( "rss/reward-campaigns/", - RewardCampaignFeed(), + views.RewardCampaignFeed(), name="reward_campaign_feed", ), # Atom feeds (added alongside RSS to preserve backward compatibility) - path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"), - path("atom/games/", GameAtomFeed(), name="game_feed_atom"), + path("atom/campaigns/", views.DropCampaignAtomFeed(), name="campaign_feed_atom"), + path("atom/games/", views.GameAtomFeed(), name="game_feed_atom"), path( "atom/games//campaigns/", - view=GameCampaignAtomFeed(), + views.GameCampaignAtomFeed(), name="game_campaign_feed_atom", ), path( "atom/organizations/", - OrganizationAtomFeed(), + views.OrganizationAtomFeed(), name="organization_feed_atom", ), path( "atom/reward-campaigns/", - RewardCampaignAtomFeed(), + views.RewardCampaignAtomFeed(), name="reward_campaign_feed_atom", ), # Discord feeds (Atom feeds with Discord relative timestamps) path( "discord/campaigns/", - DropCampaignDiscordFeed(), + views.DropCampaignDiscordFeed(), name="campaign_feed_discord", ), - path("discord/games/", GameDiscordFeed(), name="game_feed_discord"), + path("discord/games/", views.GameDiscordFeed(), name="game_feed_discord"), path( "discord/games//campaigns/", - GameCampaignDiscordFeed(), + views.GameCampaignDiscordFeed(), name="game_campaign_feed_discord", ), path( "discord/organizations/", - OrganizationDiscordFeed(), + views.OrganizationDiscordFeed(), name="organization_feed_discord", ), path( "discord/reward-campaigns/", - RewardCampaignDiscordFeed(), + views.RewardCampaignDiscordFeed(), name="reward_campaign_feed_discord", ), ] diff --git a/core/views.py b/core/views.py index 2b97f8f..601ce11 100644 --- a/core/views.py +++ b/core/views.py @@ -3,6 +3,7 @@ import json import logging import operator from collections import OrderedDict +from copy import copy from typing import TYPE_CHECKING from typing import Any @@ -26,6 +27,21 @@ from django.utils import timezone from kick.models import KickChannel 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 ChatBadge from twitch.models import ChatBadgeSet @@ -37,11 +53,13 @@ from twitch.models import RewardCampaign from twitch.models import TimeBasedDrop if TYPE_CHECKING: + from collections.abc import Callable from os import stat_result from pathlib import Path from django.db.models import QuerySet from django.http import HttpRequest + from django.http.request import QueryDict 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 page_title: str = "ttvdrops", page_description: str | None = None, - page_url: str | None = None, page_image: str | None = None, page_image_width: int | None = None, page_image_height: int | None = None, @@ -72,7 +89,6 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 Args: page_title: Page title (shown in browser tab, og:title). 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_width: Width 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, "robots_directive": robots_directive, } - if page_url: - context["page_url"] = page_url if page_image: context["page_image"] = page_image if page_image_width and page_image_height: @@ -261,33 +275,177 @@ def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915 # MARK: /docs/rss/ 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: request: The HTTP request object. Returns: - HttpResponse: The rendered documentation page. + Rendered HTML response with list of RSS feeds. """ - now: datetime.datetime = timezone.now() - sample_game: Game | None = ( - Game.objects - .filter(drop_campaigns__start_at__lte=now, drop_campaigns__end_at__gte=now) - .distinct() - .first() - ) + + def absolute(path: str) -> str: + try: + return request.build_absolute_uri(path) + except Exception: + 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("", 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//campaigns/") + ), + "atom_url": ( + absolute( + reverse( + "core:game_campaign_feed_atom", + args=[sample_game.twitch_id], + ), + ) + if sample_game + else absolute("/atom/games//campaigns/") + ), + "discord_url": ( + absolute( + reverse( + "core:game_campaign_feed_discord", + args=[sample_game.twitch_id], + ), + ) + if sample_game + else absolute("/discord/games//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( - page_title="Feed Documentation", - page_description="Documentation for the RSS feeds available on ttvdrops.lovinator.space, including how to use them and what data they contain.", - page_url=request.build_absolute_uri(reverse("core:docs_rss")), + page_title="Twitch RSS Feeds", + page_description="RSS feeds for Twitch drops.", ) - return render( 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, }, ) @@ -432,7 +590,7 @@ def debug_view(request: HttpRequest) -> HttpResponse: ) context.update(seo_context) - return render(request, "core/debug.html", context) + return render(request, "twitch/debug.html", context) # MARK: /datasets/ @@ -543,7 +701,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse: "dataset_count": len(datasets), **seo_context, } - return render(request, "core/dataset_backups.html", context) + return render(request, "twitch/dataset_backups.html", context) def dataset_backup_download_view( @@ -678,11 +836,10 @@ def search_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title=page_title, page_description=page_description, - page_url=request.build_absolute_uri(reverse("core:search")), ) return render( request, - "core/search_results.html", + "twitch/search_results.html", {"query": query, "results": results, **seo_context}, ) @@ -794,9 +951,7 @@ def dashboard(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch/Kick Drops", - page_description=( - "RSS feeds, historical data, and information about Twitch and Kick drops, campaigns, rewards, and more." - ), + page_description=("Twitch and Kick drops."), og_type="website", schema_data=website_schema, ) diff --git a/kick/views.py b/kick/views.py index f87fb08..d803524 100644 --- a/kick/views.py +++ b/kick/views.py @@ -1,9 +1,6 @@ -from __future__ import annotations - import json import logging from typing import TYPE_CHECKING -from typing import Any from typing import Literal from django.core.paginator import EmptyPage @@ -27,7 +24,6 @@ if TYPE_CHECKING: from django.http import HttpRequest from django.http import HttpResponse - from core.seo import SeoMeta from kick.models import KickChannel from kick.models import KickReward @@ -37,38 +33,26 @@ logger: logging.Logger = logging.getLogger("ttvdrops.kick.views") def _build_seo_context( page_title: str = "Kick Drops", page_description: str | None = None, - seo_meta: SeoMeta | None = None, -) -> dict[str, Any]: - """Build SEO context for template rendering. + og_type: str = "website", + robots_directive: str = "index, follow", +) -> dict[str, str]: + """Build minimal SEO context for template rendering. Args: page_title: The title of the page for 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: A dictionary with SEO-related context variables. """ - context: dict[str, Any] = { + return { "page_title": page_title, "page_description": page_description or "Archive of Kick drops.", - "og_type": "website", - "robots_directive": "index, follow", + "og_type": og_type, + "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: @@ -227,7 +211,6 @@ def campaign_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, str] = _build_seo_context( page_title=title, page_description="Browse Kick drop campaigns.", - seo_meta={"page_url": request.build_absolute_uri(base_url)}, ) return render( request, @@ -295,51 +278,9 @@ def campaign_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse: }, ]) - campaign_url: str = request.build_absolute_uri( - 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( + seo_context: dict[str, str] = _build_seo_context( page_title=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( request, @@ -438,27 +379,9 @@ def category_detail_view(request: HttpRequest, kick_id: int) -> HttpResponse: }, ]) - category_url: str = request.build_absolute_uri( - 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( + seo_context: dict[str, str] = _build_seo_context( page_title=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( request, @@ -543,37 +466,9 @@ def organization_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse }, ]) - org_url: str = request.build_absolute_uri( - 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( + seo_context: dict[str, str] = _build_seo_context( page_title=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( request, diff --git a/templates/core/dashboard.html b/templates/core/dashboard.html index 44155b8..d6b1bdf 100644 --- a/templates/core/dashboard.html +++ b/templates/core/dashboard.html @@ -48,7 +48,6 @@ title="Atom feed for Twitch campaigns">[atom]</a> <a href="{% url 'core:campaign_feed_discord' %}" title="Discord feed for Twitch campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> </header> {% if campaigns_by_game %} @@ -224,7 +223,6 @@ title="Atom feed for all Kick campaigns">[atom]</a> <a href="{% url 'kick:campaign_feed_discord' %}" title="Discord feed for all Kick campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> </header> {% if kick_campaigns_by_game %} diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html deleted file mode 100644 index 41011a1..0000000 --- a/templates/core/docs_rss.html +++ /dev/null @@ -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><t:1773450272:R></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 %} diff --git a/templates/includes/meta_tags.html b/templates/includes/meta_tags.html index a8e1797..32a0b5f 100644 --- a/templates/includes/meta_tags.html +++ b/templates/includes/meta_tags.html @@ -16,6 +16,9 @@ {# - robots_directive: str - robots meta content (default: "index, follow") #} {# #} {% 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 #} <meta name="description" content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" /> @@ -23,7 +26,7 @@ <meta name="robots" content="{% firstof robots_directive 'index, follow' %}" /> {# Author and Copyright #} -<meta name="author" content="TheLovinator" /> +<meta name="author" content="TheLovinator1" /> <meta name="copyright" content="This work is dedicated to the public domain under CC0 1.0 Universal." /> {# Open Graph tags for social sharing #} @@ -42,14 +45,10 @@ {% endif %} {# Twitter Card tags for rich previews #} <meta name="twitter:card" - content="{% if page_image %} - summary_large_image - {% else %} - summary - {% endif %}" /> + content="{% if page_image %}summary_large_image{% else %}summary{% endif %}" /> <meta name="twitter:title" content="{% firstof page_title 'ttvdrops' %}" /> <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 %} {# Article dates for content pages #} {% 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 %} {% endif %} {# Schema.org JSON-LD structured data #} -{% if schema_data %} - <script type="application/ld+json"> - { - { - schema_data | safe - } - } - </script> -{% endif %} +{% if schema_data %}<script type="application/ld+json">{{ schema_data|safe }}</script>{% endif %} {# Breadcrumb schema #} -{% if breadcrumb_schema %} - <script type="application/ld+json"> - { - { - breadcrumb_schema | safe - } - } - </script> -{% endif %} +{% if breadcrumb_schema %}<script type="application/ld+json">{{ breadcrumb_schema|safe }}</script>{% endif %} diff --git a/templates/kick/campaign_detail.html b/templates/kick/campaign_detail.html index 376d25f..30b03dc 100644 --- a/templates/kick/campaign_detail.html +++ b/templates/kick/campaign_detail.html @@ -59,7 +59,6 @@ title="Atom feed for {{ campaign.category.name }} campaigns">[atom]</a> <a href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}" title="Discord feed for {{ campaign.category.name }} campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% endif %} <p style="margin: 0.25rem 0; color: #666;"> diff --git a/templates/kick/campaign_list.html b/templates/kick/campaign_list.html index 8b58cc8..7e6d149 100644 --- a/templates/kick/campaign_list.html +++ b/templates/kick/campaign_list.html @@ -26,7 +26,6 @@ title="Atom feed for all campaigns">[atom]</a> <a href="{% url 'kick:campaign_feed_discord' %}" title="Discord feed for all campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <form method="get" action="{% url 'kick:campaign_list' %}"> <div style="display: flex; diff --git a/templates/kick/category_detail.html b/templates/kick/category_detail.html index 113479b..ddcb7b6 100644 --- a/templates/kick/category_detail.html +++ b/templates/kick/category_detail.html @@ -56,7 +56,6 @@ title="Atom feed for {{ category.name }} campaigns">[atom]</a> <a href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}" title="Discord feed for {{ category.name }} campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% if category.kick_url %} <p style="margin: 0.25rem 0;"> diff --git a/templates/kick/category_list.html b/templates/kick/category_list.html index b09c3bf..9efd2a8 100644 --- a/templates/kick/category_list.html +++ b/templates/kick/category_list.html @@ -25,7 +25,6 @@ title="Atom feed for all games">[atom]</a> <a href="{% url 'kick:game_feed_discord' %}" title="Discord feed for all games">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% if categories %} <ul> diff --git a/templates/kick/dashboard.html b/templates/kick/dashboard.html index 886a75c..0f568d0 100644 --- a/templates/kick/dashboard.html +++ b/templates/kick/dashboard.html @@ -27,7 +27,6 @@ title="Atom feed for all campaigns">[atom]</a> <a href="{% url 'kick:campaign_feed_discord' %}" title="Discord feed for all campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <hr /> {% if active_campaigns %} diff --git a/templates/kick/org_list.html b/templates/kick/org_list.html index 9e92e52..c3aca0b 100644 --- a/templates/kick/org_list.html +++ b/templates/kick/org_list.html @@ -26,7 +26,6 @@ title="Atom feed for all organizations">[atom]</a> <a href="{% url 'kick:organization_feed_discord' %}" title="Discord feed for all organizations">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% if orgs %} <ul> diff --git a/templates/twitch/badge_list.html b/templates/twitch/badge_list.html index 0a9e41c..49e4016 100644 --- a/templates/twitch/badge_list.html +++ b/templates/twitch/badge_list.html @@ -7,11 +7,13 @@ <h1>{{ badge_sets.count }} Twitch Chat Badges</h1> {% if badge_sets %} {% for data in badge_data %} + <!-- {{ data.set.set_id }} - {{ data.badges|length }} version{% if data.badges|length > 1 %}s{% endif %} --> <h2> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a> </h2> <table> {% for badge in data.badges %} + <!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} --> <tr> <td style="width: 40px;"> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}"> diff --git a/templates/twitch/badge_set_detail.html b/templates/twitch/badge_set_detail.html index c14bc1d..3d203e6 100644 --- a/templates/twitch/badge_set_detail.html +++ b/templates/twitch/badge_set_detail.html @@ -9,6 +9,7 @@ <table> <tbody> {% for badge in badges %} + <!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} --> <tr> <td> <code>{{ badge.badge_id }}</code> @@ -47,4 +48,5 @@ {% else %} <p>No badges found in this set.</p> {% endif %} + {{ set_data|safe }} {% endblock content %} diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 6eda59b..61cfccc 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -45,14 +45,6 @@ </div> <!-- Campaign description --> <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 --> <div> {% if campaign.end_at < now %} @@ -104,7 +96,6 @@ title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a> <a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}" title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> {% endif %} </div> </div> @@ -115,6 +106,7 @@ <table style="border-collapse: collapse; width: 100%;"> <tbody> {% for drop in drops %} + <!-- {{ drop.drop.name }} - {{ drop.drop.benefits.all|join:", " }} --> <tr> <td> {% for benefit in drop.drop.benefits.all %} @@ -177,6 +169,7 @@ <h5>Allowed Channels</h5> <div> {% 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> {% endfor %} </div> @@ -188,4 +181,6 @@ Go to a participating live channel </a> {% endif %} + <!-- Campaign JSON --> + {{ campaign_data|safe }} {% endblock content %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 9e17073..9e904c0 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% load static %} {% load image_tags %} +{% load image_tags %} {% block title %} Drop Campaigns {% endblock title %} @@ -30,7 +31,6 @@ title="Atom feed for all campaigns">[atom]</a> <a href="{% url 'core:campaign_feed_discord' %}" 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 %}" title="Export campaigns as CSV">[csv]</a> <a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" @@ -47,6 +47,7 @@ <select name="game"> <option value="">All Games</option> {% for game in games %} + <!-- Game option with Twitch ID {{ game.twitch_id }} and display name "{{ game.display_name }}" --> <option value="{{ game.twitch_id }}" {% if selected_game == game.twitch_id %}selected{% endif %}> {{ game.display_name|default:game.name|default:game.slug|default:game.twitch_id }} @@ -57,6 +58,7 @@ <select id="status" name="status"> <option value="">All Statuses</option> {% for status in status_options %} + <!-- Status option "{{ status }}" --> <option value="{{ status }}" {% if selected_status == status %}selected{% endif %}>{{ status|title }}</option> {% endfor %} @@ -69,6 +71,7 @@ {% if campaigns %} {% regroup campaigns by game as 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> <div style="display: flex; gap: 1rem;"> <div> @@ -105,6 +108,9 @@ <div style="overflow-x: auto;"> <div style="display: flex; gap: 1rem; min-width: max-content;"> {% 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; flex-direction: column; align-items: flex-start; @@ -159,6 +165,7 @@ {% endif %} <!-- Pagination --> {% 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;"> <div> {% if page_obj.has_previous %} diff --git a/templates/twitch/channel_detail.html b/templates/twitch/channel_detail.html index 49a7487..be5d02a 100644 --- a/templates/twitch/channel_detail.html +++ b/templates/twitch/channel_detail.html @@ -20,26 +20,22 @@ </iframe> <!-- Channel Info --> <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 %} <h5>Active Campaigns</h5> <table> <tbody> {% for campaign in active_campaigns %} + <!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) --> <tr> <td> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> {% if campaign.time_based_drops.all %} <!-- If the campaign has time-based drops, show the benefits in a nested div --> <div> + <!-- swag swag swag {{campaign.sorted_benefits}} --> {% for benefit in campaign.sorted_benefits %} + <!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) --> + <!-- {{ benefit.image_best_url }} --> <span title="{{ benefit.name }}"> {% if benefit.image_best_url or benefit.image_asset_url %} <!-- Show the benefit image if available --> @@ -81,6 +77,7 @@ <table> <tbody> {% for campaign in upcoming_campaigns %} + <!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) --> <tr> <td> <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 --> <div> {% for benefit in campaign.sorted_benefits %} + <!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) --> <span title="{{ benefit.name }}"> {% if benefit.image_best_url or benefit.image_asset_url %} <!-- Show the benefit image if available --> @@ -129,6 +127,7 @@ <table> <tbody> {% for campaign in expired_campaigns %} + <!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) --> <tr> <td> <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 --> <div> {% for benefit in campaign.sorted_benefits %} + <!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) --> <span title="{{ benefit.name }}"> {% if benefit.image_best_url or 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 %} <p>No campaigns found for this channel.</p> {% endif %} + {{ channel_data|safe }} {% endblock content %} diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index 590950f..134e050 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -39,7 +39,6 @@ title="Atom feed for campaigns">[atom]</a> <a href="{% url 'core:campaign_feed_discord' %}" title="Discord feed for campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <hr /> {% if campaigns_by_game %} @@ -64,6 +63,7 @@ <div style="flex: 1; overflow-x: auto;"> <div style="display: flex; gap: 1rem; min-width: max-content;"> {% for campaign_data in game_data.campaigns %} + <!-- {{ campaign_data.campaign.name }} --> <article style="display: flex; flex-direction: column; align-items: center; @@ -106,6 +106,7 @@ {% if campaign_data.campaign.allow_is_enabled %} {% if campaign_data.allowed_channels %} {% for channel in campaign_data.allowed_channels|slice:":5" %} + <!-- {{ channel.name }} --> <li style="margin-bottom: 0.1rem;"> <a href="https://twitch.tv/{{ channel.name }}" rel="nofollow ugc" @@ -131,12 +132,14 @@ {% endif %} {% endif %} {% 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;"> ... and {{ campaign_data.allowed_channels|length|add:"-5" }} more </li> {% endif %} {% else %} {% 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> <a href="{{ campaign_data.campaign.game.twitch_directory_url }}" rel="nofollow ugc" @@ -145,6 +148,7 @@ </a> </li> {% else %} + <!-- {{ campaign_data.campaign.game.display_name }} Twitch directory URL not available --> <li>Failed to get Twitch directory URL :(</li> {% endif %} {% endif %} diff --git a/templates/core/dataset_backups.html b/templates/twitch/dataset_backups.html similarity index 100% rename from templates/core/dataset_backups.html rename to templates/twitch/dataset_backups.html diff --git a/templates/core/debug.html b/templates/twitch/debug.html similarity index 100% rename from templates/core/debug.html rename to templates/twitch/debug.html diff --git a/templates/twitch/docs_rss.html b/templates/twitch/docs_rss.html new file mode 100644 index 0000000..b43f9b1 --- /dev/null +++ b/templates/twitch/docs_rss.html @@ -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><t:1773450272:R></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 %} +  |  + <a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a> + {% endif %} + {% if feed.discord_url %} +  |  + <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 %} |  Atom: <code>{{ feed.atom_url }}</code>{% endif %} + {% if feed.discord_url %} |  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 %} +  |  + <a href="{{ feed.atom_url }}">View Atom example</a> + {% endif %} + {% if feed.discord_url %} +  |  + <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 %} diff --git a/templates/twitch/emote_gallery.html b/templates/twitch/emote_gallery.html index 83178a1..b42b6d6 100644 --- a/templates/twitch/emote_gallery.html +++ b/templates/twitch/emote_gallery.html @@ -6,6 +6,8 @@ {% block content %} <h1>Emotes</h1> {% 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 %}" title="{{ emote.campaign.name }}" style="display: inline-block"> diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index 940b883..6e68f7e 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -47,14 +47,6 @@ Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a> </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 --> <div> <a href="{% url 'core:game_campaign_feed' game.twitch_id %}" @@ -63,7 +55,6 @@ title="Atom feed for {{ game.display_name }} campaigns">[atom]</a> <a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}" 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> @@ -159,4 +150,5 @@ {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} <p id="no-campaigns-message">No campaigns found for this game.</p> {% endif %} + {{ game_data|safe }} {% endblock content %} diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index a02b843..cc9fa22 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -28,7 +28,6 @@ title="Atom feed for all games">[atom]</a> <a href="{% url 'core:game_feed_discord' %}" 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' %}" title="Export all games as CSV">[csv]</a> <a href="{% url 'twitch:export_games_json' %}" diff --git a/templates/twitch/games_list.html b/templates/twitch/games_list.html index a2ac080..c2c1406 100644 --- a/templates/twitch/games_list.html +++ b/templates/twitch/games_list.html @@ -26,7 +26,6 @@ title="Atom feed for all games">[atom]</a> <a href="{% url 'core:game_feed_discord' %}" 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' %}" title="Export all games as CSV">[csv]</a> <a href="{% url 'twitch:export_games_json' %}" diff --git a/templates/twitch/org_list.html b/templates/twitch/org_list.html index 7b2b465..838acae 100644 --- a/templates/twitch/org_list.html +++ b/templates/twitch/org_list.html @@ -11,7 +11,6 @@ title="Atom feed for all organizations">[atom]</a> <a href="{% url 'core:organization_feed_discord' %}" 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' %}" title="Export all organizations as CSV">[csv]</a> <a href="{% url 'twitch:export_organizations_json' %}" @@ -28,4 +27,5 @@ {% else %} <p>No organizations found.</p> {% endif %} + {{ orgs_data|safe }} {% endblock content %} diff --git a/templates/twitch/organization_detail.html b/templates/twitch/organization_detail.html index f460edc..25326b0 100644 --- a/templates/twitch/organization_detail.html +++ b/templates/twitch/organization_detail.html @@ -21,22 +21,14 @@ {% endif %} {% endblock extra_head %} {% block content %} - <h1>{{ organization.name }}</h1> - <p> - Published: - <time datetime="{{ organization.added_at|date:'c' }}" - title="{{ organization.added_at|date:'DATETIME_FORMAT' }}">{{ organization.added_at|date:"M d, Y H:i" }}</time> - · 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> + <h1 id="org-name">{{ organization.name }}</h1> + <theader> + <h2 id="games-header">Games by {{ organization.name }}</h2> + </theader> + <table id="games-table"> <tbody> {% for game in games %} - <tr> + <tr id="game-row-{{ game.twitch_id }}"> <td> <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a> </td> @@ -44,4 +36,6 @@ {% endfor %} </tbody> </table> + <hr /> + {{ org_data|safe }} {% endblock content %} diff --git a/templates/twitch/reward_campaign_detail.html b/templates/twitch/reward_campaign_detail.html index f5c937c..234c6b5 100644 --- a/templates/twitch/reward_campaign_detail.html +++ b/templates/twitch/reward_campaign_detail.html @@ -37,12 +37,11 @@ <div style="margin-bottom: 1rem;"> <a href="{% url 'core:reward_campaign_feed' %}" 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' %}" title="Atom feed for all reward campaigns">[atom]</a> <a href="{% url 'core:reward_campaign_feed_discord' %}" title="Discord feed for all reward campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <!-- Campaign Summary --> {% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %} @@ -165,4 +164,6 @@ </tr> </tbody> </table> + <hr /> + {{ campaign_data|safe }} {% endblock content %} diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html index 1704dd2..97affdf 100644 --- a/templates/twitch/reward_campaign_list.html +++ b/templates/twitch/reward_campaign_list.html @@ -27,7 +27,6 @@ title="Atom feed for all reward campaigns">[atom]</a> <a href="{% url 'core:reward_campaign_feed_discord' %}" title="Discord feed for all reward campaigns">[discord]</a> - <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p> <p> diff --git a/templates/core/search_results.html b/templates/twitch/search_results.html similarity index 100% rename from templates/core/search_results.html rename to templates/twitch/search_results.html diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 5a9bec4..a125cd8 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -958,7 +958,7 @@ class TestChannelListView: client: Client, monkeypatch: pytest.MonkeyPatch, ) -> 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( twitch_id="org-game-detail", name="Org Game Detail", @@ -970,10 +970,17 @@ class TestChannelListView: ) 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]) response: _MonkeyPatchedWSGIResponse = client.get(url) 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 def test_org_list_view(self, client: Client) -> None: @@ -1006,29 +1013,14 @@ class TestChannelListView: @pytest.mark.django_db 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")) assert response.status_code == 200 - - # Add Game with running campaign to ensure it's included in the RSS feed - game: Game = Game.objects.create( - twitch_id="g-rss", - name="Game RSS", - 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") + assert "feeds" in response.context + assert "filtered_feeds" in response.context + assert response.context["feeds"][0]["example_xml"] + html: str = response.content.decode() + assert '<code class="language-xml">' in html @pytest.mark.django_db @@ -1059,11 +1051,9 @@ class TestSEOHelperFunctions: context: dict[str, Any] = _build_seo_context( page_title="Test Title", page_description="Test Description", - seo_meta={ - "page_image": "https://example.com/image.jpg", - "og_type": "article", - "schema_data": {"@context": "https://schema.org"}, - }, + page_image="https://example.com/image.jpg", + og_type="article", + schema_data={"@context": "https://schema.org"}, ) assert context["page_title"] == "Test Title" @@ -1093,16 +1083,14 @@ class TestSEOHelperFunctions: context: dict[str, Any] = _build_seo_context( page_title="Test", page_description="Desc", - seo_meta={ - "page_image": "https://example.com/img.jpg", - "og_type": "article", - "schema_data": {}, - "breadcrumb_schema": breadcrumb, - "pagination_info": [{"rel": "next", "url": "/page/2/"}], - "published_date": now.isoformat(), - "modified_date": now.isoformat(), - "robots_directive": "noindex, follow", - }, + page_image="https://example.com/img.jpg", + og_type="article", + schema_data={}, + breadcrumb_schema=breadcrumb, + pagination_info=[{"rel": "next", "url": "/page/2/"}], + published_date=now.isoformat(), + modified_date=now.isoformat(), + robots_directive="noindex, follow", ) # breadcrumb_schema is JSON-dumped, so parse it back @@ -1711,6 +1699,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) assert response.status_code == 200 @@ -1731,6 +1720,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1745,6 +1735,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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( twitch_id="game-no-art", name="no_art_game", @@ -1765,6 +1756,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1779,6 +1771,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1791,6 +1784,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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( twitch_id="org-nameless", name="", @@ -1818,6 +1812,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) assert response.status_code == 200 @@ -1838,6 +1833,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1853,6 +1849,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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( twitch_id="camp-no-img", name="No Image Campaign", @@ -1874,6 +1871,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1887,6 +1885,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1899,6 +1898,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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( twitch_id="game-no-owner", name="no_owner_game", @@ -1926,6 +1926,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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_id="twitch-gaming", name="Twitch Gaming", @@ -1955,6 +1956,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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_id="twitch-gaming-solo", name="Twitch Gaming", @@ -1979,6 +1981,7 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """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_id="twitch-gaming-camp", name="Twitch Gaming", diff --git a/twitch/views.py b/twitch/views.py index 78ccc9a..4c5420f 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import csv import datetime import json @@ -14,6 +12,7 @@ from django.core.paginator import EmptyPage from django.core.paginator import Page from django.core.paginator import PageNotAnInteger from django.core.paginator import Paginator +from django.core.serializers import serialize from django.db.models import Case from django.db.models import Count from django.db.models import Prefetch @@ -27,6 +26,9 @@ from django.urls import reverse from django.utils import timezone from django.views.generic import DetailView 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 ChatBadge @@ -42,8 +44,6 @@ if TYPE_CHECKING: from django.db.models import QuerySet from django.http import HttpRequest - from core.seo import SeoMeta - logger: logging.Logger = logging.getLogger("ttvdrops.views") 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] + "…" -def _build_seo_context( +def _build_seo_context( # noqa: PLR0913, PLR0917 page_title: str = "ttvdrops", 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]: """Build SEO context for template rendering. Args: page_title: Page title (shown in browser tab, og:title). page_description: Page description (meta description, og:description). - seo_meta: Optional typed SEO metadata with image, schema, breadcrumb, - pagination, OpenGraph, and date fields. + page_image: Image URL for og:image meta tag. + 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: 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] = { "page_title": page_title, "page_description": page_description or DEFAULT_SITE_DESCRIPTION, - "og_type": "website", - "robots_directive": "index, follow", + "og_type": og_type, + "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("page_image"): - context["page_image"] = seo_meta["page_image"] - if seo_meta.get("page_image_width") and seo_meta.get("page_image_height"): - context["page_image_width"] = seo_meta["page_image_width"] - context["page_image_height"] = seo_meta["page_image_height"] - if seo_meta.get("schema_data"): - context["schema_data"] = json.dumps(seo_meta["schema_data"]) - if seo_meta.get("breadcrumb_schema"): - 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"] + if page_image: + context["page_image"] = page_image + if page_image_width and page_image_height: + context["page_image_width"] = page_image_width + context["page_image_height"] = page_image_height + if schema_data: + context["schema_data"] = json.dumps(schema_data) + if breadcrumb_schema: + context["breadcrumb_schema"] = json.dumps(breadcrumb_schema) + if pagination_info: + context["pagination_info"] = pagination_info + if published_date: + context["published_date"] = published_date + if modified_date: + context["modified_date"] = modified_date return context @@ -260,6 +276,14 @@ def org_list_view(request: HttpRequest) -> HttpResponse: """ 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 collection_schema: dict[str, str] = { "@context": "https://schema.org", @@ -272,10 +296,11 @@ def org_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Organizations", page_description="List of Twitch organizations.", - seo_meta={"schema_data": collection_schema}, + schema_data=collection_schema, ) context: dict[str, Any] = { "orgs": orgs, + "orgs_data": format_and_color_json(orgs_data), **seo_context, } @@ -283,7 +308,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse: # 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. Args: @@ -304,6 +329,30 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon 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 games_count: int = games.count() 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( 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", "name": org_name, "url": url, "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: 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( page_title=org_name, page_description=org_description, - seo_meta={ - "schema_data": org_schema, - "breadcrumb_schema": breadcrumb_schema, - "published_date": organization.added_at.isoformat(), - "modified_date": organization.updated_at.isoformat(), - }, + schema_data=org_schema, + breadcrumb_schema=breadcrumb_schema, + modified_date=organization.updated_at.isoformat(), ) context: dict[str, Any] = { "organization": organization, "games": games, + "org_data": format_and_color_json(org_data[0]), **seo_context, } @@ -446,11 +481,8 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 seo_context: dict[str, Any] = _build_seo_context( page_title=title, page_description=description, - seo_meta={ - "page_url": request.build_absolute_uri(base_url), - "pagination_info": pagination_info, - "schema_data": collection_schema, - }, + pagination_info=pagination_info, + schema_data=collection_schema, ) context: dict[str, Any] = { "campaigns": campaigns, @@ -467,6 +499,22 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 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( drops: QuerySet[TimeBasedDrop], now: datetime.datetime, @@ -510,7 +558,7 @@ def _enhance_drops_with_context( # 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. Args: @@ -544,6 +592,96 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo .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() enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now) # 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, "now": now, "drops": enhanced_drops, + "campaign_data": format_and_color_json(campaign_data[0]), "owners": list(campaign.game.owners.all()), "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 - campaign_event: dict[str, Any] = { + campaign_schema: dict[str, str | dict[str, str]] = { "@context": "https://schema.org", "@type": "Event", "name": campaign_name, @@ -599,9 +738,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo }, } if campaign.start_at: - campaign_event["startDate"] = campaign.start_at.isoformat() + campaign_schema["startDate"] = campaign.start_at.isoformat() if campaign.end_at: - campaign_event["endDate"] = campaign.end_at.isoformat() + campaign_schema["endDate"] = campaign.end_at.isoformat() campaign_owner: Organization | 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" ) if campaign_image: - campaign_event["image"] = { + campaign_schema["image"] = { "@type": "ImageObject", "contentUrl": request.build_absolute_uri(campaign_image), "creditText": campaign_owner_name, "copyrightNotice": campaign_owner_name, } if campaign_owner: - campaign_event["organizer"] = { + campaign_schema["organizer"] = { "@type": "Organization", "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 # 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( page_title=campaign_name, page_description=campaign_description, - seo_meta={ - "page_image": campaign_image, - "page_image_width": campaign_image_width, - "page_image_height": campaign_image_height, - "schema_data": campaign_schema, - "breadcrumb_schema": breadcrumb_schema, - "published_date": campaign.added_at.isoformat() - if campaign.added_at - else None, - "modified_date": campaign.updated_at.isoformat() - if campaign.updated_at - else None, - }, + page_image=campaign_image, + page_image_width=campaign_image_width, + page_image_height=campaign_image_height, + schema_data=campaign_schema, + breadcrumb_schema=breadcrumb_schema, + modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None, ) context.update(seo_context) @@ -763,7 +887,7 @@ class GamesGridView(ListView): seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Games", page_description="Twitch games that had or have Twitch drops.", - seo_meta={"schema_data": collection_schema}, + schema_data=collection_schema, ) context.update(seo_context) @@ -898,6 +1022,45 @@ class GameDetailView(DetailView): 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()) 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]), ), } - 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) owner_name: str = ( (preferred_owner.name or preferred_owner.twitch_id) @@ -955,17 +1114,12 @@ class GameDetailView(DetailView): seo_context: dict[str, Any] = _build_seo_context( page_title=game_name, page_description=game_description, - seo_meta={ - "page_image": game_image, - "page_image_width": game_image_width, - "page_image_height": game_image_height, - "schema_data": game_schema, - "breadcrumb_schema": breadcrumb_schema, - "published_date": game.added_at.isoformat() if game.added_at else None, - "modified_date": game.updated_at.isoformat() - if game.updated_at - else None, - }, + page_image=game_image, + page_image_width=game_image_width, + page_image_height=game_image_height, + schema_data=game_schema, + breadcrumb_schema=breadcrumb_schema, + modified_date=game.updated_at.isoformat() if game.updated_at else None, ) context.update({ "active_campaigns": active_campaigns, @@ -975,6 +1129,7 @@ class GameDetailView(DetailView): "owners": owners, "drop_awarded_badges": drop_awarded_badges, "now": now, + "game_data": format_and_color_json(game_data[0]), **seo_context, }) @@ -1058,10 +1213,8 @@ def dashboard(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Drops", page_description="Overview of active Twitch drop campaigns and rewards.", - seo_meta={ - "og_type": "website", - "schema_data": website_schema, - }, + og_type="website", + schema_data=website_schema, ) return render( request, @@ -1153,11 +1306,8 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title=title, page_description=description, - seo_meta={ - "page_url": request.build_absolute_uri(base_url), - "pagination_info": pagination_info, - "schema_data": collection_schema, - }, + pagination_info=pagination_info, + schema_data=collection_schema, ) context: dict[str, Any] = { "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" 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() 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}" ) - reward_url: str = request.build_absolute_uri( - reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]), - ) - - campaign_event: dict[str, Any] = { + campaign_schema: dict[str, str | dict[str, str]] = { + "@context": "https://schema.org", "@type": "Event", "name": campaign_name, "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", "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode", "location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"}, } 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: - 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(): owner = reward_campaign.game.owners.first() - campaign_event["organizer"] = { + campaign_schema["organizer"] = { "@type": "Organization", "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: dict[str, Any] = _build_breadcrumb_schema([ {"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( page_title=campaign_name, page_description=campaign_description, - seo_meta={ - "schema_data": campaign_schema, - "breadcrumb_schema": breadcrumb_schema, - "published_date": reward_campaign.added_at.isoformat(), - "modified_date": reward_campaign.updated_at.isoformat(), - }, + schema_data=campaign_schema, + breadcrumb_schema=breadcrumb_schema, + modified_date=reward_campaign.updated_at.isoformat(), ) context: dict[str, Any] = { "reward_campaign": reward_campaign, "now": now, + "campaign_data": format_and_color_json(campaign_data[0]), "is_active": reward_campaign.is_active, **seo_context, } @@ -1352,11 +1506,8 @@ class ChannelListView(ListView): seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Channels", page_description="List of Twitch channels participating in drop campaigns.", - seo_meta={ - "page_url": self.request.build_absolute_uri(base_url), - "pagination_info": pagination_info, - "schema_data": collection_schema, - }, + pagination_info=pagination_info, + schema_data=collection_schema, ) context.update(seo_context) context["search_query"] = search_query @@ -1464,36 +1615,50 @@ class ChannelDetailView(DetailView): 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 total_campaigns: int = len(campaigns_list) description: str = f"{name} participates in {total_campaigns} drop campaign" if total_campaigns > 1: description += "s" - channel_url: str = self.request.build_absolute_uri( - reverse("twitch:channel_detail", args=[channel.twitch_id]), - ) - channel_node: dict[str, Any] = { + channel_schema: dict[str, Any] = { + "@context": "https://schema.org", "@type": "BroadcastChannel", "name": name, "description": description, - "url": channel_url, + "url": self.request.build_absolute_uri( + reverse("twitch:channel_detail", args=[channel.twitch_id]), + ), "broadcastChannelId": channel.twitch_id, "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: dict[str, Any] = _build_breadcrumb_schema([ @@ -1510,22 +1675,18 @@ class ChannelDetailView(DetailView): seo_context: dict[str, Any] = _build_seo_context( page_title=name, page_description=description, - seo_meta={ - "schema_data": channel_schema, - "breadcrumb_schema": breadcrumb_schema, - "published_date": channel.added_at.isoformat() - if channel.added_at - else None, - "modified_date": channel.updated_at.isoformat() - if channel.updated_at - else None, - }, + schema_data=channel_schema, + breadcrumb_schema=breadcrumb_schema, + modified_date=channel.updated_at.isoformat() + if channel.updated_at + else None, ) context.update({ "active_campaigns": active_campaigns, "upcoming_campaigns": upcoming_campaigns, "expired_campaigns": expired_campaigns, "now": now, + "channel_data": format_and_color_json(channel_data[0]), **seo_context, }) @@ -1572,7 +1733,7 @@ def badge_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Chat Badges", 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] = { "badge_sets": badge_sets, @@ -1642,6 +1803,34 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse: ).distinct() 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_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( page_title=f"Badge Set: {badge_set_name}", page_description=badge_set_description, - seo_meta={"schema_data": badge_schema}, + schema_data=badge_schema, ) context: dict[str, Any] = { "badge_set": badge_set, "badges": badges, + "set_data": format_and_color_json(set_data[0]), **seo_context, }