From 6430183185df69cc0a39c52f2e8e03d8e2f968a3 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 9 Jun 2026 23:15:04 +0100 Subject: [PATCH 01/18] 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 --- .../forge/download/ScryfallBulkDataTest.java | 26 +++ forge-gui/pom.xml | 5 + .../java/forge/gui/download/CdnUuidCache.java | 148 ++++++++++++++++++ .../GuiDownloadFilteredCardImages.java | 69 ++++---- .../forge/gui/download/ScryfallBulkData.java | 31 ++++ .../properties/ForgeConstants.java | 1 + .../main/java/forge/util/ImageFetcher.java | 18 ++- 7 files changed, 263 insertions(+), 35 deletions(-) create mode 100644 forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java create mode 100644 forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java create mode 100644 forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java 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/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..ba9a6e2bc5d9 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -0,0 +1,148 @@ +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.File; +import java.io.FileReader; +import java.nio.charset.StandardCharsets; +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 stored in + * {@code forge-gui/res/cdn_uuid/{setCode}/{collectorNumber}.json}. + * + *

Each JSON file maps language codes to the CDN UUID for that card print: + *

+ * + *

On the first lookup for a given set, all {@code {cn}.json} files in that set's + * directory are loaded at once and held in memory for the lifetime of the process. + * Returns {@code null} when no UUID data is available (caller falls back to the + * rate-limited Scryfall API or the cardforge server). + */ +public final class CdnUuidCache { + + private static final String FALLBACK_LANG = "en"; + /** Sentinel: set directory was scanned and found empty / absent. */ + private static final Map> MISSING_SET = Collections.emptyMap(); + + /** + * Holds the front and (optionally different) back UUID for one language of one card. + * {@code back} is null when both faces share the same UUID. + */ + private static final class LangUuids { + final String front; + final String back; // null → same as front + LangUuids(String front, String back) { this.front = front; this.back = back; } + } + + /** Cache: setCode -> (collectorNumber -> (lang -> LangUuids)) */ + private static final ConcurrentHashMap>> setCache = + new ConcurrentHashMap<>(); + + private CdnUuidCache() {} + + /** + * 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; + + File setDir = new File(ForgeConstants.CDN_UUID_DIR + setCode); + if (!setDir.isDirectory()) { + setCache.put(setCode, MISSING_SET); + return MISSING_SET; + } + + File[] files = setDir.listFiles(f -> f.getName().endsWith(".json")); + if (files == null || files.length == 0) { + setCache.put(setCode, MISSING_SET); + return MISSING_SET; + } + + Map> cardMap = new HashMap<>(files.length * 2); + for (File f : files) { + String cn = f.getName().substring(0, f.getName().length() - 5); // strip .json + try { + Map langMap = parseCardFile(f); + if (!langMap.isEmpty()) cardMap.put(cn, langMap); + } catch (Exception e) { + Logger.warn("CdnUuidCache: failed to parse {}: {}", f, e.getMessage()); + } + } + + Map> result = Collections.unmodifiableMap(cardMap); + setCache.put(setCode, result); + return result; + } + + /** + * Parses a single {@code {cn}.json} file. + * Format: {@code {"en":"uuid","ja":["fuuid","buuid"],...}} + */ + private static Map parseCardFile(File file) throws Exception { + JsonObject obj; + try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { + obj = JsonParser.parseReader(reader).getAsJsonObject(); + } + Map result = new HashMap<>(obj.size() * 2); + for (Map.Entry entry : obj.entrySet()) { + JsonElement val = entry.getValue(); + if (val.isJsonPrimitive()) { + result.put(entry.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(); + result.put(entry.getKey(), new LangUuids(front, back.equals(front) ? null : back)); + } else if (arr.size() == 1) { + result.put(entry.getKey(), new LangUuids(arr.get(0).getAsString(), null)); + } + } + } + return result; + } +} 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..e3a0f0af9f60 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -93,6 +93,7 @@ public final class ForgeConstants { public static final String CARD_DATA_DIR = RES_DIR + "cardsfolder" + PATH_SEPARATOR; public static final String TOKEN_DATA_DIR = RES_DIR + "tokenscripts" + PATH_SEPARATOR; public static final String EDITIONS_DIR = RES_DIR + "editions" + PATH_SEPARATOR; + public static final String CDN_UUID_DIR = RES_DIR + "cdn_uuid" + PATH_SEPARATOR; public static final String BLOCK_DATA_DIR = RES_DIR + "blockdata" + PATH_SEPARATOR; public static final String FORMATS_DATA_DIR = RES_DIR + "formats" + PATH_SEPARATOR; public static final String DECK_CUBE_DIR = RES_DIR + "cube" + PATH_SEPARATOR; 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) { From 7972005a34b429177e3c3333419b083548dfbf45 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Wed, 10 Jun 2026 00:05:30 +0100 Subject: [PATCH 02/18] Add unit tests for CdnUuidCache; make base dir injectable for tests - Add package-private `cdnBaseDirOverride` and `clearCacheForTesting()` so tests can supply a temp directory without triggering ForgeConstants/GUI init - Switch ensureSetLoaded to use the override when set (production path unchanged) - Log DEBUG with absolute path when a set directory is not found, making path-resolution failures visible in runtime logs - Add CdnUuidCacheTest covering happy path, language fallback, DFC front/back, same-UUID DFC, missing set (MISSING_SET sentinel caching), missing collector number, null inputs, and set-code case normalisation (16 tests total, all pass) Co-Authored-By: Claude Sonnet 4.6 --- .../forge/gui/download/CdnUuidCacheTest.java | 178 ++++++++++++++++++ .../java/forge/gui/download/CdnUuidCache.java | 15 +- 2 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java 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..3df6b69bc332 --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -0,0 +1,178 @@ +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"; + + private File tempDir; + + @BeforeClass + public void setUp() throws IOException { + tempDir = Files.createTempDirectory("cdn_uuid_test").toFile(); + + // SET: single-faced cards in multiple languages + File setDir = new File(tempDir, SET); + setDir.mkdir(); + // 1.json — "en" and "ja" UUIDs + write(new File(setDir, "1.json"), + "{\"en\":\"" + UUID_EN + "\",\"ja\":\"" + UUID_JA + "\"}"); + // 2.json — "en" only + write(new File(setDir, "2.json"), + "{\"en\":\"" + UUID_EN + "\"}"); + + // SET_DFC: double-faced cards + File dfcDir = new File(tempDir, SET_DFC); + dfcDir.mkdir(); + // 1.json — distinct front and back UUIDs + write(new File(dfcDir, "1.json"), + "{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_BACK + "\"]}"); + // 2.json — both faces share the same UUID + write(new File(dfcDir, "2.json"), + "{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}"); + + CdnUuidCache.cdnBaseDirOverride = tempDir.getAbsolutePath() + File.separator; + CdnUuidCache.clearCacheForTesting(); + } + + @AfterClass + public void tearDown() { + CdnUuidCache.cdnBaseDirOverride = null; + CdnUuidCache.clearCacheForTesting(); + deleteDir(tempDir); + } + + // --- 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=null internally; front UUID is used. + 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")); + } + + // --- 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 absentSetDirectory_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "1", "en", "front", "normal")); + } + + @Test(dependsOnMethods = "absentSetDirectory_returnsNull") + public void absentSetCachedAsMissing_secondCallAlsoNull() { + // MISSING_SET sentinel should be in cache; same result on repeated lookup. + 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 child.delete(); + } + } + dir.delete(); + } +} diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index ba9a6e2bc5d9..744592859123 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -51,8 +51,19 @@ private static final class LangUuids { private static final ConcurrentHashMap>> setCache = new ConcurrentHashMap<>(); + /** + * Override the CDN UUID base directory. Package-private for unit tests only. + * Must end with the platform file separator when set. + */ + static volatile String cdnBaseDirOverride = 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. @@ -90,8 +101,10 @@ private static Map> ensureSetLoaded(String setCod Map> cached = setCache.get(setCode); if (cached != null) return cached; - File setDir = new File(ForgeConstants.CDN_UUID_DIR + setCode); + String baseDir = cdnBaseDirOverride != null ? cdnBaseDirOverride : ForgeConstants.CDN_UUID_DIR; + File setDir = new File(baseDir + setCode); if (!setDir.isDirectory()) { + Logger.debug("CdnUuidCache: set directory not found: {}", setDir.getAbsolutePath()); setCache.put(setCode, MISSING_SET); return MISSING_SET; } From d5f2a08b2035c4a33f9e7a80a28910b319fd3824 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Wed, 10 Jun 2026 00:21:34 +0100 Subject: [PATCH 03/18] Fix CDN image URLs not being saved as .fullborder.jpg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SwingImageFetcher and LibGDXImageFetcher only applied the .full → .fullborder path transform for api.scryfall.com URLs. CDN URLs (cards.scryfall.io) were saved as .full.jpg, which the game's image display code doesn't find — causing repeated re-download attempts. Add URL_SCRYFALL_CDN constant to ForgeConstants and use it in both fetchers so CDN downloads are treated identically to API downloads for file-path purposes. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/java/forge/util/SwingImageFetcher.java | 6 ++++-- forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java | 6 ++++-- .../java/forge/localinstance/properties/ForgeConstants.java | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) 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-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/src/main/java/forge/localinstance/properties/ForgeConstants.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java index e3a0f0af9f60..6c0d3cdeaceb 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -346,6 +346,7 @@ 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/"; // Constants for Display Card Identity game setting public static final String DISP_CURRENT_COLORS_ALWAYS = "Always"; From bf04c67f16753d1222bc93f31947d36d154cb092 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Wed, 10 Jun 2026 12:25:00 +0100 Subject: [PATCH 04/18] Fetch CDN UUID data from forge-extras; cache per-set JSON files locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CdnUuidCache now loads per-set JSON from {cacheDir}/cdn_uuid/{set}.json. On first lookup for a set the file is fetched from forge-extras (no rate limit) and written to the local cache for all subsequent resolutions. Returns null on any failure — callers fall back to the Scryfall API as before. ForgeConstants: replace CDN_UUID_DIR (res/) with CACHE_CDN_UUID_DIR (cache/) and FORGE_EXTRAS_CDN_UUID_URL. Tests use localCacheDirOverride + remoteBaseUrlOverride (file:// in tests) to verify the full local-hit and remote-fetch paths without network access. Co-Authored-By: Claude Sonnet 4.6 --- .../forge/gui/download/CdnUuidCacheTest.java | 97 +++++--- .../java/forge/gui/download/CdnUuidCache.java | 217 ++++++++++++------ .../properties/ForgeConstants.java | 3 +- 3 files changed, 218 insertions(+), 99 deletions(-) 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 index 3df6b69bc332..ec30cfbd951c 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -22,41 +22,44 @@ public class CdnUuidCacheTest { private static final String UUID_FRONT = "aaaaaaaa-bbbb-cccc-dddd-000000000003"; private static final String UUID_BACK = "aaaaaaaa-bbbb-cccc-dddd-000000000004"; - private File tempDir; + /** 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 { - tempDir = Files.createTempDirectory("cdn_uuid_test").toFile(); - - // SET: single-faced cards in multiple languages - File setDir = new File(tempDir, SET); - setDir.mkdir(); - // 1.json — "en" and "ja" UUIDs - write(new File(setDir, "1.json"), - "{\"en\":\"" + UUID_EN + "\",\"ja\":\"" + UUID_JA + "\"}"); - // 2.json — "en" only - write(new File(setDir, "2.json"), - "{\"en\":\"" + UUID_EN + "\"}"); - - // SET_DFC: double-faced cards - File dfcDir = new File(tempDir, SET_DFC); - dfcDir.mkdir(); - // 1.json — distinct front and back UUIDs - write(new File(dfcDir, "1.json"), - "{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_BACK + "\"]}"); - // 2.json — both faces share the same UUID - write(new File(dfcDir, "2.json"), - "{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}"); - - CdnUuidCache.cdnBaseDirOverride = tempDir.getAbsolutePath() + File.separator; + 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.cdnBaseDirOverride = null; + CdnUuidCache.localCacheDirOverride = null; + CdnUuidCache.remoteBaseUrlOverride = null; CdnUuidCache.clearCacheForTesting(); - deleteDir(tempDir); + deleteDir(localCacheDir); + deleteDir(remoteDir); } // --- happy path --- @@ -109,7 +112,7 @@ public void dfcDistinctFaces_backUuid() { @Test public void dfcSameUuid_backRequestStillUsesSharedUuid() { - // When both faces share the same UUID, back=null internally; front UUID is used. + // 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")); } @@ -117,8 +120,8 @@ public void dfcSameUuid_backRequestStillUsesSharedUuid() { @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"); + String urlEmpty = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "", "normal"); + String urlFront = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal"); Assert.assertEquals(urlEmpty, urlFront); } @@ -130,6 +133,31 @@ public void uppercaseSetCode_lowercasedBeforeLookup() { 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 @@ -143,13 +171,14 @@ public void nullCollectorNumber_returnsNull() { } @Test - public void absentSetDirectory_returnsNull() { + 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 = "absentSetDirectory_returnsNull") + @Test(dependsOnMethods = "absentSet_returnsNull") public void absentSetCachedAsMissing_secondCallAlsoNull() { - // MISSING_SET sentinel should be in cache; same result on repeated lookup. + // MISSING_SET sentinel must be in cache; second lookup must not retry remote. Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "99", "en", "front", "normal")); } @@ -170,9 +199,11 @@ private static void deleteDir(File dir) { if (children != null) { for (File child : children) { if (child.isDirectory()) deleteDir(child); - else child.delete(); + else //noinspection ResultOfMethodCallIgnored + child.delete(); } } + //noinspection ResultOfMethodCallIgnored dir.delete(); } } diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 744592859123..2fedcce9c0ee 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -7,62 +7,76 @@ 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 stored in - * {@code forge-gui/res/cdn_uuid/{setCode}/{collectorNumber}.json}. + * Lazy-loading, thread-safe cache for Scryfall CDN UUIDs. * - *

Each JSON file maps language codes to the CDN UUID for that card print: - *

+ *

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. * - *

On the first lookup for a given set, all {@code {cn}.json} files in that set's - * directory are loaded at once and held in memory for the lifetime of the process. - * Returns {@code null} when no UUID data is available (caller falls 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"; - /** Sentinel: set directory was scanned and found empty / absent. */ + 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(); - /** - * Holds the front and (optionally different) back UUID for one language of one card. - * {@code back} is null when both faces share the same UUID. - */ private static final class LangUuids { final String front; - final String back; // null → same as 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)) */ + /** Cache: setCode → (collectorNumber → (lang → LangUuids)) */ private static final ConcurrentHashMap>> setCache = new ConcurrentHashMap<>(); /** - * Override the CDN UUID base directory. Package-private for unit tests only. + * Override the local cache directory. Package-private for unit tests. * Must end with the platform file separator when set. */ - static volatile String cdnBaseDirOverride = null; + 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(); - } + static void clearCacheForTesting() { setCache.clear(); } /** * Returns the Scryfall CDN image URL for a given card face, or {@code null} @@ -101,61 +115,134 @@ private static Map> ensureSetLoaded(String setCod Map> cached = setCache.get(setCode); if (cached != null) return cached; - String baseDir = cdnBaseDirOverride != null ? cdnBaseDirOverride : ForgeConstants.CDN_UUID_DIR; - File setDir = new File(baseDir + setCode); - if (!setDir.isDirectory()) { - Logger.debug("CdnUuidCache: set directory not found: {}", setDir.getAbsolutePath()); - setCache.put(setCode, MISSING_SET); - return MISSING_SET; + 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(); + } } - File[] files = setDir.listFiles(f -> f.getName().endsWith(".json")); - if (files == null || files.length == 0) { - setCache.put(setCode, MISSING_SET); - return MISSING_SET; + // 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()); } - Map> cardMap = new HashMap<>(files.length * 2); - for (File f : files) { - String cn = f.getName().substring(0, f.getName().length() - 5); // strip .json + 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 { - Map langMap = parseCardFile(f); - if (!langMap.isEmpty()) cardMap.put(cn, langMap); + Files.write(tmp, json.getBytes(StandardCharsets.UTF_8)); + Files.move(tmp, file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (Exception e) { - Logger.warn("CdnUuidCache: failed to parse {}: {}", f, e.getMessage()); + Files.deleteIfExists(tmp); + throw e; } + } catch (Exception e) { + Logger.warn("CdnUuidCache: could not write local cache {}: {}", file, e.getMessage()); } + } - Map> result = Collections.unmodifiableMap(cardMap); - setCache.put(setCode, result); - return result; + 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 single {@code {cn}.json} file. - * Format: {@code {"en":"uuid","ja":["fuuid","buuid"],...}} + * Parses a set JSON object. + * Format: {@code {"cn": {"lang": "uuid"|["frontUuid","backUuid"]}, ...}} */ - private static Map parseCardFile(File file) throws Exception { - JsonObject obj; - try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) { - obj = JsonParser.parseReader(reader).getAsJsonObject(); - } - Map result = new HashMap<>(obj.size() * 2); - for (Map.Entry entry : obj.entrySet()) { - JsonElement val = entry.getValue(); - if (val.isJsonPrimitive()) { - result.put(entry.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(); - result.put(entry.getKey(), new LangUuids(front, back.equals(front) ? null : back)); - } else if (arr.size() == 1) { - result.put(entry.getKey(), new LangUuids(arr.get(0).getAsString(), null)); + 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 result; + return Collections.unmodifiableMap(cardMap); } } 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 6c0d3cdeaceb..71d18bafea5f 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -93,7 +93,6 @@ public final class ForgeConstants { public static final String CARD_DATA_DIR = RES_DIR + "cardsfolder" + PATH_SEPARATOR; public static final String TOKEN_DATA_DIR = RES_DIR + "tokenscripts" + PATH_SEPARATOR; public static final String EDITIONS_DIR = RES_DIR + "editions" + PATH_SEPARATOR; - public static final String CDN_UUID_DIR = RES_DIR + "cdn_uuid" + PATH_SEPARATOR; public static final String BLOCK_DATA_DIR = RES_DIR + "blockdata" + PATH_SEPARATOR; public static final String FORMATS_DATA_DIR = RES_DIR + "formats" + PATH_SEPARATOR; public static final String DECK_CUBE_DIR = RES_DIR + "cube" + PATH_SEPARATOR; @@ -241,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; @@ -347,6 +347,7 @@ public final class ForgeConstants { 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"; From 9df74cdc0685713cfce4d4ddfbb108e76d372a27 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 28 Jul 2026 09:01:15 +0100 Subject: [PATCH 05/18] Compress cdn_uuid data over the wire and in local cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Request Accept-Encoding: gzip when fetching a set's UUID JSON from forge-extras; raw.githubusercontent.com honors it (~48% smaller on the wire for this UUID-heavy JSON), decompressed transparently. - Store the local disk cache gzip-compressed too (.json.gz), saving space for whichever sets a user actually triggers a lookup for. The files committed to forge-extras itself stay plain, sorted text — compressing those would turn every regeneration into a full binary rewrite instead of the minimal diff the CLI now produces. Verified against the live forge-extras branch: server returns Content-Encoding: gzip (110,709 bytes) and decompresses back to the full 214,541-byte file. All 17 CdnUuidCacheTest cases still pass. --- .../forge/gui/download/CdnUuidCacheTest.java | 5 +-- .../java/forge/gui/download/CdnUuidCache.java | 32 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) 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 index ec30cfbd951c..a07326b73711 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -137,9 +137,10 @@ public void uppercaseSetCode_lowercasedBeforeLookup() { @Test public void remoteFetch_writesLocalCacheFile() { - // After the first successful lookup, the set JSON should be present in localCacheDir. + // After the first successful lookup, the set JSON should be present in localCacheDir, + // gzip-compressed. CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); - Assert.assertTrue(new File(localCacheDir, SET + ".json").exists(), + Assert.assertTrue(new File(localCacheDir, SET + ".json.gz").exists(), "local cache file should be written after remote fetch"); } diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 2fedcce9c0ee..1d7632ccd999 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -9,7 +9,7 @@ import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; +import java.io.FileInputStream; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; @@ -23,17 +23,27 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; /** * 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. + * {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. If absent it fetches the file from + * forge-extras and writes it locally (gzip-compressed) 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. * + *

The remote fetch requests {@code Accept-Encoding: gzip}; GitHub's raw content + * server honors it (roughly halving transfer size for this UUID-heavy JSON), and + * the response is decompressed transparently. The local disk copy is stored + * gzip-compressed too, independent of whatever encoding the remote used. Note this + * is purely a runtime concern — the JSON files as committed to forge-extras are + * kept as plain, sorted text so they stay diffable; gzipping those would make + * every regeneration a full-file binary rewrite instead of a minimal diff. + * *

Set JSON format: *

  *   {
@@ -154,7 +164,7 @@ private static File localCacheFile(String setCode) {
         String dir = localCacheDirOverride != null
                 ? localCacheDirOverride
                 : ForgeConstants.CACHE_CDN_UUID_DIR;
-        return new File(dir, setCode + ".json");
+        return new File(dir, setCode + ".json.gz");
     }
 
     private static String remoteUrl(String setCode) {
@@ -170,13 +180,18 @@ private static String fetchString(String urlStr) throws Exception {
         conn.setConnectTimeout(FETCH_TIMEOUT_MS);
         conn.setReadTimeout(FETCH_TIMEOUT_MS);
         conn.setRequestProperty("Accept", "application/json");
+        conn.setRequestProperty("Accept-Encoding", "gzip");
         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();
+        // Setting Accept-Encoding ourselves means the JDK won't auto-decompress;
+        // only unwrap if the server actually honored it (e.g. file:// URLs never do).
+        boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding());
+        try (InputStream raw = conn.getInputStream();
+             InputStream is = gzipped ? new GZIPInputStream(raw) : raw;
              BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
             StringBuilder sb = new StringBuilder();
             String line;
@@ -191,7 +206,9 @@ private static void writeLocalCache(File file, String json) {
             file.getParentFile().mkdirs();
             Path tmp = Files.createTempFile(file.getParentFile().toPath(), "cdn-", ".tmp");
             try {
-                Files.write(tmp, json.getBytes(StandardCharsets.UTF_8));
+                try (GZIPOutputStream gz = new GZIPOutputStream(Files.newOutputStream(tmp))) {
+                    gz.write(json.getBytes(StandardCharsets.UTF_8));
+                }
                 Files.move(tmp, file.toPath(),
                         StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
             } catch (Exception e) {
@@ -204,7 +221,8 @@ private static void writeLocalCache(File file, String json) {
     }
 
     private static Map> parseSetFile(File file) throws Exception {
-        try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) {
+        try (InputStream is = new GZIPInputStream(new FileInputStream(file));
+             InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
             return parseSetObject(JsonParser.parseReader(reader).getAsJsonObject());
         }
     }

From 9d6ff6f29a5f714b15730a05397b025b29cbe946 Mon Sep 17 00:00:00 2001
From: Hugh Kaznowski 
Date: Wed, 29 Jul 2026 11:33:14 +0100
Subject: [PATCH 06/18] Let users build their own CDN image lookup data from
 Scryfall's manifest API

Addresses the Discord discussion on forge-owned vs. user-owned CDN data:
this adds a user-triggered path that doesn't depend on forge-extras at
all, alongside (not instead of) the existing hosted data.

Scryfall's /cards/manifest endpoint returns just id/set/collector_number
/lang per card -- everything this lookup needs -- in ~15k-entry pages,
without the ~100MB-2.5GB cost of a full bulk-data export. English
coverage is currently ~8 pages (~25-30MB). It doesn't expose the actual
CDN image URL, so a double-faced card's back face is assumed to share
the front's id; verified against live Scryfall data that this holds for
the overwhelming majority of DFCs (the CDN URL only differs by a
front/back path segment), and the existing CDN-miss fallback already
covers the rare exceptions.

ScryfallManifestSync paginates the manifest (respecting its documented
10/minute rate limit), and supports incremental resync: entries are
requested newest-image-update-first, with a persisted per-language
watermark so a repeat sync typically stops after one page instead of
re-walking the whole catalog.

CdnUuidCache gains mergeSetEntries() (fills gaps without ever
overwriting existing entries -- forge-extras/CLI data can carry a
double-faced card's real distinct front/back UUIDs, which a manifest-
derived guess must not clobber) and a public clearCache(), covering the
simpler "let users purge it from settings" alternative to per-file
expiry that came up in the same discussion.

Both actions are wired into the mobile card-image-download screen,
which is where this whole feature already lives (desktop never exposed
"Download Card Images" -- only mobile/CardImageBrowserScreen does).

10 new tests in ScryfallManifestSyncTest (embedded HTTP server, no live
network dependency) cover pagination, the incremental watermark, the
never-overwrite merge policy, and cache clearing; all 27 CDN-related
tests pass together.
---
 .../download/ScryfallManifestSyncTest.java    | 243 ++++++++++++++++++
 .../settings/CardImageBrowserScreen.java      |  47 ++++
 forge-gui/res/languages/en-US.properties      |   9 +
 .../java/forge/gui/download/CdnUuidCache.java |  68 ++++-
 .../gui/download/ScryfallManifestSync.java    | 182 +++++++++++++
 5 files changed, 545 insertions(+), 4 deletions(-)
 create mode 100644 forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java
 create mode 100644 forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java

diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java
new file mode 100644
index 000000000000..74e732973c94
--- /dev/null
+++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java
@@ -0,0 +1,243 @@
+package forge.gui.download;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Exercises {@link ScryfallManifestSync} against a tiny embedded HTTP server standing
+ * in for {@code api.scryfall.com/cards/manifest}, so these run offline and don't
+ * depend on Scryfall's real rate limit or live catalog.
+ */
+@Test(groups = {"UnitTest"})
+public class ScryfallManifestSyncTest {
+
+    private HttpServer server;
+    private File localCacheDir;
+    private final AtomicInteger requestCount = new AtomicInteger();
+
+    /** Pages served for the current test, keyed by 1-based page number. */
+    private final Map pages = new HashMap<>();
+
+    @BeforeClass
+    public void setUp() throws IOException {
+        localCacheDir = Files.createTempDirectory("manifest_local").toFile();
+        CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator;
+
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/", this::handle);
+        server.start();
+        ScryfallManifestSync.manifestBaseUrlOverride = "http://127.0.0.1:" + server.getAddress().getPort();
+    }
+
+    @AfterClass
+    public void tearDown() {
+        server.stop(0);
+        CdnUuidCache.localCacheDirOverride = null;
+        ScryfallManifestSync.manifestBaseUrlOverride = null;
+    }
+
+    @AfterMethod
+    public void resetBetweenTests() {
+        CdnUuidCache.clearCache();
+        pages.clear();
+        requestCount.set(0);
+        for (File f : localCacheDir.listFiles()) {
+            //noinspection ResultOfMethodCallIgnored
+            f.delete();
+        }
+    }
+
+    private void handle(HttpExchange ex) throws IOException {
+        requestCount.incrementAndGet();
+        Map params = queryParams(ex.getRequestURI().getRawQuery());
+        int page = Integer.parseInt(params.getOrDefault("page", "1"));
+        String body = pages.getOrDefault(page, "{\"object\":\"list\",\"has_more\":false,\"data\":[]}");
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        ex.getResponseHeaders().add("Content-Type", "application/json");
+        ex.sendResponseHeaders(200, bytes.length);
+        try (var os = ex.getResponseBody()) {
+            os.write(bytes);
+        }
+    }
+
+    private static Map queryParams(String rawQuery) {
+        Map out = new HashMap<>();
+        if (rawQuery == null) return out;
+        for (String pair : rawQuery.split("&")) {
+            int eq = pair.indexOf('=');
+            out.put(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
+                    URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
+        }
+        return out;
+    }
+
+    private static String entry(String id, String set, String cn, String lang, String imageUpdatedAt) {
+        return String.format(
+                "{\"id\":\"%s\",\"set_code\":\"%s\",\"collector_number\":\"%s\",\"lang\":\"%s\",\"image_updated_at\":%s}",
+                id, set, cn, lang, imageUpdatedAt == null ? "null" : "\"" + imageUpdatedAt + "\"");
+    }
+
+    private static String page(boolean hasMore, String... entries) {
+        return "{\"object\":\"list\",\"has_more\":" + hasMore + ",\"data\":[" + String.join(",", entries) + "]}";
+    }
+
+    // -------------------------------------------------------------------------
+
+    @Test
+    public void singlePage_mergesAllEntriesAndResolvesViaCdnUuidCache() throws Exception {
+        pages.put(1, page(false,
+                entry("11111111-1111-1111-1111-111111111111", "neo", "1", "en", "2026-01-01T00:00:00Z"),
+                entry("22222222-2222-2222-2222-222222222222", "neo", "2", "en", "2026-01-01T00:00:00Z")));
+
+        int merged = ScryfallManifestSync.sync("en", null);
+
+        Assert.assertEquals(merged, 2);
+        String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal");
+        Assert.assertEquals(url, ScryfallBulkData.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal"));
+    }
+
+    @Test
+    public void dfcWithoutSeparateBackId_backFaceReusesFrontUuid() throws Exception {
+        pages.put(1, page(false,
+                entry("33333333-3333-3333-3333-333333333333", "neo", "5", "en", "2026-01-01T00:00:00Z")));
+
+        ScryfallManifestSync.sync("en", null);
+
+        String back = CdnUuidCache.getCdnUrl("neo", "5", "en", "back", "normal");
+        Assert.assertEquals(back, ScryfallBulkData.cdnUrl("33333333-3333-3333-3333-333333333333", "back", "normal"),
+                "manifest-derived entries should assume back shares the front's id");
+    }
+
+    @Test
+    public void entriesWithoutImageUpdatedAt_areSkipped() throws Exception {
+        pages.put(1, page(false, entry("44444444-4444-4444-4444-444444444444", "neo", "9", "en", null)));
+
+        int merged = ScryfallManifestSync.sync("en", null);
+
+        Assert.assertEquals(merged, 0);
+        Assert.assertNull(CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"));
+    }
+
+    @Test
+    public void multiplePages_allMergedAndFollowsPagination() throws Exception {
+        pages.put(1, page(true, entry("aaaaaaaa-0000-0000-0000-000000000001", "one", "1", "en", "2026-03-03T00:00:00Z")));
+        pages.put(2, page(false, entry("aaaaaaaa-0000-0000-0000-000000000002", "two", "1", "en", "2026-03-01T00:00:00Z")));
+
+        int merged = ScryfallManifestSync.sync("en", null);
+
+        Assert.assertEquals(merged, 2);
+        Assert.assertNotNull(CdnUuidCache.getCdnUrl("one", "1", "en", "front", "normal"));
+        Assert.assertNotNull(CdnUuidCache.getCdnUrl("two", "1", "en", "front", "normal"));
+    }
+
+    @Test
+    public void secondSync_withNoNewData_stopsAfterFirstPage() throws Exception {
+        pages.put(1, page(false, entry("bbbbbbbb-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
+        ScryfallManifestSync.sync("en", null); // establishes the watermark
+        requestCount.set(0);
+
+        int merged = ScryfallManifestSync.sync("en", null);
+
+        Assert.assertEquals(merged, 0, "everything is at or before the stored watermark");
+        Assert.assertEquals(requestCount.get(), 1, "should not paginate past the page containing only known data");
+    }
+
+    @Test
+    public void incrementalSync_onlyMergesEntriesNewerThanWatermark() throws Exception {
+        pages.put(1, page(false, entry("cccccccc-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
+        ScryfallManifestSync.sync("en", null);
+
+        // A later sync: one new card (newer image update), then the previously-seen card.
+        pages.put(1, page(false,
+                entry("dddddddd-0000-0000-0000-000000000002", "neo", "2", "en", "2026-02-01T00:00:00Z"),
+                entry("cccccccc-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
+
+        int merged = ScryfallManifestSync.sync("en", null);
+
+        Assert.assertEquals(merged, 1, "only the entry newer than the watermark should be merged");
+        Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "2", "en", "front", "normal"));
+    }
+
+    @Test
+    public void merge_neverOverwritesExistingPrecomputedEntry() throws Exception {
+        // Simulate forge-extras data already on disk with a genuine distinct back-face UUID.
+        Map cn1 = new HashMap<>();
+        cn1.put("en", "eeeeeeee-0000-0000-0000-000000000001"); // pre-existing, precise front id
+        Map> existing = new HashMap<>();
+        existing.put("1", cn1);
+        CdnUuidCache.mergeSetEntries("neo", existing);
+
+        // Manifest sync sees a *different* id for the same card/lang -- must not clobber it.
+        pages.put(1, page(false, entry("ffffffff-0000-0000-0000-000000000009", "neo", "1", "en", "2026-01-01T00:00:00Z")));
+        ScryfallManifestSync.sync("en", null);
+
+        String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal");
+        Assert.assertEquals(url, ScryfallBulkData.cdnUrl("eeeeeeee-0000-0000-0000-000000000001", "front", "normal"),
+                "pre-existing entries must win over a manifest-derived guess");
+    }
+
+    @Test
+    public void mergeSetEntries_fillsGapsAlongsideExistingLanguages() {
+        Map cn1en = new HashMap<>();
+        cn1en.put("en", "11111111-0000-0000-0000-000000000001");
+        Map> first = new HashMap<>();
+        first.put("1", cn1en);
+        CdnUuidCache.mergeSetEntries("neo", first);
+
+        Map cn1ja = new HashMap<>();
+        cn1ja.put("ja", "22222222-0000-0000-0000-000000000002");
+        Map> second = new HashMap<>();
+        second.put("1", cn1ja);
+        CdnUuidCache.mergeSetEntries("neo", second);
+
+        Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"),
+                "the earlier language entry must survive a later merge");
+        Assert.assertEquals(CdnUuidCache.getCdnUrl("neo", "1", "ja", "front", "normal"),
+                ScryfallBulkData.cdnUrl("22222222-0000-0000-0000-000000000002", "front", "normal"));
+    }
+
+    @Test
+    public void clearCache_removesMergedDataAndDiskFiles() throws Exception {
+        pages.put(1, page(false, entry("99999999-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
+        ScryfallManifestSync.sync("en", null);
+        Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"));
+
+        CdnUuidCache.clearCache();
+
+        Assert.assertNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"));
+        Assert.assertEquals(new File(localCacheDir, "neo.json.gz").exists(), false);
+    }
+
+    @Test
+    public void progressListener_calledWithDoneOnLastPageOnly() throws Exception {
+        pages.put(1, page(true, entry("12121212-0000-0000-0000-000000000001", "one", "1", "en", "2026-03-03T00:00:00Z")));
+        pages.put(2, page(false, entry("13131313-0000-0000-0000-000000000002", "two", "1", "en", "2026-03-01T00:00:00Z")));
+
+        List doneFlags = new ArrayList<>();
+        List pageNumbers = new ArrayList<>();
+        ScryfallManifestSync.sync("en", (page, merged, done) -> {
+            pageNumbers.add(page);
+            doneFlags.add(done);
+        });
+
+        Assert.assertEquals(pageNumbers, List.of(1, 2));
+        Assert.assertEquals(doneFlags, List.of(false, true));
+    }
+}
diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java
index a50c14c97e48..09e7908a4c64 100644
--- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java
+++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java
@@ -9,12 +9,16 @@
 import forge.card.CardEdition;
 import forge.game.GameFormat;
 import forge.gui.FThreads;
+import forge.gui.download.CdnUuidCache;
 import forge.gui.download.GuiDownloadFilteredCardImages;
+import forge.gui.download.ScryfallManifestSync;
+import forge.gui.util.SOptionPane;
 import forge.item.PaperCard;
 import forge.itemmanager.SFilterUtil;
 import forge.itemmanager.filters.ArchivedFormatSelect;
 import forge.model.FModel;
 import forge.screens.FScreen;
+import forge.screens.LoadingOverlay;
 import forge.toolbox.*;
 import forge.util.TextUtil;
 import forge.util.Utils;
@@ -37,6 +41,8 @@ public class CardImageBrowserScreen extends FScreen {
     private final FLabel            lblDownloaded;
     private final FLabel            lblMissing;
     private final FButton           btnDownload;
+    private final FButton           btnBuildCdnCache;
+    private final FButton           btnClearCdnCache;
 
     private GameFormat   selectedFormat    = null;
     private String       selectedFormatText;
@@ -114,6 +120,13 @@ public CardImageBrowserScreen() {
         btnDownload = add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages")));
         btnDownload.setCommand(e -> startDownload());
 
+        // ── CDN image lookup cache: build (from Scryfall) and clear ──────────
+        btnBuildCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnBuildCdnImageCache")));
+        btnBuildCdnCache.setCommand(e -> buildCdnCache());
+
+        btnClearCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache")));
+        btnClearCdnCache.setCommand(e -> clearCdnCache());
+
         // Run initial stats for "All cards, all sets" as soon as the screen opens
         scheduleStatsUpdate();
     }
@@ -165,6 +178,34 @@ private void startDownload() {
         }).show();
     }
 
+    // =========================================================================
+    //  CDN image lookup cache: build directly from Scryfall, or clear
+    // =========================================================================
+
+    private void buildCdnCache() {
+        LoadingOverlay.runBackgroundTask(Forge.getLocalizer().getMessage("lblBuildingCdnImageCache"), () -> {
+            try {
+                int merged = ScryfallManifestSync.sync("en", null);
+                FThreads.invokeInEdtLater(() -> {
+                    SOptionPane.showMessageDialog(
+                            Forge.getLocalizer().getMessage("lblCdnImageCacheBuildComplete", merged));
+                    scheduleStatsUpdate();
+                });
+            } catch (Exception e) {
+                FThreads.invokeInEdtLater(() -> SOptionPane.showErrorDialog(
+                        Forge.getLocalizer().getMessage("lblCdnImageCacheBuildFailed", e.getMessage())));
+            }
+        });
+    }
+
+    private void clearCdnCache() {
+        if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblClearCdnImageCacheConfirm"))) {
+            return;
+        }
+        CdnUuidCache.clearCache();
+        SOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblCdnImageCacheCleared"));
+    }
+
     // =========================================================================
     //  Helpers
     // =========================================================================
@@ -206,6 +247,12 @@ protected void doLayout(float startY, float width, float height) {
 
         float btnW = Math.min(w * 0.6f, Utils.AVG_FINGER_HEIGHT * 4);
         btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT);
+        y += BTN_HEIGHT + PADDING;
+
+        btnBuildCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT);
+        y += BTN_HEIGHT + PADDING;
+
+        btnClearCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT);
     }
 
     // =========================================================================
diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties
index d73307eb383d..b64b7d812f12 100644
--- a/forge-gui/res/languages/en-US.properties
+++ b/forge-gui/res/languages/en-US.properties
@@ -327,6 +327,15 @@ lblDownloadSkins=Download available skins used in customizing forge appearance.
 lblDownloadCardImages=Pre-download cards or check how many images are missing.
 lblDownloaded=Downloaded
 lblMissing=Missing
+btnBuildCdnImageCache=Build CDN Image Lookup
+lblBuildCdnImageCache=Fetch card image locations straight from Scryfall (English only) so downloads use their fast CDN instead of the rate-limited API. Safe to run repeatedly -- later runs only fetch what's changed.
+lblBuildingCdnImageCache=Fetching card image locations from Scryfall...
+lblCdnImageCacheBuildComplete=Done. {0} card entries added or updated.
+lblCdnImageCacheBuildFailed=Could not fetch data from Scryfall: {0}
+btnClearCdnImageCache=Clear CDN Image Lookup Cache
+lblClearCdnImageCache=Delete the local CDN image lookup cache. Use this if some cards keep resolving through the slower fallback even though their images should be available -- Forge will re-fetch cache data as needed afterward.
+lblClearCdnImageCacheConfirm=This deletes the local CDN image lookup cache. Forge will re-fetch it as needed. Continue?
+lblCdnImageCacheCleared=CDN image lookup cache cleared.
 lblDownloadCJKFonts=Download fonts for locales using CJK glyphs.
 lblDownloadCJKFontPrompt=Select a font file to download
 lblYourVersionOfJavaIsTooOld=Your version of Java is too old to use the content downloaders.
diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java
index 1d7632ccd999..ba771848c060 100644
--- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java
+++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java
@@ -88,6 +88,69 @@ private CdnUuidCache() {}
     /** Clears the in-memory cache. Package-private for unit tests only. */
     static void clearCacheForTesting() { setCache.clear(); }
 
+    /** The directory local set caches live in, honoring the test override. Package-private for {@link ScryfallManifestSync}. */
+    static String cacheDir() {
+        return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR;
+    }
+
+    /**
+     * Deletes every locally cached CDN UUID set file and clears the in-memory cache.
+     * Exposed as a user-facing "clear CDN image cache" action: a set whose local
+     * copy predates a Scryfall image update (or was cached before it had any image
+     * at all) stays stale until re-fetched, and this is the simplest way to force
+     * that without per-file expiry bookkeeping.
+     */
+    public static void clearCache() {
+        setCache.clear();
+        File dir = new File(cacheDir());
+        File[] files = dir.listFiles();
+        if (files == null) return;
+        for (File f : files) {
+            //noinspection ResultOfMethodCallIgnored
+            f.delete();
+        }
+    }
+
+    /**
+     * Merges externally-sourced (setCode → collectorNumber → lang → uuid) entries into
+     * the on-disk cache, creating or updating {@code {cacheDir}/{setCode}.json.gz}.
+     * Used by {@link ScryfallManifestSync} when a user builds/refreshes their own
+     * lookup data straight from the Scryfall API instead of (or in addition to)
+     * forge-extras.
+     *
+     * 

Existing entries are never overwritten: forge-extras/CLI-sourced data can + * record a double-faced card's distinct front/back UUIDs (a real but rare case), + * while a manifest-derived entry only ever has a single UUID (see + * {@link ScryfallManifestSync}) — merging must fill gaps, not degrade precision. + */ + static synchronized void mergeSetEntries(String setCode, Map> newEntries) { + File file = localCacheFile(setCode); + JsonObject merged = new JsonObject(); + if (file.exists()) { + try (InputStream is = new GZIPInputStream(new FileInputStream(file)); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + merged = JsonParser.parseReader(reader).getAsJsonObject(); + } catch (Exception e) { + Logger.warn("CdnUuidCache: corrupt local cache {}, rebuilding: {}", file, e.getMessage()); + } + } + + for (Map.Entry> cnEntry : newEntries.entrySet()) { + String cn = cnEntry.getKey(); + JsonObject langObj = merged.has(cn) && merged.get(cn).isJsonObject() + ? merged.getAsJsonObject(cn) : new JsonObject(); + for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { + if (!langObj.has(langEntry.getKey())) { + langObj.addProperty(langEntry.getKey(), langEntry.getValue()); + } + } + if (langObj.size() > 0) merged.add(cn, langObj); + } + + writeLocalCache(file, merged.toString()); + setCache.remove(setCode); // force a re-read of the freshly-written file on next lookup + } + /** * Returns the Scryfall CDN image URL for a given card face, or {@code null} * if no UUID data is available. @@ -161,10 +224,7 @@ private static Map> loadSet(String setCode) { } private static File localCacheFile(String setCode) { - String dir = localCacheDirOverride != null - ? localCacheDirOverride - : ForgeConstants.CACHE_CDN_UUID_DIR; - return new File(dir, setCode + ".json.gz"); + return new File(cacheDir(), setCode + ".json.gz"); } private static String remoteUrl(String setCode) { diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java new file mode 100644 index 000000000000..f8fa1fc02598 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java @@ -0,0 +1,182 @@ +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 org.tinylog.Logger; + +import java.io.File; +import java.io.IOException; +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.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * Client-side alternative (or supplement) to the forge-extras hosted cdn_uuid data: + * builds or incrementally refreshes the local CDN UUID cache directly from Scryfall's + * {@code /cards/manifest} endpoint, entirely on the user's machine. + * + *

Compared to a full bulk-data export (~100 MB for English only, ~2.5 GB for every + * language), the manifest returns only the fields needed here (id, set code, collector + * number, lang) in pages of up to 15,000 entries — full English coverage is currently + * ~8 pages, roughly 25-30 MB total. + * + *

Supports incremental resync: entries are requested with {@code order=imageupdated} + * (newest image update first), and a per-language watermark of the most recent + * {@code image_updated_at} seen is persisted alongside the cache. A later call stops + * as soon as it reaches an entry at or before that watermark, so a repeat sync after + * the first full one is typically a single page. + * + *

The manifest doesn't expose the actual CDN image URL, only the card's own + * {@code id} — so a double-faced card's back face is assumed to share the front + * face's id. That's true for the overwhelming majority of double-faced cards (the + * CDN URL only differs by a {@code front}/{@code back} path segment); a small number + * of known exceptions fall back to the existing rate-limited-API path on a CDN miss, + * same as any set/card this sync hasn't (yet) covered. + */ +public final class ScryfallManifestSync { + + private static final String MANIFEST_PATH_AND_QUERY = "?order=imageupdated&lang=%s&page=%d"; + private static final String DEFAULT_MANIFEST_URL = "https://api.scryfall.com/cards/manifest"; + private static final String WATERMARK_FILE = ".manifest-watermark.json"; + + /** Scryfall documents this endpoint's rate limit as 10 requests per minute. */ + private static final long REQUEST_INTERVAL_MS = 10_000; + private static final int CONNECT_TIMEOUT_MS = 15_000; + + /** Override for tests; must be a full base URL with no trailing query string. */ + static volatile String manifestBaseUrlOverride = null; + + private ScryfallManifestSync() {} + + /** Reports sync progress; {@code done} is true on the final call. */ + public interface ProgressListener { + void onProgress(int page, int cardsMerged, boolean done); + } + + /** + * Builds or incrementally refreshes the local CDN UUID cache for {@code lang} + * straight from Scryfall. + * + * @param lang 2-3 letter Scryfall language code, e.g. {@code "en"} + * @param listener optional progress callback; may be {@code null} + * @return number of (set, collector number, language) entries newly merged + */ + public static int sync(String lang, ProgressListener listener) throws IOException, InterruptedException { + String watermark = readWatermark(lang); + String newestSeen = null; + Map>> bySet = new HashMap<>(); + int page = 1; + int totalMerged = 0; + + while (true) { + if (page > 1) Thread.sleep(REQUEST_INTERVAL_MS); + JsonObject response = fetchPage(lang, page); + JsonArray data = response.has("data") ? response.getAsJsonArray("data") : new JsonArray(); + + boolean reachedWatermark = false; + for (JsonElement el : data) { + JsonObject card = el.getAsJsonObject(); + String imageUpdatedAt = str(card, "image_updated_at"); + if (imageUpdatedAt == null) continue; // no image yet; nothing useful to record + + if (newestSeen == null) newestSeen = imageUpdatedAt; // first entry overall is the newest (descending order) + + if (watermark != null && imageUpdatedAt.compareTo(watermark) <= 0) { + reachedWatermark = true; + break; + } + + String setCode = str(card, "set_code"); + String cn = str(card, "collector_number"); + String id = str(card, "id"); + String cardLang = str(card, "lang"); + if (setCode == null || cn == null || id == null || cardLang == null) continue; + + bySet.computeIfAbsent(setCode.toLowerCase(), k -> new HashMap<>()) + .computeIfAbsent(cn, k -> new HashMap<>()) + .put(cardLang, id); + totalMerged++; + } + + boolean hasMore = !reachedWatermark && response.has("has_more") && response.get("has_more").getAsBoolean(); + if (listener != null) listener.onProgress(page, totalMerged, !hasMore); + if (!hasMore) break; + page++; + } + + for (Map.Entry>> e : bySet.entrySet()) { + CdnUuidCache.mergeSetEntries(e.getKey(), e.getValue()); + } + if (newestSeen != null) writeWatermark(lang, newestSeen); + return totalMerged; + } + + // ------------------------------------------------------------------------- + + private static JsonObject fetchPage(String lang, int page) throws IOException { + String base = manifestBaseUrlOverride != null ? manifestBaseUrlOverride : DEFAULT_MANIFEST_URL; + String url = base + String.format(MANIFEST_PATH_AND_QUERY, lang, page); + URLConnection conn = new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(CONNECT_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Encoding", "gzip"); + conn.connect(); + if (conn instanceof HttpURLConnection) { + int status = ((HttpURLConnection) conn).getResponseCode(); + if (status != 200) throw new IOException("HTTP " + status + " for " + url); + } + boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding()); + try (InputStream raw = conn.getInputStream(); + InputStream is = gzipped ? new GZIPInputStream(raw) : raw; + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + } + + private static String str(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; + } + + private static String readWatermark(String lang) { + File file = new File(CdnUuidCache.cacheDir(), WATERMARK_FILE); + if (!file.exists()) return null; + try { + String json = Files.readString(file.toPath(), StandardCharsets.UTF_8); + JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); + return str(obj, lang); + } catch (Exception e) { + Logger.warn("ScryfallManifestSync: corrupt watermark file {}: {}", file, e.getMessage()); + return null; + } + } + + private static void writeWatermark(String lang, String value) { + File file = new File(CdnUuidCache.cacheDir(), WATERMARK_FILE); + JsonObject obj = new JsonObject(); + if (file.exists()) { + try { + obj = JsonParser.parseString(Files.readString(file.toPath(), StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (Exception ignored) { + obj = new JsonObject(); + } + } + obj.addProperty(lang, value); + try { + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), obj.toString(), StandardCharsets.UTF_8); + } catch (IOException e) { + Logger.warn("ScryfallManifestSync: could not write watermark file {}: {}", file, e.getMessage()); + } + } +} From 211c58cbcb3beedde9fe71dadb04fd55c96de152 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Wed, 29 Jul 2026 12:24:47 +0100 Subject: [PATCH 07/18] Fold ScryfallBulkData's one-liner into CdnUuidCache Addresses tool4ever's review comment on PR #10928: a single-method class wrapping a URL format string didn't warrant its own file. CdnUuidCache.getCdnUrl() was the only production caller, so cdnUrl() moves there as a public static method; test call sites updated to match, and ScryfallBulkDataTest's formula assertion is folded into CdnUuidCacheTest instead of being dropped. --- .../forge/download/ScryfallBulkDataTest.java | 26 --------------- .../forge/gui/download/CdnUuidCacheTest.java | 33 +++++++++++++------ .../download/ScryfallManifestSyncTest.java | 8 ++--- .../java/forge/gui/download/CdnUuidCache.java | 16 ++++++++- .../forge/gui/download/ScryfallBulkData.java | 31 ----------------- 5 files changed, 42 insertions(+), 72 deletions(-) delete mode 100644 forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java delete mode 100644 forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java diff --git a/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java b/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java deleted file mode 100644 index 08bd2a8dacdb..000000000000 --- a/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java +++ /dev/null @@ -1,26 +0,0 @@ -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 index a07326b73711..6fb6c6b4792e 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -62,24 +62,37 @@ public void tearDown() { deleteDir(remoteDir); } + // --- CDN URL formula --- + + @Test + public void cdnUrl_matchesScryfallFormula() { + String uuid = "4e7a547f-d1b0-4f4e-9a99-3c44fc89c048"; + Assert.assertEquals( + CdnUuidCache.cdnUrl(uuid, "front", "normal"), + "https://cards.scryfall.io/normal/front/4/e/" + uuid + ".jpg"); + Assert.assertEquals( + CdnUuidCache.cdnUrl(uuid, "back", "art_crop"), + "https://cards.scryfall.io/art_crop/back/4/e/" + uuid + ".jpg"); + } + // --- 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")); + Assert.assertEquals(url, CdnUuidCache.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")); + Assert.assertEquals(url, CdnUuidCache.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")); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_JA, "front", "normal")); } // --- language fallback --- @@ -87,13 +100,13 @@ public void japaneseLang_returnsJaUuid() { @Test public void unknownLang_fallsBackToEnglish() { String url = CdnUuidCache.getCdnUrl(SET, "1", "zz", "front", "normal"); - Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal")); + Assert.assertEquals(url, CdnUuidCache.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")); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); } // --- DFC (double-faced cards) --- @@ -101,20 +114,20 @@ public void cardWithOnlyEn_jaRequestFallsBack() { @Test public void dfcDistinctFaces_frontUuid() { String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal"); - Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_FRONT, "front", "normal")); + Assert.assertEquals(url, CdnUuidCache.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")); + Assert.assertEquals(url, CdnUuidCache.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")); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_FRONT, "back", "normal")); } @Test @@ -130,7 +143,7 @@ public void dfcEmptyFaceString_treatedAsFront() { @Test public void uppercaseSetCode_lowercasedBeforeLookup() { String url = CdnUuidCache.getCdnUrl(SET.toUpperCase(), "1", "en", "front", "normal"); - Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal")); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); } // --- remote fetch writes to local cache --- @@ -151,7 +164,7 @@ public void localCache_usedOnSubsequentLookup() throws IOException { CdnUuidCache.remoteBaseUrlOverride = "file:///nonexistent-dir/"; try { String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); - Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"), + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal"), "should resolve from local cache even when remote is unavailable"); } finally { CdnUuidCache.remoteBaseUrlOverride = remoteDir.toURI().toURL().toString(); diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java index 74e732973c94..e9171923344c 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java @@ -110,7 +110,7 @@ public void singlePage_mergesAllEntriesAndResolvesViaCdnUuidCache() throws Excep Assert.assertEquals(merged, 2); String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); - Assert.assertEquals(url, ScryfallBulkData.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal")); + Assert.assertEquals(url, CdnUuidCache.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal")); } @Test @@ -121,7 +121,7 @@ public void dfcWithoutSeparateBackId_backFaceReusesFrontUuid() throws Exception ScryfallManifestSync.sync("en", null); String back = CdnUuidCache.getCdnUrl("neo", "5", "en", "back", "normal"); - Assert.assertEquals(back, ScryfallBulkData.cdnUrl("33333333-3333-3333-3333-333333333333", "back", "normal"), + Assert.assertEquals(back, CdnUuidCache.cdnUrl("33333333-3333-3333-3333-333333333333", "back", "normal"), "manifest-derived entries should assume back shares the front's id"); } @@ -189,7 +189,7 @@ public void merge_neverOverwritesExistingPrecomputedEntry() throws Exception { ScryfallManifestSync.sync("en", null); String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); - Assert.assertEquals(url, ScryfallBulkData.cdnUrl("eeeeeeee-0000-0000-0000-000000000001", "front", "normal"), + Assert.assertEquals(url, CdnUuidCache.cdnUrl("eeeeeeee-0000-0000-0000-000000000001", "front", "normal"), "pre-existing entries must win over a manifest-derived guess"); } @@ -210,7 +210,7 @@ public void mergeSetEntries_fillsGapsAlongsideExistingLanguages() { Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"), "the earlier language entry must survive a later merge"); Assert.assertEquals(CdnUuidCache.getCdnUrl("neo", "1", "ja", "front", "normal"), - ScryfallBulkData.cdnUrl("22222222-0000-0000-0000-000000000002", "front", "normal")); + CdnUuidCache.cdnUrl("22222222-0000-0000-0000-000000000002", "front", "normal")); } @Test diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index ba771848c060..1c890d3c2fb0 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -179,7 +179,21 @@ public static String getCdnUrl(String scryfallCode, String collectorNum, String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; String side = wantBack ? "back" : "front"; - return ScryfallBulkData.cdnUrl(uuid, side, size); + return cdnUrl(uuid, side, size); + } + + /** + * Builds a Scryfall CDN image URL from a card UUID. The CDN ({@code cards.scryfall.io}) + * is not rate-limited; given a UUID and image size, the URL is fully deterministic: + * {@code https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg}. + * + * @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/gui/download/ScryfallBulkData.java b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java deleted file mode 100644 index d4133d92c196..000000000000 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkData.java +++ /dev/null @@ -1,31 +0,0 @@ -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"; - } -} From 79766c1614a47f884e48f15705d2253f2f09687f Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 11 Aug 2026 18:33:21 +0100 Subject: [PATCH 08/18] Drop forge-extras as a data source; generate CDN UUID cache client-side CdnUuidCache no longer fetches per-set JSON from the forge-extras repo. On a cache miss it now asks the new ScryfallSetSync to build that set's mapping on the spot from Scryfall's card search API (scoped to that one set, all languages), the client-side equivalent of what the old forge-extras/CLI generator did against a full bulk-data export. Unlike ScryfallManifestSync (which only ever sees a card's own id), the search API exposes each face's own image URL, so a double-faced card's rare genuinely-distinct back-face UUID is captured precisely via the new CdnUuidCache.mergeSetEntriesWithFaces, instead of assumed to match the front. This replaces the forge-scryfall-uuid-map CLI and forge-extras cdn_uuid data entirely -- both PRs are being closed in favor of this. --- .../forge/gui/download/CdnUuidCacheTest.java | 68 ++---- .../download/ScryfallManifestSyncTest.java | 5 + .../gui/download/ScryfallSetSyncTest.java | 221 ++++++++++++++++++ .../java/forge/gui/download/CdnUuidCache.java | 144 +++++------- .../forge/gui/download/ScryfallSetSync.java | 170 ++++++++++++++ .../properties/ForgeConstants.java | 1 - 6 files changed, 480 insertions(+), 129 deletions(-) create mode 100644 forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java create mode 100644 forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java 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 index 6fb6c6b4792e..0ba85b88aa1b 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -6,10 +6,20 @@ import org.testng.annotations.Test; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; - +import java.util.zip.GZIPOutputStream; + +/** + * All UUID data here is seeded straight into the local cache dir (gzip-compressed, + * exactly as {@link CdnUuidCache} writes it) rather than served from anywhere remote — + * there's no hosted data source anymore. {@link ScryfallSetSync#searchBaseUrlOverride} + * points at an unreachable address so any lookup that isn't pre-seeded fails fast + * instead of hitting the real Scryfall API; on-demand generation from Scryfall itself + * is covered by {@link ScryfallSetSyncTest}. + */ @Test(groups = {"UnitTest"}) public class CdnUuidCacheTest { @@ -22,44 +32,40 @@ public class CdnUuidCacheTest { 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"), + // tst.json.gz — single-faced cards, multiple languages + writeGzip(new File(localCacheDir, SET + ".json.gz"), "{" + "\"1\":{\"en\":\"" + UUID_EN + "\",\"ja\":\"" + UUID_JA + "\"}," + "\"2\":{\"en\":\"" + UUID_EN + "\"}" + "}"); - // dfc.json — double-faced cards - write(new File(remoteDir, SET_DFC + ".json"), + // dfc.json.gz — double-faced cards + writeGzip(new File(localCacheDir, SET_DFC + ".json.gz"), "{" + "\"1\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_BACK + "\"]}," + "\"2\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}" + "}"); - // SET_ABSENT has no file in remoteDir — lookups must return null + // SET_ABSENT has no local file and the Scryfall override is unreachable — + // lookups must return null rather than hang or hit the real API. CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; - CdnUuidCache.remoteBaseUrlOverride = remoteDir.toURI().toURL().toString(); + ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable"; CdnUuidCache.clearCacheForTesting(); } @AfterClass public void tearDown() { CdnUuidCache.localCacheDirOverride = null; - CdnUuidCache.remoteBaseUrlOverride = null; + ScryfallSetSync.searchBaseUrlOverride = null; CdnUuidCache.clearCacheForTesting(); deleteDir(localCacheDir); - deleteDir(remoteDir); } // --- CDN URL formula --- @@ -146,32 +152,6 @@ public void uppercaseSetCode_lowercasedBeforeLookup() { Assert.assertEquals(url, CdnUuidCache.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, - // gzip-compressed. - CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); - Assert.assertTrue(new File(localCacheDir, SET + ".json.gz").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, CdnUuidCache.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 @@ -186,13 +166,13 @@ public void nullCollectorNumber_returnsNull() { @Test public void absentSet_returnsNull() { - // No local file and no remote file for SET_ABSENT. + // No local file for SET_ABSENT, and the Scryfall search override is unreachable. 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. + // MISSING_SET sentinel must be in cache; second lookup must not retry Scryfall. Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "99", "en", "front", "normal")); } @@ -203,8 +183,10 @@ public void unknownCollectorNumber_returnsNull() { // --- helpers --- - private static void write(File f, String content) throws IOException { - Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + private static void writeGzip(File f, String content) throws IOException { + try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(f))) { + gz.write(content.getBytes(StandardCharsets.UTF_8)); + } } private static void deleteDir(File dir) { diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java index e9171923344c..14b86e83a292 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java @@ -44,6 +44,10 @@ public void setUp() throws IOException { server.createContext("/", this::handle); server.start(); ScryfallManifestSync.manifestBaseUrlOverride = "http://127.0.0.1:" + server.getAddress().getPort(); + // A lookup CdnUuidCache can't answer locally now falls back to ScryfallSetSync; + // point it at an unreachable address so these tests stay offline and a "should be + // null" assertion isn't quietly satisfied by a live Scryfall hit instead. + ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable"; } @AfterClass @@ -51,6 +55,7 @@ public void tearDown() { server.stop(0); CdnUuidCache.localCacheDirOverride = null; ScryfallManifestSync.manifestBaseUrlOverride = null; + ScryfallSetSync.searchBaseUrlOverride = null; } @AfterMethod diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java new file mode 100644 index 000000000000..e75e95f02ca0 --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java @@ -0,0 +1,221 @@ +package forge.gui.download; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Exercises {@link ScryfallSetSync} — the on-demand, per-set generator that + * {@link CdnUuidCache} falls back to when a set has no local data yet — against + * a tiny embedded HTTP server standing in for {@code api.scryfall.com/cards/search}, + * so these run offline and don't depend on Scryfall's real catalog. + */ +@Test(groups = {"UnitTest"}) +public class ScryfallSetSyncTest { + + private HttpServer server; + private String baseUrl; + private File localCacheDir; + private final AtomicInteger requestCount = new AtomicInteger(); + private volatile String lastQuery; + + /** Pages served for the current test, keyed by 1-based page number. */ + private final Map pages = new HashMap<>(); + + @BeforeClass + public void setUp() throws IOException { + localCacheDir = Files.createTempDirectory("setsync_local").toFile(); + CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + ScryfallSetSync.searchBaseUrlOverride = baseUrl; + } + + @AfterClass + public void tearDown() { + server.stop(0); + CdnUuidCache.localCacheDirOverride = null; + ScryfallSetSync.searchBaseUrlOverride = null; + } + + @AfterMethod + public void resetBetweenTests() { + CdnUuidCache.clearCacheForTesting(); + pages.clear(); + requestCount.set(0); + lastQuery = null; + for (File f : localCacheDir.listFiles()) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + } + + private void handle(HttpExchange ex) throws IOException { + requestCount.incrementAndGet(); + lastQuery = ex.getRequestURI().getRawQuery(); + Map params = queryParams(lastQuery); + int page = Integer.parseInt(params.getOrDefault("page", "1")); + String body = pages.get(page); + byte[] bytes; + int status; + if (body == null) { + bytes = "{\"object\":\"error\",\"status\":404,\"details\":\"no cards found\"}" + .getBytes(StandardCharsets.UTF_8); + status = 404; + } else { + bytes = body.getBytes(StandardCharsets.UTF_8); + status = 200; + } + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(status, bytes.length); + try (var os = ex.getResponseBody()) { + os.write(bytes); + } + } + + private static Map queryParams(String rawQuery) { + Map out = new HashMap<>(); + if (rawQuery == null) return out; + for (String pair : rawQuery.split("&")) { + int eq = pair.indexOf('='); + out.put(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8)); + } + return out; + } + + private static String cdnUrl(String uuid) { + return "https://cards.scryfall.io/normal/front/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg"; + } + + private static String singleFaced(String id, String cn, String lang) { + return String.format( + "{\"id\":\"%s\",\"collector_number\":\"%s\",\"lang\":\"%s\",\"image_uris\":{\"normal\":\"%s\"}}", + id, cn, lang, cdnUrl(id)); + } + + private static String doubleFaced(String id, String cn, String lang, String frontUuid, String backUuid) { + return String.format( + "{\"id\":\"%s\",\"collector_number\":\"%s\",\"lang\":\"%s\",\"card_faces\":[" + + "{\"image_uris\":{\"normal\":\"%s\"}}," + + "{\"image_uris\":{\"normal\":\"%s\"}}]}", + id, cn, lang, cdnUrl(frontUuid), cdnUrl(backUuid)); + } + + private String page(boolean hasMore, int pageNumber, String... entries) { + String nextPage = hasMore ? ",\"next_page\":\"" + baseUrl + "/?page=" + (pageNumber + 1) + "\"" : ""; + return "{\"object\":\"list\",\"has_more\":" + hasMore + nextPage + + ",\"data\":[" + String.join(",", entries) + "]}"; + } + + // ------------------------------------------------------------------------- + + @Test + public void singleFacedCard_resolvesViaCdnUuidCache() { + pages.put(1, page(false, 1, + singleFaced("11111111-1111-1111-1111-111111111111", "1", "en"))); + + String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); + + Assert.assertEquals(url, + CdnUuidCache.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal")); + Assert.assertTrue(new File(localCacheDir, "neo.json.gz").exists()); + } + + @Test + public void dfcWithDistinctArtworkUuids_bothFacesResolveIndependently() { + pages.put(1, page(false, 1, + doubleFaced("22222222-2222-2222-2222-222222222222", "5", "en", + "aaaaaaaa-0000-0000-0000-000000000001", + "bbbbbbbb-0000-0000-0000-000000000002"))); + + String front = CdnUuidCache.getCdnUrl("sld", "5", "en", "front", "normal"); + String back = CdnUuidCache.getCdnUrl("sld", "5", "en", "back", "normal"); + + Assert.assertEquals(front, CdnUuidCache.cdnUrl("aaaaaaaa-0000-0000-0000-000000000001", "front", "normal"), + "front face UUID should come from card_faces[0].image_uris, not the card id"); + Assert.assertEquals(back, CdnUuidCache.cdnUrl("bbbbbbbb-0000-0000-0000-000000000002", "back", "normal"), + "back face UUID should come from card_faces[1].image_uris, distinct from front"); + } + + @Test + public void dfcWithSharedArtworkUuid_backResolvesToSameUuid() { + pages.put(1, page(false, 1, + doubleFaced("33333333-3333-3333-3333-333333333333", "6", "en", + "cccccccc-0000-0000-0000-000000000003", + "cccccccc-0000-0000-0000-000000000003"))); + + String back = CdnUuidCache.getCdnUrl("neo", "6", "en", "back", "normal"); + + Assert.assertEquals(back, CdnUuidCache.cdnUrl("cccccccc-0000-0000-0000-000000000003", "back", "normal")); + } + + @Test + public void multiplePages_paginatesViaNextPageAndMergesAll() { + pages.put(1, page(true, 1, singleFaced("dddddddd-0000-0000-0000-000000000001", "1", "en"))); + pages.put(2, page(false, 2, singleFaced("eeeeeeee-0000-0000-0000-000000000002", "2", "en"))); + + String url1 = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); + String url2 = CdnUuidCache.getCdnUrl("neo", "2", "en", "front", "normal"); + + Assert.assertNotNull(url1); + Assert.assertNotNull(url2); + Assert.assertEquals(requestCount.get(), 2, "should follow next_page across both pages"); + } + + @Test + public void unknownSet_returns404_lookupIsNullWithoutThrowing() { + // No page registered for "xyz" -> handle() serves a 404, same as Scryfall for no matches. + String url = CdnUuidCache.getCdnUrl("xyz", "1", "en", "front", "normal"); + + Assert.assertNull(url); + Assert.assertFalse(new File(localCacheDir, "xyz.json.gz").exists()); + } + + @Test + public void sync_neverOverwritesExistingPrecomputedEntry() { + // Pre-existing, precise data (e.g. from a prior manifest sync). + Map> existing = new HashMap<>(); + Map cn1 = new HashMap<>(); + cn1.put("en", new String[]{"ffffffff-0000-0000-0000-000000000001", null}); + existing.put("1", cn1); + CdnUuidCache.mergeSetEntriesWithFaces("neo", existing); + + // Scryfall search sees a *different* id for the same card/lang -- must not clobber it. + pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "1", "en"))); + ScryfallSetSync.sync("neo"); + + String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl("ffffffff-0000-0000-0000-000000000001", "front", "normal"), + "pre-existing entries must win over a freshly-synced one"); + } + + @Test + public void searchQuery_scopesToSetAndAllLanguages() { + pages.put(1, page(false, 1, singleFaced("11111111-2222-3333-4444-555555555555", "1", "en"))); + + CdnUuidCache.getCdnUrl("ltr", "1", "en", "front", "normal"); + + Assert.assertNotNull(lastQuery); + String decoded = URLDecoder.decode(lastQuery, StandardCharsets.UTF_8); + Assert.assertTrue(decoded.contains("set:ltr"), "query should scope to the requested set: " + decoded); + Assert.assertTrue(decoded.contains("lang:any"), "query should request all languages: " + decoded); + } +} diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 1c890d3c2fb0..67733c288d31 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -7,14 +7,10 @@ import forge.localinstance.properties.ForgeConstants; import org.tinylog.Logger; -import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; 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; @@ -29,20 +25,15 @@ /** * 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.gz}. If absent it fetches the file from - * forge-extras and writes it locally (gzip-compressed) 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. - * - *

The remote fetch requests {@code Accept-Encoding: gzip}; GitHub's raw content - * server honors it (roughly halving transfer size for this UUID-heavy JSON), and - * the response is decompressed transparently. The local disk copy is stored - * gzip-compressed too, independent of whatever encoding the remote used. Note this - * is purely a runtime concern — the JSON files as committed to forge-extras are - * kept as plain, sorted text so they stay diffable; gzipping those would make - * every regeneration a full-file binary rewrite instead of a minimal diff. + *

All UUID data is generated on the user's machine, straight from Scryfall — there + * is no hosted/bundled data source. On the first lookup for a set, the cache checks + * for a local copy under {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. If absent, it + * asks {@link ScryfallSetSync} to build that set's mapping from the Scryfall card + * search API and writes the result locally (gzip-compressed) so subsequent lookups + * are instant. {@link ScryfallManifestSync} additionally lets a set's entries be kept + * fresh in bulk (newest image updates first) instead of one set at a time. Returns + * {@code null} on any failure so callers fall back to the rate-limited Scryfall API + * or the cardforge server. * *

Set JSON format: *

@@ -56,9 +47,8 @@
 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). */
+    /** Sentinel: set was looked up and no data exists (locally or on Scryfall). */
     private static final Map> MISSING_SET = Collections.emptyMap();
 
     private static final class LangUuids {
@@ -77,12 +67,6 @@ private static final class LangUuids {
      */
     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. */
@@ -113,17 +97,35 @@ public static void clearCache() {
 
     /**
      * Merges externally-sourced (setCode → collectorNumber → lang → uuid) entries into
-     * the on-disk cache, creating or updating {@code {cacheDir}/{setCode}.json.gz}.
-     * Used by {@link ScryfallManifestSync} when a user builds/refreshes their own
-     * lookup data straight from the Scryfall API instead of (or in addition to)
-     * forge-extras.
+     * the on-disk cache. Used by {@link ScryfallManifestSync}, whose data source (the
+     * manifest endpoint) never distinguishes a double-faced card's back-face UUID from
+     * its front — every entry is treated as front-only. Delegates to
+     * {@link #mergeSetEntriesWithFaces} so the never-overwrite guarantee lives in one place.
+     */
+    static void mergeSetEntries(String setCode, Map> newEntries) {
+        Map> withFaces = new HashMap<>(newEntries.size() * 2);
+        for (Map.Entry> cnEntry : newEntries.entrySet()) {
+            Map langMap = new HashMap<>(cnEntry.getValue().size() * 2);
+            for (Map.Entry langEntry : cnEntry.getValue().entrySet()) {
+                langMap.put(langEntry.getKey(), new String[]{langEntry.getValue(), null});
+            }
+            withFaces.put(cnEntry.getKey(), langMap);
+        }
+        mergeSetEntriesWithFaces(setCode, withFaces);
+    }
+
+    /**
+     * Merges externally-sourced (setCode → collectorNumber → lang → [frontUuid, backUuidOrNull])
+     * entries into the on-disk cache, creating or updating {@code {cacheDir}/{setCode}.json.gz}.
+     * Used by {@link ScryfallSetSync} when a set has no local data at all yet and is built
+     * fresh from Scryfall's card search API, which — unlike the manifest endpoint — exposes
+     * each face's own image URL, so a double-faced card's genuinely distinct back-face UUID
+     * (a real but rare case) can be recorded precisely instead of assumed.
      *
-     * 

Existing entries are never overwritten: forge-extras/CLI-sourced data can - * record a double-faced card's distinct front/back UUIDs (a real but rare case), - * while a manifest-derived entry only ever has a single UUID (see - * {@link ScryfallManifestSync}) — merging must fill gaps, not degrade precision. + *

Existing entries are never overwritten, so a precise front/back pair already on + * disk can't be degraded by a later, less precise source filling the same slot. */ - static synchronized void mergeSetEntries(String setCode, Map> newEntries) { + static synchronized void mergeSetEntriesWithFaces(String setCode, Map> newEntries) { File file = localCacheFile(setCode); JsonObject merged = new JsonObject(); if (file.exists()) { @@ -135,13 +137,22 @@ static synchronized void mergeSetEntries(String setCode, Map> cnEntry : newEntries.entrySet()) { + for (Map.Entry> cnEntry : newEntries.entrySet()) { String cn = cnEntry.getKey(); JsonObject langObj = merged.has(cn) && merged.get(cn).isJsonObject() ? merged.getAsJsonObject(cn) : new JsonObject(); - for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { - if (!langObj.has(langEntry.getKey())) { - langObj.addProperty(langEntry.getKey(), langEntry.getValue()); + for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { + String lang = langEntry.getKey(); + if (langObj.has(lang)) continue; + String front = langEntry.getValue()[0]; + String back = langEntry.getValue()[1]; + if (back != null && !back.equals(front)) { + JsonArray arr = new JsonArray(); + arr.add(front); + arr.add(back); + langObj.add(lang, arr); + } else { + langObj.addProperty(lang, front); } } if (langObj.size() > 0) merged.add(cn, langObj); @@ -222,16 +233,16 @@ private static Map> loadSet(String setCode) { } } - // 2. Fetch from remote, cache locally - String remoteUrl = remoteUrl(setCode); - try { - String json = fetchString(remoteUrl); - if (json != null) { - writeLocalCache(localFile, json); - return parseSetJson(json); + // 2. Nothing local yet — build this set's mapping straight from Scryfall. + // ScryfallSetSync merges its own results into the local cache file (via + // mergeSetEntriesWithFaces) and evicts setCache, so re-reading that file + // afterward picks up whatever it found. + if (ScryfallSetSync.sync(setCode) && localFile.exists()) { + try { + return parseSetFile(localFile); + } catch (Exception e) { + Logger.warn("CdnUuidCache: corrupt local cache {} after Scryfall sync: {}", localFile, e.getMessage()); } - } catch (Exception e) { - Logger.debug("CdnUuidCache: no UUID data for set '{}': {}", setCode, e.getMessage()); } return MISSING_SET; @@ -241,39 +252,6 @@ private static File localCacheFile(String setCode) { return new File(cacheDir(), setCode + ".json.gz"); } - 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.setRequestProperty("Accept-Encoding", "gzip"); - 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); - } - // Setting Accept-Encoding ourselves means the JDK won't auto-decompress; - // only unwrap if the server actually honored it (e.g. file:// URLs never do). - boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding()); - try (InputStream raw = conn.getInputStream(); - InputStream is = gzipped ? new GZIPInputStream(raw) : raw; - 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 @@ -301,10 +279,6 @@ private static Map> parseSetFile(File file) throw } } - private static Map> parseSetJson(String json) { - return parseSetObject(JsonParser.parseString(json).getAsJsonObject()); - } - /** * Parses a set JSON object. * Format: {@code {"cn": {"lang": "uuid"|["frontUuid","backUuid"]}, ...}} diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java new file mode 100644 index 000000000000..fe5c6560a87e --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -0,0 +1,170 @@ +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 org.tinylog.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * Builds the local CDN UUID cache for a single set, on demand, straight from + * Scryfall's card search API — the client-side equivalent of what the old + * forge-extras/CLI generator did against a full bulk-data export, but scoped + * to exactly the set a lookup just missed on, so nothing needs to be + * pre-generated or hosted anywhere. + * + *

Unlike {@link ScryfallManifestSync} (which only ever sees a card's own + * {@code id} and assumes a double-faced card's back face shares it), the + * search API returns each card's own {@code image_uris} (single-faced) or + * {@code card_faces[].image_uris} (double-faced) — so the UUID is read out of + * the actual CDN image URL for each face, exactly like the old bulk-data + * generator did. That matters for a small number of double-faced cards (e.g. + * a couple of Secret Lair prints) whose back face uses a genuinely different + * artwork UUID than the card's {@code id}. + */ +final class ScryfallSetSync { + + private static final String DEFAULT_SEARCH_URL = "https://api.scryfall.com/cards/search"; + + /** Scryfall's general API guidance: 50-100ms between requests. */ + private static final long REQUEST_INTERVAL_MS = 100; + private static final int CONNECT_TIMEOUT_MS = 15_000; + + /** Override for tests; must be a full base URL with no trailing query string. */ + static volatile String searchBaseUrlOverride = null; + + private ScryfallSetSync() {} + + /** + * Fetches every print of {@code setCode} (all languages) from Scryfall and merges + * the result into {@link CdnUuidCache}'s local cache for that set. + * + * @return {@code true} if any card data was found and cached for this set + */ + static boolean sync(String setCode) { + Map> byCn = new HashMap<>(); + String url = searchUrl(setCode); + try { + while (url != null) { + JsonObject page = fetchJson(url); + if (page == null) break; // 404: no such set / no matching cards + + JsonArray data = page.has("data") ? page.getAsJsonArray("data") : new JsonArray(); + for (JsonElement el : data) { + addCard(byCn, el.getAsJsonObject()); + } + + boolean hasMore = page.has("has_more") && page.get("has_more").getAsBoolean(); + url = hasMore && page.has("next_page") ? page.get("next_page").getAsString() : null; + if (url != null) Thread.sleep(REQUEST_INTERVAL_MS); + } + } catch (Exception e) { + Logger.debug("ScryfallSetSync: could not build set '{}' from Scryfall: {}", setCode, e.getMessage()); + } + + if (byCn.isEmpty()) return false; + CdnUuidCache.mergeSetEntriesWithFaces(setCode, byCn); + return true; + } + + // ------------------------------------------------------------------------- + + private static void addCard(Map> byCn, JsonObject card) { + String cn = str(card, "collector_number"); + String lang = str(card, "lang"); + String id = str(card, "id"); + if (cn == null || lang == null || id == null) return; + + String front; + String back = null; + if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { + front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris")), id); + } else if (card.has("card_faces") && card.get("card_faces").isJsonArray()) { + JsonArray faces = card.getAsJsonArray("card_faces"); + if (faces.size() == 0) return; + JsonObject face0 = faces.get(0).getAsJsonObject(); + if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) return; + front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris")), id); + if (faces.size() > 1) { + JsonObject face1 = faces.get(1).getAsJsonObject(); + if (face1.has("image_uris") && face1.get("image_uris").isJsonObject()) { + back = uuidFromUrl(normalUrl(face1.getAsJsonObject("image_uris")), id); + } + } + } else { + return; // no image data for this card + } + if (front == null) return; + + byCn.computeIfAbsent(cn, k -> new HashMap<>()).put(lang, new String[]{front, back}); + } + + /** Reads an {@code image_uris} object and returns the value of the {@code normal} key. */ + private static String normalUrl(JsonObject imageUris) { + return imageUris.has("normal") ? imageUris.get("normal").getAsString() : null; + } + + /** + * Extracts the UUID segment from a Scryfall CDN image URL. + * + *

URL format: {@code https://cards.scryfall.io/normal/front/4/e/{uuid}.jpg?timestamp} + * + *

Parsing the UUID from the URL (rather than using the card's {@code id} field directly) + * correctly handles the rare double-faced cards where both faces share an artwork UUID + * that differs from the card's own {@code id}. Falls back to {@code cardId} for non-CDN + * URLs such as {@code errors.scryfall.com/soon.jpg} (placeholder for missing images). + */ + private static String uuidFromUrl(String url, String cardId) { + if (url == null) return null; + if (!url.contains("cards.scryfall.io")) return cardId; + int qmark = url.indexOf('?'); + String path = qmark >= 0 ? url.substring(0, qmark) : url; + int slash = path.lastIndexOf('/'); + String filename = slash >= 0 ? path.substring(slash + 1) : path; + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(0, dot) : filename; + } + + private static String searchUrl(String setCode) { + String base = searchBaseUrlOverride != null ? searchBaseUrlOverride : DEFAULT_SEARCH_URL; + String query = "set:" + setCode + " lang:any"; + return base + "?unique=prints&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); + } + + private static String str(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; + } + + /** Fetches {@code urlStr} and parses it as JSON, or returns {@code null} for HTTP 404. */ + private static JsonObject fetchJson(String urlStr) throws IOException { + URLConnection conn = new URL(urlStr).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(CONNECT_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Encoding", "gzip"); + conn.connect(); + if (conn instanceof HttpURLConnection) { + int status = ((HttpURLConnection) conn).getResponseCode(); + if (status == 404) return null; + if (status != 200) throw new IOException("HTTP " + status + " for " + urlStr); + } + boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding()); + try (InputStream raw = conn.getInputStream(); + InputStream is = gzipped ? new GZIPInputStream(raw) : raw; + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + } +} 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 8aeca4a94c81..33cd665a29d3 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -348,7 +348,6 @@ public final class ForgeConstants { 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"; From ba00cb006e49ef6211b5f2b45d895da995be1b91 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 11 Aug 2026 20:27:31 +0100 Subject: [PATCH 09/18] Update comments and structure --- .../download/ScryfallManifestSyncTest.java | 12 +++--- forge-gui/res/languages/en-US.properties | 4 +- .../java/forge/gui/download/CdnUuidCache.java | 41 +++++++++---------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java index 14b86e83a292..ee29792f385f 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java @@ -21,9 +21,10 @@ import java.util.concurrent.atomic.AtomicInteger; /** - * Exercises {@link ScryfallManifestSync} against a tiny embedded HTTP server standing - * in for {@code api.scryfall.com/cards/manifest}, so these run offline and don't - * depend on Scryfall's real rate limit or live catalog. + * Tests run against an embedded mock scryfall api. + * + * The ScryfallManifestSync pulls in the latest uploaded scans from Scryfall. + * This is not the same as bulk downloading that happens while browsing decks/adventure. */ @Test(groups = {"UnitTest"}) public class ScryfallManifestSyncTest { @@ -44,9 +45,8 @@ public void setUp() throws IOException { server.createContext("/", this::handle); server.start(); ScryfallManifestSync.manifestBaseUrlOverride = "http://127.0.0.1:" + server.getAddress().getPort(); - // A lookup CdnUuidCache can't answer locally now falls back to ScryfallSetSync; - // point it at an unreachable address so these tests stay offline and a "should be - // null" assertion isn't quietly satisfied by a live Scryfall hit instead. + // A CdnUuidCache miss falls back to ScryfallSetSync; + // point it at an unreachable address so these tests stay offline ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable"; } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index cc13b32c3013..f6bfa3d4349f 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -328,12 +328,12 @@ lblDownloadCardImages=Pre-download cards or check how many images are missing. lblDownloaded=Downloaded lblMissing=Missing btnBuildCdnImageCache=Build CDN Image Lookup -lblBuildCdnImageCache=Fetch card image locations straight from Scryfall (English only) so downloads use their fast CDN instead of the rate-limited API. Safe to run repeatedly -- later runs only fetch what's changed. +lblBuildCdnImageCache=Fetch card image locations straight from Scryfall (English only) lblBuildingCdnImageCache=Fetching card image locations from Scryfall... lblCdnImageCacheBuildComplete=Done. {0} card entries added or updated. lblCdnImageCacheBuildFailed=Could not fetch data from Scryfall: {0} btnClearCdnImageCache=Clear CDN Image Lookup Cache -lblClearCdnImageCache=Delete the local CDN image lookup cache. Use this if some cards keep resolving through the slower fallback even though their images should be available -- Forge will re-fetch cache data as needed afterward. +lblClearCdnImageCache=Delete the local CDN image lookup cache. lblClearCdnImageCacheConfirm=This deletes the local CDN image lookup cache. Forge will re-fetch it as needed. Continue? lblCdnImageCacheCleared=CDN image lookup cache cleared. lblDownloadCJKFonts=Download fonts for locales using CJK glyphs. diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 67733c288d31..f82dfb54b7d0 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -25,15 +25,16 @@ /** * Lazy-loading, thread-safe cache for Scryfall CDN UUIDs. * - *

All UUID data is generated on the user's machine, straight from Scryfall — there - * is no hosted/bundled data source. On the first lookup for a set, the cache checks - * for a local copy under {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. If absent, it - * asks {@link ScryfallSetSync} to build that set's mapping from the Scryfall card - * search API and writes the result locally (gzip-compressed) so subsequent lookups - * are instant. {@link ScryfallManifestSync} additionally lets a set's entries be kept - * fresh in bulk (newest image updates first) instead of one set at a time. Returns - * {@code null} on any failure so callers fall back to the rate-limited Scryfall API - * or the cardforge server. + * All UUID data is generated on the user's machine, straight from Scryfall. + * + * The cache resolves data locally first from + * {@code {cacheDir}/cdn_uuid/{setCode}.json.gz} files. + * + * When there is a cache miss (file or entry), it's repopulated from + * the scryfall search api. + * + * The cache returns null, so the user may resolve via a rate-limited, non-cdn scryfall + * image hosting. The client rate limits these requests. * *

Set JSON format: *

@@ -48,7 +49,7 @@ public final class CdnUuidCache {
 
     private static final String FALLBACK_LANG    = "en";
 
-    /** Sentinel: set was looked up and no data exists (locally or on Scryfall). */
+    /** Used when set was looked up and no data exists. */
     private static final Map> MISSING_SET = Collections.emptyMap();
 
     private static final class LangUuids {
@@ -62,14 +63,13 @@ private static final class LangUuids {
             new ConcurrentHashMap<>();
 
     /**
-     * Override the local cache directory. Package-private for unit tests.
-     * Must end with the platform file separator when set.
+     * Override for the local cache directory for tests.
      */
     static volatile String localCacheDirOverride = null;
 
     private CdnUuidCache() {}
 
-    /** Clears the in-memory cache. Package-private for unit tests only. */
+    /** Test helper */
     static void clearCacheForTesting() { setCache.clear(); }
 
     /** The directory local set caches live in, honoring the test override. Package-private for {@link ScryfallManifestSync}. */
@@ -79,10 +79,6 @@ static String cacheDir() {
 
     /**
      * Deletes every locally cached CDN UUID set file and clears the in-memory cache.
-     * Exposed as a user-facing "clear CDN image cache" action: a set whose local
-     * copy predates a Scryfall image update (or was cached before it had any image
-     * at all) stays stale until re-fetched, and this is the simplest way to force
-     * that without per-file expiry bookkeeping.
      */
     public static void clearCache() {
         setCache.clear();
@@ -97,10 +93,12 @@ public static void clearCache() {
 
     /**
      * Merges externally-sourced (setCode → collectorNumber → lang → uuid) entries into
-     * the on-disk cache. Used by {@link ScryfallManifestSync}, whose data source (the
-     * manifest endpoint) never distinguishes a double-faced card's back-face UUID from
-     * its front — every entry is treated as front-only. Delegates to
-     * {@link #mergeSetEntriesWithFaces} so the never-overwrite guarantee lives in one place.
+     * the on-disk cache.
+     *
+     * Used by {@link ScryfallManifestSync}, whose data source (the manifest endpoint)
+     * never distinguishes a double-faced card's back-face UUID from its front.
+     *
+     * Delegates to {@link #mergeSetEntriesWithFaces} so the never-overwrite guarantee lives in one place.
      */
     static void mergeSetEntries(String setCode, Map> newEntries) {
         Map> withFaces = new HashMap<>(newEntries.size() * 2);
@@ -117,6 +115,7 @@ static void mergeSetEntries(String setCode, Map> new
     /**
      * Merges externally-sourced (setCode → collectorNumber → lang → [frontUuid, backUuidOrNull])
      * entries into the on-disk cache, creating or updating {@code {cacheDir}/{setCode}.json.gz}.
+     *
      * Used by {@link ScryfallSetSync} when a set has no local data at all yet and is built
      * fresh from Scryfall's card search API, which — unlike the manifest endpoint — exposes
      * each face's own image URL, so a double-faced card's genuinely distinct back-face UUID

From d9a1a191f5d3619d5ffcc6c9cca302a5abad9d90 Mon Sep 17 00:00:00 2001
From: Hugh Kaznowski 
Date: Tue, 11 Aug 2026 20:38:32 +0100
Subject: [PATCH 10/18] Update locale

---
 forge-gui/res/languages/de-DE.properties | 9 +++++++++
 forge-gui/res/languages/es-ES.properties | 9 +++++++++
 forge-gui/res/languages/fr-FR.properties | 9 +++++++++
 forge-gui/res/languages/it-IT.properties | 9 +++++++++
 forge-gui/res/languages/ja-JP.properties | 9 +++++++++
 forge-gui/res/languages/ko-KR.properties | 9 +++++++++
 forge-gui/res/languages/pt-BR.properties | 9 +++++++++
 forge-gui/res/languages/ru-RU.properties | 9 +++++++++
 forge-gui/res/languages/zh-CN.properties | 9 +++++++++
 9 files changed, 81 insertions(+)

diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties
index e7f9535f8fab..f23ad9159a79 100644
--- a/forge-gui/res/languages/de-DE.properties
+++ b/forge-gui/res/languages/de-DE.properties
@@ -287,6 +287,15 @@ lblDownloadSkins=Lade verfügbare Skins herunter, um die Erscheinung von Forge a
 lblDownloadCardImages=Kartenbilder vorab herunterladen oder prüfen, wie viele Bilder fehlen.
 lblDownloaded=Heruntergeladen
 lblMissing=Fehlend
+btnBuildCdnImageCache=CDN-Bildzuordnung erstellen
+lblBuildCdnImageCache=Ruft Kartenbild-Speicherorte direkt von Scryfall ab (nur Englisch), damit Downloads das schnelle CDN statt der ratenbegrenzten API nutzen. Kann gefahrlos wiederholt ausgeführt werden -- spätere Läufe holen nur Änderungen ab.
+lblBuildingCdnImageCache=Kartenbild-Speicherorte werden von Scryfall abgerufen...
+lblCdnImageCacheBuildComplete=Fertig. {0} Karteneinträge hinzugefügt oder aktualisiert.
+lblCdnImageCacheBuildFailed=Daten konnten nicht von Scryfall abgerufen werden: {0}
+btnClearCdnImageCache=CDN-Bildzuordnung leeren
+lblClearCdnImageCache=Löscht den lokalen CDN-Bildzuordnungs-Cache. Nützlich, wenn manche Karten weiterhin über den langsameren Ausweichpfad geladen werden, obwohl ihre Bilder eigentlich verfügbar sein sollten -- Forge ruft die Daten danach bei Bedarf erneut ab.
+lblClearCdnImageCacheConfirm=Dies löscht den lokalen CDN-Bildzuordnungs-Cache. Forge ruft ihn bei Bedarf erneut ab. Fortfahren?
+lblCdnImageCacheCleared=CDN-Bildzuordnungs-Cache geleert.
 lblDownloadCJKFonts=Lade Schriftarten zur lokalen Nutzung von CJK-Zeichen herunter.
 lblDownloadCJKFontPrompt=Wähle eine Schriftart zum Herunterladen aus.
 lblYourVersionOfJavaIsTooOld=Deine Java-Version ist leider zu alt.
diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties
index 501ceb406956..17fcd8c3d5b2 100644
--- a/forge-gui/res/languages/es-ES.properties
+++ b/forge-gui/res/languages/es-ES.properties
@@ -263,6 +263,15 @@ lblDownloadSkins=Descarga los temas disponibles para personalizar el aspecto de
 lblDownloadCardImages=Descarga imágenes de cartas por adelantado o comprueba cuántas faltan.
 lblDownloaded=Descargado
 lblMissing=Faltante
+btnBuildCdnImageCache=Generar búsqueda de imágenes CDN
+lblBuildCdnImageCache=Obtiene las ubicaciones de las imágenes de las cartas directamente de Scryfall (solo en inglés) para que las descargas usen su CDN rápida en lugar de la API con límite de solicitudes. Se puede ejecutar repetidamente sin problema -- las siguientes ejecuciones solo obtienen lo que haya cambiado.
+lblBuildingCdnImageCache=Obteniendo ubicaciones de imágenes de cartas desde Scryfall...
+lblCdnImageCacheBuildComplete=Listo. Se añadieron o actualizaron {0} entradas de cartas.
+lblCdnImageCacheBuildFailed=No se pudieron obtener datos de Scryfall: {0}
+btnClearCdnImageCache=Borrar caché de búsqueda de imágenes CDN
+lblClearCdnImageCache=Elimina la caché local de búsqueda de imágenes CDN. Úsalo si algunas cartas siguen resolviéndose por la vía alternativa más lenta aunque sus imágenes deberían estar disponibles -- Forge la volverá a obtener cuando sea necesario.
+lblClearCdnImageCacheConfirm=Esto elimina la caché local de búsqueda de imágenes CDN. Forge la volverá a obtener cuando sea necesario. ¿Continuar?
+lblCdnImageCacheCleared=Caché de búsqueda de imágenes CDN borrada.
 lblDownloadCJKFonts=Descargar fuentes para idiomas que utilicen glifos CJK.
 lblDownloadCJKFontPrompt=Selecciona un archivo de fuente para descargar
 lblYourVersionOfJavaIsTooOld=Tu versión de Java es demasiado antigua para usar los descargadores de contenido.
diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties
index 09e81c2b3a00..831438232629 100644
--- a/forge-gui/res/languages/fr-FR.properties
+++ b/forge-gui/res/languages/fr-FR.properties
@@ -261,6 +261,15 @@ lblDownloadSkins=T\u00e9l\u00e9charger les skins disponibles utilis\u00e9s pour
 lblDownloadCardImages=Pré-téléchargez des images de cartes ou vérifiez combien d'images manquent.
 lblDownloaded=Téléchargé
 lblMissing=Manquant
+btnBuildCdnImageCache=Générer la table de correspondance CDN
+lblBuildCdnImageCache=Récupère les emplacements des images de cartes directement depuis Scryfall (anglais uniquement) afin que les téléchargements utilisent leur CDN rapide plutôt que l'API à débit limité. Peut être exécuté à répétition sans risque -- les exécutions suivantes ne récupèrent que ce qui a changé.
+lblBuildingCdnImageCache=Récupération des emplacements des images de cartes depuis Scryfall...
+lblCdnImageCacheBuildComplete=Terminé. {0} entrées de cartes ajoutées ou mises à jour.
+lblCdnImageCacheBuildFailed=Impossible de récupérer les données depuis Scryfall : {0}
+btnClearCdnImageCache=Vider le cache de correspondance CDN
+lblClearCdnImageCache=Supprime le cache local de correspondance CDN. À utiliser si certaines cartes continuent de passer par la solution de repli plus lente alors que leurs images devraient être disponibles -- Forge le régénérera au besoin.
+lblClearCdnImageCacheConfirm=Cette action supprime le cache local de correspondance CDN. Forge le régénérera au besoin. Continuer ?
+lblCdnImageCacheCleared=Cache de correspondance CDN vidé.
 lblDownloadCJKFonts=T\u00e9l\u00e9charger les polices pour les locales utilisant les glyphes CJK.
 lblDownloadCJKFontPrompt=Sélectionnez un fichier de police à télécharger
 lblYourVersionOfJavaIsTooOld=Votre version de Java est trop ancienne pour utiliser les téléchargeurs de contenu.
diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties
index d54615883a9c..b5ef3252b5b8 100644
--- a/forge-gui/res/languages/it-IT.properties
+++ b/forge-gui/res/languages/it-IT.properties
@@ -260,6 +260,15 @@ lblDownloadSkins=Scarica i temi disponibili per personalizzare la grafica di For
 lblDownloadCardImages=Scarica in anticipo le immagini delle carte o controlla quante mancano.
 lblDownloaded=Scaricato
 lblMissing=Mancante
+btnBuildCdnImageCache=Genera mappatura immagini CDN
+lblBuildCdnImageCache=Recupera le posizioni delle immagini delle carte direttamente da Scryfall (solo inglese) in modo che i download usino la CDN veloce invece dell'API con limite di frequenza. Può essere eseguito ripetutamente senza problemi -- le esecuzioni successive recuperano solo ciò che è cambiato.
+lblBuildingCdnImageCache=Recupero delle posizioni delle immagini delle carte da Scryfall...
+lblCdnImageCacheBuildComplete=Fatto. {0} voci di carte aggiunte o aggiornate.
+lblCdnImageCacheBuildFailed=Impossibile recuperare i dati da Scryfall: {0}
+btnClearCdnImageCache=Svuota cache mappatura immagini CDN
+lblClearCdnImageCache=Elimina la cache locale della mappatura immagini CDN. Usalo se alcune carte continuano a essere risolte tramite il percorso alternativo più lento anche se le loro immagini dovrebbero essere disponibili -- Forge la recupererà nuovamente quando necessario.
+lblClearCdnImageCacheConfirm=Questa operazione elimina la cache locale della mappatura immagini CDN. Forge la recupererà nuovamente quando necessario. Continuare?
+lblCdnImageCacheCleared=Cache della mappatura immagini CDN svuotata.
 lblDownloadCJKFonts=Scarica i font per le lingue che utilizzano caratteri Cinesi-Giapponesi-Coreani
 lblDownloadCJKFontPrompt=Seleziona un font da scaricare
 lblYourVersionOfJavaIsTooOld=La tua versione di Java è troppo vecchia per scaricare contenuti aggiuntivi.
diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties
index ac67478d33d7..39d6f736110c 100644
--- a/forge-gui/res/languages/ja-JP.properties
+++ b/forge-gui/res/languages/ja-JP.properties
@@ -261,6 +261,15 @@ lblDownloadSkins=Forge のカスタマイズ用のテーマをダウンロード
 lblDownloadCardImages=カード画像を事前にダウンロードするか、不足している画像数を確認します。
 lblDownloaded=ダウンロード済み
 lblMissing=未取得
+btnBuildCdnImageCache=CDN画像索引を構築
+lblBuildCdnImageCache=Scryfallから直接カード画像の場所を取得します(英語のみ)。これにより、ダウンロードはレート制限のあるAPIではなく高速なCDNを使用します。繰り返し実行しても安全です -- 次回以降は変更分のみ取得します。
+lblBuildingCdnImageCache=Scryfallからカード画像の場所を取得しています...
+lblCdnImageCacheBuildComplete=完了しました。{0}件のカードエントリを追加または更新しました。
+lblCdnImageCacheBuildFailed=Scryfallからデータを取得できませんでした: {0}
+btnClearCdnImageCache=CDN画像索引キャッシュを削除
+lblClearCdnImageCache=ローカルのCDN画像索引キャッシュを削除します。画像が利用可能なはずなのに一部のカードが遅いフォールバック経由で解決され続ける場合に使用してください -- 必要に応じてForgeが再取得します。
+lblClearCdnImageCacheConfirm=ローカルのCDN画像索引キャッシュを削除します。必要に応じてForgeが再取得します。続行しますか?
+lblCdnImageCacheCleared=CDN画像索引キャッシュを削除しました。
 lblDownloadCJKFonts=日中韓文字を使う言語用のフォントをダウンロードします。
 lblDownloadCJKFontPrompt=ダウンロードするフォントファイルを選択してください。
 lblYourVersionOfJavaIsTooOld=Java のバージョンが古すぎるため、コンテンツダウンローダーを使用できません。
diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties
index db51eaa9c8c0..a934c245d60a 100644
--- a/forge-gui/res/languages/ko-KR.properties
+++ b/forge-gui/res/languages/ko-KR.properties
@@ -288,6 +288,15 @@ lblDownloadQuestImages=퀘스트 모드에 사용되는 토큰과 아이콘 다
 lblDownloadAchievementImages=업적 이미지를 다운로드해 트로피 강조.
 lblDownloadPrices=게임 내 카드 상점 최신 가격표 다운로드.
 lblDownloadSkins=Forge 맞춤 테마 다운로드.
+btnBuildCdnImageCache=CDN 이미지 조회 생성
+lblBuildCdnImageCache=Scryfall에서 카드 이미지 위치를 직접 가져옵니다(영어만 해당). 다운로드가 속도 제한이 있는 API 대신 빠른 CDN을 사용하도록 합니다. 반복 실행해도 안전합니다 -- 이후 실행에서는 변경된 항목만 가져옵니다.
+lblBuildingCdnImageCache=Scryfall에서 카드 이미지 위치를 가져오는 중...
+lblCdnImageCacheBuildComplete=완료되었습니다. 카드 항목 {0}개가 추가되거나 업데이트되었습니다.
+lblCdnImageCacheBuildFailed=Scryfall에서 데이터를 가져올 수 없습니다: {0}
+btnClearCdnImageCache=CDN 이미지 조회 캐시 지우기
+lblClearCdnImageCache=로컬 CDN 이미지 조회 캐시를 삭제합니다. 일부 카드의 이미지를 사용할 수 있어야 하는데도 계속 느린 대체 경로로 해결되는 경우 사용하세요 -- 이후 필요에 따라 Forge가 다시 가져옵니다.
+lblClearCdnImageCacheConfirm=로컬 CDN 이미지 조회 캐시를 삭제합니다. 필요에 따라 Forge가 다시 가져옵니다. 계속하시겠습니까?
+lblCdnImageCacheCleared=CDN 이미지 조회 캐시를 지웠습니다.
 lblDownloadCJKFonts=한중일 문자 사용하는 언어용 폰트 다운로드.
 lblDownloadCJKFontPrompt=다운로드할 폰트 파일을 선택하세요.
 lblYourVersionOfJavaIsTooOld=Java 버전이 너무 오래되어 콘텐츠 다운로더를 사용할 수 없습니다.
diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties
index 0f05face87b3..28bceaf7cc57 100644
--- a/forge-gui/res/languages/pt-BR.properties
+++ b/forge-gui/res/languages/pt-BR.properties
@@ -262,6 +262,15 @@ lblDownloadSkins=Baixar as skins disponíveis usadas na customização da aparê
 lblDownloadCardImages=Pré-baixe imagens de cartas ou verifique quantas imagens ainda faltam.
 lblDownloaded=Baixado
 lblMissing=Faltando
+btnBuildCdnImageCache=Gerar busca de imagens CDN
+lblBuildCdnImageCache=Busca as localizações das imagens das cartas diretamente do Scryfall (somente em inglês) para que os downloads usem a CDN rápida em vez da API com limite de taxa. Pode ser executado repetidamente sem problemas -- execuções posteriores buscam apenas o que mudou.
+lblBuildingCdnImageCache=Buscando localizações das imagens das cartas no Scryfall...
+lblCdnImageCacheBuildComplete=Concluído. {0} entradas de cartas adicionadas ou atualizadas.
+lblCdnImageCacheBuildFailed=Não foi possível buscar dados do Scryfall: {0}
+btnClearCdnImageCache=Limpar cache de busca de imagens CDN
+lblClearCdnImageCache=Exclui o cache local de busca de imagens CDN. Use isso se algumas cartas continuarem sendo resolvidas pelo caminho alternativo mais lento mesmo que suas imagens devessem estar disponíveis -- o Forge buscará novamente conforme necessário.
+lblClearCdnImageCacheConfirm=Isso exclui o cache local de busca de imagens CDN. O Forge buscará novamente conforme necessário. Continuar?
+lblCdnImageCacheCleared=Cache de busca de imagens CDN limpo.
 lblDownloadCJKFonts=Baixe fontes para locais usando CJK glyphs.
 lblDownloadCJKFontPrompt=Selecione um arquivo de fonte para download
 lblYourVersionOfJavaIsTooOld=Sua versão do Java é muito antiga para usar os baixadores de conteúdo.
diff --git a/forge-gui/res/languages/ru-RU.properties b/forge-gui/res/languages/ru-RU.properties
index 1d8eda90f766..a89e56d1710d 100644
--- a/forge-gui/res/languages/ru-RU.properties
+++ b/forge-gui/res/languages/ru-RU.properties
@@ -327,6 +327,15 @@ lblDownloadSkins=Скачать доступные темы оформления
 lblDownloadCardImages=Заранее скачать карты или проверить, сколько изображений отсутствует.
 lblDownloaded=Скачано
 lblMissing=Отсутствует
+btnBuildCdnImageCache=Построить CDN-индекс изображений
+lblBuildCdnImageCache=Получает расположение изображений карт напрямую от Scryfall (только на английском), чтобы загрузки использовали быстрый CDN вместо API с ограничением частоты запросов. Можно безопасно запускать повторно -- последующие запуски загружают только изменения.
+lblBuildingCdnImageCache=Получение расположения изображений карт от Scryfall...
+lblCdnImageCacheBuildComplete=Готово. Добавлено или обновлено записей карт: {0}.
+lblCdnImageCacheBuildFailed=Не удалось получить данные от Scryfall: {0}
+btnClearCdnImageCache=Очистить кэш CDN-индекса изображений
+lblClearCdnImageCache=Удаляет локальный кэш CDN-индекса изображений. Используйте, если некоторые карты по-прежнему загружаются через более медленный резервный путь, хотя их изображения должны быть доступны -- Forge при необходимости заново получит данные кэша.
+lblClearCdnImageCacheConfirm=Это удалит локальный кэш CDN-индекса изображений. Forge при необходимости заново его получит. Продолжить?
+lblCdnImageCacheCleared=Кэш CDN-индекса изображений очищен.
 lblDownloadCJKFonts=Скачать шрифты для локалей, использующих иероглифы CJK.
 lblDownloadCJKFontPrompt=Выберите файл шрифта для загрузки
 lblYourVersionOfJavaIsTooOld=Ваша версия Java слишком старая для использования загрузчиков контента.
diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties
index b7401bec3744..7e0c293a5525 100644
--- a/forge-gui/res/languages/zh-CN.properties
+++ b/forge-gui/res/languages/zh-CN.properties
@@ -263,6 +263,15 @@ lblDownloadSkins=下载可用于自定义forge外观的主题。
 lblDownloadCardImages=预下载卡牌图片,或检查有多少图片缺失。
 lblDownloaded=已下载
 lblMissing=缺失
+btnBuildCdnImageCache=构建CDN图片索引
+lblBuildCdnImageCache=直接从Scryfall获取卡牌图片位置(仅限英文),使下载使用其快速的CDN,而不是有速率限制的API。可以安全地重复运行——之后的运行只会获取发生变化的部分。
+lblBuildingCdnImageCache=正在从Scryfall获取卡牌图片位置……
+lblCdnImageCacheBuildComplete=完成。已添加或更新 {0} 条卡牌记录。
+lblCdnImageCacheBuildFailed=无法从Scryfall获取数据:{0}
+btnClearCdnImageCache=清除CDN图片索引缓存
+lblClearCdnImageCache=删除本地CDN图片索引缓存。如果某些卡牌的图片明明应该可用,却一直通过较慢的备用方式解析,可以使用此项——之后Forge会按需重新获取缓存数据。
+lblClearCdnImageCacheConfirm=这将删除本地CDN图片索引缓存,Forge会按需重新获取。是否继续?
+lblCdnImageCacheCleared=CDN图片索引缓存已清除。
 lblDownloadCJKFonts=下载可用于显示当前区域设置所使用的CJK图形的字体。
 lblDownloadCJKFontPrompt=选择要下载的字体文件
 lblYourVersionOfJavaIsTooOld=你的Java版本太旧无法开始下载内容

From 9c4fbdbde1c63b829e007c91ff9d78ac77afa8a9 Mon Sep 17 00:00:00 2001
From: Hugh Kaznowski 
Date: Sun, 16 Aug 2026 21:05:51 +0100
Subject: [PATCH 11/18] Resolve to client-only cacher

---
 .../forge/gui/download/CdnUuidCacheTest.java  |  50 ++++
 .../download/ScryfallManifestSyncTest.java    | 248 ------------------
 .../gui/download/ScryfallSetSyncTest.java     |  52 ++++
 .../settings/CardImageBrowserScreen.java      |  29 +-
 forge-gui/res/languages/de-DE.properties      |   5 -
 forge-gui/res/languages/en-US.properties      |   5 -
 forge-gui/res/languages/es-ES.properties      |   5 -
 forge-gui/res/languages/fr-FR.properties      |   5 -
 forge-gui/res/languages/it-IT.properties      |   5 -
 forge-gui/res/languages/ja-JP.properties      |   5 -
 forge-gui/res/languages/ko-KR.properties      |   5 -
 forge-gui/res/languages/pt-BR.properties      |   5 -
 forge-gui/res/languages/ru-RU.properties      |   5 -
 forge-gui/res/languages/zh-CN.properties      |   5 -
 .../java/forge/gui/download/CdnUuidCache.java | 178 +++++++++----
 .../gui/download/ScryfallManifestSync.java    | 182 -------------
 .../forge/gui/download/ScryfallSetSync.java   |  18 +-
 17 files changed, 241 insertions(+), 566 deletions(-)
 delete mode 100644 forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java
 delete mode 100644 forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java

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
index 0ba85b88aa1b..fbe06286bb83 100644
--- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java
+++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java
@@ -6,10 +6,14 @@
 import org.testng.annotations.Test;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
 import java.util.zip.GZIPOutputStream;
 
 /**
@@ -181,6 +185,46 @@ public void unknownCollectorNumber_returnsNull() {
         Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal"));
     }
 
+    // --- miss tracking ---
+
+    @Test(dependsOnMethods = "unknownCollectorNumber_returnsNull")
+    public void missingEntry_isPersistedAsTimestampedMiss() throws IOException {
+        // The prior lookup for cn 9999 (unresolvable, and the Scryfall override is
+        // unreachable so no retry could have succeeded) should have recorded a miss
+        // marker on disk rather than just silently returning null every time.
+        String raw = readGunzipped(new File(localCacheDir, SET + ".json.gz"));
+        Assert.assertTrue(raw.contains("\"9999\""), "miss should be recorded under its collector number: " + raw);
+        Assert.assertTrue(raw.contains("\"miss\""), "miss should be recorded with the miss marker: " + raw);
+
+        // Real entries already in the file must be untouched.
+        Assert.assertEquals(CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"),
+                CdnUuidCache.cdnUrl(UUID_EN, "front", "normal"));
+    }
+
+    @Test(dependsOnMethods = "missingEntry_isPersistedAsTimestampedMiss")
+    public void freshMiss_stillReturnsNullOnRepeatLookup() {
+        // The miss recorded above is only seconds old -- well under the 1-day retry
+        // window -- so this must short-circuit to null again without touching the
+        // (unreachable) Scryfall override.
+        Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal"));
+    }
+
+    @Test
+    public void recordedMiss_upgradesToRealEntryOnceMerged() {
+        // Simulate ScryfallSetSync later finding this card (e.g. after a resync
+        // triggered by a stale miss): a real entry must overwrite the miss marker.
+        Map cn7 = new HashMap<>();
+        cn7.put("en", new String[]{UUID_EN, null});
+        Map> found = new HashMap<>();
+        found.put("7", cn7);
+
+        Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "7", "en", "front", "normal"));
+        CdnUuidCache.mergeSetEntriesWithFaces(SET, found);
+
+        Assert.assertEquals(CdnUuidCache.getCdnUrl(SET, "7", "en", "front", "normal"),
+                CdnUuidCache.cdnUrl(UUID_EN, "front", "normal"));
+    }
+
     // --- helpers ---
 
     private static void writeGzip(File f, String content) throws IOException {
@@ -189,6 +233,12 @@ private static void writeGzip(File f, String content) throws IOException {
         }
     }
 
+    private static String readGunzipped(File f) throws IOException {
+        try (GZIPInputStream gz = new GZIPInputStream(new FileInputStream(f))) {
+            return new String(gz.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
     private static void deleteDir(File dir) {
         if (dir == null) return;
         File[] children = dir.listFiles();
diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java
deleted file mode 100644
index ee29792f385f..000000000000
--- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallManifestSyncTest.java
+++ /dev/null
@@ -1,248 +0,0 @@
-package forge.gui.download;
-
-import com.sun.net.httpserver.HttpExchange;
-import com.sun.net.httpserver.HttpServer;
-import org.testng.Assert;
-import org.testng.annotations.AfterClass;
-import org.testng.annotations.AfterMethod;
-import org.testng.annotations.BeforeClass;
-import org.testng.annotations.Test;
-
-import java.io.File;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicInteger;
-
-/**
- * Tests run against an embedded mock scryfall api.
- *
- * The ScryfallManifestSync pulls in the latest uploaded scans from Scryfall.
- * This is not the same as bulk downloading that happens while browsing decks/adventure.
- */
-@Test(groups = {"UnitTest"})
-public class ScryfallManifestSyncTest {
-
-    private HttpServer server;
-    private File localCacheDir;
-    private final AtomicInteger requestCount = new AtomicInteger();
-
-    /** Pages served for the current test, keyed by 1-based page number. */
-    private final Map pages = new HashMap<>();
-
-    @BeforeClass
-    public void setUp() throws IOException {
-        localCacheDir = Files.createTempDirectory("manifest_local").toFile();
-        CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator;
-
-        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
-        server.createContext("/", this::handle);
-        server.start();
-        ScryfallManifestSync.manifestBaseUrlOverride = "http://127.0.0.1:" + server.getAddress().getPort();
-        // A CdnUuidCache miss falls back to ScryfallSetSync;
-        // point it at an unreachable address so these tests stay offline
-        ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable";
-    }
-
-    @AfterClass
-    public void tearDown() {
-        server.stop(0);
-        CdnUuidCache.localCacheDirOverride = null;
-        ScryfallManifestSync.manifestBaseUrlOverride = null;
-        ScryfallSetSync.searchBaseUrlOverride = null;
-    }
-
-    @AfterMethod
-    public void resetBetweenTests() {
-        CdnUuidCache.clearCache();
-        pages.clear();
-        requestCount.set(0);
-        for (File f : localCacheDir.listFiles()) {
-            //noinspection ResultOfMethodCallIgnored
-            f.delete();
-        }
-    }
-
-    private void handle(HttpExchange ex) throws IOException {
-        requestCount.incrementAndGet();
-        Map params = queryParams(ex.getRequestURI().getRawQuery());
-        int page = Integer.parseInt(params.getOrDefault("page", "1"));
-        String body = pages.getOrDefault(page, "{\"object\":\"list\",\"has_more\":false,\"data\":[]}");
-        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
-        ex.getResponseHeaders().add("Content-Type", "application/json");
-        ex.sendResponseHeaders(200, bytes.length);
-        try (var os = ex.getResponseBody()) {
-            os.write(bytes);
-        }
-    }
-
-    private static Map queryParams(String rawQuery) {
-        Map out = new HashMap<>();
-        if (rawQuery == null) return out;
-        for (String pair : rawQuery.split("&")) {
-            int eq = pair.indexOf('=');
-            out.put(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
-                    URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
-        }
-        return out;
-    }
-
-    private static String entry(String id, String set, String cn, String lang, String imageUpdatedAt) {
-        return String.format(
-                "{\"id\":\"%s\",\"set_code\":\"%s\",\"collector_number\":\"%s\",\"lang\":\"%s\",\"image_updated_at\":%s}",
-                id, set, cn, lang, imageUpdatedAt == null ? "null" : "\"" + imageUpdatedAt + "\"");
-    }
-
-    private static String page(boolean hasMore, String... entries) {
-        return "{\"object\":\"list\",\"has_more\":" + hasMore + ",\"data\":[" + String.join(",", entries) + "]}";
-    }
-
-    // -------------------------------------------------------------------------
-
-    @Test
-    public void singlePage_mergesAllEntriesAndResolvesViaCdnUuidCache() throws Exception {
-        pages.put(1, page(false,
-                entry("11111111-1111-1111-1111-111111111111", "neo", "1", "en", "2026-01-01T00:00:00Z"),
-                entry("22222222-2222-2222-2222-222222222222", "neo", "2", "en", "2026-01-01T00:00:00Z")));
-
-        int merged = ScryfallManifestSync.sync("en", null);
-
-        Assert.assertEquals(merged, 2);
-        String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal");
-        Assert.assertEquals(url, CdnUuidCache.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal"));
-    }
-
-    @Test
-    public void dfcWithoutSeparateBackId_backFaceReusesFrontUuid() throws Exception {
-        pages.put(1, page(false,
-                entry("33333333-3333-3333-3333-333333333333", "neo", "5", "en", "2026-01-01T00:00:00Z")));
-
-        ScryfallManifestSync.sync("en", null);
-
-        String back = CdnUuidCache.getCdnUrl("neo", "5", "en", "back", "normal");
-        Assert.assertEquals(back, CdnUuidCache.cdnUrl("33333333-3333-3333-3333-333333333333", "back", "normal"),
-                "manifest-derived entries should assume back shares the front's id");
-    }
-
-    @Test
-    public void entriesWithoutImageUpdatedAt_areSkipped() throws Exception {
-        pages.put(1, page(false, entry("44444444-4444-4444-4444-444444444444", "neo", "9", "en", null)));
-
-        int merged = ScryfallManifestSync.sync("en", null);
-
-        Assert.assertEquals(merged, 0);
-        Assert.assertNull(CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"));
-    }
-
-    @Test
-    public void multiplePages_allMergedAndFollowsPagination() throws Exception {
-        pages.put(1, page(true, entry("aaaaaaaa-0000-0000-0000-000000000001", "one", "1", "en", "2026-03-03T00:00:00Z")));
-        pages.put(2, page(false, entry("aaaaaaaa-0000-0000-0000-000000000002", "two", "1", "en", "2026-03-01T00:00:00Z")));
-
-        int merged = ScryfallManifestSync.sync("en", null);
-
-        Assert.assertEquals(merged, 2);
-        Assert.assertNotNull(CdnUuidCache.getCdnUrl("one", "1", "en", "front", "normal"));
-        Assert.assertNotNull(CdnUuidCache.getCdnUrl("two", "1", "en", "front", "normal"));
-    }
-
-    @Test
-    public void secondSync_withNoNewData_stopsAfterFirstPage() throws Exception {
-        pages.put(1, page(false, entry("bbbbbbbb-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
-        ScryfallManifestSync.sync("en", null); // establishes the watermark
-        requestCount.set(0);
-
-        int merged = ScryfallManifestSync.sync("en", null);
-
-        Assert.assertEquals(merged, 0, "everything is at or before the stored watermark");
-        Assert.assertEquals(requestCount.get(), 1, "should not paginate past the page containing only known data");
-    }
-
-    @Test
-    public void incrementalSync_onlyMergesEntriesNewerThanWatermark() throws Exception {
-        pages.put(1, page(false, entry("cccccccc-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
-        ScryfallManifestSync.sync("en", null);
-
-        // A later sync: one new card (newer image update), then the previously-seen card.
-        pages.put(1, page(false,
-                entry("dddddddd-0000-0000-0000-000000000002", "neo", "2", "en", "2026-02-01T00:00:00Z"),
-                entry("cccccccc-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
-
-        int merged = ScryfallManifestSync.sync("en", null);
-
-        Assert.assertEquals(merged, 1, "only the entry newer than the watermark should be merged");
-        Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "2", "en", "front", "normal"));
-    }
-
-    @Test
-    public void merge_neverOverwritesExistingPrecomputedEntry() throws Exception {
-        // Simulate forge-extras data already on disk with a genuine distinct back-face UUID.
-        Map cn1 = new HashMap<>();
-        cn1.put("en", "eeeeeeee-0000-0000-0000-000000000001"); // pre-existing, precise front id
-        Map> existing = new HashMap<>();
-        existing.put("1", cn1);
-        CdnUuidCache.mergeSetEntries("neo", existing);
-
-        // Manifest sync sees a *different* id for the same card/lang -- must not clobber it.
-        pages.put(1, page(false, entry("ffffffff-0000-0000-0000-000000000009", "neo", "1", "en", "2026-01-01T00:00:00Z")));
-        ScryfallManifestSync.sync("en", null);
-
-        String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal");
-        Assert.assertEquals(url, CdnUuidCache.cdnUrl("eeeeeeee-0000-0000-0000-000000000001", "front", "normal"),
-                "pre-existing entries must win over a manifest-derived guess");
-    }
-
-    @Test
-    public void mergeSetEntries_fillsGapsAlongsideExistingLanguages() {
-        Map cn1en = new HashMap<>();
-        cn1en.put("en", "11111111-0000-0000-0000-000000000001");
-        Map> first = new HashMap<>();
-        first.put("1", cn1en);
-        CdnUuidCache.mergeSetEntries("neo", first);
-
-        Map cn1ja = new HashMap<>();
-        cn1ja.put("ja", "22222222-0000-0000-0000-000000000002");
-        Map> second = new HashMap<>();
-        second.put("1", cn1ja);
-        CdnUuidCache.mergeSetEntries("neo", second);
-
-        Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"),
-                "the earlier language entry must survive a later merge");
-        Assert.assertEquals(CdnUuidCache.getCdnUrl("neo", "1", "ja", "front", "normal"),
-                CdnUuidCache.cdnUrl("22222222-0000-0000-0000-000000000002", "front", "normal"));
-    }
-
-    @Test
-    public void clearCache_removesMergedDataAndDiskFiles() throws Exception {
-        pages.put(1, page(false, entry("99999999-0000-0000-0000-000000000001", "neo", "1", "en", "2026-01-01T00:00:00Z")));
-        ScryfallManifestSync.sync("en", null);
-        Assert.assertNotNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"));
-
-        CdnUuidCache.clearCache();
-
-        Assert.assertNull(CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"));
-        Assert.assertEquals(new File(localCacheDir, "neo.json.gz").exists(), false);
-    }
-
-    @Test
-    public void progressListener_calledWithDoneOnLastPageOnly() throws Exception {
-        pages.put(1, page(true, entry("12121212-0000-0000-0000-000000000001", "one", "1", "en", "2026-03-03T00:00:00Z")));
-        pages.put(2, page(false, entry("13131313-0000-0000-0000-000000000002", "two", "1", "en", "2026-03-01T00:00:00Z")));
-
-        List doneFlags = new ArrayList<>();
-        List pageNumbers = new ArrayList<>();
-        ScryfallManifestSync.sync("en", (page, merged, done) -> {
-            pageNumbers.add(page);
-            doneFlags.add(done);
-        });
-
-        Assert.assertEquals(pageNumbers, List.of(1, 2));
-        Assert.assertEquals(doneFlags, List.of(false, true));
-    }
-}
diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java
index e75e95f02ca0..6b4b4c8131d0 100644
--- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java
+++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java
@@ -9,6 +9,7 @@
 import org.testng.annotations.Test;
 
 import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.net.URLDecoder;
@@ -17,6 +18,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.GZIPOutputStream;
 
 /**
  * Exercises {@link ScryfallSetSync} — the on-demand, per-set generator that
@@ -218,4 +220,54 @@ public void searchQuery_scopesToSetAndAllLanguages() {
         Assert.assertTrue(decoded.contains("set:ltr"), "query should scope to the requested set: " + decoded);
         Assert.assertTrue(decoded.contains("lang:any"), "query should request all languages: " + decoded);
     }
+
+    // -------------------------------------------------------------------------
+    // Miss-record retry: CdnUuidCache persists a timestamped "miss" for a (cn, lang) it
+    // can't find in an already-synced set, and only re-hits Scryfall once that's stale.
+
+    private static void writeGzip(File f, String content) throws IOException {
+        try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(f))) {
+            gz.write(content.getBytes(StandardCharsets.UTF_8));
+        }
+    }
+
+    @Test
+    public void freshMiss_doesNotRetryScryfall() throws IOException {
+        // "neo" already has local data, but cn 9 was checked moments ago and came back empty.
+        writeGzip(new File(localCacheDir, "neo.json.gz"),
+                "{\"1\":{\"en\":\"11111111-0000-0000-0000-000000000001\"},"
+                        + "\"9\":{\"en\":{\"miss\":\"" + java.time.Instant.now() + "\"}}}");
+
+        String url = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal");
+
+        Assert.assertNull(url);
+        Assert.assertEquals(requestCount.get(), 0, "a fresh miss must not hit Scryfall again");
+    }
+
+    @Test
+    public void staleMiss_retriesAndResolvesIfNowFound() throws IOException {
+        writeGzip(new File(localCacheDir, "neo.json.gz"),
+                "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}");
+        pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "9", "en")));
+
+        String url = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal");
+
+        Assert.assertEquals(url, CdnUuidCache.cdnUrl("99999999-0000-0000-0000-000000000009", "front", "normal"));
+        Assert.assertEquals(requestCount.get(), 1, "a stale miss should trigger exactly one retry");
+    }
+
+    @Test
+    public void staleMiss_stillMissing_refreshesTimestampAndStopsRetrying() throws IOException {
+        writeGzip(new File(localCacheDir, "neo.json.gz"),
+                "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}");
+        // No page registered -> 404, same as Scryfall finding nothing for this set.
+
+        String first = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal");
+        Assert.assertNull(first);
+        Assert.assertEquals(requestCount.get(), 1, "the stale miss should have triggered one retry");
+
+        String second = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal");
+        Assert.assertNull(second);
+        Assert.assertEquals(requestCount.get(), 1, "the refreshed miss timestamp should prevent an immediate second retry");
+    }
 }
diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java
index db0476ea0407..a942507c2884 100644
--- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java
+++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java
@@ -11,14 +11,12 @@
 import forge.gui.FThreads;
 import forge.gui.download.CdnUuidCache;
 import forge.gui.download.GuiDownloadFilteredCardImages;
-import forge.gui.download.ScryfallManifestSync;
 import forge.gui.util.SOptionPane;
 import forge.item.PaperCard;
 import forge.itemmanager.SFilterUtil;
 import forge.itemmanager.filters.ArchivedFormatSelect;
 import forge.model.FModel;
 import forge.screens.FScreen;
-import forge.screens.LoadingOverlay;
 import forge.toolbox.*;
 import forge.util.TextUtil;
 import forge.util.Utils;
@@ -41,7 +39,6 @@ public class CardImageBrowserScreen extends FScreen {
     private final FLabel            lblDownloaded;
     private final FLabel            lblMissing;
     private final FButton           btnDownload;
-    private final FButton           btnBuildCdnCache;
     private final FButton           btnClearCdnCache;
 
     private GameFormat   selectedFormat    = null;
@@ -121,10 +118,7 @@ public CardImageBrowserScreen() {
         btnDownload = add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages")));
         btnDownload.setCommand(e -> startDownload());
 
-        // ── CDN image lookup cache: build (from Scryfall) and clear ──────────
-        btnBuildCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnBuildCdnImageCache")));
-        btnBuildCdnCache.setCommand(e -> buildCdnCache());
-
+        // ── CDN image lookup cache: clear (rebuilt automatically on demand) ──
         btnClearCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache")));
         btnClearCdnCache.setCommand(e -> clearCdnCache());
 
@@ -180,25 +174,9 @@ private void startDownload() {
     }
 
     // =========================================================================
-    //  CDN image lookup cache: build directly from Scryfall, or clear
+    //  CDN image lookup cache: clear
     // =========================================================================
 
-    private void buildCdnCache() {
-        LoadingOverlay.runBackgroundTask(Forge.getLocalizer().getMessage("lblBuildingCdnImageCache"), () -> {
-            try {
-                int merged = ScryfallManifestSync.sync("en", null);
-                FThreads.invokeInEdtLater(() -> {
-                    SOptionPane.showMessageDialog(
-                            Forge.getLocalizer().getMessage("lblCdnImageCacheBuildComplete", merged));
-                    scheduleStatsUpdate();
-                });
-            } catch (Exception e) {
-                FThreads.invokeInEdtLater(() -> SOptionPane.showErrorDialog(
-                        Forge.getLocalizer().getMessage("lblCdnImageCacheBuildFailed", e.getMessage())));
-            }
-        });
-    }
-
     private void clearCdnCache() {
         if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblClearCdnImageCacheConfirm"))) {
             return;
@@ -250,9 +228,6 @@ protected void doLayout(float startY, float width, float height) {
         btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT);
         y += BTN_HEIGHT + PADDING;
 
-        btnBuildCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT);
-        y += BTN_HEIGHT + PADDING;
-
         btnClearCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT);
     }
 
diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties
index f23ad9159a79..dbe7501abde6 100644
--- a/forge-gui/res/languages/de-DE.properties
+++ b/forge-gui/res/languages/de-DE.properties
@@ -287,11 +287,6 @@ lblDownloadSkins=Lade verfügbare Skins herunter, um die Erscheinung von Forge a
 lblDownloadCardImages=Kartenbilder vorab herunterladen oder prüfen, wie viele Bilder fehlen.
 lblDownloaded=Heruntergeladen
 lblMissing=Fehlend
-btnBuildCdnImageCache=CDN-Bildzuordnung erstellen
-lblBuildCdnImageCache=Ruft Kartenbild-Speicherorte direkt von Scryfall ab (nur Englisch), damit Downloads das schnelle CDN statt der ratenbegrenzten API nutzen. Kann gefahrlos wiederholt ausgeführt werden -- spätere Läufe holen nur Änderungen ab.
-lblBuildingCdnImageCache=Kartenbild-Speicherorte werden von Scryfall abgerufen...
-lblCdnImageCacheBuildComplete=Fertig. {0} Karteneinträge hinzugefügt oder aktualisiert.
-lblCdnImageCacheBuildFailed=Daten konnten nicht von Scryfall abgerufen werden: {0}
 btnClearCdnImageCache=CDN-Bildzuordnung leeren
 lblClearCdnImageCache=Löscht den lokalen CDN-Bildzuordnungs-Cache. Nützlich, wenn manche Karten weiterhin über den langsameren Ausweichpfad geladen werden, obwohl ihre Bilder eigentlich verfügbar sein sollten -- Forge ruft die Daten danach bei Bedarf erneut ab.
 lblClearCdnImageCacheConfirm=Dies löscht den lokalen CDN-Bildzuordnungs-Cache. Forge ruft ihn bei Bedarf erneut ab. Fortfahren?
diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties
index f6bfa3d4349f..0a369353a266 100644
--- a/forge-gui/res/languages/en-US.properties
+++ b/forge-gui/res/languages/en-US.properties
@@ -327,11 +327,6 @@ lblDownloadSkins=Download available skins used in customizing forge appearance.
 lblDownloadCardImages=Pre-download cards or check how many images are missing.
 lblDownloaded=Downloaded
 lblMissing=Missing
-btnBuildCdnImageCache=Build CDN Image Lookup
-lblBuildCdnImageCache=Fetch card image locations straight from Scryfall (English only)
-lblBuildingCdnImageCache=Fetching card image locations from Scryfall...
-lblCdnImageCacheBuildComplete=Done. {0} card entries added or updated.
-lblCdnImageCacheBuildFailed=Could not fetch data from Scryfall: {0}
 btnClearCdnImageCache=Clear CDN Image Lookup Cache
 lblClearCdnImageCache=Delete the local CDN image lookup cache.
 lblClearCdnImageCacheConfirm=This deletes the local CDN image lookup cache. Forge will re-fetch it as needed. Continue?
diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties
index 17fcd8c3d5b2..4706f2b82456 100644
--- a/forge-gui/res/languages/es-ES.properties
+++ b/forge-gui/res/languages/es-ES.properties
@@ -263,11 +263,6 @@ lblDownloadSkins=Descarga los temas disponibles para personalizar el aspecto de
 lblDownloadCardImages=Descarga imágenes de cartas por adelantado o comprueba cuántas faltan.
 lblDownloaded=Descargado
 lblMissing=Faltante
-btnBuildCdnImageCache=Generar búsqueda de imágenes CDN
-lblBuildCdnImageCache=Obtiene las ubicaciones de las imágenes de las cartas directamente de Scryfall (solo en inglés) para que las descargas usen su CDN rápida en lugar de la API con límite de solicitudes. Se puede ejecutar repetidamente sin problema -- las siguientes ejecuciones solo obtienen lo que haya cambiado.
-lblBuildingCdnImageCache=Obteniendo ubicaciones de imágenes de cartas desde Scryfall...
-lblCdnImageCacheBuildComplete=Listo. Se añadieron o actualizaron {0} entradas de cartas.
-lblCdnImageCacheBuildFailed=No se pudieron obtener datos de Scryfall: {0}
 btnClearCdnImageCache=Borrar caché de búsqueda de imágenes CDN
 lblClearCdnImageCache=Elimina la caché local de búsqueda de imágenes CDN. Úsalo si algunas cartas siguen resolviéndose por la vía alternativa más lenta aunque sus imágenes deberían estar disponibles -- Forge la volverá a obtener cuando sea necesario.
 lblClearCdnImageCacheConfirm=Esto elimina la caché local de búsqueda de imágenes CDN. Forge la volverá a obtener cuando sea necesario. ¿Continuar?
diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties
index 831438232629..fc3aa8a8123b 100644
--- a/forge-gui/res/languages/fr-FR.properties
+++ b/forge-gui/res/languages/fr-FR.properties
@@ -261,11 +261,6 @@ lblDownloadSkins=T\u00e9l\u00e9charger les skins disponibles utilis\u00e9s pour
 lblDownloadCardImages=Pré-téléchargez des images de cartes ou vérifiez combien d'images manquent.
 lblDownloaded=Téléchargé
 lblMissing=Manquant
-btnBuildCdnImageCache=Générer la table de correspondance CDN
-lblBuildCdnImageCache=Récupère les emplacements des images de cartes directement depuis Scryfall (anglais uniquement) afin que les téléchargements utilisent leur CDN rapide plutôt que l'API à débit limité. Peut être exécuté à répétition sans risque -- les exécutions suivantes ne récupèrent que ce qui a changé.
-lblBuildingCdnImageCache=Récupération des emplacements des images de cartes depuis Scryfall...
-lblCdnImageCacheBuildComplete=Terminé. {0} entrées de cartes ajoutées ou mises à jour.
-lblCdnImageCacheBuildFailed=Impossible de récupérer les données depuis Scryfall : {0}
 btnClearCdnImageCache=Vider le cache de correspondance CDN
 lblClearCdnImageCache=Supprime le cache local de correspondance CDN. À utiliser si certaines cartes continuent de passer par la solution de repli plus lente alors que leurs images devraient être disponibles -- Forge le régénérera au besoin.
 lblClearCdnImageCacheConfirm=Cette action supprime le cache local de correspondance CDN. Forge le régénérera au besoin. Continuer ?
diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties
index b5ef3252b5b8..f4c11b9db689 100644
--- a/forge-gui/res/languages/it-IT.properties
+++ b/forge-gui/res/languages/it-IT.properties
@@ -260,11 +260,6 @@ lblDownloadSkins=Scarica i temi disponibili per personalizzare la grafica di For
 lblDownloadCardImages=Scarica in anticipo le immagini delle carte o controlla quante mancano.
 lblDownloaded=Scaricato
 lblMissing=Mancante
-btnBuildCdnImageCache=Genera mappatura immagini CDN
-lblBuildCdnImageCache=Recupera le posizioni delle immagini delle carte direttamente da Scryfall (solo inglese) in modo che i download usino la CDN veloce invece dell'API con limite di frequenza. Può essere eseguito ripetutamente senza problemi -- le esecuzioni successive recuperano solo ciò che è cambiato.
-lblBuildingCdnImageCache=Recupero delle posizioni delle immagini delle carte da Scryfall...
-lblCdnImageCacheBuildComplete=Fatto. {0} voci di carte aggiunte o aggiornate.
-lblCdnImageCacheBuildFailed=Impossibile recuperare i dati da Scryfall: {0}
 btnClearCdnImageCache=Svuota cache mappatura immagini CDN
 lblClearCdnImageCache=Elimina la cache locale della mappatura immagini CDN. Usalo se alcune carte continuano a essere risolte tramite il percorso alternativo più lento anche se le loro immagini dovrebbero essere disponibili -- Forge la recupererà nuovamente quando necessario.
 lblClearCdnImageCacheConfirm=Questa operazione elimina la cache locale della mappatura immagini CDN. Forge la recupererà nuovamente quando necessario. Continuare?
diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties
index 39d6f736110c..72c251e59707 100644
--- a/forge-gui/res/languages/ja-JP.properties
+++ b/forge-gui/res/languages/ja-JP.properties
@@ -261,11 +261,6 @@ lblDownloadSkins=Forge のカスタマイズ用のテーマをダウンロード
 lblDownloadCardImages=カード画像を事前にダウンロードするか、不足している画像数を確認します。
 lblDownloaded=ダウンロード済み
 lblMissing=未取得
-btnBuildCdnImageCache=CDN画像索引を構築
-lblBuildCdnImageCache=Scryfallから直接カード画像の場所を取得します(英語のみ)。これにより、ダウンロードはレート制限のあるAPIではなく高速なCDNを使用します。繰り返し実行しても安全です -- 次回以降は変更分のみ取得します。
-lblBuildingCdnImageCache=Scryfallからカード画像の場所を取得しています...
-lblCdnImageCacheBuildComplete=完了しました。{0}件のカードエントリを追加または更新しました。
-lblCdnImageCacheBuildFailed=Scryfallからデータを取得できませんでした: {0}
 btnClearCdnImageCache=CDN画像索引キャッシュを削除
 lblClearCdnImageCache=ローカルのCDN画像索引キャッシュを削除します。画像が利用可能なはずなのに一部のカードが遅いフォールバック経由で解決され続ける場合に使用してください -- 必要に応じてForgeが再取得します。
 lblClearCdnImageCacheConfirm=ローカルのCDN画像索引キャッシュを削除します。必要に応じてForgeが再取得します。続行しますか?
diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties
index a934c245d60a..b0a79ef992e1 100644
--- a/forge-gui/res/languages/ko-KR.properties
+++ b/forge-gui/res/languages/ko-KR.properties
@@ -288,11 +288,6 @@ lblDownloadQuestImages=퀘스트 모드에 사용되는 토큰과 아이콘 다
 lblDownloadAchievementImages=업적 이미지를 다운로드해 트로피 강조.
 lblDownloadPrices=게임 내 카드 상점 최신 가격표 다운로드.
 lblDownloadSkins=Forge 맞춤 테마 다운로드.
-btnBuildCdnImageCache=CDN 이미지 조회 생성
-lblBuildCdnImageCache=Scryfall에서 카드 이미지 위치를 직접 가져옵니다(영어만 해당). 다운로드가 속도 제한이 있는 API 대신 빠른 CDN을 사용하도록 합니다. 반복 실행해도 안전합니다 -- 이후 실행에서는 변경된 항목만 가져옵니다.
-lblBuildingCdnImageCache=Scryfall에서 카드 이미지 위치를 가져오는 중...
-lblCdnImageCacheBuildComplete=완료되었습니다. 카드 항목 {0}개가 추가되거나 업데이트되었습니다.
-lblCdnImageCacheBuildFailed=Scryfall에서 데이터를 가져올 수 없습니다: {0}
 btnClearCdnImageCache=CDN 이미지 조회 캐시 지우기
 lblClearCdnImageCache=로컬 CDN 이미지 조회 캐시를 삭제합니다. 일부 카드의 이미지를 사용할 수 있어야 하는데도 계속 느린 대체 경로로 해결되는 경우 사용하세요 -- 이후 필요에 따라 Forge가 다시 가져옵니다.
 lblClearCdnImageCacheConfirm=로컬 CDN 이미지 조회 캐시를 삭제합니다. 필요에 따라 Forge가 다시 가져옵니다. 계속하시겠습니까?
diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties
index 28bceaf7cc57..7a58dd590438 100644
--- a/forge-gui/res/languages/pt-BR.properties
+++ b/forge-gui/res/languages/pt-BR.properties
@@ -262,11 +262,6 @@ lblDownloadSkins=Baixar as skins disponíveis usadas na customização da aparê
 lblDownloadCardImages=Pré-baixe imagens de cartas ou verifique quantas imagens ainda faltam.
 lblDownloaded=Baixado
 lblMissing=Faltando
-btnBuildCdnImageCache=Gerar busca de imagens CDN
-lblBuildCdnImageCache=Busca as localizações das imagens das cartas diretamente do Scryfall (somente em inglês) para que os downloads usem a CDN rápida em vez da API com limite de taxa. Pode ser executado repetidamente sem problemas -- execuções posteriores buscam apenas o que mudou.
-lblBuildingCdnImageCache=Buscando localizações das imagens das cartas no Scryfall...
-lblCdnImageCacheBuildComplete=Concluído. {0} entradas de cartas adicionadas ou atualizadas.
-lblCdnImageCacheBuildFailed=Não foi possível buscar dados do Scryfall: {0}
 btnClearCdnImageCache=Limpar cache de busca de imagens CDN
 lblClearCdnImageCache=Exclui o cache local de busca de imagens CDN. Use isso se algumas cartas continuarem sendo resolvidas pelo caminho alternativo mais lento mesmo que suas imagens devessem estar disponíveis -- o Forge buscará novamente conforme necessário.
 lblClearCdnImageCacheConfirm=Isso exclui o cache local de busca de imagens CDN. O Forge buscará novamente conforme necessário. Continuar?
diff --git a/forge-gui/res/languages/ru-RU.properties b/forge-gui/res/languages/ru-RU.properties
index a89e56d1710d..2c189365b7b8 100644
--- a/forge-gui/res/languages/ru-RU.properties
+++ b/forge-gui/res/languages/ru-RU.properties
@@ -327,11 +327,6 @@ lblDownloadSkins=Скачать доступные темы оформления
 lblDownloadCardImages=Заранее скачать карты или проверить, сколько изображений отсутствует.
 lblDownloaded=Скачано
 lblMissing=Отсутствует
-btnBuildCdnImageCache=Построить CDN-индекс изображений
-lblBuildCdnImageCache=Получает расположение изображений карт напрямую от Scryfall (только на английском), чтобы загрузки использовали быстрый CDN вместо API с ограничением частоты запросов. Можно безопасно запускать повторно -- последующие запуски загружают только изменения.
-lblBuildingCdnImageCache=Получение расположения изображений карт от Scryfall...
-lblCdnImageCacheBuildComplete=Готово. Добавлено или обновлено записей карт: {0}.
-lblCdnImageCacheBuildFailed=Не удалось получить данные от Scryfall: {0}
 btnClearCdnImageCache=Очистить кэш CDN-индекса изображений
 lblClearCdnImageCache=Удаляет локальный кэш CDN-индекса изображений. Используйте, если некоторые карты по-прежнему загружаются через более медленный резервный путь, хотя их изображения должны быть доступны -- Forge при необходимости заново получит данные кэша.
 lblClearCdnImageCacheConfirm=Это удалит локальный кэш CDN-индекса изображений. Forge при необходимости заново его получит. Продолжить?
diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties
index 7e0c293a5525..d9207c77aede 100644
--- a/forge-gui/res/languages/zh-CN.properties
+++ b/forge-gui/res/languages/zh-CN.properties
@@ -263,11 +263,6 @@ lblDownloadSkins=下载可用于自定义forge外观的主题。
 lblDownloadCardImages=预下载卡牌图片,或检查有多少图片缺失。
 lblDownloaded=已下载
 lblMissing=缺失
-btnBuildCdnImageCache=构建CDN图片索引
-lblBuildCdnImageCache=直接从Scryfall获取卡牌图片位置(仅限英文),使下载使用其快速的CDN,而不是有速率限制的API。可以安全地重复运行——之后的运行只会获取发生变化的部分。
-lblBuildingCdnImageCache=正在从Scryfall获取卡牌图片位置……
-lblCdnImageCacheBuildComplete=完成。已添加或更新 {0} 条卡牌记录。
-lblCdnImageCacheBuildFailed=无法从Scryfall获取数据:{0}
 btnClearCdnImageCache=清除CDN图片索引缓存
 lblClearCdnImageCache=删除本地CDN图片索引缓存。如果某些卡牌的图片明明应该可用,却一直通过较慢的备用方式解析,可以使用此项——之后Forge会按需重新获取缓存数据。
 lblClearCdnImageCacheConfirm=这将删除本地CDN图片索引缓存,Forge会按需重新获取。是否继续?
diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java
index f82dfb54b7d0..0c0b3561c692 100644
--- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java
+++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java
@@ -15,6 +15,8 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
@@ -36,26 +38,44 @@
  * The cache returns null, so the user may resolve via a rate-limited, non-cdn scryfall
  * image hosting. The client rate limits these requests.
  *
+ * 

A (collector number, language) that a set's data genuinely doesn't have -- e.g. a + * card released after the set was last synced -- is recorded as a negative-cache "miss" + * with a timestamp, right alongside the real entries. A miss younger than + * {@link #MISS_RETRY_AFTER} short-circuits with no network call; once it's older, the + * next lookup re-syncs the set from Scryfall to see if it's showed up since. + * *

Set JSON format: *

  *   {
  *     "1":   {"en": "uuid"},
  *     "2":   {"en": "uuid", "ja": "ja-uuid"},
- *     "A-40":{"en": ["frontUuid", "backUuid"]}
+ *     "A-40":{"en": ["frontUuid", "backUuid"]},
+ *     "9":   {"en": {"miss": "2026-08-11T18:23:45.123Z"}}
  *   }
  * 
*/ public final class CdnUuidCache { private static final String FALLBACK_LANG = "en"; + private static final Duration MISS_RETRY_AFTER = Duration.ofDays(1); /** Used when set was looked up and no data exists. */ 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; } + final String front; // null when this is a miss record + final String back; // null → same UUID for both faces (only meaningful for a real entry) + final Instant missedAt; // non-null → negative-cache record; front/back are unused + + private LangUuids(String front, String back, Instant missedAt) { + this.front = front; + this.back = back; + this.missedAt = missedAt; + } + + static LangUuids found(String front, String back) { return new LangUuids(front, back, null); } + static LangUuids miss(Instant at) { return new LangUuids(null, null, at); } + boolean isMiss() { return missedAt != null; } } /** Cache: setCode → (collectorNumber → (lang → LangUuids)) */ @@ -72,7 +92,7 @@ private CdnUuidCache() {} /** Test helper */ static void clearCacheForTesting() { setCache.clear(); } - /** The directory local set caches live in, honoring the test override. Package-private for {@link ScryfallManifestSync}. */ + /** The directory local set caches live in, honoring the test override. Package-private for {@link ScryfallSetSync}. */ static String cacheDir() { return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR; } @@ -91,50 +111,21 @@ public static void clearCache() { } } - /** - * Merges externally-sourced (setCode → collectorNumber → lang → uuid) entries into - * the on-disk cache. - * - * Used by {@link ScryfallManifestSync}, whose data source (the manifest endpoint) - * never distinguishes a double-faced card's back-face UUID from its front. - * - * Delegates to {@link #mergeSetEntriesWithFaces} so the never-overwrite guarantee lives in one place. - */ - static void mergeSetEntries(String setCode, Map> newEntries) { - Map> withFaces = new HashMap<>(newEntries.size() * 2); - for (Map.Entry> cnEntry : newEntries.entrySet()) { - Map langMap = new HashMap<>(cnEntry.getValue().size() * 2); - for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { - langMap.put(langEntry.getKey(), new String[]{langEntry.getValue(), null}); - } - withFaces.put(cnEntry.getKey(), langMap); - } - mergeSetEntriesWithFaces(setCode, withFaces); - } - /** * Merges externally-sourced (setCode → collectorNumber → lang → [frontUuid, backUuidOrNull]) * entries into the on-disk cache, creating or updating {@code {cacheDir}/{setCode}.json.gz}. * - * Used by {@link ScryfallSetSync} when a set has no local data at all yet and is built - * fresh from Scryfall's card search API, which — unlike the manifest endpoint — exposes - * each face's own image URL, so a double-faced card's genuinely distinct back-face UUID - * (a real but rare case) can be recorded precisely instead of assumed. + * Used by {@link ScryfallSetSync} when a set has no local data at all yet (or a + * previously-recorded miss is stale) and is (re)built from Scryfall's card search API. * - *

Existing entries are never overwritten, so a precise front/back pair already on - * disk can't be degraded by a later, less precise source filling the same slot. + *

A real entry already on disk is never overwritten, so a precise front/back pair + * can't be degraded by a later, less precise source filling the same slot. A miss + * record occupying the slot doesn't count as "already there" -- real data always + * upgrades a miss. */ static synchronized void mergeSetEntriesWithFaces(String setCode, Map> newEntries) { File file = localCacheFile(setCode); - JsonObject merged = new JsonObject(); - if (file.exists()) { - try (InputStream is = new GZIPInputStream(new FileInputStream(file)); - InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - merged = JsonParser.parseReader(reader).getAsJsonObject(); - } catch (Exception e) { - Logger.warn("CdnUuidCache: corrupt local cache {}, rebuilding: {}", file, e.getMessage()); - } - } + JsonObject merged = readLocalJson(file); for (Map.Entry> cnEntry : newEntries.entrySet()) { String cn = cnEntry.getKey(); @@ -142,7 +133,7 @@ static synchronized void mergeSetEntriesWithFaces(String setCode, Map langEntry : cnEntry.getValue().entrySet()) { String lang = langEntry.getKey(); - if (langObj.has(lang)) continue; + if (isRealEntry(langObj.get(lang))) continue; // never overwrite real data String front = langEntry.getValue()[0]; String back = langEntry.getValue()[1]; if (back != null && !back.equals(front)) { @@ -161,6 +152,43 @@ static synchronized void mergeSetEntriesWithFaces(String setCode, 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); + LangUuids uuids = resolveWithFallback(cardMap, collectorNum, lang); + if (uuids == null) uuids = trackMiss(setCode, cardMap, collectorNum, lang); if (uuids == null) return null; String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; @@ -192,6 +217,50 @@ public static String getCdnUrl(String scryfallCode, String collectorNum, return cdnUrl(uuid, side, size); } + /** The real (non-miss) entry for {@code lang}, or its English fallback, if either has real data. */ + private static LangUuids resolveWithFallback(Map> cardMap, String cn, String lang) { + Map langMap = cardMap.get(cn); + if (langMap == null) return null; + + LangUuids direct = langMap.get(lang); + if (direct != null && !direct.isMiss()) return direct; + + if (!FALLBACK_LANG.equals(lang)) { + LangUuids fallback = langMap.get(FALLBACK_LANG); + if (fallback != null && !fallback.isMiss()) return fallback; + } + return null; + } + + /** + * Neither {@code lang} nor its English fallback resolved to real data. Tracks that as a + * miss against the exact requested {@code lang}: a miss seen for the first time, or one + * younger than {@link #MISS_RETRY_AFTER}, is recorded/left alone and this returns + * {@code null} with no network call. A miss older than that is worth re-checking -- + * the set is re-synced from Scryfall, in case the card has shown up since. + */ + private static LangUuids trackMiss(String setCode, Map> cardMap, + String cn, String lang) { + Map langMap = cardMap.get(cn); + LangUuids existing = langMap != null ? langMap.get(lang) : null; + + if (existing != null && existing.isMiss() && isStale(existing.missedAt)) { + ScryfallSetSync.sync(setCode); + Map> refreshed = loadSet(setCode); + LangUuids retried = resolveWithFallback(refreshed, cn, lang); + if (retried != null) return retried; + } else if (existing != null && existing.isMiss()) { + return null; // recorded recently; don't hit the network again yet + } + + recordMiss(setCode, cn, lang); + return null; + } + + private static boolean isStale(Instant missedAt) { + return Duration.between(missedAt, Instant.now()).compareTo(MISS_RETRY_AFTER) >= 0; + } + /** * Builds a Scryfall CDN image URL from a card UUID. The CDN ({@code cards.scryfall.io}) * is not rate-limited; given a UUID and image size, the URL is fully deterministic: @@ -280,7 +349,7 @@ private static Map> parseSetFile(File file) throw /** * Parses a set JSON object. - * Format: {@code {"cn": {"lang": "uuid"|["frontUuid","backUuid"]}, ...}} + * Format: {@code {"cn": {"lang": "uuid"|["frontUuid","backUuid"]|{"miss": timestamp}}, ...}} */ private static Map> parseSetObject(JsonObject setObj) { Map> cardMap = new HashMap<>(setObj.size() * 2); @@ -291,17 +360,24 @@ private static Map> parseSetObject(JsonObject set for (Map.Entry langEntry : langObj.entrySet()) { JsonElement val = langEntry.getValue(); if (val.isJsonPrimitive()) { - langMap.put(langEntry.getKey(), new LangUuids(val.getAsString(), null)); + langMap.put(langEntry.getKey(), LangUuids.found(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)); + LangUuids.found(front, back.equals(front) ? null : back)); } else if (arr.size() == 1) { langMap.put(langEntry.getKey(), - new LangUuids(arr.get(0).getAsString(), null)); + LangUuids.found(arr.get(0).getAsString(), null)); + } + } else if (val.isJsonObject() && val.getAsJsonObject().has("miss")) { + try { + Instant missedAt = Instant.parse(val.getAsJsonObject().get("miss").getAsString()); + langMap.put(langEntry.getKey(), LangUuids.miss(missedAt)); + } catch (Exception ignored) { + // corrupt timestamp -- treat as though this (cn, lang) was never checked } } } diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java deleted file mode 100644 index f8fa1fc02598..000000000000 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallManifestSync.java +++ /dev/null @@ -1,182 +0,0 @@ -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 org.tinylog.Logger; - -import java.io.File; -import java.io.IOException; -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.util.HashMap; -import java.util.Map; -import java.util.zip.GZIPInputStream; - -/** - * Client-side alternative (or supplement) to the forge-extras hosted cdn_uuid data: - * builds or incrementally refreshes the local CDN UUID cache directly from Scryfall's - * {@code /cards/manifest} endpoint, entirely on the user's machine. - * - *

Compared to a full bulk-data export (~100 MB for English only, ~2.5 GB for every - * language), the manifest returns only the fields needed here (id, set code, collector - * number, lang) in pages of up to 15,000 entries — full English coverage is currently - * ~8 pages, roughly 25-30 MB total. - * - *

Supports incremental resync: entries are requested with {@code order=imageupdated} - * (newest image update first), and a per-language watermark of the most recent - * {@code image_updated_at} seen is persisted alongside the cache. A later call stops - * as soon as it reaches an entry at or before that watermark, so a repeat sync after - * the first full one is typically a single page. - * - *

The manifest doesn't expose the actual CDN image URL, only the card's own - * {@code id} — so a double-faced card's back face is assumed to share the front - * face's id. That's true for the overwhelming majority of double-faced cards (the - * CDN URL only differs by a {@code front}/{@code back} path segment); a small number - * of known exceptions fall back to the existing rate-limited-API path on a CDN miss, - * same as any set/card this sync hasn't (yet) covered. - */ -public final class ScryfallManifestSync { - - private static final String MANIFEST_PATH_AND_QUERY = "?order=imageupdated&lang=%s&page=%d"; - private static final String DEFAULT_MANIFEST_URL = "https://api.scryfall.com/cards/manifest"; - private static final String WATERMARK_FILE = ".manifest-watermark.json"; - - /** Scryfall documents this endpoint's rate limit as 10 requests per minute. */ - private static final long REQUEST_INTERVAL_MS = 10_000; - private static final int CONNECT_TIMEOUT_MS = 15_000; - - /** Override for tests; must be a full base URL with no trailing query string. */ - static volatile String manifestBaseUrlOverride = null; - - private ScryfallManifestSync() {} - - /** Reports sync progress; {@code done} is true on the final call. */ - public interface ProgressListener { - void onProgress(int page, int cardsMerged, boolean done); - } - - /** - * Builds or incrementally refreshes the local CDN UUID cache for {@code lang} - * straight from Scryfall. - * - * @param lang 2-3 letter Scryfall language code, e.g. {@code "en"} - * @param listener optional progress callback; may be {@code null} - * @return number of (set, collector number, language) entries newly merged - */ - public static int sync(String lang, ProgressListener listener) throws IOException, InterruptedException { - String watermark = readWatermark(lang); - String newestSeen = null; - Map>> bySet = new HashMap<>(); - int page = 1; - int totalMerged = 0; - - while (true) { - if (page > 1) Thread.sleep(REQUEST_INTERVAL_MS); - JsonObject response = fetchPage(lang, page); - JsonArray data = response.has("data") ? response.getAsJsonArray("data") : new JsonArray(); - - boolean reachedWatermark = false; - for (JsonElement el : data) { - JsonObject card = el.getAsJsonObject(); - String imageUpdatedAt = str(card, "image_updated_at"); - if (imageUpdatedAt == null) continue; // no image yet; nothing useful to record - - if (newestSeen == null) newestSeen = imageUpdatedAt; // first entry overall is the newest (descending order) - - if (watermark != null && imageUpdatedAt.compareTo(watermark) <= 0) { - reachedWatermark = true; - break; - } - - String setCode = str(card, "set_code"); - String cn = str(card, "collector_number"); - String id = str(card, "id"); - String cardLang = str(card, "lang"); - if (setCode == null || cn == null || id == null || cardLang == null) continue; - - bySet.computeIfAbsent(setCode.toLowerCase(), k -> new HashMap<>()) - .computeIfAbsent(cn, k -> new HashMap<>()) - .put(cardLang, id); - totalMerged++; - } - - boolean hasMore = !reachedWatermark && response.has("has_more") && response.get("has_more").getAsBoolean(); - if (listener != null) listener.onProgress(page, totalMerged, !hasMore); - if (!hasMore) break; - page++; - } - - for (Map.Entry>> e : bySet.entrySet()) { - CdnUuidCache.mergeSetEntries(e.getKey(), e.getValue()); - } - if (newestSeen != null) writeWatermark(lang, newestSeen); - return totalMerged; - } - - // ------------------------------------------------------------------------- - - private static JsonObject fetchPage(String lang, int page) throws IOException { - String base = manifestBaseUrlOverride != null ? manifestBaseUrlOverride : DEFAULT_MANIFEST_URL; - String url = base + String.format(MANIFEST_PATH_AND_QUERY, lang, page); - URLConnection conn = new URL(url).openConnection(); - conn.setConnectTimeout(CONNECT_TIMEOUT_MS); - conn.setReadTimeout(CONNECT_TIMEOUT_MS); - conn.setRequestProperty("Accept", "application/json"); - conn.setRequestProperty("Accept-Encoding", "gzip"); - conn.connect(); - if (conn instanceof HttpURLConnection) { - int status = ((HttpURLConnection) conn).getResponseCode(); - if (status != 200) throw new IOException("HTTP " + status + " for " + url); - } - boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding()); - try (InputStream raw = conn.getInputStream(); - InputStream is = gzipped ? new GZIPInputStream(raw) : raw; - InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return JsonParser.parseReader(reader).getAsJsonObject(); - } - } - - private static String str(JsonObject obj, String key) { - return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; - } - - private static String readWatermark(String lang) { - File file = new File(CdnUuidCache.cacheDir(), WATERMARK_FILE); - if (!file.exists()) return null; - try { - String json = Files.readString(file.toPath(), StandardCharsets.UTF_8); - JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); - return str(obj, lang); - } catch (Exception e) { - Logger.warn("ScryfallManifestSync: corrupt watermark file {}: {}", file, e.getMessage()); - return null; - } - } - - private static void writeWatermark(String lang, String value) { - File file = new File(CdnUuidCache.cacheDir(), WATERMARK_FILE); - JsonObject obj = new JsonObject(); - if (file.exists()) { - try { - obj = JsonParser.parseString(Files.readString(file.toPath(), StandardCharsets.UTF_8)).getAsJsonObject(); - } catch (Exception ignored) { - obj = new JsonObject(); - } - } - obj.addProperty(lang, value); - try { - //noinspection ResultOfMethodCallIgnored - file.getParentFile().mkdirs(); - Files.writeString(file.toPath(), obj.toString(), StandardCharsets.UTF_8); - } catch (IOException e) { - Logger.warn("ScryfallManifestSync: could not write watermark file {}: {}", file, e.getMessage()); - } - } -} diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java index fe5c6560a87e..f24a67a98c1e 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -25,14 +25,16 @@ * to exactly the set a lookup just missed on, so nothing needs to be * pre-generated or hosted anywhere. * - *

Unlike {@link ScryfallManifestSync} (which only ever sees a card's own - * {@code id} and assumes a double-faced card's back face shares it), the - * search API returns each card's own {@code image_uris} (single-faced) or - * {@code card_faces[].image_uris} (double-faced) — so the UUID is read out of - * the actual CDN image URL for each face, exactly like the old bulk-data - * generator did. That matters for a small number of double-faced cards (e.g. - * a couple of Secret Lair prints) whose back face uses a genuinely different - * artwork UUID than the card's {@code id}. + *

The search API returns each card's own {@code image_uris} (single-faced) or + * {@code card_faces[].image_uris} (double-faced), so the UUID is read out of the + * actual CDN image URL for each face rather than assumed from the card's {@code id}. + * That matters for a small number of double-faced cards (e.g. a couple of Secret + * Lair prints) whose back face uses a genuinely different artwork UUID than the + * card's {@code id}. + * + *

A card this doesn't find (e.g. released after the set was last synced) is left + * for {@link CdnUuidCache} to record as a timestamped miss and retry later, rather + * than retried here. */ final class ScryfallSetSync { From f73a49f020da83d478e0aed8073787432a84d3db Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Mon, 17 Aug 2026 00:17:03 +0100 Subject: [PATCH 12/18] Reduce refactor and resolve cache off hotpath --- .../forge/gui/download/CdnUuidCacheTest.java | 2 + .../gui/download/ScryfallSetSyncTest.java | 47 +++-- .../java/forge/gui/download/CdnUuidCache.java | 169 ++++++++++-------- .../GuiDownloadFilteredCardImages.java | 8 +- .../forge/gui/download/ScryfallSetSync.java | 27 ++- .../main/java/forge/util/ImageFetcher.java | 2 +- 6 files changed, 152 insertions(+), 103 deletions(-) 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 index fbe06286bb83..6e5b10fe1b67 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -60,6 +60,7 @@ public void setUp() throws IOException { // lookups must return null rather than hang or hit the real API. CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; + CdnUuidCache.autoSyncEnabled = false; // these tests drive misses synchronously, not via the background pool ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable"; CdnUuidCache.clearCacheForTesting(); } @@ -67,6 +68,7 @@ public void setUp() throws IOException { @AfterClass public void tearDown() { CdnUuidCache.localCacheDirOverride = null; + CdnUuidCache.autoSyncEnabled = true; ScryfallSetSync.searchBaseUrlOverride = null; CdnUuidCache.clearCacheForTesting(); deleteDir(localCacheDir); diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java index 6b4b4c8131d0..899ce9dca934 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java @@ -25,6 +25,11 @@ * {@link CdnUuidCache} falls back to when a set has no local data yet — against * a tiny embedded HTTP server standing in for {@code api.scryfall.com/cards/search}, * so these run offline and don't depend on Scryfall's real catalog. + * + *

CdnUuidCache never blocks the caller on network I/O: a lookup that needs Scryfall + * data queues the set code and returns immediately as if the data weren't there. + * {@code autoSyncEnabled} is disabled here so tests drive the queue synchronously via + * {@link CdnUuidCache#syncPendingSets} instead of racing a real background submission. */ @Test(groups = {"UnitTest"}) public class ScryfallSetSyncTest { @@ -42,6 +47,7 @@ public class ScryfallSetSyncTest { public void setUp() throws IOException { localCacheDir = Files.createTempDirectory("setsync_local").toFile(); CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; + CdnUuidCache.autoSyncEnabled = false; server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", this::handle); @@ -54,6 +60,7 @@ public void setUp() throws IOException { public void tearDown() { server.stop(0); CdnUuidCache.localCacheDirOverride = null; + CdnUuidCache.autoSyncEnabled = true; ScryfallSetSync.searchBaseUrlOverride = null; } @@ -127,6 +134,17 @@ private String page(boolean hasMore, int pageNumber, String... entries) { + ",\"data\":[" + String.join(",", entries) + "]}"; } + /** + * A cold lookup only queues the set and returns {@code null} immediately. This triggers + * it, runs the queue synchronously, and re-queries for the (now-resolved, or still-absent) + * result. + */ + private static String resolveAfterSync(String set, String cn, String lang, String face, String size) { + CdnUuidCache.getCdnUrl(set, cn, lang, face, size); + CdnUuidCache.syncPendingSets(); + return CdnUuidCache.getCdnUrl(set, cn, lang, face, size); + } + // ------------------------------------------------------------------------- @Test @@ -134,7 +152,7 @@ public void singleFacedCard_resolvesViaCdnUuidCache() { pages.put(1, page(false, 1, singleFaced("11111111-1111-1111-1111-111111111111", "1", "en"))); - String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); + String url = resolveAfterSync("neo", "1", "en", "front", "normal"); Assert.assertEquals(url, CdnUuidCache.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal")); @@ -148,8 +166,8 @@ public void dfcWithDistinctArtworkUuids_bothFacesResolveIndependently() { "aaaaaaaa-0000-0000-0000-000000000001", "bbbbbbbb-0000-0000-0000-000000000002"))); - String front = CdnUuidCache.getCdnUrl("sld", "5", "en", "front", "normal"); - String back = CdnUuidCache.getCdnUrl("sld", "5", "en", "back", "normal"); + String front = resolveAfterSync("sld", "5", "en", "front", "normal"); + String back = CdnUuidCache.getCdnUrl("sld", "5", "en", "back", "normal"); // set already synced by now Assert.assertEquals(front, CdnUuidCache.cdnUrl("aaaaaaaa-0000-0000-0000-000000000001", "front", "normal"), "front face UUID should come from card_faces[0].image_uris, not the card id"); @@ -164,7 +182,7 @@ public void dfcWithSharedArtworkUuid_backResolvesToSameUuid() { "cccccccc-0000-0000-0000-000000000003", "cccccccc-0000-0000-0000-000000000003"))); - String back = CdnUuidCache.getCdnUrl("neo", "6", "en", "back", "normal"); + String back = resolveAfterSync("neo", "6", "en", "back", "normal"); Assert.assertEquals(back, CdnUuidCache.cdnUrl("cccccccc-0000-0000-0000-000000000003", "back", "normal")); } @@ -174,8 +192,8 @@ public void multiplePages_paginatesViaNextPageAndMergesAll() { pages.put(1, page(true, 1, singleFaced("dddddddd-0000-0000-0000-000000000001", "1", "en"))); pages.put(2, page(false, 2, singleFaced("eeeeeeee-0000-0000-0000-000000000002", "2", "en"))); - String url1 = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); - String url2 = CdnUuidCache.getCdnUrl("neo", "2", "en", "front", "normal"); + String url1 = resolveAfterSync("neo", "1", "en", "front", "normal"); + String url2 = CdnUuidCache.getCdnUrl("neo", "2", "en", "front", "normal"); // set already synced by now Assert.assertNotNull(url1); Assert.assertNotNull(url2); @@ -185,7 +203,7 @@ public void multiplePages_paginatesViaNextPageAndMergesAll() { @Test public void unknownSet_returns404_lookupIsNullWithoutThrowing() { // No page registered for "xyz" -> handle() serves a 404, same as Scryfall for no matches. - String url = CdnUuidCache.getCdnUrl("xyz", "1", "en", "front", "normal"); + String url = resolveAfterSync("xyz", "1", "en", "front", "normal"); Assert.assertNull(url); Assert.assertFalse(new File(localCacheDir, "xyz.json.gz").exists()); @@ -202,7 +220,7 @@ public void sync_neverOverwritesExistingPrecomputedEntry() { // Scryfall search sees a *different* id for the same card/lang -- must not clobber it. pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "1", "en"))); - ScryfallSetSync.sync("neo"); + ScryfallSetSync.sync("neo"); // direct call, bypassing CdnUuidCache's backgrounding entirely String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); Assert.assertEquals(url, CdnUuidCache.cdnUrl("ffffffff-0000-0000-0000-000000000001", "front", "normal"), @@ -213,7 +231,7 @@ public void sync_neverOverwritesExistingPrecomputedEntry() { public void searchQuery_scopesToSetAndAllLanguages() { pages.put(1, page(false, 1, singleFaced("11111111-2222-3333-4444-555555555555", "1", "en"))); - CdnUuidCache.getCdnUrl("ltr", "1", "en", "front", "normal"); + resolveAfterSync("ltr", "1", "en", "front", "normal"); Assert.assertNotNull(lastQuery); String decoded = URLDecoder.decode(lastQuery, StandardCharsets.UTF_8); @@ -250,8 +268,13 @@ public void staleMiss_retriesAndResolvesIfNowFound() throws IOException { "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}"); pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "9", "en"))); - String url = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); + // The triggering call still sees the stale miss and returns null; the retry is only + // queued, not run synchronously -- syncPendingSets() is what actually performs it. + String immediate = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); + Assert.assertNull(immediate, "the retry is only queued, not run synchronously"); + CdnUuidCache.syncPendingSets(); + String url = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); Assert.assertEquals(url, CdnUuidCache.cdnUrl("99999999-0000-0000-0000-000000000009", "front", "normal")); Assert.assertEquals(requestCount.get(), 1, "a stale miss should trigger exactly one retry"); } @@ -262,8 +285,8 @@ public void staleMiss_stillMissing_refreshesTimestampAndStopsRetrying() throws I "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}"); // No page registered -> 404, same as Scryfall finding nothing for this set. - String first = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); - Assert.assertNull(first); + CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); // queues the retry + CdnUuidCache.syncPendingSets(); Assert.assertEquals(requestCount.get(), 1, "the stale miss should have triggered one retry"); String second = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 0c0b3561c692..5684d9bcffba 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import forge.localinstance.properties.ForgeConstants; +import forge.util.ThreadUtil; import org.tinylog.Logger; import java.io.File; @@ -20,29 +21,26 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; /** - * Lazy-loading, thread-safe cache for Scryfall CDN UUIDs. + * Lazy-loading, thread-safe cache for Scryfall CDN UUIDs, generated on demand from Scryfall's + * search API (see {@link ScryfallSetSync}) and cached locally at + * {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. A {@code null} result lets the caller fall + * back to rate-limited, non-CDN Scryfall image hosting. * - * All UUID data is generated on the user's machine, straight from Scryfall. + *

Callers include interactive gameplay code that runs on the EDT/render thread, so a lookup + * this can't answer from disk never blocks on network I/O itself: it queues the set code and + * returns as if the data weren't there yet. {@link #syncPendingSets} does the actual work and + * is submitted to the existing shared {@link ThreadUtil#getServicePool}, warming the cache for + * the next lookup instead of the current one. * - * The cache resolves data locally first from - * {@code {cacheDir}/cdn_uuid/{setCode}.json.gz} files. - * - * When there is a cache miss (file or entry), it's repopulated from - * the scryfall search api. - * - * The cache returns null, so the user may resolve via a rate-limited, non-cdn scryfall - * image hosting. The client rate limits these requests. - * - *

A (collector number, language) that a set's data genuinely doesn't have -- e.g. a - * card released after the set was last synced -- is recorded as a negative-cache "miss" - * with a timestamp, right alongside the real entries. A miss younger than - * {@link #MISS_RETRY_AFTER} short-circuits with no network call; once it's older, the - * next lookup re-syncs the set from Scryfall to see if it's showed up since. + *

A (collector number, language) that a set's cached data genuinely doesn't have -- e.g. a + * card released after the set was last synced -- is recorded as a negative-cache "miss" with + * a timestamp; see {@link #trackMiss} for the retry policy. * *

Set JSON format: *

@@ -82,6 +80,12 @@ private LangUuids(String front, String back, Instant missedAt) {
     private static final ConcurrentHashMap>> setCache =
             new ConcurrentHashMap<>();
 
+    /** Set codes a lookup couldn't answer locally, waiting for {@link #syncPendingSets}. */
+    private static final Set pendingSyncs = ConcurrentHashMap.newKeySet();
+
+    /** Submits {@link #syncPendingSets} to the shared pool for tests to disable. */
+    static volatile boolean autoSyncEnabled = true;
+
     /**
      * Override for the local cache directory for tests.
      */
@@ -112,16 +116,13 @@ public static void clearCache() {
     }
 
     /**
-     * Merges externally-sourced (setCode → collectorNumber → lang → [frontUuid, backUuidOrNull])
-     * entries into the on-disk cache, creating or updating {@code {cacheDir}/{setCode}.json.gz}.
-     *
-     * Used by {@link ScryfallSetSync} when a set has no local data at all yet (or a
-     * previously-recorded miss is stale) and is (re)built from Scryfall's card search API.
+     * Merges externally-sourced (collectorNumber → lang → [frontUuid, backUuidOrNull]) entries
+     * for {@code setCode} into its on-disk cache file. Called by {@link ScryfallSetSync} after
+     * (re)building a set from Scryfall's card search API.
      *
-     * 

A real entry already on disk is never overwritten, so a precise front/back pair - * can't be degraded by a later, less precise source filling the same slot. A miss - * record occupying the slot doesn't count as "already there" -- real data always - * upgrades a miss. + *

A real entry already on disk is never overwritten -- a precise front/back pair can't be + * degraded by a later, less precise source -- but a miss record occupying the slot doesn't + * count as "already there", so real data always upgrades a miss. */ static synchronized void mergeSetEntriesWithFaces(String setCode, Map> newEntries) { File file = localCacheFile(setCode); @@ -180,15 +181,21 @@ private static boolean isRealEntry(JsonElement value) { private static JsonObject readLocalJson(File file) { if (!file.exists()) return new JsonObject(); - try (InputStream is = new GZIPInputStream(new FileInputStream(file)); - InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return JsonParser.parseReader(reader).getAsJsonObject(); + try { + return readGzipJson(file); } catch (Exception e) { Logger.warn("CdnUuidCache: corrupt local cache {}, rebuilding: {}", file, e.getMessage()); return new JsonObject(); } } + private static JsonObject readGzipJson(File file) throws Exception { + try (InputStream is = new GZIPInputStream(new FileInputStream(file)); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + } + /** * Returns the Scryfall CDN image URL for a given card face, or {@code null} * if no UUID data is available. @@ -209,8 +216,10 @@ public static String getCdnUrl(String scryfallCode, String collectorNum, if (cardMap == MISSING_SET) return null; LangUuids uuids = resolveWithFallback(cardMap, collectorNum, lang); - if (uuids == null) uuids = trackMiss(setCode, cardMap, collectorNum, lang); - if (uuids == null) return null; + if (uuids == null) { + trackMiss(setCode, cardMap, collectorNum, lang); + return null; + } String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; String side = wantBack ? "back" : "front"; @@ -233,34 +242,57 @@ private static LangUuids resolveWithFallback(Map> } /** - * Neither {@code lang} nor its English fallback resolved to real data. Tracks that as a - * miss against the exact requested {@code lang}: a miss seen for the first time, or one - * younger than {@link #MISS_RETRY_AFTER}, is recorded/left alone and this returns - * {@code null} with no network call. A miss older than that is worth re-checking -- - * the set is re-synced from Scryfall, in case the card has shown up since. + * Neither {@code lang} nor its English fallback resolved to real data. A miss seen for the + * first time is recorded immediately (no network call). One already recorded and younger + * than {@link #MISS_RETRY_AFTER} is left alone -- also no network call. One older than that + * queues the set for {@link #syncPendingSets} to re-check. */ - private static LangUuids trackMiss(String setCode, Map> cardMap, - String cn, String lang) { + private static void trackMiss(String setCode, Map> cardMap, + String cn, String lang) { Map langMap = cardMap.get(cn); LangUuids existing = langMap != null ? langMap.get(lang) : null; - if (existing != null && existing.isMiss() && isStale(existing.missedAt)) { - ScryfallSetSync.sync(setCode); - Map> refreshed = loadSet(setCode); - LangUuids retried = resolveWithFallback(refreshed, cn, lang); - if (retried != null) return retried; - } else if (existing != null && existing.isMiss()) { - return null; // recorded recently; don't hit the network again yet + if (existing != null && existing.isMiss()) { + if (isStale(existing.missedAt)) queueSync(setCode); + return; } recordMiss(setCode, cn, lang); - return null; } private static boolean isStale(Instant missedAt) { return Duration.between(missedAt, Instant.now()).compareTo(MISS_RETRY_AFTER) >= 0; } + /** Queues {@code setCode} and, unless a test disabled it, submits {@link #syncPendingSets} to the shared pool. */ + private static void queueSync(String setCode) { + if (pendingSyncs.add(setCode) && autoSyncEnabled) { + ThreadUtil.getServicePool().submit(CdnUuidCache::syncPendingSets); + } + } + + /** + * Synchronously syncs every set a lookup has queued (a never-seen set, or a stale miss due + * a retry) from Scryfall. Meant to run off the EDT/render thread -- see the class javadoc. + */ + public static void syncPendingSets() { + for (String setCode : pendingSyncs) { + if (!pendingSyncs.remove(setCode)) continue; // another thread already claimed it + ScryfallSetSync.sync(setCode); + refreshStaleMisses(setCode); + } + } + + /** A miss still unresolved after a resync attempt just got reconfirmed -- refresh its timestamp. */ + private static void refreshStaleMisses(String setCode) { + for (Map.Entry> cnEntry : readSetFromDisk(setCode).entrySet()) { + for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { + LangUuids v = langEntry.getValue(); + if (v.isMiss() && isStale(v.missedAt)) recordMiss(setCode, cnEntry.getKey(), langEntry.getKey()); + } + } + } + /** * Builds a Scryfall CDN image URL from a card UUID. The CDN ({@code cards.scryfall.io}) * is not rate-limited; given a UUID and image size, the URL is fully deterministic: @@ -288,32 +320,28 @@ private static Map> ensureSetLoaded(String setCod } private static Map> loadSet(String setCode) { - File localFile = localCacheFile(setCode); + Map> onDisk = readSetFromDisk(setCode); + if (onDisk != MISSING_SET) return onDisk; - // 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(); - } - } + // Nothing local yet -- queue this set for syncPendingSets instead of blocking on network + // I/O here. ScryfallSetSync merges its results into the local cache file and evicts + // setCache when it's done, so the next lookup picks up whatever it found. + queueSync(setCode); + return MISSING_SET; + } - // 2. Nothing local yet — build this set's mapping straight from Scryfall. - // ScryfallSetSync merges its own results into the local cache file (via - // mergeSetEntriesWithFaces) and evicts setCache, so re-reading that file - // afterward picks up whatever it found. - if (ScryfallSetSync.sync(setCode) && localFile.exists()) { - try { - return parseSetFile(localFile); - } catch (Exception e) { - Logger.warn("CdnUuidCache: corrupt local cache {} after Scryfall sync: {}", localFile, e.getMessage()); - } + /** Pure disk read: {@link #MISSING_SET} if there's no local file yet, without queuing a sync. */ + private static Map> readSetFromDisk(String setCode) { + File localFile = localCacheFile(setCode); + if (!localFile.exists()) return MISSING_SET; + try { + return parseSetFile(localFile); + } catch (Exception e) { + Logger.warn("CdnUuidCache: corrupt local cache {}: {}", localFile, e.getMessage()); + //noinspection ResultOfMethodCallIgnored + localFile.delete(); + return MISSING_SET; } - - return MISSING_SET; } private static File localCacheFile(String setCode) { @@ -341,10 +369,7 @@ private static void writeLocalCache(File file, String json) { } private static Map> parseSetFile(File file) throws Exception { - try (InputStream is = new GZIPInputStream(new FileInputStream(file)); - InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { - return parseSetObject(JsonParser.parseReader(reader).getAsJsonObject()); - } + return parseSetObject(readGzipJson(file)); } /** 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 384161a6e5ef..b1c6f43d8cd6 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -20,8 +20,8 @@ * Downloads card images for all cards that match the supplied predicate. * * 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 + * 1. cards.scryfall.io CDN (not rate-limited) — via {@link CdnUuidCache}, which resolves + * from its local cache or syncs the set from Scryfall on demand * 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 @@ -84,7 +84,7 @@ private static void addIfMissing(PaperCard c, String face, Map d * 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) + * 1. cards.scryfall.io CDN URL via {@link CdnUuidCache} (no rate limit) * 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 @@ -100,7 +100,7 @@ private static String buildUrl(PaperCard c, String face) { 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 + // 1. CDN — fast, no rate limit; resolved (or synced on demand) via CdnUuidCache if (edition != null && hasCollectorNum && hasScryfallCode) { String cdnUrl = CdnUuidCache.getCdnUrl( scryfallCode, collectorNum, edition.getCardsLangCode(), face, "normal"); diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java index f24a67a98c1e..fa38ea443f4d 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -92,22 +92,26 @@ private static void addCard(Map> byCn, JsonObject String front; String back = null; if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { - front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris")), id); + front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris"))); } else if (card.has("card_faces") && card.get("card_faces").isJsonArray()) { JsonArray faces = card.getAsJsonArray("card_faces"); if (faces.size() == 0) return; JsonObject face0 = faces.get(0).getAsJsonObject(); if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) return; - front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris")), id); + front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris"))); if (faces.size() > 1) { JsonObject face1 = faces.get(1).getAsJsonObject(); if (face1.has("image_uris") && face1.get("image_uris").isJsonObject()) { - back = uuidFromUrl(normalUrl(face1.getAsJsonObject("image_uris")), id); + back = uuidFromUrl(normalUrl(face1.getAsJsonObject("image_uris"))); } } } else { return; // no image data for this card } + // No real image yet (e.g. a placeholder like errors.scryfall.com/soon.jpg for an + // unreleased/preview card): leave this (cn, lang) unmerged so CdnUuidCache records + // it as a retryable miss, rather than guessing a UUID that may never become valid + // and would then be stuck forever (merged "real" data is never overwritten). if (front == null) return; byCn.computeIfAbsent(cn, k -> new HashMap<>()).put(lang, new String[]{front, back}); @@ -119,18 +123,13 @@ private static String normalUrl(JsonObject imageUris) { } /** - * Extracts the UUID segment from a Scryfall CDN image URL. - * - *

URL format: {@code https://cards.scryfall.io/normal/front/4/e/{uuid}.jpg?timestamp} - * - *

Parsing the UUID from the URL (rather than using the card's {@code id} field directly) - * correctly handles the rare double-faced cards where both faces share an artwork UUID - * that differs from the card's own {@code id}. Falls back to {@code cardId} for non-CDN - * URLs such as {@code errors.scryfall.com/soon.jpg} (placeholder for missing images). + * Extracts the UUID segment from a Scryfall CDN image URL + * ({@code https://cards.scryfall.io/normal/front/4/e/{uuid}.jpg?timestamp}), or + * {@code null} for a non-CDN URL (e.g. the {@code errors.scryfall.com/soon.jpg} + * placeholder Scryfall serves for a card with no image yet). */ - private static String uuidFromUrl(String url, String cardId) { - if (url == null) return null; - if (!url.contains("cards.scryfall.io")) return cardId; + private static String uuidFromUrl(String url) { + if (url == null || !url.contains("cards.scryfall.io")) return null; int qmark = url.indexOf('?'); String path = qmark >= 0 ? url.substring(0, qmark) : url; int slash = path.lastIndexOf('/'); diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index dbfed4bf5611..544e112281c2 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -141,7 +141,7 @@ private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, Arr String setCode = edition.getScryfallCode(); String langCode = edition.getCardsLangCode(); - // Prefer CDN (no rate limit) when a UUID JSON file exists in the assets. + // Prefer CDN (no rate limit) when CdnUuidCache already has (or can sync) a UUID for this card. if (!StringUtils.isBlank(setCode)) { String size = useArtCrop ? "art_crop" : "normal"; String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrl( From c0ebfe551756bbaf1e2f31de6b96ed6e0fb04bad Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Mon, 17 Aug 2026 01:12:43 +0100 Subject: [PATCH 13/18] Trim doc comments across the CDN cache work No behavior change: condenses multi-paragraph javadoc down to one or two lines per method/class, drops inline comments that just restated an adjacent assert message or code branch, and removes a duplicated CDN/API/cardforge priority list that appeared in both a class and method javadoc. --- .../forge/gui/download/CdnUuidCacheTest.java | 25 ++--- .../gui/download/ScryfallSetSyncTest.java | 24 +--- .../settings/CardImageBrowserScreen.java | 2 +- .../java/forge/gui/download/CdnUuidCache.java | 103 ++++++------------ .../GuiDownloadFilteredCardImages.java | 26 +---- .../forge/gui/download/ScryfallSetSync.java | 39 ++----- .../main/java/forge/util/ImageFetcher.java | 2 +- 7 files changed, 62 insertions(+), 159 deletions(-) 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 index 6e5b10fe1b67..eb299d7b7eed 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -17,12 +17,9 @@ import java.util.zip.GZIPOutputStream; /** - * All UUID data here is seeded straight into the local cache dir (gzip-compressed, - * exactly as {@link CdnUuidCache} writes it) rather than served from anywhere remote — - * there's no hosted data source anymore. {@link ScryfallSetSync#searchBaseUrlOverride} - * points at an unreachable address so any lookup that isn't pre-seeded fails fast - * instead of hitting the real Scryfall API; on-demand generation from Scryfall itself - * is covered by {@link ScryfallSetSyncTest}. + * UUID data is pre-seeded into the local cache dir (gzip, as {@link CdnUuidCache} writes it). + * {@link ScryfallSetSync#searchBaseUrlOverride} points at an unreachable address so an + * unseeded lookup fails fast; on-demand generation is covered by {@link ScryfallSetSyncTest}. */ @Test(groups = {"UnitTest"}) public class CdnUuidCacheTest { @@ -56,11 +53,10 @@ public void setUp() throws IOException { + "\"2\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}" + "}"); - // SET_ABSENT has no local file and the Scryfall override is unreachable — - // lookups must return null rather than hang or hit the real API. + // SET_ABSENT has no local file; the Scryfall override is unreachable. CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; - CdnUuidCache.autoSyncEnabled = false; // these tests drive misses synchronously, not via the background pool + CdnUuidCache.autoSyncEnabled = false; // drive misses synchronously, not via the pool ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable"; CdnUuidCache.clearCacheForTesting(); } @@ -191,9 +187,7 @@ public void unknownCollectorNumber_returnsNull() { @Test(dependsOnMethods = "unknownCollectorNumber_returnsNull") public void missingEntry_isPersistedAsTimestampedMiss() throws IOException { - // The prior lookup for cn 9999 (unresolvable, and the Scryfall override is - // unreachable so no retry could have succeeded) should have recorded a miss - // marker on disk rather than just silently returning null every time. + // The prior lookup for cn 9999 should have recorded a miss marker on disk. String raw = readGunzipped(new File(localCacheDir, SET + ".json.gz")); Assert.assertTrue(raw.contains("\"9999\""), "miss should be recorded under its collector number: " + raw); Assert.assertTrue(raw.contains("\"miss\""), "miss should be recorded with the miss marker: " + raw); @@ -205,16 +199,13 @@ public void missingEntry_isPersistedAsTimestampedMiss() throws IOException { @Test(dependsOnMethods = "missingEntry_isPersistedAsTimestampedMiss") public void freshMiss_stillReturnsNullOnRepeatLookup() { - // The miss recorded above is only seconds old -- well under the 1-day retry - // window -- so this must short-circuit to null again without touching the - // (unreachable) Scryfall override. + // The miss recorded above is fresh, well under the retry window. Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal")); } @Test public void recordedMiss_upgradesToRealEntryOnceMerged() { - // Simulate ScryfallSetSync later finding this card (e.g. after a resync - // triggered by a stale miss): a real entry must overwrite the miss marker. + // A real entry found later (e.g. via a resync) must overwrite the miss marker. Map cn7 = new HashMap<>(); cn7.put("en", new String[]{UUID_EN, null}); Map> found = new HashMap<>(); diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java index 899ce9dca934..bdd1559f0106 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java @@ -21,15 +21,10 @@ import java.util.zip.GZIPOutputStream; /** - * Exercises {@link ScryfallSetSync} — the on-demand, per-set generator that - * {@link CdnUuidCache} falls back to when a set has no local data yet — against - * a tiny embedded HTTP server standing in for {@code api.scryfall.com/cards/search}, - * so these run offline and don't depend on Scryfall's real catalog. - * - *

CdnUuidCache never blocks the caller on network I/O: a lookup that needs Scryfall - * data queues the set code and returns immediately as if the data weren't there. - * {@code autoSyncEnabled} is disabled here so tests drive the queue synchronously via - * {@link CdnUuidCache#syncPendingSets} instead of racing a real background submission. + * Exercises {@link ScryfallSetSync} against an embedded HTTP server standing in for + * {@code api.scryfall.com/cards/search}, offline. {@code autoSyncEnabled} is disabled so + * tests drive {@link CdnUuidCache#syncPendingSets} synchronously instead of racing a real + * background submission. */ @Test(groups = {"UnitTest"}) public class ScryfallSetSyncTest { @@ -134,11 +129,7 @@ private String page(boolean hasMore, int pageNumber, String... entries) { + ",\"data\":[" + String.join(",", entries) + "]}"; } - /** - * A cold lookup only queues the set and returns {@code null} immediately. This triggers - * it, runs the queue synchronously, and re-queries for the (now-resolved, or still-absent) - * result. - */ + /** Triggers a cold lookup, (cdn) syncs (operation) synchronously, then re-queries for the result. */ private static String resolveAfterSync(String set, String cn, String lang, String face, String size) { CdnUuidCache.getCdnUrl(set, cn, lang, face, size); CdnUuidCache.syncPendingSets(); @@ -240,8 +231,7 @@ public void searchQuery_scopesToSetAndAllLanguages() { } // ------------------------------------------------------------------------- - // Miss-record retry: CdnUuidCache persists a timestamped "miss" for a (cn, lang) it - // can't find in an already-synced set, and only re-hits Scryfall once that's stale. + // Miss-record retry private static void writeGzip(File f, String content) throws IOException { try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(f))) { @@ -268,8 +258,6 @@ public void staleMiss_retriesAndResolvesIfNowFound() throws IOException { "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}"); pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "9", "en"))); - // The triggering call still sees the stale miss and returns null; the retry is only - // queued, not run synchronously -- syncPendingSets() is what actually performs it. String immediate = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); Assert.assertNull(immediate, "the retry is only queued, not run synchronously"); CdnUuidCache.syncPendingSets(); diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java index a942507c2884..c8eaa77a2060 100644 --- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java +++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java @@ -118,7 +118,7 @@ public CardImageBrowserScreen() { btnDownload = add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages"))); btnDownload.setCommand(e -> startDownload()); - // ── CDN image lookup cache: clear (rebuilt automatically on demand) ── + // ── Clear CDN cache button ───────────────────────────────────────────── btnClearCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache"))); btnClearCdnCache.setCommand(e -> clearCdnCache()); diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 5684d9bcffba..429c07e8aac3 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -27,30 +27,13 @@ import java.util.zip.GZIPOutputStream; /** - * Lazy-loading, thread-safe cache for Scryfall CDN UUIDs, generated on demand from Scryfall's - * search API (see {@link ScryfallSetSync}) and cached locally at - * {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. A {@code null} result lets the caller fall - * back to rate-limited, non-CDN Scryfall image hosting. + * Thread-safe cache of Scryfall CDN image URLs, built on demand via {@link ScryfallSetSync} + * and stored at {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. Never blocks on network I/O: + * a miss queues the set for {@link #syncPendingSets} and returns {@code null}. An unresolved + * (cn, lang) is recorded as a timestamped miss so repeat lookups skip until it's stale + * (see {@link #trackMiss}). * - *

Callers include interactive gameplay code that runs on the EDT/render thread, so a lookup - * this can't answer from disk never blocks on network I/O itself: it queues the set code and - * returns as if the data weren't there yet. {@link #syncPendingSets} does the actual work and - * is submitted to the existing shared {@link ThreadUtil#getServicePool}, warming the cache for - * the next lookup instead of the current one. - * - *

A (collector number, language) that a set's cached data genuinely doesn't have -- e.g. a - * card released after the set was last synced -- is recorded as a negative-cache "miss" with - * a timestamp; see {@link #trackMiss} for the retry policy. - * - *

Set JSON format: - *

- *   {
- *     "1":   {"en": "uuid"},
- *     "2":   {"en": "uuid", "ja": "ja-uuid"},
- *     "A-40":{"en": ["frontUuid", "backUuid"]},
- *     "9":   {"en": {"miss": "2026-08-11T18:23:45.123Z"}}
- *   }
- * 
+ *

Set JSON: {@code {"cn": {"lang": "uuid" | ["front","back"] | {"miss": timestamp}}}} */ public final class CdnUuidCache { @@ -83,12 +66,10 @@ private LangUuids(String front, String back, Instant missedAt) { /** Set codes a lookup couldn't answer locally, waiting for {@link #syncPendingSets}. */ private static final Set pendingSyncs = ConcurrentHashMap.newKeySet(); - /** Submits {@link #syncPendingSets} to the shared pool for tests to disable. */ + /** Submits {@link #syncPendingSets} to the shared pool; tests disable this. */ static volatile boolean autoSyncEnabled = true; - /** - * Override for the local cache directory for tests. - */ + /** Test override for the local cache directory. */ static volatile String localCacheDirOverride = null; private CdnUuidCache() {} @@ -96,14 +77,12 @@ private CdnUuidCache() {} /** Test helper */ static void clearCacheForTesting() { setCache.clear(); } - /** The directory local set caches live in, honoring the test override. Package-private for {@link ScryfallSetSync}. */ + /** Local set-cache directory, honoring the test override. */ static String cacheDir() { return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR; } - /** - * Deletes every locally cached CDN UUID set file and clears the in-memory cache. - */ + /** Deletes every local cache file and clears the in-memory cache. */ public static void clearCache() { setCache.clear(); File dir = new File(cacheDir()); @@ -116,13 +95,9 @@ public static void clearCache() { } /** - * Merges externally-sourced (collectorNumber → lang → [frontUuid, backUuidOrNull]) entries - * for {@code setCode} into its on-disk cache file. Called by {@link ScryfallSetSync} after - * (re)building a set from Scryfall's card search API. - * - *

A real entry already on disk is never overwritten -- a precise front/back pair can't be - * degraded by a later, less precise source -- but a miss record occupying the slot doesn't - * count as "already there", so real data always upgrades a miss. + * Merges (cn → lang → [front, back]) entries for {@code setCode} into its cache file. + * Never overwrites a real entry, but a miss record doesn't count as "already there" -- + * real data always upgrades a miss. */ static synchronized void mergeSetEntriesWithFaces(String setCode, Map> newEntries) { File file = localCacheFile(setCode); @@ -153,11 +128,7 @@ static synchronized void mergeSetEntriesWithFaces(String setCode, Map> } /** - * Neither {@code lang} nor its English fallback resolved to real data. A miss seen for the - * first time is recorded immediately (no network call). One already recorded and younger - * than {@link #MISS_RETRY_AFTER} is left alone -- also no network call. One older than that - * queues the set for {@link #syncPendingSets} to re-check. + * Records a first-time miss immediately. A miss younger than {@link #MISS_RETRY_AFTER} + * is left alone; an older one queues the set for {@link #syncPendingSets}. */ private static void trackMiss(String setCode, Map> cardMap, String cn, String lang) { @@ -271,10 +239,7 @@ private static void queueSync(String setCode) { } } - /** - * Synchronously syncs every set a lookup has queued (a never-seen set, or a stale miss due - * a retry) from Scryfall. Meant to run off the EDT/render thread -- see the class javadoc. - */ + /** Syncs every queued set from Scryfall. Meant to run off the EDT/render thread. */ public static void syncPendingSets() { for (String setCode : pendingSyncs) { if (!pendingSyncs.remove(setCode)) continue; // another thread already claimed it @@ -283,7 +248,7 @@ public static void syncPendingSets() { } } - /** A miss still unresolved after a resync attempt just got reconfirmed -- refresh its timestamp. */ + /** Refreshes the timestamp of any miss still unresolved after a resync. */ private static void refreshStaleMisses(String setCode) { for (Map.Entry> cnEntry : readSetFromDisk(setCode).entrySet()) { for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { @@ -294,13 +259,11 @@ private static void refreshStaleMisses(String setCode) { } /** - * Builds a Scryfall CDN image URL from a card UUID. The CDN ({@code cards.scryfall.io}) - * is not rate-limited; given a UUID and image size, the URL is fully deterministic: - * {@code https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg}. + * Builds the deterministic Scryfall CDN URL for a card UUID. * - * @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"} + * @param uuid Scryfall card UUID + * @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 @@ -323,9 +286,8 @@ private static Map> loadSet(String setCode) { Map> onDisk = readSetFromDisk(setCode); if (onDisk != MISSING_SET) return onDisk; - // Nothing local yet -- queue this set for syncPendingSets instead of blocking on network - // I/O here. ScryfallSetSync merges its results into the local cache file and evicts - // setCache when it's done, so the next lookup picks up whatever it found. + // Nothing local yet -- queue instead of blocking; the next lookup picks up whatever + // syncPendingSets finds. queueSync(setCode); return MISSING_SET; } @@ -372,10 +334,7 @@ private static Map> parseSetFile(File file) throw return parseSetObject(readGzipJson(file)); } - /** - * Parses a set JSON object. - * Format: {@code {"cn": {"lang": "uuid"|["frontUuid","backUuid"]|{"miss": timestamp}}, ...}} - */ + /** Format: {@code {"cn": {"lang": "uuid"|["front","back"]|{"miss": timestamp}}}} */ private static Map> parseSetObject(JsonObject setObj) { Map> cardMap = new HashMap<>(setObj.size() * 2); for (Map.Entry cnEntry : setObj.entrySet()) { 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 b1c6f43d8cd6..8a26ed5670d0 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -17,14 +17,8 @@ import java.util.function.Predicate; /** - * Downloads card images for all cards that match the supplied predicate. - * - * URL priority per card face: - * 1. cards.scryfall.io CDN (not rate-limited) — via {@link CdnUuidCache}, which resolves - * from its local cache or syncs the set from Scryfall on demand - * 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 + * Downloads card images for all cards matching the predicate. + * Per face: CDN via {@link CdnUuidCache}, then the rate-limited Scryfall API, then cardforge. */ public class GuiDownloadFilteredCardImages extends GuiDownloadService { @@ -80,15 +74,7 @@ private static void addIfMissing(PaperCard c, String face, Map d downloads.put(destFull.getAbsolutePath(), url); } - /** - * Returns the best available download URL for one card face. - * - * Priority: - * 1. cards.scryfall.io CDN URL via {@link CdnUuidCache} (no rate limit) - * 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 - */ + /** Best available download URL for one face: CDN, then Scryfall API, then cardforge. */ private static String buildUrl(PaperCard c, String face) { final String collectorNum = c.getCollectorNumber(); final boolean hasCollectorNum = !IPaperCard.NO_COLLECTOR_NUMBER.equals(collectorNum) @@ -100,21 +86,21 @@ private static String buildUrl(PaperCard c, String face) { String scryfallCode = (edition != null) ? edition.getScryfallCode() : null; boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode); - // 1. CDN — fast, no rate limit; resolved (or synced on demand) via CdnUuidCache + // 1. CDN 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) + // 2. Scryfall API 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; } - // 3. Cardforge hosted server + // 3. Cardforge 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/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java index fa38ea443f4d..6486c274d9bb 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -19,22 +19,10 @@ import java.util.zip.GZIPInputStream; /** - * Builds the local CDN UUID cache for a single set, on demand, straight from - * Scryfall's card search API — the client-side equivalent of what the old - * forge-extras/CLI generator did against a full bulk-data export, but scoped - * to exactly the set a lookup just missed on, so nothing needs to be - * pre-generated or hosted anywhere. - * - *

The search API returns each card's own {@code image_uris} (single-faced) or - * {@code card_faces[].image_uris} (double-faced), so the UUID is read out of the - * actual CDN image URL for each face rather than assumed from the card's {@code id}. - * That matters for a small number of double-faced cards (e.g. a couple of Secret - * Lair prints) whose back face uses a genuinely different artwork UUID than the - * card's {@code id}. - * - *

A card this doesn't find (e.g. released after the set was last synced) is left - * for {@link CdnUuidCache} to record as a timestamped miss and retry later, rather - * than retried here. + * Builds one set's CDN UUID mapping from Scryfall's card search API, on demand. + * Reads each face's own image URL rather than assuming from the card's {@code id}, + * so double-faced cards with distinct front/back art resolve correctly. + * A card not found here is left for {@link CdnUuidCache} to record as a retryable miss. */ final class ScryfallSetSync { @@ -50,10 +38,8 @@ final class ScryfallSetSync { private ScryfallSetSync() {} /** - * Fetches every print of {@code setCode} (all languages) from Scryfall and merges - * the result into {@link CdnUuidCache}'s local cache for that set. - * - * @return {@code true} if any card data was found and cached for this set + * Fetches every print of {@code setCode} from Scryfall and merges it into the cache. + * @return true if anything was found and cached */ static boolean sync(String setCode) { Map> byCn = new HashMap<>(); @@ -108,10 +94,8 @@ private static void addCard(Map> byCn, JsonObject } else { return; // no image data for this card } - // No real image yet (e.g. a placeholder like errors.scryfall.com/soon.jpg for an - // unreleased/preview card): leave this (cn, lang) unmerged so CdnUuidCache records - // it as a retryable miss, rather than guessing a UUID that may never become valid - // and would then be stuck forever (merged "real" data is never overwritten). + // No image yet (placeholder URL) -- leave unmerged so CdnUuidCache retries later + // instead of permanently recording a guessed UUID. if (front == null) return; byCn.computeIfAbsent(cn, k -> new HashMap<>()).put(lang, new String[]{front, back}); @@ -122,12 +106,7 @@ private static String normalUrl(JsonObject imageUris) { return imageUris.has("normal") ? imageUris.get("normal").getAsString() : null; } - /** - * Extracts the UUID segment from a Scryfall CDN image URL - * ({@code https://cards.scryfall.io/normal/front/4/e/{uuid}.jpg?timestamp}), or - * {@code null} for a non-CDN URL (e.g. the {@code errors.scryfall.com/soon.jpg} - * placeholder Scryfall serves for a card with no image yet). - */ + /** UUID segment of a Scryfall CDN URL, or {@code null} for a non-CDN placeholder URL. */ private static String uuidFromUrl(String url) { if (url == null || !url.contains("cards.scryfall.io")) return null; int qmark = url.indexOf('?'); diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index 544e112281c2..91831becbe31 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -141,7 +141,7 @@ private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, Arr String setCode = edition.getScryfallCode(); String langCode = edition.getCardsLangCode(); - // Prefer CDN (no rate limit) when CdnUuidCache already has (or can sync) a UUID for this card. + // Prefer CDN (no rate limit) if CdnUuidCache has a UUID for this card. if (!StringUtils.isBlank(setCode)) { String size = useArtCrop ? "art_crop" : "normal"; String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrl( From daf2c7d70b6b79a251f91e16d814bb3a1e460406 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 18 Aug 2026 02:42:11 +0100 Subject: [PATCH 14/18] Fix Scryfall rate limiting and add bulk CDN link resolution Global ScryfallRateLimiter replaces scattered per-class throttling, paces per Scryfall's documented per-endpoint limits (500ms for search/named, 100ms otherwise), and backs off using the response's Retry-After header instead of a flat 5-minute cooldown. cards.scryfall.io CDN URLs continue to bypass the limiter entirely. CdnUuidCache's implicit auto-sync-on-miss no longer fires from gameplay's lazy image fetch or the bulk downloader's per-card lookup -- both are now read-only against the local cache (getCdnUrlIfCached), so a missing image during normal play never silently kicks off a full-set Scryfall search in the background. The bulk downloader's explicit warm-up loop is now the only thing that triggers a sync, waits out an active cooldown instead of skipping through it, and skips any set already cached locally. Add ScryfallBulkDataSync: resolves CDN links for every set in one pass from Scryfall's bulk data export instead of one paginated /cards/search call per set, wired up as a new button in both the desktop and mobile card image downloader screens. Co-Authored-By: Claude Sonnet 5 --- .../home/settings/CSubmenuDownloaders.java | 2 + .../settings/DialogDownloadCardImages.java | 288 ++++++++++++++++++ .../home/settings/VSubmenuDownloaders.java | 5 + .../java/forge/util/SwingImageFetcher.java | 10 +- .../gui/download/ScryfallSetSyncTest.java | 7 +- .../settings/CardImageBrowserScreen.java | 80 ++++- .../src/forge/util/LibGDXImageFetcher.java | 17 +- forge-gui/res/languages/en-US.properties | 3 + .../java/forge/gui/download/CdnUuidCache.java | 50 +++ .../GuiDownloadFilteredCardImages.java | 53 +++- .../gui/download/GuiDownloadService.java | 31 +- .../gui/download/ScryfallBulkDataSync.java | 284 +++++++++++++++++ .../forge/gui/download/ScryfallSetSync.java | 24 +- .../main/java/forge/util/ImageFetcher.java | 74 +---- .../java/forge/util/ScryfallRateLimiter.java | 180 +++++++++++ 15 files changed, 1006 insertions(+), 102 deletions(-) create mode 100644 forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java create mode 100644 forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java create mode 100644 forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java index 3a015bee71fc..1b9378a2e5b7 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java @@ -35,6 +35,7 @@ public enum CSubmenuDownloaders implements ICDoc { private final UiCommand cmdLicensing = VSubmenuDownloaders.SINGLETON_INSTANCE::showLicensing; private final UiCommand cmdCheckForUpdates = () -> new AutoUpdater(false).attemptToUpdate(CompletableFuture.supplyAsync(() -> RSSReader.getCommitLog(GITHUB_COMMITS_ATOM, BuildInfo.getTimestamp(), FControl.instance.getSnapsTimestamp()))); + private final UiCommand cmdDownloadCardImages = () -> new DialogDownloadCardImages().show(); private final UiCommand cmdPicDownload = () -> new GuiDownloader(new GuiDownloadPicturesLQ()).show(); private final UiCommand cmdPicDownloadHQ = () -> new GuiDownloader(new GuiDownloadPicturesHQ()).show(); @@ -59,6 +60,7 @@ public void register() { public void initialize() { final VSubmenuDownloaders view = VSubmenuDownloaders.SINGLETON_INSTANCE; view.setCheckForUpdatesCommand(cmdCheckForUpdates); + view.setDownloadCardImagesCommand(cmdDownloadCardImages); view.setDownloadPicsCommand(cmdPicDownload); view.setDownloadPicsHQCommand(cmdPicDownloadHQ); view.setDownloadSetPicsCommand(cmdSetDownload); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java new file mode 100644 index 000000000000..49b4b8fb153a --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java @@ -0,0 +1,288 @@ +package forge.screens.home.settings; + +import com.google.common.collect.Iterables; +import forge.card.CardEdition; +import forge.download.GuiDownloader; +import forge.game.GameFormat; +import forge.gui.FThreads; +import forge.gui.SOverlayUtils; +import forge.gui.download.CdnUuidCache; +import forge.gui.download.GuiDownloadFilteredCardImages; +import forge.gui.download.ScryfallBulkDataSync; +import forge.gui.util.SOptionPane; +import forge.item.PaperCard; +import forge.itemmanager.SFilterUtil; +import forge.localinstance.skin.FSkinProp; +import forge.model.FModel; +import forge.toolbox.*; +import forge.toolbox.FCheckBoxTree.FTreeNodeData; +import forge.util.Localizer; +import net.miginfocom.swing.MigLayout; + +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.Timer; +import java.awt.Font; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; + +/** + * Desktop equivalent of the mobile {@code CardImageBrowserScreen}: a Scryfall-syntax search box, + * a format/"Choose Sets" filter, live total/downloaded/missing stats, and Download / Clear CDN + * Cache buttons, all driving the shared {@link GuiDownloadFilteredCardImages}. + */ +public class DialogDownloadCardImages { + private static final Localizer localizer = Localizer.getInstance(); + + private final FTextField txtSearch = new FTextField.Builder() + .ghostText(localizer.getMessage("lblSearch") + " " + localizer.getMessage("lblCards") + "...") + .build(); + private final FComboBox cbxFormats = new FComboBox<>(); + private final FLabel lblTotal = new FLabel.Builder().text("--").fontSize(14).fontAlign(SwingConstants.CENTER).build(); + private final FLabel lblDownloaded = new FLabel.Builder().text("--").fontSize(14).fontAlign(SwingConstants.CENTER).build(); + private final FLabel lblMissing = new FLabel.Builder().text("--").fontSize(14).fontAlign(SwingConstants.CENTER).build(); + private final FProgressBar bulkSyncProgress = new FProgressBar(); + + private final Timer searchDebounce = new Timer(200, e -> scheduleStatsUpdate()); + private final AtomicInteger generation = new AtomicInteger(0); + + private GameFormat selectedFormat = null; + private FPanel mainPanel; + private FButton btnDownload; + private FButton btnSyncBulkData; + private FButton btnClearCdnCache; + + public void show() { + searchDebounce.setRepeats(false); + buildMainPanel(); + showMainOverlay(); + scheduleStatsUpdate(); + } + + // ========================================================================= + // Main dialog + // ========================================================================= + + private void buildMainPanel() { + txtSearch.addChangeListener(new FTextField.ChangeListener() { + @Override public void textChanged() { searchDebounce.restart(); } + }); + + cbxFormats.addItem(localizer.getMessage("lblAllSetsFormats")); + for (GameFormat fmt : FModel.getFormats().getFilterList()) { + cbxFormats.addItem(fmt); + } + cbxFormats.addItem(localizer.getMessage("lblChooseSets")); + + cbxFormats.addActionListener(e -> { + int idx = cbxFormats.getSelectedIndex(); + if (idx < 0) { return; } + if (idx == cbxFormats.getItemCount() - 1) { + openChooseSets(); + return; + } + selectedFormat = idx == 0 ? null : (GameFormat) cbxFormats.getSelectedItem(); + scheduleStatsUpdate(); + }); + + btnDownload = new FButton(localizer.getMessage("btnDownloadCardImages")); + btnDownload.addActionListener(e -> startDownload()); + + btnSyncBulkData = new FButton(localizer.getMessage("btnSyncBulkCardData")); + btnSyncBulkData.addActionListener(e -> startBulkSync()); + + btnClearCdnCache = new FButton(localizer.getMessage("btnClearCdnImageCache")); + btnClearCdnCache.addActionListener(e -> clearCdnCache()); + + final FButton btnClose = new FButton(localizer.getMessage("lblClose")); + btnClose.addActionListener(e -> SOverlayUtils.hideOverlay()); + + mainPanel = new FPanel(new MigLayout("insets 15, gap 5, wrap, center")); + mainPanel.setOpaque(false); + mainPanel.setBackgroundTexture(FSkin.getIcon(FSkinProp.BG_TEXTURE)); + + mainPanel.add(new FLabel.Builder().text(localizer.getMessage("btnDownloadCardImages")) + .fontSize(18).fontStyle(Font.BOLD).fontAlign(SwingConstants.CENTER).build(), "center, w 480!, gaptop 5"); + mainPanel.add(txtSearch, "w 480!, h 28!, gaptop 10"); + mainPanel.add(cbxFormats, "w 480!, h 28!, gaptop 8"); + mainPanel.add(lblTotal, "w 480!, gaptop 15, center"); + mainPanel.add(lblDownloaded, "w 480!, center"); + mainPanel.add(lblMissing, "w 480!, center, gapbottom 10"); + mainPanel.add(btnDownload, "w 300!, h 32!, center, gaptop 10"); + mainPanel.add(btnSyncBulkData, "w 300!, h 32!, center, gaptop 8"); + mainPanel.add(bulkSyncProgress, "w 460!, h 26!, center, gaptop 6"); + mainPanel.add(btnClearCdnCache, "w 300!, h 32!, center, gaptop 8"); + mainPanel.add(btnClose, "w 300!, h 32!, center, gaptop 8, gapbottom 15"); + } + + private void showMainOverlay() { + final JPanel overlay = FOverlay.SINGLETON_INSTANCE.getPanel(); + overlay.removeAll(); + overlay.setLayout(new MigLayout("insets 0, gap 0, wrap, ax center, ay center")); + overlay.add(mainPanel); + SOverlayUtils.showOverlay(); + } + + // ========================================================================= + // Stats computation (non-blocking, discards stale results) + // ========================================================================= + + private void scheduleStatsUpdate() { + final int gen = generation.incrementAndGet(); + + lblTotal.setText(localizer.getMessage("lblTotalCards") + ": ..."); + lblDownloaded.setText(localizer.getMessage("lblDownloaded") + ": ..."); + lblMissing.setText(localizer.getMessage("lblMissing") + ": ..."); + + final Predicate combined = buildCurrentFilter(); + + FThreads.invokeInBackgroundThread(() -> { + int total = 0, downloaded = 0, missing = 0; + for (PaperCard card : Iterables.concat( + FModel.getMagicDb().getCommonCards().getAllCards(), + FModel.getMagicDb().getVariantCards().getAllCards())) { + if (combined.test(card)) { + total++; + if (card.hasImage()) { downloaded++; } else { missing++; } + } + } + + if (generation.get() != gen) { return; } + final int fTotal = total, fDownloaded = downloaded, fMissing = missing; + FThreads.invokeInEdtLater(() -> { + if (generation.get() != gen) { return; } + lblTotal.setText(localizer.getMessage("lblTotalCards") + ": " + fTotal); + lblDownloaded.setText(localizer.getMessage("lblDownloaded") + ": " + fDownloaded); + lblMissing.setText(localizer.getMessage("lblMissing") + ": " + fMissing); + }); + }); + } + + /** Builds the combined card predicate from the current search field and format selection. */ + private Predicate buildCurrentFilter() { + Predicate textPred = SFilterUtil.buildTextFilter( + txtSearch.getText(), false, true, true, true, false); + Predicate fmtPred = selectedFormat == null ? x -> true + : selectedFormat.getName() == null ? selectedFormat.getFilterPrinted() + : selectedFormat.getFilterRules(); + return textPred.and(fmtPred); + } + + // ========================================================================= + // Download / cache actions + // ========================================================================= + + private void startDownload() { + new GuiDownloader(new GuiDownloadFilteredCardImages(buildCurrentFilter())).show(); + } + + private void clearCdnCache() { + if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblClearCdnImageCacheConfirm"))) { + return; + } + CdnUuidCache.clearCache(); + SOptionPane.showMessageDialog(localizer.getMessage("lblCdnImageCacheCleared")); + } + + /** Resolves CDN links for every set at once from Scryfall's bulk data export, instead of one set at a time. */ + private void startBulkSync() { + if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblSyncBulkCardDataConfirm"))) { + return; + } + btnDownload.setEnabled(false); + btnSyncBulkData.setEnabled(false); + btnClearCdnCache.setEnabled(false); + + bulkSyncProgress.reset(); + bulkSyncProgress.setMaximum(100); + bulkSyncProgress.setShowETA(false); + bulkSyncProgress.setShowCount(false); + bulkSyncProgress.setIndeterminate(true); + bulkSyncProgress.setDescription("Starting..."); + + FThreads.invokeInBackgroundThread(() -> { + int setCount = ScryfallBulkDataSync.sync( + (message, fraction) -> FThreads.invokeInEdtLater(() -> { + bulkSyncProgress.setDescription(message); + if (fraction >= 0) { + bulkSyncProgress.setIndeterminate(false); + bulkSyncProgress.setValue((int) Math.round(fraction * 100)); + } else { + bulkSyncProgress.setIndeterminate(true); + } + }), + () -> false); + FThreads.invokeInEdtLater(() -> { + btnDownload.setEnabled(true); + btnSyncBulkData.setEnabled(true); + btnClearCdnCache.setEnabled(true); + bulkSyncProgress.setIndeterminate(false); + if (setCount >= 0) { + bulkSyncProgress.setValue(100); + bulkSyncProgress.setDescription(localizer.getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets)"); + scheduleStatsUpdate(); + } else { + bulkSyncProgress.setDescription("Bulk sync failed -- see log for details."); + } + }); + }); + } + + // ========================================================================= + // "Choose Sets" picker (mirrors DialogChooseSets, minus its randomizer/format extras) + // ========================================================================= + + private void openChooseSets() { + final FCheckBoxTree checkBoxTree = new FCheckBoxTree(); + checkBoxTree.setOpaque(false); + + final TreeMap> treeData = new TreeMap<>(); + for (Map.Entry> entry : FModel.getMagicDb().getEditionsTypeMap().entrySet()) { + if (entry.getValue().isEmpty()) { continue; } + final List nodes = new ArrayList<>(); + for (CardEdition ce : entry.getValue()) { + nodes.add(new FTreeNodeData(ce, ce.getName(), ce.getCode())); + } + treeData.put(new FTreeNodeData(entry.getKey()), nodes); + } + checkBoxTree.setTreeData(treeData); + + final FPanel panel = new FPanel(new MigLayout("insets 10, gap 5, wrap, center")); + panel.setOpaque(false); + panel.setBackgroundTexture(FSkin.getIcon(FSkinProp.BG_TEXTURE)); + panel.add(new FLabel.Builder().text(localizer.getMessage("lblChooseSets")) + .fontSize(18).fontStyle(Font.BOLD).fontAlign(SwingConstants.CENTER).build(), "center, w 500!, gaptop 5"); + panel.add(new FScrollPane(checkBoxTree, true), "w 500!, h 400!, gaptop 10"); + + final FButton btnOk = new FButton(localizer.getMessage("lblOK")); + final FButton btnCancel = new FButton(localizer.getMessage("lblCancel")); + btnOk.addActionListener(e -> { + final List codes = new ArrayList<>(); + for (Object v : checkBoxTree.getCheckedValues(true)) { + codes.add(((CardEdition) v).getCode()); + } + showMainOverlay(); + if (!codes.isEmpty()) { + selectedFormat = new GameFormat(null, codes, null); + scheduleStatsUpdate(); + } + }); + btnCancel.addActionListener(e -> showMainOverlay()); + + final JPanel southPanel = new JPanel(new MigLayout("insets 10, gap 30, ax center")); + southPanel.setOpaque(false); + southPanel.add(btnOk, "w 200!, h 30!"); + southPanel.add(btnCancel, "w 200!, h 30!"); + panel.add(southPanel, "gaptop 10, gapbottom 5"); + + final JPanel overlay = FOverlay.SINGLETON_INSTANCE.getPanel(); + overlay.removeAll(); + overlay.setLayout(new MigLayout("insets 0, gap 0, wrap, ax center, ay center")); + overlay.add(panel); + SOverlayUtils.showOverlay(); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java index efbc86de486e..f8e1fc92a3f4 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java @@ -55,6 +55,7 @@ public enum VSubmenuDownloaders implements IVSubmenu { private final FScrollPane scrContent = new FScrollPane(pnlContent, false); private final FLabel btnCheckForUpdates = _makeButton(localizer.getMessage("btnCheckForUpdates")); + private final FLabel btnDownloadCardImages = _makeButton(localizer.getMessage("btnDownloadCardImages")); private final FLabel btnDownloadSetPics = _makeButton(localizer.getMessage("btnDownloadSetPics")); private final FLabel btnDownloadPics = _makeButton(localizer.getMessage("btnDownloadPics")); private final FLabel btnDownloadPicsHQ = _makeButton(localizer.getMessage("btnDownloadPicsHQ")); @@ -85,6 +86,9 @@ public enum VSubmenuDownloaders implements IVSubmenu { pnlContent.add(btnCheckForUpdates, constraintsBTN); pnlContent.add(_makeLabel(localizer.getMessage("lblCheckForUpdates")), constraintsLBL); + pnlContent.add(btnDownloadCardImages, constraintsBTN); + pnlContent.add(_makeLabel(localizer.getMessage("btnDownloadCardImages")), constraintsLBL); + // pnlContent.add(btnDownloadPics, constraintsBTN); // pnlContent.add(_makeLabel(localizer.getMessage("lblDownloadPics")), constraintsLBL); // @@ -145,6 +149,7 @@ public EMenuGroup getGroupEnum() { } public void setCheckForUpdatesCommand(UiCommand command) { btnCheckForUpdates.setCommand(command); } + public void setDownloadCardImagesCommand(UiCommand command) { btnDownloadCardImages.setCommand(command); } public void setDownloadPicsCommand(UiCommand command) { btnDownloadPics.setCommand(command); } public void setDownloadPicsHQCommand(UiCommand command) { btnDownloadPicsHQ.setCommand(command); } public void setDownloadSetPicsCommand(UiCommand command) { btnDownloadSetPics.setCommand(command); } 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 6fc1346d02e3..bd52857a80e2 100644 --- a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java +++ b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java @@ -36,7 +36,7 @@ private boolean doFetch(String urlToDownload) throws IOException { return false; } - if (inScryfallCooldown(urlToDownload)) { + if (ScryfallRateLimiter.shouldSkip(urlToDownload)) { return false; } @@ -48,7 +48,7 @@ private boolean doFetch(String urlToDownload) throws IOException { 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); - paceScryfall(urlToDownload); + ScryfallRateLimiter.acquire(urlToDownload); // Read through a connection rather than ImageIO.read(URL), which discards the response // code - without it a 429 is indistinguishable from any other failure and we keep asking. @@ -60,9 +60,9 @@ private boolean doFetch(String urlToDownload) throws IOException { if (responseCode != HttpURLConnection.HTTP_OK) { System.err.println("Failed to fetch image. HTTP code: " + responseCode + " (" + httpConnection.getResponseMessage() + ") for URL: " + urlToDownload); - if (responseCode == 429 && isScryfall(urlToDownload)) { - System.err.println("Rate limited by scryfall. Pausing image downloads."); - noteScryfallRateLimited(); + if (responseCode == 429 && ScryfallRateLimiter.isApiUrl(urlToDownload)) { + long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(httpConnection.getHeaderField("Retry-After")); + ScryfallRateLimiter.noteRateLimited(urlToDownload, retryAfter); } httpConnection.disconnect(); return false; diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java index bdd1559f0106..38c7f13145ba 100644 --- a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java @@ -219,7 +219,7 @@ public void sync_neverOverwritesExistingPrecomputedEntry() { } @Test - public void searchQuery_scopesToSetAndAllLanguages() { + public void searchQuery_scopesToSetAndEnglish() { pages.put(1, page(false, 1, singleFaced("11111111-2222-3333-4444-555555555555", "1", "en"))); resolveAfterSync("ltr", "1", "en", "front", "normal"); @@ -227,7 +227,10 @@ public void searchQuery_scopesToSetAndAllLanguages() { Assert.assertNotNull(lastQuery); String decoded = URLDecoder.decode(lastQuery, StandardCharsets.UTF_8); Assert.assertTrue(decoded.contains("set:ltr"), "query should scope to the requested set: " + decoded); - Assert.assertTrue(decoded.contains("lang:any"), "query should request all languages: " + decoded); + // Scoped to English only -- CdnUuidCache.getCdnUrl() always falls back to "en" and + // virtually every edition is English, so fetching every printed language (lang:any) + // multiplied API calls ~10x and tripped Scryfall's rate limit before a set finished syncing. + Assert.assertTrue(decoded.contains("lang:en"), "query should scope to English: " + decoded); } // ------------------------------------------------------------------------- diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java index c8eaa77a2060..19aeb4eaf8ce 100644 --- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java +++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java @@ -11,6 +11,7 @@ import forge.gui.FThreads; import forge.gui.download.CdnUuidCache; import forge.gui.download.GuiDownloadFilteredCardImages; +import forge.gui.download.ScryfallBulkDataSync; import forge.gui.util.SOptionPane; import forge.item.PaperCard; import forge.itemmanager.SFilterUtil; @@ -39,6 +40,8 @@ public class CardImageBrowserScreen extends FScreen { private final FLabel lblDownloaded; private final FLabel lblMissing; private final FButton btnDownload; + private final FButton btnSyncBulkData; + private final FProgressBar bulkSyncProgress; private final FButton btnClearCdnCache; private GameFormat selectedFormat = null; @@ -118,6 +121,11 @@ public CardImageBrowserScreen() { btnDownload = add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages"))); btnDownload.setCommand(e -> startDownload()); + // ── Bulk data sync button ──────────────────────────────────────────── + btnSyncBulkData = add(new FButton(Forge.getLocalizer().getMessage("btnSyncBulkCardData"))); + btnSyncBulkData.setCommand(e -> startBulkSync()); + bulkSyncProgress = add(new FProgressBar()); + // ── Clear CDN cache button ───────────────────────────────────────────── btnClearCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache"))); btnClearCdnCache.setCommand(e -> clearCdnCache()); @@ -173,16 +181,72 @@ private void startDownload() { }).show(); } + // ========================================================================= + // Bulk data sync: resolve CDN links for every set at once + // ========================================================================= + + private void startBulkSync() { + // SOptionPane.showConfirmDialog() blocks its caller while the dialog renders on the EDT, + // so it must never be called directly from a tap handler (which runs on the EDT itself) + // -- that throws immediately and the whole method aborts before any UI update happens. + FThreads.invokeInBackgroundThread(() -> { + if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblSyncBulkCardDataConfirm"))) { + return; + } + + FThreads.invokeInEdtLater(() -> { + btnDownload.setEnabled(false); + btnSyncBulkData.setEnabled(false); + btnClearCdnCache.setEnabled(false); + bulkSyncProgress.reset(); + bulkSyncProgress.setMaximum(100); + bulkSyncProgress.setShowETA(false); + bulkSyncProgress.setShowCount(false); + bulkSyncProgress.setDescription("Starting..."); + bulkSyncProgress.setShowProgressTrail(true); + }); + + int setCount = ScryfallBulkDataSync.sync( + (message, fraction) -> FThreads.invokeInEdtLater(() -> { + bulkSyncProgress.setDescription(message); + if (fraction >= 0) { + bulkSyncProgress.setShowProgressTrail(false); + bulkSyncProgress.setValue((int) Math.round(fraction * 100)); + } else { + bulkSyncProgress.setShowProgressTrail(true); + } + }), + () -> false); + FThreads.invokeInEdtLater(() -> { + btnDownload.setEnabled(true); + btnSyncBulkData.setEnabled(true); + btnClearCdnCache.setEnabled(true); + bulkSyncProgress.setShowProgressTrail(false); + if (setCount >= 0) { + bulkSyncProgress.setValue(100); + bulkSyncProgress.setDescription(Forge.getLocalizer().getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets)"); + scheduleStatsUpdate(); + } else { + bulkSyncProgress.setDescription("Bulk sync failed -- see log for details."); + } + }); + }); + } + // ========================================================================= // CDN image lookup cache: clear // ========================================================================= private void clearCdnCache() { - if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblClearCdnImageCacheConfirm"))) { - return; - } - CdnUuidCache.clearCache(); - SOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblCdnImageCacheCleared")); + // Same EDT restriction as startBulkSync() -- must not call SOptionPane directly from a + // tap handler. + FThreads.invokeInBackgroundThread(() -> { + if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblClearCdnImageCacheConfirm"))) { + return; + } + CdnUuidCache.clearCache(); + SOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblCdnImageCacheCleared")); + }); } // ========================================================================= @@ -228,6 +292,12 @@ protected void doLayout(float startY, float width, float height) { btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); y += BTN_HEIGHT + PADDING; + btnSyncBulkData.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + bulkSyncProgress.setBounds(x, y, w, STAT_HEIGHT * 0.8f); + y += STAT_HEIGHT * 0.8f + PADDING; + btnClearCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); } diff --git a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java index 7e8c17cecccf..0e6d1c84e0c6 100644 --- a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java +++ b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java @@ -55,7 +55,7 @@ private boolean doFetch(String urlToDownload) throws IOException { return false; } - if (inScryfallCooldown(urlToDownload)) { + if (ScryfallRateLimiter.shouldSkip(urlToDownload)) { return false; } @@ -68,6 +68,7 @@ private boolean doFetch(String urlToDownload) throws IOException { 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); + ScryfallRateLimiter.acquire(urlToDownload); HttpURLConnection c = (HttpURLConnection) url.openConnection(); c.setRequestProperty("Accept", "*/*"); c.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); @@ -80,14 +81,14 @@ private boolean doFetch(String urlToDownload) throws IOException { System.out.println("HTTP Response: " + responseCode + " " + responseMessage + " for URL: " + urlToDownload); if (responseCode != HttpURLConnection.HTTP_OK) { System.err.println("Failed to fetch image. HTTP code: " + responseCode + " (" + responseMessage + ") for URL: " + urlToDownload); - c.disconnect(); - if (responseCode == 429) { - System.err.println("Device has been rate limited. Adding reduction of download attempts for this device."); + if (responseCode == 429 && ScryfallRateLimiter.isApiUrl(urlToDownload)) { Sentry.captureMessage("Device has been rate limited. Adding reduction of download attempts for this device. " + urlToDownload); - noteScryfallRateLimited(); + long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(c.getHeaderField("Retry-After")); + ScryfallRateLimiter.noteRateLimited(urlToDownload, retryAfter); } + c.disconnect(); return false; } @@ -176,12 +177,6 @@ public void run() { } } } - } finally { - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } } } if (!success) { diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 0a369353a266..6ca6caa9cd87 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -331,6 +331,9 @@ btnClearCdnImageCache=Clear CDN Image Lookup Cache lblClearCdnImageCache=Delete the local CDN image lookup cache. lblClearCdnImageCacheConfirm=This deletes the local CDN image lookup cache. Forge will re-fetch it as needed. Continue? lblCdnImageCacheCleared=CDN image lookup cache cleared. +btnSyncBulkCardData=Download Bulk (~350MB) +lblSyncBulkCardDataConfirm=This downloads Scryfall''s full card database (~350 MB) to resolve every card''s CDN image link at once, instead of one set at a time. Continue? +lblBulkCardDataSynced=Card data synced. lblDownloadCJKFonts=Download fonts for locales using CJK glyphs. lblDownloadCJKFontPrompt=Select a font file to download lblYourVersionOfJavaIsTooOld=Your version of Java is too old to use the content downloaders. diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 429c07e8aac3..ed22b863187c 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -82,6 +82,15 @@ static String cacheDir() { return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR; } + /** + * Whether {@code scryfallCode} already has a local cache file, from either the per-set sync + * or a bulk-data sync. Lets a caller about to warm the cache skip a set that's already warm + * instead of unconditionally re-fetching it. + */ + public static boolean isSetCached(String scryfallCode) { + return scryfallCode != null && localCacheFile(scryfallCode.toLowerCase()).exists(); + } + /** Deletes every local cache file and clears the in-memory cache. */ public static void clearCache() { setCache.clear(); @@ -145,6 +154,47 @@ private static synchronized void recordMiss(String setCode, String cn, String la setCache.remove(setCode); } + /** + * Read-only counterpart to {@link #getCdnUrl}: returns a CDN URL only if {@code scryfallCode} + * has already been synced (by the bulk downloader), and never has the side effect of queuing + * a sync or recording a miss. Safe to call from the interactive gameplay image-fetch path, + * which must never silently kick off a full multi-page Scryfall search just because a card + * happens to be missing -- that's real, unbounded background API traffic triggered by + * ordinary play, not a deliberate, cancelable, user-initiated bulk operation. + */ + public static String getCdnUrlIfCached(String scryfallCode, String collectorNum, + String lang, String face, String size) { + if (scryfallCode == null || collectorNum == null) return null; + String setCode = scryfallCode.toLowerCase(); + + Map> cardMap = ensureSetLoadedReadOnly(setCode); + if (cardMap == MISSING_SET) return null; + + LangUuids uuids = resolveWithFallback(cardMap, collectorNum, lang); + if (uuids == null) return null; + + boolean wantBack = "back".equals(face); + String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; + String side = wantBack ? "back" : "front"; + return cdnUrl(uuid, side, size); + } + + /** + * Like {@link #ensureSetLoaded}, but never queues a sync on a miss -- so a set that hasn't + * been synced yet stays retryable (e.g. once the bulk downloader finishes it) instead of + * either triggering background work or getting permanently cached as absent. + */ + private static Map> ensureSetLoadedReadOnly(String setCode) { + Map> cached = setCache.get(setCode); + if (cached != null) return cached; + + Map> onDisk = readSetFromDisk(setCode); + if (onDisk == MISSING_SET) return MISSING_SET; + + Map> existing = setCache.putIfAbsent(setCode, onDisk); + return existing != null ? existing : onDisk; + } + /** A JSON string or array value is real data; an object (miss record) or absent value is not. */ private static boolean isRealEntry(JsonElement value) { return value != null && !value.isJsonObject(); 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 8a26ed5670d0..bfc5659adbc6 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -8,12 +8,16 @@ import forge.localinstance.properties.ForgeConstants; import forge.model.FModel; import forge.util.ImageUtil; +import forge.util.ScryfallRateLimiter; import forge.util.TextUtil; import org.apache.commons.lang3.StringUtils; import java.io.File; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.TreeSet; import java.util.function.Predicate; /** @@ -37,6 +41,8 @@ public String getTitle() { protected Map getNeededFiles() { final Map downloads = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + final List matches = new ArrayList<>(); + final TreeSet scryfallSetCodes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); for (final PaperCard c : Iterables.concat( FModel.getMagicDb().getCommonCards().getAllCards(), FModel.getMagicDb().getVariantCards().getAllCards())) { @@ -46,6 +52,46 @@ protected Map getNeededFiles() { final String setCode3 = c.getEdition(); if (StringUtils.isBlank(setCode3) || CardEdition.UNKNOWN_CODE.equals(setCode3)) { continue; } + matches.add(c); + CardEdition edition = StaticData.instance().getEditions().get(setCode3); + if (edition != null && !StringUtils.isBlank(edition.getScryfallCode())) { + scryfallSetCodes.add(edition.getScryfallCode()); + } + } + + // Warm the CDN UUID cache for every needed set before resolving URLs below, so this run + // uses the unthrottled CDN path instead of falling back to the rate-limited Scryfall API + // just because the cache hadn't finished populating yet. Skip a set that's already + // cached (e.g. from a bulk-data sync via ScryfallBulkDataSync) instead of unconditionally + // re-fetching it through the rate-limited /cards/search endpoint -- otherwise the bulk + // sync's whole point (avoiding that endpoint) is defeated on every subsequent download. + final List needSync = new ArrayList<>(); + for (String setCode : scryfallSetCodes) { + if (!CdnUuidCache.isSetCached(setCode)) { + needSync.add(setCode); + } + } + if (needSync.isEmpty() && !scryfallSetCodes.isEmpty()) { + reportStatus("All " + scryfallSetCodes.size() + " needed sets already cached."); + } else if (!needSync.isEmpty()) { + int alreadyCached = scryfallSetCodes.size() - needSync.size(); + if (alreadyCached > 0) { + reportStatus(alreadyCached + "/" + scryfallSetCodes.size() + " sets already cached; syncing the remaining " + needSync.size() + "..."); + } + } + + int setIndex = 0; + for (String setCode : needSync) { + if (cancel) break; + setIndex++; + reportStatus("Syncing card data from Scryfall: set " + setIndex + "/" + needSync.size() + + " (" + setCode.toUpperCase() + ")..."); + ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); + if (cancel) break; + ScryfallSetSync.sync(setCode); + } + + for (final PaperCard c : matches) { addIfMissing(c, "", downloads); if (c.hasBackFace()) { addIfMissing(c, "back", downloads); @@ -86,9 +132,12 @@ private static String buildUrl(PaperCard c, String face) { String scryfallCode = (edition != null) ? edition.getScryfallCode() : null; boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode); - // 1. CDN + // 1. CDN -- read-only: the warm-up loop above is the one deliberate, sequential place + // that syncs sets. If a set's warm-up sync failed to produce data, fall through to the + // API below for this run rather than triggering another uncoordinated background sync + // (see CdnUuidCache.getCdnUrlIfCached()). if (edition != null && hasCollectorNum && hasScryfallCode) { - String cdnUrl = CdnUuidCache.getCdnUrl( + String cdnUrl = CdnUuidCache.getCdnUrlIfCached( scryfallCode, collectorNum, edition.getCardsLangCode(), face, "normal"); if (cdnUrl != null) return cdnUrl; } diff --git a/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java b/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java index 7164a3e923d5..b0c35b1b9b2b 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java @@ -27,8 +27,10 @@ import forge.gui.interfaces.IProgressBar; import forge.gui.interfaces.ITextField; import forge.localinstance.properties.ForgeConstants; +import forge.util.BuildInfo; import forge.util.FileUtil; import forge.util.HttpUtil; +import forge.util.ScryfallRateLimiter; import forge.util.TextUtil; import org.apache.commons.lang3.tuple.Pair; @@ -37,7 +39,6 @@ import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; -import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -122,6 +123,11 @@ protected String getStartOverrideDesc() { return null; } + /** Updates the visible progress description from a background thread. */ + protected void reportStatus(String message) { + FThreads.invokeInEdtLater(() -> progressBar.setDescription(message)); + } + private void readyToStart() { if (files == null) { progressBar.setDescription("Connection error?"); @@ -251,14 +257,24 @@ public void run() { FileOutputStream fos = null; try { final File base = fileDest.getParentFile(); - if (FileUtil.ensureDirectoryExists(base)) { //ensure destination directory exists + if (ScryfallRateLimiter.isApiUrl(url)) { + // Wait out an active cooldown rather than skipping -- this is a background, + // cancelable bulk run, so it's worth pausing to actually finish downloading + // instead of racing through the rest of a large queue in skip-only mode. + ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); + } + if (cancel) { + // cancelled while waiting out a cooldown + } else if (FileUtil.ensureDirectoryExists(base)) { //ensure destination directory exists URL imageUrl = new URL(url); HttpURLConnection conn = (HttpURLConnection) imageUrl.openConnection(p); + // Scryfall asks for a descriptive User-Agent and rate-limits harder without one. + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); // don't allow redirections here -- they indicate 'file not found' on the server // only allow redirections to consume Scryfall API - if(url.contains("api.scryfall.com")) { + if (ScryfallRateLimiter.isApiUrl(url)) { conn.setInstanceFollowRedirects(true); - TimeUnit.MILLISECONDS.sleep(100); + ScryfallRateLimiter.acquire(url); } else { conn.setInstanceFollowRedirects(false); } @@ -310,6 +326,13 @@ public void run() { if(url.contains("/images/") && !isJPG && !isLogged) System.out.println("File not found: .." + url.substring(url.lastIndexOf("/images/")+1)); break; + case 429: + if (ScryfallRateLimiter.isApiUrl(url)) { + long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(conn.getHeaderField("Retry-After")); + ScryfallRateLimiter.noteRateLimited(url, retryAfter); + } + conn.disconnect(); + break; default: conn.disconnect(); System.out.println(" Connection failed for url: " + url); diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java new file mode 100644 index 000000000000..201969bb7ee0 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java @@ -0,0 +1,284 @@ +package forge.gui.download; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import forge.util.BuildInfo; +import forge.util.ScryfallRateLimiter; +import org.tinylog.Logger; + +import java.io.BufferedReader; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BooleanSupplier; +import java.util.zip.GZIPInputStream; + +/** + * Warms {@link CdnUuidCache} for every set in one pass using Scryfall's Bulk Data API + * (https://scryfall.com/docs/api/bulk-data), instead of one paginated {@code /cards/search} + * request per set via {@link ScryfallSetSync}. A format like Standard touches dozens of sets + * full of reprints; resolving each of those sets individually means dozens of paced (500ms) + * search requests just to build CDN UUIDs Scryfall already publishes in one file. The + * "default_cards" bulk file -- one row per print, in English or the card's sole language -- is a + * single download from a direct *.scryfall.io file origin, which Scryfall documents as having no + * rate limit at all, and covers the whole catalog at once. + */ +public final class ScryfallBulkDataSync { + private static final String BULK_DATA_LISTING_URL = "https://api.scryfall.com/bulk-data"; + private static final String BULK_DATA_TYPE = "default_cards"; + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int READ_TIMEOUT_MS = 60_000; + + /** Override for tests; must be a full URL to a bulk-data listing JSON document. */ + static volatile String listingUrlOverride = null; + + private ScryfallBulkDataSync() {} + + public interface ProgressListener { + /** @param fractionDone 0.0-1.0, or -1 if not yet known (e.g. before a Content-Length is available) */ + void onProgress(String message, double fractionDone); + } + + /** + * Downloads and parses the "default_cards" bulk file, merging every print into + * {@link CdnUuidCache}. Returns the number of sets written, or -1 on failure/cancellation + * before any data was merged. + */ + public static int sync(ProgressListener progress, BooleanSupplier cancelled) { + String downloadUrl = findDefaultCardsUrl(progress); + if (downloadUrl == null || cancelled.getAsBoolean()) { + return -1; + } + + final Map>> bySet = new HashMap<>(); + try { + streamAndAccumulate(downloadUrl, bySet, progress, cancelled); + } catch (IOException e) { + Logger.error(e, "ScryfallBulkDataSync: failed to download/parse bulk data"); + if (progress != null) { + progress.onProgress("Bulk data download failed: " + e.getMessage(), -1); + } + return -1; + } + + if (cancelled.getAsBoolean()) { + return -1; + } + + if (progress != null) { + progress.onProgress("Writing " + bySet.size() + " sets to local cache...", -1); + } + int setCount = 0; + for (Map.Entry>> e : bySet.entrySet()) { + if (cancelled.getAsBoolean()) { + break; + } + CdnUuidCache.mergeSetEntriesWithFaces(e.getKey(), e.getValue()); + setCount++; + } + if (progress != null) { + progress.onProgress("Synced " + setCount + " sets from Scryfall bulk data.", 1.0); + } + return setCount; + } + + // ------------------------------------------------------------------------- + + private static String findDefaultCardsUrl(ProgressListener progress) { + String url = listingUrlOverride != null ? listingUrlOverride : BULK_DATA_LISTING_URL; + if (progress != null) { + progress.onProgress("Looking up Scryfall bulk data files...", -1); + } + try { + ScryfallRateLimiter.acquire(url); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(CONNECT_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); + conn.connect(); + + int status = conn.getResponseCode(); + if (status == 429) { + if (ScryfallRateLimiter.isApiUrl(url)) { + long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(conn.getHeaderField("Retry-After")); + ScryfallRateLimiter.noteRateLimited(url, retryAfter); + } + return null; + } + if (status != 200) { + Logger.error("ScryfallBulkDataSync: bulk-data listing returned HTTP {}", status); + return null; + } + + JsonObject root; + try (InputStream is = conn.getInputStream(); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + root = JsonParser.parseReader(reader).getAsJsonObject(); + } + JsonArray data = root.has("data") ? root.getAsJsonArray("data") : new JsonArray(); + for (var el : data) { + JsonObject obj = el.getAsJsonObject(); + if (!obj.has("type") || !BULK_DATA_TYPE.equals(obj.get("type").getAsString())) { + continue; + } + if (obj.has("jsonl_download_uri")) { + return obj.get("jsonl_download_uri").getAsString(); + } + if (obj.has("download_uri")) { + return obj.get("download_uri").getAsString(); + } + } + Logger.error("ScryfallBulkDataSync: no '{}' entry found in bulk-data listing", BULK_DATA_TYPE); + return null; + } catch (IOException e) { + Logger.error(e, "ScryfallBulkDataSync: failed to fetch bulk-data listing"); + return null; + } + } + + private static void streamAndAccumulate(String downloadUrl, Map>> bySet, + ProgressListener progress, BooleanSupplier cancelled) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(downloadUrl).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); + // downloadUrl is a *.scryfall.io direct file origin -- Scryfall documents these as having + // no rate limit, and ScryfallRateLimiter.isApiUrl() correctly excludes this host, so no + // acquire() call here (matches how CDN image URLs are already treated). + conn.connect(); + + int status = conn.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new IOException("HTTP " + status + " for " + downloadUrl); + } + final long totalBytes = conn.getContentLengthLong(); + final long[] bytesRead = {0}; + final long[] cardsSeen = {0}; + + InputStream counting = new FilterInputStream(conn.getInputStream()) { + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n > 0) { + bytesRead[0] += n; + } + return n; + } + + @Override + public int read() throws IOException { + int n = super.read(); + if (n >= 0) { + bytesRead[0]++; + } + return n; + } + }; + + try (InputStream gz = new GZIPInputStream(counting); + BufferedReader reader = new BufferedReader(new InputStreamReader(gz, StandardCharsets.UTF_8))) { + String line; + long lastReportAt = 0; + while ((line = reader.readLine()) != null) { + if (cancelled.getAsBoolean()) { + return; + } + if (line.isBlank()) { + continue; + } + + JsonObject card; + try { + card = JsonParser.parseString(line).getAsJsonObject(); + } catch (Exception e) { + continue; // skip a malformed line rather than aborting the whole sync + } + addCard(bySet, card); + cardsSeen[0]++; + + long now = System.currentTimeMillis(); + if (progress != null && now - lastReportAt >= 500) { + lastReportAt = now; + double fraction = totalBytes > 0 ? Math.min(1.0, bytesRead[0] / (double) totalBytes) : -1; + String pct = totalBytes > 0 ? Math.round(fraction * 100) + "%" : (bytesRead[0] / 1_000_000) + " MB"; + progress.onProgress("Downloading Scryfall bulk card data: " + pct + + " (" + cardsSeen[0] + " cards processed)...", fraction); + } + } + } + } + + private static void addCard(Map>> bySet, JsonObject card) { + String setCode = str(card, "set"); + String cn = str(card, "collector_number"); + String lang = str(card, "lang"); + if (setCode == null || cn == null || lang == null) { + return; + } + + String[] frontBack = frontBackUuids(card); + if (frontBack == null) { + return; + } + + bySet.computeIfAbsent(setCode, k -> new HashMap<>()) + .computeIfAbsent(cn, k -> new HashMap<>()) + .put(lang, frontBack); + } + + /** Per-face CDN UUIDs for one card, or {@code null} if it has no usable image data yet. */ + private static String[] frontBackUuids(JsonObject card) { + String front; + String back = null; + if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { + front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris"))); + } else if (card.has("card_faces") && card.get("card_faces").isJsonArray()) { + JsonArray faces = card.getAsJsonArray("card_faces"); + if (faces.isEmpty()) { + return null; + } + JsonObject face0 = faces.get(0).getAsJsonObject(); + if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) { + return null; + } + front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris"))); + if (faces.size() > 1) { + JsonObject face1 = faces.get(1).getAsJsonObject(); + if (face1.has("image_uris") && face1.get("image_uris").isJsonObject()) { + back = uuidFromUrl(normalUrl(face1.getAsJsonObject("image_uris"))); + } + } + } else { + return null; + } + return front == null ? null : new String[]{front, back}; + } + + private static String normalUrl(JsonObject imageUris) { + return imageUris.has("normal") ? imageUris.get("normal").getAsString() : null; + } + + private static String uuidFromUrl(String url) { + if (url == null || !url.contains("cards.scryfall.io")) { + return null; + } + int qmark = url.indexOf('?'); + String path = qmark >= 0 ? url.substring(0, qmark) : url; + int slash = path.lastIndexOf('/'); + String filename = slash >= 0 ? path.substring(slash + 1) : path; + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(0, dot) : filename; + } + + private static String str(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; + } +} diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java index 6486c274d9bb..59f3794b7ebf 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -4,6 +4,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import forge.util.BuildInfo; +import forge.util.ScryfallRateLimiter; import org.tinylog.Logger; import java.io.IOException; @@ -28,9 +30,7 @@ final class ScryfallSetSync { private static final String DEFAULT_SEARCH_URL = "https://api.scryfall.com/cards/search"; - /** Scryfall's general API guidance: 50-100ms between requests. */ - private static final long REQUEST_INTERVAL_MS = 100; - private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int CONNECT_TIMEOUT_MS = 15_000; /** Override for tests; must be a full base URL with no trailing query string. */ static volatile String searchBaseUrlOverride = null; @@ -46,6 +46,8 @@ static boolean sync(String setCode) { String url = searchUrl(setCode); try { while (url != null) { + if (ScryfallRateLimiter.isCoolingDown()) break; // back off; a later sync retries + JsonObject page = fetchJson(url); if (page == null) break; // 404: no such set / no matching cards @@ -56,7 +58,6 @@ static boolean sync(String setCode) { boolean hasMore = page.has("has_more") && page.get("has_more").getAsBoolean(); url = hasMore && page.has("next_page") ? page.get("next_page").getAsString() : null; - if (url != null) Thread.sleep(REQUEST_INTERVAL_MS); } } catch (Exception e) { Logger.debug("ScryfallSetSync: could not build set '{}' from Scryfall: {}", setCode, e.getMessage()); @@ -119,7 +120,10 @@ private static String uuidFromUrl(String url) { private static String searchUrl(String setCode) { String base = searchBaseUrlOverride != null ? searchBaseUrlOverride : DEFAULT_SEARCH_URL; - String query = "set:" + setCode + " lang:any"; + // English only: CdnUuidCache.getCdnUrl() always falls back to "en", and virtually every + // edition's cards are English, so "lang:any" fetched ~10x more pages (every printed + // language) than needed and was tripping Scryfall's rate limit before finishing a set. + String query = "set:" + setCode + " lang:en"; return base + "?unique=prints&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); } @@ -129,15 +133,25 @@ private static String str(JsonObject obj, String key) { /** Fetches {@code urlStr} and parses it as JSON, or returns {@code null} for HTTP 404. */ private static JsonObject fetchJson(String urlStr) throws IOException { + ScryfallRateLimiter.acquire(urlStr); URLConnection conn = new URL(urlStr).openConnection(); conn.setConnectTimeout(CONNECT_TIMEOUT_MS); conn.setReadTimeout(CONNECT_TIMEOUT_MS); conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("Accept-Encoding", "gzip"); + // Scryfall asks for a descriptive User-Agent and rate-limits harder without one. + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); conn.connect(); if (conn instanceof HttpURLConnection) { int status = ((HttpURLConnection) conn).getResponseCode(); if (status == 404) return null; + if (status == 429) { + if (ScryfallRateLimiter.isApiUrl(urlStr)) { + long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(conn.getHeaderField("Retry-After")); + ScryfallRateLimiter.noteRateLimited(urlStr, retryAfter); + } + throw new IOException("HTTP 429 (rate limited) for " + urlStr); + } if (status != 200) throw new IOException("HTTP " + status + " for " + urlStr); } boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding()); diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index 91831becbe31..c87544aea1dd 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -14,7 +14,6 @@ import java.io.File; import java.util.*; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; public abstract class ImageFetcher { @@ -22,72 +21,8 @@ public abstract class ImageFetcher { // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes private static final HashMap langCodeMap = new HashMap<>(); protected static final boolean disableHostedDownload = true; - protected static volatile Date scryfallCooldownTime = null; private static final HashSet fetching = new HashSet<>(); - /** Minimum gap between Scryfall requests, matching what GuiDownloadService already keeps. */ - private static final long SCRYFALL_MIN_INTERVAL_MS = 100; - private static final long SCRYFALL_COOLDOWN_MINUTES = 5; - private static final Object scryfallPacing = new Object(); - private static long lastScryfallRequest = 0; - - protected static boolean isScryfall(final String url) { - return url != null && url.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD); - } - - /** Whether we are still backing off after Scryfall rate limited us. Clears an expired cooldown. */ - protected static boolean scryfallCoolingDown() { - final Date cooldown = scryfallCooldownTime; - if (cooldown == null) { - return false; - } - if (cooldown.after(new Date())) { - return true; - } - scryfallCooldownTime = null; - return false; - } - - /** - * Whether this particular download should be skipped because we are backing off Scryfall, so - * callers can simply skip the fetch while this returns true. - */ - protected static boolean inScryfallCooldown(final String url) { - if (!isScryfall(url) || !scryfallCoolingDown()) { - return false; - } - System.err.println("Currently in cooldown period for scryfall downloads. Skipping download attempt for: " + url); - return true; - } - - /** Record that Scryfall returned 429, so we stop asking for a while. */ - protected static void noteScryfallRateLimited() { - scryfallCooldownTime = new Date(System.currentTimeMillis() - + TimeUnit.MINUTES.toMillis(SCRYFALL_COOLDOWN_MINUTES)); - } - - /** - * Space Scryfall requests out. Downloads run on a work stealing pool, so without this a screen - * full of missing images asks for all of them at once. Called from the download task, never the - * EDT. - */ - protected static void paceScryfall(final String url) { - if (!isScryfall(url)) { - return; - } - synchronized (scryfallPacing) { - final long wait = lastScryfallRequest + SCRYFALL_MIN_INTERVAL_MS - System.currentTimeMillis(); - if (wait > 0) { - try { - Thread.sleep(wait); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - lastScryfallRequest = System.currentTimeMillis(); - } - } - static { langCodeMap.put("en-US", "en"); langCodeMap.put("es-ES", "es"); @@ -141,10 +76,13 @@ private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, Arr String setCode = edition.getScryfallCode(); String langCode = edition.getCardsLangCode(); - // Prefer CDN (no rate limit) if CdnUuidCache has a UUID for this card. + // Prefer CDN (no rate limit) if this set was already synced by the bulk downloader. + // Read-only lookup: gameplay must never have the side effect of queuing a full-set + // Scryfall search just because a card's image happens to be missing -- see + // CdnUuidCache.getCdnUrlIfCached() for why. if (!StringUtils.isBlank(setCode)) { String size = useArtCrop ? "art_crop" : "normal"; - String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrl( + String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrlIfCached( setCode, card.getCollectorNumber(), langCode, face, size); if (cdnUrl != null && !downloadUrls.contains(cdnUrl)) downloadUrls.add(cdnUrl); } @@ -484,7 +422,7 @@ private void setupObserver(final String destPath, final Callback callback, final // the in-flight set below, so a fetch registered during the cooldown would never be retried // once the cooldown lifts. Only when every candidate is Scryfall - otherwise another source // may still serve it. - if (scryfallCoolingDown() && downloadUrls.stream().allMatch(ImageFetcher::isScryfall)) { + if (ScryfallRateLimiter.isCoolingDown() && downloadUrls.stream().allMatch(ScryfallRateLimiter::isApiUrl)) { return; } // Note: No synchronization is needed here because this is executed on diff --git a/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java b/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java new file mode 100644 index 000000000000..0a1039390263 --- /dev/null +++ b/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java @@ -0,0 +1,180 @@ +package forge.util; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * Single global pacing/cooldown gate for Scryfall's api.scryfall.com endpoints (card lookup, + * search, download-by-name). Never applies to cards.scryfall.io CDN URLs -- those are pre-resolved + * and intentionally unthrottled; see CdnUuidCache. + * + *

Scryfall's documented hard per-endpoint-category limits (see + * https://scryfall.com/docs/api): {@code /cards/search}, {@code /cards/named}, + * {@code /cards/random}, and {@code /cards/collection} are capped at 2/sec (500ms); every other + * method is capped at 10/sec (100ms). Forge only ever calls {@code search} (CDN UUID resolution) + * and {@code named} (a legacy fuzzy-name lookup) from the slow bucket -- everything else (the + * per-card {@code /cards/{set}/{cn}/{lang}} lookup) is in the fast bucket. Pacing every request + * at the fast bucket's 100ms, including search, was 5x faster than Scryfall actually allows for + * that endpoint and was the real cause of repeated 429s during bulk CDN-cache warm-up. + */ +public final class ScryfallRateLimiter { + private static final String API_HOST_PREFIX = "https://api.scryfall.com/"; + private static final long SEARCH_INTERVAL_MS = 500; // /cards/search, /cards/named -- 2/sec + private static final long DEFAULT_INTERVAL_MS = 100; // everything else -- 10/sec + /** Fallback only -- Scryfall's 429 response almost always carries a Retry-After header, which takes priority. */ + private static final long DEFAULT_COOLDOWN_SECONDS = 30; + + private static final Object searchPacingLock = new Object(); + private static long lastSearchRequestAt = 0; + private static final Object defaultPacingLock = new Object(); + private static long lastDefaultRequestAt = 0; + private static volatile Date cooldownUntil = null; + + private ScryfallRateLimiter() {} + + /** True for any api.scryfall.com request -- card lookup, search, named, and bulk-data listing. */ + public static boolean isApiUrl(String url) { + return url != null && url.startsWith(API_HOST_PREFIX); + } + + /** Whether {@code url} is in Scryfall's 2/sec-limited bucket (search, named). */ + private static boolean isSlowEndpoint(String url) { + return url != null && (url.contains("/cards/search") || url.contains("/cards/named")); + } + + /** Whether we are still backing off after Scryfall rate limited us. Clears an expired cooldown. */ + public static boolean isCoolingDown() { + final Date until = cooldownUntil; + if (until == null) { + return false; + } + if (until.after(new Date())) { + return true; + } + cooldownUntil = null; + return false; + } + + /** + * Record that Scryfall returned 429, so we stop asking for a while. Always logged (not + * debug-only): this is the one place that explains every subsequent "in cooldown" skip. + * + * @param retryAfterSeconds value of the response's {@code Retry-After} header, or {@code <= 0} + * if absent/unparseable, in which case a short default is used. + */ + public static void noteRateLimited(String triggerUrl, long retryAfterSeconds) { + long seconds = retryAfterSeconds > 0 ? retryAfterSeconds : DEFAULT_COOLDOWN_SECONDS; + cooldownUntil = new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(seconds)); + System.err.println("==== Scryfall returned 429 for: " + triggerUrl + + " -- backing off all api.scryfall.com requests until " + cooldownUntil + " ===="); + } + + /** Parses a {@code Retry-After} header value (seconds) into seconds, or {@code -1} if absent/unparseable. */ + public static long parseRetryAfterSeconds(String headerValue) { + if (headerValue == null) { + return -1; + } + try { + return Long.parseLong(headerValue.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + /** True (and logs) if url is a Scryfall API URL and we're currently backing off. */ + public static boolean shouldSkip(String url) { + if (!isApiUrl(url) || !isCoolingDown()) { + return false; + } + System.err.println("Currently in cooldown period for scryfall downloads. Skipping download attempt for: " + url); + return true; + } + + /** + * Blocks the calling thread until any active cooldown clears, polling {@code cancelled} so a + * long-running background job (the bulk downloader) can still be cancelled while waiting. + * No-op if not currently cooling down. + * + *

Only call this from a background thread that may legitimately block for minutes at a + * time -- never from a gameplay fire-and-forget path (e.g. {@code CdnUuidCache}'s implicit + * cache-miss sync), where failing fast and moving on is the correct behavior. Without this, + * a large multi-set/multi-thousand-file bulk run that trips one 429 early on races through + * its entire remaining backlog in skip-and-print mode -- far faster than the cooldown can + * ever clear -- and ends up downloading nothing for the rest of the run. + */ + public static void awaitCooldownCleared(java.util.function.BooleanSupplier cancelled) { + awaitCooldownCleared(cancelled, null); + } + + /** + * Same as {@link #awaitCooldownCleared(java.util.function.BooleanSupplier)}, but also invokes + * {@code onWaiting} (throttled to roughly once every 5 seconds) with a human-readable status + * string, so a caller with a visible progress UI can show that it's genuinely waiting rather + * than appearing to hang. + */ + public static void awaitCooldownCleared(java.util.function.BooleanSupplier cancelled, + java.util.function.Consumer onWaiting) { + if (!isCoolingDown()) { + return; + } + long lastLoggedAt = 0; + while (isCoolingDown()) { + if (cancelled.getAsBoolean()) { + return; + } + long remainingSec = Math.max(0, cooldownUntil.getTime() - System.currentTimeMillis()) / 1000 + 1; + String message = "Rate limited by Scryfall -- waiting " + remainingSec + "s before continuing..."; + if (onWaiting != null) { + onWaiting.accept(message); + } + long now = System.currentTimeMillis(); + if (now - lastLoggedAt >= 5000) { + System.out.println(" " + message); + lastLoggedAt = now; + } + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + /** + * Blocks the calling thread (never the EDT) until it's this caller's turn. No-op for + * non-API URLs, so CDN/cardforge downloads never pay this cost. Search/named requests pace + * independently from everything else, so a burst of one kind never forces the other to wait + * longer than its own documented limit requires. + */ + public static void acquire(String url) { + if (!isApiUrl(url)) { + return; + } + if (isSlowEndpoint(url)) { + synchronized (searchPacingLock) { + final long wait = lastSearchRequestAt + SEARCH_INTERVAL_MS - System.currentTimeMillis(); + if (wait > 0) { + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastSearchRequestAt = System.currentTimeMillis(); + } + } else { + synchronized (defaultPacingLock) { + final long wait = lastDefaultRequestAt + DEFAULT_INTERVAL_MS - System.currentTimeMillis(); + if (wait > 0) { + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastDefaultRequestAt = System.currentTimeMillis(); + } + } + } +} From a1d12a3d1a47d10160577ced7483ff6349f775ec Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 18 Aug 2026 21:22:02 +0100 Subject: [PATCH 15/18] Add bulk download button+prompt, improve desktop batch image download --- .../settings/DialogDownloadCardImages.java | 10 ++++++ .../src/main/java/forge/view/FView.java | 13 +++++++ forge-gui-mobile/src/forge/Forge.java | 36 +++++++++++++++++++ .../src/forge/screens/SplashScreen.java | 2 ++ .../settings/CardImageBrowserScreen.java | 15 ++++++++ forge-gui/res/languages/de-DE.properties | 4 +++ forge-gui/res/languages/en-US.properties | 1 + forge-gui/res/languages/es-ES.properties | 4 +++ forge-gui/res/languages/fr-FR.properties | 4 +++ forge-gui/res/languages/it-IT.properties | 4 +++ forge-gui/res/languages/ja-JP.properties | 4 +++ forge-gui/res/languages/ko-KR.properties | 4 +++ forge-gui/res/languages/pt-BR.properties | 4 +++ forge-gui/res/languages/ru-RU.properties | 4 +++ forge-gui/res/languages/zh-CN.properties | 4 +++ .../java/forge/gui/download/CdnUuidCache.java | 8 ++++- 16 files changed, 120 insertions(+), 1 deletion(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java index 49b4b8fb153a..7e40b622bcd6 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java @@ -188,11 +188,21 @@ private void clearCdnCache() { SOptionPane.showMessageDialog(localizer.getMessage("lblCdnImageCacheCleared")); } + /** Called after {@link #show()} to open straight into a confirmed bulk sync (e.g. the first-run prompt). */ + public void showAndAutoStartBulkSync() { + show(); + runBulkSync(); + } + /** Resolves CDN links for every set at once from Scryfall's bulk data export, instead of one set at a time. */ private void startBulkSync() { if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblSyncBulkCardDataConfirm"))) { return; } + runBulkSync(); + } + + private void runBulkSync() { btnDownload.setEnabled(false); btnSyncBulkData.setEnabled(false); btnClearCdnCache.setEnabled(false); diff --git a/forge-gui-desktop/src/main/java/forge/view/FView.java b/forge-gui-desktop/src/main/java/forge/view/FView.java index 843d78aa3e24..270dca7c52a1 100644 --- a/forge-gui-desktop/src/main/java/forge/view/FView.java +++ b/forge-gui-desktop/src/main/java/forge/view/FView.java @@ -29,6 +29,8 @@ import forge.Singletons; import forge.gui.ImportDialog; import forge.gui.SOverlayUtils; +import forge.gui.download.CdnUuidCache; +import forge.gui.util.SOptionPane; import forge.gui.framework.DragCell; import forge.gui.framework.EDocID; import forge.gui.framework.FScreen; @@ -41,6 +43,7 @@ import forge.screens.bazaar.VBazaarUI; import forge.screens.deckeditor.VDeckEditorUI; import forge.screens.home.VHomeUI; +import forge.screens.home.settings.DialogDownloadCardImages; import forge.sound.MusicPlaylist; import forge.sound.SoundSystem; import forge.toolbox.CardFaceSymbols; @@ -53,6 +56,7 @@ import forge.toolbox.FScrollPane; import forge.toolbox.FSkin; import forge.toolbox.FSkin.SkinnedLayeredPane; +import forge.util.Localizer; import forge.util.BuildInfo; import net.miginfocom.swing.MigLayout; @@ -233,6 +237,15 @@ public void initialize() { SwingUtilities.invokeLater(btnOk::requestFocusInWindow); }).show(); + } else if (!CdnUuidCache.hasAnyCachedSets()) { + // First run (or cache cleared): offer the one-time bulk CDN sync so later card + // image downloads resolve instantly instead of one Scryfall set at a time. Skipped + // alongside the (rare) legacy-migration prompt above to avoid stacking dialogs. + if (SOptionPane.showConfirmDialog( + Localizer.getInstance().getMessage("lblFirstRunBulkCdnPrompt"), + "Forge", "Download Now", "Not Now", true)) { + new DialogDownloadCardImages().showAndAutoStartBulkSync(); + } } } diff --git a/forge-gui-mobile/src/forge/Forge.java b/forge-gui-mobile/src/forge/Forge.java index 3cccbdd6f210..434ff46c4da8 100644 --- a/forge-gui-mobile/src/forge/Forge.java +++ b/forge-gui-mobile/src/forge/Forge.java @@ -28,7 +28,9 @@ import forge.gamemodes.limited.BoosterDraft; import forge.gui.FThreads; import forge.gui.GuiBase; +import forge.gui.download.CdnUuidCache; import forge.gui.error.BugReporter; +import forge.gui.util.SOptionPane; import forge.interfaces.IDeviceAdapter; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences; @@ -43,6 +45,7 @@ import forge.screens.home.NewGameMenu; import forge.screens.match.MatchController; import forge.screens.match.MatchScreen; +import forge.screens.settings.CardImageBrowserScreen; import forge.sound.MusicPlaylist; import forge.sound.SoundSystem; import forge.toolbox.*; @@ -360,6 +363,29 @@ public static void openHomeScreen(int index, FScreen lastMatch) { System.out.println(fScreen.toString());*/ } + /** + * First run (or cache cleared): offer the one-time bulk CDN sync so later card image + * downloads resolve instantly instead of one Scryfall set at a time. Call only once a real + * home/adventure screen is open -- not while the mode-selector splash is still showing. + */ + public static void maybePromptForBulkCdnSync() { + boolean hasCache = CdnUuidCache.hasAnyCachedSets(); + System.out.println("maybePromptForBulkCdnSync: cacheDir=" + CdnUuidCache.cacheDir() + " hasAnyCachedSets=" + hasCache); + if (hasCache) { + return; + } + System.out.println("maybePromptForBulkCdnSync: showing prompt"); + FThreads.invokeInBackgroundThread(() -> { + boolean confirmed = SOptionPane.showConfirmDialog( + getLocalizer().getMessage("lblFirstRunBulkCdnPrompt"), + "Forge", "Download Now", "Not Now", true); + System.out.println("maybePromptForBulkCdnSync: user answered confirmed=" + confirmed); + if (confirmed) { + CardImageBrowserScreen.openAndAutoStartBulkSync(); + } + }); + } + public static void openHomeDefault() { //default to English only if CJK is missing getLocalizer().setEnglish(forcedEnglishonCJKMissing); @@ -451,25 +477,35 @@ protected void afterDbLoaded() { } //selection transition setTransitionScreen(new TransitionScreen(() -> { + boolean openedRealScreen; if (createNewAdventureMap) { openAdventure(); clearSplashScreen(); + openedRealScreen = true; } else { if (selector.equals("Classic")) { openHomeDefault(); clearSplashScreen(); + openedRealScreen = true; } else if (selector.equals("Adventure")) { openAdventure(); clearSplashScreen(); + openedRealScreen = true; } else if (splashScreen != null) { splashScreen.setShowModeSelector(true); + openedRealScreen = false; } else {//default mode in case splashscreen is null at some point as seen on resume.. openHomeDefault(); clearSplashScreen(); + openedRealScreen = true; } } safeToClose = true; clearTransitionScreen(); + System.out.println("afterDbLoaded: TransitionScreen callback done, openedRealScreen=" + openedRealScreen + ", selector=" + selector); + if (openedRealScreen) { + maybePromptForBulkCdnSync(); + } if (GuiBase.isIOS()) { // POST-LOAD memory reclaim (iOS): booting parses ~32k card rules + // builds ~100k PaperCards + loads skin assets — a large transient diff --git a/forge-gui-mobile/src/forge/screens/SplashScreen.java b/forge-gui-mobile/src/forge/screens/SplashScreen.java index bd44347f2865..f03fe20a8f09 100644 --- a/forge-gui-mobile/src/forge/screens/SplashScreen.java +++ b/forge-gui-mobile/src/forge/screens/SplashScreen.java @@ -226,6 +226,8 @@ protected void onEnd(boolean endingAll) { else Forge.openHomeDefault(); Forge.clearSplashScreen(); + System.out.println("SplashScreen.BgAnimation.onEnd: openAdventure=" + openAdventure); + Forge.maybePromptForBulkCdnSync(); } } } diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java index 19aeb4eaf8ce..15c822f284d3 100644 --- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java +++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java @@ -185,6 +185,15 @@ private void startDownload() { // Bulk data sync: resolve CDN links for every set at once // ========================================================================= + /** Opens the screen and immediately starts a bulk sync that's already been confirmed elsewhere (e.g. the first-run prompt). Safe to call from any thread. */ + public static void openAndAutoStartBulkSync() { + FThreads.invokeInEdtLater(() -> { + CardImageBrowserScreen screen = new CardImageBrowserScreen(); + Forge.openScreen(screen); + screen.runBulkSync(); + }); + } + private void startBulkSync() { // SOptionPane.showConfirmDialog() blocks its caller while the dialog renders on the EDT, // so it must never be called directly from a tap handler (which runs on the EDT itself) @@ -193,7 +202,13 @@ private void startBulkSync() { if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblSyncBulkCardDataConfirm"))) { return; } + runBulkSync(); + }); + } + /** Runs the sync itself; always hops onto its own background thread, so it's safe to call from the EDT or not. */ + private void runBulkSync() { + FThreads.invokeInBackgroundThread(() -> { FThreads.invokeInEdtLater(() -> { btnDownload.setEnabled(false); btnSyncBulkData.setEnabled(false); diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index 0e72219a232f..e0100d22876c 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -337,6 +337,10 @@ btnClearCdnImageCache=CDN-Bildzuordnung leeren lblClearCdnImageCache=Löscht den lokalen CDN-Bildzuordnungs-Cache. Nützlich, wenn manche Karten weiterhin über den langsameren Ausweichpfad geladen werden, obwohl ihre Bilder eigentlich verfügbar sein sollten -- Forge ruft die Daten danach bei Bedarf erneut ab. lblClearCdnImageCacheConfirm=Dies löscht den lokalen CDN-Bildzuordnungs-Cache. Forge ruft ihn bei Bedarf erneut ab. Fortfahren? lblCdnImageCacheCleared=CDN-Bildzuordnungs-Cache geleert. +btnSyncBulkCardData=Massendownload (~350 MB) +lblSyncBulkCardDataConfirm=Dies lädt Scryfalls vollständige Kartendatenbank (~350 MB) herunter, um den CDN-Bildlink jeder Karte auf einmal aufzulösen, statt Set für Set. Fortfahren? +lblBulkCardDataSynced=Kartendaten synchronisiert. +lblFirstRunBulkCdnPrompt=Forge kann jetzt Scryfalls vollständige Kartendatenbank herunterladen (~350 MB, einmalig), damit künftige Kartenbild-Downloads sofort statt Set für Set aufgelöst werden. Jetzt herunterladen? lblDownloadCJKFonts=Lade Schriftarten zur lokalen Nutzung von CJK-Zeichen herunter. lblDownloadCJKFontPrompt=Wähle eine Schriftart zum Herunterladen aus. lblYourVersionOfJavaIsTooOld=Deine Java-Version ist leider zu alt. diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 6ca6caa9cd87..a1cd33f0983a 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -334,6 +334,7 @@ lblCdnImageCacheCleared=CDN image lookup cache cleared. btnSyncBulkCardData=Download Bulk (~350MB) lblSyncBulkCardDataConfirm=This downloads Scryfall''s full card database (~350 MB) to resolve every card''s CDN image link at once, instead of one set at a time. Continue? lblBulkCardDataSynced=Card data synced. +lblFirstRunBulkCdnPrompt=Forge can download Scryfall''s full card database now (~350 MB, one time) so that future card image downloads resolve instantly instead of one set at a time. Download it now? lblDownloadCJKFonts=Download fonts for locales using CJK glyphs. lblDownloadCJKFontPrompt=Select a font file to download lblYourVersionOfJavaIsTooOld=Your version of Java is too old to use the content downloaders. diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index f6c9e5dc2e48..ebee282dc27a 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -312,6 +312,10 @@ btnClearCdnImageCache=Borrar caché de búsqueda de imágenes CDN lblClearCdnImageCache=Elimina la caché local de búsqueda de imágenes CDN. Úsalo si algunas cartas siguen resolviéndose por la vía alternativa más lenta aunque sus imágenes deberían estar disponibles -- Forge la volverá a obtener cuando sea necesario. lblClearCdnImageCacheConfirm=Esto elimina la caché local de búsqueda de imágenes CDN. Forge la volverá a obtener cuando sea necesario. ¿Continuar? lblCdnImageCacheCleared=Caché de búsqueda de imágenes CDN borrada. +btnSyncBulkCardData=Descarga masiva (~350 MB) +lblSyncBulkCardDataConfirm=Esto descarga la base de datos completa de cartas de Scryfall (~350 MB) para resolver el enlace de imagen CDN de todas las cartas a la vez, en lugar de una edición a la vez. ¿Continuar? +lblBulkCardDataSynced=Datos de cartas sincronizados. +lblFirstRunBulkCdnPrompt=Forge puede descargar ahora la base de datos completa de cartas de Scryfall (~350 MB, solo una vez) para que las futuras descargas de imágenes de cartas se resuelvan al instante en lugar de una edición a la vez. ¿Descargarla ahora? lblDownloadCJKFonts=Descargar fuentes para idiomas que utilicen glifos CJK. lblDownloadCJKFontPrompt=Selecciona un archivo de fuente para descargar lblYourVersionOfJavaIsTooOld=Tu versión de Java es demasiado antigua para usar los descargadores de contenido. diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index f68919bd5b25..83a95da8345f 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -312,6 +312,10 @@ btnClearCdnImageCache=Vider le cache de correspondance CDN lblClearCdnImageCache=Supprime le cache local de correspondance CDN. À utiliser si certaines cartes continuent de passer par la solution de repli plus lente alors que leurs images devraient être disponibles -- Forge le régénérera au besoin. lblClearCdnImageCacheConfirm=Cette action supprime le cache local de correspondance CDN. Forge le régénérera au besoin. Continuer ? lblCdnImageCacheCleared=Cache de correspondance CDN vidé. +btnSyncBulkCardData=Téléchargement groupé (~350 Mo) +lblSyncBulkCardDataConfirm=Ceci télécharge la base de données complète des cartes de Scryfall (~350 Mo) afin de résoudre le lien d''image CDN de chaque carte en une seule fois, plutôt qu''un jeu de cartes à la fois. Continuer ? +lblBulkCardDataSynced=Données des cartes synchronisées. +lblFirstRunBulkCdnPrompt=Forge peut télécharger dès maintenant la base de données complète des cartes de Scryfall (~350 Mo, une seule fois) afin que les prochains téléchargements d''images de cartes se résolvent instantanément plutôt qu''un jeu de cartes à la fois. Télécharger maintenant ? lblDownloadCJKFonts=T\u00e9l\u00e9charger les polices pour les locales utilisant les glyphes CJK. lblDownloadCJKFontPrompt=Sélectionnez un fichier de police à télécharger lblYourVersionOfJavaIsTooOld=Votre version de Java est trop ancienne pour utiliser les téléchargeurs de contenu. diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index b2077c1cc4da..4013ef0707a7 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -311,6 +311,10 @@ btnClearCdnImageCache=Svuota cache mappatura immagini CDN lblClearCdnImageCache=Elimina la cache locale della mappatura immagini CDN. Usalo se alcune carte continuano a essere risolte tramite il percorso alternativo più lento anche se le loro immagini dovrebbero essere disponibili -- Forge la recupererà nuovamente quando necessario. lblClearCdnImageCacheConfirm=Questa operazione elimina la cache locale della mappatura immagini CDN. Forge la recupererà nuovamente quando necessario. Continuare? lblCdnImageCacheCleared=Cache della mappatura immagini CDN svuotata. +btnSyncBulkCardData=Download in blocco (~350 MB) +lblSyncBulkCardDataConfirm=Questa operazione scarica il database completo delle carte di Scryfall (~350 MB) per risolvere il collegamento immagine CDN di tutte le carte in una sola volta, invece che un''espansione alla volta. Continuare? +lblBulkCardDataSynced=Dati delle carte sincronizzati. +lblFirstRunBulkCdnPrompt=Forge può scaricare subito il database completo delle carte di Scryfall (~350 MB, una tantum) in modo che i futuri download delle immagini delle carte vengano risolti istantaneamente invece che un''espansione alla volta. Scaricarlo ora? lblDownloadCJKFonts=Scarica i font per le lingue che utilizzano caratteri Cinesi-Giapponesi-Coreani lblDownloadCJKFontPrompt=Seleziona un font da scaricare lblYourVersionOfJavaIsTooOld=La tua versione di Java è troppo vecchia per scaricare contenuti aggiuntivi. diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index d034feb33d6c..83b6cb9fe5fe 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -312,6 +312,10 @@ btnClearCdnImageCache=CDN画像索引キャッシュを削除 lblClearCdnImageCache=ローカルのCDN画像索引キャッシュを削除します。画像が利用可能なはずなのに一部のカードが遅いフォールバック経由で解決され続ける場合に使用してください -- 必要に応じてForgeが再取得します。 lblClearCdnImageCacheConfirm=ローカルのCDN画像索引キャッシュを削除します。必要に応じてForgeが再取得します。続行しますか? lblCdnImageCacheCleared=CDN画像索引キャッシュを削除しました。 +btnSyncBulkCardData=一括ダウンロード (~350MB) +lblSyncBulkCardDataConfirm=Scryfallの完全なカードデータベース (~350MB) をダウンロードし、セットごとではなく全カードのCDN画像リンクを一度に解決します。続行しますか? +lblBulkCardDataSynced=カードデータを同期しました。 +lblFirstRunBulkCdnPrompt=Forgeは今すぐScryfallの完全なカードデータベース (~350MB、一度限り) をダウンロードできます。これにより、今後のカード画像のダウンロードがセットごとではなく即座に解決されるようになります。今すぐダウンロードしますか? lblDownloadCJKFonts=日中韓文字を使う言語用のフォントをダウンロードします。 lblDownloadCJKFontPrompt=ダウンロードするフォントファイルを選択してください。 lblYourVersionOfJavaIsTooOld=Java のバージョンが古すぎるため、コンテンツダウンローダーを使用できません。 diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 5c5d6f839da2..bb2487690e2f 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -337,6 +337,10 @@ btnClearCdnImageCache=CDN 이미지 조회 캐시 지우기 lblClearCdnImageCache=로컬 CDN 이미지 조회 캐시를 삭제합니다. 일부 카드의 이미지를 사용할 수 있어야 하는데도 계속 느린 대체 경로로 해결되는 경우 사용하세요 -- 이후 필요에 따라 Forge가 다시 가져옵니다. lblClearCdnImageCacheConfirm=로컬 CDN 이미지 조회 캐시를 삭제합니다. 필요에 따라 Forge가 다시 가져옵니다. 계속하시겠습니까? lblCdnImageCacheCleared=CDN 이미지 조회 캐시를 지웠습니다. +btnSyncBulkCardData=대량 다운로드 (~350MB) +lblSyncBulkCardDataConfirm=이 작업은 Scryfall의 전체 카드 데이터베이스(~350MB)를 다운로드하여, 세트별로 확인하는 대신 모든 카드의 CDN 이미지 링크를 한 번에 확인합니다. 계속하시겠습니까? +lblBulkCardDataSynced=카드 데이터가 동기화되었습니다. +lblFirstRunBulkCdnPrompt=Forge는 지금 Scryfall의 전체 카드 데이터베이스(~350MB, 1회성)를 다운로드하여 이후 카드 이미지 다운로드가 세트별이 아닌 즉시 확인되도록 할 수 있습니다. 지금 다운로드하시겠습니까? lblDownloadCJKFonts=한중일 문자 사용하는 언어용 폰트 다운로드. lblDownloadCJKFontPrompt=다운로드할 폰트 파일을 선택하세요. lblYourVersionOfJavaIsTooOld=Java 버전이 너무 오래되어 콘텐츠 다운로더를 사용할 수 없습니다. diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index 8107c0826ec3..a2ba657c709b 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -313,6 +313,10 @@ btnClearCdnImageCache=Limpar cache de busca de imagens CDN lblClearCdnImageCache=Exclui o cache local de busca de imagens CDN. Use isso se algumas cartas continuarem sendo resolvidas pelo caminho alternativo mais lento mesmo que suas imagens devessem estar disponíveis -- o Forge buscará novamente conforme necessário. lblClearCdnImageCacheConfirm=Isso exclui o cache local de busca de imagens CDN. O Forge buscará novamente conforme necessário. Continuar? lblCdnImageCacheCleared=Cache de busca de imagens CDN limpo. +btnSyncBulkCardData=Baixar em massa (~350MB) +lblSyncBulkCardDataConfirm=Isso baixa o banco de dados completo de cartas do Scryfall (~350 MB) para resolver o link de imagem CDN de todas as cartas de uma só vez, em vez de uma coleção por vez. Continuar? +lblBulkCardDataSynced=Dados das cartas sincronizados. +lblFirstRunBulkCdnPrompt=O Forge pode baixar agora o banco de dados completo de cartas do Scryfall (~350 MB, uma única vez) para que os futuros downloads de imagens de cartas sejam resolvidos instantaneamente, em vez de uma coleção por vez. Baixar agora? lblDownloadCJKFonts=Baixe fontes para locais usando CJK glyphs. lblDownloadCJKFontPrompt=Selecione um arquivo de fonte para download lblYourVersionOfJavaIsTooOld=Sua versão do Java é muito antiga para usar os baixadores de conteúdo. diff --git a/forge-gui/res/languages/ru-RU.properties b/forge-gui/res/languages/ru-RU.properties index 6b66fd6d9464..020b5946d0d5 100644 --- a/forge-gui/res/languages/ru-RU.properties +++ b/forge-gui/res/languages/ru-RU.properties @@ -333,6 +333,10 @@ btnClearCdnImageCache=Очистить кэш CDN-индекса изображ lblClearCdnImageCache=Удаляет локальный кэш CDN-индекса изображений. Используйте, если некоторые карты по-прежнему загружаются через более медленный резервный путь, хотя их изображения должны быть доступны -- Forge при необходимости заново получит данные кэша. lblClearCdnImageCacheConfirm=Это удалит локальный кэш CDN-индекса изображений. Forge при необходимости заново его получит. Продолжить? lblCdnImageCacheCleared=Кэш CDN-индекса изображений очищен. +btnSyncBulkCardData=Массовая загрузка (~350 МБ) +lblSyncBulkCardDataConfirm=Это загрузит полную базу данных карт Scryfall (~350 МБ), чтобы разрешить ссылку на CDN-изображение сразу для всех карт, а не по одному выпуску за раз. Продолжить? +lblBulkCardDataSynced=Данные карт синхронизированы. +lblFirstRunBulkCdnPrompt=Forge может сейчас загрузить полную базу данных карт Scryfall (~350 МБ, единоразово), чтобы последующие загрузки изображений карт разрешались мгновенно, а не по одному выпуску за раз. Загрузить сейчас? lblDownloadCJKFonts=Скачать шрифты для локалей, использующих иероглифы CJK. lblDownloadCJKFontPrompt=Выберите файл шрифта для загрузки lblYourVersionOfJavaIsTooOld=Ваша версия Java слишком старая для использования загрузчиков контента. diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 387059db6812..53d067a58209 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -312,6 +312,10 @@ btnClearCdnImageCache=清除CDN图片索引缓存 lblClearCdnImageCache=删除本地CDN图片索引缓存。如果某些卡牌的图片明明应该可用,却一直通过较慢的备用方式解析,可以使用此项——之后Forge会按需重新获取缓存数据。 lblClearCdnImageCacheConfirm=这将删除本地CDN图片索引缓存,Forge会按需重新获取。是否继续? lblCdnImageCacheCleared=CDN图片索引缓存已清除。 +btnSyncBulkCardData=批量下载 (~350MB) +lblSyncBulkCardDataConfirm=这将下载Scryfall的完整卡牌数据库 (~350MB),一次性解析所有卡牌的CDN图片链接,而不是逐个系列进行。是否继续? +lblBulkCardDataSynced=卡牌数据已同步。 +lblFirstRunBulkCdnPrompt=Forge现在可以下载Scryfall的完整卡牌数据库 (~350MB,仅需一次),这样以后下载卡牌图片时可以立即解析,而不必逐个系列进行。是否现在下载? lblDownloadCJKFonts=下载可用于显示当前区域设置所使用的CJK图形的字体。 lblDownloadCJKFontPrompt=选择要下载的字体文件 lblYourVersionOfJavaIsTooOld=你的Java版本太旧无法开始下载内容 diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index ed22b863187c..261dd55161e7 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -78,7 +78,7 @@ private CdnUuidCache() {} static void clearCacheForTesting() { setCache.clear(); } /** Local set-cache directory, honoring the test override. */ - static String cacheDir() { + public static String cacheDir() { return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR; } @@ -91,6 +91,12 @@ public static boolean isSetCached(String scryfallCode) { return scryfallCode != null && localCacheFile(scryfallCode.toLowerCase()).exists(); } + /** Whether any set has ever been synced locally. Used to offer a one-time bulk warm-up on first run. */ + public static boolean hasAnyCachedSets() { + File[] files = new File(cacheDir()).listFiles((dir, name) -> name.endsWith(".json.gz")); + return files != null && files.length > 0; + } + /** Deletes every local cache file and clears the in-memory cache. */ public static void clearCache() { setCache.clear(); From 5e4ce7f4ac46dd87c647863a7029f69370ce4e51 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 18 Aug 2026 21:29:15 +0100 Subject: [PATCH 16/18] Add language codes --- .../java/forge/gui/download/CdnUuidCache.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 261dd55161e7..998f5e6f77cd 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -40,6 +40,31 @@ public final class CdnUuidCache { private static final String FALLBACK_LANG = "en"; private static final Duration MISS_RETRY_AFTER = Duration.ofDays(1); + /** + * Every Scryfall language code observed + */ + public static final String[] LANGUAGE_CODES = { + "ar", // Arabic + "de", // German + "dw", // Dwarvish (joke/funny-set language) + "en", // English + "es", // Spanish + "fr", // French + "grc", // Ancient Greek + "he", // Hebrew + "it", // Italian + "ja", // Japanese + "ko", // Korean + "la", // Latin + "ph", // Phyrexian + "pt", // Portuguese + "qya", // Quenya + "ru", // Russian + "sa", // Sanskrit + "zhs", // Chinese Simplified + "zht" // Chinese Traditional + }; + /** Used when set was looked up and no data exists. */ private static final Map> MISSING_SET = Collections.emptyMap(); From 77a8f6291095dd6a73b50ce3773a01d08adcd72b Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Tue, 18 Aug 2026 22:32:43 +0100 Subject: [PATCH 17/18] Simplify --- .../java/forge/util/SwingImageFetcher.java | 5 +- forge-gui-mobile/src/forge/Forge.java | 7 +- .../src/forge/screens/SplashScreen.java | 1 - .../src/forge/util/LibGDXImageFetcher.java | 3 +- .../java/forge/gui/download/CdnUuidCache.java | 22 ++---- .../GuiDownloadFilteredCardImages.java | 13 +--- .../gui/download/GuiDownloadService.java | 11 +-- .../gui/download/ScryfallBulkDataSync.java | 75 ++----------------- .../forge/gui/download/ScryfallSetSync.java | 31 ++++---- .../main/java/forge/util/ImageFetcher.java | 5 +- .../java/forge/util/ScryfallRateLimiter.java | 49 ++++-------- 11 files changed, 56 insertions(+), 166 deletions(-) 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 bd52857a80e2..38dc099106ce 100644 --- a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java +++ b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java @@ -60,10 +60,7 @@ private boolean doFetch(String urlToDownload) throws IOException { if (responseCode != HttpURLConnection.HTTP_OK) { System.err.println("Failed to fetch image. HTTP code: " + responseCode + " (" + httpConnection.getResponseMessage() + ") for URL: " + urlToDownload); - if (responseCode == 429 && ScryfallRateLimiter.isApiUrl(urlToDownload)) { - long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(httpConnection.getHeaderField("Retry-After")); - ScryfallRateLimiter.noteRateLimited(urlToDownload, retryAfter); - } + ScryfallRateLimiter.noteIfRateLimited(responseCode, urlToDownload, httpConnection.getHeaderField("Retry-After")); httpConnection.disconnect(); return false; } diff --git a/forge-gui-mobile/src/forge/Forge.java b/forge-gui-mobile/src/forge/Forge.java index 434ff46c4da8..0df75b61d658 100644 --- a/forge-gui-mobile/src/forge/Forge.java +++ b/forge-gui-mobile/src/forge/Forge.java @@ -369,17 +369,13 @@ public static void openHomeScreen(int index, FScreen lastMatch) { * home/adventure screen is open -- not while the mode-selector splash is still showing. */ public static void maybePromptForBulkCdnSync() { - boolean hasCache = CdnUuidCache.hasAnyCachedSets(); - System.out.println("maybePromptForBulkCdnSync: cacheDir=" + CdnUuidCache.cacheDir() + " hasAnyCachedSets=" + hasCache); - if (hasCache) { + if (CdnUuidCache.hasAnyCachedSets()) { return; } - System.out.println("maybePromptForBulkCdnSync: showing prompt"); FThreads.invokeInBackgroundThread(() -> { boolean confirmed = SOptionPane.showConfirmDialog( getLocalizer().getMessage("lblFirstRunBulkCdnPrompt"), "Forge", "Download Now", "Not Now", true); - System.out.println("maybePromptForBulkCdnSync: user answered confirmed=" + confirmed); if (confirmed) { CardImageBrowserScreen.openAndAutoStartBulkSync(); } @@ -502,7 +498,6 @@ protected void afterDbLoaded() { } safeToClose = true; clearTransitionScreen(); - System.out.println("afterDbLoaded: TransitionScreen callback done, openedRealScreen=" + openedRealScreen + ", selector=" + selector); if (openedRealScreen) { maybePromptForBulkCdnSync(); } diff --git a/forge-gui-mobile/src/forge/screens/SplashScreen.java b/forge-gui-mobile/src/forge/screens/SplashScreen.java index f03fe20a8f09..09b7768d9824 100644 --- a/forge-gui-mobile/src/forge/screens/SplashScreen.java +++ b/forge-gui-mobile/src/forge/screens/SplashScreen.java @@ -226,7 +226,6 @@ protected void onEnd(boolean endingAll) { else Forge.openHomeDefault(); Forge.clearSplashScreen(); - System.out.println("SplashScreen.BgAnimation.onEnd: openAdventure=" + openAdventure); Forge.maybePromptForBulkCdnSync(); } } diff --git a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java index 0e6d1c84e0c6..dde0d27ad09d 100644 --- a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java +++ b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java @@ -84,8 +84,7 @@ private boolean doFetch(String urlToDownload) throws IOException { if (responseCode == 429 && ScryfallRateLimiter.isApiUrl(urlToDownload)) { Sentry.captureMessage("Device has been rate limited. Adding reduction of download attempts for this device. " + urlToDownload); - long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(c.getHeaderField("Retry-After")); - ScryfallRateLimiter.noteRateLimited(urlToDownload, retryAfter); + ScryfallRateLimiter.noteIfRateLimited(responseCode, urlToDownload, c.getHeaderField("Retry-After")); } c.disconnect(); diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 998f5e6f77cd..2fcdea194c10 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -107,11 +107,7 @@ public static String cacheDir() { return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR; } - /** - * Whether {@code scryfallCode} already has a local cache file, from either the per-set sync - * or a bulk-data sync. Lets a caller about to warm the cache skip a set that's already warm - * instead of unconditionally re-fetching it. - */ + /** Whether {@code scryfallCode} already has a local cache file (per-set or bulk-data sync). */ public static boolean isSetCached(String scryfallCode) { return scryfallCode != null && localCacheFile(scryfallCode.toLowerCase()).exists(); } @@ -186,12 +182,8 @@ private static synchronized void recordMiss(String setCode, String cn, String la } /** - * Read-only counterpart to {@link #getCdnUrl}: returns a CDN URL only if {@code scryfallCode} - * has already been synced (by the bulk downloader), and never has the side effect of queuing - * a sync or recording a miss. Safe to call from the interactive gameplay image-fetch path, - * which must never silently kick off a full multi-page Scryfall search just because a card - * happens to be missing -- that's real, unbounded background API traffic triggered by - * ordinary play, not a deliberate, cancelable, user-initiated bulk operation. + * Read-only counterpart to {@link #getCdnUrl}: never queues a sync or records a miss. Use + * from the gameplay image-fetch path, which must not trigger background Scryfall traffic. */ public static String getCdnUrlIfCached(String scryfallCode, String collectorNum, String lang, String face, String size) { @@ -210,11 +202,7 @@ public static String getCdnUrlIfCached(String scryfallCode, String collectorNum, return cdnUrl(uuid, side, size); } - /** - * Like {@link #ensureSetLoaded}, but never queues a sync on a miss -- so a set that hasn't - * been synced yet stays retryable (e.g. once the bulk downloader finishes it) instead of - * either triggering background work or getting permanently cached as absent. - */ + /** Like {@link #ensureSetLoaded}, but never queues a sync -- an unsynced set stays retryable rather than triggering work or being cached as permanently absent. */ private static Map> ensureSetLoadedReadOnly(String setCode) { Map> cached = setCache.get(setCode); if (cached != null) return cached; @@ -347,7 +335,7 @@ private static void refreshStaleMisses(String setCode) { * @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 + return ForgeConstants.URL_SCRYFALL_CDN + size + "/" + side + "/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg"; } 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 bfc5659adbc6..9a00c7e5a07c 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -60,11 +60,8 @@ protected Map getNeededFiles() { } // Warm the CDN UUID cache for every needed set before resolving URLs below, so this run - // uses the unthrottled CDN path instead of falling back to the rate-limited Scryfall API - // just because the cache hadn't finished populating yet. Skip a set that's already - // cached (e.g. from a bulk-data sync via ScryfallBulkDataSync) instead of unconditionally - // re-fetching it through the rate-limited /cards/search endpoint -- otherwise the bulk - // sync's whole point (avoiding that endpoint) is defeated on every subsequent download. + // prefers the unthrottled CDN path over the rate-limited Scryfall API. Skip sets already + // cached (e.g. by a prior bulk-data sync). final List needSync = new ArrayList<>(); for (String setCode : scryfallSetCodes) { if (!CdnUuidCache.isSetCached(setCode)) { @@ -132,10 +129,8 @@ private static String buildUrl(PaperCard c, String face) { String scryfallCode = (edition != null) ? edition.getScryfallCode() : null; boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode); - // 1. CDN -- read-only: the warm-up loop above is the one deliberate, sequential place - // that syncs sets. If a set's warm-up sync failed to produce data, fall through to the - // API below for this run rather than triggering another uncoordinated background sync - // (see CdnUuidCache.getCdnUrlIfCached()). + // 1. CDN -- read-only (see CdnUuidCache.getCdnUrlIfCached()); falls through to the API + // below if the warm-up loop above didn't resolve this set. if (edition != null && hasCollectorNum && hasScryfallCode) { String cdnUrl = CdnUuidCache.getCdnUrlIfCached( scryfallCode, collectorNum, edition.getCardsLangCode(), face, "normal"); diff --git a/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java b/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java index b0c35b1b9b2b..df0f1187dd4a 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java @@ -263,9 +263,7 @@ public void run() { // instead of racing through the rest of a large queue in skip-only mode. ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); } - if (cancel) { - // cancelled while waiting out a cooldown - } else if (FileUtil.ensureDirectoryExists(base)) { //ensure destination directory exists + if (!cancel && FileUtil.ensureDirectoryExists(base)) { //ensure destination directory exists URL imageUrl = new URL(url); HttpURLConnection conn = (HttpURLConnection) imageUrl.openConnection(p); // Scryfall asks for a descriptive User-Agent and rate-limits harder without one. @@ -327,10 +325,7 @@ public void run() { System.out.println("File not found: .." + url.substring(url.lastIndexOf("/images/")+1)); break; case 429: - if (ScryfallRateLimiter.isApiUrl(url)) { - long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(conn.getHeaderField("Retry-After")); - ScryfallRateLimiter.noteRateLimited(url, retryAfter); - } + ScryfallRateLimiter.noteIfRateLimited(429, url, conn.getHeaderField("Retry-After")); conn.disconnect(); break; default: @@ -338,7 +333,7 @@ public void run() { System.out.println(" Connection failed for url: " + url); break; } - } else { + } else if (!cancel) { System.out.println(" Can't create folder: " + base.getAbsolutePath()); } } diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java index 201969bb7ee0..7167424ef8f5 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java @@ -21,14 +21,8 @@ import java.util.zip.GZIPInputStream; /** - * Warms {@link CdnUuidCache} for every set in one pass using Scryfall's Bulk Data API - * (https://scryfall.com/docs/api/bulk-data), instead of one paginated {@code /cards/search} - * request per set via {@link ScryfallSetSync}. A format like Standard touches dozens of sets - * full of reprints; resolving each of those sets individually means dozens of paced (500ms) - * search requests just to build CDN UUIDs Scryfall already publishes in one file. The - * "default_cards" bulk file -- one row per print, in English or the card's sole language -- is a - * single download from a direct *.scryfall.io file origin, which Scryfall documents as having no - * rate limit at all, and covers the whole catalog at once. + * Warms {@link CdnUuidCache} for every set in one pass via Scryfall's Bulk Data API + * (https://scryfall.com/docs/api/bulk-data) */ public final class ScryfallBulkDataSync { private static final String BULK_DATA_LISTING_URL = "https://api.scryfall.com/bulk-data"; @@ -107,10 +101,7 @@ private static String findDefaultCardsUrl(ProgressListener progress) { int status = conn.getResponseCode(); if (status == 429) { - if (ScryfallRateLimiter.isApiUrl(url)) { - long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(conn.getHeaderField("Retry-After")); - ScryfallRateLimiter.noteRateLimited(url, retryAfter); - } + ScryfallRateLimiter.noteIfRateLimited(429, url, conn.getHeaderField("Retry-After")); return null; } if (status != 200) { @@ -150,9 +141,7 @@ private static void streamAndAccumulate(String downloadUrl, Map>> bySet, JsonObject card) { - String setCode = str(card, "set"); - String cn = str(card, "collector_number"); - String lang = str(card, "lang"); + String setCode = ScryfallSetSync.str(card, "set"); + String cn = ScryfallSetSync.str(card, "collector_number"); + String lang = ScryfallSetSync.str(card, "lang"); if (setCode == null || cn == null || lang == null) { return; } - String[] frontBack = frontBackUuids(card); + String[] frontBack = ScryfallSetSync.frontBackUuids(card); if (frontBack == null) { return; } @@ -233,52 +222,4 @@ private static void addCard(Map>> bySe .computeIfAbsent(cn, k -> new HashMap<>()) .put(lang, frontBack); } - - /** Per-face CDN UUIDs for one card, or {@code null} if it has no usable image data yet. */ - private static String[] frontBackUuids(JsonObject card) { - String front; - String back = null; - if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { - front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris"))); - } else if (card.has("card_faces") && card.get("card_faces").isJsonArray()) { - JsonArray faces = card.getAsJsonArray("card_faces"); - if (faces.isEmpty()) { - return null; - } - JsonObject face0 = faces.get(0).getAsJsonObject(); - if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) { - return null; - } - front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris"))); - if (faces.size() > 1) { - JsonObject face1 = faces.get(1).getAsJsonObject(); - if (face1.has("image_uris") && face1.get("image_uris").isJsonObject()) { - back = uuidFromUrl(normalUrl(face1.getAsJsonObject("image_uris"))); - } - } - } else { - return null; - } - return front == null ? null : new String[]{front, back}; - } - - private static String normalUrl(JsonObject imageUris) { - return imageUris.has("normal") ? imageUris.get("normal").getAsString() : null; - } - - private static String uuidFromUrl(String url) { - if (url == null || !url.contains("cards.scryfall.io")) { - return null; - } - int qmark = url.indexOf('?'); - String path = qmark >= 0 ? url.substring(0, qmark) : url; - int slash = path.lastIndexOf('/'); - String filename = slash >= 0 ? path.substring(slash + 1) : path; - int dot = filename.lastIndexOf('.'); - return dot >= 0 ? filename.substring(0, dot) : filename; - } - - private static String str(JsonObject obj, String key) { - return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; - } } diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java index 59f3794b7ebf..1123b99f9e60 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -73,18 +73,25 @@ static boolean sync(String setCode) { private static void addCard(Map> byCn, JsonObject card) { String cn = str(card, "collector_number"); String lang = str(card, "lang"); - String id = str(card, "id"); - if (cn == null || lang == null || id == null) return; + if (cn == null || lang == null) return; + String[] frontBack = frontBackUuids(card); + if (frontBack == null) return; // no image data yet, or a placeholder URL + + byCn.computeIfAbsent(cn, k -> new HashMap<>()).put(lang, frontBack); + } + + /** Per-face CDN UUIDs for one card, or {@code null} if it has no usable image data yet. Shared with {@link ScryfallBulkDataSync}. */ + static String[] frontBackUuids(JsonObject card) { String front; String back = null; if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris"))); } else if (card.has("card_faces") && card.get("card_faces").isJsonArray()) { JsonArray faces = card.getAsJsonArray("card_faces"); - if (faces.size() == 0) return; + if (faces.size() == 0) return null; JsonObject face0 = faces.get(0).getAsJsonObject(); - if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) return; + if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) return null; front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris"))); if (faces.size() > 1) { JsonObject face1 = faces.get(1).getAsJsonObject(); @@ -93,13 +100,9 @@ private static void addCard(Map> byCn, JsonObject } } } else { - return; // no image data for this card + return null; // no image data for this card } - // No image yet (placeholder URL) -- leave unmerged so CdnUuidCache retries later - // instead of permanently recording a guessed UUID. - if (front == null) return; - - byCn.computeIfAbsent(cn, k -> new HashMap<>()).put(lang, new String[]{front, back}); + return front == null ? null : new String[]{front, back}; } /** Reads an {@code image_uris} object and returns the value of the {@code normal} key. */ @@ -127,7 +130,8 @@ private static String searchUrl(String setCode) { return base + "?unique=prints&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); } - private static String str(JsonObject obj, String key) { + /** Shared with {@link ScryfallBulkDataSync}. */ + static String str(JsonObject obj, String key) { return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; } @@ -146,10 +150,7 @@ private static JsonObject fetchJson(String urlStr) throws IOException { int status = ((HttpURLConnection) conn).getResponseCode(); if (status == 404) return null; if (status == 429) { - if (ScryfallRateLimiter.isApiUrl(urlStr)) { - long retryAfter = ScryfallRateLimiter.parseRetryAfterSeconds(conn.getHeaderField("Retry-After")); - ScryfallRateLimiter.noteRateLimited(urlStr, retryAfter); - } + ScryfallRateLimiter.noteIfRateLimited(429, urlStr, conn.getHeaderField("Retry-After")); throw new IOException("HTTP 429 (rate limited) for " + urlStr); } if (status != 200) throw new IOException("HTTP " + status + " for " + urlStr); diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index c87544aea1dd..c7a80c3454e5 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -76,10 +76,7 @@ private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, Arr String setCode = edition.getScryfallCode(); String langCode = edition.getCardsLangCode(); - // Prefer CDN (no rate limit) if this set was already synced by the bulk downloader. - // Read-only lookup: gameplay must never have the side effect of queuing a full-set - // Scryfall search just because a card's image happens to be missing -- see - // CdnUuidCache.getCdnUrlIfCached() for why. + // Prefer CDN (no rate limit) if this set was already synced; read-only, see getCdnUrlIfCached(). if (!StringUtils.isBlank(setCode)) { String size = useArtCrop ? "art_crop" : "normal"; String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrlIfCached( diff --git a/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java b/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java index 0a1039390263..61496747539b 100644 --- a/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java +++ b/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java @@ -4,18 +4,11 @@ import java.util.concurrent.TimeUnit; /** - * Single global pacing/cooldown gate for Scryfall's api.scryfall.com endpoints (card lookup, - * search, download-by-name). Never applies to cards.scryfall.io CDN URLs -- those are pre-resolved - * and intentionally unthrottled; see CdnUuidCache. + * Global pacing/cooldown gate for api.scryfall.com endpoints. Never applies to cards.scryfall.io + * CDN URLs -- those are unthrottled; see {@link forge.gui.download.CdnUuidCache}. * - *

Scryfall's documented hard per-endpoint-category limits (see - * https://scryfall.com/docs/api): {@code /cards/search}, {@code /cards/named}, - * {@code /cards/random}, and {@code /cards/collection} are capped at 2/sec (500ms); every other - * method is capped at 10/sec (100ms). Forge only ever calls {@code search} (CDN UUID resolution) - * and {@code named} (a legacy fuzzy-name lookup) from the slow bucket -- everything else (the - * per-card {@code /cards/{set}/{cn}/{lang}} lookup) is in the fast bucket. Pacing every request - * at the fast bucket's 100ms, including search, was 5x faster than Scryfall actually allows for - * that endpoint and was the real cause of repeated 429s during bulk CDN-cache warm-up. + *

Scryfall caps {@code /cards/search} and {@code /cards/named} at 2/sec (500ms); everything + * else at 10/sec (100ms). */ public final class ScryfallRateLimiter { private static final String API_HOST_PREFIX = "https://api.scryfall.com/"; @@ -81,6 +74,13 @@ public static long parseRetryAfterSeconds(String headerValue) { } } + /** Records a cooldown if {@code responseCode} is 429 for a Scryfall API url; no-op otherwise. */ + public static void noteIfRateLimited(int responseCode, String url, String retryAfterHeader) { + if (responseCode == 429 && isApiUrl(url)) { + noteRateLimited(url, parseRetryAfterSeconds(retryAfterHeader)); + } + } + /** True (and logs) if url is a Scryfall API URL and we're currently backing off. */ public static boolean shouldSkip(String url) { if (!isApiUrl(url) || !isCoolingDown()) { @@ -91,27 +91,15 @@ public static boolean shouldSkip(String url) { } /** - * Blocks the calling thread until any active cooldown clears, polling {@code cancelled} so a - * long-running background job (the bulk downloader) can still be cancelled while waiting. - * No-op if not currently cooling down. - * - *

Only call this from a background thread that may legitimately block for minutes at a - * time -- never from a gameplay fire-and-forget path (e.g. {@code CdnUuidCache}'s implicit - * cache-miss sync), where failing fast and moving on is the correct behavior. Without this, - * a large multi-set/multi-thousand-file bulk run that trips one 429 early on races through - * its entire remaining backlog in skip-and-print mode -- far faster than the cooldown can - * ever clear -- and ends up downloading nothing for the rest of the run. + * Blocks the calling thread until any active cooldown clears, polling {@code cancelled}. + * No-op if not cooling down. Only call from a cancelable background job (e.g. the bulk + * downloader) -- never from a gameplay fire-and-forget path, where failing fast is correct. */ public static void awaitCooldownCleared(java.util.function.BooleanSupplier cancelled) { awaitCooldownCleared(cancelled, null); } - /** - * Same as {@link #awaitCooldownCleared(java.util.function.BooleanSupplier)}, but also invokes - * {@code onWaiting} (throttled to roughly once every 5 seconds) with a human-readable status - * string, so a caller with a visible progress UI can show that it's genuinely waiting rather - * than appearing to hang. - */ + /** Same as {@link #awaitCooldownCleared(java.util.function.BooleanSupplier)}, but reports a status string (throttled to ~5s) via {@code onWaiting} for a visible progress UI. */ public static void awaitCooldownCleared(java.util.function.BooleanSupplier cancelled, java.util.function.Consumer onWaiting) { if (!isCoolingDown()) { @@ -141,12 +129,7 @@ public static void awaitCooldownCleared(java.util.function.BooleanSupplier cance } } - /** - * Blocks the calling thread (never the EDT) until it's this caller's turn. No-op for - * non-API URLs, so CDN/cardforge downloads never pay this cost. Search/named requests pace - * independently from everything else, so a burst of one kind never forces the other to wait - * longer than its own documented limit requires. - */ + /** Blocks the calling thread (never the EDT) until it's this caller's turn. No-op for non-API URLs. */ public static void acquire(String url) { if (!isApiUrl(url)) { return; From d88b6be489263a45c6c11b9039bf6f3aeffe949a Mon Sep 17 00:00:00 2001 From: Churrufli Date: Thu, 27 Aug 2026 02:11:53 +0200 Subject: [PATCH 18/18] CDN Multi-language card image downloads --- .../src/main/java/forge/StaticData.java | 13 +- .../src/main/java/forge/card/CardDb.java | 25 ++- .../java/forge/download/GuiDownloader.java | 5 +- .../java/forge/itemmanager/CardManager.java | 35 +++- .../java/forge/itemmanager/ItemManager.java | 36 +++- .../home/settings/CSubmenuPreferences.java | 42 +++++ .../settings/DialogDownloadCardImages.java | 76 ++++++-- .../home/settings/VSubmenuPreferences.java | 16 ++ .../settings/CardImageBrowserScreen.java | 163 ++++++++++++------ .../forge/screens/settings/SettingsPage.java | 30 ++++ forge-gui/res/languages/de-DE.properties | 13 +- forge-gui/res/languages/en-US.properties | 11 +- forge-gui/res/languages/es-ES.properties | 9 +- forge-gui/res/languages/fr-FR.properties | 13 +- forge-gui/res/languages/it-IT.properties | 13 +- forge-gui/res/languages/ja-JP.properties | 13 +- forge-gui/res/languages/ko-KR.properties | 13 +- forge-gui/res/languages/pt-BR.properties | 13 +- forge-gui/res/languages/ru-RU.properties | 13 +- forge-gui/res/languages/zh-CN.properties | 13 +- .../java/forge/gui/download/CdnUuidCache.java | 41 ++++- .../GuiDownloadFilteredCardImages.java | 47 +++-- .../gui/download/ScryfallBulkDataSync.java | 44 +++-- .../forge/gui/download/ScryfallSetSync.java | 18 +- .../properties/ForgeConstants.java | 16 ++ .../properties/ForgePreferences.java | 2 + .../src/main/java/forge/model/FModel.java | 17 +- .../main/java/forge/util/ImageFetcher.java | 13 +- forge-gui/tools/java/ForgeMatrixWriter.java | 3 +- 29 files changed, 615 insertions(+), 151 deletions(-) diff --git a/forge-core/src/main/java/forge/StaticData.java b/forge-core/src/main/java/forge/StaticData.java index 818d355ebf8a..cf471551a1d4 100644 --- a/forge-core/src/main/java/forge/StaticData.java +++ b/forge-core/src/main/java/forge/StaticData.java @@ -15,6 +15,7 @@ import java.io.File; import java.util.*; +import java.util.function.BiPredicate; import java.util.function.Predicate; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; @@ -64,9 +65,12 @@ public class StaticData { private static StaticData lastInstance = null; public StaticData(CardStorageReader cardReader, CardStorageReader customCardReader, String editionFolder, String customEditionsFolder, String blockDataFolder, String cardArtPreference, boolean enableUnknownCards, boolean loadNonLegalCards) { - this(cardReader, null, customCardReader, null, editionFolder, customEditionsFolder, blockDataFolder, "", cardArtPreference, enableUnknownCards, loadNonLegalCards, false, false); + this(cardReader, null, customCardReader, null, editionFolder, customEditionsFolder, blockDataFolder, "", cardArtPreference, enableUnknownCards, loadNonLegalCards, false, false, null); } public StaticData(CardStorageReader cardReader, CardStorageReader tokenReader, CardStorageReader customCardReader, CardStorageReader customTokenReader, String editionFolder, String customEditionsFolder, String blockDataFolder, String setLookupFolder, String cardArtPreference, boolean enableUnknownCards, boolean loadNonLegalCards, boolean allowCustomCardsInDecksConformance, boolean enableSmartCardArtSelection) { + this(cardReader, tokenReader, customCardReader, customTokenReader, editionFolder, customEditionsFolder, blockDataFolder, setLookupFolder, cardArtPreference, enableUnknownCards, loadNonLegalCards, allowCustomCardsInDecksConformance, enableSmartCardArtSelection, null); + } + public StaticData(CardStorageReader cardReader, CardStorageReader tokenReader, CardStorageReader customCardReader, CardStorageReader customTokenReader, String editionFolder, String customEditionsFolder, String blockDataFolder, String setLookupFolder, String cardArtPreference, boolean enableUnknownCards, boolean loadNonLegalCards, boolean allowCustomCardsInDecksConformance, boolean enableSmartCardArtSelection, BiPredicate preferredLanguageAvailability) { this.cardReader = cardReader; this.tokenReader = tokenReader; this.editions = new CardEdition.Collection(new CardEdition.Reader(new File(editionFolder))); @@ -131,6 +135,8 @@ public StaticData(CardStorageReader cardReader, CardStorageReader tokenReader, C commonCards.setCardArtPreference(cardArtPreference); variantCards.setCardArtPreference(cardArtPreference); + commonCards.setPreferredLanguageAvailability(preferredLanguageAvailability); + variantCards.setPreferredLanguageAvailability(preferredLanguageAvailability); //must initialize after establish field values for the sake of card image logic commonCards.initialize(false, false, enableUnknownCards); @@ -956,6 +962,11 @@ public void setCardArtPreference(String artPreference) { this.variantCards.setCardArtPreference(artPreference); } + public void setPreferredLanguageAvailability(BiPredicate availability) { + this.commonCards.setPreferredLanguageAvailability(availability); + this.variantCards.setPreferredLanguageAvailability(availability); + } + public boolean isEnabledCardArtSmartSelection() { return this.enableSmartCardArtSelection; } diff --git a/forge-core/src/main/java/forge/card/CardDb.java b/forge-core/src/main/java/forge/card/CardDb.java index 2a615dcecffc..d19c50726c45 100644 --- a/forge-core/src/main/java/forge/card/CardDb.java +++ b/forge-core/src/main/java/forge/card/CardDb.java @@ -32,6 +32,7 @@ import java.util.*; import java.util.Map.Entry; +import java.util.function.BiPredicate; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -98,6 +99,8 @@ public int compare(CardEdition o1, CardEdition o2) { // Placeholder to setup default art Preference - to be moved from Static Data! private CardArtPreference defaultCardArtPreference; + private BiPredicate preferredLanguageAvailability; + private boolean initialized; public static class CardRequest { public String cardName; @@ -536,6 +539,7 @@ public void initialize(boolean logMissingPerEdition, boolean logMissingSummary, } } + initialized = true; reIndex(); } @@ -583,11 +587,23 @@ private void reIndex() { private PaperCard getBestUniquePrint(final Collection cards) { return cards.stream() .filter(pc -> !pc.getRarity().equals(CardRarity.Special)) - .min(Comparator.comparing((PaperCard pc) -> editions.get(pc.getEdition()), defaultCardArtPreference) + .min(Comparator.comparing((PaperCard pc) -> isPreferredLanguagePrint(pc) ? 0 : 1) + .thenComparing((PaperCard pc) -> editions.get(pc.getEdition()), defaultCardArtPreference) .thenComparing(PaperCard::getCollectorNumber)) .orElseGet(() -> cards.iterator().next()); } + private boolean isPreferredLanguagePrint(PaperCard pc) { + if (preferredLanguageAvailability == null) { + return false; + } + CardEdition edition = editions.get(pc.getEdition()); + if (edition == null) { + return false; + } + return preferredLanguageAvailability.test(edition.getScryfallCode(), pc.getCollectorNumber()); + } + public boolean setPreferredArt(String cardName, String setCode, int artIndex) { String cardRequestForPreferredArt = CardRequest.compose(cardName, setCode, artIndex); PaperCard pc = this.getCard(cardRequestForPreferredArt); @@ -613,6 +629,13 @@ public void setCardArtPreference(boolean latestArt, boolean coreExpansionOnly){ } } + public void setPreferredLanguageAvailability(BiPredicate availability) { + this.preferredLanguageAvailability = availability; + if (initialized) { + reIndex(); + } + } + /** * Retrieves a CardRules matching the provided name. * @param allowAltNames If false, the name must be the exact name of the card in its default state. If true, flavor diff --git a/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java b/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java index 04671d2e01ee..c3855442d424 100644 --- a/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java +++ b/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java @@ -115,7 +115,10 @@ public void show() { pnl.add(pnlDialog, "w 400px!, h 350px!, ax center, ay center"); SOverlayUtils.showOverlay(); - service.initialize(txtAddress, txtPort, progressBar, btnStart, cmdClose, null, this::fireStateChanged); + progressBar.setIndeterminate(true); + + service.initialize(txtAddress, txtPort, progressBar, btnStart, cmdClose, + () -> progressBar.setIndeterminate(false), this::fireStateChanged); } private class ProxyHandler implements ChangeListener { diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java b/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java index 78e4b86a5df8..e10f2559f6de 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java @@ -9,6 +9,7 @@ import forge.gamemodes.quest.QuestWorld; import forge.gamemodes.quest.data.QuestPreferences; import forge.gui.GuiUtils; +import forge.gui.download.CdnUuidCache; import forge.item.PaperCard; import forge.itemmanager.filters.*; import forge.localinstance.properties.ForgePreferences; @@ -62,6 +63,14 @@ protected Iterable> getUnique(Iterable cardsMap = new HashMap<>(); for (String cardName : entriesByName.keySet()) { @@ -85,7 +94,7 @@ protected Iterable> getUnique(Iterable cardEntry = getCardEntryToAdd(entriesByEdition, acceptedEditions); + Entry cardEntry = getCardEntryToAdd(entriesByEdition, acceptedEditions, preferredLang); if (cardEntry != null) cardsMap.put(cardEntry.getKey(), cardEntry.getValue()); } @@ -95,7 +104,7 @@ protected Iterable> getUnique(Iterable getCardEntryToAdd(ListMultimap> entriesByEdition, - List acceptedEditions) { + List acceptedEditions, String preferredLang) { // Use standard sort + index, for better performance! Collections.sort(acceptedEditions); if (StaticData.instance().cardArtPreferenceIsLatest()) @@ -107,6 +116,20 @@ private Entry getCardEntryToAdd(ListMultimap> cardEntries = entriesByEdition.get(cardEdition); + + if (preferredLang != null) { + for (Entry entry : cardEntries) { + if (isPreferredLanguagePrint(entry.getKey(), preferredLang)) { + if (firstCandidateEntryFound == null) + firstCandidateEntryFound = entry; + candidateEntry = entry; + break; + } + } + if (candidateEntry != null) + break; + } + Iterator> entriesIterator = cardEntries.iterator(); candidateEntry = entriesIterator.hasNext() ? entriesIterator.next() : null; if (candidateEntry != null && firstCandidateEntryFound == null) @@ -123,6 +146,14 @@ private Entry getCardEntryToAdd(ListMultimap itemManager) { diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java b/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java index 5d6b80f25651..890accb91be3 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java @@ -18,11 +18,17 @@ package forge.itemmanager; import com.google.common.collect.Lists; +import forge.StaticData; +import forge.card.CardEdition; import forge.gui.GuiUtils; import forge.gui.UiCommand; +import forge.gui.download.CdnUuidCache; +import forge.item.IPaperCard; import forge.item.InventoryItem; import forge.item.PaperCard; import forge.itemmanager.filters.ItemFilter; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; import forge.itemmanager.views.ImageView; import forge.itemmanager.views.ItemListView; import forge.itemmanager.views.ItemTableColumn; @@ -998,7 +1004,35 @@ public void refresh() { } protected Iterable> getUnique(final Iterable> items) { - return Aggregates.uniqueByLast(items, from -> from.getKey().getName()); + if (!FModel.getPreferences().getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)) { + return Aggregates.uniqueByLast(items, from -> from.getKey().getName()); + } + final String preferredLang = FModel.getPreferences().getPref(FPref.UI_CARD_DOWNLOAD_LANG); + if (preferredLang == null || preferredLang.isEmpty() || "en".equalsIgnoreCase(preferredLang)) { + return Aggregates.uniqueByLast(items, from -> from.getKey().getName()); + } + + final Map> uniques = new LinkedHashMap<>(); + for (final Entry entry : items) { + final String key = entry.getKey().getName(); + final Entry existing = uniques.get(key); + if (existing == null || isPreferredLanguagePrint(entry.getKey(), preferredLang)) { + uniques.put(key, entry); + } + } + return uniques.values(); + } + + private boolean isPreferredLanguagePrint(final T item, final String preferredLang) { + if (!(item instanceof IPaperCard)) { + return false; + } + final IPaperCard card = (IPaperCard) item; + final CardEdition edition = StaticData.instance().getEditions().get(card.getEdition()); + if (edition == null) { + return false; + } + return CdnUuidCache.isAvailableInLanguage(edition.getScryfallCode(), card.getCollectorNumber(), preferredLang); } /** diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 16f05906123d..429effb2fe4d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -10,6 +10,7 @@ import forge.gamemodes.net.server.FServerManager; import forge.gui.GuiBase; import forge.gui.UiCommand; +import forge.gui.download.CdnUuidCache; import forge.gui.framework.FScreen; import forge.gui.framework.ICDoc; import forge.localinstance.properties.ForgeConstants; @@ -205,6 +206,7 @@ public void initialize() { initializeDefaultFontSizeComboBox(); initializeCardArtFormatComboBox(); initializeCardArtPreference(); + initializeCardDownloadLanguageComboBox(); initializeAutoUpdaterComboBox(); initializeServerUPnPComboBox(); initializeMulliganRuleComboBox(); @@ -418,6 +420,46 @@ private void initializeAutoUpdaterComboBox() { panel.setComboBox(comboBox, selectedItem); } + private void initializeCardDownloadLanguageComboBox() { + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + final String[] localizedOptions = cardLangMapping.keySet().toArray(new String[0]); + + final FPref cardLangPreference = FPref.UI_CARD_DOWNLOAD_LANG; + + final FComboBoxPanel panel = this.view.getCbpCardDownloadLangComboBoxPanel(); + final FComboBox comboBox = createLocalizedComboBox(localizedOptions, cardLangPreference, cardLangMapping); + comboBox.addItemListener(e -> applyPreferredLanguageAvailability()); + + final String savedCode = this.prefs.getPref(cardLangPreference); + final String selectedDisplayName = cardLangMapping.entrySet().stream() + .filter(entry -> entry.getValue().equals(savedCode)) + .map(Map.Entry::getKey) + .findFirst() + .orElse("English"); + + panel.setComboBox(comboBox, selectedDisplayName); + + final JCheckBox cbPreferLang = this.view.getCbPreferLangForUniqueCards(); + cbPreferLang.setSelected(this.prefs.getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)); + cbPreferLang.addItemListener(e -> { + this.prefs.setPref(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, String.valueOf(cbPreferLang.isSelected())); + this.prefs.save(); + applyPreferredLanguageAvailability(); + }); + + applyPreferredLanguageAvailability(); + } + + private void applyPreferredLanguageAvailability() { + String langCode = this.prefs.getPref(FPref.UI_CARD_DOWNLOAD_LANG); + boolean preferForUnique = this.prefs.getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + private void initializeServerUPnPComboBox() { // Step 1: Define the localized strings and mappings final Map upnpPreferenceMapping = ForgeConstants.getUPnPPreferenceMapping(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java index 7e40b622bcd6..8a17e3a9806b 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java @@ -12,6 +12,8 @@ import forge.gui.util.SOptionPane; import forge.item.PaperCard; import forge.itemmanager.SFilterUtil; +import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.localinstance.skin.FSkinProp; import forge.model.FModel; import forge.toolbox.*; @@ -54,6 +56,9 @@ public class DialogDownloadCardImages { private FPanel mainPanel; private FButton btnDownload; private FButton btnSyncBulkData; + private FComboBox cbxIndexLang; + private FButton btnSyncBulkDataLang; + private FCheckBox cbPreferLangForUnique; private FButton btnClearCdnCache; public void show() { @@ -93,7 +98,38 @@ private void buildMainPanel() { btnDownload.addActionListener(e -> startDownload()); btnSyncBulkData = new FButton(localizer.getMessage("btnSyncBulkCardData")); - btnSyncBulkData.addActionListener(e -> startBulkSync()); + btnSyncBulkData.addActionListener(e -> startBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English")); + + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + cbxIndexLang = new FComboBox<>(); + for (Map.Entry entry : cardLangMapping.entrySet()) { + if (!"en".equalsIgnoreCase(entry.getValue())) { + cbxIndexLang.addItem(entry.getKey()); + } + } + final String savedLangCode = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + cardLangMapping.entrySet().stream() + .filter(entry -> entry.getValue().equals(savedLangCode)) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(cbxIndexLang::setSelectedItem); + + cbPreferLangForUnique = new FCheckBox(localizer.getMessage("cbPreferLangForUniqueCards"), + FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)); + cbPreferLangForUnique.setToolTipText(localizer.getMessage("nlPreferLangForUniqueCards")); + + btnSyncBulkDataLang = new FButton(localizer.getMessage("btnSyncBulkCardDataLang")); + btnSyncBulkDataLang.addActionListener(e -> { + String selectedLangName = (String) cbxIndexLang.getSelectedItem(); + String selectedLangCode = cardLangMapping.get(selectedLangName); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG, selectedLangCode); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, + String.valueOf(cbPreferLangForUnique.isSelected())); + FModel.getPreferences().save(); + applyPreferredLanguageAvailability(selectedLangCode); + startBulkSync(ScryfallBulkDataSync.BULK_TYPE_ALL_CARDS, + new java.util.HashSet<>(java.util.Arrays.asList("en", selectedLangCode)), selectedLangName); + }); btnClearCdnCache = new FButton(localizer.getMessage("btnClearCdnImageCache")); btnClearCdnCache.addActionListener(e -> clearCdnCache()); @@ -112,11 +148,14 @@ private void buildMainPanel() { mainPanel.add(lblTotal, "w 480!, gaptop 15, center"); mainPanel.add(lblDownloaded, "w 480!, center"); mainPanel.add(lblMissing, "w 480!, center, gapbottom 10"); - mainPanel.add(btnDownload, "w 300!, h 32!, center, gaptop 10"); - mainPanel.add(btnSyncBulkData, "w 300!, h 32!, center, gaptop 8"); + mainPanel.add(btnDownload, "w 460!, h 32!, center, gaptop 10"); + mainPanel.add(btnSyncBulkData, "w 460!, h 32!, center, gaptop 8"); + mainPanel.add(cbxIndexLang, "w 300!, h 32!, split 2, gaptop 8"); + mainPanel.add(btnSyncBulkDataLang, "w 154!, h 32!"); + mainPanel.add(cbPreferLangForUnique, "w 460!, center, gaptop 2"); mainPanel.add(bulkSyncProgress, "w 460!, h 26!, center, gaptop 6"); - mainPanel.add(btnClearCdnCache, "w 300!, h 32!, center, gaptop 8"); - mainPanel.add(btnClose, "w 300!, h 32!, center, gaptop 8, gapbottom 15"); + mainPanel.add(btnClearCdnCache, "w 460!, h 32!, center, gaptop 8"); + mainPanel.add(btnClose, "w 460!, h 32!, center, gaptop 8, gapbottom 15"); } private void showMainOverlay() { @@ -191,20 +230,30 @@ private void clearCdnCache() { /** Called after {@link #show()} to open straight into a confirmed bulk sync (e.g. the first-run prompt). */ public void showAndAutoStartBulkSync() { show(); - runBulkSync(); + runBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English"); + } + + private void applyPreferredLanguageAvailability(String langCode) { + boolean preferForUnique = cbPreferLangForUnique.isSelected(); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } } - /** Resolves CDN links for every set at once from Scryfall's bulk data export, instead of one set at a time. */ - private void startBulkSync() { - if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblSyncBulkCardDataConfirm"))) { + /** Resolves CDN links for every set at once from an online bulk index, instead of one set at a time. */ + private void startBulkSync(String bulkDataType, java.util.Set allowedLangs, String langLabel) { + if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblSyncBulkCardDataConfirm", ScryfallBulkDataSync.approxSizeLabel(bulkDataType)))) { return; } - runBulkSync(); + runBulkSync(bulkDataType, allowedLangs, langLabel); } - private void runBulkSync() { + private void runBulkSync(String bulkDataType, java.util.Set allowedLangs, String langLabel) { btnDownload.setEnabled(false); btnSyncBulkData.setEnabled(false); + btnSyncBulkDataLang.setEnabled(false); btnClearCdnCache.setEnabled(false); bulkSyncProgress.reset(); @@ -215,7 +264,7 @@ private void runBulkSync() { bulkSyncProgress.setDescription("Starting..."); FThreads.invokeInBackgroundThread(() -> { - int setCount = ScryfallBulkDataSync.sync( + int setCount = ScryfallBulkDataSync.sync(bulkDataType, allowedLangs, (message, fraction) -> FThreads.invokeInEdtLater(() -> { bulkSyncProgress.setDescription(message); if (fraction >= 0) { @@ -229,11 +278,12 @@ private void runBulkSync() { FThreads.invokeInEdtLater(() -> { btnDownload.setEnabled(true); btnSyncBulkData.setEnabled(true); + btnSyncBulkDataLang.setEnabled(true); btnClearCdnCache.setEnabled(true); bulkSyncProgress.setIndeterminate(false); if (setCount >= 0) { bulkSyncProgress.setValue(100); - bulkSyncProgress.setDescription(localizer.getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets)"); + bulkSyncProgress.setDescription(localizer.getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets) - " + langLabel); scheduleStatsUpdate(); } else { bulkSyncProgress.setDescription("Bulk sync failed -- see log for details."); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index f2db42391ad8..4fbc963e2a58 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -87,6 +87,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbSROptimize = new OptionsCheckBox(localizer.getMessage("cbSROptimize")); private final JCheckBox cbFilteredHands = new OptionsCheckBox(localizer.getMessage("cbFilteredHands")); private final JCheckBox cbImageFetcher = new OptionsCheckBox(localizer.getMessage("cbImageFetcher")); + private final JCheckBox cbPreferLangForUniqueCards = new OptionsCheckBox(localizer.getMessage("cbPreferLangForUniqueCards")); private final JCheckBox cbDisableCardImages = new OptionsCheckBox(localizer.getMessage("lblDisableCardImages")); private final JCheckBox cbCloneImgSource = new OptionsCheckBox(localizer.getMessage("cbCloneImgSource")); private final JCheckBox cbScaleLarger = new OptionsCheckBox(localizer.getMessage("cbScaleLarger")); @@ -146,6 +147,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final FComboBoxPanel cbpDefaultFontSize = new FComboBoxPanel<>(localizer.getMessage("cbpDefaultFontSize")+":"); private final FComboBoxPanel cbpCardArtFormat = new FComboBoxPanel<>(localizer.getMessage("cbpCardArtFormat")+":"); private final FComboBoxPanel cbpCardArtPreference = new FComboBoxPanel<>(localizer.getMessage("lblPreferredArt")+":"); + private final FComboBoxPanel cbpCardDownloadLang = new FComboBoxPanel<>(localizer.getMessage("cbpCardDownloadLang")+":"); private final FComboBoxPanel cbpMulliganRule = new FComboBoxPanel<>(localizer.getMessage("cbpMulliganRule")+":"); private final FComboBoxPanel cbpSoundSets = new FComboBoxPanel<>(localizer.getMessage("cbpSoundSets")+":"); private final FComboBoxPanel cbpMusicSets = new FComboBoxPanel<>(localizer.getMessage("cbpMusicSets")+":"); @@ -397,6 +399,12 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbImageFetcher, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlImageFetcher")), descriptionConstraints); + pnlPrefs.add(cbpCardDownloadLang, comboBoxConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlCardDownloadLang")), descriptionConstraints); + + pnlPrefs.add(cbPreferLangForUniqueCards, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlPreferLangForUniqueCards")), descriptionConstraints); + pnlPrefs.add(cbDisableCardImages, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlDisableCardImages")), descriptionConstraints); @@ -783,6 +791,14 @@ public JCheckBox getCbImageFetcher() { return cbImageFetcher; } + public JCheckBox getCbPreferLangForUniqueCards() { + return cbPreferLangForUniqueCards; + } + + public FComboBoxPanel getCbpCardDownloadLangComboBoxPanel() { + return cbpCardDownloadLang; + } + /** @return {@link javax.swing.JCheckBox} */ public JCheckBox getCbDisableCardImages() { return cbDisableCardImages; diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java index 15c822f284d3..5d54fcbfb18f 100644 --- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java +++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java @@ -16,6 +16,8 @@ import forge.item.PaperCard; import forge.itemmanager.SFilterUtil; import forge.itemmanager.filters.ArchivedFormatSelect; +import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; import forge.screens.FScreen; import forge.toolbox.*; @@ -34,6 +36,7 @@ public class CardImageBrowserScreen extends FScreen { private static final float BTN_HEIGHT = Math.round(Utils.AVG_FINGER_HEIGHT * 0.9f); private static final FSkinFont STAT_FONT = FSkinFont.get(16); + private final FScrollPane scroller; private final FTextField txtSearch; private final FComboBox cbxFormats; private final FLabel lblTotal; @@ -41,6 +44,9 @@ public class CardImageBrowserScreen extends FScreen { private final FLabel lblMissing; private final FButton btnDownload; private final FButton btnSyncBulkData; + private final FComboBox cbxIndexLang; + private final FButton btnSyncBulkDataLang; + private final FCheckBox cbPreferLangForUnique; private final FProgressBar bulkSyncProgress; private final FButton btnClearCdnCache; @@ -55,8 +61,57 @@ public class CardImageBrowserScreen extends FScreen { public CardImageBrowserScreen() { super(Forge.getLocalizer().getMessage("btnDownloadCardImages")); + scroller = add(new FScrollPane() { + @Override + protected ScrollBounds layoutAndGetScrollBounds(float visibleWidth, float visibleHeight) { + float x = PADDING; + float y = PADDING; + float w = visibleWidth - 2 * PADDING; + + txtSearch.setBounds(x, y, w, FIELD_HEIGHT); + y += FIELD_HEIGHT + PADDING; + + cbxFormats.setBounds(x, y, w, FIELD_HEIGHT); + y += FIELD_HEIGHT + PADDING * 5; + + lblTotal.setBounds(x, y, w, STAT_HEIGHT); + y += STAT_HEIGHT + PADDING; + + lblDownloaded.setBounds(x, y, w, STAT_HEIGHT); + y += STAT_HEIGHT + PADDING; + + lblMissing.setBounds(x, y, w, STAT_HEIGHT); + y += STAT_HEIGHT + PADDING * 5; + + float btnW = Math.min(w * 0.6f, Utils.AVG_FINGER_HEIGHT * 4); + btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + btnSyncBulkData.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + cbxIndexLang.setBounds(x, y, w, FIELD_HEIGHT); + y += FIELD_HEIGHT + PADDING; + + btnSyncBulkDataLang.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + float checkboxHeight = Math.round(Utils.AVG_FINGER_HEIGHT * 0.6f); + cbPreferLangForUnique.setBounds(x, y, w, checkboxHeight); + y += checkboxHeight + PADDING; + + bulkSyncProgress.setBounds(x, y, w, STAT_HEIGHT * 0.8f); + y += STAT_HEIGHT * 0.8f + PADDING; + + btnClearCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + return new ScrollBounds(visibleWidth, y); + } + }); + // ── Search text field ──────────────────────────────────────────────── - txtSearch = add(new FTextField()); + txtSearch = scroller.add(new FTextField()); txtSearch.setFont(FSkinFont.get(12)); txtSearch.setGhostText(Forge.getLocalizer().getMessage("lblSearch") + " " + Forge.getLocalizer().getMessage("lblCards") + "..."); @@ -65,7 +120,7 @@ public CardImageBrowserScreen() { txtSearch.setChangedHandler(e -> scheduleStatsUpdate()); // ── Format / Sets combo — same options as the deck-browser filter ──── - cbxFormats = add(new FComboBox<>()); + cbxFormats = scroller.add(new FComboBox<>()); cbxFormats.setFont(FSkinFont.get(12)); cbxFormats.addItem(Forge.getLocalizer().getMessage("lblAllSetsFormats")); for (GameFormat fmt : FModel.getFormats().getFilterList()) { @@ -113,21 +168,51 @@ public CardImageBrowserScreen() { }); // ── Stats labels ───────────────────────────────────────────────────── - lblTotal = add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); - lblDownloaded = add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); - lblMissing = add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); + lblTotal = scroller.add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); + lblDownloaded = scroller.add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); + lblMissing = scroller.add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); // ── Download button ────────────────────────────────────────────────── - btnDownload = add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages"))); + btnDownload = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages"))); btnDownload.setCommand(e -> startDownload()); - // ── Bulk data sync button ──────────────────────────────────────────── - btnSyncBulkData = add(new FButton(Forge.getLocalizer().getMessage("btnSyncBulkCardData"))); - btnSyncBulkData.setCommand(e -> startBulkSync()); - bulkSyncProgress = add(new FProgressBar()); + // ── Bulk data sync buttons ─────────────────────────────────────────── + btnSyncBulkData = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnSyncBulkCardData"))); + btnSyncBulkData.setCommand(e -> startBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English")); + + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + cbxIndexLang = scroller.add(new FComboBox<>()); + cbxIndexLang.setFont(FSkinFont.get(12)); + for (Map.Entry entry : cardLangMapping.entrySet()) { + if (!"en".equalsIgnoreCase(entry.getValue())) { + cbxIndexLang.addItem(entry.getKey()); + } + } + final String savedLangCode = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + cardLangMapping.entrySet().stream() + .filter(entry -> entry.getValue().equals(savedLangCode)) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(cbxIndexLang::setSelectedItem); + + cbPreferLangForUnique = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("cbPreferLangForUniqueCards"), + FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS))); + + btnSyncBulkDataLang = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnSyncBulkCardDataLang"))); + btnSyncBulkDataLang.setCommand(e -> { + String selectedLangName = cbxIndexLang.getSelectedItem(); + String selectedLangCode = cardLangMapping.get(selectedLangName); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG, selectedLangCode); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, + String.valueOf(cbPreferLangForUnique.isSelected())); + FModel.getPreferences().save(); + applyPreferredLanguageAvailability(selectedLangCode); + startBulkSync(ScryfallBulkDataSync.BULK_TYPE_ALL_CARDS, new HashSet<>(Arrays.asList("en", selectedLangCode)), selectedLangName); + }); + bulkSyncProgress = scroller.add(new FProgressBar()); // ── Clear CDN cache button ───────────────────────────────────────────── - btnClearCdnCache = add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache"))); + btnClearCdnCache = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache"))); btnClearCdnCache.setCommand(e -> clearCdnCache()); // Run initial stats for "All cards, all sets" as soon as the screen opens @@ -190,28 +275,38 @@ public static void openAndAutoStartBulkSync() { FThreads.invokeInEdtLater(() -> { CardImageBrowserScreen screen = new CardImageBrowserScreen(); Forge.openScreen(screen); - screen.runBulkSync(); + screen.runBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English"); }); } - private void startBulkSync() { + private void applyPreferredLanguageAvailability(String langCode) { + boolean preferForUnique = cbPreferLangForUnique.isSelected(); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + + private void startBulkSync(String bulkDataType, Set allowedLangs, String langLabel) { // SOptionPane.showConfirmDialog() blocks its caller while the dialog renders on the EDT, // so it must never be called directly from a tap handler (which runs on the EDT itself) // -- that throws immediately and the whole method aborts before any UI update happens. FThreads.invokeInBackgroundThread(() -> { - if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblSyncBulkCardDataConfirm"))) { + if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblSyncBulkCardDataConfirm", ScryfallBulkDataSync.approxSizeLabel(bulkDataType)))) { return; } - runBulkSync(); + runBulkSync(bulkDataType, allowedLangs, langLabel); }); } /** Runs the sync itself; always hops onto its own background thread, so it's safe to call from the EDT or not. */ - private void runBulkSync() { + private void runBulkSync(String bulkDataType, Set allowedLangs, String langLabel) { FThreads.invokeInBackgroundThread(() -> { FThreads.invokeInEdtLater(() -> { btnDownload.setEnabled(false); btnSyncBulkData.setEnabled(false); + btnSyncBulkDataLang.setEnabled(false); btnClearCdnCache.setEnabled(false); bulkSyncProgress.reset(); bulkSyncProgress.setMaximum(100); @@ -221,7 +316,7 @@ private void runBulkSync() { bulkSyncProgress.setShowProgressTrail(true); }); - int setCount = ScryfallBulkDataSync.sync( + int setCount = ScryfallBulkDataSync.sync(bulkDataType, allowedLangs, (message, fraction) -> FThreads.invokeInEdtLater(() -> { bulkSyncProgress.setDescription(message); if (fraction >= 0) { @@ -235,11 +330,12 @@ private void runBulkSync() { FThreads.invokeInEdtLater(() -> { btnDownload.setEnabled(true); btnSyncBulkData.setEnabled(true); + btnSyncBulkDataLang.setEnabled(true); btnClearCdnCache.setEnabled(true); bulkSyncProgress.setShowProgressTrail(false); if (setCount >= 0) { bulkSyncProgress.setValue(100); - bulkSyncProgress.setDescription(Forge.getLocalizer().getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets)"); + bulkSyncProgress.setDescription(Forge.getLocalizer().getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets) - " + langLabel); scheduleStatsUpdate(); } else { bulkSyncProgress.setDescription("Bulk sync failed -- see log for details."); @@ -284,36 +380,7 @@ private Predicate buildCurrentFilter() { @Override protected void doLayout(float startY, float width, float height) { - float x = PADDING; - float y = startY + PADDING; - float w = width - 2 * PADDING; - - txtSearch.setBounds(x, y, w, FIELD_HEIGHT); - y += FIELD_HEIGHT + PADDING; - - cbxFormats.setBounds(x, y, w, FIELD_HEIGHT); - y += FIELD_HEIGHT + PADDING * 5; - - lblTotal.setBounds(x, y, w, STAT_HEIGHT); - y += STAT_HEIGHT + PADDING; - - lblDownloaded.setBounds(x, y, w, STAT_HEIGHT); - y += STAT_HEIGHT + PADDING; - - lblMissing.setBounds(x, y, w, STAT_HEIGHT); - y += STAT_HEIGHT + PADDING * 5; - - float btnW = Math.min(w * 0.6f, Utils.AVG_FINGER_HEIGHT * 4); - btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); - y += BTN_HEIGHT + PADDING; - - btnSyncBulkData.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); - y += BTN_HEIGHT + PADDING; - - bulkSyncProgress.setBounds(x, y, w, STAT_HEIGHT * 0.8f); - y += STAT_HEIGHT * 0.8f + PADDING; - - btnClearCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + scroller.setBounds(0, startY, width, height - startY); } // ========================================================================= diff --git a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java index 14e073289442..4c2de3930647 100644 --- a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java +++ b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java @@ -12,6 +12,7 @@ import forge.game.GameLogEntryType; import forge.game.GameLogVerbosity; import forge.gui.GuiBase; +import forge.gui.download.CdnUuidCache; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgeNetPreferences; import forge.localinstance.properties.ForgePreferences; @@ -507,6 +508,25 @@ public void select() { lstSettings.addItem(new BooleanSetting(FPref.UI_ENABLE_ONLINE_IMAGE_FETCHER, Forge.getLocalizer().getMessage("cbImageFetcher"), Forge.getLocalizer().getMessage("nlImageFetcher")), 4); + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + lstSettings.addItem(new CustomSelectSetting(FPref.UI_CARD_DOWNLOAD_LANG, "Card art language", + "Preferred language for downloaded card images", + cardLangMapping.values()) { + @Override + public void valueChanged(String newValue) { + super.valueChanged(newValue); + applyPreferredLanguageAvailability(); + } + }, 4); + lstSettings.addItem(new BooleanSetting(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, + "Prefer language for unique cards", + "When enabled, prioritizes cards available in the selected language for unique art") { + @Override + public void select() { + super.select(); + applyPreferredLanguageAvailability(); + } + }, 4); lstSettings.addItem(new CustomSelectSetting(FPref.UI_PREFERRED_ART, Forge.getLocalizer().getMessage("lblPreferredArt"), Forge.getLocalizer().getMessage("nlPreferredArt"), @@ -743,6 +763,16 @@ public void refreshCJKFontsList() { settingCJKFonts.updateOptions(FSkinFont.getAllCJKFonts()); } + private void applyPreferredLanguageAvailability() { + String langCode = FModel.getPreferences().getPref(FPref.UI_CARD_DOWNLOAD_LANG); + boolean preferForUnique = FModel.getPreferences().getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + private void applySearch() { final String query = txtSearch.getText().toLowerCase().trim(); if (query.isEmpty()) { diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index e0100d22876c..95eb6b941088 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -107,7 +107,8 @@ cbPerformanceMode=Performance-Modus cbOrderHand=Keep Hand ordered cbFilteredHands=Alternative Starthand cbImageFetcher=Lade automatisch fehlende Kartenbilder -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Kartenbildsprache +cbPreferLangForUniqueCards=Meine Sprache für einzigartige Karten bevorzugen cbCloneImgSource=Klone zeigen eigenes Kartenbild cbScaleLarger=Skaliere Bilder größer cbRenderBlackCardBorders=Zeige schwarzen Rand @@ -246,7 +247,8 @@ nlDefaultFontSize=Die Standardschriftgröße. Alle Schriftelemente werden relati nlCardArtFormat=Format der Kartenbilder (Full: Bilder ganzen Karte; Crop: nur Bildteil der Karte) cbpMulliganRule=Mulligan Regel nlImageFetcher=Ermöglicht bei bestehender Onlineverbindung das automatisches Nachladen fehlender Kartenbilder. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=Die bevorzugte Sprache zum Herunterladen von Kartenbildern, sofern verfügbar. Fällt auf Englisch zurück, wenn die gewählte Sprache für eine bestimmte Karte nicht gedruckt wurde. +nlPreferLangForUniqueCards=Bevorzugt bei der Anzeige nur einzigartiger Karten einen Druck, der nachweislich in deiner Kartenbildsprache existiert, gegenüber der Standard-Motivauswahl. nlDisplayFoil=Zeige FOIL-Karten mit einem optischen FOIL-Effekt. nlRandomFoil=Zeige den FOIL-Effekt bei zufälligen Karten. nlScaleLarger=Erlaubt Kartenbilder größer als ihre originale Größe zu zeigen. @@ -337,10 +339,11 @@ btnClearCdnImageCache=CDN-Bildzuordnung leeren lblClearCdnImageCache=Löscht den lokalen CDN-Bildzuordnungs-Cache. Nützlich, wenn manche Karten weiterhin über den langsameren Ausweichpfad geladen werden, obwohl ihre Bilder eigentlich verfügbar sein sollten -- Forge ruft die Daten danach bei Bedarf erneut ab. lblClearCdnImageCacheConfirm=Dies löscht den lokalen CDN-Bildzuordnungs-Cache. Forge ruft ihn bei Bedarf erneut ab. Fortfahren? lblCdnImageCacheCleared=CDN-Bildzuordnungs-Cache geleert. -btnSyncBulkCardData=Massendownload (~350 MB) -lblSyncBulkCardDataConfirm=Dies lädt Scryfalls vollständige Kartendatenbank (~350 MB) herunter, um den CDN-Bildlink jeder Karte auf einmal aufzulösen, statt Set für Set. Fortfahren? +btnSyncBulkCardData=Index herunterladen - Nur Englisch (~75 MB) +btnSyncBulkCardDataLang=Index herunterladen +lblSyncBulkCardDataConfirm=Dies lädt einen vollständigen Kartenbild-Index von einer Online-Quelle herunter, um den Bildlink jeder Karte auf einmal aufzulösen, statt Set für Set. Fortfahren? lblBulkCardDataSynced=Kartendaten synchronisiert. -lblFirstRunBulkCdnPrompt=Forge kann jetzt Scryfalls vollständige Kartendatenbank herunterladen (~350 MB, einmalig), damit künftige Kartenbild-Downloads sofort statt Set für Set aufgelöst werden. Jetzt herunterladen? +lblFirstRunBulkCdnPrompt=Forge kann jetzt einen vollständigen Kartenbild-Index (einmalig, nur Englisch) von einer Online-Quelle herunterladen, damit künftige Kartenbild-Downloads sofort statt Set für Set aufgelöst werden. Es gibt auch einen mehrsprachigen Index -- dafür unter "Inhalte herunterladen" > "Kartenbilder herunterladen" gehen. Jetzt herunterladen? lblDownloadCJKFonts=Lade Schriftarten zur lokalen Nutzung von CJK-Zeichen herunter. lblDownloadCJKFontPrompt=Wähle eine Schriftart zum Herunterladen aus. lblYourVersionOfJavaIsTooOld=Deine Java-Version ist leider zu alt. diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index a1cd33f0983a..f0b45a59b8ea 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -105,6 +105,8 @@ cbPerformanceMode=Performance Mode cbOrderHand=Keep Hand ordered cbFilteredHands=Filtered Hands cbImageFetcher=Automatically Download Missing Card Art +cbpCardDownloadLang=Card Image Language +cbPreferLangForUniqueCards=Prefer My Language for Unique Cards cbCloneImgSource=Clones Use Original Card Art cbScaleLarger=Scale Image Larger cbRenderBlackCardBorders=Render Black Card Borders @@ -241,6 +243,8 @@ nlCardArtFormat=The format of card art images. (Full: image of entire card. Crop nlDefaultFontSize=The default font size within the UI. All font elements are scaled relative to this. (REQUIRES RESTART) cbpMulliganRule=Mulligan Rule nlImageFetcher=Enables live fetching of missing card images from an online resource. +nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlPreferLangForUniqueCards=When showing only unique cards, prefer a printing confirmed to exist in your card image language over the default art selection. nlDisplayFoil=Displays foil cards with the visual foil overlay effect. nlRandomFoil=Adds foil effect to random cards. nlScaleLarger=Allows card pictures to be expanded larger than their original size. @@ -331,10 +335,11 @@ btnClearCdnImageCache=Clear CDN Image Lookup Cache lblClearCdnImageCache=Delete the local CDN image lookup cache. lblClearCdnImageCacheConfirm=This deletes the local CDN image lookup cache. Forge will re-fetch it as needed. Continue? lblCdnImageCacheCleared=CDN image lookup cache cleared. -btnSyncBulkCardData=Download Bulk (~350MB) -lblSyncBulkCardDataConfirm=This downloads Scryfall''s full card database (~350 MB) to resolve every card''s CDN image link at once, instead of one set at a time. Continue? +btnSyncBulkCardData=Download Index - English Only (~75MB) +btnSyncBulkCardDataLang=Download Index +lblSyncBulkCardDataConfirm=This downloads a full card image index (~{0}) from an online source to resolve every card''s image link at once, instead of one set at a time. Continue? lblBulkCardDataSynced=Card data synced. -lblFirstRunBulkCdnPrompt=Forge can download Scryfall''s full card database now (~350 MB, one time) so that future card image downloads resolve instantly instead of one set at a time. Download it now? +lblFirstRunBulkCdnPrompt=Forge can download a full card image index now (one time, English only) from an online source, so that future card image downloads resolve instantly instead of one set at a time. A multi-language index is also available -- for that, go to Download Content > Download Card Images. Download it now? lblDownloadCJKFonts=Download fonts for locales using CJK glyphs. lblDownloadCJKFontPrompt=Select a font file to download lblYourVersionOfJavaIsTooOld=Your version of Java is too old to use the content downloaders. diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index ebee282dc27a..faf22ed9f32e 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -83,6 +83,7 @@ cbOrderHand=Keep Hand ordered cbFilteredHands=Manos filtradas cbImageFetcher=Descargar automáticamente el arte de la carta si no existe cbpCardDownloadLang=Idioma de la imagen de carta +cbPreferLangForUniqueCards=Preferir mi idioma en cartas únicas cbCloneImgSource=Clones usan el arte original de la carta cbScaleLarger=Imagen de escala más grande cbRenderBlackCardBorders=Renderizar bordes de cartas negras @@ -222,6 +223,7 @@ nlCardArtFormat=El formato de las imágenes del arte de la carta. (Full: imagen cbpMulliganRule=Regla de mulligan nlImageFetcher=Permite la descarga al vuelo de las imágenes de cartas que falten. nlCardDownloadLang=El idioma preferido para descargar las imágenes de las cartas cuando esté disponible. Si la carta no se imprimió en el idioma seleccionado, se usará el inglés como alternativa. +nlPreferLangForUniqueCards=Al mostrar solo cartas únicas, prioriza una impresión confirmada en tu idioma de imagen de carta sobre la selección de arte por defecto. nlDisplayFoil=Mostrar las cartas foil con un capa sobre la carta que da efecto foil nlRandomFoil=Agrega efecto de foil a cartas aleatorias. nlScaleLarger=Permite que las imágenes de las cartas se amplíen más que su tamaño original. @@ -312,10 +314,11 @@ btnClearCdnImageCache=Borrar caché de búsqueda de imágenes CDN lblClearCdnImageCache=Elimina la caché local de búsqueda de imágenes CDN. Úsalo si algunas cartas siguen resolviéndose por la vía alternativa más lenta aunque sus imágenes deberían estar disponibles -- Forge la volverá a obtener cuando sea necesario. lblClearCdnImageCacheConfirm=Esto elimina la caché local de búsqueda de imágenes CDN. Forge la volverá a obtener cuando sea necesario. ¿Continuar? lblCdnImageCacheCleared=Caché de búsqueda de imágenes CDN borrada. -btnSyncBulkCardData=Descarga masiva (~350 MB) -lblSyncBulkCardDataConfirm=Esto descarga la base de datos completa de cartas de Scryfall (~350 MB) para resolver el enlace de imagen CDN de todas las cartas a la vez, en lugar de una edición a la vez. ¿Continuar? +btnSyncBulkCardData=Descargar índice - Solo inglés (~75 MB) +btnSyncBulkCardDataLang=Descargar índice +lblSyncBulkCardDataConfirm=Esto descarga un índice completo de imágenes de cartas (~{0}) desde una fuente online para resolver el enlace de imagen de todas las cartas a la vez, en lugar de una edición a la vez. ¿Continuar? lblBulkCardDataSynced=Datos de cartas sincronizados. -lblFirstRunBulkCdnPrompt=Forge puede descargar ahora la base de datos completa de cartas de Scryfall (~350 MB, solo una vez) para que las futuras descargas de imágenes de cartas se resuelvan al instante en lugar de una edición a la vez. ¿Descargarla ahora? +lblFirstRunBulkCdnPrompt=Forge puede descargar ahora un índice completo de imágenes de cartas (solo una vez, solo inglés) desde una fuente online, para que las futuras descargas de imágenes de cartas se resuelvan al instante en lugar de una edición a la vez. También existe un índice multiidioma -- para eso, ve a Descargar contenido > Descargar imágenes de cartas. ¿Descargarlo ahora? lblDownloadCJKFonts=Descargar fuentes para idiomas que utilicen glifos CJK. lblDownloadCJKFontPrompt=Selecciona un archivo de fuente para descargar lblYourVersionOfJavaIsTooOld=Tu versión de Java es demasiado antigua para usar los descargadores de contenido. diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index 83a95da8345f..a781a1cbb8d0 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -82,7 +82,8 @@ cbPerformanceMode=Mode Performances cbOrderHand=Keep Hand ordered cbFilteredHands=Mains filtrées cbImageFetcher=Télécharger automatiquement l'illustration de la carte manquante -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Langue des images de cartes +cbPreferLangForUniqueCards=Préférer ma langue pour les cartes uniques cbCloneImgSource=Les clones utilisent l'illustration originale de la carte cbScaleLarger=Agrandir l'image cbRenderBlackCardBorders=Rendre les bordures noires de la carte @@ -221,7 +222,8 @@ nlCardArtFormat=Le format des images d'art de la carte. (Plein : image de la car nlDefaultFontSize=La taille de police par défaut dans l'interface utilisateur. Tous les éléments de police sont mis à l'échelle par rapport à cela. (NÉCESSITE UN REDÉMARRAGE) cbpMulliganRule=règle de Mulligan nlImageFetcher=Active la récupération en direct des images de cartes manquantes à partir d'une ressource en ligne. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=La langue préférée pour télécharger les images de cartes, lorsqu''elle est disponible. Revient à l''anglais si la langue sélectionnée n''a pas été imprimée pour une carte donnée. +nlPreferLangForUniqueCards=Lors de l''affichage des cartes uniques uniquement, privilégie une impression confirmée dans votre langue d''image de carte plutôt que la sélection d''art par défaut. nlDisplayFoil=Affiche les cartes brillantes avec l'effet visuel de superposition de feuille. nlRandomFoil=Ajoute un effet foil aux cartes aléatoires. nlScaleLarger=Permet aux images de la carte d'être agrandies au-delà de leur taille d'origine. @@ -312,10 +314,11 @@ btnClearCdnImageCache=Vider le cache de correspondance CDN lblClearCdnImageCache=Supprime le cache local de correspondance CDN. À utiliser si certaines cartes continuent de passer par la solution de repli plus lente alors que leurs images devraient être disponibles -- Forge le régénérera au besoin. lblClearCdnImageCacheConfirm=Cette action supprime le cache local de correspondance CDN. Forge le régénérera au besoin. Continuer ? lblCdnImageCacheCleared=Cache de correspondance CDN vidé. -btnSyncBulkCardData=Téléchargement groupé (~350 Mo) -lblSyncBulkCardDataConfirm=Ceci télécharge la base de données complète des cartes de Scryfall (~350 Mo) afin de résoudre le lien d''image CDN de chaque carte en une seule fois, plutôt qu''un jeu de cartes à la fois. Continuer ? +btnSyncBulkCardData=Télécharger l''index - Anglais uniquement (~75 Mo) +btnSyncBulkCardDataLang=Télécharger l''index +lblSyncBulkCardDataConfirm=Ceci télécharge un index complet d''images de cartes (~{0}) depuis une source en ligne afin de résoudre le lien d''image de chaque carte en une seule fois, plutôt qu''un jeu de cartes à la fois. Continuer ? lblBulkCardDataSynced=Données des cartes synchronisées. -lblFirstRunBulkCdnPrompt=Forge peut télécharger dès maintenant la base de données complète des cartes de Scryfall (~350 Mo, une seule fois) afin que les prochains téléchargements d''images de cartes se résolvent instantanément plutôt qu''un jeu de cartes à la fois. Télécharger maintenant ? +lblFirstRunBulkCdnPrompt=Forge peut télécharger dès maintenant un index complet d''images de cartes (une seule fois, anglais uniquement) depuis une source en ligne, afin que les prochains téléchargements d''images de cartes se résolvent instantanément plutôt qu''un jeu de cartes à la fois. Un index multilingue est également disponible -- pour cela, allez dans Télécharger du contenu > Télécharger des images de cartes. Télécharger maintenant ? lblDownloadCJKFonts=T\u00e9l\u00e9charger les polices pour les locales utilisant les glyphes CJK. lblDownloadCJKFontPrompt=Sélectionnez un fichier de police à télécharger lblYourVersionOfJavaIsTooOld=Votre version de Java est trop ancienne pour utiliser les téléchargeurs de contenu. diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index 4013ef0707a7..17c72fb11110 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -81,7 +81,8 @@ cbPerformanceMode=Modalità alte prestazioni cbOrderHand=Keep Hand ordered cbFilteredHands=Mani Filtrate cbImageFetcher=Scarica automaticamente l'arte delle carte mancanti -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Lingua immagine carta +cbPreferLangForUniqueCards=Preferisci la mia lingua per le carte uniche cbCloneImgSource=I cloni usano l'arte della carta originale cbScaleLarger=Ingrandisci l'immagine cbRenderBlackCardBorders=Disegna i bordi neri delle carte @@ -220,7 +221,8 @@ nlDefaultFontSize=La dimensione predefinita dei caratteri nell'interfaccia utent nlCardArtFormat=Formato delle Illustrazioni delle carte. (Full: Immagine per intero della carta. Crop: Solo illustrazione) cbpMulliganRule=Tipo di Mulligan nlImageFetcher=Consente il recupero in tempo reale delle immagini mancanti da una risorsa online. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=La lingua preferita per scaricare le immagini delle carte, quando disponibile. Torna all''inglese se la lingua selezionata non è stata stampata per una determinata carta. +nlPreferLangForUniqueCards=Quando vengono mostrate solo carte uniche, preferisce una stampa confermata esistente nella lingua immagine impostata rispetto alla selezione dell''illustrazione predefinita. nlDisplayFoil=Visualizza le carte foil con l'effetto di sovrapposizione foil visivo. nlRandomFoil=Aggiunge l'effetto foil alle carte casuali. nlScaleLarger=Consente di espandere le immagini delle carte più grandi delle loro dimensioni originali. @@ -311,10 +313,11 @@ btnClearCdnImageCache=Svuota cache mappatura immagini CDN lblClearCdnImageCache=Elimina la cache locale della mappatura immagini CDN. Usalo se alcune carte continuano a essere risolte tramite il percorso alternativo più lento anche se le loro immagini dovrebbero essere disponibili -- Forge la recupererà nuovamente quando necessario. lblClearCdnImageCacheConfirm=Questa operazione elimina la cache locale della mappatura immagini CDN. Forge la recupererà nuovamente quando necessario. Continuare? lblCdnImageCacheCleared=Cache della mappatura immagini CDN svuotata. -btnSyncBulkCardData=Download in blocco (~350 MB) -lblSyncBulkCardDataConfirm=Questa operazione scarica il database completo delle carte di Scryfall (~350 MB) per risolvere il collegamento immagine CDN di tutte le carte in una sola volta, invece che un''espansione alla volta. Continuare? +btnSyncBulkCardData=Scarica indice - Solo inglese (~75 MB) +btnSyncBulkCardDataLang=Scarica indice +lblSyncBulkCardDataConfirm=Questa operazione scarica un indice completo di immagini delle carte da una fonte online per risolvere il collegamento immagine di tutte le carte in una sola volta, invece che un''espansione alla volta. Continuare? lblBulkCardDataSynced=Dati delle carte sincronizzati. -lblFirstRunBulkCdnPrompt=Forge può scaricare subito il database completo delle carte di Scryfall (~350 MB, una tantum) in modo che i futuri download delle immagini delle carte vengano risolti istantaneamente invece che un''espansione alla volta. Scaricarlo ora? +lblFirstRunBulkCdnPrompt=Forge può scaricare subito un indice completo di immagini delle carte (una tantum, solo inglese) da una fonte online, in modo che i futuri download delle immagini delle carte vengano risolti istantaneamente invece che un''espansione alla volta. È disponibile anche un indice multilingue -- per ottenerlo, vai su Scarica contenuti > Scarica immagini delle carte. Scaricarlo ora? lblDownloadCJKFonts=Scarica i font per le lingue che utilizzano caratteri Cinesi-Giapponesi-Coreani lblDownloadCJKFontPrompt=Seleziona un font da scaricare lblYourVersionOfJavaIsTooOld=La tua versione di Java è troppo vecchia per scaricare contenuti aggiuntivi. diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index 83b6cb9fe5fe..db31fe5a09b5 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -82,7 +82,8 @@ cbPerformanceMode=パフォーマンスモード cbOrderHand=Keep Hand ordered cbFilteredHands=初期ハンド補正 cbImageFetcher=欠落したカード画像を自動的にダウンロードする -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=カード画像の言語 +cbPreferLangForUniqueCards=固有カードで自分の言語を優先する cbCloneImgSource=コピーカードは元のカード画像を使用します cbScaleLarger=画像を拡大する cbRenderBlackCardBorders=黒枠のレンダリング @@ -221,7 +222,8 @@ nlDefaultFontSize=UI 内のデフォルトのフォントサイズ。 すべて nlCardArtFormat=カード描画時使うアートのフォーマット(Full: カード全体の画像を使う。Crop: カードのアートの部分の画像だけを使う。) cbpMulliganRule =マリガンルール nlImageFetcher=オンラインサーバーから欠落しているカード画像の動的にフェッチを有効にします。 -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=利用可能な場合にカード画像をダウンロードする優先言語です。選択した言語でそのカードが印刷されていない場合は英語にフォールバックします。 +nlPreferLangForUniqueCards=固有カードのみを表示する際、デフォルトのアート選択よりも、設定した画像言語で存在が確認された印刷を優先します。 nlDisplayFoil=視覚的フォイルオーバーレイ効果のあるフォイルカードを表示します。 nlRandomFoil=ランダムなカードにフォイル効果を追加します。 nlScaleLarger=カード画像を元のサイズより大きく拡大できます。 @@ -312,10 +314,11 @@ btnClearCdnImageCache=CDN画像索引キャッシュを削除 lblClearCdnImageCache=ローカルのCDN画像索引キャッシュを削除します。画像が利用可能なはずなのに一部のカードが遅いフォールバック経由で解決され続ける場合に使用してください -- 必要に応じてForgeが再取得します。 lblClearCdnImageCacheConfirm=ローカルのCDN画像索引キャッシュを削除します。必要に応じてForgeが再取得します。続行しますか? lblCdnImageCacheCleared=CDN画像索引キャッシュを削除しました。 -btnSyncBulkCardData=一括ダウンロード (~350MB) -lblSyncBulkCardDataConfirm=Scryfallの完全なカードデータベース (~350MB) をダウンロードし、セットごとではなく全カードのCDN画像リンクを一度に解決します。続行しますか? +btnSyncBulkCardData=インデックスをダウンロード - 英語のみ (~75MB) +btnSyncBulkCardDataLang=インデックスをダウンロード +lblSyncBulkCardDataConfirm=オンラインの提供元から完全なカード画像インデックスをダウンロードし、セットごとではなく全カードの画像リンクを一度に解決します。続行しますか? lblBulkCardDataSynced=カードデータを同期しました。 -lblFirstRunBulkCdnPrompt=Forgeは今すぐScryfallの完全なカードデータベース (~350MB、一度限り) をダウンロードできます。これにより、今後のカード画像のダウンロードがセットごとではなく即座に解決されるようになります。今すぐダウンロードしますか? +lblFirstRunBulkCdnPrompt=Forgeは今すぐ完全なカード画像インデックス(一度限り、英語のみ)をオンラインの提供元からダウンロードできます。これにより、今後のカード画像のダウンロードがセットごとではなく即座に解決されるようになります。多言語対応のインデックスも利用可能です。その場合は「コンテンツをダウンロード」>「カード画像をダウンロード」に移動してください。今すぐダウンロードしますか? lblDownloadCJKFonts=日中韓文字を使う言語用のフォントをダウンロードします。 lblDownloadCJKFontPrompt=ダウンロードするフォントファイルを選択してください。 lblYourVersionOfJavaIsTooOld=Java のバージョンが古すぎるため、コンテンツダウンローダーを使用できません。 diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index bb2487690e2f..c69f9c028b62 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -107,7 +107,8 @@ cbPerformanceMode=성능 모드 cbOrderHand=핸드 정렬 유지 cbFilteredHands=초기 핸드 보정 cbImageFetcher=누락된 카드 이미지를 자동 다운로드 -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=카드 이미지 언어 +cbPreferLangForUniqueCards=고유 카드에 내 언어 우선 사용 cbCloneImgSource=복제 카드는 원본 카드 이미지를 사용 cbScaleLarger=이미지 확대 cbRenderBlackCardBorders=검은 테두리 렌더링 @@ -246,7 +247,8 @@ nlCardArtFormat=카드 그릴 때 사용할 아트 포맷 (Full: 카드 전체 nlDefaultFontSize=UI 내부 기본 글꼴 크기. 모든 글꼴 요소가 이에 비례해 확대/축소됩니다(재시작 필요). cbpMulliganRule =멀리건 규칙 nlImageFetcher=온라인 서버에서 누락된 카드 이미지를 동적으로 가져오기를 활성화합니다. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=사용 가능한 경우 카드 이미지를 다운로드할 선호 언어입니다. 선택한 언어로 해당 카드가 인쇄되지 않은 경우 영어로 대체됩니다. +nlPreferLangForUniqueCards=고유 카드만 표시할 때, 기본 아트 선택 대신 설정한 카드 이미지 언어로 존재가 확인된 인쇄본을 우선합니다. nlDisplayFoil=시각적인 포일 오버레이 효과가 있는 포일 카드를 표시합니다. nlRandomFoil=랜덤 카드에 포일 효과를 추가합니다. nlScaleLarger=카드 이미지를 원본보다 크게 확대할 수 있습니다. @@ -337,10 +339,11 @@ btnClearCdnImageCache=CDN 이미지 조회 캐시 지우기 lblClearCdnImageCache=로컬 CDN 이미지 조회 캐시를 삭제합니다. 일부 카드의 이미지를 사용할 수 있어야 하는데도 계속 느린 대체 경로로 해결되는 경우 사용하세요 -- 이후 필요에 따라 Forge가 다시 가져옵니다. lblClearCdnImageCacheConfirm=로컬 CDN 이미지 조회 캐시를 삭제합니다. 필요에 따라 Forge가 다시 가져옵니다. 계속하시겠습니까? lblCdnImageCacheCleared=CDN 이미지 조회 캐시를 지웠습니다. -btnSyncBulkCardData=대량 다운로드 (~350MB) -lblSyncBulkCardDataConfirm=이 작업은 Scryfall의 전체 카드 데이터베이스(~350MB)를 다운로드하여, 세트별로 확인하는 대신 모든 카드의 CDN 이미지 링크를 한 번에 확인합니다. 계속하시겠습니까? +btnSyncBulkCardData=인덱스 다운로드 - 영어만 (~75MB) +btnSyncBulkCardDataLang=인덱스 다운로드 +lblSyncBulkCardDataConfirm=이 작업은 온라인 소스에서 전체 카드 이미지 인덱스를 다운로드하여, 세트별로 확인하는 대신 모든 카드의 이미지 링크를 한 번에 확인합니다. 계속하시겠습니까? lblBulkCardDataSynced=카드 데이터가 동기화되었습니다. -lblFirstRunBulkCdnPrompt=Forge는 지금 Scryfall의 전체 카드 데이터베이스(~350MB, 1회성)를 다운로드하여 이후 카드 이미지 다운로드가 세트별이 아닌 즉시 확인되도록 할 수 있습니다. 지금 다운로드하시겠습니까? +lblFirstRunBulkCdnPrompt=Forge는 지금 온라인 소스에서 전체 카드 이미지 인덱스(1회성, 영어만)를 다운로드하여 이후 카드 이미지 다운로드가 세트별이 아닌 즉시 확인되도록 할 수 있습니다. 다국어 인덱스도 사용할 수 있습니다 -- 이를 위해서는 콘텐츠 다운로드 > 카드 이미지 다운로드로 이동하세요. 지금 다운로드하시겠습니까? lblDownloadCJKFonts=한중일 문자 사용하는 언어용 폰트 다운로드. lblDownloadCJKFontPrompt=다운로드할 폰트 파일을 선택하세요. lblYourVersionOfJavaIsTooOld=Java 버전이 너무 오래되어 콘텐츠 다운로더를 사용할 수 없습니다. diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index a2ba657c709b..d8c68fec3bd7 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -83,7 +83,8 @@ cbPerformanceMode=Modo de Desempenho cbOrderHand=Keep Hand ordered cbFilteredHands=Mãos Filtradas cbImageFetcher=Baixar Automaticamente a Arte de Cartas Ausentes -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Idioma da imagem da carta +cbPreferLangForUniqueCards=Preferir meu idioma em cartas únicas cbCloneImgSource=Clones Usam a Arte da Carta Origem cbScaleLarger=Expandir Imagem cbRenderBlackCardBorders=Renderizar Bordas Pretas de Carta @@ -222,7 +223,8 @@ nlCardArtFormat=O formato das imagens de arte da carta. (Total\: imagem inteira nlDefaultFontSize=O tamanho padrão da fonte dentro da interface do usuário. Todos os elementos de fonte são dimensionados em relação a isso. (REQUER REINÍCIO) cbpMulliganRule = Regra de Mulligan nlImageFetcher=Habilita a busca sob demanda das imagens das cartas ausentes a partir de um repositório online. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=O idioma preferido para baixar as imagens das cartas quando disponível. Usa o inglês como alternativa se a carta não foi impressa no idioma selecionado. +nlPreferLangForUniqueCards=Ao mostrar apenas cartas únicas, prioriza uma impressão confirmada no seu idioma de imagem de carta em vez da seleção de arte padrão. nlDisplayFoil=Exibe cartas cromadas com o efeito de camada cromada. nlRandomFoil=Adiciona o efeito cromado a cartas aleatórias. nlScaleLarger=Permite que as imagens das cartas sejam expandidas maior que o tamanho original. @@ -313,10 +315,11 @@ btnClearCdnImageCache=Limpar cache de busca de imagens CDN lblClearCdnImageCache=Exclui o cache local de busca de imagens CDN. Use isso se algumas cartas continuarem sendo resolvidas pelo caminho alternativo mais lento mesmo que suas imagens devessem estar disponíveis -- o Forge buscará novamente conforme necessário. lblClearCdnImageCacheConfirm=Isso exclui o cache local de busca de imagens CDN. O Forge buscará novamente conforme necessário. Continuar? lblCdnImageCacheCleared=Cache de busca de imagens CDN limpo. -btnSyncBulkCardData=Baixar em massa (~350MB) -lblSyncBulkCardDataConfirm=Isso baixa o banco de dados completo de cartas do Scryfall (~350 MB) para resolver o link de imagem CDN de todas as cartas de uma só vez, em vez de uma coleção por vez. Continuar? +btnSyncBulkCardData=Baixar índice - Somente inglês (~75MB) +btnSyncBulkCardDataLang=Baixar índice +lblSyncBulkCardDataConfirm=Isso baixa um índice completo de imagens de cartas de uma fonte online para resolver o link de imagem de todas as cartas de uma só vez, em vez de uma coleção por vez. Continuar? lblBulkCardDataSynced=Dados das cartas sincronizados. -lblFirstRunBulkCdnPrompt=O Forge pode baixar agora o banco de dados completo de cartas do Scryfall (~350 MB, uma única vez) para que os futuros downloads de imagens de cartas sejam resolvidos instantaneamente, em vez de uma coleção por vez. Baixar agora? +lblFirstRunBulkCdnPrompt=O Forge pode baixar agora um índice completo de imagens de cartas (uma única vez, somente inglês) de uma fonte online, para que os futuros downloads de imagens de cartas sejam resolvidos instantaneamente, em vez de uma coleção por vez. Também existe um índice multilíngue -- para isso, vá em Baixar conteúdo > Baixar imagens de cartas. Baixar agora? lblDownloadCJKFonts=Baixe fontes para locais usando CJK glyphs. lblDownloadCJKFontPrompt=Selecione um arquivo de fonte para download lblYourVersionOfJavaIsTooOld=Sua versão do Java é muito antiga para usar os baixadores de conteúdo. diff --git a/forge-gui/res/languages/ru-RU.properties b/forge-gui/res/languages/ru-RU.properties index 020b5946d0d5..f1bf2637766b 100644 --- a/forge-gui/res/languages/ru-RU.properties +++ b/forge-gui/res/languages/ru-RU.properties @@ -105,7 +105,8 @@ cbPerformanceMode=Режим производительности cbOrderHand=Упорядочивать карты в руке cbFilteredHands=Фильтрация стартовых рук cbImageFetcher=Автоматически загружать недостающие изображения карт -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Язык изображений карт +cbPreferLangForUniqueCards=Предпочитать мой язык для уникальных карт cbCloneImgSource=Клоны используют оригинальное изображение карты cbScaleLarger=Увеличивать изображение cbRenderBlackCardBorders=Отображать чёрные рамки карт @@ -242,7 +243,8 @@ nlCardArtFormat=Формат изображений карт. (Полное: и nlDefaultFontSize=Размер шрифта по умолчанию в интерфейсе. Все элементы шрифта масштабируются относительно этого значения. (ТРЕБУЕТСЯ ПЕРЕЗАПУСК) cbpMulliganRule=Правило маллигана nlImageFetcher=Включает загрузку недостающих изображений карт из онлайн-источника в реальном времени. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=Предпочитаемый язык для загрузки изображений карт, если доступен. Возвращается к английскому, если карта не была напечатана на выбранном языке. +nlPreferLangForUniqueCards=При отображении только уникальных карт отдаёт предпочтение изданию, подтверждённо существующему на выбранном языке изображений карт, а не выбору иллюстрации по умолчанию. nlDisplayFoil=Отображает фольгированные карты с визуальным эффектом фольги. nlRandomFoil=Добавляет эффект фольги случайным картам. nlScaleLarger=Позволяет увеличивать изображения карт больше их исходного размера. @@ -333,10 +335,11 @@ btnClearCdnImageCache=Очистить кэш CDN-индекса изображ lblClearCdnImageCache=Удаляет локальный кэш CDN-индекса изображений. Используйте, если некоторые карты по-прежнему загружаются через более медленный резервный путь, хотя их изображения должны быть доступны -- Forge при необходимости заново получит данные кэша. lblClearCdnImageCacheConfirm=Это удалит локальный кэш CDN-индекса изображений. Forge при необходимости заново его получит. Продолжить? lblCdnImageCacheCleared=Кэш CDN-индекса изображений очищен. -btnSyncBulkCardData=Массовая загрузка (~350 МБ) -lblSyncBulkCardDataConfirm=Это загрузит полную базу данных карт Scryfall (~350 МБ), чтобы разрешить ссылку на CDN-изображение сразу для всех карт, а не по одному выпуску за раз. Продолжить? +btnSyncBulkCardData=Загрузить индекс - Только английский (~75 МБ) +btnSyncBulkCardDataLang=Загрузить индекс +lblSyncBulkCardDataConfirm=Это загрузит полный индекс изображений карт из онлайн-источника, чтобы разрешить ссылку на изображение сразу для всех карт, а не по одному выпуску за раз. Продолжить? lblBulkCardDataSynced=Данные карт синхронизированы. -lblFirstRunBulkCdnPrompt=Forge может сейчас загрузить полную базу данных карт Scryfall (~350 МБ, единоразово), чтобы последующие загрузки изображений карт разрешались мгновенно, а не по одному выпуску за раз. Загрузить сейчас? +lblFirstRunBulkCdnPrompt=Forge может сейчас загрузить полный индекс изображений карт (единоразово, только на английском) из онлайн-источника, чтобы последующие загрузки изображений карт разрешались мгновенно, а не по одному выпуску за раз. Также доступен многоязычный индекс -- для этого перейдите в Раздел Загрузить контент > Загрузить изображения карт. Загрузить сейчас? lblDownloadCJKFonts=Скачать шрифты для локалей, использующих иероглифы CJK. lblDownloadCJKFontPrompt=Выберите файл шрифта для загрузки lblYourVersionOfJavaIsTooOld=Ваша версия Java слишком старая для использования загрузчиков контента. diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 53d067a58209..e87dc3c5cf49 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -82,7 +82,8 @@ cbPerformanceMode=性能模式 cbOrderHand=Keep Hand ordered cbFilteredHands=手牌过滤 cbImageFetcher=自动下载缺失的图片 -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=卡牌图片语言 +cbPreferLangForUniqueCards=唯一卡牌优先使用我的语言 cbCloneImgSource=复制使用原始的图片 cbScaleLarger=将图像缩放的更大 cbRenderBlackCardBorders=渲染黑卡边框 @@ -221,7 +222,8 @@ nlDefaultFontSize=UI中字体的默认大小。所有字体元素都相对于此 nlCardArtFormat=牌图的格式。(Full表示使用完整的牌张图片,Crop表示只使用牌图的插画部分) cbpMulliganRule = 调度规则 nlImageFetcher=允许从在线资源中实时获取缺失的图片 -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=在可用的情况下下载卡牌图片时使用的首选语言。如果该卡牌没有以所选语言印刷,则回退为英语。 +nlPreferLangForUniqueCards=仅显示唯一卡牌时,优先选择已确认存在于你所设图片语言中的印刷版本,而不是默认的插画选择。 nlDisplayFoil=显示闪卡 nlRandomFoil=随机将牌设置为闪卡 nlScaleLarger=允许卡牌图片缩放为初始大小 @@ -312,10 +314,11 @@ btnClearCdnImageCache=清除CDN图片索引缓存 lblClearCdnImageCache=删除本地CDN图片索引缓存。如果某些卡牌的图片明明应该可用,却一直通过较慢的备用方式解析,可以使用此项——之后Forge会按需重新获取缓存数据。 lblClearCdnImageCacheConfirm=这将删除本地CDN图片索引缓存,Forge会按需重新获取。是否继续? lblCdnImageCacheCleared=CDN图片索引缓存已清除。 -btnSyncBulkCardData=批量下载 (~350MB) -lblSyncBulkCardDataConfirm=这将下载Scryfall的完整卡牌数据库 (~350MB),一次性解析所有卡牌的CDN图片链接,而不是逐个系列进行。是否继续? +btnSyncBulkCardData=下载索引 - 仅英语 (~75MB) +btnSyncBulkCardDataLang=下载索引 +lblSyncBulkCardDataConfirm=这将从在线来源下载完整的卡牌图片索引,一次性解析所有卡牌的图片链接,而不是逐个系列进行。是否继续? lblBulkCardDataSynced=卡牌数据已同步。 -lblFirstRunBulkCdnPrompt=Forge现在可以下载Scryfall的完整卡牌数据库 (~350MB,仅需一次),这样以后下载卡牌图片时可以立即解析,而不必逐个系列进行。是否现在下载? +lblFirstRunBulkCdnPrompt=Forge现在可以从在线来源下载完整的卡牌图片索引(仅需一次,仅英语),这样以后下载卡牌图片时可以立即解析,而不必逐个系列进行。还有多语言索引可用 -- 前往“下载内容”>“下载卡牌图片”获取。是否现在下载? lblDownloadCJKFonts=下载可用于显示当前区域设置所使用的CJK图形的字体。 lblDownloadCJKFontPrompt=选择要下载的字体文件 lblYourVersionOfJavaIsTooOld=你的Java版本太旧无法开始下载内容 diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java index 2fcdea194c10..d96b4bf555b1 100644 --- a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -181,6 +181,30 @@ private static synchronized void recordMiss(String setCode, String cn, String la setCache.remove(setCode); } + public static boolean isAvailableInLanguage(String scryfallCode, String collectorNum, String lang) { + if (scryfallCode == null || collectorNum == null || lang == null) return false; + String setCode = scryfallCode.toLowerCase(); + + Map> cardMap = ensureSetLoadedReadOnly(setCode); + if (cardMap == MISSING_SET) return false; + + Map langMap = cardMap.get(collectorNum); + if (langMap == null) return false; + + LangUuids uuids = langMap.get(lang.toLowerCase()); + return uuids != null && !uuids.isMiss(); + } + + public static String resolvePreferredLangCode(String preferredLang, String scryfallCode, + String collectorNum, String defaultLangCode) { + if (preferredLang == null || preferredLang.isEmpty() + || FALLBACK_LANG.equalsIgnoreCase(preferredLang) + || preferredLang.equalsIgnoreCase(defaultLangCode)) { + return defaultLangCode; + } + return isAvailableInLanguage(scryfallCode, collectorNum, preferredLang) ? preferredLang : defaultLangCode; + } + /** * Read-only counterpart to {@link #getCdnUrl}: never queues a sync or records a miss. Use * from the gameplay image-fetch path, which must not trigger background Scryfall traffic. @@ -376,7 +400,22 @@ private static Map> readSetFromDisk(String setCod } private static File localCacheFile(String setCode) { - return new File(cacheDir(), setCode + ".json.gz"); + return new File(cacheDir(), safeFileStem(setCode) + ".json.gz"); + } + + /** + * Windows reserves CON, PRN, AUX, NUL, COM1-9 and LPT1-9 as device names -- a file called + * {@code con.json.gz} can never be created there, extension or not. This bites us for real: + * Conflux's Scryfall set code is "con". Prefix the handful of set codes that collide so their + * cache files can actually be written; every other set code is untouched. + */ + private static final java.util.Set WINDOWS_RESERVED_NAMES = new java.util.HashSet<>(java.util.Arrays.asList( + "con", "prn", "aux", "nul", + "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9")); + + private static String safeFileStem(String setCode) { + return WINDOWS_RESERVED_NAMES.contains(setCode.toLowerCase()) ? "set-" + setCode : setCode; } private static void writeLocalCache(File file, String json) { 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 9a00c7e5a07c..8dedeb1cbe87 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -6,6 +6,7 @@ import forge.item.IPaperCard; import forge.item.PaperCard; import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; import forge.util.ImageUtil; import forge.util.ScryfallRateLimiter; @@ -77,15 +78,22 @@ protected Map getNeededFiles() { } } - int setIndex = 0; - for (String setCode : needSync) { - if (cancel) break; - setIndex++; - reportStatus("Syncing card data from Scryfall: set " + setIndex + "/" + needSync.size() - + " (" + setCode.toUpperCase() + ")..."); - ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); - if (cancel) break; - ScryfallSetSync.sync(setCode); + // Syncing sets one at a time against the rate-limited Scryfall API is fine for a handful + // of sets, but with a cold cache (dozens/hundreds of missing sets) it can take minutes of + // sequential requests while barely updating the UI. Past a threshold, do it in one shot + // via the bulk-data export instead -- the same mechanism as the "Descarga masiva" button. + final int BULK_SYNC_THRESHOLD = 15; + if (needSync.size() > BULK_SYNC_THRESHOLD) { + reportStatus(needSync.size() + " sets need syncing; using bulk data sync instead of " + + "one-by-one requests..."); + int setCount = ScryfallBulkDataSync.sync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, + (message, fraction) -> reportStatus(message), () -> cancel); + if (setCount < 0 && !cancel) { + reportStatus("Bulk sync failed; falling back to per-set sync..."); + syncSetsOneByOne(needSync); + } + } else { + syncSetsOneByOne(needSync); } for (final PaperCard c : matches) { @@ -97,6 +105,19 @@ protected Map getNeededFiles() { return downloads; } + private void syncSetsOneByOne(List needSync) { + int setIndex = 0; + for (String setCode : needSync) { + if (cancel) break; + setIndex++; + reportStatus("Syncing card data from an online source: set " + setIndex + "/" + needSync.size() + + " (" + setCode.toUpperCase() + ")..."); + ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); + if (cancel) break; + ScryfallSetSync.sync(setCode); + } + } + // ------------------------------------------------------------------------- private static void addIfMissing(PaperCard c, String face, Map downloads) { @@ -128,19 +149,23 @@ private static String buildUrl(PaperCard c, String face) { ? StaticData.instance().getEditions().get(c.getEdition()) : null; String scryfallCode = (edition != null) ? edition.getScryfallCode() : null; boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = (edition != null && hasScryfallCode) + ? CdnUuidCache.resolvePreferredLangCode(preferredLang, scryfallCode, collectorNum, edition.getCardsLangCode()) + : null; // 1. CDN -- read-only (see CdnUuidCache.getCdnUrlIfCached()); falls through to the API // below if the warm-up loop above didn't resolve this set. if (edition != null && hasCollectorNum && hasScryfallCode) { String cdnUrl = CdnUuidCache.getCdnUrlIfCached( - scryfallCode, collectorNum, edition.getCardsLangCode(), face, "normal"); + scryfallCode, collectorNum, langCode, face, "normal"); if (cdnUrl != null) return cdnUrl; } // 2. Scryfall API if (hasCollectorNum && edition != null && hasScryfallCode) { String apiPath = ImageUtil.getScryfallDownloadUrl( - c, face, scryfallCode, edition.getCardsLangCode(), false); + c, face, scryfallCode, langCode, false); if (apiPath != null) return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + apiPath; } diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java index 7167424ef8f5..343031593277 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java @@ -17,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.function.BooleanSupplier; import java.util.zip.GZIPInputStream; @@ -26,7 +27,8 @@ */ public final class ScryfallBulkDataSync { private static final String BULK_DATA_LISTING_URL = "https://api.scryfall.com/bulk-data"; - private static final String BULK_DATA_TYPE = "default_cards"; + public static final String BULK_TYPE_DEFAULT_CARDS = "default_cards"; + public static final String BULK_TYPE_ALL_CARDS = "all_cards"; private static final int CONNECT_TIMEOUT_MS = 15_000; private static final int READ_TIMEOUT_MS = 60_000; @@ -35,25 +37,32 @@ public final class ScryfallBulkDataSync { private ScryfallBulkDataSync() {} + /** Approximate compressed download size for the given bulk type, measured against the live API. */ + public static String approxSizeLabel(String bulkDataType) { + return BULK_TYPE_ALL_CARDS.equals(bulkDataType) ? "375 MB" : "75 MB"; + } + public interface ProgressListener { /** @param fractionDone 0.0-1.0, or -1 if not yet known (e.g. before a Content-Length is available) */ void onProgress(String message, double fractionDone); } /** - * Downloads and parses the "default_cards" bulk file, merging every print into - * {@link CdnUuidCache}. Returns the number of sets written, or -1 on failure/cancellation - * before any data was merged. + * Downloads and parses the given Scryfall bulk file type ({@link #BULK_TYPE_DEFAULT_CARDS} or + * {@link #BULK_TYPE_ALL_CARDS}), merging every print into {@link CdnUuidCache}. If + * {@code allowedLangs} is non-null, only prints in one of those languages are kept -- lets a + * single-language sync of {@code all_cards} skip writing the other 16+ languages to disk. + * Returns the number of sets written, or -1 on failure/cancellation before any data was merged. */ - public static int sync(ProgressListener progress, BooleanSupplier cancelled) { - String downloadUrl = findDefaultCardsUrl(progress); + public static int sync(String bulkDataType, Set allowedLangs, ProgressListener progress, BooleanSupplier cancelled) { + String downloadUrl = findBulkDataUrl(bulkDataType, progress); if (downloadUrl == null || cancelled.getAsBoolean()) { return -1; } final Map>> bySet = new HashMap<>(); try { - streamAndAccumulate(downloadUrl, bySet, progress, cancelled); + streamAndAccumulate(downloadUrl, bySet, allowedLangs, progress, cancelled); } catch (IOException e) { Logger.error(e, "ScryfallBulkDataSync: failed to download/parse bulk data"); if (progress != null) { @@ -78,17 +87,17 @@ public static int sync(ProgressListener progress, BooleanSupplier cancelled) { setCount++; } if (progress != null) { - progress.onProgress("Synced " + setCount + " sets from Scryfall bulk data.", 1.0); + progress.onProgress("Synced " + setCount + " sets from the online card index.", 1.0); } return setCount; } // ------------------------------------------------------------------------- - private static String findDefaultCardsUrl(ProgressListener progress) { + private static String findBulkDataUrl(String bulkDataType, ProgressListener progress) { String url = listingUrlOverride != null ? listingUrlOverride : BULK_DATA_LISTING_URL; if (progress != null) { - progress.onProgress("Looking up Scryfall bulk data files...", -1); + progress.onProgress("Looking up available card index files...", -1); } try { ScryfallRateLimiter.acquire(url); @@ -117,7 +126,7 @@ private static String findDefaultCardsUrl(ProgressListener progress) { JsonArray data = root.has("data") ? root.getAsJsonArray("data") : new JsonArray(); for (var el : data) { JsonObject obj = el.getAsJsonObject(); - if (!obj.has("type") || !BULK_DATA_TYPE.equals(obj.get("type").getAsString())) { + if (!obj.has("type") || !bulkDataType.equals(obj.get("type").getAsString())) { continue; } if (obj.has("jsonl_download_uri")) { @@ -127,7 +136,7 @@ private static String findDefaultCardsUrl(ProgressListener progress) { return obj.get("download_uri").getAsString(); } } - Logger.error("ScryfallBulkDataSync: no '{}' entry found in bulk-data listing", BULK_DATA_TYPE); + Logger.error("ScryfallBulkDataSync: no '{}' entry found in bulk-data listing", bulkDataType); return null; } catch (IOException e) { Logger.error(e, "ScryfallBulkDataSync: failed to fetch bulk-data listing"); @@ -136,7 +145,7 @@ private static String findDefaultCardsUrl(ProgressListener progress) { } private static void streamAndAccumulate(String downloadUrl, Map>> bySet, - ProgressListener progress, BooleanSupplier cancelled) throws IOException { + Set allowedLangs, ProgressListener progress, BooleanSupplier cancelled) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(downloadUrl).openConnection(); conn.setConnectTimeout(CONNECT_TIMEOUT_MS); conn.setReadTimeout(READ_TIMEOUT_MS); @@ -190,7 +199,7 @@ public int read() throws IOException { } catch (Exception e) { continue; // skip a malformed line rather than aborting the whole sync } - addCard(bySet, card); + addCard(bySet, card, allowedLangs); cardsSeen[0]++; long now = System.currentTimeMillis(); @@ -198,20 +207,23 @@ public int read() throws IOException { lastReportAt = now; double fraction = totalBytes > 0 ? Math.min(1.0, bytesRead[0] / (double) totalBytes) : -1; String pct = totalBytes > 0 ? Math.round(fraction * 100) + "%" : (bytesRead[0] / 1_000_000) + " MB"; - progress.onProgress("Downloading Scryfall bulk card data: " + pct + progress.onProgress("Downloading card index: " + pct + " (" + cardsSeen[0] + " cards processed)...", fraction); } } } } - private static void addCard(Map>> bySet, JsonObject card) { + private static void addCard(Map>> bySet, JsonObject card, Set allowedLangs) { String setCode = ScryfallSetSync.str(card, "set"); String cn = ScryfallSetSync.str(card, "collector_number"); String lang = ScryfallSetSync.str(card, "lang"); if (setCode == null || cn == null || lang == null) { return; } + if (allowedLangs != null && !allowedLangs.contains(lang)) { + return; + } String[] frontBack = ScryfallSetSync.frontBackUuids(card); if (frontBack == null) { diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java index 1123b99f9e60..cfa47fafd4dc 100644 --- a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -4,6 +4,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import forge.localinstance.properties.ForgePreferences; +import forge.model.FModel; import forge.util.BuildInfo; import forge.util.ScryfallRateLimiter; import org.tinylog.Logger; @@ -83,6 +85,9 @@ private static void addCard(Map> byCn, JsonObject /** Per-face CDN UUIDs for one card, or {@code null} if it has no usable image data yet. Shared with {@link ScryfallBulkDataSync}. */ static String[] frontBackUuids(JsonObject card) { + String imageStatus = str(card, "image_status"); + if ("missing".equals(imageStatus) || "placeholder".equals(imageStatus)) return null; + String front; String back = null; if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { @@ -123,10 +128,15 @@ private static String uuidFromUrl(String url) { private static String searchUrl(String setCode) { String base = searchBaseUrlOverride != null ? searchBaseUrlOverride : DEFAULT_SEARCH_URL; - // English only: CdnUuidCache.getCdnUrl() always falls back to "en", and virtually every - // edition's cards are English, so "lang:any" fetched ~10x more pages (every printed - // language) than needed and was tripping Scryfall's rate limit before finishing a set. - String query = "set:" + setCode + " lang:en"; + // Always fetch English: CdnUuidCache.getCdnUrl() falls back to it, and virtually every + // edition's cards are English. "lang:any" fetched ~10x more pages (every printed + // language) than needed and was tripping Scryfall's rate limit before finishing a set -- + // so if the user has a non-English language preference, add just that one language too, + // instead of every language. + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String query = (preferredLang != null && !preferredLang.isEmpty() && !"en".equalsIgnoreCase(preferredLang)) + ? "set:" + setCode + " (lang:en or lang:" + preferredLang + ")" + : "set:" + setCode + " lang:en"; return base + "?unique=prints&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); } 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 33cd665a29d3..0a1c1961a667 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -397,6 +397,22 @@ public static Map getUPnPPreferenceMapping() { ); } + public static Map getScryfallCardLanguageMapping() { + final java.util.LinkedHashMap map = new java.util.LinkedHashMap<>(); + map.put("English", "en"); + map.put("Spanish", "es"); + map.put("French", "fr"); + map.put("German", "de"); + map.put("Italian", "it"); + map.put("Portuguese", "pt"); + map.put("Japanese", "ja"); + map.put("Korean", "ko"); + map.put("Russian", "ru"); + map.put("Chinese Simplified", "zhs"); + map.put("Chinese Traditional", "zht"); + return map; + } + public enum CounterDisplayLocation { TOP("Top of Card"), BOTTOM("Bottom of Card"); diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 3decedfa4fe4..ca41f8b857b6 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -77,6 +77,8 @@ public enum FPref implements AbstractPreferences.IPref { UI_OVERLAY_ABILITY_ICONS("true"), UI_OVERLAY_DRAFT_RANKING("true"), UI_ENABLE_ONLINE_IMAGE_FETCHER ("true"), + UI_CARD_DOWNLOAD_LANG ("en"), + UI_PREFER_LANG_FOR_UNIQUE_CARDS ("false"), UI_PREFERRED_ART("LATEST_ART_ALL_EDITIONS"), UI_SMART_CARD_ART("false"), UI_AUTO_AIDECK_SELECTION("true"), diff --git a/forge-gui/src/main/java/forge/model/FModel.java b/forge-gui/src/main/java/forge/model/FModel.java index 922d78e12047..8f24ea2dfe0d 100644 --- a/forge-gui/src/main/java/forge/model/FModel.java +++ b/forge-gui/src/main/java/forge/model/FModel.java @@ -34,6 +34,7 @@ import forge.game.card.CardUtil; import forge.game.spellability.Spell; import forge.gamemodes.gauntlet.GauntletData; +import forge.gui.download.CdnUuidCache; import forge.gamemodes.limited.GauntletMini; import forge.gamemodes.limited.ThemedChaosDraft; import forge.gamemodes.planarconquest.ConquestController; @@ -63,6 +64,7 @@ import java.io.File; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; import java.util.function.Function; /** @@ -85,7 +87,20 @@ private FModel() { } //don't allow creating instance getPreferences().getPrefBoolean(FPref.UI_LOAD_UNKNOWN_CARDS), getPreferences().getPrefBoolean(FPref.UI_LOAD_NONLEGAL_CARDS), getPreferences().getPrefBoolean(FPref.ALLOW_CUSTOM_CARDS_IN_DECKS_CONFORMANCE), - getPreferences().getPrefBoolean(FPref.UI_SMART_CARD_ART))); + getPreferences().getPrefBoolean(FPref.UI_SMART_CARD_ART), + buildPreferredLanguageAvailability())); + + private static BiPredicate buildPreferredLanguageAvailability() { + if (!getPreferences().getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)) { + return null; + } + String preferredLang = getPreferences().getPref(FPref.UI_CARD_DOWNLOAD_LANG); + if (preferredLang == null || preferredLang.isEmpty() || "en".equalsIgnoreCase(preferredLang)) { + return null; + } + return (setCode, collectorNumber) -> CdnUuidCache.isAvailableInLanguage(setCode, collectorNumber, preferredLang); + } + private static final Supplier questPreferences = Suppliers.memoize(QuestPreferences::new); private static final Supplier conquestPreferences = Suppliers.memoize(() -> { final ConquestPreferences cp = new ConquestPreferences(); diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index c7a80c3454e5..9ca66d56cc4b 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -4,6 +4,7 @@ import forge.StaticData; import forge.card.CardEdition; import forge.gui.FThreads; +import forge.gui.download.CdnUuidCache; import forge.item.IPaperCard; import forge.item.PaperCard; import forge.localinstance.properties.ForgeConstants; @@ -74,7 +75,8 @@ private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, Arr if (edition == null) return; String setCode = edition.getScryfallCode(); - String langCode = edition.getCardsLangCode(); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, setCode, card.getCollectorNumber(), edition.getCardsLangCode()); // Prefer CDN (no rate limit) if this set was already synced; read-only, see getCdnUrlIfCached(). if (!StringUtils.isBlank(setCode)) { @@ -155,7 +157,8 @@ public void fetchImage(final String imageKey, final Callback callback) { CardEdition ed = StaticData.instance().getEditions().get(pc.getEdition()); if (ed != null) { String setCode = ed.getScryfallCode(); - String langCode = ed.getCardsLangCode(); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, setCode, pc.getCollectorNumber(), ed.getCardsLangCode()); downloadUrls.add("PLANECHASEBG:" + ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(pc, "", setCode, langCode, true)); FileUtil.ensureDirectoryExists(ForgeConstants.CACHE_PLANECHASE_PICS_DIR); File destFile = new File(ForgeConstants.CACHE_PLANECHASE_PICS_DIR, getPlanechaseFilename(cardName)); @@ -330,7 +333,8 @@ public void fetchImage(final String imageKey, final Callback callback) { if (tempdata.length > 2) { String tokenCode = edition.getTokensCode(); - String langCode = edition.getCardsLangCode(); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, tokenCode, tempdata[2], edition.getCardsLangCode()); // Just assume the CNr from the token image is valid downloadUrls.add(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallTokenDownloadUrl(tempdata[2], tokenCode, langCode, face)); } else if (!allTokens.isEmpty()) { @@ -345,10 +349,11 @@ public void fetchImage(final String imageKey, final Callback callback) { while (it.hasNext()) { tis = it.next(); String tokenCode = edition.getTokensCode(); - String langCode = edition.getCardsLangCode(); if (tis.collectorNumber() == null || tis.collectorNumber().isEmpty()) { continue; } + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, tokenCode, tis.collectorNumber(), edition.getCardsLangCode()); downloadUrls.add(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallTokenDownloadUrl(tis.collectorNumber(), tokenCode, langCode, face)); } diff --git a/forge-gui/tools/java/ForgeMatrixWriter.java b/forge-gui/tools/java/ForgeMatrixWriter.java index a9b0a2ad5501..83b90b518721 100644 --- a/forge-gui/tools/java/ForgeMatrixWriter.java +++ b/forge-gui/tools/java/ForgeMatrixWriter.java @@ -71,7 +71,8 @@ public static void main(String[] args) throws Exception { true, false, false, - false); + false, + null); HashMap>> matrix = new HashMap<>(); int rows = 0;