Skip to content

Commit 6430183

Browse files
Hugh Kaznowskiclaude
authored andcommitted
Add Scryfall CDN image lookup via cdn_uuid asset files
Introduces CdnUuidCache which lazily loads res/cdn_uuid/{setCode}/{cn}.json files and resolves CDN URLs (cards.scryfall.io, no rate limit) for card image fetching. ImageFetcher and GuiDownloadFilteredCardImages prefer CDN URLs when the asset files are present, falling back to the Scryfall API and then the cardforge server as before. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a51e07e commit 6430183

7 files changed

Lines changed: 263 additions & 35 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package forge.download;
2+
3+
import forge.gui.download.ScryfallBulkData;
4+
import org.testng.Assert;
5+
import org.testng.annotations.Test;
6+
7+
/**
8+
* Unit tests for {@link ScryfallBulkData}.
9+
*
10+
* Card UUIDs are stored in {@code res/cdn_uuid/{setCode}/{collectorNumber}.json} files
11+
* (part of the assets zip) and loaded at runtime by {@link forge.gui.download.CdnUuidCache}.
12+
*/
13+
@Test(groups = {"UnitTest"})
14+
public class ScryfallBulkDataTest {
15+
16+
@Test
17+
public void testCdnUrlFormula() {
18+
String uuid = "4e7a547f-d1b0-4f4e-9a99-3c44fc89c048";
19+
Assert.assertEquals(
20+
ScryfallBulkData.cdnUrl(uuid, "front", "normal"),
21+
"https://cards.scryfall.io/normal/front/4/e/" + uuid + ".jpg");
22+
Assert.assertEquals(
23+
ScryfallBulkData.cdnUrl(uuid, "back", "art_crop"),
24+
"https://cards.scryfall.io/art_crop/back/4/e/" + uuid + ".jpg");
25+
}
26+
}

forge-gui/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@
8282
<artifactId>jetty-servlet</artifactId>
8383
<version>${jetty.version}</version>
8484
</dependency>
85+
<dependency>
86+
<groupId>com.google.code.gson</groupId>
87+
<artifactId>gson</artifactId>
88+
<version>2.13.2</version>
89+
</dependency>
8590
</dependencies>
8691

8792
</project>
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package forge.gui.download;
2+
3+
import com.google.gson.JsonArray;
4+
import com.google.gson.JsonElement;
5+
import com.google.gson.JsonObject;
6+
import com.google.gson.JsonParser;
7+
import forge.localinstance.properties.ForgeConstants;
8+
import org.tinylog.Logger;
9+
10+
import java.io.File;
11+
import java.io.FileReader;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.Collections;
14+
import java.util.HashMap;
15+
import java.util.Map;
16+
import java.util.concurrent.ConcurrentHashMap;
17+
18+
/**
19+
* Lazy-loading, thread-safe cache for Scryfall CDN UUIDs stored in
20+
* {@code forge-gui/res/cdn_uuid/{setCode}/{collectorNumber}.json}.
21+
*
22+
* <p>Each JSON file maps language codes to the CDN UUID for that card print:
23+
* <ul>
24+
* <li>{@code {"en":"uuid"}} — single language</li>
25+
* <li>{@code {"en":"uuid","ja":"ja-uuid"}} — multiple languages</li>
26+
* <li>{@code {"en":["frontUuid","backUuid"]}} — DFC with distinct face UUIDs (rare)</li>
27+
* </ul>
28+
*
29+
* <p>On the first lookup for a given set, all {@code {cn}.json} files in that set's
30+
* directory are loaded at once and held in memory for the lifetime of the process.
31+
* Returns {@code null} when no UUID data is available (caller falls back to the
32+
* rate-limited Scryfall API or the cardforge server).
33+
*/
34+
public final class CdnUuidCache {
35+
36+
private static final String FALLBACK_LANG = "en";
37+
/** Sentinel: set directory was scanned and found empty / absent. */
38+
private static final Map<String, Map<String, LangUuids>> MISSING_SET = Collections.emptyMap();
39+
40+
/**
41+
* Holds the front and (optionally different) back UUID for one language of one card.
42+
* {@code back} is null when both faces share the same UUID.
43+
*/
44+
private static final class LangUuids {
45+
final String front;
46+
final String back; // null → same as front
47+
LangUuids(String front, String back) { this.front = front; this.back = back; }
48+
}
49+
50+
/** Cache: setCode -> (collectorNumber -> (lang -> LangUuids)) */
51+
private static final ConcurrentHashMap<String, Map<String, Map<String, LangUuids>>> setCache =
52+
new ConcurrentHashMap<>();
53+
54+
private CdnUuidCache() {}
55+
56+
/**
57+
* Returns the Scryfall CDN image URL for a given card face, or {@code null}
58+
* if no UUID data is available.
59+
*
60+
* @param scryfallCode lowercase Scryfall set code (e.g. {@code "ltr"})
61+
* @param collectorNum collector number as in Scryfall data (e.g. {@code "51"}, {@code "T1"})
62+
* @param lang preferred language code (e.g. {@code "en"}, {@code "ja"})
63+
* @param face {@code ""} or {@code "front"} for the front face; {@code "back"} for the back
64+
* @param size {@code "normal"} or {@code "art_crop"}
65+
*/
66+
public static String getCdnUrl(String scryfallCode, String collectorNum,
67+
String lang, String face, String size) {
68+
if (scryfallCode == null || collectorNum == null) return null;
69+
String setCode = scryfallCode.toLowerCase();
70+
boolean wantBack = "back".equals(face);
71+
72+
Map<String, Map<String, LangUuids>> cardMap = ensureSetLoaded(setCode);
73+
if (cardMap == MISSING_SET) return null;
74+
75+
Map<String, LangUuids> langMap = cardMap.get(collectorNum);
76+
if (langMap == null) return null;
77+
78+
LangUuids uuids = langMap.get(lang);
79+
if (uuids == null && !FALLBACK_LANG.equals(lang)) uuids = langMap.get(FALLBACK_LANG);
80+
if (uuids == null) return null;
81+
82+
String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front;
83+
String side = wantBack ? "back" : "front";
84+
return ScryfallBulkData.cdnUrl(uuid, side, size);
85+
}
86+
87+
// -------------------------------------------------------------------------
88+
89+
private static Map<String, Map<String, LangUuids>> ensureSetLoaded(String setCode) {
90+
Map<String, Map<String, LangUuids>> cached = setCache.get(setCode);
91+
if (cached != null) return cached;
92+
93+
File setDir = new File(ForgeConstants.CDN_UUID_DIR + setCode);
94+
if (!setDir.isDirectory()) {
95+
setCache.put(setCode, MISSING_SET);
96+
return MISSING_SET;
97+
}
98+
99+
File[] files = setDir.listFiles(f -> f.getName().endsWith(".json"));
100+
if (files == null || files.length == 0) {
101+
setCache.put(setCode, MISSING_SET);
102+
return MISSING_SET;
103+
}
104+
105+
Map<String, Map<String, LangUuids>> cardMap = new HashMap<>(files.length * 2);
106+
for (File f : files) {
107+
String cn = f.getName().substring(0, f.getName().length() - 5); // strip .json
108+
try {
109+
Map<String, LangUuids> langMap = parseCardFile(f);
110+
if (!langMap.isEmpty()) cardMap.put(cn, langMap);
111+
} catch (Exception e) {
112+
Logger.warn("CdnUuidCache: failed to parse {}: {}", f, e.getMessage());
113+
}
114+
}
115+
116+
Map<String, Map<String, LangUuids>> result = Collections.unmodifiableMap(cardMap);
117+
setCache.put(setCode, result);
118+
return result;
119+
}
120+
121+
/**
122+
* Parses a single {@code {cn}.json} file.
123+
* Format: {@code {"en":"uuid","ja":["fuuid","buuid"],...}}
124+
*/
125+
private static Map<String, LangUuids> parseCardFile(File file) throws Exception {
126+
JsonObject obj;
127+
try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) {
128+
obj = JsonParser.parseReader(reader).getAsJsonObject();
129+
}
130+
Map<String, LangUuids> result = new HashMap<>(obj.size() * 2);
131+
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
132+
JsonElement val = entry.getValue();
133+
if (val.isJsonPrimitive()) {
134+
result.put(entry.getKey(), new LangUuids(val.getAsString(), null));
135+
} else if (val.isJsonArray()) {
136+
JsonArray arr = val.getAsJsonArray();
137+
if (arr.size() >= 2) {
138+
String front = arr.get(0).getAsString();
139+
String back = arr.get(1).getAsString();
140+
result.put(entry.getKey(), new LangUuids(front, back.equals(front) ? null : back));
141+
} else if (arr.size() == 1) {
142+
result.put(entry.getKey(), new LangUuids(arr.get(0).getAsString(), null));
143+
}
144+
}
145+
}
146+
return result;
147+
}
148+
}

forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818

1919
/**
2020
* Downloads card images for all cards that match the supplied predicate.
21-
* Uses Scryfall as the primary source (matching the auto-downloader path) so
22-
* that images are actually available; falls back to the cardforge hosted server
23-
* for cards that lack a collector number.
21+
*
22+
* URL priority per card face:
23+
* 1. cards.scryfall.io CDN (not rate-limited) — when a UUID JSON file exists at
24+
* {@code res/cdn_uuid/{scryfallCode}/{collectorNumber}.json} for this card
25+
* 2. api.scryfall.com per-card API (rate-limited, 100 ms/request) — fallback when
26+
* no UUID is available but the card has a collector number
27+
* 3. cardforge hosted server — final fallback
2428
*/
2529
public class GuiDownloadFilteredCardImages extends GuiDownloadService {
2630

@@ -60,50 +64,57 @@ protected Map<String, String> getNeededFiles() {
6064

6165
private static void addIfMissing(PaperCard c, String face, Map<String, String> downloads) {
6266
final String imageKey = ImageUtil.getImageKey(c, face, true);
63-
if (imageKey == null) { return; }
67+
if (imageKey == null) return;
6468

65-
// Destination path for this card face in the local cache
66-
final File destFull = new File(ForgeConstants.CACHE_CARD_PICS_DIR, imageKey + ".jpg");
67-
// Also check for the fullborder variant that LibGDXImageFetcher produces from Scryfall
68-
final String fbKey = TextUtil.fastReplace(imageKey, ".full", ".fullborder") +
69-
(!imageKey.contains(".full") ? ".fullborder" : "") ;
70-
final File destFb = new File(ForgeConstants.CACHE_CARD_PICS_DIR, fbKey + ".jpg");
69+
final File destFull = new File(ForgeConstants.CACHE_CARD_PICS_DIR, imageKey + ".jpg");
70+
final String fbKey = TextUtil.fastReplace(imageKey, ".full", ".fullborder") +
71+
(!imageKey.contains(".full") ? ".fullborder" : "");
72+
final File destFb = new File(ForgeConstants.CACHE_CARD_PICS_DIR, fbKey + ".jpg");
7173

72-
if (destFull.exists() || destFb.exists()) { return; }
73-
if (downloads.containsKey(destFull.getAbsolutePath())) { return; }
74+
if (destFull.exists() || destFb.exists()) return;
75+
if (downloads.containsKey(destFull.getAbsolutePath())) return;
7476

7577
final String url = buildUrl(c, face);
76-
if (url == null) { return; }
78+
if (url == null) return;
7779

7880
downloads.put(destFull.getAbsolutePath(), url);
7981
}
8082

8183
/**
82-
* Builds the download URL for one card face.
83-
* Prefers Scryfall (which works) for cards that have a collector number;
84-
* falls back to the cardforge hosted server otherwise.
84+
* Returns the best available download URL for one card face.
85+
*
86+
* Priority:
87+
* 1. cards.scryfall.io CDN URL from cdn_uuid JSON file (no rate limit; optional assets)
88+
* 2. api.scryfall.com per-card API URL (rate-limited; GuiDownloadService
89+
* enforces 100 ms between requests to api.scryfall.com URLs automatically)
90+
* 3. cardforge hosted server
8591
*/
8692
private static String buildUrl(PaperCard c, String face) {
8793
final String collectorNum = c.getCollectorNumber();
8894
final boolean hasCollectorNum = !IPaperCard.NO_COLLECTOR_NUMBER.equals(collectorNum)
8995
&& !"0".equals(collectorNum)
9096
&& !StringUtils.isBlank(collectorNum);
9197

92-
if (hasCollectorNum) {
93-
CardEdition edition = StaticData.instance().getEditions().get(c.getEdition());
94-
if (edition != null) {
95-
String scryfallCode = edition.getScryfallCode();
96-
if (!StringUtils.isBlank(scryfallCode)) {
97-
String langCode = edition.getCardsLangCode();
98-
String path = ImageUtil.getScryfallDownloadUrl(c, face, scryfallCode, langCode, false);
99-
if (path != null) {
100-
return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + path;
101-
}
102-
}
103-
}
98+
CardEdition edition = hasCollectorNum
99+
? StaticData.instance().getEditions().get(c.getEdition()) : null;
100+
String scryfallCode = (edition != null) ? edition.getScryfallCode() : null;
101+
boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode);
102+
103+
// 1. CDN — fast, no rate limit; requires cdn_uuid JSON files in assets
104+
if (edition != null && hasCollectorNum && hasScryfallCode) {
105+
String cdnUrl = CdnUuidCache.getCdnUrl(
106+
scryfallCode, collectorNum, edition.getCardsLangCode(), face, "normal");
107+
if (cdnUrl != null) return cdnUrl;
108+
}
109+
110+
// 2. Scryfall per-card API — rate-limited (100 ms/request via GuiDownloadService)
111+
if (hasCollectorNum && edition != null && hasScryfallCode) {
112+
String apiPath = ImageUtil.getScryfallDownloadUrl(
113+
c, face, scryfallCode, edition.getCardsLangCode(), false);
114+
if (apiPath != null) return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + apiPath;
104115
}
105116

106-
// Fallback: cardforge hosted server
117+
// 3. Cardforge hosted server
107118
String cardforgeUrl = ImageUtil.getDownloadUrl(c, face);
108119
return cardforgeUrl != null ? ForgeConstants.URL_PIC_DOWNLOAD + cardforgeUrl : null;
109120
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package forge.gui.download;
2+
3+
/**
4+
* Utility for constructing Scryfall CDN image URLs from card UUIDs.
5+
*
6+
* <p>The CDN ({@code cards.scryfall.io}) is not rate-limited. Given a UUID and image
7+
* size, the URL is fully deterministic:
8+
* <pre>
9+
* https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg
10+
* </pre>
11+
* where {@code size} is {@code "normal"} or {@code "art_crop"}.
12+
*
13+
* <p>UUIDs are loaded from {@code res/cdn_uuid/{setCode}/{collectorNumber}.json} asset files
14+
* by {@link CdnUuidCache}.
15+
*/
16+
public final class ScryfallBulkData {
17+
18+
private ScryfallBulkData() {}
19+
20+
/**
21+
* Builds a Scryfall CDN image URL.
22+
*
23+
* @param uuid the Scryfall card UUID (e.g. {@code "4e7a547f-..."})
24+
* @param side {@code "front"} or {@code "back"}
25+
* @param size {@code "normal"} or {@code "art_crop"}
26+
*/
27+
public static String cdnUrl(String uuid, String side, String size) {
28+
return "https://cards.scryfall.io/" + size + "/" + side
29+
+ "/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg";
30+
}
31+
}

forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public final class ForgeConstants {
9393
public static final String CARD_DATA_DIR = RES_DIR + "cardsfolder" + PATH_SEPARATOR;
9494
public static final String TOKEN_DATA_DIR = RES_DIR + "tokenscripts" + PATH_SEPARATOR;
9595
public static final String EDITIONS_DIR = RES_DIR + "editions" + PATH_SEPARATOR;
96+
public static final String CDN_UUID_DIR = RES_DIR + "cdn_uuid" + PATH_SEPARATOR;
9697
public static final String BLOCK_DATA_DIR = RES_DIR + "blockdata" + PATH_SEPARATOR;
9798
public static final String FORMATS_DATA_DIR = RES_DIR + "formats" + PATH_SEPARATOR;
9899
public static final String DECK_CUBE_DIR = RES_DIR + "cube" + PATH_SEPARATOR;

forge-gui/src/main/java/forge/util/ImageFetcher.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import forge.localinstance.properties.ForgeConstants;
1010
import forge.localinstance.properties.ForgePreferences;
1111
import forge.model.FModel;
12+
import org.apache.commons.lang3.StringUtils;
1213

1314
import java.io.File;
1415
import java.util.*;
@@ -71,16 +72,21 @@ private String getScryfallDownloadURL(PaperCard c, String face, boolean useArtCr
7172

7273
private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, ArrayList<String> downloadUrls) {
7374
CardEdition edition = StaticData.instance().getEditions().get(card.getEdition());
74-
if (edition == null) {
75-
return;
76-
}
75+
if (edition == null) return;
7776

7877
String setCode = edition.getScryfallCode();
7978
String langCode = edition.getCardsLangCode();
80-
String primaryUrl = ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(card, face, setCode, langCode, useArtCrop);
81-
if (!downloadUrls.contains(primaryUrl)) {
82-
downloadUrls.add(primaryUrl);
79+
80+
// Prefer CDN (no rate limit) when a UUID JSON file exists in the assets.
81+
if (!StringUtils.isBlank(setCode)) {
82+
String size = useArtCrop ? "art_crop" : "normal";
83+
String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrl(
84+
setCode, card.getCollectorNumber(), langCode, face, size);
85+
if (cdnUrl != null && !downloadUrls.contains(cdnUrl)) downloadUrls.add(cdnUrl);
8386
}
87+
88+
String primaryUrl = ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(card, face, setCode, langCode, useArtCrop);
89+
if (!downloadUrls.contains(primaryUrl)) downloadUrls.add(primaryUrl);
8490
}
8591

8692
protected boolean shouldTryScryfallSetLookupCandidate(PaperCard requestedCard, PaperCard candidate) {

0 commit comments

Comments
 (0)