Download and cache campaign, benefit, and reward images locally
This commit is contained in:
parent
f4925b8e45
commit
55c2273e27
7 changed files with 346 additions and 266 deletions
265
example.json
265
example.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -52,7 +52,7 @@ Hover over the end time to see the exact date and time.
|
||||||
flex-shrink: 0">
|
flex-shrink: 0">
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
|
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
|
||||||
<img src="{{ campaign_data.campaign.image_url }}"
|
<img src="{{ campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url }}"
|
||||||
alt="Image for {{ campaign_data.campaign.name }}"
|
alt="Image for {{ campaign_data.campaign.name }}"
|
||||||
width="120"
|
width="120"
|
||||||
height="120"
|
height="120"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
<p>
|
<p>
|
||||||
<a href="{% url 'twitch:reward_campaign_list' %}">← Back to Reward Campaigns</a>
|
<a href="{% url 'twitch:reward_campaign_list' %}">← Back to Reward Campaigns</a>
|
||||||
</p>
|
</p>
|
||||||
|
<!-- Campaign image -->
|
||||||
|
{% if reward_campaign.image_url %}
|
||||||
|
<img height="160"
|
||||||
|
width="160"
|
||||||
|
src="{{ reward_campaign.image_best_url|default:reward_campaign.image_url }}"
|
||||||
|
alt="{{ reward_campaign.name }}" />
|
||||||
|
{% endif %}
|
||||||
<!-- RSS Feeds -->
|
<!-- RSS Feeds -->
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<a href="{% url 'twitch:reward_campaign_feed' %}"
|
<a href="{% url 'twitch:reward_campaign_feed' %}"
|
||||||
|
|
|
||||||
|
|
@ -1084,6 +1084,7 @@ class Command(BaseCommand):
|
||||||
"about_url": reward_campaign.about_url,
|
"about_url": reward_campaign.about_url,
|
||||||
"is_sitewide": reward_campaign.is_sitewide,
|
"is_sitewide": reward_campaign.is_sitewide,
|
||||||
"game": game_obj,
|
"game": game_obj,
|
||||||
|
"image_url": reward_campaign.image.image1x_url if reward_campaign.image else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
_reward_campaign_obj, created = RewardCampaign.objects.update_or_create(
|
_reward_campaign_obj, created = RewardCampaign.objects.update_or_create(
|
||||||
|
|
|
||||||
276
twitch/management/commands/download_campaign_images.py
Normal file
276
twitch/management/commands/download_campaign_images.py
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
"""Management command to download and cache campaign, benefit, and reward images locally."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import ParseResult
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.management.base import CommandParser
|
||||||
|
|
||||||
|
from twitch.models import DropBenefit
|
||||||
|
from twitch.models import DropCampaign
|
||||||
|
from twitch.models import RewardCampaign
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.db.models.fields.files import FieldFile
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Download and cache campaign, benefit, and reward images locally."""
|
||||||
|
|
||||||
|
help = "Download and cache campaign, benefit, and reward images locally."
|
||||||
|
|
||||||
|
def add_arguments(self, parser: CommandParser) -> 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}"))
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -811,6 +811,18 @@ class RewardCampaign(auto_prefetch.Model):
|
||||||
default="",
|
default="",
|
||||||
help_text="About URL for the reward campaign.",
|
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(
|
is_sitewide = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text="Whether the reward campaign is sitewide.",
|
help_text="Whether the reward campaign is sitewide.",
|
||||||
|
|
@ -863,6 +875,19 @@ class RewardCampaign(auto_prefetch.Model):
|
||||||
return False
|
return False
|
||||||
return self.starts_at <= now <= self.ends_at
|
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:
|
def get_feed_title(self) -> str:
|
||||||
"""Return the reward campaign name as the feed item title."""
|
"""Return the reward campaign name as the feed item title."""
|
||||||
if self.brand:
|
if self.brand:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue