/* This file is part of Airsonic. Airsonic is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Airsonic is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Airsonic. If not, see . Copyright 2016 (C) Airsonic Authors Based upon Subsonic, Copyright 2009 (C) Sindre Mehus */ package org.airsonic.player.controller; import org.airsonic.player.dao.AlbumDao; import org.airsonic.player.dao.ArtistDao; import org.airsonic.player.domain.*; import org.airsonic.player.service.*; import org.airsonic.player.service.metadata.JaudiotaggerParser; import org.airsonic.player.util.FileUtil; import org.airsonic.player.util.StringUtil; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.tuple.Pair; import org.jaudiotagger.tag.images.Artwork; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.ServletRequestUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.mvc.LastModified; import javax.annotation.PostConstruct; import javax.imageio.ImageIO; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.awt.*; import java.awt.image.BufferedImage; import java.io.*; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Semaphore; /** * Controller which produces cover art images. * * @author Sindre Mehus */ @Controller @RequestMapping({"/coverArt", "/ext/coverArt"}) public class CoverArtController implements LastModified { public static final String ALBUM_COVERART_PREFIX = "al-"; public static final String ARTIST_COVERART_PREFIX = "ar-"; public static final String PLAYLIST_COVERART_PREFIX = "pl-"; public static final String PODCAST_COVERART_PREFIX = "pod-"; private static final Logger LOG = LoggerFactory.getLogger(CoverArtController.class); @Autowired private MediaFileService mediaFileService; @Autowired private TranscodingService transcodingService; @Autowired private SettingsService settingsService; @Autowired private PlaylistService playlistService; @Autowired private PodcastService podcastService; @Autowired private ArtistDao artistDao; @Autowired private AlbumDao albumDao; @Autowired private JaudiotaggerParser jaudiotaggerParser; private Semaphore semaphore; @PostConstruct public void init() { semaphore = new Semaphore(settingsService.getCoverArtConcurrency()); } public long getLastModified(HttpServletRequest request) { CoverArtRequest coverArtRequest = createCoverArtRequest(request); // LOG.info("getLastModified - " + coverArtRequest + ": " + new Date(result)); return coverArtRequest.lastModified(); } @GetMapping public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception { CoverArtRequest coverArtRequest = createCoverArtRequest(request); LOG.trace("handleRequest - " + coverArtRequest); Integer size = ServletRequestUtils.getIntParameter(request, "size"); // Send fallback image if no ID is given. (No need to cache it, since it will be cached in browser.) if (coverArtRequest == null) { sendFallback(size, response); return; } try { // Optimize if no scaling is required. if (size == null && coverArtRequest.getCoverArt() != null) { LOG.trace("sendUnscaled - " + coverArtRequest); sendUnscaled(coverArtRequest, response); return; } // Send cached image, creating it if necessary. if (size == null) { size = CoverArtScheme.LARGE.getSize() * 2; } File cachedImage = getCachedImage(coverArtRequest, size); sendImage(cachedImage, response); } catch (Exception e) { LOG.debug("Sending fallback as an exception was encountered during normal cover art processing", e); sendFallback(size, response); } } private CoverArtRequest createCoverArtRequest(HttpServletRequest request) { String id = request.getParameter("id"); if (id == null) { return null; } if (id.startsWith(ALBUM_COVERART_PREFIX)) { return createAlbumCoverArtRequest(Integer.valueOf(id.replace(ALBUM_COVERART_PREFIX, ""))); } if (id.startsWith(ARTIST_COVERART_PREFIX)) { return createArtistCoverArtRequest(Integer.valueOf(id.replace(ARTIST_COVERART_PREFIX, ""))); } if (id.startsWith(PLAYLIST_COVERART_PREFIX)) { return createPlaylistCoverArtRequest(Integer.valueOf(id.replace(PLAYLIST_COVERART_PREFIX, ""))); } if (id.startsWith(PODCAST_COVERART_PREFIX)) { return createPodcastCoverArtRequest(Integer.valueOf(id.replace(PODCAST_COVERART_PREFIX, "")), request); } return createMediaFileCoverArtRequest(Integer.valueOf(id), request); } private CoverArtRequest createAlbumCoverArtRequest(int id) { Album album = albumDao.getAlbum(id); return album == null ? null : new AlbumCoverArtRequest(album); } private CoverArtRequest createArtistCoverArtRequest(int id) { Artist artist = artistDao.getArtist(id); return artist == null ? null : new ArtistCoverArtRequest(artist); } private PlaylistCoverArtRequest createPlaylistCoverArtRequest(int id) { Playlist playlist = playlistService.getPlaylist(id); return playlist == null ? null : new PlaylistCoverArtRequest(playlist); } private CoverArtRequest createPodcastCoverArtRequest(int id, HttpServletRequest request) { PodcastChannel channel = podcastService.getChannel(id); if (channel == null) { return null; } if (channel.getMediaFileId() == null) { return new PodcastCoverArtRequest(channel); } return createMediaFileCoverArtRequest(channel.getMediaFileId(), request); } private CoverArtRequest createMediaFileCoverArtRequest(int id, HttpServletRequest request) { MediaFile mediaFile = mediaFileService.getMediaFile(id); if (mediaFile == null) { return null; } if (mediaFile.isVideo()) { int offset = ServletRequestUtils.getIntParameter(request, "offset", 60); return new VideoCoverArtRequest(mediaFile, offset); } return new MediaFileCoverArtRequest(mediaFile); } private void sendImage(File file, HttpServletResponse response) throws IOException { response.setContentType(StringUtil.getMimeType(FilenameUtils.getExtension(file.getName()))); InputStream in = new FileInputStream(file); try { IOUtils.copy(in, response.getOutputStream()); } finally { FileUtil.closeQuietly(in); } } private void sendFallback(Integer size, HttpServletResponse response) throws IOException { if (response.getContentType() == null) { response.setContentType(StringUtil.getMimeType("jpeg")); } InputStream in = null; try { in = getClass().getResourceAsStream("default_cover.jpg"); BufferedImage image = ImageIO.read(in); if (size != null) { image = scale(image, size, size); } ImageIO.write(image, "jpeg", response.getOutputStream()); } finally { FileUtil.closeQuietly(in); } } private void sendUnscaled(CoverArtRequest coverArtRequest, HttpServletResponse response) throws IOException { File file = coverArtRequest.getCoverArt(); InputStream in = null; try { Pair imageInputStreamWithType = getImageInputStreamWithType(file); in = imageInputStreamWithType.getLeft(); response.setContentType(imageInputStreamWithType.getRight()); IOUtils.copy(in, response.getOutputStream()); } finally { FileUtil.closeQuietly(in); } } private File getCachedImage(CoverArtRequest request, int size) throws IOException { String hash = DigestUtils.md5Hex(request.getKey()); String encoding = request.getCoverArt() != null ? "jpeg" : "png"; File cachedImage = new File(getImageCacheDirectory(size), hash + "." + encoding); // Synchronize to avoid concurrent writing to the same file. synchronized (hash.intern()) { // Is cache missing or obsolete? if (!cachedImage.exists() || request.lastModified() > cachedImage.lastModified()) { // LOG.info("Cache MISS - " + request + " (" + size + ")"); OutputStream out = null; try { semaphore.acquire(); BufferedImage image = request.createImage(size); if (image == null) { throw new Exception("Unable to decode image."); } out = new FileOutputStream(cachedImage); ImageIO.write(image, encoding, out); } catch (Throwable x) { // Delete corrupt (probably empty) thumbnail cache. LOG.warn("Failed to create thumbnail for " + request, x); FileUtil.closeQuietly(out); cachedImage.delete(); throw new IOException("Failed to create thumbnail for " + request + ". " + x.getMessage()); } finally { semaphore.release(); FileUtil.closeQuietly(out); } } else { // LOG.info("Cache HIT - " + request + " (" + size + ")"); } return cachedImage; } } /** * Returns an input stream to the image in the given file. If the file is an audio file, * the embedded album art is returned. */ private InputStream getImageInputStream(File file) throws IOException { return getImageInputStreamWithType(file).getLeft(); } /** * Returns an input stream to the image in the given file. If the file is an audio file, * the embedded album art is returned. In addition returns the mime type */ private Pair getImageInputStreamWithType(File file) throws IOException { InputStream is; String mimeType; if (jaudiotaggerParser.isApplicable(file)) { LOG.trace("Using Jaudio Tagger for reading artwork from {}", file); MediaFile mediaFile = mediaFileService.getMediaFile(file); Artwork artwork; try { LOG.trace("Reading artwork from file {}", mediaFile); artwork = jaudiotaggerParser.getArtwork(mediaFile); is = new ByteArrayInputStream(artwork.getBinaryData()); mimeType = artwork.getMimeType(); } catch (Exception e) { LOG.debug("Could not read artwork from file {}", mediaFile); throw new RuntimeException(e); } } else { is = new FileInputStream(file); mimeType = StringUtil.getMimeType(FilenameUtils.getExtension(file.getName())); } return Pair.of(is, mimeType); } private InputStream getImageInputStreamForVideo(MediaFile mediaFile, int width, int height, int offset) throws Exception { VideoTranscodingSettings videoSettings = new VideoTranscodingSettings(width, height, offset, 0, false); TranscodingService.Parameters parameters = new TranscodingService.Parameters(mediaFile, videoSettings); String command = settingsService.getVideoImageCommand(); parameters.setTranscoding(new Transcoding(null, null, null, null, command, null, null, false)); return transcodingService.getTranscodedInputStream(parameters); } private synchronized File getImageCacheDirectory(int size) { File dir = new File(SettingsService.getAirsonicHome(), "thumbs"); dir = new File(dir, String.valueOf(size)); if (!dir.exists()) { if (dir.mkdirs()) { LOG.info("Created thumbnail cache " + dir); } else { LOG.error("Failed to create thumbnail cache " + dir); } } return dir; } public static BufferedImage scale(BufferedImage image, int width, int height) { int w = image.getWidth(); int h = image.getHeight(); BufferedImage thumb = image; // For optimal results, use step by step bilinear resampling - halfing the size at each step. do { w /= 2; h /= 2; if (w < width) { w = width; } if (h < height) { h = height; } BufferedImage temp = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g2 = temp.createGraphics(); g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2.drawImage(thumb, 0, 0, temp.getWidth(), temp.getHeight(), null); g2.dispose(); thumb = temp; } while (w != width); return thumb; } private abstract class CoverArtRequest { protected File coverArt; private CoverArtRequest() { } private CoverArtRequest(String coverArtPath) { this.coverArt = coverArtPath == null ? null : new File(coverArtPath); } private File getCoverArt() { return coverArt; } public abstract String getKey(); public abstract long lastModified(); public BufferedImage createImage(int size) { if (coverArt != null) { InputStream in = null; String reason = null; try { in = getImageInputStream(coverArt); if (in == null) { reason = "getImageInputStream"; } else { BufferedImage bimg = ImageIO.read(in); if (bimg == null) { reason = "ImageIO.read"; } else { return scale(bimg, size, size); } } LOG.warn("Failed to process cover art " + coverArt + ": " + reason + " failed"); } catch (Throwable x) { LOG.warn("Failed to process cover art " + coverArt + ": " + x, x); } finally { FileUtil.closeQuietly(in); } } return createAutoCover(size, size); } protected BufferedImage createAutoCover(int width, int height) { BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D graphics = image.createGraphics(); AutoCover autoCover = new AutoCover(graphics, getKey(), getArtist(), getAlbum(), width, height); autoCover.paintCover(); graphics.dispose(); return image; } public abstract String getAlbum(); public abstract String getArtist(); } private class ArtistCoverArtRequest extends CoverArtRequest { private final Artist artist; private ArtistCoverArtRequest(Artist artist) { super(artist.getCoverArtPath()); this.artist = artist; } @Override public String getKey() { return artist.getCoverArtPath() != null ? artist.getCoverArtPath() : (ARTIST_COVERART_PREFIX + artist.getId()); } @Override public long lastModified() { return coverArt != null ? coverArt.lastModified() : artist.getLastScanned().getTime(); } @Override public String getAlbum() { return null; } @Override public String getArtist() { return artist.getName(); } @Override public String toString() { return "Artist " + artist.getId() + " - " + artist.getName(); } } private class AlbumCoverArtRequest extends CoverArtRequest { private final Album album; private AlbumCoverArtRequest(Album album) { super(album.getCoverArtPath()); this.album = album; } @Override public String getKey() { return album.getCoverArtPath() != null ? album.getCoverArtPath() : (ALBUM_COVERART_PREFIX + album.getId()); } @Override public long lastModified() { return coverArt != null ? coverArt.lastModified() : album.getLastScanned().getTime(); } @Override public String getAlbum() { return album.getName(); } @Override public String getArtist() { return album.getArtist(); } @Override public String toString() { return "Album " + album.getId() + " - " + album.getName(); } } private class PlaylistCoverArtRequest extends CoverArtRequest { private final Playlist playlist; private PlaylistCoverArtRequest(Playlist playlist) { super(null); this.playlist = playlist; } @Override public String getKey() { return PLAYLIST_COVERART_PREFIX + playlist.getId(); } @Override public long lastModified() { return playlist.getChanged().getTime(); } @Override public String getAlbum() { return null; } @Override public String getArtist() { return playlist.getName(); } @Override public String toString() { return "Playlist " + playlist.getId() + " - " + playlist.getName(); } @Override public BufferedImage createImage(int size) { List albums = getRepresentativeAlbums(); if (albums.isEmpty()) { return createAutoCover(size, size); } if (albums.size() < 4) { return new MediaFileCoverArtRequest(albums.get(0)).createImage(size); } BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); Graphics2D graphics = image.createGraphics(); int half = size / 2; graphics.drawImage(new MediaFileCoverArtRequest(albums.get(0)).createImage(half), null, 0, 0); graphics.drawImage(new MediaFileCoverArtRequest(albums.get(1)).createImage(half), null, half, 0); graphics.drawImage(new MediaFileCoverArtRequest(albums.get(2)).createImage(half), null, 0, half); graphics.drawImage(new MediaFileCoverArtRequest(albums.get(3)).createImage(half), null, half, half); graphics.dispose(); return image; } private List getRepresentativeAlbums() { Set albums = new LinkedHashSet(); for (MediaFile song : playlistService.getFilesInPlaylist(playlist.getId())) { MediaFile album = mediaFileService.getParentOf(song); if (album != null && !mediaFileService.isRoot(album)) { albums.add(album); } } return new ArrayList<>(albums); } } private class PodcastCoverArtRequest extends CoverArtRequest { private final PodcastChannel channel; PodcastCoverArtRequest(PodcastChannel channel) { this.channel = channel; } @Override public String getKey() { return PODCAST_COVERART_PREFIX + channel.getId(); } @Override public long lastModified() { return -1; } @Override public String getAlbum() { return null; } @Override public String getArtist() { return channel.getTitle() != null ? channel.getTitle() : channel.getUrl(); } } private class MediaFileCoverArtRequest extends CoverArtRequest { private final MediaFile mediaFile; private final MediaFile dir; private MediaFileCoverArtRequest(MediaFile mediaFile) { this.mediaFile = mediaFile; dir = mediaFile.isDirectory() ? mediaFile : mediaFileService.getParentOf(mediaFile); coverArt = mediaFileService.getCoverArt(mediaFile); } @Override public String getKey() { return coverArt != null ? coverArt.getPath() : dir.getPath(); } @Override public long lastModified() { return coverArt != null ? coverArt.lastModified() : dir.getChanged().getTime(); } @Override public String getAlbum() { return dir.getName(); } @Override public String getArtist() { return dir.getAlbumArtist() != null ? dir.getAlbumArtist() : dir.getArtist(); } @Override public String toString() { return "Media file " + mediaFile.getId() + " - " + mediaFile; } } private class VideoCoverArtRequest extends CoverArtRequest { private final MediaFile mediaFile; private final int offset; private VideoCoverArtRequest(MediaFile mediaFile, int offset) { this.mediaFile = mediaFile; this.offset = offset; } @Override public BufferedImage createImage(int size) { int height; height = size; int width = height * 16 / 9; InputStream in = null; try { in = getImageInputStreamForVideo(mediaFile, width, height, offset); BufferedImage result = ImageIO.read(in); if (result != null) { return result; } LOG.warn("Failed to process cover art for " + mediaFile + ": {}", result); } catch (Throwable x) { LOG.warn("Failed to process cover art for " + mediaFile + ": " + x, x); } finally { FileUtil.closeQuietly(in); } return createAutoCover(width, height); } @Override public String getKey() { return mediaFile.getPath() + "/" + offset; } @Override public long lastModified() { return mediaFile.getChanged().getTime(); } @Override public String getAlbum() { return null; } @Override public String getArtist() { return mediaFile.getName(); } @Override public String toString() { return "Video file " + mediaFile.getId() + " - " + mediaFile; } } static class AutoCover { private final static int[] COLORS = {0x33B5E5, 0xAA66CC, 0x99CC00, 0xFFBB33, 0xFF4444}; private final Graphics2D graphics; private final String artist; private final String album; private final int width; private final int height; private final Color color; AutoCover(Graphics2D graphics, String key, String artist, String album, int width, int height) { this.graphics = graphics; this.artist = artist; this.album = album; this.width = width; this.height = height; int hash = key.hashCode(); int rgb = COLORS[Math.abs(hash) % COLORS.length]; this.color = new Color(rgb); } public void paintCover() { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.setPaint(color); graphics.fillRect(0, 0, width, height); int y = height * 2 / 3; graphics.setPaint(new GradientPaint(0, y, new Color(82, 82, 82), 0, height, Color.BLACK)); graphics.fillRect(0, y, width, height / 3); graphics.setPaint(Color.WHITE); float fontSize = 3.0f + height * 0.07f; Font font = new Font(Font.SANS_SERIF, Font.BOLD, (int) fontSize); graphics.setFont(font); if (album != null) { graphics.drawString(album, width * 0.05f, height * 0.6f); } if (artist != null) { graphics.drawString(artist, width * 0.05f, height * 0.8f); } int borderWidth = height / 50; graphics.fillRect(0, 0, borderWidth, height); graphics.fillRect(width - borderWidth, 0, height - borderWidth, height); graphics.fillRect(0, 0, width, borderWidth); graphics.fillRect(0, height - borderWidth, width, height); } } }