Compare commits

...

2 commits

Author SHA1 Message Date
5d95038faf
Add box art and campaign image metadata fields; update feeds and backfill command
All checks were successful
Deploy to Server / deploy (push) Successful in 10s
2026-03-09 19:53:10 +01:00
0d72d99b8f
Add TODO 2026-03-09 15:23:16 +01:00
7 changed files with 265 additions and 26 deletions

View file

@ -497,16 +497,27 @@ class GameFeed(Feed):
return box_art return box_art
return "" return ""
def item_enclosure_length(self, item: Game) -> int: # noqa: ARG002 def item_enclosure_length(self, item: Game) -> int:
"""Returns the length of the enclosure.""" """Returns the length of the enclosure.
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
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 return 0
def item_enclosure_mime_type(self, item: Game) -> str: # noqa: ARG002 def item_enclosure_mime_type(self, item: Game) -> str:
"""Returns the MIME type of the enclosure.""" """Returns the MIME type of the enclosure.
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
return "image/jpeg" 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/ # MARK: /rss/campaigns/
@ -633,15 +644,26 @@ class DropCampaignFeed(Feed):
"""Returns the URL of the campaign image for enclosure.""" """Returns the URL of the campaign image for enclosure."""
return item.get_feed_enclosure_url() return item.get_feed_enclosure_url()
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002 def item_enclosure_length(self, item: DropCampaign) -> int:
"""Returns the length of the enclosure.""" """Returns the length of the enclosure.
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
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 return 0
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002 def item_enclosure_mime_type(self, item: DropCampaign) -> str:
"""Returns the MIME type of the enclosure.""" """Returns the MIME type of the enclosure.
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
return "image/jpeg" 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/<twitch_id>/campaigns/ # MARK: /rss/games/<twitch_id>/campaigns/
@ -801,16 +823,26 @@ class GameCampaignFeed(Feed):
"""Returns the URL of the campaign image for enclosure.""" """Returns the URL of the campaign image for enclosure."""
return item.get_feed_enclosure_url() return item.get_feed_enclosure_url()
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002 def item_enclosure_length(self, item: DropCampaign) -> int:
"""Returns the length of the enclosure.""" """Returns the length of the enclosure.
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
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 return 0
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002 def item_enclosure_mime_type(self, item: DropCampaign) -> str:
"""Returns the MIME type of the enclosure.""" """Returns the MIME type of the enclosure.
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
return "image/jpeg" 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/ # MARK: /rss/reward-campaigns/

View file

@ -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 from django.core.management.base import BaseCommand
@ -24,6 +25,14 @@ class Command(BaseCommand):
try: try:
# Opening the file and saving triggers dimension calculation # Opening the file and saving triggers dimension calculation
game.box_art_file.open() 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() game.save()
total_updated += 1 total_updated += 1
self.stdout.write(self.style.SUCCESS(f" Updated {game}")) 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: if campaign.image_file and not campaign.image_width:
try: try:
campaign.image_file.open() 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() campaign.save()
total_updated += 1 total_updated += 1
self.stdout.write(self.style.SUCCESS(f" Updated {campaign}")) self.stdout.write(self.style.SUCCESS(f" Updated {campaign}"))

View file

@ -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,
),
),
]

View file

@ -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,
),
),
]

View file

@ -115,6 +115,19 @@ class Game(auto_prefetch.Model):
editable=False, editable=False,
help_text="Height of cached box art image in pixels.", 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( owners = models.ManyToManyField(
Organization, Organization,
@ -357,6 +370,19 @@ class DropCampaign(auto_prefetch.Model):
editable=False, editable=False,
help_text="Height of cached image in pixels.", 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( start_at = models.DateTimeField(
null=True, null=True,
blank=True, blank=True,

View file

@ -6,10 +6,15 @@ from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from hypothesis.extra.django import TestCase 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 Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet from twitch.models import ChatBadgeSet
@ -51,6 +56,19 @@ class RSSFeedTestCase(TestCase):
operation_names=["DropCampaignDetails"], 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: 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("twitch:organization_feed")
@ -73,6 +91,16 @@ class RSSFeedTestCase(TestCase):
) )
assert expected_rss_link in content 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: 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("twitch:campaign_feed")
@ -80,6 +108,17 @@ class RSSFeedTestCase(TestCase):
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" 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: def test_campaign_feed_includes_badge_description(self) -> None:
"""Badge benefit descriptions should be visible in the RSS drop summary.""" """Badge benefit descriptions should be visible in the RSS drop summary."""
drop: TimeBasedDrop = TimeBasedDrop.objects.create( drop: TimeBasedDrop = TimeBasedDrop.objects.create(
@ -152,6 +191,60 @@ class RSSFeedTestCase(TestCase):
# Should NOT contain other campaign # Should NOT contain other campaign
assert "Other Campaign" not in content 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]] QueryAsserter = Callable[..., AbstractContextManager[object]]

View file

@ -614,7 +614,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
""" """
# TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003 # 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): 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" datasets_root: Path = settings.DATA_DIR / "datasets"
search_dirs: list[Path] = [datasets_root] search_dirs: list[Path] = [datasets_root]
seen_paths: set[str] = set() seen_paths: set[str] = set()