Compare commits

...

9 commits

53 changed files with 1942 additions and 1036 deletions

View file

@ -21,7 +21,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- run: uv sync --all-extras --dev - run: uv sync --all-extras --dev -U
- run: uv run pytest - run: uv run pytest
- name: Deploy to Server - name: Deploy to Server
if: ${{ success() }} if: ${{ success() }}

View file

@ -114,22 +114,13 @@ uv run python manage.py backup_db --output-dir "<path>" --prefix "ttvdrops"
### How the duck does permissions work on Linux? ### How the duck does permissions work on Linux?
```bash ```bash
sudo groupadd responses sudo chown -R ttvdrops:http /home/ttvdrops/.local/share/TTVDrops/media/
sudo usermod -aG responses lovinator sudo chgrp -R http /home/ttvdrops/.local/share/TTVDrops/media
sudo usermod -aG responses ttvdrops sudo find /home/ttvdrops/.local/share/TTVDrops/media -type d -exec chmod 2775 {} \;
sudo find /home/ttvdrops/.local/share/TTVDrops/media -type f -exec chmod 664 {} \;
sudo chown -R lovinator:responses /mnt/fourteen/Data/Responses sudo chown -R ttvdrops:http /home/ttvdrops/.local/share/TTVDrops/datasets/
sudo chown -R lovinator:responses /mnt/fourteen/Data/ttvdrops sudo chgrp -R http /home/ttvdrops/.local/share/TTVDrops/datasets/
sudo chmod -R 2775 /mnt/fourteen/Data/Responses sudo find /home/ttvdrops/.local/share/TTVDrops/datasets -type d -exec chmod 2775 {} \;
sudo chmod -R 2775 /mnt/fourteen/Data/ttvdrops sudo find /home/ttvdrops/.local/share/TTVDrops/datasets -type f -exec chmod 664 {} \;
# Import dir
sudo setfacl -b /mnt/fourteen/Data/Responses /mnt/fourteen/Data/Responses/imported
sudo setfacl -m g:responses:rwx /mnt/fourteen/Data/Responses /mnt/fourteen/Data/Responses/imported
sudo setfacl -d -m g:responses:rwx /mnt/fourteen/Data/Responses /mnt/fourteen/Data/Responses/imported
# Backup dir
sudo setfacl -b /mnt/fourteen/Data/ttvdrops
sudo setfacl -m g:responses:rwx /mnt/fourteen/Data/ttvdrops
sudo setfacl -d -m g:responses:rwx /mnt/fourteen/Data/ttvdrops
``` ```

View file

@ -4,6 +4,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import sentry_sdk
from dotenv import load_dotenv from dotenv import load_dotenv
from platformdirs import user_data_dir from platformdirs import user_data_dir
@ -140,6 +141,8 @@ INSTALLED_APPS: list[str] = [
"django.contrib.postgres", "django.contrib.postgres",
"twitch.apps.TwitchConfig", "twitch.apps.TwitchConfig",
"kick.apps.KickConfig", "kick.apps.KickConfig",
"youtube.apps.YoutubeConfig",
"core.apps.CoreConfig",
] ]
MIDDLEWARE: list[str] = [ MIDDLEWARE: list[str] = [
@ -189,3 +192,13 @@ if not TESTING:
"silk.middleware.SilkyMiddleware", "silk.middleware.SilkyMiddleware",
*MIDDLEWARE, *MIDDLEWARE,
] ]
if not DEBUG:
sentry_sdk.init(
dsn="https://1aa1ac672090fb795783de0e90a2b19f@o4505228040339456.ingest.us.sentry.io/4511055670738944",
send_default_pii=True,
enable_logs=True,
traces_sample_rate=1.0,
profile_session_sample_rate=1.0,
profile_lifecycle="trace",
)

View file

@ -12,7 +12,6 @@ from config import settings
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Generator from collections.abc import Generator
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
@ -28,7 +27,7 @@ def reload_settings_module() -> Generator[Callable[..., ModuleType]]:
original_env: dict[str, str] = os.environ.copy() original_env: dict[str, str] = os.environ.copy()
@contextmanager @contextmanager
def temporary_env(env: dict[str, str]) -> Iterator[None]: def temporary_env(env: dict[str, str]) -> Generator[None]:
previous_env: dict[str, str] = os.environ.copy() previous_env: dict[str, str] = os.environ.copy()
os.environ.clear() os.environ.clear()
os.environ.update(env) os.environ.update(env)

View file

@ -34,8 +34,13 @@ def _reload_urls_with(**overrides) -> ModuleType:
def test_top_level_named_routes_available() -> None: def test_top_level_named_routes_available() -> None:
"""Top-level routes defined in `config.urls` are reversible.""" """Top-level routes defined in `config.urls` are reversible."""
assert reverse("sitemap") == "/sitemap.xml" assert reverse("sitemap") == "/sitemap.xml"
# ensure the included `twitch` namespace is present # ensure the included `twitch` namespace is present
assert reverse("twitch:dashboard") == "/" msg: str = f"Expected 'twitch:dashboard' to reverse to '/twitch/', got {reverse('twitch:dashboard')}"
assert reverse("twitch:dashboard") == "/twitch/", msg
youtube_msg: str = f"Expected 'youtube:index' to reverse to '/youtube/', got {reverse('youtube:index')}"
assert reverse("youtube:index") == "/youtube/", youtube_msg
def test_debug_tools_not_present_while_testing() -> None: def test_debug_tools_not_present_while_testing() -> None:

View file

@ -5,16 +5,22 @@ from django.conf.urls.static import static
from django.urls import include from django.urls import include
from django.urls import path from django.urls import path
from twitch import views as twitch_views from core import views as core_views
if TYPE_CHECKING: if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver from django.urls.resolvers import URLResolver
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="sitemap.xml", view=twitch_views.sitemap_view, name="sitemap"), path(route="sitemap.xml", view=core_views.sitemap_view, name="sitemap"),
path(route="", view=include("twitch.urls", namespace="twitch")), # Core app
path(route="", view=include("core.urls", namespace="core")),
# Twitch app
path(route="twitch/", view=include("twitch.urls", namespace="twitch")),
# Kick app
path(route="kick/", view=include("kick.urls", namespace="kick")), path(route="kick/", view=include("kick.urls", namespace="kick")),
# YouTube app
path(route="youtube/", view=include("youtube.urls", namespace="youtube")),
] ]
# Serve media in development # Serve media in development

0
core/__init__.py Normal file
View file

1
core/admin.py Normal file
View file

@ -0,0 +1 @@
# Register your models here.

7
core/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
"""Core application configuration."""
name = "core"

View file

1
core/models.py Normal file
View file

@ -0,0 +1 @@
# Create your models here.

0
core/tests/__init__.py Normal file
View file

96
core/urls.py Normal file
View file

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

922
core/views.py Normal file
View file

@ -0,0 +1,922 @@
import datetime
import json
import logging
import operator
from collections import OrderedDict
from copy import copy
from typing import TYPE_CHECKING
from typing import Any
from django.conf import settings
from django.db import connection
from django.db.models import Count
from django.db.models import Exists
from django.db.models import F
from django.db.models import OuterRef
from django.db.models import Prefetch
from django.db.models import Q
from django.db.models.functions import Trim
from django.db.models.query import QuerySet
from django.http import FileResponse
from django.http import Http404
from django.http import HttpResponse
from django.shortcuts import render
from django.template.defaultfilters import filesizeformat
from django.urls import reverse
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
from twitch.models import DropBenefit
from twitch.models import DropCampaign
from twitch.models import Game
from twitch.models import Organization
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")
MIN_QUERY_LENGTH_FOR_FTS = 3
MIN_SEARCH_RANK = 0.05
DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and more."
def _build_seo_context( # noqa: PLR0913, PLR0917
page_title: str = "ttvdrops",
page_description: str | 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).
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": og_type,
"robots_directive": robots_directive,
}
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
# MARK: /sitemap.xml
def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
"""Generate a dynamic XML sitemap for search engines.
Args:
request: The HTTP request.
Returns:
HttpResponse: XML sitemap.
"""
base_url: str = f"{request.scheme}://{request.get_host()}"
# Start building sitemap XML
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
# Static pages
sitemap_urls.extend([
{"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"},
{"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"},
{
"url": f"{base_url}/reward-campaigns/",
"priority": "0.9",
"changefreq": "daily",
},
{"url": f"{base_url}/games/", "priority": "0.9", "changefreq": "weekly"},
{
"url": f"{base_url}/organizations/",
"priority": "0.8",
"changefreq": "weekly",
},
{"url": f"{base_url}/channels/", "priority": "0.8", "changefreq": "weekly"},
{"url": f"{base_url}/badges/", "priority": "0.7", "changefreq": "monthly"},
{"url": f"{base_url}/emotes/", "priority": "0.7", "changefreq": "monthly"},
{"url": f"{base_url}/search/", "priority": "0.6", "changefreq": "monthly"},
])
# Dynamic detail pages - Games
games: QuerySet[Game] = Game.objects.all()
for game in games:
entry: dict[str, str | dict[str, str]] = {
"url": f"{base_url}{reverse('twitch:game_detail', args=[game.twitch_id])}",
"priority": "0.8",
"changefreq": "weekly",
}
if game.updated_at:
entry["lastmod"] = game.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Campaigns
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all()
for campaign in campaigns:
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.7",
"changefreq": "weekly",
}
if campaign.updated_at:
entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Organizations
orgs: QuerySet[Organization] = Organization.objects.all()
for org in orgs:
resource_url = reverse("twitch:organization_detail", args=[org.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.7",
"changefreq": "weekly",
}
if org.updated_at:
entry["lastmod"] = org.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Channels
channels: QuerySet[Channel] = Channel.objects.all()
for channel in channels:
resource_url = reverse("twitch:channel_detail", args=[channel.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.6",
"changefreq": "weekly",
}
if channel.updated_at:
entry["lastmod"] = channel.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Badges
badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all()
for badge_set in badge_sets:
resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id])
full_url: str = f"{base_url}{resource_url}"
sitemap_urls.append({
"url": full_url,
"priority": "0.5",
"changefreq": "monthly",
})
# Dynamic detail pages - Reward Campaigns
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
for reward_campaign in reward_campaigns:
resource_url = reverse(
"twitch:reward_campaign_detail",
args=[
reward_campaign.twitch_id,
],
)
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.6",
"changefreq": "weekly",
}
if reward_campaign.updated_at:
entry["lastmod"] = reward_campaign.updated_at.isoformat()
sitemap_urls.append(entry)
# Build XML
xml_content = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml_content += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url_entry in sitemap_urls:
xml_content += " <url>\n"
xml_content += f" <loc>{url_entry['url']}</loc>\n"
if url_entry.get("lastmod"):
xml_content += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
xml_content += (
f" <changefreq>{url_entry.get('changefreq', 'monthly')}</changefreq>\n"
)
xml_content += f" <priority>{url_entry.get('priority', '0.5')}</priority>\n"
xml_content += " </url>\n"
xml_content += "</urlset>"
return HttpResponse(xml_content, content_type="application/xml")
# MARK: /docs/rss/
def docs_rss_view(request: HttpRequest) -> HttpResponse:
"""View for /docs/rss that lists all available RSS feeds.
Args:
request: The HTTP request object.
Returns:
Rendered HTML response with list of RSS feeds.
"""
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("<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(
page_title="Twitch RSS Feeds",
page_description="RSS feeds for Twitch drops.",
)
return render(
request,
"twitch/docs_rss.html",
{
"feeds": feeds,
"filtered_feeds": filtered_feeds,
"sample_game": sample_game,
"sample_org": sample_org,
**seo_context,
},
)
# MARK: /debug/
def debug_view(request: HttpRequest) -> HttpResponse:
"""Debug view showing potentially broken or inconsistent data.
Returns:
HttpResponse: Rendered debug template or redirect if unauthorized.
"""
now: datetime.datetime = timezone.now()
# Games with no assigned owner organization
games_without_owner: QuerySet[Game] = Game.objects.filter(
owners__isnull=True,
).order_by("display_name")
# Campaigns with no images at all (no direct URL and no benefit image fallbacks)
broken_image_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(
Q(image_url__isnull=True)
| Q(image_url__exact="")
| ~Q(image_url__startswith="http"),
)
.exclude(
Exists(
TimeBasedDrop.objects.filter(campaign=OuterRef("pk")).filter(
benefits__image_asset_url__startswith="http",
),
),
)
.select_related("game")
)
# Benefits with missing images
broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate(
trimmed_url=Trim("image_asset_url"),
).filter(
Q(image_asset_url__isnull=True)
| Q(trimmed_url__exact="")
| ~Q(image_asset_url__startswith="http"),
)
# Time-based drops without any benefits
drops_without_benefits: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
benefits__isnull=True,
).select_related("campaign__game")
# Campaigns with invalid dates (start after end or missing either)
invalid_date_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
Q(start_at__gt=F("end_at")) | Q(start_at__isnull=True) | Q(end_at__isnull=True),
).select_related("game")
# Duplicate campaign names per game.
# We retrieve the game's name for user-friendly display.
duplicate_name_campaigns: QuerySet[DropCampaign, dict[str, Any]] = (
DropCampaign.objects
.values("game__display_name", "name", "game__twitch_id")
.annotate(name_count=Count("twitch_id"))
.filter(name_count__gt=1)
.order_by("game__display_name", "name")
)
# Active campaigns with no images at all
active_missing_image: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(start_at__lte=now, end_at__gte=now)
.filter(
Q(image_url__isnull=True)
| Q(image_url__exact="")
| ~Q(image_url__startswith="http"),
)
.exclude(
Exists(
TimeBasedDrop.objects.filter(campaign=OuterRef("pk")).filter(
benefits__image_asset_url__startswith="http",
),
),
)
.select_related("game")
)
# Distinct GraphQL operation names used to fetch campaigns with counts
# Since operation_names is now a JSON list field, we need to flatten and count
operation_names_counter: dict[str, int] = {}
for campaign in DropCampaign.objects.only("operation_names"):
for op_name in campaign.operation_names:
if op_name and op_name.strip():
operation_names_counter[op_name.strip()] = (
operation_names_counter.get(op_name.strip(), 0) + 1
)
operation_names_with_counts: list[dict[str, Any]] = [
{"trimmed_op": op_name, "count": count}
for op_name, count in sorted(operation_names_counter.items())
]
# Campaigns missing DropCampaignDetails operation name
# Need to handle SQLite separately since it doesn't support JSONField lookups
# Sqlite is used when testing
if connection.vendor == "sqlite":
all_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.select_related(
"game",
).order_by("game__display_name", "name")
campaigns_missing_dropcampaigndetails: list[DropCampaign] = [
c
for c in all_campaigns
if c.operation_names is None
or "DropCampaignDetails" not in c.operation_names
]
else:
campaigns_missing_dropcampaigndetails: list[DropCampaign] = list(
DropCampaign.objects
.filter(
Q(operation_names__isnull=True)
| ~Q(operation_names__contains=["DropCampaignDetails"]),
)
.select_related("game")
.order_by("game__display_name", "name"),
)
context: dict[str, Any] = {
"now": now,
"games_without_owner": games_without_owner,
"broken_image_campaigns": broken_image_campaigns,
"broken_benefit_images": broken_benefit_images,
"drops_without_benefits": drops_without_benefits,
"invalid_date_campaigns": invalid_date_campaigns,
"duplicate_name_campaigns": duplicate_name_campaigns,
"active_missing_image": active_missing_image,
"operation_names_with_counts": operation_names_with_counts,
"campaigns_missing_dropcampaigndetails": campaigns_missing_dropcampaigndetails,
}
seo_context: dict[str, Any] = _build_seo_context(
page_title="Debug",
page_description="Debug view showing potentially broken or inconsistent data.",
robots_directive="noindex, nofollow",
)
context.update(seo_context)
return render(request, "twitch/debug.html", context)
# MARK: /datasets/
def dataset_backups_view(request: HttpRequest) -> HttpResponse:
"""View to list database backup datasets on disk.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered dataset backups page.
"""
# TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003
# TODO(TheLovinator): Upload to s3 instead. # noqa: TD003
# TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld
datasets_root: Path = settings.DATA_DIR / "datasets"
search_dirs: list[Path] = [datasets_root]
seen_paths: set[str] = set()
datasets: list[dict[str, Any]] = []
for folder in search_dirs:
if not folder.exists() or not folder.is_dir():
continue
# Only include .zst files
for path in folder.glob("*.zst"):
if not path.is_file():
continue
key = str(path.resolve())
if key in seen_paths:
continue
seen_paths.add(key)
stat: stat_result = path.stat()
updated_at: datetime.datetime = datetime.datetime.fromtimestamp(
stat.st_mtime,
tz=timezone.get_current_timezone(),
)
try:
display_path = str(path.relative_to(datasets_root))
download_path: str | None = display_path
except ValueError:
display_path: str = path.name
download_path: str | None = None
datasets.append({
"name": path.name,
"display_path": display_path,
"download_path": download_path,
"size": filesizeformat(stat.st_size),
"updated_at": updated_at,
})
datasets.sort(key=operator.itemgetter("updated_at"), reverse=True)
seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Dataset",
page_description="Database backups and datasets available for download.",
)
context: dict[str, Any] = {
"datasets": datasets,
"data_dir": str(datasets_root),
"dataset_count": len(datasets),
**seo_context,
}
return render(request, "twitch/dataset_backups.html", context)
def dataset_backup_download_view(
request: HttpRequest, # noqa: ARG001
relative_path: str,
) -> FileResponse:
"""Download a dataset backup from the data directory.
Args:
request: The HTTP request.
relative_path: The path relative to the data directory.
Returns:
FileResponse: The file response for the requested dataset.
Raises:
Http404: When the file is not found or is outside the data directory.
"""
# TODO(TheLovinator): Use s3 instead of local disk. # noqa: TD003
datasets_root: Path = settings.DATA_DIR / "datasets"
requested_path: Path = (datasets_root / relative_path).resolve()
data_root: Path = datasets_root.resolve()
try:
requested_path.relative_to(data_root)
except ValueError as exc:
msg = "File not found"
raise Http404(msg) from exc
if not requested_path.exists() or not requested_path.is_file():
msg = "File not found"
raise Http404(msg)
if not requested_path.name.endswith(".zst"):
msg = "File not found"
raise Http404(msg)
return FileResponse(
requested_path.open("rb"),
as_attachment=True,
filename=requested_path.name,
)
# MARK: /search/
def search_view(request: HttpRequest) -> HttpResponse:
"""Search view for all models.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered search results.
"""
query: str = request.GET.get("q", "")
results: dict[str, QuerySet] = {}
if query:
if len(query) < MIN_QUERY_LENGTH_FOR_FTS:
results["organizations"] = Organization.objects.filter(
name__istartswith=query,
)
results["games"] = Game.objects.filter(
Q(name__istartswith=query) | Q(display_name__istartswith=query),
)
results["campaigns"] = DropCampaign.objects.filter(
Q(name__istartswith=query) | Q(description__icontains=query),
).select_related("game")
results["drops"] = TimeBasedDrop.objects.filter(
name__istartswith=query,
).select_related("campaign")
results["benefits"] = DropBenefit.objects.filter(
name__istartswith=query,
).prefetch_related("drops__campaign")
results["reward_campaigns"] = RewardCampaign.objects.filter(
Q(name__istartswith=query)
| Q(brand__istartswith=query)
| Q(summary__icontains=query),
).select_related("game")
results["badge_sets"] = ChatBadgeSet.objects.filter(
set_id__istartswith=query,
)
results["badges"] = ChatBadge.objects.filter(
Q(title__istartswith=query) | Q(description__icontains=query),
).select_related("badge_set")
else:
results["organizations"] = Organization.objects.filter(
name__icontains=query,
)
results["games"] = Game.objects.filter(
Q(name__icontains=query) | Q(display_name__icontains=query),
)
results["campaigns"] = DropCampaign.objects.filter(
Q(name__icontains=query) | Q(description__icontains=query),
).select_related("game")
results["drops"] = TimeBasedDrop.objects.filter(
name__icontains=query,
).select_related("campaign")
results["benefits"] = DropBenefit.objects.filter(
name__icontains=query,
).prefetch_related("drops__campaign")
results["reward_campaigns"] = RewardCampaign.objects.filter(
Q(name__icontains=query)
| Q(brand__icontains=query)
| Q(summary__icontains=query),
).select_related("game")
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__icontains=query)
results["badges"] = ChatBadge.objects.filter(
Q(title__icontains=query) | Q(description__icontains=query),
).select_related("badge_set")
total_results_count: int = sum(len(qs) for qs in results.values())
# TODO(TheLovinator): Make the description more informative by including counts of each result type, e.g. "Found 5 games, 3 campaigns, and 10 drops for 'rust'." # noqa: TD003
if query:
page_title: str = f"Search Results for '{query}'"[:60]
page_description: str = f"Found {total_results_count} results for '{query}'."
else:
page_title = "Search"
page_description = "Search for drops, games, channels, and organizations."
seo_context: dict[str, Any] = _build_seo_context(
page_title=page_title,
page_description=page_description,
)
return render(
request,
"twitch/search_results.html",
{"query": query, "results": results, **seo_context},
)
# MARK: /
def dashboard(request: HttpRequest) -> HttpResponse:
"""Dashboard view showing summary stats and latest campaigns.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered dashboard page.
"""
now: datetime.datetime = timezone.now()
active_twitch_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(start_at__lte=now, end_at__gte=now)
.select_related("game")
.prefetch_related("game__owners")
.prefetch_related(
Prefetch(
"allow_channels",
queryset=Channel.objects.order_by("display_name"),
to_attr="channels_ordered",
),
)
.order_by("-start_at")
)
twitch_campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict()
for campaign in active_twitch_campaigns:
game: Game = campaign.game
game_id: str = game.twitch_id
if game_id not in twitch_campaigns_by_game:
twitch_campaigns_by_game[game_id] = {
"name": game.display_name,
"box_art": game.box_art_best_url,
"owners": list(game.owners.all()),
"campaigns": [],
}
twitch_campaigns_by_game[game_id]["campaigns"].append({
"campaign": campaign,
"allowed_channels": getattr(campaign, "channels_ordered", []),
})
active_kick_campaigns: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(starts_at__lte=now, ends_at__gte=now)
.select_related("organization", "category")
.prefetch_related(
Prefetch("channels", queryset=KickChannel.objects.select_related("user")),
"rewards",
)
.order_by("-starts_at")
)
kick_campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict()
for campaign in active_kick_campaigns:
if campaign.category is None:
game_key: str = "unknown"
game_name: str = "Unknown Category"
game_image: str = ""
game_kick_id: int | None = None
else:
game_key = str(campaign.category.kick_id)
game_name = campaign.category.name
game_image = campaign.category.image_url
game_kick_id = campaign.category.kick_id
if game_key not in kick_campaigns_by_game:
kick_campaigns_by_game[game_key] = {
"name": game_name,
"image": game_image,
"kick_id": game_kick_id,
"campaigns": [],
}
kick_campaigns_by_game[game_key]["campaigns"].append({
"campaign": campaign,
"channels": list(campaign.channels.all()),
"rewards": list(campaign.rewards.all()),
})
active_reward_campaigns: QuerySet[RewardCampaign] = (
RewardCampaign.objects
.filter(starts_at__lte=now, ends_at__gte=now)
.select_related("game")
.order_by("-starts_at")
)
website_schema: dict[str, str | dict[str, str | dict[str, str]]] = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": "ttvdrops",
"url": request.build_absolute_uri("/"),
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": request.build_absolute_uri(
"/search/?q={search_term_string}",
),
},
"query-input": "required name=search_term_string",
},
}
seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch/Kick Drops",
page_description=("Twitch and Kick drops."),
og_type="website",
schema_data=website_schema,
)
return render(
request,
"core/dashboard.html",
{
"campaigns_by_game": twitch_campaigns_by_game,
"kick_campaigns_by_game": kick_campaigns_by_game,
"active_reward_campaigns": active_reward_campaigns,
"now": now,
**seo_context,
},
)

View file

@ -289,7 +289,7 @@ class KickDropCampaign(auto_prefetch.Model):
"""Return the image URL for the campaign.""" """Return the image URL for the campaign."""
# Image from first drop # Image from first drop
if self.rewards.exists(): # pyright: ignore[reportAttributeAccessIssue] if self.rewards.exists(): # pyright: ignore[reportAttributeAccessIssue]
first_reward: KickReward = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue] first_reward: KickReward | None = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue]
if first_reward and first_reward.image_url: if first_reward and first_reward.image_url:
return first_reward.full_image_url return first_reward.full_image_url

View file

@ -442,6 +442,8 @@ class ImportKickDropsCommandTest(TestCase):
campaign: KickDropCampaign = KickDropCampaign.objects.get() campaign: KickDropCampaign = KickDropCampaign.objects.get()
assert campaign.name == "PUBG 9th Anniversary" assert campaign.name == "PUBG 9th Anniversary"
assert campaign.status == "active" assert campaign.status == "active"
assert campaign.organization is not None
assert campaign.category is not None
assert campaign.organization.name == "KRAFTON" assert campaign.organization.name == "KRAFTON"
assert campaign.category.name == "PUBG: Battlegrounds" assert campaign.category.name == "PUBG: Battlegrounds"

View file

@ -20,6 +20,7 @@ dependencies = [
"pydantic", "pydantic",
"pygments", "pygments",
"python-dotenv", "python-dotenv",
"sentry-sdk",
"setproctitle", "setproctitle",
"tqdm", "tqdm",
] ]

View file

@ -24,55 +24,55 @@
</title> </title>
{% include "includes/meta_tags.html" %} {% include "includes/meta_tags.html" %}
<!-- Feed discovery links --> <!-- Feed discovery links -->
<!-- Read {% url 'twitch:docs_rss' %} for more details on available feeds --> <!-- Read {% url 'core:docs_rss' %} for more details on available feeds -->
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="All campaigns (RSS)" title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" /> href="{% url 'core:campaign_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Atom)" title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" /> href="{% url 'core:campaign_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Discord)" title="All campaigns (Discord)"
href="{% url 'twitch:campaign_feed_discord' %}" /> href="{% url 'core:campaign_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added games (RSS)" title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" /> href="{% url 'core:game_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Atom)" title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" /> href="{% url 'core:game_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Discord)" title="Newly added games (Discord)"
href="{% url 'twitch:game_feed_discord' %}" /> href="{% url 'core:game_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added organizations (RSS)" title="Newly added organizations (RSS)"
href="{% url 'twitch:organization_feed' %}" /> href="{% url 'core:organization_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added organizations (Atom)" title="Newly added organizations (Atom)"
href="{% url 'twitch:organization_feed_atom' %}" /> href="{% url 'core:organization_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added organizations (Discord)" title="Newly added organizations (Discord)"
href="{% url 'twitch:organization_feed_discord' %}" /> href="{% url 'core:organization_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added reward campaigns (RSS)" title="Newly added reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" /> href="{% url 'core:reward_campaign_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added reward campaigns (Atom)" title="Newly added reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" /> href="{% url 'core:reward_campaign_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added reward campaigns (Discord)" title="Newly added reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_discord' %}" /> href="{% url 'core:reward_campaign_feed_discord' %}" />
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="All Kick campaigns (RSS)" title="All Kick campaigns (RSS)"
@ -244,15 +244,13 @@
</head> </head>
<body> <body>
<nav> <nav>
<a href="{% url 'twitch:dashboard' %}">Dashboard</a> | <a href="{% url 'core:dashboard' %}">Dashboard</a> |
<a href="{% url 'twitch:docs_rss' %}">RSS</a> | <a href="{% url 'core:docs_rss' %}">RSS</a> |
<a href="{% url 'twitch:debug' %}">Debug</a> | <a href="{% url 'core:debug' %}">Debug</a> |
<a href="{% url 'twitch:dataset_backups' %}">Dataset</a> | <a href="{% url 'core:dataset_backups' %}">Dataset</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a> | <a href="https://github.com/sponsors/TheLovinator1">Donate</a> |
<a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> | <a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> |
<form action="{% url 'twitch:search' %}" <form action="{% url 'core:search' %}" method="get" style="display: inline">
method="get"
style="display: inline">
<input type="search" <input type="search"
name="q" name="q"
placeholder="Search..." placeholder="Search..."
@ -278,7 +276,7 @@
<br /> <br />
<strong>Other sites</strong> <strong>Other sites</strong>
<a href="#">Steam</a> | <a href="#">Steam</a> |
<a href="#">YouTube</a> | <a href="{% url 'youtube:index' %}">YouTube</a> |
<a href="#">TikTok</a> | <a href="#">TikTok</a> |
<a href="#">Discord</a> <a href="#">Discord</a>
</nav> </nav>

View file

@ -0,0 +1,355 @@
{% extends "base.html" %}
{% load image_tags %}
{% block title %}
Drops Dashboard
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All Twitch campaigns (RSS)"
href="{% url 'core:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All Twitch campaigns (Atom)"
href="{% url 'core:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All Twitch campaigns (Discord)"
href="{% url 'core:campaign_feed_discord' %}" />
<link rel="alternate"
type="application/rss+xml"
title="All Kick campaigns (RSS)"
href="{% url 'kick:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All Kick campaigns (Atom)"
href="{% url 'kick:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All Kick campaigns (Discord)"
href="{% url 'kick:campaign_feed_discord' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<h1>Active Drops Dashboard</h1>
<p>
A combined overview of currently active Twitch and Kick drops campaigns.
<br />
Click any campaign to open details.
</p>
<hr />
<section id="twitch-campaigns-section">
<header style="margin-bottom: 1rem;">
<h2 style="margin: 0 0 0.5rem 0;">Twitch Campaigns</h2>
<div>
<a href="{% url 'core:campaign_feed' %}"
title="RSS feed for all Twitch campaigns">[rss]</a>
<a href="{% url 'core:campaign_feed_atom' %}"
title="Atom feed for Twitch campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for Twitch campaigns">[discord]</a>
</div>
</header>
{% if campaigns_by_game %}
{% for game_id, game_data in campaigns_by_game.items %}
<article id="twitch-game-article-{{ game_id }}" style="margin-bottom: 2rem;">
<header style="margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:game_detail' game_id %}">{{ game_data.name }}</a>
</h3>
{% if game_data.owners %}
<div style="font-size: 0.9rem; color: #666;">
Organizations:
{% for org in game_data.owners %}
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
{% if not forloop.last %},{% endif %}
{% endfor %}
</div>
{% endif %}
</header>
<div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;">{% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 %}</div>
<div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %}
<article style="display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
flex-shrink: 0">
<div>
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
{% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 %}
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4>
</a>
<time datetime="{{ campaign_data.campaign.end_at|date:'c' }}"
title="{{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Ends in {{ campaign_data.campaign.end_at|timeuntil }}
</time>
<time datetime="{{ campaign_data.campaign.start_at|date:'c' }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Started {{ campaign_data.campaign.start_at|timesince }} ago
</time>
<time datetime="{{ campaign_data.campaign.duration_iso }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Duration: {{ campaign_data.campaign.start_at|timesince:campaign_data.campaign.end_at }}
</time>
<div style="margin-top: 0.5rem; font-size: 0.8rem; ">
<strong>Channels:</strong>
<ul style="margin: 0.25rem 0 0 0;
padding-left: 1rem;
list-style-type: none">
{% if campaign_data.campaign.allow_is_enabled %}
{% if campaign_data.allowed_channels %}
{% for channel in campaign_data.allowed_channels|slice:":5" %}
<li style="margin-bottom: 0.1rem;">
<a href="https://twitch.tv/{{ channel.name }}"
rel="nofollow ugc"
title="Watch {{ channel.display_name }} on Twitch">
{{ channel.display_name }}</a><a href="{% url 'twitch:channel_detail' channel.twitch_id %}"
title="View {{ channel.display_name }} details"
style="font-family: monospace;
text-decoration: none">[i]</a>
</li>
{% endfor %}
{% else %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Find streamers playing {{ campaign_data.campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
</li>
{% else %}
<li>Failed to get Twitch directory URL :(</li>
{% endif %}
{% endif %}
{% if campaign_data.allowed_channels|length > 5 %}
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p>No active Twitch campaigns at the moment.</p>
{% endif %}
</section>
{% if active_reward_campaigns %}
<section id="reward-campaigns-section"
style="margin-top: 2rem;
border-top: 2px solid #ddd;
padding-top: 1rem">
<header style="margin-bottom: 1rem;">
<h2 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:reward_campaign_list' %}">Twitch Reward Campaigns (Quest Rewards)</a>
</h2>
</header>
<div style="display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{% for campaign in active_reward_campaigns %}
<article id="reward-campaign-{{ campaign.twitch_id }}"
style="border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem">
<h3 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
{{ campaign.brand }}: {{ campaign.name }}
{% else %}
{{ campaign.name }}
{% endif %}
</a>
</h3>
{% if campaign.summary %}
<p style="font-size: 0.9rem; color: #555; margin: 0.5rem 0;">{{ campaign.summary }}</p>
{% endif %}
<div style="font-size: 0.85rem; color: #666;">
{% if campaign.ends_at %}
<p style="margin: 0.25rem 0;">
<strong>Ends:</strong>
<time datetime="{{ campaign.ends_at|date:'c' }}"
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">
{{ campaign.ends_at|date:"M d, Y H:i" }}
</time>
</p>
{% endif %}
{% if campaign.game %}
<p style="margin: 0.25rem 0;">
<strong>Game:</strong>
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
</p>
{% elif campaign.is_sitewide %}
<p style="margin: 0.25rem 0;">
<strong>Type:</strong> Site-wide reward campaign
</p>
{% endif %}
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
<section id="kick-campaigns-section"
style="margin-top: 2rem;
border-top: 2px solid #ddd;
padding-top: 1rem">
<header style="margin-bottom: 1rem;">
<h2 style="margin: 0 0 0.5rem 0;">Kick Campaigns</h2>
<div>
<a href="{% url 'kick:campaign_feed' %}"
title="RSS feed for all Kick campaigns">[rss]</a>
<a href="{% url 'kick:campaign_feed_atom' %}"
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>
</div>
</header>
{% if kick_campaigns_by_game %}
{% for game_id, game_data in kick_campaigns_by_game.items %}
<article id="kick-game-article-{{ game_id }}" style="margin-bottom: 2rem;">
<header style="margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem 0;">
{% if game_data.kick_id %}
<a href="{% url 'kick:game_detail' game_data.kick_id %}">{{ game_data.name }}</a>
{% else %}
{{ game_data.name }}
{% endif %}
</h3>
</header>
<div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;">
{% if game_data.image %}
<img src="{{ game_data.image }}"
width="200"
height="200"
alt="Image for {{ game_data.name }}"
style="width: 200px;
height: auto;
border-radius: 8px" />
{% else %}
<div style="width: 200px;
height: 200px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ddd">No Image</div>
{% endif %}
</div>
<div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %}
<article style="display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
flex-shrink: 0;
width: 260px">
<div>
<a href="{% url 'kick:campaign_detail' campaign_data.campaign.kick_id %}">
{% if campaign_data.campaign.image_url %}
<img src="{{ campaign_data.campaign.image_url }}"
width="120"
height="120"
alt="Image for {{ campaign_data.campaign.name }}"
style="width: 120px;
height: auto;
border-radius: 8px" />
{% endif %}
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.name }}</h4>
</a>
<time datetime="{{ campaign_data.campaign.ends_at|date:'c' }}"
title="{{ campaign_data.campaign.ends_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Ends in {{ campaign_data.campaign.ends_at|timeuntil }}
</time>
<time datetime="{{ campaign_data.campaign.starts_at|date:'c' }}"
title="{{ campaign_data.campaign.starts_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Started {{ campaign_data.campaign.starts_at|timesince }} ago
</time>
{% if campaign_data.campaign.organization %}
<p style="margin: 0.25rem 0; font-size: 0.9rem; text-align: left;">
<strong>Organization:</strong>
<a href="{% url 'kick:organization_detail' campaign_data.campaign.organization.kick_id %}">{{ campaign_data.campaign.organization.name }}</a>
</p>
{% endif %}
<div style="margin-top: 0.5rem; font-size: 0.8rem;">
<strong>Channels:</strong>
<ul style="margin: 0.25rem 0 0 0;
padding-left: 1rem;
list-style-type: none">
{% if campaign_data.channels %}
{% for channel in campaign_data.channels|slice:":5" %}
<li style="margin-bottom: 0.1rem;">
<a href="{{ channel.channel_url }}" rel="nofollow ugc" target="_blank">
{% if channel.user %}
{{ channel.user.username }}
{% else %}
{{ channel.slug }}
{% endif %}
</a>
</li>
{% endfor %}
{% if campaign_data.channels|length > 5 %}
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign_data.channels|length|add:"-5" }} more
</li>
{% endif %}
{% else %}
<li>No specific channels listed.</li>
{% endif %}
</ul>
</div>
{% if campaign_data.rewards %}
<div style="margin-top: 0.5rem; font-size: 0.8rem;">
<strong>Rewards:</strong>
<ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;">
{% for reward in campaign_data.rewards|slice:":3" %}
<li>{{ reward.name }} ({{ reward.required_units }} min)</li>
{% endfor %}
{% if campaign_data.rewards|length > 3 %}
<li style="color: #666; font-style: italic;">... and {{ campaign_data.rewards|length|add:"-3" }} more</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p>No active Kick campaigns at the moment.</p>
{% endif %}
</section>
</main>
{% endblock content %}

View file

@ -9,15 +9,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="{{ campaign.game.display_name }} campaigns (RSS)" title="{{ campaign.game.display_name }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}" /> href="{% url 'core:game_campaign_feed' campaign.game.twitch_id %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="{{ campaign.game.display_name }} campaigns (Atom)" title="{{ campaign.game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" /> href="{% url 'core:game_campaign_feed_atom' campaign.game.twitch_id %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="{{ campaign.game.display_name }} campaigns (Discord)" title="{{ campaign.game.display_name }} campaigns (Discord)"
href="{% url 'twitch:game_campaign_feed_discord' campaign.game.twitch_id %}" /> href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}" />
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
@ -90,11 +90,11 @@
{% endif %} {% endif %}
<!-- RSS Feeds --> <!-- RSS Feeds -->
{% if campaign.game %} {% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}" <a href="{% url 'core:game_campaign_feed' campaign.game.twitch_id %}"
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a> title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" <a href="{% url 'core:game_campaign_feed_atom' campaign.game.twitch_id %}"
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 'twitch: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>
{% endif %} {% endif %}
</div> </div>

View file

@ -9,15 +9,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="All campaigns (RSS)" title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" /> href="{% url 'core:campaign_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Atom)" title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" /> href="{% url 'core:campaign_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Discord)" title="All campaigns (Discord)"
href="{% url 'twitch:campaign_feed_discord' %}" /> href="{% url 'core:campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -25,11 +25,11 @@
<h1>Drop Campaigns</h1> <h1>Drop Campaigns</h1>
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div> <div>
<a href="{% url 'twitch:campaign_feed' %}" <a href="{% url 'core:campaign_feed' %}"
title="RSS feed for all campaigns">[rss]</a> title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}" <a href="{% url 'core:campaign_feed_atom' %}"
title="Atom feed for all campaigns">[atom]</a> title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'twitch: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 '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>

View file

@ -8,15 +8,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="All campaigns (RSS)" title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" /> href="{% url 'core:campaign_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Atom)" title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" /> href="{% url 'core:campaign_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="All campaigns (Discord)" title="All campaigns (Discord)"
href="{% url 'twitch:campaign_feed_discord' %}" /> href="{% url 'core:campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -33,11 +33,11 @@
</p> </p>
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div> <div>
<a href="{% url 'twitch:campaign_feed' %}" <a href="{% url 'core:campaign_feed' %}"
title="RSS feed for all campaigns">[rss]</a> title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}" <a href="{% url 'core:campaign_feed_atom' %}"
title="Atom feed for campaigns">[atom]</a> title="Atom feed for campaigns">[atom]</a>
<a href="{% url 'twitch:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for campaigns">[discord]</a> title="Discord feed for campaigns">[discord]</a>
</div> </div>
<hr /> <hr />

View file

@ -18,7 +18,7 @@
{% for dataset in datasets %} {% for dataset in datasets %}
<tr"> <tr">
<td> <td>
<a href="{% url 'twitch:dataset_backup_download' dataset.download_path %}">{{ dataset.name }}</a> <a href="{% url 'core:dataset_backup_download' dataset.download_path %}">{{ dataset.name }}</a>
</td> </td>
<td>{{ dataset.size }}</td> <td>{{ dataset.size }}</td>
<td> <td>

View file

@ -8,15 +8,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="{{ game.display_name }} campaigns (RSS)" title="{{ game.display_name }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" /> href="{% url 'core:game_campaign_feed' game.twitch_id %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="{{ game.display_name }} campaigns (Atom)" title="{{ game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" /> href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="{{ game.display_name }} campaigns (Discord)" title="{{ game.display_name }} campaigns (Discord)"
href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}" /> href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}" />
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
@ -49,11 +49,11 @@
<div>Twitch slug: {{ game.slug }}</div> <div>Twitch slug: {{ game.slug }}</div>
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div> <div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" <a href="{% url 'core:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">[rss]</a> title="RSS feed for {{ game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" <a href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a> title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
<a href="{% url 'twitch: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>
</div> </div>
</div> </div>

View file

@ -7,15 +7,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added games (RSS)" title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" /> href="{% url 'core:game_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Atom)" title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" /> href="{% url 'core:game_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Discord)" title="Newly added games (Discord)"
href="{% url 'twitch:game_feed_discord' %}" /> href="{% url 'core:game_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
@ -23,10 +23,10 @@
<h1 id="page-title">All Games</h1> <h1 id="page-title">All Games</h1>
<div> <div>
<a href="{% url 'twitch:games_list' %}" title="View games as list">[list]</a> <a href="{% url 'twitch:games_list' %}" title="View games as list">[list]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a> <a href="{% url 'core:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}" <a href="{% url 'core:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch: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 '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>

View file

@ -6,25 +6,25 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Newly added games (RSS)" title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" /> href="{% url 'core:game_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Atom)" title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" /> href="{% url 'core:game_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Newly added games (Discord)" title="Newly added games (Discord)"
href="{% url 'twitch:game_feed_discord' %}" /> href="{% url 'core:game_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
<h1>Games List</h1> <h1>Games List</h1>
<div> <div>
<a href="{% url 'twitch:games_grid' %}" title="View games as grid">[grid]</a> <a href="{% url 'twitch:games_grid' %}" title="View games as grid">[grid]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a> <a href="{% url 'core:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}" <a href="{% url 'core:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch: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 '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>

View file

@ -5,11 +5,11 @@
{% block content %} {% block content %}
<h1>Organizations</h1> <h1>Organizations</h1>
<div> <div>
<a href="{% url 'twitch:organization_feed' %}" <a href="{% url 'core:organization_feed' %}"
title="RSS feed for all organizations">[rss]</a> title="RSS feed for all organizations">[rss]</a>
<a href="{% url 'twitch:organization_feed_atom' %}" <a href="{% url 'core:organization_feed_atom' %}"
title="Atom feed for all organizations">[atom]</a> title="Atom feed for all organizations">[atom]</a>
<a href="{% url 'twitch: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 '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>

View file

@ -8,15 +8,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (RSS)" title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" /> href="{% url 'core:game_campaign_feed' game.twitch_id %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)" title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" /> href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Discord)" title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Discord)"
href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}" /> href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}" />
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}

View file

@ -8,15 +8,15 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Reward campaigns (RSS)" title="Reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" /> href="{% url 'core:reward_campaign_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Reward campaigns (Atom)" title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" /> href="{% url 'core:reward_campaign_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Reward campaigns (Discord)" title="Reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_discord' %}" /> href="{% url 'core:reward_campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<!-- Campaign Title --> <!-- Campaign Title -->
@ -35,12 +35,12 @@
{% endif %} {% endif %}
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<a href="{% url 'twitch: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 feed for all reward campaigns</a> title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'twitch: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 'twitch: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>
</div> </div>
<!-- Campaign Summary --> <!-- Campaign Summary -->

View file

@ -7,25 +7,25 @@
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Reward campaigns (RSS)" title="Reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" /> href="{% url 'core:reward_campaign_feed' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Reward campaigns (Atom)" title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" /> href="{% url 'core:reward_campaign_feed_atom' %}" />
<link rel="alternate" <link rel="alternate"
type="application/atom+xml" type="application/atom+xml"
title="Reward campaigns (Discord)" title="Reward campaigns (Discord)"
href="{% url 'twitch:reward_campaign_feed_discord' %}" /> href="{% url 'core:reward_campaign_feed_discord' %}" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<h1>Reward Campaigns</h1> <h1>Reward Campaigns</h1>
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div> <div>
<a href="{% url 'twitch:reward_campaign_feed' %}" <a href="{% url 'core:reward_campaign_feed' %}"
title="RSS feed for all reward campaigns">[rss]</a> title="RSS feed for all reward campaigns">[rss]</a>
<a href="{% url 'twitch: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 'twitch: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>
</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>

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}
YouTube Drops Channels
{% endblock title %}
{% block content %}
<main>
<h1>YouTube Drops Channels</h1>
<p>Official channels from YouTube partner accounts where drops/rewards may be available.</p>
{% for group in partner_groups %}
<h2>{{ group.partner }}</h2>
<ul>
{% for item in group.channels %}
<li>
<a href="{{ item.url }}" rel="noopener" target="_blank">{{ item.channel }}</a>
</li>
{% endfor %}
</ul>
{% endfor %}
</main>
{% endblock content %}

View file

@ -7,6 +7,8 @@ Wants=network-online.target
Type=simple Type=simple
User=ttvdrops User=ttvdrops
Group=ttvdrops Group=ttvdrops
SupplementaryGroups=http
UMask=0002
WorkingDirectory=/home/ttvdrops/ttvdrops WorkingDirectory=/home/ttvdrops/ttvdrops
EnvironmentFile=/home/ttvdrops/ttvdrops/.env EnvironmentFile=/home/ttvdrops/ttvdrops/.env
ExecStart=/usr/bin/uv run python manage.py watch_imports /mnt/fourteen/Data/Responses/pending --verbose ExecStart=/usr/bin/uv run python manage.py watch_imports /mnt/fourteen/Data/Responses/pending --verbose

View file

@ -7,6 +7,8 @@ Wants=network-online.target
Type=oneshot Type=oneshot
User=ttvdrops User=ttvdrops
Group=ttvdrops Group=ttvdrops
SupplementaryGroups=http
UMask=0002
WorkingDirectory=/home/ttvdrops/ttvdrops WorkingDirectory=/home/ttvdrops/ttvdrops
EnvironmentFile=/home/ttvdrops/ttvdrops/.env EnvironmentFile=/home/ttvdrops/ttvdrops/.env
ExecStart=/usr/bin/uv run python manage.py import_kick_drops ExecStart=/usr/bin/uv run python manage.py import_kick_drops

View file

@ -177,7 +177,9 @@ class TTVDropsAtomBaseFeed(TTVDropsBaseFeed):
feed_type = BrowserFriendlyAtom1Feed feed_type = BrowserFriendlyAtom1Feed
def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCampaign]: def _with_campaign_related(
queryset: QuerySet[DropCampaign, DropCampaign],
) -> QuerySet[DropCampaign, DropCampaign]:
"""Apply related-selects/prefetches needed by feed rendering to avoid N+1 queries. """Apply related-selects/prefetches needed by feed rendering to avoid N+1 queries.
Returns: Returns:
@ -759,7 +761,7 @@ class OrganizationRSSFeed(TTVDropsBaseFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the absolute URL for this feed.""" """Return the absolute URL for this feed."""
return reverse("twitch:organization_feed") return reverse("core:organization_feed")
# MARK: /rss/games/ # MARK: /rss/games/
@ -829,7 +831,7 @@ class GameFeed(TTVDropsBaseFeed):
# Get the full URL for TTVDrops game detail page # Get the full URL for TTVDrops game detail page
game_url: str = reverse("twitch:game_detail", args=[twitch_id]) game_url: str = reverse("twitch:game_detail", args=[twitch_id])
rss_feed_url: str = reverse("twitch:game_campaign_feed", args=[twitch_id]) rss_feed_url: str = reverse("core:game_campaign_feed", args=[twitch_id])
twitch_directory_url: str = getattr(item, "twitch_directory_url", "") twitch_directory_url: str = getattr(item, "twitch_directory_url", "")
description_parts.append( description_parts.append(
@ -911,7 +913,7 @@ class GameFeed(TTVDropsBaseFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the RSS feed itself.""" """Return the URL to the RSS feed itself."""
return reverse("twitch:game_feed") return reverse("core:game_feed")
# MARK: /rss/campaigns/ # MARK: /rss/campaigns/
@ -1054,7 +1056,7 @@ class DropCampaignFeed(TTVDropsBaseFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the RSS feed itself.""" """Return the URL to the RSS feed itself."""
return reverse("twitch:campaign_feed") return reverse("core:campaign_feed")
# MARK: /rss/games/<twitch_id>/campaigns/ # MARK: /rss/games/<twitch_id>/campaigns/
@ -1230,7 +1232,7 @@ class GameCampaignFeed(TTVDropsBaseFeed):
def feed_url(self, obj: Game) -> str: def feed_url(self, obj: Game) -> str:
"""Return the URL to the RSS feed itself.""" """Return the URL to the RSS feed itself."""
return reverse("twitch:game_campaign_feed", args=[obj.twitch_id]) return reverse("core:game_campaign_feed", args=[obj.twitch_id])
# MARK: /rss/reward-campaigns/ # MARK: /rss/reward-campaigns/
@ -1422,7 +1424,7 @@ class RewardCampaignFeed(TTVDropsBaseFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the RSS feed itself.""" """Return the URL to the RSS feed itself."""
return reverse("twitch:reward_campaign_feed") return reverse("core:reward_campaign_feed")
# Atom feed variants: reuse existing logic but switch the feed generator to Atom # Atom feed variants: reuse existing logic but switch the feed generator to Atom
@ -1433,7 +1435,7 @@ class OrganizationAtomFeed(TTVDropsAtomBaseFeed, OrganizationRSSFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Atom feed itself.""" """Return the URL to the Atom feed itself."""
return reverse("twitch:organization_feed_atom") return reverse("core:organization_feed_atom")
class GameAtomFeed(TTVDropsAtomBaseFeed, GameFeed): class GameAtomFeed(TTVDropsAtomBaseFeed, GameFeed):
@ -1443,7 +1445,7 @@ class GameAtomFeed(TTVDropsAtomBaseFeed, GameFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Atom feed itself.""" """Return the URL to the Atom feed itself."""
return reverse("twitch:game_feed_atom") return reverse("core:game_feed_atom")
class DropCampaignAtomFeed(TTVDropsAtomBaseFeed, DropCampaignFeed): class DropCampaignAtomFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
@ -1453,7 +1455,7 @@ class DropCampaignAtomFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Atom feed itself.""" """Return the URL to the Atom feed itself."""
return reverse("twitch:campaign_feed_atom") return reverse("core:campaign_feed_atom")
class GameCampaignAtomFeed(TTVDropsAtomBaseFeed, GameCampaignFeed): class GameCampaignAtomFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
@ -1461,7 +1463,7 @@ class GameCampaignAtomFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
def feed_url(self, obj: Game) -> str: def feed_url(self, obj: Game) -> str:
"""Return the URL to the Atom feed itself.""" """Return the URL to the Atom feed itself."""
return reverse("twitch:game_campaign_feed_atom", args=[obj.twitch_id]) return reverse("core:game_campaign_feed_atom", args=[obj.twitch_id])
class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed): class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
@ -1471,7 +1473,7 @@ class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Atom feed itself.""" """Return the URL to the Atom feed itself."""
return reverse("twitch:reward_campaign_feed_atom") return reverse("core:reward_campaign_feed_atom")
# Discord feed variants: Atom feeds with Discord relative timestamps # Discord feed variants: Atom feeds with Discord relative timestamps
@ -1482,7 +1484,7 @@ class OrganizationDiscordFeed(TTVDropsAtomBaseFeed, OrganizationRSSFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Discord feed itself.""" """Return the URL to the Discord feed itself."""
return reverse("twitch:organization_feed_discord") return reverse("core:organization_feed_discord")
class GameDiscordFeed(TTVDropsAtomBaseFeed, GameFeed): class GameDiscordFeed(TTVDropsAtomBaseFeed, GameFeed):
@ -1492,7 +1494,7 @@ class GameDiscordFeed(TTVDropsAtomBaseFeed, GameFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Discord feed itself.""" """Return the URL to the Discord feed itself."""
return reverse("twitch:game_feed_discord") return reverse("core:game_feed_discord")
class DropCampaignDiscordFeed(TTVDropsAtomBaseFeed, DropCampaignFeed): class DropCampaignDiscordFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
@ -1515,7 +1517,7 @@ class DropCampaignDiscordFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Discord feed itself.""" """Return the URL to the Discord feed itself."""
return reverse("twitch:campaign_feed_discord") return reverse("core:campaign_feed_discord")
class GameCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameCampaignFeed): class GameCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
@ -1535,7 +1537,7 @@ class GameCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
def feed_url(self, obj: Game) -> str: def feed_url(self, obj: Game) -> str:
"""Return the URL to the Discord feed itself.""" """Return the URL to the Discord feed itself."""
return reverse("twitch:game_campaign_feed_discord", args=[obj.twitch_id]) return reverse("core:game_campaign_feed_discord", args=[obj.twitch_id])
class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed): class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
@ -1602,4 +1604,4 @@ class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
def feed_url(self) -> str: def feed_url(self) -> str:
"""Return the URL to the Discord feed itself.""" """Return the URL to the Discord feed itself."""
return reverse("twitch:reward_campaign_feed_discord") return reverse("core:reward_campaign_feed_discord")

View file

@ -631,6 +631,9 @@ class Command(BaseCommand):
) )
return return
if game_obj.box_art_file is None:
return
game_obj.box_art_file.save(file_name, ContentFile(response.content), save=True) game_obj.box_art_file.save(file_name, ContentFile(response.content), save=True)
def _get_or_create_channel(self, channel_info: ChannelInfoSchema) -> Channel: def _get_or_create_channel(self, channel_info: ChannelInfoSchema) -> Channel:

View file

@ -11,8 +11,8 @@ from twitch.models import Game
from twitch.models import Organization from twitch.models import Organization
if TYPE_CHECKING: if TYPE_CHECKING:
from debug_toolbar.panels.templates.panel import QuerySet
from django.core.management.base import CommandParser from django.core.management.base import CommandParser
from django.db.models import QuerySet
class Command(BaseCommand): class Command(BaseCommand):

View file

@ -5,6 +5,7 @@ from urllib.parse import urlparse
import httpx import httpx
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from PIL import Image from PIL import Image
@ -38,7 +39,7 @@ class Command(BaseCommand):
help="Re-download even if a local box art file already exists.", help="Re-download even if a local box art file already exists.",
) )
def handle(self, *_args: object, **options: object) -> None: def handle(self, *_args: object, **options: object) -> None: # noqa: PLR0914, PLR0915
"""Download Twitch box art images for all games.""" """Download Twitch box art images for all games."""
limit_value: object | None = options.get("limit") limit_value: object | None = options.get("limit")
limit: int | None = limit_value if isinstance(limit_value, int) else None limit: int | None = limit_value if isinstance(limit_value, int) else None
@ -92,6 +93,10 @@ class Command(BaseCommand):
skipped += 1 skipped += 1
continue continue
if game.box_art_file is None:
failed += 1
continue
game.box_art_file.save( game.box_art_file.save(
file_name, file_name,
ContentFile(response.content), ContentFile(response.content),
@ -99,7 +104,9 @@ class Command(BaseCommand):
) )
# Auto-convert to WebP and AVIF # Auto-convert to WebP and AVIF
self._convert_to_modern_formats(game.box_art_file.path) box_art_path: str | None = getattr(game.box_art_file, "path", None)
if box_art_path:
self._convert_to_modern_formats(box_art_path)
downloaded += 1 downloaded += 1
@ -112,6 +119,15 @@ class Command(BaseCommand):
box_art_dir: Path = Path(settings.MEDIA_ROOT) / "games" / "box_art" box_art_dir: Path = Path(settings.MEDIA_ROOT) / "games" / "box_art"
self.stdout.write(self.style.SUCCESS(f"Saved box art to: {box_art_dir}")) self.stdout.write(self.style.SUCCESS(f"Saved box art to: {box_art_dir}"))
# Convert downloaded images to modern formats (WebP, AVIF)
if downloaded > 0:
self.stdout.write(
self.style.MIGRATE_HEADING(
"\nConverting downloaded images to modern formats...",
),
)
call_command("convert_images_to_modern_formats")
def _convert_to_modern_formats(self, image_path: str) -> None: def _convert_to_modern_formats(self, image_path: str) -> None:
"""Convert downloaded image to WebP and AVIF formats. """Convert downloaded image to WebP and AVIF formats.

View file

@ -7,6 +7,7 @@ from urllib.parse import urlparse
import httpx import httpx
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from PIL import Image from PIL import Image
@ -20,6 +21,7 @@ if TYPE_CHECKING:
from django.core.management.base import CommandParser from django.core.management.base import CommandParser
from django.db.models import QuerySet from django.db.models import QuerySet
from django.db.models.fields.files import FieldFile from django.db.models.fields.files import FieldFile
from PIL.ImageFile import ImageFile
class Command(BaseCommand): class Command(BaseCommand):
@ -68,7 +70,7 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
self.style.MIGRATE_HEADING("\nProcessing Drop Campaigns..."), self.style.MIGRATE_HEADING("\nProcessing Drop Campaigns..."),
) )
stats = self._download_campaign_images( stats: dict[str, int] = self._download_campaign_images(
client=client, client=client,
limit=limit, limit=limit,
force=force, force=force,
@ -112,6 +114,15 @@ class Command(BaseCommand):
), ),
) )
# Convert downloaded images to modern formats (WebP, AVIF)
if total_stats["downloaded"] > 0:
self.stdout.write(
self.style.MIGRATE_HEADING(
"\nConverting downloaded images to modern formats...",
),
)
call_command("convert_images_to_modern_formats")
def _download_campaign_images( def _download_campaign_images(
self, self,
client: httpx.Client, client: httpx.Client,
@ -151,7 +162,7 @@ class Command(BaseCommand):
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
result = self._download_image( result: str = self._download_image(
client, client,
campaign.image_url, campaign.image_url,
campaign.twitch_id, campaign.twitch_id,
@ -200,7 +211,7 @@ class Command(BaseCommand):
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
result = self._download_image( result: str = self._download_image(
client, client,
benefit.image_asset_url, benefit.image_asset_url,
benefit.twitch_id, benefit.twitch_id,
@ -249,7 +260,7 @@ class Command(BaseCommand):
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
result = self._download_image( result: str = self._download_image(
client, client,
reward_campaign.image_url, reward_campaign.image_url,
reward_campaign.twitch_id, reward_campaign.twitch_id,
@ -264,7 +275,7 @@ class Command(BaseCommand):
client: httpx.Client, client: httpx.Client,
image_url: str, image_url: str,
twitch_id: str, twitch_id: str,
file_field: FieldFile, file_field: FieldFile | None,
) -> str: ) -> str:
"""Download a single image and save it to the file field. """Download a single image and save it to the file field.
@ -281,6 +292,9 @@ class Command(BaseCommand):
suffix: str = Path(parsed_url.path).suffix or ".jpg" suffix: str = Path(parsed_url.path).suffix or ".jpg"
file_name: str = f"{twitch_id}{suffix}" file_name: str = f"{twitch_id}{suffix}"
if file_field is None:
return "failed"
try: try:
response: httpx.Response = client.get(image_url) response: httpx.Response = client.get(image_url)
response.raise_for_status() response.raise_for_status()
@ -299,7 +313,9 @@ class Command(BaseCommand):
file_field.save(file_name, ContentFile(response.content), save=True) file_field.save(file_name, ContentFile(response.content), save=True)
# Auto-convert to WebP and AVIF # Auto-convert to WebP and AVIF
self._convert_to_modern_formats(file_field.path) image_path: str | None = getattr(file_field, "path", None)
if image_path:
self._convert_to_modern_formats(image_path)
return "downloaded" return "downloaded"
@ -320,17 +336,19 @@ class Command(BaseCommand):
}: }:
return return
base_path = source_path.with_suffix("") base_path: Path = source_path.with_suffix("")
webp_path = base_path.with_suffix(".webp") webp_path: Path = base_path.with_suffix(".webp")
avif_path = base_path.with_suffix(".avif") avif_path: Path = base_path.with_suffix(".avif")
with Image.open(source_path) as img: with Image.open(source_path) as img:
# Convert to RGB if needed # Convert to RGB if needed
if img.mode in {"RGBA", "LA"} or ( if img.mode in {"RGBA", "LA"} or (
img.mode == "P" and "transparency" in img.info img.mode == "P" and "transparency" in img.info
): ):
background = Image.new("RGB", img.size, (255, 255, 255)) background: Image = Image.new("RGB", img.size, (255, 255, 255))
rgba_img = img.convert("RGBA") if img.mode == "P" else img rgba_img: Image | ImageFile = (
img.convert("RGBA") if img.mode == "P" else img
)
background.paste( background.paste(
rgba_img, rgba_img,
mask=rgba_img.split()[-1] mask=rgba_img.split()[-1]
@ -339,9 +357,9 @@ class Command(BaseCommand):
) )
rgb_img = background rgb_img = background
elif img.mode != "RGB": elif img.mode != "RGB":
rgb_img = img.convert("RGB") rgb_img: Image = img.convert("RGB")
else: else:
rgb_img = img rgb_img: ImageFile = img
# Save WebP # Save WebP
rgb_img.save(webp_path, "WEBP", quality=85, method=6) rgb_img.save(webp_path, "WEBP", quality=85, method=6)
@ -372,11 +390,11 @@ class Command(BaseCommand):
), ),
) )
if stats["downloaded"] > 0: if stats["downloaded"] > 0:
media_path = Path(settings.MEDIA_ROOT) media_path: Path = Path(settings.MEDIA_ROOT)
if "Campaigns" in model_name and "Reward" not in model_name: if "Campaigns" in model_name and "Reward" not in model_name:
image_dir = media_path / "campaigns" / "images" image_dir: Path = media_path / "campaigns" / "images"
elif "Benefits" in model_name: elif "Benefits" in model_name:
image_dir = media_path / "benefits" / "images" image_dir: Path = media_path / "benefits" / "images"
else: else:
image_dir = media_path / "reward_campaigns" / "images" image_dir: Path = media_path / "reward_campaigns" / "images"
self.stdout.write(self.style.SUCCESS(f"Saved images to: {image_dir}")) self.stdout.write(self.style.SUCCESS(f"Saved images to: {image_dir}"))

View file

@ -288,7 +288,7 @@ class TestDatasetBackupViews:
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent) monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backups"), reverse("core:dataset_backups"),
) )
assert response.status_code == 200 assert response.status_code == 200
@ -305,7 +305,7 @@ class TestDatasetBackupViews:
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent) monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backups"), reverse("core:dataset_backups"),
) )
assert response.status_code == 200 assert response.status_code == 200
@ -339,7 +339,7 @@ class TestDatasetBackupViews:
os.utime(newer_backup, (newer_time, newer_time)) os.utime(newer_backup, (newer_time, newer_time))
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backups"), reverse("core:dataset_backups"),
) )
content = response.content.decode() content = response.content.decode()
@ -361,7 +361,7 @@ class TestDatasetBackupViews:
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse( reverse(
"twitch:dataset_backup_download", "core:dataset_backup_download",
args=["ttvdrops-20260210-120000.sql.zst"], args=["ttvdrops-20260210-120000.sql.zst"],
), ),
) )
@ -382,7 +382,7 @@ class TestDatasetBackupViews:
# Attempt path traversal # Attempt path traversal
response = client.get( response = client.get(
reverse("twitch:dataset_backup_download", args=["../../../etc/passwd"]), reverse("core:dataset_backup_download", args=["../../../etc/passwd"]),
) )
assert response.status_code == 404 assert response.status_code == 404
@ -400,7 +400,7 @@ class TestDatasetBackupViews:
invalid_file.write_text("not a backup") invalid_file.write_text("not a backup")
response = client.get( response = client.get(
reverse("twitch:dataset_backup_download", args=["malicious.exe"]), reverse("core:dataset_backup_download", args=["malicious.exe"]),
) )
assert response.status_code == 404 assert response.status_code == 404
@ -414,7 +414,7 @@ class TestDatasetBackupViews:
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent) monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
response = client.get( response = client.get(
reverse("twitch:dataset_backup_download", args=["nonexistent.sql.zst"]), reverse("core:dataset_backup_download", args=["nonexistent.sql.zst"]),
) )
assert response.status_code == 404 assert response.status_code == 404
@ -429,7 +429,7 @@ class TestDatasetBackupViews:
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent) monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backups"), reverse("core:dataset_backups"),
) )
assert response.status_code == 200 assert response.status_code == 200
@ -452,7 +452,7 @@ class TestDatasetBackupViews:
(datasets_dir / "old_backup.gz").write_bytes(b"should be ignored") (datasets_dir / "old_backup.gz").write_bytes(b"should be ignored")
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backups"), reverse("core:dataset_backups"),
) )
content = response.content.decode() content = response.content.decode()
@ -481,7 +481,7 @@ class TestDatasetBackupViews:
handle.write("-- Test\n") handle.write("-- Test\n")
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backup_download", args=["2026/02/backup.sql.zst"]), reverse("core:dataset_backup_download", args=["2026/02/backup.sql.zst"]),
) )
assert response.status_code == 200 assert response.status_code == 200

View file

@ -155,7 +155,7 @@ class TestBadgeSearch:
ChatBadgeSet.objects.create(set_id="vip") ChatBadgeSet.objects.create(set_id="vip")
ChatBadgeSet.objects.create(set_id="subscriber") ChatBadgeSet.objects.create(set_id="subscriber")
response = client.get(reverse("twitch:search"), {"q": "vip"}) response = client.get(reverse("core:search"), {"q": "vip"})
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
@ -175,7 +175,7 @@ class TestBadgeSearch:
description="Test description", description="Test description",
) )
response = client.get(reverse("twitch:search"), {"q": "Moderator"}) response = client.get(reverse("core:search"), {"q": "Moderator"})
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
@ -195,7 +195,7 @@ class TestBadgeSearch:
description="Unique description text", description="Unique description text",
) )
response = client.get(reverse("twitch:search"), {"q": "Unique description"}) response = client.get(reverse("core:search"), {"q": "Unique description"})
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()

View file

@ -44,7 +44,7 @@ class ExportViewsTestCase(TestCase):
def test_export_campaigns_csv(self) -> None: def test_export_campaigns_csv(self) -> None:
"""Test CSV export of campaigns.""" """Test CSV export of campaigns."""
response = self.client.get("/export/campaigns/csv/") response = self.client.get("/twitch/export/campaigns/csv/")
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "text/csv" assert response["Content-Type"] == "text/csv"
assert b"Twitch ID" in response.content assert b"Twitch ID" in response.content
@ -53,7 +53,7 @@ class ExportViewsTestCase(TestCase):
def test_export_campaigns_json(self) -> None: def test_export_campaigns_json(self) -> None:
"""Test JSON export of campaigns.""" """Test JSON export of campaigns."""
response = self.client.get("/export/campaigns/json/") response = self.client.get("/twitch/export/campaigns/json/")
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/json" assert response["Content-Type"] == "application/json"
@ -66,7 +66,7 @@ class ExportViewsTestCase(TestCase):
def test_export_games_csv(self) -> None: def test_export_games_csv(self) -> None:
"""Test CSV export of games.""" """Test CSV export of games."""
response = self.client.get("/export/games/csv/") response = self.client.get("/twitch/export/games/csv/")
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "text/csv" assert response["Content-Type"] == "text/csv"
assert b"Twitch ID" in response.content assert b"Twitch ID" in response.content
@ -75,7 +75,7 @@ class ExportViewsTestCase(TestCase):
def test_export_games_json(self) -> None: def test_export_games_json(self) -> None:
"""Test JSON export of games.""" """Test JSON export of games."""
response = self.client.get("/export/games/json/") response = self.client.get("/twitch/export/games/json/")
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/json" assert response["Content-Type"] == "application/json"
@ -87,7 +87,7 @@ class ExportViewsTestCase(TestCase):
def test_export_organizations_csv(self) -> None: def test_export_organizations_csv(self) -> None:
"""Test CSV export of organizations.""" """Test CSV export of organizations."""
response = self.client.get("/export/organizations/csv/") response = self.client.get("/twitch/export/organizations/csv/")
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "text/csv" assert response["Content-Type"] == "text/csv"
assert b"Twitch ID" in response.content assert b"Twitch ID" in response.content
@ -96,7 +96,7 @@ class ExportViewsTestCase(TestCase):
def test_export_organizations_json(self) -> None: def test_export_organizations_json(self) -> None:
"""Test JSON export of organizations.""" """Test JSON export of organizations."""
response = self.client.get("/export/organizations/json/") response = self.client.get("/twitch/export/organizations/json/")
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/json" assert response["Content-Type"] == "application/json"
@ -108,13 +108,13 @@ class ExportViewsTestCase(TestCase):
def test_export_campaigns_csv_with_filters(self) -> None: def test_export_campaigns_csv_with_filters(self) -> None:
"""Test CSV export of campaigns with status filter.""" """Test CSV export of campaigns with status filter."""
response = self.client.get("/export/campaigns/csv/?status=active") response = self.client.get("/twitch/export/campaigns/csv/?status=active")
assert response.status_code == 200 assert response.status_code == 200
assert b"campaign123" in response.content assert b"campaign123" in response.content
def test_export_campaigns_json_with_filters(self) -> None: def test_export_campaigns_json_with_filters(self) -> None:
"""Test JSON export of campaigns with status filter.""" """Test JSON export of campaigns with status filter."""
response = self.client.get("/export/campaigns/json/?status=active") response = self.client.get("/twitch/export/campaigns/json/?status=active")
assert response.status_code == 200 assert response.status_code == 200
data = json.loads(response.content) data = json.loads(response.content)

View file

@ -106,7 +106,7 @@ class RSSFeedTestCase(TestCase):
def test_organization_feed(self) -> None: def test_organization_feed(self) -> None:
"""Test organization feed returns 200.""" """Test organization feed returns 200."""
url: str = reverse("twitch:organization_feed") url: str = reverse("core:organization_feed")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -114,7 +114,7 @@ class RSSFeedTestCase(TestCase):
def test_game_feed(self) -> None: def test_game_feed(self) -> None:
"""Test game feed returns 200.""" """Test game feed returns 200."""
url: str = reverse("twitch:game_feed") url: str = reverse("core:game_feed")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -123,7 +123,7 @@ class RSSFeedTestCase(TestCase):
assert "Owned by Test Organization." in content assert "Owned by Test Organization." in content
expected_rss_link: str = reverse( expected_rss_link: str = reverse(
"twitch:game_campaign_feed", "core:game_campaign_feed",
args=[self.game.twitch_id], args=[self.game.twitch_id],
) )
assert expected_rss_link in content assert expected_rss_link in content
@ -137,7 +137,7 @@ class RSSFeedTestCase(TestCase):
def test_organization_atom_feed(self) -> None: def test_organization_atom_feed(self) -> None:
"""Test organization Atom feed returns 200 and Atom XML.""" """Test organization Atom feed returns 200 and Atom XML."""
url: str = reverse("twitch:organization_feed_atom") url: str = reverse("core:organization_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
msg: str = f"Expected 200 OK, got {response.status_code} with content: {response.content.decode('utf-8')}" msg: str = f"Expected 200 OK, got {response.status_code} with content: {response.content.decode('utf-8')}"
@ -151,14 +151,14 @@ class RSSFeedTestCase(TestCase):
def test_game_atom_feed(self) -> None: def test_game_atom_feed(self) -> None:
"""Test game Atom feed returns 200 and contains expected content.""" """Test game Atom feed returns 200 and contains expected content."""
url: str = reverse("twitch:game_feed_atom") url: str = reverse("core:game_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
assert "Owned by Test Organization." in content assert "Owned by Test Organization." in content
expected_atom_link: str = reverse( expected_atom_link: str = reverse(
"twitch:game_campaign_feed", "core:game_campaign_feed",
args=[self.game.twitch_id], args=[self.game.twitch_id],
) )
assert expected_atom_link in content assert expected_atom_link in content
@ -167,7 +167,7 @@ class RSSFeedTestCase(TestCase):
def test_campaign_atom_feed_uses_url_ids_and_correct_self_link(self) -> None: def test_campaign_atom_feed_uses_url_ids_and_correct_self_link(self) -> None:
"""Atom campaign feed should use URL ids and a matching self link.""" """Atom campaign feed should use URL ids and a matching self link."""
url: str = reverse("twitch:campaign_feed_atom") url: str = reverse("core:campaign_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -180,33 +180,35 @@ class RSSFeedTestCase(TestCase):
assert 'href="http://testserver/atom/campaigns/"' in content, msg assert 'href="http://testserver/atom/campaigns/"' in content, msg
msg: str = f"Expected entry ID to be the campaign URL, got: {content}" msg: str = f"Expected entry ID to be the campaign URL, got: {content}"
assert "<id>http://testserver/campaigns/test-campaign-123/</id>" in content, msg assert (
"<id>http://testserver/twitch/campaigns/test-campaign-123/</id>" in content
), msg
def test_all_atom_feeds_use_url_ids_and_correct_self_links(self) -> None: def test_all_atom_feeds_use_url_ids_and_correct_self_links(self) -> None:
"""All Atom feeds should use absolute URL entry IDs and matching self links.""" """All Atom feeds should use absolute URL entry IDs and matching self links."""
atom_feed_cases: list[tuple[str, dict[str, str], str]] = [ atom_feed_cases: list[tuple[str, dict[str, str], str]] = [
( (
"twitch:campaign_feed_atom", "core:campaign_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"twitch:game_feed_atom", "core:game_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}", f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}",
), ),
( (
"twitch:game_campaign_feed_atom", "core:game_campaign_feed_atom",
{"twitch_id": self.game.twitch_id}, {"twitch_id": self.game.twitch_id},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"twitch:organization_feed_atom", "core:organization_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}", f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}",
), ),
( (
"twitch:reward_campaign_feed_atom", "core:reward_campaign_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}", f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
), ),
@ -246,7 +248,7 @@ class RSSFeedTestCase(TestCase):
) )
drop.benefits.add(benefit) drop.benefits.add(benefit)
url: str = reverse("twitch:campaign_feed_atom") url: str = reverse("core:campaign_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -257,11 +259,11 @@ class RSSFeedTestCase(TestCase):
def test_atom_feeds_include_stylesheet_processing_instruction(self) -> None: def test_atom_feeds_include_stylesheet_processing_instruction(self) -> None:
"""Atom feeds should include an xml-stylesheet processing instruction.""" """Atom feeds should include an xml-stylesheet processing instruction."""
feed_urls: list[str] = [ feed_urls: list[str] = [
reverse("twitch:campaign_feed_atom"), reverse("core:campaign_feed_atom"),
reverse("twitch:game_feed_atom"), reverse("core:game_feed_atom"),
reverse("twitch:game_campaign_feed_atom", args=[self.game.twitch_id]), reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
reverse("twitch:organization_feed_atom"), reverse("core:organization_feed_atom"),
reverse("twitch:reward_campaign_feed_atom"), reverse("core:reward_campaign_feed_atom"),
] ]
for url in feed_urls: for url in feed_urls:
@ -277,6 +279,7 @@ class RSSFeedTestCase(TestCase):
def test_campaign_and_game_feeds_use_absolute_media_enclosure_urls(self) -> None: def test_campaign_and_game_feeds_use_absolute_media_enclosure_urls(self) -> None:
"""Campaign/game RSS+Atom enclosures should use absolute URLs for local media files.""" """Campaign/game RSS+Atom enclosures should use absolute URLs for local media files."""
self.game.box_art = "" self.game.box_art = ""
assert self.game.box_art_file is not None
self.game.box_art_file.save( self.game.box_art_file.save(
"box.png", "box.png",
ContentFile(b"game-image-bytes"), ContentFile(b"game-image-bytes"),
@ -287,6 +290,7 @@ class RSSFeedTestCase(TestCase):
self.game.save() self.game.save()
self.campaign.image_url = "" self.campaign.image_url = ""
assert self.campaign.image_file is not None
self.campaign.image_file.save( self.campaign.image_file.save(
"campaign.png", "campaign.png",
ContentFile(b"campaign-image-bytes"), ContentFile(b"campaign-image-bytes"),
@ -297,12 +301,12 @@ class RSSFeedTestCase(TestCase):
self.campaign.save() self.campaign.save()
feed_urls: list[str] = [ feed_urls: list[str] = [
reverse("twitch:game_feed"), reverse("core:game_feed"),
reverse("twitch:campaign_feed"), reverse("core:campaign_feed"),
reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]), reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("twitch:game_feed_atom"), reverse("core:game_feed_atom"),
reverse("twitch:campaign_feed_atom"), reverse("core:campaign_feed_atom"),
reverse("twitch:game_campaign_feed_atom", args=[self.game.twitch_id]), reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
] ]
for url in feed_urls: for url in feed_urls:
@ -333,14 +337,14 @@ class RSSFeedTestCase(TestCase):
self.reward_campaign.save() self.reward_campaign.save()
feed_urls: list[str] = [ feed_urls: list[str] = [
reverse("twitch:game_feed"), reverse("core:game_feed"),
reverse("twitch:campaign_feed"), reverse("core:campaign_feed"),
reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]), reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("twitch:reward_campaign_feed"), reverse("core:reward_campaign_feed"),
reverse("twitch:game_feed_atom"), reverse("core:game_feed_atom"),
reverse("twitch:campaign_feed_atom"), reverse("core:campaign_feed_atom"),
reverse("twitch:game_campaign_feed_atom", args=[self.game.twitch_id]), reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
reverse("twitch:reward_campaign_feed_atom"), reverse("core:reward_campaign_feed_atom"),
] ]
for url in feed_urls: for url in feed_urls:
@ -378,7 +382,7 @@ class RSSFeedTestCase(TestCase):
def test_campaign_feed(self) -> None: def test_campaign_feed(self) -> None:
"""Test campaign feed returns 200.""" """Test campaign feed returns 200."""
url: str = reverse("twitch:campaign_feed") url: str = reverse("core:campaign_feed")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -392,11 +396,11 @@ class RSSFeedTestCase(TestCase):
def test_rss_feeds_include_stylesheet_processing_instruction(self) -> None: def test_rss_feeds_include_stylesheet_processing_instruction(self) -> None:
"""RSS feeds should include an xml-stylesheet processing instruction.""" """RSS feeds should include an xml-stylesheet processing instruction."""
feed_urls: list[str] = [ feed_urls: list[str] = [
reverse("twitch:campaign_feed"), reverse("core:campaign_feed"),
reverse("twitch:game_feed"), reverse("core:game_feed"),
reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]), reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("twitch:organization_feed"), reverse("core:organization_feed"),
reverse("twitch:reward_campaign_feed"), reverse("core:reward_campaign_feed"),
] ]
for url in feed_urls: for url in feed_urls:
@ -443,11 +447,11 @@ class RSSFeedTestCase(TestCase):
def test_rss_feeds_include_shared_metadata_fields(self) -> None: def test_rss_feeds_include_shared_metadata_fields(self) -> None:
"""RSS output should contain base feed metadata fields.""" """RSS output should contain base feed metadata fields."""
feed_urls: list[str] = [ feed_urls: list[str] = [
reverse("twitch:campaign_feed"), reverse("core:campaign_feed"),
reverse("twitch:game_feed"), reverse("core:game_feed"),
reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]), reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("twitch:organization_feed"), reverse("core:organization_feed"),
reverse("twitch:reward_campaign_feed"), reverse("core:reward_campaign_feed"),
] ]
for url in feed_urls: for url in feed_urls:
@ -480,7 +484,7 @@ class RSSFeedTestCase(TestCase):
operation_names=["DropCampaignDetails"], operation_names=["DropCampaignDetails"],
) )
url: str = reverse("twitch:campaign_feed") url: str = reverse("core:campaign_feed")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
@ -539,7 +543,7 @@ class RSSFeedTestCase(TestCase):
description="This badge was earned by subscribing.", description="This badge was earned by subscribing.",
) )
url: str = reverse("twitch:campaign_feed") url: str = reverse("core:campaign_feed")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
@ -547,7 +551,7 @@ class RSSFeedTestCase(TestCase):
def test_game_campaign_feed(self) -> None: def test_game_campaign_feed(self) -> None:
"""Test game-specific campaign feed returns 200.""" """Test game-specific campaign feed returns 200."""
url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) url: str = reverse("core:game_campaign_feed", args=[self.game.twitch_id])
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -576,7 +580,7 @@ class RSSFeedTestCase(TestCase):
) )
# Get feed for first game # Get feed for first game
url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) url: str = reverse("core:game_campaign_feed", args=[self.game.twitch_id])
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
@ -609,7 +613,7 @@ class RSSFeedTestCase(TestCase):
operation_names=["DropCampaignDetails"], operation_names=["DropCampaignDetails"],
) )
url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) url: str = reverse("core:game_campaign_feed", args=[self.game.twitch_id])
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
@ -664,7 +668,7 @@ class RSSFeedTestCase(TestCase):
game=self.game, game=self.game,
) )
url: str = reverse("twitch:reward_campaign_feed") url: str = reverse("core:reward_campaign_feed")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
@ -710,6 +714,7 @@ class RSSFeedTestCase(TestCase):
name="File Game", name="File Game",
display_name="File Game", display_name="File Game",
) )
assert game2.box_art_file is not None
game2.box_art_file.save("sample.png", ContentFile(b"hello")) game2.box_art_file.save("sample.png", ContentFile(b"hello"))
game2.save() game2.save()
@ -721,6 +726,7 @@ class RSSFeedTestCase(TestCase):
end_at=timezone.now() + timedelta(days=1), end_at=timezone.now() + timedelta(days=1),
operation_names=["DropCampaignDetails"], operation_names=["DropCampaignDetails"],
) )
assert campaign2.image_file is not None
campaign2.image_file.save("camp.jpg", ContentFile(b"world")) campaign2.image_file.save("camp.jpg", ContentFile(b"world"))
campaign2.save() campaign2.save()
@ -855,7 +861,7 @@ def test_campaign_feed_queries_bounded(
for i in range(3): for i in range(3):
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("twitch:campaign_feed") url: str = reverse("core:campaign_feed")
# TODO(TheLovinator): 14 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003 # TODO(TheLovinator): 14 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003
with django_assert_num_queries(14, exact=False): with django_assert_num_queries(14, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -911,7 +917,7 @@ def test_campaign_feed_queries_do_not_scale_with_items(
) )
drop.benefits.add(benefit) drop.benefits.add(benefit)
url: str = reverse("twitch:campaign_feed") url: str = reverse("core:campaign_feed")
# N+1 safeguard: query count should not scale linearly with campaign count. # N+1 safeguard: query count should not scale linearly with campaign count.
with django_assert_num_queries(40, exact=False): with django_assert_num_queries(40, exact=False):
@ -941,7 +947,7 @@ def test_game_campaign_feed_queries_bounded(
for i in range(3): for i in range(3):
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id]) url: str = reverse("core:game_campaign_feed", args=[game.twitch_id])
with django_assert_num_queries(6, exact=False): with django_assert_num_queries(6, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -970,7 +976,7 @@ def test_game_campaign_feed_queries_do_not_scale_with_items(
for i in range(50): for i in range(50):
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id]) url: str = reverse("core:game_campaign_feed", args=[game.twitch_id])
with django_assert_num_queries(6, exact=False): with django_assert_num_queries(6, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -987,7 +993,7 @@ def test_organization_feed_queries_bounded(
for i in range(5): for i in range(5):
Organization.objects.create(twitch_id=f"org-feed-{i}", name=f"Org Feed {i}") Organization.objects.create(twitch_id=f"org-feed-{i}", name=f"Org Feed {i}")
url: str = reverse("twitch:organization_feed") url: str = reverse("core:organization_feed")
with django_assert_num_queries(1, exact=True): with django_assert_num_queries(1, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1014,7 +1020,7 @@ def test_game_feed_queries_bounded(
) )
game.owners.add(org) game.owners.add(org)
url: str = reverse("twitch:game_feed") url: str = reverse("core:game_feed")
# One query for games + one prefetch query for owners. # One query for games + one prefetch query for owners.
with django_assert_num_queries(2, exact=True): with django_assert_num_queries(2, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1043,7 +1049,7 @@ def test_reward_campaign_feed_queries_bounded(
for i in range(3): for i in range(3):
_build_reward_campaign(game, i) _build_reward_campaign(game, i)
url: str = reverse("twitch:reward_campaign_feed") url: str = reverse("core:reward_campaign_feed")
with django_assert_num_queries(1, exact=True): with django_assert_num_queries(1, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1076,7 +1082,7 @@ def test_docs_rss_queries_bounded(
_build_campaign(game, i) _build_campaign(game, i)
_build_reward_campaign(game, i) _build_reward_campaign(game, i)
url: str = reverse("twitch:docs_rss") url: str = reverse("core:docs_rss")
# TODO(TheLovinator): 31 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003 # TODO(TheLovinator): 31 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003
with django_assert_num_queries(31, exact=False): with django_assert_num_queries(31, exact=False):
@ -1093,8 +1099,8 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("twitch:campaign_detail", {"twitch_id": "test-campaign-123"}), ("twitch:campaign_detail", {"twitch_id": "test-campaign-123"}),
("twitch:channel_list", {}), ("twitch:channel_list", {}),
("twitch:channel_detail", {"twitch_id": "test-channel-123"}), ("twitch:channel_detail", {"twitch_id": "test-channel-123"}),
("twitch:debug", {}), ("core:debug", {}),
("twitch:docs_rss", {}), ("core:docs_rss", {}),
("twitch:emote_gallery", {}), ("twitch:emote_gallery", {}),
("twitch:games_grid", {}), ("twitch:games_grid", {}),
("twitch:games_list", {}), ("twitch:games_list", {}),
@ -1103,12 +1109,12 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("twitch:organization_detail", {"twitch_id": "test-org-123"}), ("twitch:organization_detail", {"twitch_id": "test-org-123"}),
("twitch:reward_campaign_list", {}), ("twitch:reward_campaign_list", {}),
("twitch:reward_campaign_detail", {"twitch_id": "test-reward-123"}), ("twitch:reward_campaign_detail", {"twitch_id": "test-reward-123"}),
("twitch:search", {}), ("core:search", {}),
("twitch:campaign_feed", {}), ("core:campaign_feed", {}),
("twitch:game_feed", {}), ("core:game_feed", {}),
("twitch:game_campaign_feed", {"twitch_id": "test-game-123"}), ("core:game_campaign_feed", {"twitch_id": "test-game-123"}),
("twitch:organization_feed", {}), ("core:organization_feed", {}),
("twitch:reward_campaign_feed", {}), ("core:reward_campaign_feed", {}),
] ]
@ -1251,7 +1257,7 @@ class DiscordFeedTestCase(TestCase):
def test_organization_discord_feed(self) -> None: def test_organization_discord_feed(self) -> None:
"""Test organization Discord feed returns 200.""" """Test organization Discord feed returns 200."""
url: str = reverse("twitch:organization_feed_discord") url: str = reverse("core:organization_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -1262,7 +1268,7 @@ class DiscordFeedTestCase(TestCase):
def test_game_discord_feed(self) -> None: def test_game_discord_feed(self) -> None:
"""Test game Discord feed returns 200.""" """Test game Discord feed returns 200."""
url: str = reverse("twitch:game_feed_discord") url: str = reverse("core:game_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -1272,7 +1278,7 @@ class DiscordFeedTestCase(TestCase):
def test_campaign_discord_feed(self) -> None: def test_campaign_discord_feed(self) -> None:
"""Test campaign Discord feed returns 200 with Discord timestamps.""" """Test campaign Discord feed returns 200 with Discord timestamps."""
url: str = reverse("twitch:campaign_feed_discord") url: str = reverse("core:campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -1286,7 +1292,7 @@ class DiscordFeedTestCase(TestCase):
def test_game_campaign_discord_feed(self) -> None: def test_game_campaign_discord_feed(self) -> None:
"""Test game-specific campaign Discord feed returns 200.""" """Test game-specific campaign Discord feed returns 200."""
url: str = reverse( url: str = reverse(
"twitch:game_campaign_feed_discord", "core:game_campaign_feed_discord",
args=[self.game.twitch_id], args=[self.game.twitch_id],
) )
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
@ -1298,7 +1304,7 @@ class DiscordFeedTestCase(TestCase):
def test_reward_campaign_discord_feed(self) -> None: def test_reward_campaign_discord_feed(self) -> None:
"""Test reward campaign Discord feed returns 200.""" """Test reward campaign Discord feed returns 200."""
url: str = reverse("twitch:reward_campaign_feed_discord") url: str = reverse("core:reward_campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Type"] == "application/xml; charset=utf-8"
@ -1313,27 +1319,27 @@ class DiscordFeedTestCase(TestCase):
"""All Discord feeds should use absolute URL entry IDs and matching self links.""" """All Discord feeds should use absolute URL entry IDs and matching self links."""
discord_feed_cases: list[tuple[str, dict[str, str], str]] = [ discord_feed_cases: list[tuple[str, dict[str, str], str]] = [
( (
"twitch:campaign_feed_discord", "core:campaign_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"twitch:game_feed_discord", "core:game_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}", f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}",
), ),
( (
"twitch:game_campaign_feed_discord", "core:game_campaign_feed_discord",
{"twitch_id": self.game.twitch_id}, {"twitch_id": self.game.twitch_id},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"twitch:organization_feed_discord", "core:organization_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}", f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}",
), ),
( (
"twitch:reward_campaign_feed_discord", "core:reward_campaign_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}", f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
), ),
@ -1359,11 +1365,11 @@ class DiscordFeedTestCase(TestCase):
def test_discord_feeds_include_stylesheet_processing_instruction(self) -> None: def test_discord_feeds_include_stylesheet_processing_instruction(self) -> None:
"""Discord feeds should include an xml-stylesheet processing instruction.""" """Discord feeds should include an xml-stylesheet processing instruction."""
feed_urls: list[str] = [ feed_urls: list[str] = [
reverse("twitch:campaign_feed_discord"), reverse("core:campaign_feed_discord"),
reverse("twitch:game_feed_discord"), reverse("core:game_feed_discord"),
reverse("twitch:game_campaign_feed_discord", args=[self.game.twitch_id]), reverse("core:game_campaign_feed_discord", args=[self.game.twitch_id]),
reverse("twitch:organization_feed_discord"), reverse("core:organization_feed_discord"),
reverse("twitch:reward_campaign_feed_discord"), reverse("core:reward_campaign_feed_discord"),
] ]
for url in feed_urls: for url in feed_urls:
@ -1378,7 +1384,7 @@ class DiscordFeedTestCase(TestCase):
def test_discord_campaign_feed_contains_discord_timestamps(self) -> None: def test_discord_campaign_feed_contains_discord_timestamps(self) -> None:
"""Discord campaign feed should contain Discord relative timestamps.""" """Discord campaign feed should contain Discord relative timestamps."""
url: str = reverse("twitch:campaign_feed_discord") url: str = reverse("core:campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
@ -1392,7 +1398,7 @@ class DiscordFeedTestCase(TestCase):
def test_discord_reward_campaign_feed_contains_discord_timestamps(self) -> None: def test_discord_reward_campaign_feed_contains_discord_timestamps(self) -> None:
"""Discord reward campaign feed should contain Discord relative timestamps.""" """Discord reward campaign feed should contain Discord relative timestamps."""
url: str = reverse("twitch:reward_campaign_feed_discord") url: str = reverse("core:reward_campaign_feed_discord")
response: _MonkeyPatchedWSGIResponse = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")

View file

@ -327,7 +327,7 @@ class TestChannelListView:
def test_channel_list_loads(self, client: Client) -> None: def test_channel_list_loads(self, client: Client) -> None:
"""Test that channel list view loads successfully.""" """Test that channel list view loads successfully."""
response: _MonkeyPatchedWSGIResponse = client.get("/channels/") response: _MonkeyPatchedWSGIResponse = client.get("/twitch/channels/")
assert response.status_code == 200 assert response.status_code == 200
def test_campaign_count_annotation( def test_campaign_count_annotation(
@ -342,7 +342,7 @@ class TestChannelListView:
channel: Channel = channel_with_campaigns["channel"] # type: ignore[assignment] channel: Channel = channel_with_campaigns["channel"] # type: ignore[assignment]
campaigns: list[DropCampaign] = channel_with_campaigns["campaigns"] # type: ignore[assignment] campaigns: list[DropCampaign] = channel_with_campaigns["campaigns"] # type: ignore[assignment]
response: _MonkeyPatchedWSGIResponse = client.get("/channels/") response: _MonkeyPatchedWSGIResponse = client.get("/twitch/channels/")
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list): if isinstance(context, list):
context = context[-1] context = context[-1]
@ -375,7 +375,7 @@ class TestChannelListView:
display_name="NoCampaigns", display_name="NoCampaigns",
) )
response: _MonkeyPatchedWSGIResponse = client.get("/channels/") response: _MonkeyPatchedWSGIResponse = client.get("/twitch/channels/")
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list): if isinstance(context, list):
context = context[-1] context = context[-1]
@ -420,7 +420,7 @@ class TestChannelListView:
) )
campaign.allow_channels.add(channel2) campaign.allow_channels.add(channel2)
response: _MonkeyPatchedWSGIResponse = client.get("/channels/") response: _MonkeyPatchedWSGIResponse = client.get("/twitch/channels/")
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list): if isinstance(context, list):
context = context[-1] context = context[-1]
@ -462,7 +462,7 @@ class TestChannelListView:
) )
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
f"/channels/?search={channel.name}", f"/twitch/channels/?search={channel.name}",
) )
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list): if isinstance(context, list):
@ -527,7 +527,7 @@ class TestChannelListView:
@pytest.mark.django_db @pytest.mark.django_db
def test_debug_view(self, client: Client) -> None: def test_debug_view(self, client: Client) -> None:
"""Test debug view returns 200 and has games_without_owner in context.""" """Test debug view returns 200 and has games_without_owner in context."""
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:debug")) response: _MonkeyPatchedWSGIResponse = client.get(reverse("core:debug"))
assert response.status_code == 200 assert response.status_code == 200
assert "games_without_owner" in response.context assert "games_without_owner" in response.context
@ -1014,7 +1014,7 @@ 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 and has feeds in context.""" """Test docs RSS view returns 200 and has feeds in context."""
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch: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 assert "feeds" in response.context
assert "filtered_feeds" in response.context assert "filtered_feeds" in response.context
@ -1067,9 +1067,18 @@ class TestSEOHelperFunctions:
def test_build_seo_context_with_all_parameters(self) -> None: def test_build_seo_context_with_all_parameters(self) -> None:
"""Test _build_seo_context with all parameters.""" """Test _build_seo_context with all parameters."""
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
breadcrumb: list[dict[str, int | str]] = [ breadcrumb: dict[str, Any] = {
{"position": 1, "name": "Home", "url": "/"}, "@context": "https://schema.org",
] "@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "/",
},
],
}
context: dict[str, Any] = _build_seo_context( context: dict[str, Any] = _build_seo_context(
page_title="Test", page_title="Test",
@ -1077,7 +1086,7 @@ class TestSEOHelperFunctions:
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, # pyright: ignore[reportArgumentType] 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(),
@ -1268,7 +1277,7 @@ class TestSEOMetaTags:
def test_noindex_pages_have_robots_directive(self, client: Client) -> None: def test_noindex_pages_have_robots_directive(self, client: Client) -> None:
"""Test that pages with noindex have proper robots directive.""" """Test that pages with noindex have proper robots directive."""
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:dataset_backups"), reverse("core:dataset_backups"),
) )
assert response.status_code == 200 assert response.status_code == 200
assert "robots_directive" in response.context assert "robots_directive" in response.context
@ -1405,7 +1414,7 @@ class TestSitemapView:
channel: Channel = sample_entities["channel"] channel: Channel = sample_entities["channel"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
assert f"/channels/{channel.twitch_id}/" in content assert f"/twitch/channels/{channel.twitch_id}/" in content
def test_sitemap_contains_badge_detail_pages( def test_sitemap_contains_badge_detail_pages(
self, self,

View file

@ -3,21 +3,6 @@ from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from twitch import views from twitch import views
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
if TYPE_CHECKING: if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
@ -27,129 +12,82 @@ app_name = "twitch"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
# /twitch/
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
# /twitch/badges/
path("badges/", views.badge_list_view, name="badge_list"), path("badges/", views.badge_list_view, name="badge_list"),
# /twitch/badges/<set_id>/
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"), path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
# /twitch/campaigns/
path("campaigns/", views.drop_campaign_list_view, name="campaign_list"), path("campaigns/", views.drop_campaign_list_view, name="campaign_list"),
# /twitch/campaigns/<twitch_id>/
path( path(
"campaigns/<str:twitch_id>/", "campaigns/<str:twitch_id>/",
views.drop_campaign_detail_view, views.drop_campaign_detail_view,
name="campaign_detail", name="campaign_detail",
), ),
# /twitch/channels/
path("channels/", views.ChannelListView.as_view(), name="channel_list"), path("channels/", views.ChannelListView.as_view(), name="channel_list"),
# /twitch/channels/<twitch_id>/
path( path(
"channels/<str:twitch_id>/", "channels/<str:twitch_id>/",
views.ChannelDetailView.as_view(), views.ChannelDetailView.as_view(),
name="channel_detail", name="channel_detail",
), ),
path("debug/", views.debug_view, name="debug"), # /twitch/emotes/
path("datasets/", views.dataset_backups_view, name="dataset_backups"),
path(
"datasets/download/<path:relative_path>/",
views.dataset_backup_download_view,
name="dataset_backup_download",
),
path("docs/rss/", views.docs_rss_view, name="docs_rss"),
path("emotes/", views.emote_gallery_view, name="emote_gallery"), path("emotes/", views.emote_gallery_view, name="emote_gallery"),
# /twitch/games/
path("games/", views.GamesGridView.as_view(), name="games_grid"), path("games/", views.GamesGridView.as_view(), name="games_grid"),
# /twitch/games/list/
path("games/list/", views.GamesListView.as_view(), name="games_list"), path("games/list/", views.GamesListView.as_view(), name="games_list"),
# /twitch/games/<twitch_id>/
path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"), path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
# /twitch/organizations/
path("organizations/", views.org_list_view, name="org_list"), path("organizations/", views.org_list_view, name="org_list"),
# /twitch/organizations/<twitch_id>/
path( path(
"organizations/<str:twitch_id>/", "organizations/<str:twitch_id>/",
views.organization_detail_view, views.organization_detail_view,
name="organization_detail", name="organization_detail",
), ),
# /twitch/reward-campaigns/
path( path(
"reward-campaigns/", "reward-campaigns/",
views.reward_campaign_list_view, views.reward_campaign_list_view,
name="reward_campaign_list", name="reward_campaign_list",
), ),
# /twitch/reward-campaigns/<twitch_id>/
path( path(
"reward-campaigns/<str:twitch_id>/", "reward-campaigns/<str:twitch_id>/",
views.reward_campaign_detail_view, views.reward_campaign_detail_view,
name="reward_campaign_detail", name="reward_campaign_detail",
), ),
path("search/", views.search_view, name="search"), # /twitch/export/campaigns/csv/
path( path(
"export/campaigns/csv/", "export/campaigns/csv/",
views.export_campaigns_csv, views.export_campaigns_csv,
name="export_campaigns_csv", name="export_campaigns_csv",
), ),
# /twitch/export/campaigns/json/
path( path(
"export/campaigns/json/", "export/campaigns/json/",
views.export_campaigns_json, views.export_campaigns_json,
name="export_campaigns_json", name="export_campaigns_json",
), ),
# /twitch/export/games/csv/
path("export/games/csv/", views.export_games_csv, name="export_games_csv"), path("export/games/csv/", views.export_games_csv, name="export_games_csv"),
# /twitch/export/games/json/
path("export/games/json/", views.export_games_json, name="export_games_json"), path("export/games/json/", views.export_games_json, name="export_games_json"),
# /twitch/export/organizations/csv/
path( path(
"export/organizations/csv/", "export/organizations/csv/",
views.export_organizations_csv, views.export_organizations_csv,
name="export_organizations_csv", name="export_organizations_csv",
), ),
# /twitch/export/organizations/json/
path( path(
"export/organizations/json/", "export/organizations/json/",
views.export_organizations_json, views.export_organizations_json,
name="export_organizations_json", name="export_organizations_json",
), ),
# RSS feeds
# /rss/campaigns/ - all active campaigns
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
# /rss/games/ - newly added games
path("rss/games/", GameFeed(), name="game_feed"),
# /rss/games/<twitch_id>/campaigns/ - active campaigns for a specific game
path(
"rss/games/<str:twitch_id>/campaigns/",
GameCampaignFeed(),
name="game_campaign_feed",
),
# /rss/organizations/ - newly added organizations
path(
"rss/organizations/",
OrganizationRSSFeed(),
name="organization_feed",
),
# /rss/reward-campaigns/ - all active reward campaigns
path(
"rss/reward-campaigns/",
RewardCampaignFeed(),
name="reward_campaign_feed",
),
# Atom feeds (added alongside RSS to preserve backward compatibility)
path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"),
path("atom/games/", GameAtomFeed(), name="game_feed_atom"),
path(
"atom/games/<str:twitch_id>/campaigns/",
GameCampaignAtomFeed(),
name="game_campaign_feed_atom",
),
path(
"atom/organizations/",
OrganizationAtomFeed(),
name="organization_feed_atom",
),
path(
"atom/reward-campaigns/",
RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom",
),
# Discord feeds (Atom feeds with Discord relative timestamps)
path("discord/campaigns/", DropCampaignDiscordFeed(), name="campaign_feed_discord"),
path("discord/games/", GameDiscordFeed(), name="game_feed_discord"),
path(
"discord/games/<str:twitch_id>/campaigns/",
GameCampaignDiscordFeed(),
name="game_campaign_feed_discord",
),
path(
"discord/organizations/",
OrganizationDiscordFeed(),
name="organization_feed_discord",
),
path(
"discord/reward-campaigns/",
RewardCampaignDiscordFeed(),
name="reward_campaign_feed_discord",
),
] ]

View file

@ -53,7 +53,7 @@ def normalize_twitch_box_art_url(url: str) -> str:
return url return url
normalized_path: str = TWITCH_BOX_ART_SIZE_PATTERN.sub("", parsed.path) normalized_path: str = TWITCH_BOX_ART_SIZE_PATTERN.sub("", parsed.path)
return urlunparse(parsed._replace(path=normalized_path)) return str(urlunparse(parsed._replace(path=normalized_path)))
@lru_cache(maxsize=40 * 40 * 1024) @lru_cache(maxsize=40 * 40 * 1024)

View file

@ -2,36 +2,26 @@ import csv
import datetime import datetime
import json import json
import logging import logging
import operator
from collections import OrderedDict from collections import OrderedDict
from collections import defaultdict from collections import defaultdict
from copy import copy
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Literal from typing import Literal
from django.conf import settings
from django.core.paginator import EmptyPage 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.core.serializers import serialize
from django.db import connection
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 Exists
from django.db.models import F
from django.db.models import OuterRef
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q from django.db.models import Q
from django.db.models import When from django.db.models import When
from django.db.models.functions import Trim
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import FileResponse
from django.http import Http404 from django.http import Http404
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.template.defaultfilters import filesizeformat
from django.urls import reverse 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
@ -40,21 +30,6 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer from pygments.lexers.data import JsonLexer
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
@ -66,11 +41,6 @@ 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 pathlib import Path
from debug_toolbar.utils import QueryDict
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
@ -274,105 +244,6 @@ def emote_gallery_view(request: HttpRequest) -> HttpResponse:
return render(request, "twitch/emote_gallery.html", context) return render(request, "twitch/emote_gallery.html", context)
# MARK: /search/
def search_view(request: HttpRequest) -> HttpResponse:
"""Search view for all models.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered search results.
"""
query: str = request.GET.get("q", "")
results: dict[str, QuerySet] = {}
if query:
if len(query) < MIN_QUERY_LENGTH_FOR_FTS:
results["organizations"] = Organization.objects.filter(
name__istartswith=query,
)
results["games"] = Game.objects.filter(
Q(name__istartswith=query) | Q(display_name__istartswith=query),
)
results["campaigns"] = DropCampaign.objects.filter(
Q(name__istartswith=query) | Q(description__icontains=query),
).select_related("game")
results["drops"] = TimeBasedDrop.objects.filter(
name__istartswith=query,
).select_related("campaign")
results["benefits"] = DropBenefit.objects.filter(
name__istartswith=query,
).prefetch_related("drops__campaign")
results["reward_campaigns"] = RewardCampaign.objects.filter(
Q(name__istartswith=query)
| Q(brand__istartswith=query)
| Q(summary__icontains=query),
).select_related("game")
results["badge_sets"] = ChatBadgeSet.objects.filter(
set_id__istartswith=query,
)
results["badges"] = ChatBadge.objects.filter(
Q(title__istartswith=query) | Q(description__icontains=query),
).select_related("badge_set")
else:
results["organizations"] = Organization.objects.filter(
name__icontains=query,
)
results["games"] = Game.objects.filter(
Q(name__icontains=query) | Q(display_name__icontains=query),
)
results["campaigns"] = DropCampaign.objects.filter(
Q(name__icontains=query) | Q(description__icontains=query),
).select_related("game")
results["drops"] = TimeBasedDrop.objects.filter(
name__icontains=query,
).select_related("campaign")
results["benefits"] = DropBenefit.objects.filter(
name__icontains=query,
).prefetch_related("drops__campaign")
results["reward_campaigns"] = RewardCampaign.objects.filter(
Q(name__icontains=query)
| Q(brand__icontains=query)
| Q(summary__icontains=query),
).select_related("game")
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__icontains=query)
results["badges"] = ChatBadge.objects.filter(
Q(title__icontains=query) | Q(description__icontains=query),
).select_related("badge_set")
total_results_count: int = sum(len(qs) for qs in results.values())
# TODO(TheLovinator): Make the description more informative by including counts of each result type, e.g. "Found 5 games, 3 campaigns, and 10 drops for 'rust'." # noqa: TD003
if query:
page_title: str = f"Search Results for '{query}'"[:60]
page_description: str = f"Found {total_results_count} results for '{query}'."
else:
page_title = "Search"
page_description = "Search for drops, games, channels, and organizations."
seo_context: dict[str, Any] = _build_seo_context(
page_title=page_title,
page_description=page_description,
)
return render(
request,
"twitch/search_results.html",
{"query": query, "results": results, **seo_context},
)
# MARK: /organizations/ # MARK: /organizations/
def org_list_view(request: HttpRequest) -> HttpResponse: def org_list_view(request: HttpRequest) -> HttpResponse:
"""Function-based view for organization list. """Function-based view for organization list.
@ -624,111 +495,6 @@ def format_and_color_json(data: dict[str, Any] | list[dict] | str) -> str:
return highlight(formatted_code, JsonLexer(), HtmlFormatter()) return highlight(formatted_code, JsonLexer(), HtmlFormatter())
# MARK: /datasets/
def dataset_backups_view(request: HttpRequest) -> HttpResponse:
"""View to list database backup datasets on disk.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered dataset backups page.
"""
# TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003
# TODO(TheLovinator): Upload to s3 instead. # noqa: TD003
# TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld
datasets_root: Path = settings.DATA_DIR / "datasets"
search_dirs: list[Path] = [datasets_root]
seen_paths: set[str] = set()
datasets: list[dict[str, Any]] = []
for folder in search_dirs:
if not folder.exists() or not folder.is_dir():
continue
# Only include .zst files
for path in folder.glob("*.zst"):
if not path.is_file():
continue
key = str(path.resolve())
if key in seen_paths:
continue
seen_paths.add(key)
stat: stat_result = path.stat()
updated_at: datetime.datetime = datetime.datetime.fromtimestamp(
stat.st_mtime,
tz=timezone.get_current_timezone(),
)
try:
display_path = str(path.relative_to(datasets_root))
download_path: str | None = display_path
except ValueError:
display_path: str = path.name
download_path: str | None = None
datasets.append({
"name": path.name,
"display_path": display_path,
"download_path": download_path,
"size": filesizeformat(stat.st_size),
"updated_at": updated_at,
})
datasets.sort(key=operator.itemgetter("updated_at"), reverse=True)
seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Dataset",
page_description="Database backups and datasets available for download.",
)
context: dict[str, Any] = {
"datasets": datasets,
"data_dir": str(datasets_root),
"dataset_count": len(datasets),
**seo_context,
}
return render(request, "twitch/dataset_backups.html", context)
def dataset_backup_download_view(
request: HttpRequest, # noqa: ARG001
relative_path: str,
) -> FileResponse:
"""Download a dataset backup from the data directory.
Args:
request: The HTTP request.
relative_path: The path relative to the data directory.
Returns:
FileResponse: The file response for the requested dataset.
Raises:
Http404: When the file is not found or is outside the data directory.
"""
# TODO(TheLovinator): Use s3 instead of local disk. # noqa: TD003
datasets_root: Path = settings.DATA_DIR / "datasets"
requested_path: Path = (datasets_root / relative_path).resolve()
data_root: Path = datasets_root.resolve()
try:
requested_path.relative_to(data_root)
except ValueError as exc:
msg = "File not found"
raise Http404(msg) from exc
if not requested_path.exists() or not requested_path.is_file():
msg = "File not found"
raise Http404(msg)
if not requested_path.name.endswith(".zst"):
msg = "File not found"
raise Http404(msg)
return FileResponse(
requested_path.open("rb"),
as_attachment=True,
filename=requested_path.name,
)
def _enhance_drops_with_context( def _enhance_drops_with_context(
drops: QuerySet[TimeBasedDrop], drops: QuerySet[TimeBasedDrop],
now: datetime.datetime, now: datetime.datetime,
@ -1626,331 +1392,11 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
return render(request, "twitch/reward_campaign_detail.html", context) return render(request, "twitch/reward_campaign_detail.html", context)
# MARK: /debug/
def debug_view(request: HttpRequest) -> HttpResponse:
"""Debug view showing potentially broken or inconsistent data.
Returns:
HttpResponse: Rendered debug template or redirect if unauthorized.
"""
now: datetime.datetime = timezone.now()
# Games with no assigned owner organization
games_without_owner: QuerySet[Game] = Game.objects.filter(
owners__isnull=True,
).order_by("display_name")
# Campaigns with no images at all (no direct URL and no benefit image fallbacks)
broken_image_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(
Q(image_url__isnull=True)
| Q(image_url__exact="")
| ~Q(image_url__startswith="http"),
)
.exclude(
Exists(
TimeBasedDrop.objects.filter(campaign=OuterRef("pk")).filter(
benefits__image_asset_url__startswith="http",
),
),
)
.select_related("game")
)
# Benefits with missing images
broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate(
trimmed_url=Trim("image_asset_url"),
).filter(
Q(image_asset_url__isnull=True)
| Q(trimmed_url__exact="")
| ~Q(image_asset_url__startswith="http"),
)
# Time-based drops without any benefits
drops_without_benefits: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
benefits__isnull=True,
).select_related("campaign__game")
# Campaigns with invalid dates (start after end or missing either)
invalid_date_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
Q(start_at__gt=F("end_at")) | Q(start_at__isnull=True) | Q(end_at__isnull=True),
).select_related("game")
# Duplicate campaign names per game.
# We retrieve the game's name for user-friendly display.
duplicate_name_campaigns: QuerySet[DropCampaign, dict[str, Any]] = (
DropCampaign.objects
.values("game__display_name", "name", "game__twitch_id")
.annotate(name_count=Count("twitch_id"))
.filter(name_count__gt=1)
.order_by("game__display_name", "name")
)
# Active campaigns with no images at all
active_missing_image: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(start_at__lte=now, end_at__gte=now)
.filter(
Q(image_url__isnull=True)
| Q(image_url__exact="")
| ~Q(image_url__startswith="http"),
)
.exclude(
Exists(
TimeBasedDrop.objects.filter(campaign=OuterRef("pk")).filter(
benefits__image_asset_url__startswith="http",
),
),
)
.select_related("game")
)
# Distinct GraphQL operation names used to fetch campaigns with counts
# Since operation_names is now a JSON list field, we need to flatten and count
operation_names_counter: dict[str, int] = {}
for campaign in DropCampaign.objects.only("operation_names"):
for op_name in campaign.operation_names:
if op_name and op_name.strip():
operation_names_counter[op_name.strip()] = (
operation_names_counter.get(op_name.strip(), 0) + 1
)
operation_names_with_counts: list[dict[str, Any]] = [
{"trimmed_op": op_name, "count": count}
for op_name, count in sorted(operation_names_counter.items())
]
# Campaigns missing DropCampaignDetails operation name
# Need to handle SQLite separately since it doesn't support JSONField lookups
# Sqlite is used when testing
if connection.vendor == "sqlite":
all_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.select_related(
"game",
).order_by("game__display_name", "name")
campaigns_missing_dropcampaigndetails: list[DropCampaign] = [
c
for c in all_campaigns
if c.operation_names is None
or "DropCampaignDetails" not in c.operation_names
]
else:
campaigns_missing_dropcampaigndetails: list[DropCampaign] = list(
DropCampaign.objects
.filter(
Q(operation_names__isnull=True)
| ~Q(operation_names__contains=["DropCampaignDetails"]),
)
.select_related("game")
.order_by("game__display_name", "name"),
)
context: dict[str, Any] = {
"now": now,
"games_without_owner": games_without_owner,
"broken_image_campaigns": broken_image_campaigns,
"broken_benefit_images": broken_benefit_images,
"drops_without_benefits": drops_without_benefits,
"invalid_date_campaigns": invalid_date_campaigns,
"duplicate_name_campaigns": duplicate_name_campaigns,
"active_missing_image": active_missing_image,
"operation_names_with_counts": operation_names_with_counts,
"campaigns_missing_dropcampaigndetails": campaigns_missing_dropcampaigndetails,
}
seo_context: dict[str, Any] = _build_seo_context(
page_title="Debug",
page_description="Debug view showing potentially broken or inconsistent data.",
robots_directive="noindex, nofollow",
)
context.update(seo_context)
return render(request, "twitch/debug.html", context)
# MARK: /games/list/ # MARK: /games/list/
class GamesListView(GamesGridView): class GamesListView(GamesGridView):
"""List view for games in simple list format.""" """List view for games in simple list format."""
template_name: str = "twitch/games_list.html" template_name: str | None = "twitch/games_list.html"
# MARK: /docs/rss/
def docs_rss_view(request: HttpRequest) -> HttpResponse:
"""View for /docs/rss that lists all available RSS feeds.
Args:
request: The HTTP request object.
Returns:
Rendered HTML response with list of RSS feeds.
"""
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("<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 # pyright: ignore[reportAttributeAccessIssue]
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("twitch:organization_feed")),
"atom_url": absolute(reverse("twitch:organization_feed_atom")),
"discord_url": absolute(reverse("twitch: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("twitch:game_feed")),
"atom_url": absolute(reverse("twitch:game_feed_atom")),
"discord_url": absolute(reverse("twitch: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("twitch:campaign_feed")),
"atom_url": absolute(reverse("twitch:campaign_feed_atom")),
"discord_url": absolute(reverse("twitch: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("twitch:reward_campaign_feed")),
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")),
"discord_url": absolute(reverse("twitch: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("twitch:game_campaign_feed", args=[sample_game.twitch_id]),
)
if sample_game
else absolute("/rss/games/<game_id>/campaigns/")
),
"atom_url": (
absolute(
reverse(
"twitch:game_campaign_feed_atom",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/atom/games/<game_id>/campaigns/")
),
"discord_url": (
absolute(
reverse(
"twitch: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(
page_title="Twitch RSS Feeds",
page_description="RSS feeds for Twitch drops.",
)
return render(
request,
"twitch/docs_rss.html",
{
"feeds": feeds,
"filtered_feeds": filtered_feeds,
"sample_game": sample_game,
"sample_org": sample_org,
**seo_context,
},
)
# MARK: /channels/ # MARK: /channels/
@ -2302,7 +1748,7 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
) )
return ChatBadge.objects.filter(pk__in=badge_ids).order_by(preserved_order) return ChatBadge.objects.filter(pk__in=badge_ids).order_by(preserved_order)
badges = get_sorted_badges(badge_set) badges: QuerySet[ChatBadge, ChatBadge] = get_sorted_badges(badge_set)
# Attach award_campaigns attribute to each badge for template use # Attach award_campaigns attribute to each badge for template use
for badge in badges: for badge in badges:
@ -2647,143 +2093,3 @@ def export_organizations_json(request: HttpRequest) -> HttpResponse: # noqa: AR
response["Content-Disposition"] = "attachment; filename=organizations.json" response["Content-Disposition"] = "attachment; filename=organizations.json"
return response return response
# MARK: /sitemap.xml
def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
"""Generate a dynamic XML sitemap for search engines.
Args:
request: The HTTP request.
Returns:
HttpResponse: XML sitemap.
"""
base_url: str = f"{request.scheme}://{request.get_host()}"
# Start building sitemap XML
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
# Static pages
sitemap_urls.extend([
{"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"},
{"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"},
{
"url": f"{base_url}/reward-campaigns/",
"priority": "0.9",
"changefreq": "daily",
},
{"url": f"{base_url}/games/", "priority": "0.9", "changefreq": "weekly"},
{
"url": f"{base_url}/organizations/",
"priority": "0.8",
"changefreq": "weekly",
},
{"url": f"{base_url}/channels/", "priority": "0.8", "changefreq": "weekly"},
{"url": f"{base_url}/badges/", "priority": "0.7", "changefreq": "monthly"},
{"url": f"{base_url}/emotes/", "priority": "0.7", "changefreq": "monthly"},
{"url": f"{base_url}/search/", "priority": "0.6", "changefreq": "monthly"},
])
# Dynamic detail pages - Games
games: QuerySet[Game] = Game.objects.all()
for game in games:
entry: dict[str, str | dict[str, str]] = {
"url": f"{base_url}{reverse('twitch:game_detail', args=[game.twitch_id])}",
"priority": "0.8",
"changefreq": "weekly",
}
if game.updated_at:
entry["lastmod"] = game.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Campaigns
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all()
for campaign in campaigns:
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.7",
"changefreq": "weekly",
}
if campaign.updated_at:
entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Organizations
orgs: QuerySet[Organization] = Organization.objects.all()
for org in orgs:
resource_url = reverse("twitch:organization_detail", args=[org.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.7",
"changefreq": "weekly",
}
if org.updated_at:
entry["lastmod"] = org.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Channels
channels: QuerySet[Channel] = Channel.objects.all()
for channel in channels:
resource_url = reverse("twitch:channel_detail", args=[channel.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.6",
"changefreq": "weekly",
}
if channel.updated_at:
entry["lastmod"] = channel.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Badges
badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all()
for badge_set in badge_sets:
resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id])
full_url: str = f"{base_url}{resource_url}"
sitemap_urls.append({
"url": full_url,
"priority": "0.5",
"changefreq": "monthly",
})
# Dynamic detail pages - Reward Campaigns
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
for reward_campaign in reward_campaigns:
resource_url = reverse(
"twitch:reward_campaign_detail",
args=[
reward_campaign.twitch_id,
],
)
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.6",
"changefreq": "weekly",
}
if reward_campaign.updated_at:
entry["lastmod"] = reward_campaign.updated_at.isoformat()
sitemap_urls.append(entry)
# Build XML
xml_content = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml_content += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url_entry in sitemap_urls:
xml_content += " <url>\n"
xml_content += f" <loc>{url_entry['url']}</loc>\n"
if url_entry.get("lastmod"):
xml_content += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
xml_content += (
f" <changefreq>{url_entry.get('changefreq', 'monthly')}</changefreq>\n"
)
xml_content += f" <priority>{url_entry.get('priority', '0.5')}</priority>\n"
xml_content += " </url>\n"
xml_content += "</urlset>"
return HttpResponse(xml_content, content_type="application/xml")

0
youtube/__init__.py Normal file
View file

7
youtube/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class YoutubeConfig(AppConfig):
"""Django app configuration for the YouTube app."""
name = "youtube"

View file

View file

View file

@ -0,0 +1,49 @@
from typing import TYPE_CHECKING
from django.test import TestCase
from django.urls import reverse
if TYPE_CHECKING:
from django.test.client import _MonkeyPatchedWSGIResponse
class YouTubeIndexViewTest(TestCase):
"""Tests for the YouTube drops channels index page."""
def test_index_returns_200(self) -> None:
"""The YouTube index page should return HTTP 200."""
response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
assert response.status_code == 200
def test_index_displays_known_channels(self) -> None:
"""The page should include key known channels from the partner list."""
response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
content: str = response.content.decode()
assert "YouTube Drops Channels" in content
assert "Call of Duty" in content
assert "PlayOverwatch" in content
assert "Hearthstone" in content
assert "Fortnite" in content
assert "Riot Games" in content
assert "Ubisoft" in content
def test_index_includes_partner_urls(self) -> None:
"""The page should render partner channel links from the source list."""
response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
content: str = response.content.decode()
assert "https://www.youtube.com/channel/UCbLIqv9Puhyp9_ZjVtfOy7w" in content
assert "https://www.youtube.com/user/epicfortnite" in content
assert "https://www.youtube.com/lolesports" in content
def test_index_groups_partners_alphabetically(self) -> None:
"""Partner sections should render grouped and in alphabetical order."""
response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index"))
content: str = response.content.decode()
assert "<h2>Activision (Call of Duty)</h2>" in content
assert "<h2>Battle.net / Blizzard</h2>" in content
assert content.index("<h2>Activision (Call of Duty)</h2>") < content.index(
"<h2>Battle.net / Blizzard</h2>",
)

16
youtube/urls.py Normal file
View file

@ -0,0 +1,16 @@
from typing import TYPE_CHECKING
from django.urls import path
from youtube import views
if TYPE_CHECKING:
from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver
app_name = "youtube"
urlpatterns: list[URLPattern | URLResolver] = [
path(route="", view=views.index, name="index"),
]

115
youtube/views.py Normal file
View file

@ -0,0 +1,115 @@
from collections import defaultdict
from typing import TYPE_CHECKING
from django.shortcuts import render
if TYPE_CHECKING:
from django.http import HttpRequest
from django.http import HttpResponse
def index(request: HttpRequest) -> HttpResponse:
"""Render a minimal list of YouTube channels with known drops-enabled partners.
Returns:
HttpResponse: Rendered index page for YouTube drops channels.
"""
channels: list[dict[str, str]] = [
{
"partner": "Activision (Call of Duty)",
"channel": "Call of Duty",
"url": "https://www.youtube.com/channel/UCbLIqv9Puhyp9_ZjVtfOy7w",
},
{
"partner": "Battle.net / Blizzard",
"channel": "PlayOverwatch",
"url": "https://www.youtube.com/c/playoverwatch/featured",
},
{
"partner": "Battle.net / Blizzard",
"channel": "Hearthstone",
"url": "https://www.youtube.com/c/Hearthstone/featured",
},
{
"partner": "Electronic Arts",
"channel": "FIFA",
"url": "https://www.youtube.com/channel/UCFA6YGp5lvgayO20lk7_Ung",
},
{
"partner": "Electronic Arts",
"channel": "EA Madden NFL",
"url": "https://www.youtube.com/@EAMaddenNFL",
},
{
"partner": "Epic Games",
"channel": "Fortnite",
"url": "https://www.youtube.com/user/epicfortnite",
},
{
"partner": "Garena",
"channel": "Free Fire",
"url": "https://www.youtube.com/channel/UC_vVy4OI86F0amXqFN_zTMg",
},
{
"partner": "Krafton (PUBG)",
"channel": "PUBG: BATTLEGROUNDS",
"url": "https://www.youtube.com/channel/UCTDO0RgowRyaAEUrPnBAg4g",
},
{
"partner": "MLBB",
"channel": "Mobile Legends: Bang Bang",
"url": "https://www.youtube.com/channel/UCqmld-BIYME2i_ooRTo1EOg",
},
{
"partner": "NBA",
"channel": "NBA",
"url": "https://www.youtube.com/user/NBA",
},
{
"partner": "NFL",
"channel": "NFL",
"url": "https://www.youtube.com/@NFL",
},
{
"partner": "PUBG Mobile",
"channel": "PUBG MOBILE",
"url": "https://www.youtube.com/channel/UCTDO0RgowRyaAEUrPnBAg4g",
},
{
"partner": "Riot Games",
"channel": "Riot Games",
"url": "https://www.youtube.com/user/RiotGamesInc",
},
{
"partner": "Riot Games",
"channel": "LoL Esports",
"url": "https://www.youtube.com/lolesports",
},
{
"partner": "Supercell",
"channel": "Clash Royale",
"url": "https://www.youtube.com/channel/UC_F8DoJf9MZogEOU51TpTbQ",
},
{
"partner": "Ubisoft",
"channel": "Ubisoft",
"url": "https://www.youtube.com/user/ubisoft",
},
]
grouped_channels: dict[str, list[dict[str, str]]] = defaultdict(list)
for channel in channels:
grouped_channels[channel["partner"]].append(channel)
partner_groups: list[dict[str, str | list[dict[str, str]]]] = []
for partner in sorted(grouped_channels.keys(), key=str.lower):
sorted_items: list[dict[str, str]] = sorted(
grouped_channels[partner],
key=lambda item: item["channel"].lower(),
)
partner_groups.append({"partner": partner, "channels": sorted_items})
context: dict[str, list[dict[str, str | list[dict[str, str]]]]] = {
"partner_groups": partner_groups,
}
return render(request=request, template_name="youtube/index.html", context=context)