From 55c2273e27bbcbb398c78698970009c73c74fd58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Thu, 12 Feb 2026 01:34:51 +0100 Subject: [PATCH] Download and cache campaign, benefit, and reward images locally --- example.json | 265 ----------------- templates/twitch/dashboard.html | 2 +- templates/twitch/reward_campaign_detail.html | 7 + .../commands/better_import_drops.py | 1 + .../commands/download_campaign_images.py | 276 ++++++++++++++++++ ...ign_image_file_rewardcampaign_image_url.py | 36 +++ twitch/models.py | 25 ++ 7 files changed, 346 insertions(+), 266 deletions(-) delete mode 100644 example.json create mode 100644 twitch/management/commands/download_campaign_images.py create mode 100644 twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py diff --git a/example.json b/example.json deleted file mode 100644 index 029e342..0000000 --- a/example.json +++ /dev/null @@ -1,265 +0,0 @@ -[ - { - "data": { - "user": { - "id": "17658559", - "dropCampaign": { - "id": "3b965979-ecd2-11f0-876e-0a58a9feac02", - "self": { - "isAccountConnected": true, - "__typename": "DropCampaignSelfEdge" - }, - "allow": { - "channels": null, - "isEnabled": false, - "__typename": "DropCampaignACL" - }, - "accountLinkURL": "https://link.smite2.com/", - "description": "Viewers will receive 50 Wandering Market Coins for each two hours spent viewing participating streams. Watch to earn 7 drops for a total of 350 Wandering Market Coins for the week!", - "detailsURL": "https://www.smite2.com/news/closed-alpha-twitch-drops/", - "endAt": "2026-01-17T10:58:59.999Z", - "eventBasedDrops": [], - "game": { - "id": "2094865572", - "slug": "smite-2", - "displayName": "SMITE 2", - "__typename": "Game" - }, - "imageURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/47db66e8-933c-484f-ab5a-30ba09093098.png", - "name": "Jan Drops Week 2", - "owner": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "startAt": "2026-01-10T11:00:00Z", - "status": "ACTIVE", - "timeBasedDrops": [ - { - "id": "933c8f91-ecd2-11f0-b3fd-0a58a9feac02", - "requiredSubs": 0, - "benefitEdges": [ - { - "benefit": { - "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", - "createdAt": "2025-02-07T21:37:58.881Z", - "entitlementLimit": 1, - "game": { - "id": "2094865572", - "name": "SMITE 2", - "__typename": "Game" - }, - "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", - "isIosAvailable": false, - "name": "Market Coins Bundle 1", - "ownerOrganization": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "distributionType": "DIRECT_ENTITLEMENT", - "__typename": "DropBenefit" - }, - "entitlementLimit": 1, - "__typename": "DropBenefitEdge" - } - ], - "endAt": "2026-01-17T10:58:59.999Z", - "name": "Market Coins Bundle 1", - "preconditionDrops": null, - "requiredMinutesWatched": 120, - "startAt": "2026-01-10T11:00:00Z", - "__typename": "TimeBasedDrop" - }, - { - "id": "9909373d-ecd2-11f0-92b1-0a58a9feac02", - "requiredSubs": 0, - "benefitEdges": [ - { - "benefit": { - "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", - "createdAt": "2025-02-07T21:37:58.881Z", - "entitlementLimit": 1, - "game": { - "id": "2094865572", - "name": "SMITE 2", - "__typename": "Game" - }, - "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", - "isIosAvailable": false, - "name": "Market Coins Bundle 1", - "ownerOrganization": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "distributionType": "DIRECT_ENTITLEMENT", - "__typename": "DropBenefit" - }, - "entitlementLimit": 1, - "__typename": "DropBenefitEdge" - } - ], - "endAt": "2026-01-17T10:58:59.999Z", - "name": "Market Coins Bundle 2", - "preconditionDrops": null, - "requiredMinutesWatched": 240, - "startAt": "2026-01-10T11:00:00Z", - "__typename": "TimeBasedDrop" - }, - { - "id": "a5289489-ecd2-11f0-b098-0a58a9feac02", - "requiredSubs": 0, - "benefitEdges": [ - { - "benefit": { - "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", - "createdAt": "2025-02-07T21:37:58.881Z", - "entitlementLimit": 1, - "game": { - "id": "2094865572", - "name": "SMITE 2", - "__typename": "Game" - }, - "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", - "isIosAvailable": false, - "name": "Market Coins Bundle 1", - "ownerOrganization": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "distributionType": "DIRECT_ENTITLEMENT", - "__typename": "DropBenefit" - }, - "entitlementLimit": 1, - "__typename": "DropBenefitEdge" - } - ], - "endAt": "2026-01-17T10:58:59.999Z", - "name": "Market Coins Bundle 3", - "preconditionDrops": null, - "requiredMinutesWatched": 360, - "startAt": "2026-01-10T11:00:00Z", - "__typename": "TimeBasedDrop" - }, - { - "id": "ab5ea171-ecd2-11f0-9e33-0a58a9feac02", - "requiredSubs": 0, - "benefitEdges": [ - { - "benefit": { - "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", - "createdAt": "2025-02-07T21:37:58.881Z", - "entitlementLimit": 1, - "game": { - "id": "2094865572", - "name": "SMITE 2", - "__typename": "Game" - }, - "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", - "isIosAvailable": false, - "name": "Market Coins Bundle 1", - "ownerOrganization": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "distributionType": "DIRECT_ENTITLEMENT", - "__typename": "DropBenefit" - }, - "entitlementLimit": 1, - "__typename": "DropBenefitEdge" - } - ], - "endAt": "2026-01-17T10:58:59.999Z", - "name": "Market Coins Bundle 4", - "preconditionDrops": null, - "requiredMinutesWatched": 480, - "startAt": "2026-01-10T11:00:00Z", - "__typename": "TimeBasedDrop" - }, - { - "id": "b19b7afb-ecd2-11f0-bbd3-0a58a9feac02", - "requiredSubs": 0, - "benefitEdges": [ - { - "benefit": { - "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", - "createdAt": "2025-02-07T21:37:58.881Z", - "entitlementLimit": 1, - "game": { - "id": "2094865572", - "name": "SMITE 2", - "__typename": "Game" - }, - "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", - "isIosAvailable": false, - "name": "Market Coins Bundle 1", - "ownerOrganization": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "distributionType": "DIRECT_ENTITLEMENT", - "__typename": "DropBenefit" - }, - "entitlementLimit": 1, - "__typename": "DropBenefitEdge" - } - ], - "endAt": "2026-01-17T10:58:59.999Z", - "name": "Market Coins Bundle 5", - "preconditionDrops": null, - "requiredMinutesWatched": 600, - "startAt": "2026-01-10T11:00:00Z", - "__typename": "TimeBasedDrop" - }, - { - "id": "b82db8e0-ecd2-11f0-8c96-0a58a9feac02", - "requiredSubs": 0, - "benefitEdges": [ - { - "benefit": { - "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", - "createdAt": "2025-02-07T21:37:58.881Z", - "entitlementLimit": 1, - "game": { - "id": "2094865572", - "name": "SMITE 2", - "__typename": "Game" - }, - "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", - "isIosAvailable": false, - "name": "Market Coins Bundle 1", - "ownerOrganization": { - "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", - "name": "Hi-Rez Studios", - "__typename": "Organization" - }, - "distributionType": "DIRECT_ENTITLEMENT", - "__typename": "DropBenefit" - }, - "entitlementLimit": 1, - "__typename": "DropBenefitEdge" - } - ], - "endAt": "2026-01-17T10:58:59.999Z", - "name": "Market Coins Bundle 6", - "preconditionDrops": null, - "requiredMinutesWatched": 720, - "startAt": "2026-01-10T11:00:00Z", - "__typename": "TimeBasedDrop" - } - ], - "__typename": "DropCampaign" - }, - "__typename": "User" - } - }, - "extensions": { - "durationMilliseconds": 48, - "operationName": "DropCampaignDetails" - } - } -] diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index f6ee0c2..a769793 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -52,7 +52,7 @@ Hover over the end time to see the exact date and time. flex-shrink: 0">
- Image for {{ campaign_data.campaign.name }} ← Back to Reward Campaigns

+ + {% if reward_campaign.image_url %} + {{ reward_campaign.name }} + {% endif %}
None: + """Register command arguments.""" + parser.add_argument( + "--model", + type=str, + choices=["campaigns", "benefits", "rewards", "all"], + default="all", + help="Which model to download images for (campaigns, benefits, rewards, or all).", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Limit the number of items to process per model.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Re-download even if a local image file already exists.", + ) + + def handle(self, *_args: object, **options: object) -> None: + """Download images for campaigns, benefits, and/or rewards.""" + model_choice: str = str(options.get("model", "all")) + limit_value: object | None = options.get("limit") + limit: int | None = limit_value if isinstance(limit_value, int) else None + force: bool = bool(options.get("force")) + + total_stats: dict[str, int] = { + "total": 0, + "downloaded": 0, + "skipped": 0, + "failed": 0, + "placeholders_404": 0, + } + + with httpx.Client(timeout=20, follow_redirects=True) as client: + if model_choice in {"campaigns", "all"}: + self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Drop Campaigns...")) + stats = self._download_campaign_images(client=client, limit=limit, force=force) + self._merge_stats(total_stats, stats) + self._print_stats("Drop Campaigns", stats) + + if model_choice in {"benefits", "all"}: + self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Drop Benefits...")) + stats = self._download_benefit_images(client=client, limit=limit, force=force) + self._merge_stats(total_stats, stats) + self._print_stats("Drop Benefits", stats) + + if model_choice in {"rewards", "all"}: + self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Reward Campaigns...")) + stats = self._download_reward_campaign_images(client=client, limit=limit, force=force) + self._merge_stats(total_stats, stats) + self._print_stats("Reward Campaigns", stats) + + if model_choice == "all": + self.stdout.write(self.style.MIGRATE_HEADING("\nTotal Summary:")) + self.stdout.write( + self.style.SUCCESS( + f"Processed {total_stats['total']} items. " + f"Downloaded: {total_stats['downloaded']}, " + f"Skipped: {total_stats['skipped']}, " + f"404 placeholders: {total_stats['placeholders_404']}, " + f"Failed: {total_stats['failed']}.", + ), + ) + + def _download_campaign_images( + self, + client: httpx.Client, + limit: int | None, + *, + force: bool, + ) -> dict[str, int]: + """Download DropCampaign images. + + Returns: + Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404). + """ + queryset: QuerySet[DropCampaign] = DropCampaign.objects.all().order_by("twitch_id") + if limit: + queryset = queryset[:limit] + + stats: dict[str, int] = {"total": 0, "downloaded": 0, "skipped": 0, "failed": 0, "placeholders_404": 0} + stats["total"] = queryset.count() + + for campaign in queryset: + if not campaign.image_url: + stats["skipped"] += 1 + continue + if campaign.image_file and getattr(campaign.image_file, "name", "") and not force: + stats["skipped"] += 1 + continue + + result = self._download_image( + client, + campaign.image_url, + campaign.twitch_id, + campaign.image_file, + ) + stats[result] += 1 + + return stats + + def _download_benefit_images( + self, + client: httpx.Client, + limit: int | None, + *, + force: bool, + ) -> dict[str, int]: + """Download DropBenefit images. + + Returns: + Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404). + """ + queryset: QuerySet[DropBenefit] = DropBenefit.objects.all().order_by("twitch_id") + if limit: + queryset = queryset[:limit] + + stats: dict[str, int] = {"total": 0, "downloaded": 0, "skipped": 0, "failed": 0, "placeholders_404": 0} + stats["total"] = queryset.count() + + for benefit in queryset: + if not benefit.image_asset_url: + stats["skipped"] += 1 + continue + if benefit.image_file and getattr(benefit.image_file, "name", "") and not force: + stats["skipped"] += 1 + continue + + result = self._download_image( + client, + benefit.image_asset_url, + benefit.twitch_id, + benefit.image_file, + ) + stats[result] += 1 + + return stats + + def _download_reward_campaign_images( + self, + client: httpx.Client, + limit: int | None, + *, + force: bool, + ) -> dict[str, int]: + """Download RewardCampaign images. + + Returns: + Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404). + """ + queryset: QuerySet[RewardCampaign] = RewardCampaign.objects.all().order_by("twitch_id") + if limit: + queryset = queryset[:limit] + + stats: dict[str, int] = {"total": 0, "downloaded": 0, "skipped": 0, "failed": 0, "placeholders_404": 0} + stats["total"] = queryset.count() + + for reward_campaign in queryset: + if not reward_campaign.image_url: + stats["skipped"] += 1 + continue + if reward_campaign.image_file and getattr(reward_campaign.image_file, "name", "") and not force: + stats["skipped"] += 1 + continue + + result = self._download_image( + client, + reward_campaign.image_url, + reward_campaign.twitch_id, + reward_campaign.image_file, + ) + stats[result] += 1 + + return stats + + def _download_image( + self, + client: httpx.Client, + image_url: str, + twitch_id: str, + file_field: FieldFile, + ) -> str: + """Download a single image and save it to the file field. + + Args: + client: httpx.Client instance for making requests. + image_url: URL of the image to download. + twitch_id: Twitch ID to use in filename. + file_field: Django FileField to save the image to. + + Returns: + Status string: 'downloaded', 'skipped', 'failed', or 'placeholders_404'. + """ + parsed_url: ParseResult = urlparse(image_url) + suffix: str = Path(parsed_url.path).suffix or ".jpg" + file_name: str = f"{twitch_id}{suffix}" + + try: + response: httpx.Response = client.get(image_url) + response.raise_for_status() + except httpx.HTTPError as exc: + self.stdout.write( + self.style.WARNING( + f"Failed to download image for {twitch_id}: {exc}", + ), + ) + return "failed" + + # Check for 404 placeholder images (common pattern on Twitch) + if "/ttv-static/404_" in str(response.url.path): + return "placeholders_404" + + # Save the image to the FileField + if hasattr(file_field, "save"): + file_field.save(file_name, ContentFile(response.content), save=True) + return "downloaded" + + return "failed" + + def _merge_stats(self, total: dict[str, int], new: dict[str, int]) -> None: + """Merge statistics from a single model into the total stats.""" + for key in ["total", "downloaded", "skipped", "failed", "placeholders_404"]: + total[key] += new[key] + + def _print_stats(self, model_name: str, stats: dict[str, int]) -> None: + """Print statistics for a specific model.""" + self.stdout.write( + self.style.SUCCESS( + f"{model_name}: Processed {stats['total']} items. " + f"Downloaded: {stats['downloaded']}, " + f"Skipped: {stats['skipped']}, " + f"404 placeholders: {stats['placeholders_404']}, " + f"Failed: {stats['failed']}.", + ), + ) + if stats["downloaded"] > 0: + media_path = Path(settings.MEDIA_ROOT) + if "Campaigns" in model_name and "Reward" not in model_name: + image_dir = media_path / "campaigns" / "images" + elif "Benefits" in model_name: + image_dir = media_path / "benefits" / "images" + else: + image_dir = media_path / "reward_campaigns" / "images" + self.stdout.write(self.style.SUCCESS(f"Saved images to: {image_dir}")) diff --git a/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py b/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py new file mode 100644 index 0000000..d8b5a91 --- /dev/null +++ b/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.2 on 2026-02-11 22:55 +from __future__ import annotations + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Add image_file and image_url fields to RewardCampaign model for storing local file and original URL of campaign images.""" # noqa: E501 + + dependencies = [ + ("twitch", "0009_alter_chatbadge_badge_set_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="rewardcampaign", + name="image_file", + field=models.FileField( + blank=True, + help_text="Locally cached reward campaign image served from this site.", + null=True, + upload_to="reward_campaigns/images/", + ), + ), + migrations.AddField( + model_name="rewardcampaign", + name="image_url", + field=models.URLField( + blank=True, + default="", + help_text="URL to an image representing the reward campaign.", + max_length=500, + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 8a9ca7e..5b0fb0b 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -811,6 +811,18 @@ class RewardCampaign(auto_prefetch.Model): default="", help_text="About URL for the reward campaign.", ) + image_url = models.URLField( + max_length=500, + blank=True, + default="", + help_text="URL to an image representing the reward campaign.", + ) + image_file = models.FileField( + upload_to="reward_campaigns/images/", + blank=True, + null=True, + help_text="Locally cached reward campaign image served from this site.", + ) is_sitewide = models.BooleanField( default=False, help_text="Whether the reward campaign is sitewide.", @@ -863,6 +875,19 @@ class RewardCampaign(auto_prefetch.Model): return False return self.starts_at <= now <= self.ends_at + @property + def image_best_url(self) -> str: + """Return the best URL for the reward campaign image (local first).""" + try: + if self.image_file and getattr(self.image_file, "url", None): + return self.image_file.url + except (AttributeError, OSError, ValueError) as exc: + logger.debug( + "Failed to resolve RewardCampaign.image_file url: %s", + exc, + ) + return self.image_url or "" + def get_feed_title(self) -> str: """Return the reward campaign name as the feed item title.""" if self.brand: