Add image dimensions to models, and add backfill command

This commit is contained in:
Joakim Hellsén 2026-02-12 04:56:42 +01:00
commit 4727657285
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
7 changed files with 299 additions and 7 deletions

View file

@ -8,6 +8,7 @@ dependencies = [
"dateparser", "dateparser",
"django", "django",
"json-repair", "json-repair",
"pillow",
"platformdirs", "platformdirs",
"python-dotenv", "python-dotenv",
"pygments", "pygments",

View file

@ -4,6 +4,8 @@
{# - page_title: str - Page title (defaults to "ttvdrops") #} {# - page_title: str - Page title (defaults to "ttvdrops") #}
{# - page_description: str - Page description (defaults to site description) #} {# - page_description: str - Page description (defaults to site description) #}
{# - page_image: str - Image URL for og:image (optional) #} {# - 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) #} {# - page_url: str - Full URL for og:url and canonical (defaults to request.build_absolute_uri) #}
{# - og_type: str - OpenGraph type (defaults to "website") #} {# - og_type: str - OpenGraph type (defaults to "website") #}
{# - schema_data: str - JSON-LD schema data serialized as string (optional) #} {# - schema_data: str - JSON-LD schema data serialized as string (optional) #}
@ -36,8 +38,10 @@
content="{% firstof page_url request.build_absolute_uri %}" /> content="{% firstof page_url request.build_absolute_uri %}" />
{% if page_image %} {% if page_image %}
<meta property="og:image" content="{{ page_image }}" /> <meta property="og:image" content="{{ page_image }}" />
<meta property="og:image:width" content="1200" /> {% if page_image_width and page_image_height %}
<meta property="og:image:height" content="630" /> <meta property="og:image:width" content="{{ page_image_width }}" />
<meta property="og:image:height" content="{{ page_image_height }}" />
{% endif %}
{% endif %} {% endif %}
{# Twitter Card tags for rich previews #} {# Twitter Card tags for rich previews #}
<meta name="twitter:card" <meta name="twitter:card"

View file

@ -0,0 +1,73 @@
"""Management command to backfill image dimensions for existing cached images."""
from __future__ import annotations
from django.core.management.base import BaseCommand
from twitch.models import DropBenefit
from twitch.models import DropCampaign
from twitch.models import Game
from twitch.models import RewardCampaign
class Command(BaseCommand):
"""Backfill image width and height fields for existing cached images."""
help = "Backfill image dimensions for existing cached images"
def handle(self, *args, **options) -> 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."),
)

View file

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

View file

@ -104,12 +104,26 @@ class Game(auto_prefetch.Model):
verbose_name="Box art URL", verbose_name="Box art URL",
) )
box_art_file = models.FileField( box_art_file = models.ImageField(
upload_to="games/box_art/", upload_to="games/box_art/",
blank=True, blank=True,
null=True, null=True,
width_field="box_art_width",
height_field="box_art_height",
help_text="Locally cached box art image served from this site.", 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( owners = models.ManyToManyField(
Organization, Organization,
@ -332,12 +346,26 @@ class DropCampaign(auto_prefetch.Model):
default="", default="",
help_text="URL to an image representing the campaign.", help_text="URL to an image representing the campaign.",
) )
image_file = models.FileField( image_file = models.ImageField(
upload_to="campaigns/images/", upload_to="campaigns/images/",
blank=True, blank=True,
null=True, null=True,
width_field="image_width",
height_field="image_height",
help_text="Locally cached campaign image served from this site.", 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( start_at = models.DateTimeField(
null=True, null=True,
blank=True, blank=True,
@ -577,12 +605,26 @@ class DropBenefit(auto_prefetch.Model):
default="", default="",
help_text="URL to the benefit's image asset.", help_text="URL to the benefit's image asset.",
) )
image_file = models.FileField( image_file = models.ImageField(
upload_to="benefits/images/", upload_to="benefits/images/",
blank=True, blank=True,
null=True, null=True,
width_field="image_width",
height_field="image_height",
help_text="Locally cached benefit image served from this site.", 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( created_at = models.DateTimeField(
null=True, null=True,
help_text=("Timestamp when the benefit was created. This is from Twitch API and not auto-generated."), 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="", default="",
help_text="URL to an image representing the reward campaign.", help_text="URL to an image representing the reward campaign.",
) )
image_file = models.FileField( image_file = models.ImageField(
upload_to="reward_campaigns/images/", upload_to="reward_campaigns/images/",
blank=True, blank=True,
null=True, null=True,
width_field="image_width",
height_field="image_height",
help_text="Locally cached reward campaign image served from this site.", 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( is_sitewide = models.BooleanField(
default=False, default=False,
help_text="Whether the reward campaign is sitewide.", help_text="Whether the reward campaign is sitewide.",

View file

@ -13,7 +13,6 @@ from django.utils import timezone
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
TWITCH_BOX_ART_HOST = "static-cdn.jtvnw.net" TWITCH_BOX_ART_HOST = "static-cdn.jtvnw.net"
TWITCH_BOX_ART_PATH_PREFIX = "/ttv-boxart/" 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]+$)") TWITCH_BOX_ART_SIZE_PATTERN: re.Pattern[str] = re.compile(r"-(\{width\}|\d+)x(\{height\}|\d+)(?=\.[A-Za-z0-9]+$)")

View file

@ -95,6 +95,8 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
page_title: str = "ttvdrops", page_title: str = "ttvdrops",
page_description: str | None = None, page_description: str | None = None,
page_image: str | None = None, page_image: str | None = None,
page_image_width: int | None = None,
page_image_height: int | None = None,
og_type: str = "website", og_type: str = "website",
schema_data: dict[str, Any] | None = None, schema_data: dict[str, Any] | None = None,
breadcrumb_schema: 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_title: Page title (shown in browser tab, og:title).
page_description: Page description (meta description, og:description). page_description: Page description (meta description, og:description).
page_image: Image URL for og:image meta tag. 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"). og_type: OpenGraph type (e.g., "website", "article").
schema_data: Dict representation of Schema.org JSON-LD data. schema_data: Dict representation of Schema.org JSON-LD data.
breadcrumb_schema: Breadcrumb schema dict for navigation hierarchy. breadcrumb_schema: Breadcrumb schema dict for navigation hierarchy.
@ -128,6 +132,9 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
} }
if page_image: if page_image:
context["page_image"] = 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: if schema_data:
context["schema_data"] = json.dumps(schema_data) context["schema_data"] = json.dumps(schema_data)
if breadcrumb_schema: 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}" else f"Twitch drop campaign: {campaign_name}"
) )
campaign_image: str | None = campaign.image_best_url 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]] = { campaign_schema: dict[str, str | dict[str, str]] = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -905,6 +914,8 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
page_title=campaign_name, page_title=campaign_name,
page_description=campaign_description, page_description=campaign_description,
page_image=campaign_image, page_image=campaign_image,
page_image_width=campaign_image_width,
page_image_height=campaign_image_height,
schema_data=campaign_schema, schema_data=campaign_schema,
breadcrumb_schema=breadcrumb_schema, breadcrumb_schema=breadcrumb_schema,
modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None, 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." 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: 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] = { game_schema: dict[str, Any] = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -1204,6 +1217,8 @@ class GameDetailView(DetailView):
page_title=game_name, page_title=game_name,
page_description=game_description, page_description=game_description,
page_image=game_image, page_image=game_image,
page_image_width=game_image_width,
page_image_height=game_image_height,
schema_data=game_schema, schema_data=game_schema,
breadcrumb_schema=breadcrumb_schema, breadcrumb_schema=breadcrumb_schema,
modified_date=game.updated_at.isoformat() if game.updated_at else None, modified_date=game.updated_at.isoformat() if game.updated_at else None,