/* This file is part of Libresonic. Libresonic 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. Libresonic 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 Libresonic. If not, see <http://www.gnu.org/licenses/>. Copyright 2016 (C) Libresonic Authors Based upon Subsonic, Copyright 2009 (C) Sindre Mehus */ package org.libresonic.player.service; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.filefilter.PrefixFileFilter; import org.apache.commons.lang.StringUtils; import org.libresonic.player.Logger; import org.libresonic.player.controller.VideoPlayerController; import org.libresonic.player.dao.TranscodingDao; import org.libresonic.player.domain.*; import org.libresonic.player.io.TranscodeInputStream; import org.libresonic.player.util.StringUtil; import org.libresonic.player.util.Util; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; import java.util.LinkedList; import java.util.List; /** * Provides services for transcoding media. Transcoding is the process of * converting an audio stream to a different format and/or bit rate. The latter is * also called downsampling. * * @author Sindre Mehus * @see TranscodeInputStream */ public class TranscodingService { private static final Logger LOG = Logger.getLogger(TranscodingService.class); public static final String FORMAT_RAW = "raw"; private TranscodingDao transcodingDao; private SettingsService settingsService; private PlayerService playerService; /** * Returns all transcodings. * * @return Possibly empty list of all transcodings. */ public List<Transcoding> getAllTranscodings() { return transcodingDao.getAllTranscodings(); } /** * Returns all active transcodings for the given player. Only enabled transcodings are returned. * * @param player The player. * @return All active transcodings for the player. */ public List<Transcoding> getTranscodingsForPlayer(Player player) { return transcodingDao.getTranscodingsForPlayer(player.getId()); } /** * Sets the list of active transcodings for the given player. * * @param player The player. * @param transcodingIds ID's of the active transcodings. */ public void setTranscodingsForPlayer(Player player, int[] transcodingIds) { transcodingDao.setTranscodingsForPlayer(player.getId(), transcodingIds); } /** * Sets the list of active transcodings for the given player. * * @param player The player. * @param transcodings The active transcodings. */ public void setTranscodingsForPlayer(Player player, List<Transcoding> transcodings) { int[] transcodingIds = new int[transcodings.size()]; for (int i = 0; i < transcodingIds.length; i++) { transcodingIds[i] = transcodings.get(i).getId(); } setTranscodingsForPlayer(player, transcodingIds); } /** * Creates a new transcoding. * * @param transcoding The transcoding to create. */ public void createTranscoding(Transcoding transcoding) { transcodingDao.createTranscoding(transcoding); // Activate this transcoding for all players? if (transcoding.isDefaultActive()) { for (Player player : playerService.getAllPlayers()) { List<Transcoding> transcodings = getTranscodingsForPlayer(player); transcodings.add(transcoding); setTranscodingsForPlayer(player, transcodings); } } } /** * Deletes the transcoding with the given ID. * * @param id The transcoding ID. */ public void deleteTranscoding(Integer id) { transcodingDao.deleteTranscoding(id); } /** * Updates the given transcoding. * * @param transcoding The transcoding to update. */ public void updateTranscoding(Transcoding transcoding) { transcodingDao.updateTranscoding(transcoding); } /** * Returns whether transcoding is required for the given media file and player combination. * * @param mediaFile The media file. * @param player The player. * @return Whether transcoding will be performed if invoking the * {@link #getTranscodedInputStream} method with the same arguments. */ public boolean isTranscodingRequired(MediaFile mediaFile, Player player) { return getTranscoding(mediaFile, player, null, false) != null; } /** * Returns the suffix for the given player and media file, taking transcodings into account. * * @param player The player in question. * @param file The media file. * @param preferredTargetFormat Used to select among multiple applicable transcodings. May be {@code null}. * @return The file suffix, e.g., "mp3". */ public String getSuffix(Player player, MediaFile file, String preferredTargetFormat) { Transcoding transcoding = getTranscoding(file, player, preferredTargetFormat, false); return transcoding != null ? transcoding.getTargetFormat() : file.getFormat(); } /** * Creates parameters for a possibly transcoded or downsampled input stream for the given media file and player combination. * <p/> * A transcoding is applied if it is applicable for the format of the given file, and is activated for the * given player. * <p/> * If no transcoding is applicable, the file may still be downsampled, given that the player is configured * with a bit rate limit which is higher than the actual bit rate of the file. * <p/> * Otherwise, a normal input stream to the original file is returned. * * @param mediaFile The media file. * @param player The player. * @param maxBitRate Overrides the per-player and per-user bitrate limit. May be {@code null}. * @param preferredTargetFormat Used to select among multiple applicable transcodings. May be {@code null}. * @param videoTranscodingSettings Parameters used when transcoding video. May be {@code null}. * @return Parameters to be used in the {@link #getTranscodedInputStream} method. */ public Parameters getParameters(MediaFile mediaFile, Player player, Integer maxBitRate, String preferredTargetFormat, VideoTranscodingSettings videoTranscodingSettings) { Parameters parameters = new Parameters(mediaFile, videoTranscodingSettings); TranscodeScheme transcodeScheme = getTranscodeScheme(player); if (maxBitRate == null && transcodeScheme != TranscodeScheme.OFF) { maxBitRate = transcodeScheme.getMaxBitRate(); } boolean hls = videoTranscodingSettings != null && videoTranscodingSettings.isHls(); Transcoding transcoding = getTranscoding(mediaFile, player, preferredTargetFormat, hls); if (transcoding != null) { parameters.setTranscoding(transcoding); if (maxBitRate == null) { maxBitRate = mediaFile.isVideo() ? VideoPlayerController.DEFAULT_BIT_RATE : TranscodeScheme.MAX_192.getMaxBitRate(); } } else if (maxBitRate != null) { boolean supported = isDownsamplingSupported(mediaFile); Integer bitRate = mediaFile.getBitRate(); if (supported && bitRate != null && bitRate > maxBitRate) { parameters.setDownsample(true); } } parameters.setMaxBitRate(maxBitRate); return parameters; } /** * Returns a possibly transcoded or downsampled input stream for the given music file and player combination. * <p/> * A transcoding is applied if it is applicable for the format of the given file, and is activated for the * given player. * <p/> * If no transcoding is applicable, the file may still be downsampled, given that the player is configured * with a bit rate limit which is higher than the actual bit rate of the file. * <p/> * Otherwise, a normal input stream to the original file is returned. * * @param parameters As returned by {@link #getParameters}. * @return A possible transcoded or downsampled input stream. * @throws IOException If an I/O error occurs. */ public InputStream getTranscodedInputStream(Parameters parameters) throws IOException { try { if (parameters.getTranscoding() != null) { return createTranscodedInputStream(parameters); } if (parameters.downsample) { return createDownsampledInputStream(parameters); } } catch (Exception x) { LOG.warn("Failed to transcode " + parameters.getMediaFile() + ". Using original.", x); } return new FileInputStream(parameters.getMediaFile().getFile()); } /** * Returns the strictest transcoding scheme defined for the player and the user. */ private TranscodeScheme getTranscodeScheme(Player player) { String username = player.getUsername(); if (username != null) { UserSettings userSettings = settingsService.getUserSettings(username); return player.getTranscodeScheme().strictest(userSettings.getTranscodeScheme()); } return player.getTranscodeScheme(); } /** * Returns an input stream by applying the given transcoding to the given music file. * * @param parameters Transcoding parameters. * @return The transcoded input stream. * @throws IOException If an I/O error occurs. */ private InputStream createTranscodedInputStream(Parameters parameters) throws IOException { Transcoding transcoding = parameters.getTranscoding(); Integer maxBitRate = parameters.getMaxBitRate(); VideoTranscodingSettings videoTranscodingSettings = parameters.getVideoTranscodingSettings(); MediaFile mediaFile = parameters.getMediaFile(); TranscodeInputStream in = createTranscodeInputStream(transcoding.getStep1(), maxBitRate, videoTranscodingSettings, mediaFile, null); if (transcoding.getStep2() != null) { in = createTranscodeInputStream(transcoding.getStep2(), maxBitRate, videoTranscodingSettings, mediaFile, in); } if (transcoding.getStep3() != null) { in = createTranscodeInputStream(transcoding.getStep3(), maxBitRate, videoTranscodingSettings, mediaFile, in); } return in; } /** * Creates a transcoded input stream by interpreting the given command line string. * This includes the following: * <ul> * <li>Splitting the command line string to an array.</li> * <li>Replacing occurrences of "%s" with the path of the given music file.</li> * <li>Replacing occurrences of "%t" with the title of the given music file.</li> * <li>Replacing occurrences of "%l" with the album name of the given music file.</li> * <li>Replacing occurrences of "%a" with the artist name of the given music file.</li> * <li>Replacing occurrcences of "%b" with the max bitrate.</li> * <li>Replacing occurrcences of "%o" with the video time offset (used for scrubbing).</li> * <li>Replacing occurrcences of "%d" with the video duration (used for HLS).</li> * <li>Replacing occurrcences of "%w" with the video image width.</li> * <li>Replacing occurrcences of "%h" with the video image height.</li> * <li>Prepending the path of the transcoder directory if the transcoder is found there.</li> * </ul> * * @param command The command line string. * @param maxBitRate The maximum bitrate to use. May not be {@code null}. * @param videoTranscodingSettings Parameters used when transcoding video. May be {@code null}. * @param mediaFile The media file. * @param in Data to feed to the process. May be {@code null}. @return The newly created input stream. */ private TranscodeInputStream createTranscodeInputStream(String command, Integer maxBitRate, VideoTranscodingSettings videoTranscodingSettings, MediaFile mediaFile, InputStream in) throws IOException { String title = mediaFile.getTitle(); String album = mediaFile.getAlbumName(); String artist = mediaFile.getArtist(); if (title == null) { title = "Unknown Song"; } if (album == null) { title = "Unknown Album"; } if (artist == null) { title = "Unknown Artist"; } List<String> result = new LinkedList<String>(Arrays.asList(StringUtil.split(command))); result.set(0, getTranscodeDirectory().getPath() + File.separatorChar + result.get(0)); File tmpFile = null; for (int i = 1; i < result.size(); i++) { String cmd = result.get(i); if (cmd.contains("%b")) { cmd = cmd.replace("%b", String.valueOf(maxBitRate)); } if (cmd.contains("%t")) { cmd = cmd.replace("%t", title); } if (cmd.contains("%l")) { cmd = cmd.replace("%l", album); } if (cmd.contains("%a")) { cmd = cmd.replace("%a", artist); } if (cmd.contains("%o") && videoTranscodingSettings != null) { cmd = cmd.replace("%o", String.valueOf(videoTranscodingSettings.getTimeOffset())); } if (cmd.contains("%d") && videoTranscodingSettings != null) { cmd = cmd.replace("%d", String.valueOf(videoTranscodingSettings.getDuration())); } if (cmd.contains("%w") && videoTranscodingSettings != null) { cmd = cmd.replace("%w", String.valueOf(videoTranscodingSettings.getWidth())); } if (cmd.contains("%h") && videoTranscodingSettings != null) { cmd = cmd.replace("%h", String.valueOf(videoTranscodingSettings.getHeight())); } if (cmd.contains("%s")) { // Work-around for filename character encoding problem on Windows. // Create temporary file, and feed this to the transcoder. String path = mediaFile.getFile().getAbsolutePath(); if (Util.isWindows() && !mediaFile.isVideo() && !StringUtils.isAsciiPrintable(path)) { tmpFile = File.createTempFile("libresonic", "." + FilenameUtils.getExtension(path)); tmpFile.deleteOnExit(); FileUtils.copyFile(new File(path), tmpFile); LOG.debug("Created tmp file: " + tmpFile); cmd = cmd.replace("%s", tmpFile.getPath()); } else { cmd = cmd.replace("%s", path); } } result.set(i, cmd); } return new TranscodeInputStream(new ProcessBuilder(result), in, tmpFile); } /** * Returns an applicable transcoding for the given file and player, or <code>null</code> if no * transcoding should be done. */ private Transcoding getTranscoding(MediaFile mediaFile, Player player, String preferredTargetFormat, boolean hls) { if (hls) { return new Transcoding(null, "hls", mediaFile.getFormat(), "ts", settingsService.getHlsCommand(), null, null, true); } if (FORMAT_RAW.equals(preferredTargetFormat)) { return null; } List<Transcoding> applicableTranscodings = new LinkedList<Transcoding>(); String suffix = mediaFile.getFormat(); for (Transcoding transcoding : getTranscodingsForPlayer(player)) { for (String sourceFormat : transcoding.getSourceFormatsAsArray()) { if (sourceFormat.equalsIgnoreCase(suffix)) { if (isTranscodingInstalled(transcoding)) { applicableTranscodings.add(transcoding); } } } } if (applicableTranscodings.isEmpty()) { return null; } for (Transcoding transcoding : applicableTranscodings) { if (transcoding.getTargetFormat().equalsIgnoreCase(preferredTargetFormat)) { return transcoding; } } return applicableTranscodings.get(0); } /** * Returns a downsampled input stream to the music file. * * @param parameters Downsample parameters. * @throws IOException If an I/O error occurs. */ private InputStream createDownsampledInputStream(Parameters parameters) throws IOException { String command = settingsService.getDownsamplingCommand(); return createTranscodeInputStream(command, parameters.getMaxBitRate(), parameters.getVideoTranscodingSettings(), parameters.getMediaFile(), null); } /** * Returns whether downsampling is supported (i.e., whether ffmpeg is installed or not.) * * @param mediaFile If not null, returns whether downsampling is supported for this file. * @return Whether downsampling is supported. */ public boolean isDownsamplingSupported(MediaFile mediaFile) { if (mediaFile != null) { boolean isMp3 = "mp3".equalsIgnoreCase(mediaFile.getFormat()); if (!isMp3) { return false; } } String commandLine = settingsService.getDownsamplingCommand(); return isTranscodingStepInstalled(commandLine); } private boolean isTranscodingInstalled(Transcoding transcoding) { return isTranscodingStepInstalled(transcoding.getStep1()) && isTranscodingStepInstalled(transcoding.getStep2()) && isTranscodingStepInstalled(transcoding.getStep3()); } private boolean isTranscodingStepInstalled(String step) { if (StringUtils.isEmpty(step)) { return true; } String executable = StringUtil.split(step)[0]; PrefixFileFilter filter = new PrefixFileFilter(executable); String[] matches = getTranscodeDirectory().list(filter); return matches != null && matches.length > 0; } /** * Returns the directory in which all transcoders are installed. */ public File getTranscodeDirectory() { File dir = new File(SettingsService.getLibresonicHome(), "transcode"); if (!dir.exists()) { boolean ok = dir.mkdir(); if (ok) { LOG.info("Created directory " + dir); } else { LOG.warn("Failed to create directory " + dir); } } return dir; } public void setTranscodingDao(TranscodingDao transcodingDao) { this.transcodingDao = transcodingDao; } public void setSettingsService(SettingsService settingsService) { this.settingsService = settingsService; } public void setPlayerService(PlayerService playerService) { this.playerService = playerService; } public static class Parameters { private boolean downsample; private final MediaFile mediaFile; private final VideoTranscodingSettings videoTranscodingSettings; private Integer maxBitRate; private Transcoding transcoding; public Parameters(MediaFile mediaFile, VideoTranscodingSettings videoTranscodingSettings) { this.mediaFile = mediaFile; this.videoTranscodingSettings = videoTranscodingSettings; } public void setMaxBitRate(Integer maxBitRate) { this.maxBitRate = maxBitRate; } public boolean isDownsample() { return downsample; } public void setDownsample(boolean downsample) { this.downsample = downsample; } public boolean isTranscode() { return transcoding != null; } public void setTranscoding(Transcoding transcoding) { this.transcoding = transcoding; } public Transcoding getTranscoding() { return transcoding; } public MediaFile getMediaFile() { return mediaFile; } public Integer getMaxBitRate() { return maxBitRate; } public VideoTranscodingSettings getVideoTranscodingSettings() { return videoTranscodingSettings; } } }