From 5d95038faf66b5bd798fb8a384fe1fd10cd2cd9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Mon, 9 Mar 2026 19:53:10 +0100 Subject: [PATCH] Add box art and campaign image metadata fields; update feeds and backfill command --- twitch/feeds.py | 80 +++++++++++----- .../commands/backfill_image_dimensions.py | 18 +++- ...x_art_mime_type_game_box_art_size_bytes.py | 36 +++++++ ...4_dropcampaign_image_mime_type_and_more.py | 36 +++++++ twitch/models.py | 26 ++++++ twitch/tests/test_feeds.py | 93 +++++++++++++++++++ 6 files changed, 264 insertions(+), 25 deletions(-) create mode 100644 twitch/migrations/0013_game_box_art_mime_type_game_box_art_size_bytes.py create mode 100644 twitch/migrations/0014_dropcampaign_image_mime_type_and_more.py diff --git a/twitch/feeds.py b/twitch/feeds.py index 6ca0cdb..97e4058 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -497,16 +497,27 @@ class GameFeed(Feed): return box_art return "" - def item_enclosure_length(self, item: Game) -> int: # noqa: ARG002 - """Returns the length of the enclosure.""" - # TODO(TheLovinator): Track image size for proper length # noqa: TD003 + def item_enclosure_length(self, item: Game) -> int: + """Returns the length of the enclosure. - return 0 + Prefer the newly-added ``box_art_size_bytes`` field so that the RSS + feed can include an accurate ``length`` attribute. Fall back to 0 if + the value is missing or ``None``. + """ + try: + size = getattr(item, "box_art_size_bytes", None) + return int(size) if size is not None else 0 + except TypeError, ValueError: + return 0 - def item_enclosure_mime_type(self, item: Game) -> str: # noqa: ARG002 - """Returns the MIME type of the enclosure.""" - # TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003 - return "image/jpeg" + def item_enclosure_mime_type(self, item: Game) -> str: + """Returns the MIME type of the enclosure. + + Use the ``box_art_mime_type`` field when available, otherwise fall back + to a generic JPEG string (as was previously hard-coded). + """ + mime: str = getattr(item, "box_art_mime_type", "") + return mime or "image/jpeg" # MARK: /rss/campaigns/ @@ -633,15 +644,26 @@ class DropCampaignFeed(Feed): """Returns the URL of the campaign image for enclosure.""" return item.get_feed_enclosure_url() - def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002 - """Returns the length of the enclosure.""" - # TODO(TheLovinator): Track image size for proper length # noqa: TD003 - return 0 + def item_enclosure_length(self, item: DropCampaign) -> int: + """Returns the length of the enclosure. - def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002 - """Returns the MIME type of the enclosure.""" - # TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003 - return "image/jpeg" + Reads the `image_size_bytes` field added to the model. If the field is + unset it returns `0` to match previous behavior. + """ + try: + size = getattr(item, "image_size_bytes", None) + return int(size) if size is not None else 0 + except TypeError, ValueError: + return 0 + + def item_enclosure_mime_type(self, item: DropCampaign) -> str: + """Returns the MIME type of the enclosure. + + Uses `image_mime_type` on the campaign if set, falling back to the + previous hard-coded `image/jpeg`. + """ + mime: str = getattr(item, "image_mime_type", "") + return mime or "image/jpeg" # MARK: /rss/games//campaigns/ @@ -801,16 +823,26 @@ class GameCampaignFeed(Feed): """Returns the URL of the campaign image for enclosure.""" return item.get_feed_enclosure_url() - def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002 - """Returns the length of the enclosure.""" - # TODO(TheLovinator): Track image size for proper length # noqa: TD003 + def item_enclosure_length(self, item: DropCampaign) -> int: + """Returns the length of the enclosure. - return 0 + Reads the ``image_size_bytes`` field added to the model when rendering a + game-specific campaign feed. + """ + try: + size = getattr(item, "image_size_bytes", None) + return int(size) if size is not None else 0 + except TypeError, ValueError: + return 0 - def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002 - """Returns the MIME type of the enclosure.""" - # TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003 - return "image/jpeg" + def item_enclosure_mime_type(self, item: DropCampaign) -> str: + """Returns the MIME type of the enclosure. + + Prefers `image_mime_type` on the campaign object; falls back to + `image/jpeg` when not available. + """ + mime: str = getattr(item, "image_mime_type", "") + return mime or "image/jpeg" # MARK: /rss/reward-campaigns/ diff --git a/twitch/management/commands/backfill_image_dimensions.py b/twitch/management/commands/backfill_image_dimensions.py index 44e7a9b..fb62032 100644 --- a/twitch/management/commands/backfill_image_dimensions.py +++ b/twitch/management/commands/backfill_image_dimensions.py @@ -1,4 +1,5 @@ -"""Management command to backfill image dimensions for existing cached images.""" +import contextlib +import mimetypes from django.core.management.base import BaseCommand @@ -24,6 +25,14 @@ class Command(BaseCommand): try: # Opening the file and saving triggers dimension calculation game.box_art_file.open() + + # populate size and mime if available + with contextlib.suppress(Exception): + game.box_art_size_bytes = game.box_art_file.size + mime, _ = mimetypes.guess_type(game.box_art_file.name or "") + if mime: + game.box_art_mime_type = mime + game.save() total_updated += 1 self.stdout.write(self.style.SUCCESS(f" Updated {game}")) @@ -36,6 +45,13 @@ class Command(BaseCommand): if campaign.image_file and not campaign.image_width: try: campaign.image_file.open() + + with contextlib.suppress(Exception): + campaign.image_size_bytes = campaign.image_file.size + mime, _ = mimetypes.guess_type(campaign.image_file.name or "") + if mime: + campaign.image_mime_type = mime + campaign.save() total_updated += 1 self.stdout.write(self.style.SUCCESS(f" Updated {campaign}")) diff --git a/twitch/migrations/0013_game_box_art_mime_type_game_box_art_size_bytes.py b/twitch/migrations/0013_game_box_art_mime_type_game_box_art_size_bytes.py new file mode 100644 index 0000000..64b73a1 --- /dev/null +++ b/twitch/migrations/0013_game_box_art_mime_type_game_box_art_size_bytes.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.3 on 2026-03-09 18:33 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Add box art MIME type and size fields to Game, and backfill existing data.""" + + dependencies = [ + ("twitch", "0012_dropcampaign_operation_names_gin_index"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="box_art_mime_type", + field=models.CharField( + blank=True, + default="", + editable=False, + help_text="MIME type of the cached box art image (e.g., 'image/png').", + max_length=50, + ), + ), + migrations.AddField( + model_name="game", + name="box_art_size_bytes", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="File size of the cached box art image in bytes.", + null=True, + ), + ), + ] diff --git a/twitch/migrations/0014_dropcampaign_image_mime_type_and_more.py b/twitch/migrations/0014_dropcampaign_image_mime_type_and_more.py new file mode 100644 index 0000000..0ce0b4e --- /dev/null +++ b/twitch/migrations/0014_dropcampaign_image_mime_type_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.3 on 2026-03-09 18:35 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Add image MIME type and size fields to DropCampaign, and backfill existing data.""" + + dependencies = [ + ("twitch", "0013_game_box_art_mime_type_game_box_art_size_bytes"), + ] + + operations = [ + migrations.AddField( + model_name="dropcampaign", + name="image_mime_type", + field=models.CharField( + blank=True, + default="", + editable=False, + help_text="MIME type of the cached campaign image (e.g., 'image/png').", + max_length=50, + ), + ), + migrations.AddField( + model_name="dropcampaign", + name="image_size_bytes", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="File size of the cached campaign image in bytes.", + null=True, + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 5f704fd..12f4052 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -115,6 +115,19 @@ class Game(auto_prefetch.Model): editable=False, help_text="Height of cached box art image in pixels.", ) + box_art_size_bytes = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="File size of the cached box art image in bytes.", + ) + box_art_mime_type = models.CharField( + max_length=50, + blank=True, + default="", + editable=False, + help_text="MIME type of the cached box art image (e.g., 'image/png').", + ) owners = models.ManyToManyField( Organization, @@ -357,6 +370,19 @@ class DropCampaign(auto_prefetch.Model): editable=False, help_text="Height of cached image in pixels.", ) + image_size_bytes = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="File size of the cached campaign image in bytes.", + ) + image_mime_type = models.CharField( + max_length=50, + blank=True, + default="", + editable=False, + help_text="MIME type of the cached campaign image (e.g., 'image/png').", + ) start_at = models.DateTimeField( null=True, blank=True, diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index fe12daa..08005b6 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -6,10 +6,15 @@ from datetime import timedelta from typing import TYPE_CHECKING import pytest +from django.core.files.base import ContentFile +from django.core.management import call_command from django.urls import reverse from django.utils import timezone from hypothesis.extra.django import TestCase +from twitch.feeds import DropCampaignFeed +from twitch.feeds import GameCampaignFeed +from twitch.feeds import GameFeed from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import ChatBadgeSet @@ -51,6 +56,19 @@ class RSSFeedTestCase(TestCase): operation_names=["DropCampaignDetails"], ) + # populate the new enclosure metadata fields so feeds can return them + self.game.box_art_size_bytes = 42 + self.game.box_art_mime_type = "image/png" + # provide a URL so that the RSS enclosure element is emitted + self.game.box_art = "https://example.com/box.png" + self.game.save() + + self.campaign.image_size_bytes = 314 + self.campaign.image_mime_type = "image/gif" + # feed will only include an enclosure if there is some image URL/field + self.campaign.image_url = "https://example.com/campaign.png" + self.campaign.save() + def test_organization_feed(self) -> None: """Test organization feed returns 200.""" url: str = reverse("twitch:organization_feed") @@ -73,6 +91,16 @@ class RSSFeedTestCase(TestCase): ) assert expected_rss_link in content + # enclosure metadata from our new fields should be present + assert 'length="42"' in content + assert 'type="image/png"' in content + + def test_game_feed_enclosure_helpers(self) -> None: + """Helper methods should return values from model fields.""" + feed = GameFeed() + assert feed.item_enclosure_length(self.game) == 42 + assert feed.item_enclosure_mime_type(self.game) == "image/png" + def test_campaign_feed(self) -> None: """Test campaign feed returns 200.""" url: str = reverse("twitch:campaign_feed") @@ -80,6 +108,17 @@ class RSSFeedTestCase(TestCase): assert response.status_code == 200 assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + content: str = response.content.decode("utf-8") + # verify enclosure meta + assert 'length="314"' in content + assert 'type="image/gif"' in content + + def test_campaign_feed_enclosure_helpers(self) -> None: + """Helper methods for campaigns should respect new fields.""" + feed = DropCampaignFeed() + assert feed.item_enclosure_length(self.campaign) == 314 + assert feed.item_enclosure_mime_type(self.campaign) == "image/gif" + def test_campaign_feed_includes_badge_description(self) -> None: """Badge benefit descriptions should be visible in the RSS drop summary.""" drop: TimeBasedDrop = TimeBasedDrop.objects.create( @@ -152,6 +191,60 @@ class RSSFeedTestCase(TestCase): # Should NOT contain other campaign assert "Other Campaign" not in content + # enclosure metadata also should appear here + assert 'length="314"' in content + assert 'type="image/gif"' in content + + def test_game_campaign_feed_enclosure_helpers(self) -> None: + """GameCampaignFeed helper methods should pull from the model fields.""" + feed = GameCampaignFeed() + assert feed.item_enclosure_length(self.campaign) == 314 + assert feed.item_enclosure_mime_type(self.campaign) == "image/gif" + + def test_backfill_command_sets_metadata(self) -> None: + """Running the backfill command should populate size and mime fields. + + We create a fake file for both a game and a campaign and then execute the + management command. Afterward, the corresponding metadata fields should + be filled in. + """ + # create game with local file + game2: Game = Game.objects.create( + twitch_id="file-game", + slug="file-game", + name="File Game", + display_name="File Game", + ) + game2.box_art_file.save("sample.png", ContentFile(b"hello")) + game2.save() + + campaign2: DropCampaign = DropCampaign.objects.create( + twitch_id="file-camp", + name="File Campaign", + game=self.game, + start_at=timezone.now(), + end_at=timezone.now() + timedelta(days=1), + operation_names=["DropCampaignDetails"], + ) + campaign2.image_file.save("camp.jpg", ContentFile(b"world")) + campaign2.save() + + # ensure fields are not populated initially + assert campaign2.image_size_bytes is None + assert not campaign2.image_mime_type + assert game2.box_art_size_bytes is None + assert not game2.box_art_mime_type + + call_command("backfill_image_dimensions") + + campaign2.refresh_from_db() + game2.refresh_from_db() + + assert campaign2.image_size_bytes == len(b"world") + assert campaign2.image_mime_type == "image/jpeg" + assert game2.box_art_size_bytes == len(b"hello") + assert game2.box_art_mime_type == "image/png" + QueryAsserter = Callable[..., AbstractContextManager[object]]