diff --git a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java index cccdb4736f91..08bf5f9df120 100644 --- a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java +++ b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java @@ -34,9 +34,11 @@ private boolean doFetch(String urlToDownload) throws IOException { return false; } - String newdespath = urlToDownload.contains(".fullborder.jpg") || urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) ? + boolean isScryfallUrl = urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) + || urlToDownload.startsWith(ForgeConstants.URL_SCRYFALL_CDN); + String newdespath = urlToDownload.contains(".fullborder.jpg") || isScryfallUrl ? TextUtil.fastReplace(destPath, ".full.jpg", ".fullborder.jpg") : destPath; - if (!newdespath.contains(".full") && !newdespath.contains(".artcrop") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR)) + if (!newdespath.contains(".full") && !newdespath.contains(".artcrop") && isScryfallUrl && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR)) newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options URL url = new URL(urlToDownload); System.out.println("Attempting to fetch: " + url); diff --git a/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java b/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java new file mode 100644 index 000000000000..08bd2a8dacdb --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java @@ -0,0 +1,26 @@ +package forge.download; + +import forge.gui.download.ScryfallBulkData; +import org.testng.Assert; +import org.testng.annotations.Test; + +/** + * Unit tests for {@link ScryfallBulkData}. + * + * Card UUIDs are stored in {@code res/cdn_uuid/{setCode}/{collectorNumber}.json} files + * (part of the assets zip) and loaded at runtime by {@link forge.gui.download.CdnUuidCache}. + */ +@Test(groups = {"UnitTest"}) +public class ScryfallBulkDataTest { + + @Test + public void testCdnUrlFormula() { + String uuid = "4e7a547f-d1b0-4f4e-9a99-3c44fc89c048"; + Assert.assertEquals( + ScryfallBulkData.cdnUrl(uuid, "front", "normal"), + "https://cards.scryfall.io/normal/front/4/e/" + uuid + ".jpg"); + Assert.assertEquals( + ScryfallBulkData.cdnUrl(uuid, "back", "art_crop"), + "https://cards.scryfall.io/art_crop/back/4/e/" + uuid + ".jpg"); + } +} diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java new file mode 100644 index 000000000000..ec30cfbd951c --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -0,0 +1,209 @@ +package forge.gui.download; + +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +@Test(groups = {"UnitTest"}) +public class CdnUuidCacheTest { + + private static final String SET = "tst"; + private static final String SET_DFC = "dfc"; + private static final String SET_ABSENT = "xyz"; + + private static final String UUID_EN = "aaaaaaaa-bbbb-cccc-dddd-000000000001"; + private static final String UUID_JA = "aaaaaaaa-bbbb-cccc-dddd-000000000002"; + private static final String UUID_FRONT = "aaaaaaaa-bbbb-cccc-dddd-000000000003"; + private static final String UUID_BACK = "aaaaaaaa-bbbb-cccc-dddd-000000000004"; + + /** Local cache dir — starts empty; populated by the cache on first remote fetch. */ + private File localCacheDir; + /** Remote "server" dir — pre-populated with set JSON files, served via file:// URL. */ + private File remoteDir; + + @BeforeClass + public void setUp() throws IOException { + localCacheDir = Files.createTempDirectory("cdn_local").toFile(); + remoteDir = Files.createTempDirectory("cdn_remote").toFile(); + + // tst.json — single-faced cards, multiple languages + write(new File(remoteDir, SET + ".json"), + "{" + + "\"1\":{\"en\":\"" + UUID_EN + "\",\"ja\":\"" + UUID_JA + "\"}," + + "\"2\":{\"en\":\"" + UUID_EN + "\"}" + + "}"); + + // dfc.json — double-faced cards + write(new File(remoteDir, SET_DFC + ".json"), + "{" + + "\"1\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_BACK + "\"]}," + + "\"2\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}" + + "}"); + + // SET_ABSENT has no file in remoteDir — lookups must return null + + CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; + CdnUuidCache.remoteBaseUrlOverride = remoteDir.toURI().toURL().toString(); + CdnUuidCache.clearCacheForTesting(); + } + + @AfterClass + public void tearDown() { + CdnUuidCache.localCacheDirOverride = null; + CdnUuidCache.remoteBaseUrlOverride = null; + CdnUuidCache.clearCacheForTesting(); + deleteDir(localCacheDir); + deleteDir(remoteDir); + } + + // --- happy path --- + + @Test + public void englishFront_returnsCorrectCdnUrl() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal")); + } + + @Test + public void artCropSize_reflectedInUrl() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "art_crop"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "art_crop")); + } + + @Test + public void japaneseLang_returnsJaUuid() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "ja", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_JA, "front", "normal")); + } + + // --- language fallback --- + + @Test + public void unknownLang_fallsBackToEnglish() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "zz", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal")); + } + + @Test + public void cardWithOnlyEn_jaRequestFallsBack() { + String url = CdnUuidCache.getCdnUrl(SET, "2", "ja", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal")); + } + + // --- DFC (double-faced cards) --- + + @Test + public void dfcDistinctFaces_frontUuid() { + String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_FRONT, "front", "normal")); + } + + @Test + public void dfcDistinctFaces_backUuid() { + String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "back", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_BACK, "back", "normal")); + } + + @Test + public void dfcSameUuid_backRequestStillUsesSharedUuid() { + // When both faces share the same UUID, back is stored as null internally. + String url = CdnUuidCache.getCdnUrl(SET_DFC, "2", "en", "back", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_FRONT, "back", "normal")); + } + + @Test + public void dfcEmptyFaceString_treatedAsFront() { + // ImageFetcher passes "" for the front face. + String urlEmpty = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "", "normal"); + String urlFront = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal"); + Assert.assertEquals(urlEmpty, urlFront); + } + + // --- set code normalisation --- + + @Test + public void uppercaseSetCode_lowercasedBeforeLookup() { + String url = CdnUuidCache.getCdnUrl(SET.toUpperCase(), "1", "en", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal")); + } + + // --- remote fetch writes to local cache --- + + @Test + public void remoteFetch_writesLocalCacheFile() { + // After the first successful lookup, the set JSON should be present in localCacheDir. + CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); + Assert.assertTrue(new File(localCacheDir, SET + ".json").exists(), + "local cache file should be written after remote fetch"); + } + + @Test(dependsOnMethods = "remoteFetch_writesLocalCacheFile") + public void localCache_usedOnSubsequentLookup() throws IOException { + // Corrupt the in-memory cache but keep the local file; clear remote. + CdnUuidCache.clearCacheForTesting(); + CdnUuidCache.remoteBaseUrlOverride = "file:///nonexistent-dir/"; + try { + String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); + Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"), + "should resolve from local cache even when remote is unavailable"); + } finally { + CdnUuidCache.remoteBaseUrlOverride = remoteDir.toURI().toURL().toString(); + CdnUuidCache.clearCacheForTesting(); + } + } + + // --- null / missing inputs --- + + @Test + public void nullScryfallCode_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(null, "1", "en", "front", "normal")); + } + + @Test + public void nullCollectorNumber_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(SET, null, "en", "front", "normal")); + } + + @Test + public void absentSet_returnsNull() { + // No local file and no remote file for SET_ABSENT. + Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "1", "en", "front", "normal")); + } + + @Test(dependsOnMethods = "absentSet_returnsNull") + public void absentSetCachedAsMissing_secondCallAlsoNull() { + // MISSING_SET sentinel must be in cache; second lookup must not retry remote. + Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "99", "en", "front", "normal")); + } + + @Test + public void unknownCollectorNumber_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal")); + } + + // --- helpers --- + + private static void write(File f, String content) throws IOException { + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } + + private static void deleteDir(File dir) { + if (dir == null) return; + File[] children = dir.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory()) deleteDir(child); + else //noinspection ResultOfMethodCallIgnored + child.delete(); + } + } + //noinspection ResultOfMethodCallIgnored + dir.delete(); + } +} diff --git a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java index 54a993b2fbe9..75acb50dccb3 100644 --- a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java +++ b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java @@ -67,9 +67,11 @@ private boolean doFetch(String urlToDownload) throws IOException { } } - String newdespath = urlToDownload.contains(".fullborder.") || urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) ? + boolean isScryfallUrl = urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) + || urlToDownload.startsWith(ForgeConstants.URL_SCRYFALL_CDN); + String newdespath = urlToDownload.contains(".fullborder.") || isScryfallUrl ? TextUtil.fastReplace(destPath, ".full.", ".fullborder.") : destPath; - if (!newdespath.contains(".full") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) && + if (!newdespath.contains(".full") && isScryfallUrl && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR) && !destPath.startsWith(ForgeConstants.CACHE_PLANECHASE_PICS_DIR)) newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options URL url = new URL(urlToDownload); diff --git a/forge-gui/pom.xml b/forge-gui/pom.xml index 44b7e3e9ee76..327a6846afe3 100644 --- a/forge-gui/pom.xml +++ b/forge-gui/pom.xml @@ -82,6 +82,11 @@ jetty-servlet ${jetty.version} + + com.google.code.gson + gson + 2.13.2 + diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java new file mode 100644 index 000000000000..2fedcce9c0ee --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -0,0 +1,248 @@ +package forge.gui.download; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import forge.localinstance.properties.ForgeConstants; +import org.tinylog.Logger; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Lazy-loading, thread-safe cache for Scryfall CDN UUIDs. + * + *

UUID data lives in per-set JSON files hosted in the forge-extras repository. + * On the first lookup for a set, the cache checks for a local copy under + * {@code {cacheDir}/cdn_uuid/{setCode}.json}. If absent it fetches the file from + * forge-extras and writes it locally so subsequent lookups are instant. + * Returns {@code null} on any failure so callers fall back to the rate-limited + * Scryfall API or the cardforge server. + * + *

Set JSON format: + *

+ *   {
+ *     "1":   {"en": "uuid"},
+ *     "2":   {"en": "uuid", "ja": "ja-uuid"},
+ *     "A-40":{"en": ["frontUuid", "backUuid"]}
+ *   }
+ * 
+ */ +public final class CdnUuidCache { + + private static final String FALLBACK_LANG = "en"; + private static final int FETCH_TIMEOUT_MS = 10_000; + + /** Sentinel: set was looked up and no data exists (locally or remotely). */ + private static final Map> MISSING_SET = Collections.emptyMap(); + + private static final class LangUuids { + final String front; + final String back; // null → same UUID for both faces + LangUuids(String front, String back) { this.front = front; this.back = back; } + } + + /** Cache: setCode → (collectorNumber → (lang → LangUuids)) */ + private static final ConcurrentHashMap>> setCache = + new ConcurrentHashMap<>(); + + /** + * Override the local cache directory. Package-private for unit tests. + * Must end with the platform file separator when set. + */ + static volatile String localCacheDirOverride = null; + + /** + * Override the remote base URL. Package-private for unit tests. + * Must end with '/'. Supports {@code file://} URLs for offline testing. + */ + static volatile String remoteBaseUrlOverride = null; + + private CdnUuidCache() {} + + /** Clears the in-memory cache. Package-private for unit tests only. */ + static void clearCacheForTesting() { setCache.clear(); } + + /** + * Returns the Scryfall CDN image URL for a given card face, or {@code null} + * if no UUID data is available. + * + * @param scryfallCode lowercase Scryfall set code (e.g. {@code "ltr"}) + * @param collectorNum collector number as in Scryfall data (e.g. {@code "51"}, {@code "T1"}) + * @param lang preferred language code (e.g. {@code "en"}, {@code "ja"}) + * @param face {@code ""} or {@code "front"} for the front face; {@code "back"} for the back + * @param size {@code "normal"} or {@code "art_crop"} + */ + public static String getCdnUrl(String scryfallCode, String collectorNum, + String lang, String face, String size) { + if (scryfallCode == null || collectorNum == null) return null; + String setCode = scryfallCode.toLowerCase(); + boolean wantBack = "back".equals(face); + + Map> cardMap = ensureSetLoaded(setCode); + if (cardMap == MISSING_SET) return null; + + Map langMap = cardMap.get(collectorNum); + if (langMap == null) return null; + + LangUuids uuids = langMap.get(lang); + if (uuids == null && !FALLBACK_LANG.equals(lang)) uuids = langMap.get(FALLBACK_LANG); + if (uuids == null) return null; + + String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; + String side = wantBack ? "back" : "front"; + return ScryfallBulkData.cdnUrl(uuid, side, size); + } + + // ------------------------------------------------------------------------- + + private static Map> ensureSetLoaded(String setCode) { + Map> cached = setCache.get(setCode); + if (cached != null) return cached; + + Map> loaded = loadSet(setCode); + // putIfAbsent: if another thread raced and loaded first, use its result + Map> existing = setCache.putIfAbsent(setCode, loaded); + return existing != null ? existing : loaded; + } + + private static Map> loadSet(String setCode) { + File localFile = localCacheFile(setCode); + + // 1. Try local disk cache + if (localFile.exists()) { + try { + return parseSetFile(localFile); + } catch (Exception e) { + Logger.warn("CdnUuidCache: corrupt local cache {}: {}", localFile, e.getMessage()); + //noinspection ResultOfMethodCallIgnored + localFile.delete(); + } + } + + // 2. Fetch from remote, cache locally + String remoteUrl = remoteUrl(setCode); + try { + String json = fetchString(remoteUrl); + if (json != null) { + writeLocalCache(localFile, json); + return parseSetJson(json); + } + } catch (Exception e) { + Logger.debug("CdnUuidCache: no UUID data for set '{}': {}", setCode, e.getMessage()); + } + + return MISSING_SET; + } + + private static File localCacheFile(String setCode) { + String dir = localCacheDirOverride != null + ? localCacheDirOverride + : ForgeConstants.CACHE_CDN_UUID_DIR; + return new File(dir, setCode + ".json"); + } + + private static String remoteUrl(String setCode) { + String base = remoteBaseUrlOverride != null + ? remoteBaseUrlOverride + : ForgeConstants.FORGE_EXTRAS_CDN_UUID_URL; + return base + setCode + ".json"; + } + + /** Fetches {@code urlStr} and returns the body as a string, or {@code null} for HTTP 404. */ + private static String fetchString(String urlStr) throws Exception { + URLConnection conn = new URL(urlStr).openConnection(); + conn.setConnectTimeout(FETCH_TIMEOUT_MS); + conn.setReadTimeout(FETCH_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + conn.connect(); + if (conn instanceof HttpURLConnection) { + int status = ((HttpURLConnection) conn).getResponseCode(); + if (status == 404) return null; + if (status != 200) throw new Exception("HTTP " + status + " for " + urlStr); + } + try (InputStream is = conn.getInputStream(); + BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line).append('\n'); + return sb.toString(); + } + } + + private static void writeLocalCache(File file, String json) { + try { + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Path tmp = Files.createTempFile(file.getParentFile().toPath(), "cdn-", ".tmp"); + try { + Files.write(tmp, json.getBytes(StandardCharsets.UTF_8)); + Files.move(tmp, file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + Files.deleteIfExists(tmp); + throw e; + } + } catch (Exception e) { + Logger.warn("CdnUuidCache: could not write local cache {}: {}", file, e.getMessage()); + } + } + + private static Map> parseSetFile(File file) throws Exception { + try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { + return parseSetObject(JsonParser.parseReader(reader).getAsJsonObject()); + } + } + + private static Map> parseSetJson(String json) { + return parseSetObject(JsonParser.parseString(json).getAsJsonObject()); + } + + /** + * Parses a set JSON object. + * Format: {@code {"cn": {"lang": "uuid"|["frontUuid","backUuid"]}, ...}} + */ + private static Map> parseSetObject(JsonObject setObj) { + Map> cardMap = new HashMap<>(setObj.size() * 2); + for (Map.Entry cnEntry : setObj.entrySet()) { + if (!cnEntry.getValue().isJsonObject()) continue; + JsonObject langObj = cnEntry.getValue().getAsJsonObject(); + Map langMap = new HashMap<>(langObj.size() * 2); + for (Map.Entry langEntry : langObj.entrySet()) { + JsonElement val = langEntry.getValue(); + if (val.isJsonPrimitive()) { + langMap.put(langEntry.getKey(), new LangUuids(val.getAsString(), null)); + } else if (val.isJsonArray()) { + JsonArray arr = val.getAsJsonArray(); + if (arr.size() >= 2) { + String front = arr.get(0).getAsString(); + String back = arr.get(1).getAsString(); + langMap.put(langEntry.getKey(), + new LangUuids(front, back.equals(front) ? null : back)); + } else if (arr.size() == 1) { + langMap.put(langEntry.getKey(), + new LangUuids(arr.get(0).getAsString(), null)); + } + } + } + if (!langMap.isEmpty()) + cardMap.put(cnEntry.getKey(), Collections.unmodifiableMap(langMap)); + } + return Collections.unmodifiableMap(cardMap); + } +} diff --git a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java index 0ba812f9948a..384161a6e5ef 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -18,9 +18,13 @@ /** * Downloads card images for all cards that match the supplied predicate. - * Uses Scryfall as the primary source (matching the auto-downloader path) so - * that images are actually available; falls back to the cardforge hosted server - * for cards that lack a collector number. + * + * URL priority per card face: + * 1. cards.scryfall.io CDN (not rate-limited) — when a UUID JSON file exists at + * {@code res/cdn_uuid/{scryfallCode}/{collectorNumber}.json} for this card + * 2. api.scryfall.com per-card API (rate-limited, 100 ms/request) — fallback when + * no UUID is available but the card has a collector number + * 3. cardforge hosted server — final fallback */ public class GuiDownloadFilteredCardImages extends GuiDownloadService { @@ -60,28 +64,30 @@ protected Map getNeededFiles() { private static void addIfMissing(PaperCard c, String face, Map downloads) { final String imageKey = ImageUtil.getImageKey(c, face, true); - if (imageKey == null) { return; } + if (imageKey == null) return; - // Destination path for this card face in the local cache - final File destFull = new File(ForgeConstants.CACHE_CARD_PICS_DIR, imageKey + ".jpg"); - // Also check for the fullborder variant that LibGDXImageFetcher produces from Scryfall - final String fbKey = TextUtil.fastReplace(imageKey, ".full", ".fullborder") + - (!imageKey.contains(".full") ? ".fullborder" : "") ; - final File destFb = new File(ForgeConstants.CACHE_CARD_PICS_DIR, fbKey + ".jpg"); + final File destFull = new File(ForgeConstants.CACHE_CARD_PICS_DIR, imageKey + ".jpg"); + final String fbKey = TextUtil.fastReplace(imageKey, ".full", ".fullborder") + + (!imageKey.contains(".full") ? ".fullborder" : ""); + final File destFb = new File(ForgeConstants.CACHE_CARD_PICS_DIR, fbKey + ".jpg"); - if (destFull.exists() || destFb.exists()) { return; } - if (downloads.containsKey(destFull.getAbsolutePath())) { return; } + if (destFull.exists() || destFb.exists()) return; + if (downloads.containsKey(destFull.getAbsolutePath())) return; final String url = buildUrl(c, face); - if (url == null) { return; } + if (url == null) return; downloads.put(destFull.getAbsolutePath(), url); } /** - * Builds the download URL for one card face. - * Prefers Scryfall (which works) for cards that have a collector number; - * falls back to the cardforge hosted server otherwise. + * Returns the best available download URL for one card face. + * + * Priority: + * 1. cards.scryfall.io CDN URL from cdn_uuid JSON file (no rate limit; optional assets) + * 2. api.scryfall.com per-card API URL (rate-limited; GuiDownloadService + * enforces 100 ms between requests to api.scryfall.com URLs automatically) + * 3. cardforge hosted server */ private static String buildUrl(PaperCard c, String face) { final String collectorNum = c.getCollectorNumber(); @@ -89,21 +95,26 @@ private static String buildUrl(PaperCard c, String face) { && !"0".equals(collectorNum) && !StringUtils.isBlank(collectorNum); - if (hasCollectorNum) { - CardEdition edition = StaticData.instance().getEditions().get(c.getEdition()); - if (edition != null) { - String scryfallCode = edition.getScryfallCode(); - if (!StringUtils.isBlank(scryfallCode)) { - String langCode = edition.getCardsLangCode(); - String path = ImageUtil.getScryfallDownloadUrl(c, face, scryfallCode, langCode, false); - if (path != null) { - return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + path; - } - } - } + CardEdition edition = hasCollectorNum + ? StaticData.instance().getEditions().get(c.getEdition()) : null; + String scryfallCode = (edition != null) ? edition.getScryfallCode() : null; + boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode); + + // 1. CDN — fast, no rate limit; requires cdn_uuid JSON files in assets + if (edition != null && hasCollectorNum && hasScryfallCode) { + String cdnUrl = CdnUuidCache.getCdnUrl( + scryfallCode, collectorNum, edition.getCardsLangCode(), face, "normal"); + if (cdnUrl != null) return cdnUrl; + } + + // 2. Scryfall per-card API — rate-limited (100 ms/request via GuiDownloadService) + if (hasCollectorNum && edition != null && hasScryfallCode) { + String apiPath = ImageUtil.getScryfallDownloadUrl( + c, face, scryfallCode, edition.getCardsLangCode(), false); + if (apiPath != null) return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + apiPath; } - // Fallback: cardforge hosted server + // 3. Cardforge hosted server String cardforgeUrl = ImageUtil.getDownloadUrl(c, face); return cardforgeUrl != null ? ForgeConstants.URL_PIC_DOWNLOAD + cardforgeUrl : null; } diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java new file mode 100644 index 000000000000..d4133d92c196 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java @@ -0,0 +1,31 @@ +package forge.gui.download; + +/** + * Utility for constructing Scryfall CDN image URLs from card UUIDs. + * + *

The CDN ({@code cards.scryfall.io}) is not rate-limited. Given a UUID and image + * size, the URL is fully deterministic: + *

+ *   https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg
+ * 
+ * where {@code size} is {@code "normal"} or {@code "art_crop"}. + * + *

UUIDs are loaded from {@code res/cdn_uuid/{setCode}/{collectorNumber}.json} asset files + * by {@link CdnUuidCache}. + */ +public final class ScryfallBulkData { + + private ScryfallBulkData() {} + + /** + * Builds a Scryfall CDN image URL. + * + * @param uuid the Scryfall card UUID (e.g. {@code "4e7a547f-..."}) + * @param side {@code "front"} or {@code "back"} + * @param size {@code "normal"} or {@code "art_crop"} + */ + public static String cdnUrl(String uuid, String side, String size) { + return "https://cards.scryfall.io/" + size + "/" + side + + "/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg"; + } +} diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java index 8854c312ab03..71d18bafea5f 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -240,6 +240,7 @@ public final class ForgeConstants { } // data that is only in the profile dirs + public static final String CACHE_CDN_UUID_DIR = CACHE_DIR + "cdn_uuid" + PATH_SEPARATOR; public static final String USER_QUEST_DIR = USER_DIR + "quest" + PATH_SEPARATOR; public static final String USER_QUEST_WORLD_DIR = USER_QUEST_DIR + "world" + PATH_SEPARATOR; public static final String USER_CONQUEST_DIR = USER_DIR + "conquest" + PATH_SEPARATOR; @@ -345,6 +346,8 @@ public final class ForgeConstants { public static final String URL_PRICE_DOWNLOAD = GITHUB_ASSETS_BASE + "all-prices.txt"; private static final String URL_SCRYFALL = "https://api.scryfall.com"; public static final String URL_PIC_SCRYFALL_DOWNLOAD = URL_SCRYFALL + "/cards/"; + public static final String URL_SCRYFALL_CDN = "https://cards.scryfall.io/"; + public static final String FORGE_EXTRAS_CDN_UUID_URL = GITHUB_ASSETS_BASE + "cdn_uuid/"; // Constants for Display Card Identity game setting public static final String DISP_CURRENT_COLORS_ALWAYS = "Always"; diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index c23a0500735a..4f97eda5cd51 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -9,6 +9,7 @@ import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; +import org.apache.commons.lang3.StringUtils; import java.io.File; import java.util.*; @@ -71,16 +72,21 @@ private String getScryfallDownloadURL(PaperCard c, String face, boolean useArtCr private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, ArrayList downloadUrls) { CardEdition edition = StaticData.instance().getEditions().get(card.getEdition()); - if (edition == null) { - return; - } + if (edition == null) return; String setCode = edition.getScryfallCode(); String langCode = edition.getCardsLangCode(); - String primaryUrl = ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(card, face, setCode, langCode, useArtCrop); - if (!downloadUrls.contains(primaryUrl)) { - downloadUrls.add(primaryUrl); + + // Prefer CDN (no rate limit) when a UUID JSON file exists in the assets. + if (!StringUtils.isBlank(setCode)) { + String size = useArtCrop ? "art_crop" : "normal"; + String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrl( + setCode, card.getCollectorNumber(), langCode, face, size); + if (cdnUrl != null && !downloadUrls.contains(cdnUrl)) downloadUrls.add(cdnUrl); } + + String primaryUrl = ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(card, face, setCode, langCode, useArtCrop); + if (!downloadUrls.contains(primaryUrl)) downloadUrls.add(primaryUrl); } protected boolean shouldTryScryfallSetLookupCandidate(PaperCard requestedCard, PaperCard candidate) {