From 0fe25ec63e8362009135f653360305adee8af981 Mon Sep 17 00:00:00 2001 From: Tuomas-Matti Soikkeli Date: Tue, 13 Jan 2026 12:34:19 +0200 Subject: [PATCH 1/2] Add on-demand card image downloading Adds automatic downloading of missing card images when they are first displayed, eliminating the need for users to manually download all images upfront via the bulk download dialog. Features: - New OnDemandImageDownloader singleton service that downloads missing images in the background using Scryfall (normal quality) - Rate limiting (300ms between API calls) to respect Scryfall limits - Duplicate prevention - cards are only downloaded once per session - New preference checkbox "Download missing images automatically" in the Images tab (disabled by default, opt-in) When enabled, missing card images are queued for download when the ImageCache detects a cache miss. Images appear automatically once downloaded. --- .../mage/client/dialog/PreferencesDialog.java | 15 ++ .../mage/plugins/card/images/ImageCache.java | 7 + .../card/images/OnDemandImageDownloader.java | 232 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java diff --git a/Mage.Client/src/main/java/mage/client/dialog/PreferencesDialog.java b/Mage.Client/src/main/java/mage/client/dialog/PreferencesDialog.java index 13004ab13bf..4363e417cbc 100644 --- a/Mage.Client/src/main/java/mage/client/dialog/PreferencesDialog.java +++ b/Mage.Client/src/main/java/mage/client/dialog/PreferencesDialog.java @@ -84,6 +84,7 @@ public class PreferencesDialog extends javax.swing.JDialog { public static final String KEY_CARD_IMAGES_PATH = "cardImagesPath"; public static final String KEY_CARD_IMAGES_SAVE_TO_ZIP = "cardImagesSaveToZip"; public static final String KEY_CARD_IMAGES_PREF_LANGUAGE = "cardImagesPreferredImageLaguage"; + public static final String KEY_CARD_IMAGES_AUTO_DOWNLOAD = "cardImagesAutoDownload"; public static final String KEY_CARD_RENDERING_IMAGE_MODE = "cardRenderingMode"; public static final String KEY_CARD_RENDERING_ICONS_FOR_ABILITIES = "cardRenderingIconsForAbilities"; @@ -957,6 +958,7 @@ public class PreferencesDialog extends javax.swing.JDialog { txtImageFolderPath = new javax.swing.JTextField(); btnBrowseImageLocation = new javax.swing.JButton(); cbSaveToZipFiles = new javax.swing.JCheckBox(); + cbAutoDownloadImages = new javax.swing.JCheckBox(); cbPreferredImageLanguage = new javax.swing.JComboBox<>(); labelPreferredImageLanguage = new javax.swing.JLabel(); panelCardStyles = new javax.swing.JPanel(); @@ -2277,6 +2279,9 @@ public class PreferencesDialog extends javax.swing.JDialog { } }); + cbAutoDownloadImages.setText("Download missing images automatically"); + cbAutoDownloadImages.setToolTipText("Automatically download card images from Scryfall when they are first displayed"); + cbPreferredImageLanguage.setModel(new javax.swing.DefaultComboBoxModel<>(new String[] { "Item 1", "Item 2", "Item 3", "Item 4" })); labelPreferredImageLanguage.setText("Default images language:"); @@ -2297,6 +2302,7 @@ public class PreferencesDialog extends javax.swing.JDialog { .add(panelCardImagesLayout.createSequentialGroup() .add(panelCardImagesLayout.createParallelGroup(org.jdesktop.layout.GroupLayout.LEADING) .add(cbSaveToZipFiles) + .add(cbAutoDownloadImages) .add(panelCardImagesLayout.createSequentialGroup() .add(6, 6, 6) .add(labelPreferredImageLanguage) @@ -2315,6 +2321,8 @@ public class PreferencesDialog extends javax.swing.JDialog { .addPreferredGap(org.jdesktop.layout.LayoutStyle.UNRELATED) .add(cbSaveToZipFiles) .addPreferredGap(org.jdesktop.layout.LayoutStyle.RELATED) + .add(cbAutoDownloadImages) + .addPreferredGap(org.jdesktop.layout.LayoutStyle.RELATED) .add(panelCardImagesLayout.createParallelGroup(org.jdesktop.layout.GroupLayout.BASELINE) .add(labelPreferredImageLanguage) .add(cbPreferredImageLanguage, org.jdesktop.layout.GroupLayout.PREFERRED_SIZE, org.jdesktop.layout.GroupLayout.DEFAULT_SIZE, org.jdesktop.layout.GroupLayout.PREFERRED_SIZE)) @@ -3058,6 +3066,7 @@ public class PreferencesDialog extends javax.swing.JDialog { save(prefs, dialog.cbUseDefaultImageFolder, KEY_CARD_IMAGES_USE_DEFAULT, "true", "false"); saveImagesPath(prefs); save(prefs, dialog.cbSaveToZipFiles, KEY_CARD_IMAGES_SAVE_TO_ZIP, "true", "false"); + save(prefs, dialog.cbAutoDownloadImages, KEY_CARD_IMAGES_AUTO_DOWNLOAD, "true", "false"); save(prefs, dialog.cbPreferredImageLanguage, KEY_CARD_IMAGES_PREF_LANGUAGE); save(prefs, dialog.cbUseDefaultBackground, KEY_BACKGROUND_IMAGE_DEFAULT, "true", "false"); @@ -3513,6 +3522,7 @@ public class PreferencesDialog extends javax.swing.JDialog { updateCache(KEY_CARD_IMAGES_PATH, path); } load(prefs, dialog.cbSaveToZipFiles, KEY_CARD_IMAGES_SAVE_TO_ZIP, "true", "false"); + load(prefs, dialog.cbAutoDownloadImages, KEY_CARD_IMAGES_AUTO_DOWNLOAD, "true", "false"); dialog.cbPreferredImageLanguage.setSelectedItem(MageFrame.getPreferences().get(KEY_CARD_IMAGES_PREF_LANGUAGE, CardLanguage.ENGLISH.getCode())); // rendering settings @@ -3705,6 +3715,10 @@ public class PreferencesDialog extends javax.swing.JDialog { return PreferencesDialog.getCachedValue(PreferencesDialog.KEY_CARD_IMAGES_SAVE_TO_ZIP, "false").equals("true"); } + public static boolean isAutoDownloadEnabled() { + return PreferencesDialog.getCachedValue(PreferencesDialog.KEY_CARD_IMAGES_AUTO_DOWNLOAD, "false").equals("true"); + } + private static void load(Preferences prefs, JCheckBox checkBox, String propName, String yesValue) { String prop = prefs.get(propName, yesValue); checkBox.setSelected(prop.equals(yesValue)); @@ -4066,6 +4080,7 @@ public class PreferencesDialog extends javax.swing.JDialog { private javax.swing.JButton buttonSizeDefault6; private javax.swing.JCheckBox cbAllowRequestToShowHandCards; private javax.swing.JCheckBox cbAskMoveToGraveOrder; + private javax.swing.JCheckBox cbAutoDownloadImages; private javax.swing.JCheckBox cbAutoOrderTrigger; private javax.swing.JCheckBox cbCardRenderHideSetSymbol; private javax.swing.JCheckBox cbCardRenderIconsForAbilities; diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java b/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java index 2a7dde98297..daff2a77dfe 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java @@ -88,12 +88,19 @@ public final class ImageCache { // try unknown token image if (tokenFile == null || !tokenFile.exists()) { + // Queue token for on-demand download + OnDemandImageDownloader.getInstance().queueDownload(info); // TODO: replace empty token by other default card, not cardback path = CardImageUtils.buildImagePathToDefault(DirectLinksForDownload.cardbackFilename); } } else { // CARD path = CardImageUtils.buildImagePathToCardOrToken(info); + TFile cardFile = getTFile(path); + if (cardFile == null || !cardFile.exists()) { + // Queue card for on-demand download + OnDemandImageDownloader.getInstance().queueDownload(info); + } } TFile file = getTFile(path); diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java b/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java new file mode 100644 index 00000000000..44f059180a0 --- /dev/null +++ b/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java @@ -0,0 +1,232 @@ +package org.mage.plugins.card.images; + +import mage.client.dialog.PreferencesDialog; +import mage.client.remote.XmageURLConnection; +import mage.util.XmageThreadFactory; +import net.java.truevfs.access.TFile; +import net.java.truevfs.access.TFileOutputStream; +import org.apache.log4j.Logger; +import org.mage.plugins.card.dl.sources.CardImageSource; +import org.mage.plugins.card.dl.sources.CardImageUrls; +import org.mage.plugins.card.dl.sources.ScryfallImageSourceNormal; +import org.mage.plugins.card.utils.CardImageUtils; + +import java.io.*; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.nio.file.AccessDeniedException; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; + +import static org.mage.plugins.card.utils.CardImageUtils.getImagesDir; + +/** + * On-demand image downloader service. + * Downloads missing card images automatically when they are first displayed. + * Uses Scryfall as the image source with normal quality. + * + * @author Claude + */ +public class OnDemandImageDownloader { + + private static final Logger logger = Logger.getLogger(OnDemandImageDownloader.class); + + private static final OnDemandImageDownloader instance = new OnDemandImageDownloader(); + + // Track cards that have been queued or downloaded to avoid duplicates + private final Set queuedOrDownloaded = ConcurrentHashMap.newKeySet(); + + // Download queue + private final BlockingQueue downloadQueue = new LinkedBlockingQueue<>(); + + // Single-threaded executor for downloads (to respect rate limits) + private final ExecutorService executor; + + // Image source - use normal quality Scryfall + private final CardImageSource imageSource = ScryfallImageSourceNormal.getInstance(); + + // Minimum file size to consider download successful + private static final int MIN_FILE_SIZE_OF_GOOD_IMAGE = 1024 * 6; + + private OnDemandImageDownloader() { + executor = Executors.newSingleThreadExecutor( + new XmageThreadFactory("OnDemandImageDownloader", true) + ); + executor.submit(this::downloadWorker); + } + + public static OnDemandImageDownloader getInstance() { + return instance; + } + + /** + * Queue a card for download if not already queued/downloaded. + * Only queues if auto-download is enabled in preferences. + * + * @param card The card to download + */ + public void queueDownload(CardDownloadData card) { + // Check if feature is enabled + if (!PreferencesDialog.isAutoDownloadEnabled()) { + return; + } + + if (card == null) { + return; + } + + // Create unique key for this card + String key = createKey(card); + + // Only queue if not already queued/downloaded + if (queuedOrDownloaded.add(key)) { + downloadQueue.offer(card); + logger.debug("Queued for on-demand download: " + card.getName() + " (" + card.getSet() + ")"); + } + } + + private String createKey(CardDownloadData card) { + return card.getName() + "#" + card.getSet() + "#" + card.getCollectorId() + "#" + card.isToken(); + } + + /** + * Background worker that processes the download queue + */ + private void downloadWorker() { + while (!Thread.currentThread().isInterrupted()) { + try { + CardDownloadData card = downloadQueue.take(); + downloadCard(card); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + logger.error("Error in download worker: " + e.getMessage(), e); + } + } + } + + /** + * Download a single card image + * + * @param card The card to download + * @return true if download was successful + */ + private boolean downloadCard(CardDownloadData card) { + TFile fileTempImage = null; + TFile destFile = null; + + try { + // Generate download URLs + CardImageUrls urls; + if (card.isToken()) { + urls = imageSource.generateTokenUrl(card); + } else { + urls = imageSource.generateCardUrl(card); + } + + if (urls == null) { + logger.debug("No download URL available for: " + card.getName() + " (" + card.getSet() + ")"); + return false; + } + + // Create temp file + String tempPath = getImagesDir() + File.separator + "downloading" + File.separator; + fileTempImage = new TFile(tempPath + CardImageUtils.prepareCardNameForFile(card.getName()) + "-" + card.hashCode() + ".jpg"); + TFile parentFile = fileTempImage.getParentFile(); + if (parentFile != null && !parentFile.exists()) { + parentFile.mkdirs(); + } + + // Destination file + destFile = new TFile(CardImageUtils.buildImagePathToCardOrToken(card)); + + // Skip if file already exists (might have been downloaded by another means) + if (destFile.exists() && destFile.length() >= MIN_FILE_SIZE_OF_GOOD_IMAGE) { + logger.debug("Image already exists: " + card.getName() + " (" + card.getSet() + ")"); + return true; + } + + // Try to download from available URLs + List downloadUrls = urls.getDownloadList(); + XmageURLConnection connection = null; + boolean isDownloadOK = false; + + for (String currentUrl : downloadUrls) { + // Rate limiting + imageSource.doPause(currentUrl); + + connection = new XmageURLConnection(currentUrl); + connection.startConnection(); + if (connection.isConnected()) { + connection.setRequestHeaders(imageSource.getHttpRequestHeaders(currentUrl)); + + try { + connection.connect(); + } catch (SocketException | UnknownHostException e) { + logger.debug("Network error downloading " + card.getName() + ": " + e.getMessage()); + continue; + } + + int responseCode = connection.getResponseCode(); + if (responseCode == 200) { + isDownloadOK = true; + break; + } else { + logger.debug("HTTP " + responseCode + " for " + card.getName() + " from " + currentUrl); + } + } + } + + // Save the downloaded file + if (isDownloadOK && connection != null && connection.isConnected()) { + try (InputStream in = new BufferedInputStream(connection.getGoodResponseAsStream()); + OutputStream tempFileStream = new TFileOutputStream(fileTempImage); + OutputStream out = new BufferedOutputStream(tempFileStream)) { + + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + } + } + + // Validate and move to final destination + if (fileTempImage.exists() && fileTempImage.length() >= MIN_FILE_SIZE_OF_GOOD_IMAGE) { + if (!destFile.getParentFile().exists()) { + destFile.getParentFile().mkdirs(); + } + fileTempImage.cp_rp(destFile); + logger.info("Downloaded: " + card.getName() + " (" + card.getSet() + ")"); + return true; + } else { + logger.debug("Downloaded file too small for: " + card.getName()); + } + } + + } catch (AccessDeniedException e) { + logger.error("Access denied downloading " + card.getName() + ": " + e.getMessage()); + } catch (Exception e) { + logger.error("Error downloading " + card.getName() + ": " + e.getMessage(), e); + } finally { + // Cleanup temp file + if (fileTempImage != null && fileTempImage.exists()) { + try { + TFile.rm(fileTempImage); + } catch (Exception e) { + logger.debug("Could not delete temp file: " + e.getMessage()); + } + } + } + + return false; + } + + /** + * Clear the tracking set (useful for testing or reset) + */ + public void clearTracking() { + queuedOrDownloaded.clear(); + } +} From 6080969d8285d238b13ba2e45e9a18f4ed9574f3 Mon Sep 17 00:00:00 2001 From: Tuomas-Matti Soikkeli Date: Tue, 13 Jan 2026 15:32:43 +0200 Subject: [PATCH 2/2] Add auto-refresh for on-demand downloaded card images Card panels now register listeners with OnDemandImageDownloader to automatically refresh when their image is downloaded. Listener registration is gated by the auto-download preference setting. --- .../card/arcane/CardPanelRenderModeImage.java | 69 ++++++++++- .../card/arcane/CardPanelRenderModeMTGO.java | 111 ++++++++++++++---- .../mage/plugins/card/images/ImageCache.java | 29 +++++ .../card/images/OnDemandImageDownloader.java | 50 +++++++- 4 files changed, 235 insertions(+), 24 deletions(-) diff --git a/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeImage.java b/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeImage.java index e271704536d..7aa57e4cd20 100644 --- a/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeImage.java +++ b/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeImage.java @@ -15,9 +15,12 @@ import mage.constants.SubType; import mage.util.DebugUtil; import mage.view.CardView; import mage.view.CounterView; +import org.apache.log4j.Logger; import org.jdesktop.swingx.graphics.GraphicsUtilities; +import org.mage.plugins.card.images.CardDownloadData; import org.mage.plugins.card.images.ImageCache; import org.mage.plugins.card.images.ImageCacheData; +import org.mage.plugins.card.images.OnDemandImageDownloader; import org.mage.plugins.card.utils.impl.ImageManagerImpl; import javax.swing.*; @@ -26,6 +29,7 @@ import java.awt.image.BufferedImage; import java.util.Objects; import java.util.StringTokenizer; import java.util.UUID; +import java.util.function.Consumer; /** * Render mode: IMAGE @@ -34,6 +38,8 @@ import java.util.UUID; */ public class CardPanelRenderModeImage extends CardPanel { + private static final Logger LOGGER = Logger.getLogger(CardPanelRenderModeImage.class); + private static final long serialVersionUID = -3272134219262184411L; private static final SoftValuesLoadingCache IMAGE_MODE_RENDERED_CACHE = ImageCaches.register(SoftValuesLoadingCache.from(CardPanelRenderModeImage::createImage)); @@ -82,6 +88,9 @@ public class CardPanelRenderModeImage extends CardPanel { private int updateArtImageStamp; + // Listener for on-demand image downloads + private Consumer downloadListener; + private static class Key { final Insets border; @@ -356,6 +365,13 @@ public class CardPanelRenderModeImage extends CardPanel { public void cleanUp() { super.cleanUp(); counterPanel = null; + + // Unregister download listener + if (downloadListener != null) { + LOGGER.debug("cleanUp: removing download listener"); + OnDemandImageDownloader.getInstance().removeDownloadListener(downloadListener); + downloadListener = null; + } } @Override @@ -641,13 +657,25 @@ public class CardPanelRenderModeImage extends CardPanel { final int stamp = ++updateArtImageStamp; + // Capture card info on EDT before submitting to thread pool + final CardView card = getGameCard(); + if (card == null) { + return; + } + final String cardName = card.getName(); + final String setCode = card.getExpansionSetCode(); + final String cardNumber = card.getCardNumber(); + Util.threadPool.submit(() -> { try { - ImageCacheData data = ImageCache.getCardImage(getGameCard(), getCardWidth(), getCardHeight()); + ImageCacheData data = ImageCache.getCardImage(card, getCardWidth(), getCardHeight()); // save missing image + LOGGER.debug("updateArtImage for " + cardName + " [" + setCode + "/" + cardNumber + "] image=" + (data.getImage() != null ? "found" : "NULL")); if (data.getImage() == null) { setFullPath(data.getPath()); + // Register for download notification if not already registered + registerDownloadListener(cardName, setCode, cardNumber); } UI.invokeLater(() -> { @@ -655,6 +683,12 @@ public class CardPanelRenderModeImage extends CardPanel { hasImage = data.getImage() != null; setTitle(getGameCard()); setImage(data.getImage()); + + // Unregister listener if we now have an image + if (hasImage && downloadListener != null) { + OnDemandImageDownloader.getInstance().removeDownloadListener(downloadListener); + downloadListener = null; + } } }); } catch (Exception | Error e) { @@ -663,6 +697,39 @@ public class CardPanelRenderModeImage extends CardPanel { }); } + /** + * Register a listener to refresh this card when its image is downloaded. + */ + private void registerDownloadListener(String cardName, String setCode, String cardNumber) { + // Only register if auto-download is enabled + if (!PreferencesDialog.isAutoDownloadEnabled()) { + return; + } + + if (downloadListener != null) { + return; // Already registered + } + + LOGGER.debug("Registering download listener for: " + cardName + " [" + setCode + "/" + cardNumber + "]"); + + downloadListener = downloadedCard -> { + // Check if this download matches our card + boolean matches = downloadedCard.getName().equals(cardName) + && downloadedCard.getSet().equals(setCode) + && downloadedCard.getCollectorId().equals(cardNumber); + + LOGGER.debug("Listener received: " + downloadedCard.getName() + " [" + downloadedCard.getSet() + "/" + downloadedCard.getCollectorId() + "] matches=" + matches + " (looking for " + cardName + " [" + setCode + "/" + cardNumber + "])"); + + if (matches) { + // Refresh this card's image on the EDT + LOGGER.debug("Triggering updateArtImage for: " + cardName); + UI.invokeLater(this::updateArtImage); + } + }; + + OnDemandImageDownloader.getInstance().addDownloadListener(downloadListener); + } + private int getManaWidth(String manaCost, int symbolMarginX) { int width = 0; manaCost = manaCost.replace("\\", ""); diff --git a/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeMTGO.java b/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeMTGO.java index c0851b79b5e..c9015ed018b 100644 --- a/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeMTGO.java +++ b/Mage.Client/src/main/java/org/mage/card/arcane/CardPanelRenderModeMTGO.java @@ -3,6 +3,7 @@ package org.mage.card.arcane; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import mage.cards.action.ActionCallback; +import mage.client.dialog.PreferencesDialog; import mage.client.util.ImageCaches; import mage.constants.CardType; import mage.constants.SubType; @@ -10,19 +11,25 @@ import mage.constants.SuperType; import mage.view.CardView; import mage.view.CounterView; import mage.view.PermanentView; +import org.apache.log4j.Logger; import org.jdesktop.swingx.graphics.GraphicsUtilities; +import org.mage.plugins.card.images.CardDownloadData; import org.mage.plugins.card.images.ImageCache; +import org.mage.plugins.card.images.OnDemandImageDownloader; import java.awt.*; import java.awt.image.BufferedImage; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; /** * Render mode: MTGO */ public class CardPanelRenderModeMTGO extends CardPanel { + private static final Logger LOGGER = Logger.getLogger(CardPanelRenderModeMTGO.class); + // TODO: share code and use for all images/rendering (potential injection point - images cache), see #969 private static final boolean MTGO_MODE_RENDER_SMOOTH_IMAGES_ENABLED = false; private static final int MTGO_MODE_RENDER_SCALED_IMAGES_COEF = 1; // TODO: experiment with scale settings, is it useful to render in x2-x4 sizes? @@ -52,6 +59,9 @@ public class CardPanelRenderModeMTGO extends CardPanel { private int updateArtImageStamp; private final int cardRenderMode; + // Listener for on-demand image downloads + private Consumer downloadListener; + private static class ImageKey { final BufferedImage artImage; final int width; @@ -288,32 +298,89 @@ public class CardPanelRenderModeMTGO extends CardPanel { // Schedule a repaint repaint(); - // See if the image is already loaded - //artImage = ImageCache.tryGetImage(gameCard, getCardWidth(), getCardHeight()); - //this.cardRenderer.setArtImage(artImage); + // Capture card info on EDT before submitting to thread pool + final CardView card = getGameCard(); + if (card == null) { + return; + } + final String cardName = card.getName(); + final String setCode = card.getExpansionSetCode(); + final String cardNumber = card.getCardNumber(); // Submit a task to draw with the card art when it arrives - if (artImage == null) { - final int stamp = ++updateArtImageStamp; - Util.threadPool.submit(() -> { - try { - final BufferedImage srcImage; - srcImage = ImageCache.getCardImage(getGameCard(), getCardWidth(), getCardHeight()).getImage(); - UI.invokeLater(() -> { - if (stamp == updateArtImageStamp) { - artImage = srcImage; - cardRenderer.setArtImage(srcImage); - if (srcImage != null) { - // Invalidate and repaint - cardImage = null; - repaint(); + final int stamp = ++updateArtImageStamp; + Util.threadPool.submit(() -> { + try { + final BufferedImage srcImage; + srcImage = ImageCache.getCardImage(card, getCardWidth(), getCardHeight()).getImage(); + + // Register for download notification if image is missing + if (srcImage == null) { + registerDownloadListener(cardName, setCode, cardNumber); + } + + UI.invokeLater(() -> { + if (stamp == updateArtImageStamp) { + artImage = srcImage; + cardRenderer.setArtImage(srcImage); + if (srcImage != null) { + // Invalidate and repaint + cardImage = null; + repaint(); + + // Unregister listener if we now have an image + if (downloadListener != null) { + OnDemandImageDownloader.getInstance().removeDownloadListener(downloadListener); + downloadListener = null; } } - }); - } catch (Exception | Error e) { - e.printStackTrace(); - } - }); + } + }); + } catch (Exception | Error e) { + e.printStackTrace(); + } + }); + } + + /** + * Register a listener to refresh this card when its image is downloaded. + */ + private void registerDownloadListener(String cardName, String setCode, String cardNumber) { + // Only register if auto-download is enabled + if (!PreferencesDialog.isAutoDownloadEnabled()) { + return; + } + + if (downloadListener != null) { + return; // Already registered + } + + LOGGER.debug("MTGO: Registering download listener for: " + cardName + " [" + setCode + "/" + cardNumber + "]"); + + downloadListener = downloadedCard -> { + // Check if this download matches our card + boolean matches = downloadedCard.getName().equals(cardName) + && downloadedCard.getSet().equals(setCode) + && downloadedCard.getCollectorId().equals(cardNumber); + + if (matches) { + // Refresh this card's image on the EDT + LOGGER.debug("MTGO: Triggering updateArtImage for: " + cardName); + UI.invokeLater(this::updateArtImage); + } + }; + + OnDemandImageDownloader.getInstance().addDownloadListener(downloadListener); + } + + @Override + public void cleanUp() { + super.cleanUp(); + + // Unregister download listener + if (downloadListener != null) { + OnDemandImageDownloader.getInstance().removeDownloadListener(downloadListener); + downloadListener = null; } } diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java b/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java index daff2a77dfe..04de06945e5 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/images/ImageCache.java @@ -378,4 +378,33 @@ public final class ImageCache { } return null; } + + /** + * Invalidate all cache entries for a card so it will be reloaded from disk on next access. + * Used after on-demand image download completes. + * + * @param name card name + * @param setCode set code + * @param collectorId collector number + */ + public static void invalidateCard(String name, String setCode, String collectorId) { + // Key format: imageFileName#setCode#imageNumber#cardNumber#imageSize#usesVariousArt + // We need to find and invalidate all keys matching this card + String keyPattern = "#" + setCode + "#"; + String cardNumberPattern = "#" + collectorId + "#"; + + int cacheSize = SHARED_CARD_IMAGES_CACHE.asMap().size(); + LOGGER.debug("ImageCache invalidateCard: " + name + " [" + setCode + "/" + collectorId + "] cache size before: " + cacheSize); + + // Iterate through cache and invalidate matching entries + int removed = 0; + for (String key : SHARED_CARD_IMAGES_CACHE.asMap().keySet()) { + if (key.startsWith(name + "#") && key.contains(keyPattern) && key.contains(cardNumberPattern)) { + LOGGER.debug("ImageCache removing key: " + key); + SHARED_CARD_IMAGES_CACHE.invalidate(key); + removed++; + } + } + LOGGER.debug("ImageCache invalidateCard: removed " + removed + " entries"); + } } diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java b/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java index 44f059180a0..9e4927125e5 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/images/OnDemandImageDownloader.java @@ -18,6 +18,7 @@ import java.nio.file.AccessDeniedException; import java.util.List; import java.util.Set; import java.util.concurrent.*; +import java.util.function.Consumer; import static org.mage.plugins.card.utils.CardImageUtils.getImagesDir; @@ -49,6 +50,9 @@ public class OnDemandImageDownloader { // Minimum file size to consider download successful private static final int MIN_FILE_SIZE_OF_GOOD_IMAGE = 1024 * 6; + // Listeners notified when an image is downloaded + private final Set> downloadListeners = ConcurrentHashMap.newKeySet(); + private OnDemandImageDownloader() { executor = Executors.newSingleThreadExecutor( new XmageThreadFactory("OnDemandImageDownloader", true) @@ -198,7 +202,16 @@ public class OnDemandImageDownloader { destFile.getParentFile().mkdirs(); } fileTempImage.cp_rp(destFile); - logger.info("Downloaded: " + card.getName() + " (" + card.getSet() + ")"); + logger.debug("Downloaded: " + card.getName() + " (" + card.getSet() + ")"); + + // Invalidate cache so next access reloads from disk + logger.debug("Invalidating cache for: " + card.getName() + " [" + card.getSet() + "/" + card.getCollectorId() + "]"); + ImageCache.invalidateCard(card.getName(), card.getSet(), card.getCollectorId()); + + // Notify listeners that image is available + logger.debug("Notifying " + downloadListeners.size() + " listeners for: " + card.getName()); + notifyListeners(card); + return true; } else { logger.debug("Downloaded file too small for: " + card.getName()); @@ -229,4 +242,39 @@ public class OnDemandImageDownloader { public void clearTracking() { queuedOrDownloaded.clear(); } + + /** + * Register a listener to be notified when an image is downloaded. + * The listener receives the CardDownloadData of the downloaded card. + * + * @param listener the listener to add + */ + public void addDownloadListener(Consumer listener) { + downloadListeners.add(listener); + } + + /** + * Remove a download listener. + * + * @param listener the listener to remove + */ + public void removeDownloadListener(Consumer listener) { + downloadListeners.remove(listener); + } + + /** + * Notify all listeners that an image was downloaded. + */ + private void notifyListeners(CardDownloadData card) { + int count = 0; + for (Consumer listener : downloadListeners) { + try { + listener.accept(card); + count++; + } catch (Exception e) { + logger.error("Error notifying download listener: " + e.getMessage(), e); + } + } + logger.debug("Notified " + count + " listeners for " + card.getName()); + } }