From 472765728579aed61ff15c78e954a1f60ebe2428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Thu, 12 Feb 2026 04:56:42 +0100 Subject: [PATCH] Add image dimensions to models, and add backfill command --- pyproject.toml | 1 + templates/includes/meta_tags.html | 8 +- .../commands/backfill_image_dimensions.py | 73 +++++++++ ...height_dropbenefit_image_width_and_more.py | 144 ++++++++++++++++++ twitch/models.py | 64 +++++++- twitch/utils.py | 1 - twitch/views.py | 15 ++ 7 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 twitch/management/commands/backfill_image_dimensions.py create mode 100644 twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py diff --git a/pyproject.toml b/pyproject.toml index 4242132..4f1dd4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "dateparser", "django", "json-repair", + "pillow", "platformdirs", "python-dotenv", "pygments", diff --git a/templates/includes/meta_tags.html b/templates/includes/meta_tags.html index 642eef2..32a0b5f 100644 --- a/templates/includes/meta_tags.html +++ b/templates/includes/meta_tags.html @@ -4,6 +4,8 @@ {# - page_title: str - Page title (defaults to "ttvdrops") #} {# - page_description: str - Page description (defaults to site description) #} {# - page_image: str - Image URL for og:image (optional) #} +{# - page_image_width: int - Image width in pixels (optional) #} +{# - page_image_height: int - Image height in pixels (optional) #} {# - page_url: str - Full URL for og:url and canonical (defaults to request.build_absolute_uri) #} {# - og_type: str - OpenGraph type (defaults to "website") #} {# - schema_data: str - JSON-LD schema data serialized as string (optional) #} @@ -36,8 +38,10 @@ content="{% firstof page_url request.build_absolute_uri %}" /> {% if page_image %} - - + {% if page_image_width and page_image_height %} + + + {% endif %} {% endif %} {# Twitter Card tags for rich previews #} None: # noqa: ARG002 + """Execute the command.""" + total_updated = 0 + + # Update Game box art + self.stdout.write("Processing Game box_art_file...") + for game in Game.objects.exclude(box_art_file=""): + if game.box_art_file and not game.box_art_width: + try: + # Opening the file and saving triggers dimension calculation + game.box_art_file.open() + game.save() + total_updated += 1 + self.stdout.write(self.style.SUCCESS(f" Updated {game}")) + except (OSError, ValueError, AttributeError) as exc: + self.stdout.write(self.style.ERROR(f" Failed {game}: {exc}")) + + # Update DropCampaign images + self.stdout.write("Processing DropCampaign image_file...") + for campaign in DropCampaign.objects.exclude(image_file=""): + if campaign.image_file and not campaign.image_width: + try: + campaign.image_file.open() + campaign.save() + total_updated += 1 + self.stdout.write(self.style.SUCCESS(f" Updated {campaign}")) + except (OSError, ValueError, AttributeError) as exc: + self.stdout.write(self.style.ERROR(f" Failed {campaign}: {exc}")) + + # Update DropBenefit images + self.stdout.write("Processing DropBenefit image_file...") + for benefit in DropBenefit.objects.exclude(image_file=""): + if benefit.image_file and not benefit.image_width: + try: + benefit.image_file.open() + benefit.save() + total_updated += 1 + self.stdout.write(self.style.SUCCESS(f" Updated {benefit}")) + except (OSError, ValueError, AttributeError) as exc: + self.stdout.write(self.style.ERROR(f" Failed {benefit}: {exc}")) + + # Update RewardCampaign images + self.stdout.write("Processing RewardCampaign image_file...") + for reward in RewardCampaign.objects.exclude(image_file=""): + if reward.image_file and not reward.image_width: + try: + reward.image_file.open() + reward.save() + total_updated += 1 + self.stdout.write(self.style.SUCCESS(f" Updated {reward}")) + except (OSError, ValueError, AttributeError) as exc: + self.stdout.write(self.style.ERROR(f" Failed {reward}: {exc}")) + + self.stdout.write( + self.style.SUCCESS(f"\nBackfill complete! Updated {total_updated} images."), + ) diff --git a/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py b/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py new file mode 100644 index 0000000..97e10ef --- /dev/null +++ b/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py @@ -0,0 +1,144 @@ +# Generated by Django 6.0.2 on 2026-02-12 03:41 +from __future__ import annotations + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Add image height and width fields to DropBenefit, DropCampaign, Game, and RewardCampaign, then update ImageFields to use them.""" # noqa: E501 + + dependencies = [ + ("twitch", "0010_rewardcampaign_image_file_rewardcampaign_image_url"), + ] + + operations = [ + migrations.AddField( + model_name="dropbenefit", + name="image_height", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Height of cached image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="dropbenefit", + name="image_width", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Width of cached image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="dropcampaign", + name="image_height", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Height of cached image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="dropcampaign", + name="image_width", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Width of cached image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="game", + name="box_art_height", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Height of cached box art image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="game", + name="box_art_width", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Width of cached box art image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="rewardcampaign", + name="image_height", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Height of cached image in pixels.", + null=True, + ), + ), + migrations.AddField( + model_name="rewardcampaign", + name="image_width", + field=models.PositiveIntegerField( + blank=True, + editable=False, + help_text="Width of cached image in pixels.", + null=True, + ), + ), + migrations.AlterField( + model_name="dropbenefit", + name="image_file", + field=models.ImageField( + blank=True, + height_field="image_height", + help_text="Locally cached benefit image served from this site.", + null=True, + upload_to="benefits/images/", + width_field="image_width", + ), + ), + migrations.AlterField( + model_name="dropcampaign", + name="image_file", + field=models.ImageField( + blank=True, + height_field="image_height", + help_text="Locally cached campaign image served from this site.", + null=True, + upload_to="campaigns/images/", + width_field="image_width", + ), + ), + migrations.AlterField( + model_name="game", + name="box_art_file", + field=models.ImageField( + blank=True, + height_field="box_art_height", + help_text="Locally cached box art image served from this site.", + null=True, + upload_to="games/box_art/", + width_field="box_art_width", + ), + ), + migrations.AlterField( + model_name="rewardcampaign", + name="image_file", + field=models.ImageField( + blank=True, + height_field="image_height", + help_text="Locally cached reward campaign image served from this site.", + null=True, + upload_to="reward_campaigns/images/", + width_field="image_width", + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index b358662..78d2893 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -104,12 +104,26 @@ class Game(auto_prefetch.Model): verbose_name="Box art URL", ) - box_art_file = models.FileField( + box_art_file = models.ImageField( upload_to="games/box_art/", blank=True, null=True, + width_field="box_art_width", + height_field="box_art_height", help_text="Locally cached box art image served from this site.", ) + box_art_width = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Width of cached box art image in pixels.", + ) + box_art_height = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Height of cached box art image in pixels.", + ) owners = models.ManyToManyField( Organization, @@ -332,12 +346,26 @@ class DropCampaign(auto_prefetch.Model): default="", help_text="URL to an image representing the campaign.", ) - image_file = models.FileField( + image_file = models.ImageField( upload_to="campaigns/images/", blank=True, null=True, + width_field="image_width", + height_field="image_height", help_text="Locally cached campaign image served from this site.", ) + image_width = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Width of cached image in pixels.", + ) + image_height = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Height of cached image in pixels.", + ) start_at = models.DateTimeField( null=True, blank=True, @@ -577,12 +605,26 @@ class DropBenefit(auto_prefetch.Model): default="", help_text="URL to the benefit's image asset.", ) - image_file = models.FileField( + image_file = models.ImageField( upload_to="benefits/images/", blank=True, null=True, + width_field="image_width", + height_field="image_height", help_text="Locally cached benefit image served from this site.", ) + image_width = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Width of cached image in pixels.", + ) + image_height = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Height of cached image in pixels.", + ) created_at = models.DateTimeField( null=True, help_text=("Timestamp when the benefit was created. This is from Twitch API and not auto-generated."), @@ -834,12 +876,26 @@ class RewardCampaign(auto_prefetch.Model): default="", help_text="URL to an image representing the reward campaign.", ) - image_file = models.FileField( + image_file = models.ImageField( upload_to="reward_campaigns/images/", blank=True, null=True, + width_field="image_width", + height_field="image_height", help_text="Locally cached reward campaign image served from this site.", ) + image_width = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Width of cached image in pixels.", + ) + image_height = models.PositiveIntegerField( + null=True, + blank=True, + editable=False, + help_text="Height of cached image in pixels.", + ) is_sitewide = models.BooleanField( default=False, help_text="Whether the reward campaign is sitewide.", diff --git a/twitch/utils.py b/twitch/utils.py index ca3da3e..4957dfb 100644 --- a/twitch/utils.py +++ b/twitch/utils.py @@ -13,7 +13,6 @@ from django.utils import timezone if TYPE_CHECKING: from datetime import datetime - TWITCH_BOX_ART_HOST = "static-cdn.jtvnw.net" TWITCH_BOX_ART_PATH_PREFIX = "/ttv-boxart/" TWITCH_BOX_ART_SIZE_PATTERN: re.Pattern[str] = re.compile(r"-(\{width\}|\d+)x(\{height\}|\d+)(?=\.[A-Za-z0-9]+$)") diff --git a/twitch/views.py b/twitch/views.py index cc57bc0..043fb01 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -95,6 +95,8 @@ 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, @@ -109,6 +111,8 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 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. @@ -128,6 +132,9 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 } 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: @@ -858,6 +865,8 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo else f"Twitch drop campaign: {campaign_name}" ) campaign_image: str | None = campaign.image_best_url + campaign_image_width: int | None = campaign.image_width if campaign.image_file else None + campaign_image_height: int | None = campaign.image_height if campaign.image_file else None campaign_schema: dict[str, str | dict[str, str]] = { "@context": "https://schema.org", @@ -905,6 +914,8 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo page_title=campaign_name, page_description=campaign_description, page_image=campaign_image, + page_image_width=campaign_image_width, + page_image_height=campaign_image_height, schema_data=campaign_schema, breadcrumb_schema=breadcrumb_schema, modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None, @@ -1174,6 +1185,8 @@ class GameDetailView(DetailView): f"Twitch drop campaigns for {game_name}. View active, upcoming, and completed drop rewards." ) game_image: str | None = game.box_art_best_url + game_image_width: int | None = game.box_art_width if game.box_art_file else None + game_image_height: int | None = game.box_art_height if game.box_art_file else None game_schema: dict[str, Any] = { "@context": "https://schema.org", @@ -1204,6 +1217,8 @@ class GameDetailView(DetailView): page_title=game_name, page_description=game_description, page_image=game_image, + page_image_width=game_image_width, + page_image_height=game_image_height, schema_data=game_schema, breadcrumb_schema=breadcrumb_schema, modified_date=game.updated_at.isoformat() if game.updated_at else None,