This commit is contained in:
Tuomas-Matti Soikkeli 2026-01-26 19:54:03 +02:00 committed by GitHub
commit 0797e1302c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 488 additions and 23 deletions

View file

@ -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;

View file

@ -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<Key, BufferedImage> 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<CardDownloadData> 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("\\", "");

View file

@ -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<CardDownloadData> 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;
}
}

View file

@ -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);
@ -371,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");
}
}

View file

@ -0,0 +1,280 @@
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 java.util.function.Consumer;
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<String> queuedOrDownloaded = ConcurrentHashMap.newKeySet();
// Download queue
private final BlockingQueue<CardDownloadData> 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;
// Listeners notified when an image is downloaded
private final Set<Consumer<CardDownloadData>> downloadListeners = ConcurrentHashMap.newKeySet();
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<String> 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.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());
}
}
} 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();
}
/**
* 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<CardDownloadData> listener) {
downloadListeners.add(listener);
}
/**
* Remove a download listener.
*
* @param listener the listener to remove
*/
public void removeDownloadListener(Consumer<CardDownloadData> listener) {
downloadListeners.remove(listener);
}
/**
* Notify all listeners that an image was downloaded.
*/
private void notifyListeners(CardDownloadData card) {
int count = 0;
for (Consumer<CardDownloadData> 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());
}
}