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">
-
← Back to Reward Campaigns
+
+ {% if reward_campaign.image_url %}
+

+ {% 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: