From a39b4d501d0c7aa44a0cc9873db186d03546d189 Mon Sep 17 00:00:00 2001 From: Sebastian Reddig Date: Mon, 22 Jun 2026 23:03:28 +0200 Subject: [PATCH 1/2] [FEATURE] General cache location for stadium image in Area Sizer #2440 --- ...rlImageLabel.java => AsyncImageLabel.java} | 62 ++------ .../gui/image/CachedUrlImageProvider.java | 143 ++++++++++++++++++ .../java/core/gui/image/ImageProvider.java | 10 ++ .../java/core/gui/theme/ThemeManager.java | 102 +++++++------ src/main/java/core/util/FileNameUtil.java | 33 ++++ .../java/tool/arenasizer/ArenaInfoPanel.java | 19 +-- src/main/resources/release_notes.md | 1 + 7 files changed, 265 insertions(+), 105 deletions(-) rename src/main/java/core/gui/{UrlImageLabel.java => AsyncImageLabel.java} (50%) create mode 100644 src/main/java/core/gui/image/CachedUrlImageProvider.java create mode 100644 src/main/java/core/gui/image/ImageProvider.java create mode 100644 src/main/java/core/util/FileNameUtil.java diff --git a/src/main/java/core/gui/UrlImageLabel.java b/src/main/java/core/gui/AsyncImageLabel.java similarity index 50% rename from src/main/java/core/gui/UrlImageLabel.java rename to src/main/java/core/gui/AsyncImageLabel.java index 9992cbb8c..e43ecd991 100644 --- a/src/main/java/core/gui/UrlImageLabel.java +++ b/src/main/java/core/gui/AsyncImageLabel.java @@ -1,21 +1,18 @@ package core.gui; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Strings; +import core.gui.image.ImageProvider; -import javax.imageio.ImageIO; import javax.swing.*; import java.awt.*; import java.awt.image.BufferedImage; -import java.io.InputStream; -import java.net.URL; -import java.net.URLConnection; import static org.apache.commons.lang3.StringUtils.EMPTY; -public class UrlImageLabel extends JLabel { +public class AsyncImageLabel extends JLabel { - public UrlImageLabel() { + private long loadCounter = 0; + + public AsyncImageLabel() { super(EMPTY, SwingConstants.CENTER); setOpaque(true); setBackground(new Color(245, 245, 245)); @@ -27,67 +24,38 @@ public UrlImageLabel() { setMinimumSize(dimension); } - /** - * Loads primary, then fallback, otherwise placeholder - */ - public void loadWithFallback(String primaryUrl, String fallbackUrl) { - final var fixedPrimaryUrl = toUrlWithHttps(primaryUrl); - final var fixedFallbackUrl = toUrlWithHttps(fallbackUrl); + public void load(ImageProvider imageProvider) { + final long currentLoad = ++loadCounter; showLoading(); new SwingWorker() { @Override protected BufferedImage doInBackground() { - BufferedImage img = tryLoad(fixedPrimaryUrl); - if (img != null) { - return img; - } - - if (StringUtils.isNotBlank(fixedFallbackUrl)) { - img = tryLoad(fixedFallbackUrl); - } - return img; // may be null + return imageProvider.load().orElse(null); } @Override protected void done() { + if (currentLoad != loadCounter) { + return; + } + try { BufferedImage img = get(); + if (img != null) { showImage(img); } else { showPlaceholder("Image not available"); } } catch (Exception ex) { - // If the worker fails unexpectedly showPlaceholder("Error loading image"); } } }.execute(); } - private BufferedImage tryLoad(String urlString) { - if (StringUtils.isBlank(urlString)) { - return null; - } - - try { - URL url = new URL(urlString); - URLConnection con = url.openConnection(); - con.setRequestProperty("User-Agent", "Mozilla/5.0"); - con.setConnectTimeout(4000); - con.setReadTimeout(8000); - - try (InputStream in = con.getInputStream()) { - // ImageIO.read may return null if the input is not a supported image format - return ImageIO.read(in); - } - } catch (Exception e) { - return null; - } - } - private void showLoading() { setIcon(null); setText("Loading image …"); @@ -109,8 +77,4 @@ private void showPlaceholder(String text) { revalidate(); repaint(); } - - private static String toUrlWithHttps(String url) { - return Strings.CS.startsWith(url, "//") ? "https:" + url : url; - } } diff --git a/src/main/java/core/gui/image/CachedUrlImageProvider.java b/src/main/java/core/gui/image/CachedUrlImageProvider.java new file mode 100644 index 000000000..edc6e4812 --- /dev/null +++ b/src/main/java/core/gui/image/CachedUrlImageProvider.java @@ -0,0 +1,143 @@ +package core.gui.image; + +import core.util.FileNameUtil; +import core.util.HOLogger; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +public class CachedUrlImageProvider implements ImageProvider { + + private static final String IMAGE_FORMAT_NAME = "PNG"; + + private final Path cacheDirectory; + private final String primaryUrl; + private final String fallbackUrl; + + public CachedUrlImageProvider(Path cacheDirectory, String primaryUrl, String fallbackUrl) { + this.cacheDirectory = cacheDirectory; + this.primaryUrl = toUrlWithHttps(primaryUrl); + this.fallbackUrl = toUrlWithHttps(fallbackUrl); + } + + @Override + public Optional load() { + Optional primary = tryLoad(primaryUrl); + + if (primary.isPresent()) { + return primary; + } + + if (StringUtils.isNotBlank(fallbackUrl)) { + return tryLoad(fallbackUrl); + } + + return Optional.empty(); + } + + private Optional tryLoad(String urlString) { + if (StringUtils.isBlank(urlString)) { + return Optional.empty(); + } + + Path fullPath = cacheDirectory + .toAbsolutePath() + .resolve(toCacheFilename(urlString)); + + return loadFromFile(fullPath) + .or(() -> loadFromUrlAndSave(urlString, fullPath)); + } + + private static Optional loadFromUrlAndSave(String urlString, Path fullPath) { + Optional fromUrl = loadFromUrl(urlString); + fromUrl.ifPresent(bufferedImage -> saveToFile(bufferedImage, fullPath)); + return fromUrl; + } + + private static Optional loadFromUrl(String urlString) { + try { + URL url = new URL(urlString); + URLConnection con = url.openConnection(); + + con.setRequestProperty("User-Agent", "Mozilla/5.0"); + con.setConnectTimeout(4000); + con.setReadTimeout(8000); + + try (InputStream in = con.getInputStream()) { + return Optional.ofNullable(ImageIO.read(in)); + } + } catch (IOException e) { + HOLogger.instance().debug(CachedUrlImageProvider.class, + "Image could not be loaded from URL '%s': %s".formatted(urlString, e.getMessage())); + return Optional.empty(); + } + } + + private static Optional loadFromFile(Path path) { + if (!Files.isRegularFile(path)) { + return Optional.empty(); + } + + try { + Optional image = Optional.ofNullable(ImageIO.read(path.toFile())); + + if (image.isPresent()) { + HOLogger.instance().log(CachedUrlImageProvider.class, + "Image loaded from file '%s'.".formatted(path)); + } + + return image; + } catch (IOException e) { + HOLogger.instance().error(CachedUrlImageProvider.class, + "Failed to load image from file '%s': %s".formatted(path, e.getMessage())); + return Optional.empty(); + } + } + + private static void saveToFile(BufferedImage image, Path path) { + try { + Path parent = path.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + boolean written = ImageIO.write(image, IMAGE_FORMAT_NAME, path.toFile()); + + if (written) { + HOLogger.instance().log(CachedUrlImageProvider.class, + "Saved image (format '%s') to file '%s'.".formatted(IMAGE_FORMAT_NAME, path)); + } else { + HOLogger.instance().warning(CachedUrlImageProvider.class, + "Could not save image (format '%s') to file '%s'.".formatted(IMAGE_FORMAT_NAME, path)); + } + } catch (IOException e) { + HOLogger.instance().error(CachedUrlImageProvider.class, + "Failed to save image (format '%s') to file '%s': %s" + .formatted(IMAGE_FORMAT_NAME, path, e.getMessage())); + } + } + + private static String toCacheFilename(String fixedUrl) { + return "%s.%s".formatted( + FileNameUtil.fileNameFromUrl(fixedUrl), + IMAGE_FORMAT_NAME.toLowerCase() + ); + } + + private static String toUrlWithHttps(String url) { + if (StringUtils.isBlank(url)) { + return url; + } + + return Strings.CS.startsWith(url, "//") ? "https:" + url : url; + } +} diff --git a/src/main/java/core/gui/image/ImageProvider.java b/src/main/java/core/gui/image/ImageProvider.java new file mode 100644 index 000000000..9e696394a --- /dev/null +++ b/src/main/java/core/gui/image/ImageProvider.java @@ -0,0 +1,10 @@ +package core.gui.image; + +import java.awt.image.BufferedImage; +import java.util.Optional; + +@FunctionalInterface +public interface ImageProvider { + + Optional load(); +} diff --git a/src/main/java/core/gui/theme/ThemeManager.java b/src/main/java/core/gui/theme/ThemeManager.java index 5dccda3ef..03dd6ba3e 100644 --- a/src/main/java/core/gui/theme/ThemeManager.java +++ b/src/main/java/core/gui/theme/ThemeManager.java @@ -7,6 +7,8 @@ import core.db.user.UserManager; import core.file.xml.XMLAvatarsParser; import core.gui.HOMainFrame; +import core.gui.image.CachedUrlImageProvider; +import core.gui.image.ImageProvider; import core.gui.theme.dark.DarculaDarkTheme; import core.gui.theme.dark.SolarizedDarkTheme; import core.gui.theme.gnome.GnomeTheme; @@ -22,8 +24,11 @@ import core.util.OSUtils; import tool.updater.UpdateHelper; -import java.awt.Color; -import java.awt.event.*; +import javax.imageio.ImageIO; +import javax.swing.*; +import javax.swing.text.DefaultEditorKit; +import java.awt.*; +import java.awt.event.KeyEvent; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -31,11 +36,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; -import javax.imageio.ImageIO; -import javax.swing.*; -import javax.swing.text.*; /** * Manages all the HO Themes. @@ -43,61 +46,62 @@ public final class ThemeManager { /** Name of the default theme. */ - public static final String DEFAULT_THEME_NAME = NimbusTheme.THEME_NAME; + public static final String DEFAULT_THEME_NAME = NimbusTheme.THEME_NAME; - private static final Path tempImgPath = Paths.get(UserManager.instance().getDbParentFolder() , "img"); - private static final Path teamLogoPath = tempImgPath.resolve("clubLogos"); - private static final File teamLogoDir = new File(String.valueOf(teamLogoPath)); - private static final Path playerAvatarPath = tempImgPath.resolve("playersAvatar"); - private static final File playerAvatarDir = new File(String.valueOf(playerAvatarPath)); - private static final Map themes = new LinkedHashMap<>(); - private static final ThemeManager MANAGER = new ThemeManager(); + private static final Path tempImgPath = Paths.get(UserManager.instance().getDbParentFolder(), "img"); + private static final Path stadiumImagesPath = tempImgPath.resolve("stadiumImages"); + private static final Path teamLogoPath = tempImgPath.resolve("clubLogos"); + private static final Path playerAvatarPath = tempImgPath.resolve("playersAvatar"); + private static final Map themes = new LinkedHashMap<>(); + private static final ThemeManager MANAGER = new ThemeManager(); - HOClassicSchema classicSchema = new HOClassicSchema(); + HOClassicSchema classicSchema = new HOClassicSchema(); - private ThemeManager(){ - initialize(); - } + private ThemeManager() { + initialize(); + } - public static ThemeManager instance(){ - return MANAGER; - } + public static ThemeManager instance() { + return MANAGER; + } - private void initialize() { + private void initialize() { - themes.put(NimbusTheme.THEME_NAME, new NimbusTheme()); - themes.put(DarculaDarkTheme.THEME_NAME, new DarculaDarkTheme()); - themes.put(SolarizedDarkTheme.THEME_NAME, new SolarizedDarkTheme()); - themes.put(SolarizedLightTheme.THEME_NAME, new SolarizedLightTheme()); + themes.put(NimbusTheme.THEME_NAME, new NimbusTheme()); + themes.put(DarculaDarkTheme.THEME_NAME, new DarculaDarkTheme()); + themes.put(SolarizedDarkTheme.THEME_NAME, new SolarizedDarkTheme()); + themes.put(SolarizedLightTheme.THEME_NAME, new SolarizedLightTheme()); - if (OSUtils.isLinux()) { - themes.put(GnomeTheme.THEME_NAME, new GnomeTheme()); - } + if (OSUtils.isLinux()) { + themes.put(GnomeTheme.THEME_NAME, new GnomeTheme()); + } - if (!teamLogoDir.exists()) { - try { - Files.createDirectories(teamLogoPath); - } catch (IOException e) { - HOLogger.instance().log(this.getClass(),"Failed to create directory for team logos: " + e.getMessage()); - } - } + createNonExistingDir(stadiumImagesPath); + createNonExistingDir(teamLogoPath); + createNonExistingDir(playerAvatarPath); - if (!playerAvatarDir.exists()) { - try { - Files.createDirectories(playerAvatarPath); - } catch (IOException e) { - HOLogger.instance().log(this.getClass(),"Failed to create directory for player Avatars: " + e.getMessage()); - } - } + IconLoader.updateThemeStatus(new Object()); - IconLoader.updateThemeStatus(new Object()); + // TODO: Workaround some warnings which are issued incorrectly. To silence them you can call + LogUtil.getLogger(IconLoader.class).setLevel(Level.SEVERE); + Logger.getLogger("com.github.weisj.jsvg.parser.SVGLoader").setLevel(Level.SEVERE); + } - // TODO: Workaround some warnings which are issued incorrectly. To silence them you can call - LogUtil.getLogger(IconLoader.class).setLevel(Level.SEVERE); - Logger.getLogger("com.github.weisj.jsvg.parser.SVGLoader").setLevel(Level.SEVERE); - } + private static void createNonExistingDir(Path path) { + try { + Files.createDirectories(path); + + if (!Files.isDirectory(path)) { + HOLogger.instance().error(ThemeManager.class, + "Path exists but is not a directory: %s".formatted(path)); + } + } catch (IOException e) { + HOLogger.instance().log(ThemeManager.class, + "Failed to create directory: %s: %s".formatted(path, e.getMessage())); + } + } /** * Returns the list of registered themes. @@ -426,4 +430,8 @@ public void downloadPlayerAvatar(int playerId) { ThemeManager.instance().generateAllPlayerAvatar(playerAvatar, 1); HOMainFrame.instance().resetInformation(); } + + public static ImageProvider createStadiumImageProvider(String primaryUrl, String fallbackUrl) { + return new CachedUrlImageProvider(stadiumImagesPath, primaryUrl, fallbackUrl); + } } diff --git a/src/main/java/core/util/FileNameUtil.java b/src/main/java/core/util/FileNameUtil.java new file mode 100644 index 000000000..a69192745 --- /dev/null +++ b/src/main/java/core/util/FileNameUtil.java @@ -0,0 +1,33 @@ +package core.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public final class FileNameUtil { + + private FileNameUtil() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static String fileNameFromUrl(String url) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(url.getBytes(StandardCharsets.UTF_8)); + + return toHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + + for (byte b : bytes) { + result.append("%02x".formatted(b)); + } + + return result.toString(); + } +} diff --git a/src/main/java/tool/arenasizer/ArenaInfoPanel.java b/src/main/java/tool/arenasizer/ArenaInfoPanel.java index 98cd24e45..b1dc1ead6 100644 --- a/src/main/java/tool/arenasizer/ArenaInfoPanel.java +++ b/src/main/java/tool/arenasizer/ArenaInfoPanel.java @@ -1,6 +1,7 @@ package tool.arenasizer; -import core.gui.UrlImageLabel; +import core.gui.AsyncImageLabel; +import core.gui.theme.ThemeManager; import core.model.HOVerwaltung; import core.model.TranslationFacility; import core.util.HODateTime; @@ -19,7 +20,7 @@ public class ArenaInfoPanel extends JPanel { private final JPanel stadiumCard; - private final UrlImageLabel imageLoader; + private final AsyncImageLabel imageLabel; private final CapacityPanel currentCapacityPanel; private final JPanel renovationCard; private final CapacityPanel expandedCapacityPanel; @@ -33,16 +34,16 @@ public ArenaInfoPanel() { stadiumCard = new JPanel(); stadiumCard.setLayout(new BoxLayout(stadiumCard, BoxLayout.Y_AXIS)); - imageLoader = new UrlImageLabel(); - imageLoader.setPreferredSize(new Dimension(220, 220)); - imageLoader.setMinimumSize(new Dimension(220, 220)); - imageLoader.setMaximumSize(new Dimension(220, 220)); - imageLoader.setAlignmentX(Component.LEFT_ALIGNMENT); + imageLabel = new AsyncImageLabel(); + imageLabel.setPreferredSize(new Dimension(220, 220)); + imageLabel.setMinimumSize(new Dimension(220, 220)); + imageLabel.setMaximumSize(new Dimension(220, 220)); + imageLabel.setAlignmentX(Component.LEFT_ALIGNMENT); currentCapacityPanel = new CapacityPanel(); currentCapacityPanel.setAlignmentX(Component.LEFT_ALIGNMENT); - stadiumCard.add(imageLoader); + stadiumCard.add(imageLabel); stadiumCard.add(Box.createVerticalStrut(8)); stadiumCard.add(currentCapacityPanel); @@ -84,7 +85,7 @@ private void updateValues() { BorderFactory.createTitledBorder(stadium.getStadiumName()), BorderFactory.createEmptyBorder(8, 8, 8, 8) )); - imageLoader.loadWithFallback(stadium.getArenaImage(), stadium.getArenaFallbackImage()); + imageLabel.load(ThemeManager.createStadiumImageProvider(stadium.getArenaImage(), stadium.getArenaFallbackImage())); final String notAvailableString = TranslationFacility.tr("ls.general_label.not_available_abbreviation"); final var numberformat = Helper.getNumberFormat( 0); diff --git a/src/main/resources/release_notes.md b/src/main/resources/release_notes.md index c945b7c9e..3e64f8df6 100644 --- a/src/main/resources/release_notes.md +++ b/src/main/resources/release_notes.md @@ -78,6 +78,7 @@ * Removed the superfluous space for the `Spectators` in braces at the end. * Added the image of the stadium and made the view better arranged. (#2140) +* Added a local cache for stadium images to avoid downloading the same image again on each program start. (#2440) ### Misc From 80281936a35139ba86b1cdf7068ce14d7ffbeac8 Mon Sep 17 00:00:00 2001 From: Sebastian Reddig Date: Wed, 24 Jun 2026 19:40:50 +0200 Subject: [PATCH 2/2] fix: suppress expected 404 log for missing stadium images --- src/main/java/core/gui/image/CachedUrlImageProvider.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/core/gui/image/CachedUrlImageProvider.java b/src/main/java/core/gui/image/CachedUrlImageProvider.java index edc6e4812..927709af3 100644 --- a/src/main/java/core/gui/image/CachedUrlImageProvider.java +++ b/src/main/java/core/gui/image/CachedUrlImageProvider.java @@ -7,6 +7,7 @@ import javax.imageio.ImageIO; import java.awt.image.BufferedImage; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.URL; @@ -75,9 +76,12 @@ private static Optional loadFromUrl(String urlString) { try (InputStream in = con.getInputStream()) { return Optional.ofNullable(ImageIO.read(in)); } + } catch (FileNotFoundException e) { + // Expected if the club has no custom stadium image. + return Optional.empty(); } catch (IOException e) { HOLogger.instance().debug(CachedUrlImageProvider.class, - "Image could not be loaded from URL '%s': %s".formatted(urlString, e.getMessage())); + "Image could not be loaded from URL '%s': %s: %s".formatted(urlString, e.getClass().getSimpleName(), e.getMessage())); return Optional.empty(); } }