From 1d17489ea5232c8a8acf46c06d4b65443c061470 Mon Sep 17 00:00:00 2001 From: Mikhail Semenov Date: Mon, 16 Feb 2026 23:54:09 +0100 Subject: [PATCH] Add imgBB support Also add common class for remote background handlers. --- .../alexiil/mods/load/MinecraftDisplayer.java | 69 +++--- .../alexiil/mods/load/RemoteCacheManager.java | 204 ++++++++++++++++++ .../mods/load/imgbb/ImgbbCacheManager.java | 61 ++++++ .../alexiil/mods/load/imgbb/ImgbbClient.java | 68 ++++++ .../mods/load/imgur/ImgurCacheManager.java | 182 ++-------------- 5 files changed, 392 insertions(+), 192 deletions(-) create mode 100644 src/main/java/alexiil/mods/load/RemoteCacheManager.java create mode 100644 src/main/java/alexiil/mods/load/imgbb/ImgbbCacheManager.java create mode 100644 src/main/java/alexiil/mods/load/imgbb/ImgbbClient.java diff --git a/src/main/java/alexiil/mods/load/MinecraftDisplayer.java b/src/main/java/alexiil/mods/load/MinecraftDisplayer.java index 2b49b6f..6bf37cb 100644 --- a/src/main/java/alexiil/mods/load/MinecraftDisplayer.java +++ b/src/main/java/alexiil/mods/load/MinecraftDisplayer.java @@ -46,6 +46,7 @@ import org.lwjgl.opengl.SharedDrawable; import alexiil.mods.load.ProgressDisplayer.IDisplayer; +import alexiil.mods.load.imgbb.ImgbbCacheManager; import alexiil.mods.load.imgur.ImgurCacheManager; import alexiil.mods.load.json.Area; import alexiil.mods.load.json.EPosition; @@ -143,6 +144,7 @@ public class MinecraftDisplayer implements IDisplayer { private float[] lbRGB = new float[] { 1, 1, 0 }; private float loadingBarsAlpha = 0.5F; private boolean useImgur = false; + private boolean useImgbb = false; private boolean saltBGhasBeenRendered = false; @@ -157,7 +159,7 @@ public class MinecraftDisplayer implements IDisplayer { private static String newBlendImage = "none"; private static int nonStaticElementsToGo; - private ImgurCacheManager imgurCacheManager = null; + private RemoteCacheManager remoteCacheManager = null; private ScheduledExecutorService backgroundExec = null; private boolean scheduledTipExecSet = false; @@ -652,6 +654,10 @@ public void open(Configuration cfg) { String comment30 = "Set to true if you want to load images from an imgur gallery and use them as backgrounds."; useImgur = cfg.getBoolean("useImgur", "imgur", useImgur, comment30); + // imgbb + String commentImgbb = "Set to true if you want to load images from an imgbb album and use them as backgrounds."; + useImgbb = cfg.getBoolean("useImgbb", "imgbb", useImgbb, commentImgbb); + // tips String comment32 = "Set to true if you want to display random tips. Tips are stored in a separate file"; tipsEnabled = cfg.getBoolean("tipsEnabled", "tips", tipsEnabled, comment32); @@ -735,20 +741,14 @@ public void run() { } }, changeFrequency, changeFrequency, TimeUnit.SECONDS); - if (useImgur) { - imgurCacheManager = new ImgurCacheManager(); - imgurCacheManager.loadConfig(cfg); - - List imgurBackgrounds = new ArrayList<>(); - imgurCacheManager.setupImgurGallery(res -> { - // Override the default background with the first image we get, otherwise the image will only - // be visible after the first blend occurs - if (imgurBackgrounds.isEmpty()) background = res.toString(); - - // Progressively add each image to the list of random backgrounds - imgurBackgrounds.add(res.toString()); - randomBackgroundArray = imgurBackgrounds.toArray(new String[0]); - }); + if (useImgur && useImgbb) { + BetterLoadingScreen.log.warn( + "Both useImgur and useImgbb are enabled. Only one remote image provider can be active at a time. Using imgbb."); + setupRemoteCacheManager(new ImgbbCacheManager(), cfg); + } else if (useImgbb) { + setupRemoteCacheManager(new ImgbbCacheManager(), cfg); + } else if (useImgur) { + setupRemoteCacheManager(new ImgurCacheManager(), cfg); } } } @@ -1412,13 +1412,15 @@ private void bindTexture(String resourceLocation) { ResourceLocation res = new ResourceLocation(resourceLocation); // We cannot go through the default texture loader, because it can't load from the file system - AbstractTexture texture = imgurCacheManager != null ? imgurCacheManager.getCachedTexture(res) : null; - if (texture != null) { - // Add the texture to TextureManager's cache to disable the loading logic in bindTexture - try { - textureManager.loadTexture(res, texture); - } catch (Exception e) { - BetterLoadingScreen.log.error("Failed to load imgur texture: " + res.getResourcePath(), e); + if (remoteCacheManager != null) { + AbstractTexture texture = remoteCacheManager.getCachedTexture(res); + if (texture != null) { + // Add the texture to TextureManager's cache to disable the loading logic in bindTexture + try { + textureManager.loadTexture(res, texture); + } catch (Exception e) { + BetterLoadingScreen.log.error("Failed to load cached texture: " + res.getResourcePath(), e); + } } } @@ -1508,7 +1510,22 @@ public ImageRender[] getImageData() { return images; } - @Override + private void setupRemoteCacheManager(RemoteCacheManager manager, Configuration cfg) { + manager.loadConfig(cfg); + + List remoteBackgrounds = new ArrayList<>(); + manager.setup(res -> { + // Override the default background with the first image we get, otherwise the image will only + // be visible after the first blend occurs + if (remoteBackgrounds.isEmpty()) background = res.toString(); + + // Progressively add each image to the list of random backgrounds + remoteBackgrounds.add(res.toString()); + randomBackgroundArray = remoteBackgrounds.toArray(new String[0]); + }); + remoteCacheManager = manager; + } + public void close() { if (splashRenderThread != null && splashRenderThread.isAlive()) { BetterLoadingScreen.log.info("BLS Splash loading thread closing"); @@ -1531,9 +1548,9 @@ public void close() { } getOnlyList().remove(myPack); - if (imgurCacheManager != null) { - imgurCacheManager.cleanUp(); - imgurCacheManager = null; + if (remoteCacheManager != null) { + remoteCacheManager.cleanUp(); + remoteCacheManager = null; } } } diff --git a/src/main/java/alexiil/mods/load/RemoteCacheManager.java b/src/main/java/alexiil/mods/load/RemoteCacheManager.java new file mode 100644 index 0000000..44459e0 --- /dev/null +++ b/src/main/java/alexiil/mods/load/RemoteCacheManager.java @@ -0,0 +1,204 @@ +package alexiil.mods.load; + +import java.awt.image.BufferedImage; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.imageio.ImageIO; + +import net.minecraft.client.renderer.texture.AbstractTexture; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.common.config.Configuration; + +import alexiil.mods.load.imgur.LateInitDynamicTexture; + +public abstract class RemoteCacheManager { + + private static final boolean OFFLINE_MODE = Boolean.getBoolean("bls.offlineMode"); + + private final String cacheDir; + private final String providerName; + private final Map textureCache = new ConcurrentHashMap<>(); + private volatile boolean cancelSetup; + + protected RemoteCacheManager(String cacheDir, String providerName) { + this.cacheDir = cacheDir; + this.providerName = providerName; + } + + public abstract void loadConfig(Configuration config); + + protected abstract C createClient() throws Exception; + + protected abstract List fetchRemoteImages(C client) throws Exception; + + protected abstract byte[] downloadImage(C client, ImageEntry entry) throws Exception; + + public AbstractTexture getCachedTexture(ResourceLocation location) { + if (!location.getResourceDomain().equals(cacheDir)) return null; + + return textureCache.get(location.getResourcePath()); + } + + public void cleanUp() { + textureCache.values().forEach(AbstractTexture::deleteGlTexture); + textureCache.clear(); + + cancelSetup = true; + } + + public void setup(Consumer textureLocationConsumer) { + Path cacheFolder = Paths.get(cacheDir); + if (Files.notExists(cacheFolder)) { + try { + Files.createDirectory(cacheFolder); + } catch (IOException e) { + BetterLoadingScreen.log.error("Error while creating " + providerName + " cache directory", e); + return; + } + } + + List cachedImageIDs = getCachedImageIDs(); + if (cachedImageIDs == null) return; + + // Load any image that is already cached to get something rendering quickly + loadAnyImageFromDisk(cachedImageIDs, textureLocationConsumer); + + CompletableFuture.runAsync(() -> { + try (C client = OFFLINE_MODE ? null : createClient()) { + Consumer imageHandler = entry -> { + String imageID = entry.cacheId; + + synchronized (cachedImageIDs) { + cachedImageIDs.remove(imageID); + } + + if (cancelSetup) return; + + if (textureCache.containsKey(imageID)) return; + + Path imageFile = getCachedImagePath(imageID); + + try { + if (Files.exists(imageFile)) { + readAndCacheImageFromStream( + imageID, + new BufferedInputStream(Files.newInputStream(imageFile), 1024 * 1024), + false); + } else { + if (OFFLINE_MODE) return; + + readAndCacheImageFromStream( + imageID, + new ByteArrayInputStream(downloadImage(client, entry)), + true); + } + } catch (Exception e) { + BetterLoadingScreen.log.error("Error while loading " + providerName + " image", e); + return; + } + + synchronized (textureLocationConsumer) { + textureLocationConsumer.accept(new ResourceLocation(cacheDir, imageID)); + } + }; + + if (OFFLINE_MODE) { + cachedImageIDs.stream().parallel().map(id -> new ImageEntry(id, id)).forEach(imageHandler); + } else { + fetchRemoteImages(client).stream().parallel().forEach(imageHandler); + } + } catch (Exception e) { + BetterLoadingScreen.log.error("Error while fetching " + providerName + " images", e); + } + }).thenRunAsync(() -> { + if (OFFLINE_MODE) return; + + // Delete cached images that are no longer in the album + try { + for (String id : cachedImageIDs) { + Files.deleteIfExists(getCachedImagePath(id)); + } + } catch (IOException e) { + BetterLoadingScreen.log.error("Error while deleting unused cached " + providerName + " images", e); + } + }); + } + + private void loadAnyImageFromDisk(List cachedImageIDs, Consumer textureLocationConsumer) { + if (cachedImageIDs.isEmpty()) return; + + String imageID = cachedImageIDs.get(ThreadLocalRandom.current().nextInt(cachedImageIDs.size())); + try { + readAndCacheImageFromDisk(imageID); + } catch (IOException e) { + BetterLoadingScreen.log.error("Error while loading first cached " + providerName + " image", e); + return; + } + + synchronized (textureLocationConsumer) { + textureLocationConsumer.accept(new ResourceLocation(cacheDir, imageID)); + } + } + + private void readAndCacheImageFromStream(String imageID, InputStream imageStream, boolean saveToDisk) + throws IOException { + BufferedImage image = ImageIO.read(imageStream); + textureCache.put(imageID, new LateInitDynamicTexture(image, image.getWidth(), image.getHeight())); + + if (saveToDisk && Files.notExists(getCachedImagePath(imageID))) writeImageToCache(imageID, image); + } + + private void readAndCacheImageFromDisk(String imageID) throws IOException { + readAndCacheImageFromStream( + imageID, + new BufferedInputStream(Files.newInputStream(getCachedImagePath(imageID)), 1024 * 1024), + false); + } + + private void writeImageToCache(String imageID, BufferedImage image) throws IOException { + ImageIO.write( + image, + "png", + new BufferedOutputStream(Files.newOutputStream(getCachedImagePath(imageID)), 1024 * 1024)); + } + + private Path getCachedImagePath(String imageID) { + return Paths.get(cacheDir).resolve(imageID + ".png"); + } + + private List getCachedImageIDs() { + try (Stream cacheFolderStream = Files.list(Paths.get(cacheDir))) { + return cacheFolderStream.map(path -> path.getFileName().toString().replace(".png", "")) + .collect(Collectors.toList()); + } catch (IOException e) { + BetterLoadingScreen.log.error("Error while iterating " + providerName + " cache folder", e); + return null; + } + } + + public static class ImageEntry { + + public final String cacheId; + public final String downloadRef; + + public ImageEntry(String cacheId, String downloadRef) { + this.cacheId = cacheId; + this.downloadRef = downloadRef; + } + } +} diff --git a/src/main/java/alexiil/mods/load/imgbb/ImgbbCacheManager.java b/src/main/java/alexiil/mods/load/imgbb/ImgbbCacheManager.java new file mode 100644 index 0000000..38e1364 --- /dev/null +++ b/src/main/java/alexiil/mods/load/imgbb/ImgbbCacheManager.java @@ -0,0 +1,61 @@ +package alexiil.mods.load.imgbb; + +import java.util.List; +import java.util.stream.Collectors; + +import net.minecraftforge.common.config.Configuration; + +import alexiil.mods.load.RemoteCacheManager; + +public class ImgbbCacheManager extends RemoteCacheManager { + + private String albumId; + private int requestTimeout; + + public ImgbbCacheManager() { + super("bls-imgbb-cache", "imgbb"); + } + + @Override + public void loadConfig(Configuration config) { + albumId = config.getString("imgbbAlbumId", "imgbb", "", "ID of the imgbb album. For example: X7wpP4"); + requestTimeout = config.getInt( + "imgbbRequestTimeout", + "imgbb", + 5000, + 100, + Integer.MAX_VALUE, + "Request timeout (ms) for imgbb requests"); + } + + @Override + protected ImgbbClient createClient() { + return new ImgbbClient(requestTimeout); + } + + @Override + protected List fetchRemoteImages(ImgbbClient client) throws Exception { + return client.fetchAlbumImageURLs(albumId).stream().map(url -> new ImageEntry(urlToImageID(url), url)) + .collect(Collectors.toList()); + } + + @Override + protected byte[] downloadImage(ImgbbClient client, ImageEntry entry) { + return client.fetchImage(entry.downloadRef); + } + + /** + * Extracts a stable image ID from an imgbb direct URL. Uses the URL path code as the ID. For example, + * {@code https://i.ibb.co/j9rBbcHy/02.png} becomes {@code j9rBbcHy_02}. + */ + static String urlToImageID(String url) { + // URL format: https://i.ibb.co/{code}/{filename}.{ext} + String path = url.replace("https://i.ibb.co/", ""); + // path is now "{code}/{filename}.{ext}" + int lastDot = path.lastIndexOf('.'); + if (lastDot != -1) { + path = path.substring(0, lastDot); + } + return path.replace('/', '_'); + } +} diff --git a/src/main/java/alexiil/mods/load/imgbb/ImgbbClient.java b/src/main/java/alexiil/mods/load/imgbb/ImgbbClient.java new file mode 100644 index 0000000..4c699c4 --- /dev/null +++ b/src/main/java/alexiil/mods/load/imgbb/ImgbbClient.java @@ -0,0 +1,68 @@ +package alexiil.mods.load.imgbb; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpStatus; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +public class ImgbbClient implements AutoCloseable { + + private static final Pattern IMAGE_URL_PATTERN = Pattern + .compile("image-container --media\"> fetchAlbumImageURLs(String albumId) throws IOException { + try (CloseableHttpResponse response = client.execute(new HttpGet("https://ibb.co/album/" + albumId))) { + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) + throw new IOException("Failed to fetch imgbb album. Server returned " + response.getStatusLine()); + + String html = new String(IOUtils.toByteArray(response.getEntity().getContent()), StandardCharsets.UTF_8); + + List urls = new ArrayList<>(); + Matcher matcher = IMAGE_URL_PATTERN.matcher(html); + while (matcher.find()) { + urls.add(matcher.group(1)); + } + + if (urls.isEmpty()) throw new IOException("No images found in imgbb album: " + albumId); + + return urls; + } + } + + public byte[] fetchImage(String imageUrl) { + try (CloseableHttpResponse response = client.execute(new HttpGet(imageUrl))) { + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) + throw new IOException("Failed to fetch image. Server returned " + response.getStatusLine()); + + return IOUtils.toByteArray(response.getEntity().getContent()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void close() throws Exception { + this.client.close(); + } +} diff --git a/src/main/java/alexiil/mods/load/imgur/ImgurCacheManager.java b/src/main/java/alexiil/mods/load/imgur/ImgurCacheManager.java index 250b129..2f3a7ff 100644 --- a/src/main/java/alexiil/mods/load/imgur/ImgurCacheManager.java +++ b/src/main/java/alexiil/mods/load/imgur/ImgurCacheManager.java @@ -1,44 +1,23 @@ package alexiil.mods.load.imgur; -import java.awt.image.BufferedImage; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ThreadLocalRandom; -import java.util.function.Consumer; import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.imageio.ImageIO; - -import net.minecraft.client.renderer.texture.AbstractTexture; -import net.minecraft.util.ResourceLocation; import net.minecraftforge.common.config.Configuration; -import alexiil.mods.load.BetterLoadingScreen; - -public class ImgurCacheManager { +import alexiil.mods.load.RemoteCacheManager; - private static final boolean OFFLINE_MODE = Boolean.getBoolean("bls.offlineMode"); - private static final String IMGUR_CACHE_DIR = "bls-imgur-cache"; - - private final Map textureCache = new ConcurrentHashMap<>(); +public class ImgurCacheManager extends RemoteCacheManager { private String appClientId; private String galleryId; private int requestTimeout; - private volatile boolean cancelSetup; + public ImgurCacheManager() { + super("bls-imgur-cache", "imgur"); + } + @Override public void loadConfig(Configuration config) { appClientId = config.getString( "imgurAppClientId", @@ -56,148 +35,19 @@ public void loadConfig(Configuration config) { "Request timeout (ms) for imgur requests"); } - public AbstractTexture getCachedTexture(ResourceLocation location) { - if (!location.getResourceDomain().equals(IMGUR_CACHE_DIR)) return null; - - return textureCache.get(location.getResourcePath()); - } - - public void cleanUp() { - textureCache.values().forEach(AbstractTexture::deleteGlTexture); - textureCache.clear(); - - cancelSetup = true; - } - - public void setupImgurGallery(Consumer textureLocationConsumer) { - Path cacheFolder = Paths.get(IMGUR_CACHE_DIR); - if (Files.notExists(cacheFolder)) { - try { - Files.createDirectory(cacheFolder); - } catch (IOException e) { - BetterLoadingScreen.log.error("Error while creating imgur cache directory", e); - return; - } - } - - List cachedImageIDs = getCachedImageIDs(); - if (cachedImageIDs == null) return; - - // Load any image that is already cached. This avoids waiting for the imgur api call to finish to get something - // rendering - loadAnyImageFromDisk(cachedImageIDs, textureLocationConsumer); - - CompletableFuture.runAsync(() -> { - try (ImgurClient client = OFFLINE_MODE ? null : new ImgurClient(appClientId, requestTimeout)) { - Consumer imageHandler = imageID -> { - // This will leave behind cached images that are no longer in the gallery - synchronized (cachedImageIDs) { - cachedImageIDs.remove(imageID); - } - - if (cancelSetup) return; - - // Should only be the image that might have been loaded in loadAnyImageFromDisk() - if (textureCache.containsKey(imageID)) return; - - Path imageFile = getCachedImagePath(imageID); - - try { - if (Files.exists(getCachedImagePath(imageID))) { - // Read from disk - readAndCacheImageFromStream( - imageID, - new BufferedInputStream(Files.newInputStream(imageFile), 1024 * 1024), - false); - } else { - if (OFFLINE_MODE) return; - - readAndCacheImageFromStream( - imageID, - new ByteArrayInputStream(client.fetchImage(imageID)), - true); - } - } catch (IOException e) { - BetterLoadingScreen.log.error("Error while loading imgur image", e); - return; - } - - synchronized (textureLocationConsumer) { - textureLocationConsumer.accept(new ResourceLocation(IMGUR_CACHE_DIR, imageID)); - } - }; - - if (OFFLINE_MODE) { - cachedImageIDs.stream().parallel().forEach(imageHandler); - } else { - client.fetchGalleryImageIDs(galleryId, true).stream().parallel().forEach(imageHandler); - } - } catch (Exception e) { - BetterLoadingScreen.log.error("Error while fetching imgur gallery", e); - } - }).thenRunAsync(() -> { - if (OFFLINE_MODE) return; - - // Delete cached images that are no longer in the gallery - try { - for (String id : cachedImageIDs) { - Files.deleteIfExists(getCachedImagePath(id)); - } - } catch (IOException e) { - BetterLoadingScreen.log.error("Error while deleting unused cached imgur images", e); - } - }); - } - - private void loadAnyImageFromDisk(List cachedImageIDs, Consumer textureLocationConsumer) { - if (cachedImageIDs.isEmpty()) return; - - String imageID = cachedImageIDs.get(ThreadLocalRandom.current().nextInt(cachedImageIDs.size())); - try { - readAndCacheImageFromDisk(imageID); - } catch (IOException e) { - BetterLoadingScreen.log.error("Error while loading first cached imgur image", e); - return; - } - - synchronized (textureLocationConsumer) { - textureLocationConsumer.accept(new ResourceLocation(IMGUR_CACHE_DIR, imageID)); - } - } - - private void readAndCacheImageFromStream(String imageID, InputStream imageStream, boolean saveToDisk) - throws IOException { - BufferedImage image = ImageIO.read(imageStream); - textureCache.put(imageID, new LateInitDynamicTexture(image, image.getWidth(), image.getHeight())); - - if (saveToDisk && Files.notExists(getCachedImagePath(imageID))) writeImageToCache(imageID, image); - } - - private void readAndCacheImageFromDisk(String imageID) throws IOException { - readAndCacheImageFromStream( - imageID, - new BufferedInputStream(Files.newInputStream(getCachedImagePath(imageID)), 1024 * 1024), - false); - } - - private void writeImageToCache(String imageID, BufferedImage image) throws IOException { - ImageIO.write( - image, - "png", - new BufferedOutputStream(Files.newOutputStream(getCachedImagePath(imageID)), 1024 * 1024)); + @Override + protected ImgurClient createClient() { + return new ImgurClient(appClientId, requestTimeout); } - private static Path getCachedImagePath(String imageID) { - return Paths.get(IMGUR_CACHE_DIR).resolve(imageID + ".png"); + @Override + protected List fetchRemoteImages(ImgurClient client) throws Exception { + return client.fetchGalleryImageIDs(galleryId, true).stream().map(id -> new ImageEntry(id, id)) + .collect(Collectors.toList()); } - private List getCachedImageIDs() { - try (Stream cacheFolderStream = Files.list(Paths.get(IMGUR_CACHE_DIR))) { - return cacheFolderStream.map(path -> path.getFileName().toString().replace(".png", "")) - .collect(Collectors.toList()); - } catch (IOException e) { - BetterLoadingScreen.log.error("Error while iterating imgur cache folder", e); - return null; - } + @Override + protected byte[] downloadImage(ImgurClient client, ImageEntry entry) { + return client.fetchImage(entry.downloadRef); } }