package chatty.util;
import chatty.util.gif.GifUtil;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.ImageIcon;
/**
* Allows the use of getImage() methods that get an image from an URL, while
* automatically caching it on file for the next request. Each request can have
* a prefix, which is added to the cache filename and makes it possible to only
* delete some of the cache files with the also contained clear cache functions.
*
* A global path for the cache can be set with {@link setDefaultPath(Path)}, but
* a different path can also be specified for each method. It is also possible
* to globally enable/disable the cache.
*
* @author tduva
*/
public class ImageCache {
private static final Logger LOGGER = Logger.getLogger(ImageCache.class.getName());
/**
* Prefix for all image cache files (in front of the prefix defined in the
* individual method calls).
*/
private static final String GLOBAL_PREFIX = "imgcache-";
/**
* Used as expire time for {@link clearOldFiles()} and
* {@link clearOldFiles(Path)}.
*/
private static final int DELETE_FILES_OLDER_THAN = 60*60*24*30*2;
private static volatile Path defaultPath = Paths.get("");
private static volatile boolean cachingEnabled = true;
/**
* Sets the default image cache Path, used by some functions.
*
* @see clearOldFiles()
* @see getImage(URL, String, int)
* @see clearCache(String)
*
* @param path The default Path for the image cache
*/
public static void setDefaultPath(Path path) {
defaultPath = path;
}
/**
* Globally enable/disable image caching. If this is off, then the
* getImage() functions will simply request the image directly.
*
* @param enabled Whether to enable the image cache
*/
public static void setCachingEnabled(boolean enabled) {
cachingEnabled = enabled;
}
/**
* Some testing stuff.
*
* @param args
*/
public static void main(String[] args) {
try {
//clearCache("test");
//saveFile("http://static-cdn.jtvnw.net/jtv_user_pictures/chansub-global-emoticon-7ba1fb012fce74a9-30x30.png");
setDefaultPath(Paths.get("cache"));
URL testUrl = new URL("http://127.0.0.1");
//testUrl = new URL("http://static-cdn.jtvnw.net/jtv_user_pictures/chansub-global-emoticon-7ba1fb012fce74a9-30x30.png");
System.out.println(getImage(testUrl, "test", 30));
} catch (MalformedURLException ex) {
Logger.getLogger(ImageCache.class.getName()).log(Level.SEVERE, null, ex);
}
}
/**
* Deletes all image cache files with the given prefix, or all image cache
* files if the prefix is null. Uses the default path.
*
* @param prefix The prefix, or null to delete all image cache files
*/
public static void clearCache(String prefix) {
clearCache(defaultPath, prefix);
}
/**
* Deletes all image cache files with the given prefix, or all image cache
* files if the prefix is null.
*
* @param path The path to delete the files from
* @param prefix The prefix, or null to delete all image cache files
*/
public static void clearCache(Path path, String prefix) {
File[] files = path.toAbsolutePath().toFile().listFiles();
if (files != null) {
for (File file : files) {
if ((prefix == null && file.getName().startsWith(GLOBAL_PREFIX))
|| file.getName().startsWith(GLOBAL_PREFIX+prefix+"__")) {
//System.out.println(file+" "+GLOBAL_PREFIX+prefix+"__");
file.delete();
}
}
}
}
/**
* Remove all the image cache files (that are starting with the global
* prefix) from the path set with setDefaultPath() that have
* expired according to the default expire time (roughly 2 months).
*
* @see setDefaultPath(Path path)
* @see clearOldFiles(Path path)
* @see clearOldFiles(Path path, int expireTime)
*/
public static void clearOldFiles() {
clearOldFiles(defaultPath);
}
/**
* Remove all the image cache files (that are starting with the global
* prefix) from the given Path that have expired according to the default
* expire time (roughly 2 months).
*
* @see clearOldFiles(Path path, int expireTime)
*
* @param path The path to delete the files from
*/
public static void clearOldFiles(Path path) {
clearOldFiles(path, DELETE_FILES_OLDER_THAN);
}
/**
* Remove all image cache files (that are starting with the global prefix)
* from the given Path that have expired according to the given number of
* seconds.
*
* @param path The path to delete the files from
* @param expireTime The time in seconds that needs to have passed since the
* files last modification date for it to be considered expired
*/
public static void clearOldFiles(Path path, int expireTime) {
File[] files = path.toAbsolutePath().toFile().listFiles();
if (files != null) {
int deleted = 0;
int toDelete = 0;
for (File file : files) {
// If not a image cache file according to prefix, go to the next
if (!file.getName().startsWith(GLOBAL_PREFIX)) {
continue;
}
// Check last modified date and delete if appropriate
long lastModified = file.lastModified();
long ago = (System.currentTimeMillis() - lastModified) / 1000;
if (ago > expireTime) {
toDelete++;
if (file.delete()) {
deleted++;
}
}
}
if (toDelete > 0) {
LOGGER.info("ImageCache: Deleted "+deleted+"/"+toDelete+" old files");
}
}
}
/**
* Gets the image from the given URL, with caching on the default path set
* with {@link setDefaultPath(Path)}.
*
* @see getImage(URL, Path, String, int)
*
* @param url
* @param prefix
* @param expireTime
* @return
*/
public static ImageIcon getImage(URL url, String prefix, int expireTime) {
return getImage(url, defaultPath, prefix, expireTime);
}
/**
* Gets the image from the given URL, with caching on the given path.
*
* <p>
* Images are cached in files on the given path, with the given prefix.
* </p>
*
* <p>
* If the requested image is already cached and not expired, the cached
* image will be used. If the requested image is cached, but expired, it
* will be requested from the URL and the new image used, unless the request
* from the URL failed, then the cached image will be used. If the requested
* image is not in the cache, it will be requested from the URL and if that
* requests fails, null is returned.
* </p>
*
* <p>
* If caching fails altogether (e.g. if no read/write access is available)
* then it will fallback to requesting the image directly from the URL
* without caching. If that fails as well, null is returned.
* </p>
*
* <p>
* Files that are considered local (protocol of "file" or "jar") are not
* cached.
* </p>
*
* <p>
* Expired files will not be deleted, they may even still be used (see
* above), it simply means a new image requested from the URL is preferred.
* You can actually delete the cache with {@see clearOldFiles(Path, int)}.
* </p>
*
* @param url The URL to get the image from
* @param path The path to cache the image in
* @param prefix The prefix to use for the cache file
* @param expireTime The expire time of the cache in seconds
* @return The ImageIcon or null if an error occured
*/
public static ImageIcon getImage(URL url, Path path, String prefix, int expireTime) {
if (cachingEnabled && !isLocalURL(url)) {
ImageIcon image = getCachedImage(url, path, prefix, expireTime);
if (image != null) {
return image;
}
}
return getImageDirectly(url);
}
/**
* Requests the image from the given URL directly, without caching.
*
* @param url The URL of the image
* @return The ImageIcon or null if an error occured
*/
public static ImageIcon getImageDirectly(URL url) {
try {
return GifUtil.getGifFromUrl(url);
} catch (Exception ex) {
LOGGER.warning("Error loading image: "+ex);
}
return null;
}
/**
* Gets the image from the given URL, with caching on the given path.
*
* <p>
* Images are cached in files on the given path, with the given prefix.
* </p>
*
* <p>
* If the requested image is already cached and not expired, the cached
* image will be used. If the requested image is cached, but expired, it
* will be requested from the URL and the new image used, unless the request
* from the URL failed, then the cached image will be used. If the requested
* image is not in the cache, it will be requested from the URL and if that
* requests fails, null is returned.
* </p>
*
* <p>
* If writing/reading the files doesn't work at all (e.g. access denied),
* then null will always be returned since this requires the files to be
* written to the cache.
* </p>
*
* @param url The URL to get the image from (also used to determine the
* cache filename
* @param path The Path to use as cache directory
* @param prefix The cache filename prefix
* @param expireTime How many seconds ago the cache file was last modified
* for it to be considered expired and trying to request again
* @return The ImageIcon or null if an error occured
*/
private static ImageIcon getCachedImage(URL url, Path path, String prefix,
int expireTime) {
String id = sha1(url.toString());
path.toFile().mkdirs();
Path file = path.resolve(getFilename(prefix, id));
ImageIcon result = null;
Object o = getLockObject(id);
synchronized(o) {
result = getCachedImage2(url, file, expireTime);
}
removeLockObject(id);
return result;
}
private static ImageIcon getCachedImage2(URL url, Path file, int expireTime) {
ImageIcon fromFile = getImageFromFile(file);
if (fromFile == null) {
// The image was NOT read from file successfully
//System.out.println("Loading image from server (cache not found)"+url);
if (saveFile(url, file)) {
fromFile = getImageFromFile(file);
}
} else {
// The image was read from file successfully
if (hasExpired(expireTime, file)) {
//System.out.println("Loading image from server (expired)"+url);
if (saveFile(url, file)) {
// Only use new image from file if it was saved successfully
fromFile = getImageFromFile(file);
}
}
}
return fromFile;
}
private static boolean hasExpired(int expireTime, Path file) {
long lastModified = file.toFile().lastModified();
long ago = (System.currentTimeMillis() - lastModified) / 1000;
if (lastModified == 0 || (expireTime > 0 && ago > expireTime)) {
return true;
}
return false;
}
private static String getFilename(String prefix, String id) {
return GLOBAL_PREFIX+prefix+"__"+id;
}
private static boolean saveFile(URL url, Path file) {
try (InputStream is = url.openStream()) {
long written = Files.copy(is, file, StandardCopyOption.REPLACE_EXISTING);
if (written > 0) {
return true;
}
} catch (IOException ex) {
LOGGER.warning("Error saving "+url+" to "+file+": "+ex);
}
return false;
}
private static ImageIcon getImageFromFile(Path file) {
try {
URL url = file.toUri().toURL();
return GifUtil.getGifFromUrl(url);
} catch (FileNotFoundException ex) {
// Fail silently, images are expected to often be not cached yet
} catch (Exception ex) {
LOGGER.warning("Error loading image from file: "+ex);
}
return null;
}
private static String sha1(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
return byteArrayToHexString(md.digest(input.getBytes("UTF-8")));
} catch (NoSuchAlgorithmException | UnsupportedEncodingException ex) {
Logger.getLogger(ImageCache.class.getName()).log(Level.SEVERE, null, ex);
}
return null;
}
public static String byteArrayToHexString(byte[] b) {
String result = "";
for (int i = 0; i < b.length; i++) {
result += Integer.toString((b[i] & 0xff) + 0x100, 16).substring(1);
}
return result;
}
/**
* URL is currently assumed local if the protocol is either "file" or "jar".
*
* @param url The URL to check
* @return true if the URL is assumed local, false otherwise
*/
public static boolean isLocalURL(URL url) {
return "file".equalsIgnoreCase(url.getProtocol())
|| "jar".equalsIgnoreCase(url.getProtocol());
}
private static final Map<String, Object> lockObjects = new HashMap<>();
private static Object getLockObject(String file) {
synchronized(lockObjects) {
Object o = lockObjects.get(file);
if (o == null) {
o = new Object();
lockObjects.put(file, o);
}
return o;
}
}
private static void removeLockObject(String file) {
synchronized(lockObjects) {
lockObjects.remove(file);
}
}
}