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,