All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.tinymediamanager.core.ImageCache Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2012 - 2019 Manuel Laggner
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.tinymediamanager.core;

import java.awt.Point;
import java.awt.image.BufferedImage;
import java.awt.image.ColorConvertOp;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
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.util.ArrayList;
import java.util.List;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.plugins.jpeg.JPEGImageWriteParam;
import javax.imageio.stream.FileImageOutputStream;

import org.imgscalr.Scalr;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tinymediamanager.Globals;
import org.tinymediamanager.core.entities.MediaEntity;
import org.tinymediamanager.core.entities.MediaFile;
import org.tinymediamanager.scraper.http.Url;
import org.tinymediamanager.scraper.util.StrgUtils;
import org.tinymediamanager.scraper.util.UrlUtil;

/**
 * The Class ImageCache - used to build a local image cache (scaled down versions & thumbnails - also for offline access).
 * 
 * @author Manuel Laggner
 */
public class ImageCache {
  private static final Logger LOGGER     = LoggerFactory.getLogger(ImageCache.class);
  private static final Path   CACHE_DIR  = Paths.get(Globals.CACHE_FOLDER + "/image");
  private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray();

  public enum CacheType {
    FAST,
    SMOOTH
  }

  static {
    createSubdirs();
  }

  public static void createSubdirs() {
    if (!Files.exists(CACHE_DIR)) {
      try {
        Files.createDirectories(CACHE_DIR);
      }
      catch (IOException e) {
        LOGGER.warn("Could not create cache dir {} - {}", CACHE_DIR, e.getMessage());
      }
    }

    for (char sub : HEX_DIGITS) {
      try {
        Path p = CACHE_DIR.resolve(Character.toString(sub));
        Files.createDirectories(p);
      }
      catch (FileAlreadyExistsException ignore) {
        // do not care
      }
      catch (IOException e) {
        LOGGER.warn("Could not create cache sub dir '{}' - {}", sub, e.getMessage());
      }
    }
  }

  @Deprecated
  public static void migrate() {
    List files = Utils.listFiles(getCacheDir());
    for (Path f : files) {
      Path f2 = getCacheDir().resolve(Paths.get(f.getFileName().toString().substring(0, 1), f.getFileName().toString()));
      try {
        Files.move(f, f2, StandardCopyOption.ATOMIC_MOVE);
      }
      catch (IOException ignore) {
        // ignored - do not care
      }
    }
  }

  /**
   * Gets the cache dir. If it is not on the disk - it will also create it
   * 
   * @return the cache dir
   */
  public static Path getCacheDir() {
    return CACHE_DIR;
  }

  /**
   * Gets the file name (MD5 hash) of the cached file.
   * 
   * @param path
   *          the url
   * @return the cached file name
   */
  public static String getMD5(String path) {
    try {
      if (path == null) {
        return null;
      }
      // now uses a simple md5 hash, which should have a fairly low collision
      // rate, especially for our limited use
      MessageDigest md = MessageDigest.getInstance("MD5");
      byte[] key = md.digest(path.getBytes());
      return StrgUtils.bytesToHex(key);
    }
    catch (Exception e) {
      LOGGER.error("Failed to create cached filename for image: {} - {}", path, e);
    }
    return "";
  }

  public static String getMD5WithSubfolder(String path) {
    String md5 = getMD5(path);
    if (path == null) {
      return null;
    }
    return Paths.get(md5.substring(0, 1), md5).toString();
  }

  /**
   * Cache image without overwriting an existing one
   * 
   * @param mediaFile
   *          the media file
   * @return the file the cached file
   * @throws Exception
   *           any exception occurred while caching
   */
  public static Path cacheImage(MediaFile mediaFile) throws Exception {
    return cacheImage(mediaFile, false);
  }

  /**
   * Cache image.
   *
   * @param mediaFile
   *          the media file
   * @param overwrite
   *          indicator if we should overwrite any existing files
   * @return the file the cached file
   * @throws Exception
   *           any exception occurred while caching
   */
  private static Path cacheImage(MediaFile mediaFile, boolean overwrite) throws Exception {
    if (!mediaFile.isGraphic()) {
      throw new InvalidFileTypeException(mediaFile.getFileAsPath());
    }

    Path originalFile = mediaFile.getFileAsPath();
    Path cachedFile = ImageCache.getCacheDir().resolve(getMD5WithSubfolder(originalFile.toString()) + "." + Utils.getExtension(originalFile));
    if (overwrite || !Files.exists(cachedFile)) {
      // check if the original file exists && size > 0
      if (!Files.exists(originalFile)) {
        throw new FileNotFoundException("unable to cache file: " + originalFile + "; file does not exist");
      }
      if (Files.size(originalFile) == 0) {
        throw new EmptyFileException(originalFile);
      }

      // recreate cache dir if needed
      // rescale & cache
      BufferedImage originalImage = null;

      // try to cache the image file
      // we have up to 5 retries here if we hit the memory cap since we are hitting the machine hard due to multi CPU image caching
      int retries = 5;
      do {
        try {
          originalImage = ImageUtils.createImage(originalFile);
          break;
        }
        catch (OutOfMemoryError e) {
          // memory limit hit; give it another 500ms time to recover
          LOGGER.warn("hit memory cap: {}", e.getMessage());
          Thread.sleep(500);
        }
        retries--;
      } while (retries > 0);

      // calculate width based on MF type
      int desiredWidth = originalImage.getWidth(); // initialize with fallback
      // decide the scale-side depending on the aspect ratio
      if (((float) originalImage.getWidth()) / ((float) originalImage.getHeight()) > 1) {
        // landscape
        if (originalImage.getWidth() > 400) {
          desiredWidth = 400;
        }
      }
      else {
        // portrait
        if (originalImage.getHeight() > 400) {
          desiredWidth = 400 * originalImage.getWidth() / originalImage.getHeight();
        }
      }

      Point size = ImageUtils.calculateSize(desiredWidth, (int) (originalImage.getHeight() / 1.5), originalImage.getWidth(),
          originalImage.getHeight(), true);
      BufferedImage scaledImage = null;

      // we have up to 5 retries here if we hit the memory cap since we are hitting the machine hard due to multi CPU image caching
      retries = 5;
      do {
        try {
          if (Globals.settings.getImageCacheType() == CacheType.FAST) {
            // scale fast
            scaledImage = Scalr.resize(originalImage, Scalr.Method.BALANCED, Scalr.Mode.FIT_EXACT, size.x, size.y);
          }
          else {
            // scale with good quality
            scaledImage = Scalr.resize(originalImage, Scalr.Method.QUALITY, Scalr.Mode.FIT_EXACT, size.x, size.y);
          }
          break;
        }
        catch (OutOfMemoryError e) {
          // memory limit hit; give it another 500ms time to recover
          LOGGER.warn("hit memory cap: {}", e.getMessage());
          Thread.sleep(500);
        }
        retries--;
      } while (retries > 0);

      originalImage = null;

      ImageWriter imgWrtr = null;
      ImageWriteParam imgWrtrPrm = null;

      // here we have two different ways to create our thumb
      // a) a scaled down jpg/png (without transparency) which we have to modify since OpenJDK cannot call native jpg encoders
      // b) a scaled down png (with transparency) which we can store without any more modifying as png
      if (ImageUtils.hasTransparentPixels(scaledImage)) {
        // transparent image -> png
        imgWrtr = ImageIO.getImageWritersByFormatName("png").next();
        imgWrtrPrm = imgWrtr.getDefaultWriteParam();

      }
      else {
        // non transparent image -> jpg
        // convert to rgb
        BufferedImage rgb = new BufferedImage(scaledImage.getWidth(), scaledImage.getHeight(), BufferedImage.TYPE_INT_RGB);
        ColorConvertOp xformOp = new ColorConvertOp(null);
        xformOp.filter(scaledImage, rgb);
        imgWrtr = ImageIO.getImageWritersByFormatName("jpg").next();
        imgWrtrPrm = imgWrtr.getDefaultWriteParam();
        imgWrtrPrm.setCompressionMode(JPEGImageWriteParam.MODE_EXPLICIT);
        imgWrtrPrm.setCompressionQuality(0.80f);

        scaledImage = rgb;
      }

      FileImageOutputStream output = new FileImageOutputStream(cachedFile.toFile());
      imgWrtr.setOutput(output);
      IIOImage image = new IIOImage(scaledImage, null, null);
      imgWrtr.write(null, image, imgWrtrPrm);
      imgWrtr.dispose();
      output.flush();
      output.close();
      scaledImage = null;

      if (!Files.exists(cachedFile)) {
        throw new Exception("unable to cache file: " + originalFile);
      }
    }

    return cachedFile;
  }

  /**
   * Cache image silently without throwing an exception. Use the method {@link #cacheImageSilently(MediaFile)} if possible!
   *
   * @param path
   *          the path to this image
   */
  public static void cacheImageSilently(Path path) {
    cacheImageSilently(new MediaFile(path));
  }

  /**
   * Cache image silently without throwing an exception.
   *
   * @param mediaFile
   *          the media file
   */
  public static void cacheImageSilently(MediaFile mediaFile) {
    if (!Settings.getInstance().isImageCache()) {
      return;
    }

    if (!mediaFile.isGraphic()) {
      return;
    }

    try {
      cacheImage(mediaFile, true);
    }
    catch (Exception e) {
      LOGGER.warn("could not cache image: {}", e.getMessage());
    }
  }

  /**
   * Invalidate cached image. Use the method {@link #invalidateCachedImage(MediaFile)} is possible!
   *
   * @param path
   *          the path to this image
   */
  public static void invalidateCachedImage(Path path) {
    invalidateCachedImage(new MediaFile(path));
  }

  /**
   * Invalidate cached image.
   * 
   * @param mediaFile
   *          the media file
   */
  public static void invalidateCachedImage(MediaFile mediaFile) {
    if (!mediaFile.isGraphic()) {
      return;
    }

    Path path = mediaFile.getFileAsPath();
    Path cachedFile = getCacheDir().resolve(ImageCache.getMD5WithSubfolder(path.toAbsolutePath().toString()) + "." + Utils.getExtension(path));
    if (Files.exists(cachedFile)) {
      Utils.deleteFileSafely(cachedFile);
    }
  }

  /**
   * Gets the cached image for "string" location (mostly an url).
* If not found AND it is a valid url, download and cache first.
* * @param url * the url of image, or basically the unhashed string of cache file * @return the cached file or NULL */ public static Path getCachedFile(String url) { if (url == null || url.isEmpty()) { return null; } String ext = UrlUtil.getExtension(url); if (ext.isEmpty()) { ext = "jpg"; // just assume } Path cachedFile = ImageCache.getCacheDir().resolve(getMD5WithSubfolder(url) + "." + ext); if (Files.exists(cachedFile)) { LOGGER.trace("found cached url :) {}", url); return cachedFile; } // is the image cache activated? if (!Globals.settings.isImageCache()) { return null; } try { LOGGER.trace("downloading image to the image cache: {}", url); Url u = new Url(url); boolean ok = u.download(cachedFile); if (ok) { LOGGER.trace("cached url successfully :) {}", url); return cachedFile; } } catch (Exception e) { LOGGER.trace("Problem getting cached file for url {}", e.getMessage()); } LOGGER.trace("could not get cached file for url {}", url); return null; } /** * Gets the cached file for the given {@link Path} - if ImageCache is activated
* Use the method {@link #getCachedFile(MediaFile)} is possible!
* * If not found, cache original first * * @param path * the path to the image * @return the cached file */ public static Path getCachedFile(Path path) { return getCachedFile(new MediaFile(path)); } /** * Gets the cached file for the given {@link MediaFile} - if ImageCache is activated
* If not found, cache original first * * @param mediaFile * the mediaFile * @return the cached file */ public static Path getCachedFile(MediaFile mediaFile) { if (mediaFile == null || !mediaFile.isGraphic()) { return null; } Path path = mediaFile.getFileAsPath().toAbsolutePath(); Path cachedFile = ImageCache.getCacheDir().resolve(getMD5WithSubfolder(path.toString()) + "." + Utils.getExtension(path)); if (Files.exists(cachedFile)) { LOGGER.trace("found cached file :) {}", path); return cachedFile; } // is the path already inside the cache dir? serve direct if (path.startsWith(CACHE_DIR.toAbsolutePath())) { return path; } // is the image cache activated? if (!Globals.settings.isImageCache()) { LOGGER.trace("ImageCache not activated!"); // need to return null, else the caller couldn't distinguish between cached/original file return null; } try { Path p = ImageCache.cacheImage(mediaFile); LOGGER.trace("cached file successfully :) {}", p); return p; } catch (EmptyFileException e) { LOGGER.warn("failed to cache file (file is empty): {}", path); } catch (FileNotFoundException ignored) { // no need to log anything here } catch (Exception e) { LOGGER.warn("problem caching file: {}", e.getMessage()); } // need to return null, else the caller couldn't distinguish between cached/original file return null; } /** * Check whether the original image is in the image cache or not * * @param path * the path to the original image * @return true/false */ public static boolean isImageCached(Path path) { if (!Globals.settings.isImageCache()) { return false; } Path cachedFile = CACHE_DIR.resolve(ImageCache.getMD5WithSubfolder(path.toString()) + "." + Utils.getExtension(path)); return Files.exists(cachedFile); } /** * clear the image cache for all graphics within the given media entity * * @param entity * the media entity */ public static void clearImageCacheForMediaEntity(MediaEntity entity) { List mediaFiles = new ArrayList<>(entity.getMediaFiles()); for (MediaFile mediaFile : mediaFiles) { if (mediaFile.isGraphic()) { Path file = ImageCache.getCachedFile(mediaFile); if (file != null) { Utils.deleteFileSafely(file); } } } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy