/*
* PS3 Media Server, for streaming any medias to your PS3.
* Copyright (C) 2012 I. Sokolov
*
* This program 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; version 2
* of the License only.
*
* This program 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 this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package net.pms.util;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.pms.PMS;
import net.pms.configuration.PmsConfiguration;
import net.pms.dlna.DLNAMediaInfo;
import net.pms.dlna.DLNAMediaInfo.Mode3D;
import net.pms.dlna.DLNAMediaSubtitle;
import net.pms.dlna.DLNAResource;
import net.pms.formats.v2.SubtitleType;
import net.pms.io.OutputParams;
import net.pms.io.ProcessWrapperImpl;
import static net.pms.util.Constants.*;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SubtitleUtils {
private final static PmsConfiguration configuration = PMS.getConfiguration();
private static final Logger LOGGER = LoggerFactory.getLogger(SubtitleUtils.class);
private final static Map<String, String> fileCharsetToMencoderSubcpOptionMap = new HashMap<String, String>() {
private static final long serialVersionUID = 1L;
{
// Cyrillic / Russian
put(CHARSET_IBM855, "enca:ru:cp1251");
put(CHARSET_ISO_8859_5, "enca:ru:cp1251");
put(CHARSET_KOI8_R, "enca:ru:cp1251");
put(CHARSET_MACCYRILLIC, "enca:ru:cp1251");
put(CHARSET_WINDOWS_1251, "enca:ru:cp1251");
put(CHARSET_IBM866, "enca:ru:cp1251");
// Central / Eastern Europe
put(CHARSET_WINDOWS_1250, "cp1250");
put(CHARSET_ISO_8859_2, "ISO-8859-2");
// Western Europe
put(CHARSET_WINDOWS_1252, "cp1252");
put(CHARSET_ISO_8859_1, "ISO-8859-1");
// Greek
put(CHARSET_WINDOWS_1253, "cp1253");
put(CHARSET_ISO_8859_7, "ISO-8859-7");
// Turkish
put(CHARSET_WINDOWS_1254, "cp1254");
put(CHARSET_ISO_8859_9, "ISO-8859-9");
// Hebrew
put(CHARSET_WINDOWS_1255, "cp1255");
put(CHARSET_ISO_8859_8, "ISO-8859-8");
// Arabic
put(CHARSET_WINDOWS_1256, "cp1256");
put(CHARSET_ISO_8859_6, "ISO-8859-6");
// Chinese
put(CHARSET_ISO_2022_CN, "ISO-2022-CN");
put(CHARSET_BIG5, "enca:zh:big5");
put(CHARSET_GB18030, "enca:zh:big5");
put(CHARSET_EUC_TW, "enca:zh:big5");
// Korean
put(CHARSET_ISO_2022_KR, "cp949");
put(CHARSET_EUC_KR, "euc-kr");
// Japanese
put(CHARSET_ISO_2022_JP, "ISO-2022-JP");
put(CHARSET_EUC_JP, "euc-jp");
put(CHARSET_SHIFT_JIS, "shift-jis");
// Thai
put(CHARSET_WINDOWS_874, "MS874");
put(CHARSET_ISO_8859_11, "ISO-8859-11");
put(CHARSET_TIS_620, "TIS-620");
}
};
private static final String SUB_DIR = "subs";
/**
* Returns value for -subcp option for non UTF-8 external subtitles based on
* detected charset.
*
* @param dlnaMediaSubtitle DLNAMediaSubtitle with external subtitles file.
* @return value for mencoder's -subcp option or null if can't determine.
*/
public static String getSubCpOptionForMencoder(DLNAMediaSubtitle dlnaMediaSubtitle) {
if (dlnaMediaSubtitle == null) {
throw new NullPointerException("dlnaMediaSubtitle can't be null.");
}
if (isBlank(dlnaMediaSubtitle.getSubCharacterSet())) {
return null;
}
return fileCharsetToMencoderSubcpOptionMap.get(dlnaMediaSubtitle.getSubCharacterSet());
}
/**
* Applies codepage conversion to subtitles file
*
* @param fileToConvert Subtitles file to convert
* @param outputSubs Converted subtitles file
* @return Converted subtitles file
* @throws IOException
*/
public static File applyCodepageConversion(File fileToConvert, File outputSubs) throws IOException {
String line;
BufferedReader reader;
String cp = configuration.getSubtitlesCodepage();
final boolean isSubtitlesCodepageForcedInConfigurationAndSupportedByJVM = isNotBlank(cp) && Charset.isSupported(cp);
if (isSubtitlesCodepageForcedInConfigurationAndSupportedByJVM) {
reader = new BufferedReader(new InputStreamReader(new FileInputStream(fileToConvert), Charset.forName(cp)));
} else {
reader = FileUtil.bufferedReaderWithCorrectCharset(fileToConvert);
}
try (BufferedWriter output = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outputSubs), Charset.forName(CHARSET_UTF_8)))) {
while ((line = reader.readLine()) != null) {
output.write(line + "\n");
}
output.flush();
output.close();
}
reader.close();
return outputSubs;
}
/**
* Extracts embedded subtitles from video to file in SSA/ASS format, converts external SRT
* subtitles file to SSA/ASS format and applies fontconfig setting to that converted file
* and applies timeseeking when required.
*
* @param dlna DLNAResource
* @param media DLNAMediaInfo
* @param params Output parameters
* @param configuration
* @return Converted subtitle file
* @throws IOException
*/
public static File getSubtitles(DLNAResource dlna, DLNAMediaInfo media, OutputParams params, PmsConfiguration configuration, SubtitleType subtitleType) throws IOException {
if (media == null || params.sid.getId() == -1 || !params.sid.getType().isText()) {
return null;
}
String dir = configuration.getDataFile(SUB_DIR);
File subsPath = new File(dir);
if (!subsPath.exists()) {
subsPath.mkdirs();
}
boolean applyFontConfig = configuration.isFFmpegFontConfig();
boolean isEmbeddedSource = params.sid.getId() < 100;
boolean is3D = media.is3d() && !media.stereoscopyIsAnaglyph();
File convertedFile = dlna.getMediaSubtitle().getConvertedFile();
if (convertedFile != null && convertedFile.canRead()) {
// subs are already converted and exists
params.sid.setType(SubtitleType.ASS);
params.sid.setSubCharacterSet(CHARSET_UTF_8);
return convertedFile;
}
String filename = isEmbeddedSource ?
dlna.getSystemName() : params.sid.getExternalFile().getAbsolutePath();
String basename;
long modId = new File(filename).lastModified();
if (modId != 0) {
// We have a real file
basename = FilenameUtils.getBaseName(filename);
} else {
// It's something else, e.g. a url or psuedo-url without meaningful
// lastmodified and (maybe) basename characteristics.
basename = dlna.getName().replaceAll("[<>:\"\\\\/|?*+\\[\\]\n\r ']", "").trim();
modId = filename.hashCode();
}
File convertedSubs;
if (applyFontConfig || isEmbeddedSource || is3D || params.sid.getType() != subtitleType) {
convertedSubs = new File(subsPath.getAbsolutePath() + File.separator + basename + "_ID" + params.sid.getId() + "_" + modId + "." + subtitleType.getExtension());
} else {
String tmp = params.sid.getExternalFile().getName().replaceAll("[<>:\"\\\\/|?*+\\[\\]\n\r ']", "").trim();
convertedSubs = new File(subsPath.getAbsolutePath() + File.separator + modId + "_" + tmp);
}
File converted3DSubs = new File(FileUtil.getFileNameWithoutExtension(convertedSubs.getAbsolutePath()) + "_3D.ass");
if (convertedSubs.canRead() || converted3DSubs.canRead()) {
// subs are already converted
if (applyFontConfig || isEmbeddedSource || is3D) {
params.sid.setType(SubtitleType.ASS);
params.sid.setSubCharacterSet(CHARSET_UTF_8);
if (converted3DSubs.canRead()) {
convertedSubs = converted3DSubs;
}
}
params.sid.setConvertedFile(convertedSubs);
dlna.getMediaSubtitle().setConvertedFile(convertedSubs);
return convertedSubs;
}
boolean isExternalAss = false;
if (
params.sid.getType() == SubtitleType.ASS &&
params.sid.isExternal() &&
!isEmbeddedSource
) {
isExternalAss = true;
}
File tempSubs;
if (
isExternalAss ||
(
!applyFontConfig &&
!isEmbeddedSource &&
(params.sid.getType() == subtitleType) &&
(params.sid.getType() == SubtitleType.SUBRIP || params.sid.getType() == SubtitleType.WEBVTT) &&
!is3D
)
) {
tempSubs = params.sid.getExternalFile();
} else {
tempSubs = convertSubsToSubtitleType(filename, media, params, configuration, subtitleType);
}
if (tempSubs == null) {
return null;
}
if (!FileUtil.isFileUTF8(tempSubs)) {
try {
tempSubs = applyCodepageConversion(tempSubs, convertedSubs);
params.sid.setSubCharacterSet(CHARSET_UTF_8);
} catch (IOException ex) {
params.sid.setSubCharacterSet(null);
LOGGER.warn("Exception during external file charset detection.", ex);
}
} else {
FileUtils.copyFile(tempSubs, convertedSubs);
tempSubs = convertedSubs;
}
// Now we're sure we actually have our own modifiable file
if (
applyFontConfig &&
!(
configuration.isUseEmbeddedSubtitlesStyle() &&
params.sid.getType() == SubtitleType.ASS
)
) {
try {
tempSubs = applyFontconfigToASSTempSubsFile(tempSubs, media, configuration);
params.sid.setSubCharacterSet(CHARSET_UTF_8);
} catch (IOException e) {
LOGGER.debug("Applying subs setting ends with error: " + e);
return null;
}
}
if (is3D) {
try {
tempSubs = convertASSToASS3D(tempSubs, media, params);
} catch (IOException | NullPointerException e) {
LOGGER.debug("Converting to ASS3D format ends with error: " + e);
return null;
}
}
if (isEmbeddedSource) {
// params.sid.setExternalFile(tempSubs);
params.sid.setType(SubtitleType.ASS);
}
PMS.get().addTempFile(tempSubs, 30 * 24 * 3600 * 1000);
params.sid.setConvertedFile(tempSubs);
dlna.getMediaSubtitle().setConvertedFile(tempSubs);
return tempSubs;
}
/**
* Converts external subtitles or extract embedded subs to the requested subtitle type
*
* @param fileName subtitles file or video file with embedded subs
* @param media
* @param params output parameters
* @param configuration
* @param outputSubtitleType requested subtitle type
* @return Converted subtitles file in requested type
*/
public static File convertSubsToSubtitleType(String fileName, DLNAMediaInfo media, OutputParams params, PmsConfiguration configuration, SubtitleType outputSubtitleType) {
if (!params.sid.getType().isText()) {
return null;
}
List<String> cmdList = new ArrayList<>();
File tempSubsFile;
cmdList.add(configuration.getFfmpegPath());
cmdList.add("-y");
cmdList.add("-loglevel");
if (LOGGER.isTraceEnabled()) { // Set -loglevel in accordance with LOGGER setting
cmdList.add("info"); // Could be changed to "verbose" or "debug" if "info" level is not enough
} else {
cmdList.add("fatal");
}
// Try to specify input encoding if we have a non utf-8 external sub
if (params.sid.getId() >= 100 && !params.sid.isExternalFileUtf8()) {
String encoding = isNotBlank(configuration.getSubtitlesCodepage()) ?
// Prefer the global user-specified encoding if we have one.
// Note: likely wrong if the file isn't supplied by the user.
configuration.getSubtitlesCodepage() :
params.sid.getSubCharacterSet() != null ?
// Fall back on the actually detected encoding if we have it.
// Note: accuracy isn't 100% guaranteed.
params.sid.getSubCharacterSet() :
null; // Otherwise we're out of luck!
if (encoding != null) {
cmdList.add("-sub_charenc");
cmdList.add(encoding);
}
}
cmdList.add("-i");
cmdList.add(fileName);
if (params.sid.isEmbedded()) {
cmdList.add("-map");
cmdList.add("0:s:" + (media.getSubtitleTracksList().indexOf(params.sid)));
}
try {
tempSubsFile = new File(configuration.getTempFolder(), FilenameUtils.getBaseName(fileName) + "." + outputSubtitleType.getExtension());
} catch (IOException e1) {
LOGGER.debug("Subtitles conversion finished wih error: " + e1);
return null;
}
cmdList.add(tempSubsFile.getAbsolutePath());
String[] cmdArray = new String[cmdList.size()];
cmdList.toArray(cmdArray);
ProcessWrapperImpl pw = new ProcessWrapperImpl(cmdArray, params);
pw.runInNewThread();
try {
pw.join(); // Wait until the conversion is finished
pw.stopProcess(); // Avoid creating a pipe for this process and messing up with buffer progress bar
} catch (InterruptedException e) {
LOGGER.debug("Subtitles conversion finished wih error: " + e);
return null;
}
tempSubsFile.deleteOnExit();
return tempSubsFile;
}
public static File applyFontconfigToASSTempSubsFile(File tempSubs, DLNAMediaInfo media, PmsConfiguration configuration) throws IOException {
LOGGER.debug("Applying fontconfig to subtitles " + tempSubs.getName());
File outputSubs = tempSubs;
StringBuilder outputString = new StringBuilder();
File temp = new File(configuration.getTempFolder(), tempSubs.getName() + ".tmp");
FileUtils.copyFile(tempSubs, temp);
BufferedReader input = FileUtil.bufferedReaderWithCorrectCharset(temp);
BufferedWriter output = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outputSubs), CHARSET_UTF_8));
try {
String line;
String[] format = null;
int i;
boolean playResIsSet = false; // do not apply font size change when video resolution is set
while ((line = input.readLine()) != null) {
outputString.setLength(0);
if (line.contains("[Script Info]")) {
outputString.append(line).append("\n");
output.write(outputString.toString());
while ((line = input.readLine()) != null) {
outputString.setLength(0);
if (isNotBlank(line)) {
if (line.contains("PlayResY:") || line.contains("PlayResX:")) {
playResIsSet = true;
}
outputString.append(line).append("\n");
output.write(outputString.toString());
} else {
if (!playResIsSet) {
outputString.append("PlayResY: ").append(media.getHeight()).append("\n");
outputString.append("PlayResX: ").append(media.getWidth()).append("\n");
}
break;
}
}
}
if (line != null && line.contains("Format:")) {
format = line.split(",");
outputString.append(line).append("\n");
output.write(outputString.toString());
continue;
}
if (line != null && line.contains("Style: Default")) {
String[] params = line.split(",");
for (i = 0; i < format.length; i++) {
switch (format[i].trim()) {
case "Fontname":
if (!configuration.getFont().isEmpty()) {
params[i] = configuration.getFont();
}
break;
case "Fontsize":
if (!playResIsSet) {
params[i] = Integer.toString((int) ((Integer.parseInt(params[i]) * media.getHeight() / (double) 288 * Double.parseDouble(configuration.getAssScale()))));
} else {
params[i] = Integer.toString((int) (Integer.parseInt(params[i]) * Double.parseDouble(configuration.getAssScale())));
}
break;
case "PrimaryColour":
params[i] = configuration.getSubsColor().getASSv4StylesHexValue();
break;
case "Outline":
params[i] = configuration.getAssOutline();
break;
case "Shadow":
params[i] = configuration.getAssShadow();
break;
case "MarginV":
params[i] = configuration.getAssMargin();
break;
default:
break;
}
}
outputString.append(StringUtils.join(params, ",")).append("\n");
output.write(outputString.toString());
continue;
}
outputString.append(line).append("\n");
output.write(outputString.toString());
}
} finally {
input.close();
output.flush();
output.close();
temp.deleteOnExit();
}
return outputSubs;
}
/**
* Converts ASS/SSA subtitles to 3D ASS/SSA subtitles.
* Based on https://bitbucket.org/r3pek/srt2ass3d
*
* @param tempSubs Subtitles file to convert
* @param media Information about video
* @return Converted subtitles file
* @throws IOException
*/
public static File convertASSToASS3D(File tempSubs, DLNAMediaInfo media, OutputParams params) throws IOException, NullPointerException {
File outputSubs = new File(FileUtil.getFileNameWithoutExtension(tempSubs.getAbsolutePath()) + "_3D.ass");
StringBuilder outputString = new StringBuilder();
Charset subsFileCharset = FileUtil.getFileCharset(tempSubs);
if (subsFileCharset == null) {
subsFileCharset = StandardCharsets.UTF_8;
}
BufferedWriter output;
Mode3D mode3D = media.get3DLayout();
boolean isOU = mode3D == Mode3D.OUL || mode3D == Mode3D.OUR || mode3D == Mode3D.HOUL;
boolean isSBS = mode3D == Mode3D.SBSL || mode3D == Mode3D.SBSR || mode3D == Mode3D.HSBSL;
if (mode3D == null) {
LOGGER.debug("The 3D layout not recognized for the 3D video");
throw new NullPointerException("The 3D layout not recognized for the 3D video");
}
int depth3D = configuration.getDepth3D();
Pattern timePattern = Pattern.compile("[0-9]:[0-9]{2}:[0-9]{2}.[0-9]{2},[0-9]:[0-9]{2}:[0-9]{2}.[0-9]{2},");
try (BufferedReader input = new BufferedReader(new InputStreamReader(new FileInputStream(tempSubs), subsFileCharset))) {
output = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outputSubs), Charset.forName(CHARSET_UTF_8)));
String line;
outputString.append("[Script Info]\n");
outputString.append("ScriptType: v4.00+\n");
outputString.append("Collisions: Normal\n");
outputString.append("PlayResX: ").append("384\n");
outputString.append("PlayResY: ").append("288\n");
outputString.append("ScaledBorderAndShadow: yes\n");
outputString.append("PlayDepth: 0\n");
outputString.append("Timer: 100.0\n");
outputString.append("WrapStyle: 0\n\n");
outputString.append("[V4+ Styles]\n");
outputString.append("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n");
String fontScaleX = "1";
String fontScaleY = "1";
if (isOU) {
fontScaleX = Double.toString(100 * Double.parseDouble(configuration.getAssScale()));
fontScaleY = Double.toString((100 * Double.parseDouble(configuration.getAssScale())) / 2);
} else if (isSBS) {
fontScaleX = Double.toString((100 * Double.parseDouble(configuration.getAssScale())) / 2);
fontScaleY = Double.toString(100 * Double.parseDouble(configuration.getAssScale()));
}
String primaryColour = configuration.getSubsColor().getASSv4StylesHexValue();
String outline = configuration.getAssOutline();
String shadow = configuration.getAssShadow();
outputString.append("Style: Default,Arial,").append("15").append(',').append(primaryColour).append(",&H000000FF,&H00000000,&H00000000,0,0,0,0,").append(fontScaleX).append(',').append(fontScaleY).append(",0,0,1,").append(outline).append(',').append(shadow);
if (isOU) {
outputString.append(",2,15,15,15,0\n\n");
} else if (isSBS) {
outputString.append(",2,0,0,15,0\n\n");
}
outputString.append("[Events]\n");
outputString.append("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\n");
output.write(outputString.toString());
int textPosition = 0;
while ((line = input.readLine()) != null) {
if (line.startsWith("[Events]")) {
line = input.readLine();
if (line != null && line.startsWith("Format:")) {
String[] formatPattern = line.split(",");
int i = 0;
for (String component : formatPattern) {
if (component.trim().equals("Text")) {
textPosition = i;
}
i++;
}
}
}
outputString.setLength(0);
if (line != null && line.startsWith("Dialogue:") && line.contains("Default")) { // TODO: For now convert only Default style. For other styles must be position and font size recalculated
String[] dialogPattern = line.split(",");
String text = StringUtils.join(dialogPattern, ",", textPosition, dialogPattern.length);
Matcher timeMatcher = timePattern.matcher(line);
if (timeMatcher.find()) {
if (isOU) {
outputString.append("Dialogue: 0,")
.append(timeMatcher.group())
.append("Default,,");
if (depth3D > 0) {
outputString.append("0000,")
.append(String.format("%04d,", depth3D));
} else if (depth3D < 0) {
outputString.append(String.format("%04d,", -depth3D))
.append("0000,");
} else {
outputString.append("0000,0000,");
}
outputString.append(String.format("%04d,,", 159))
.append(text).append("\n")
.append("Dialogue: 0,")
.append(timeMatcher.group())
.append("Default,,0000,0000,0000,,")
.append(text).append("\n");
} else if (isSBS) {
outputString.append("Dialogue: 0,")
.append(timeMatcher.group())
.append("Default,,")
.append("0000,")
.append(String.format("%04d,", 192 - depth3D))
.append("0000,,")
.append(text).append("\n")
.append("Dialogue: 0,")
.append(timeMatcher.group())
.append("Default,,")
.append(String.format("%04d,", 192 - depth3D))
.append("0000,0000,,")
.append(text).append("\n");
}
}
output.write(outputString.toString());
}
}
}
LOGGER.debug("Subtitles converted to 3DASS format and stored in the file: " + outputSubs.getName());
output.flush();
output.close();
tempSubs.deleteOnExit();
return outputSubs;
}
public static void deleteSubs() {
FileUtils.deleteQuietly(new File(configuration.getDataFile(SUB_DIR)));
}
/**
* Remove the (HTML) tags: {@code
* <b> </b> <i> </i> <u> </u> <s> </s> <font *> </font>
* } and any ASS tags <code>
* {\*}
* </code>
* from subtitles file for renderers not capable of showing SubRip tags
* correctly. * is used as a wildcard in the definition above.
*
* @param file the source subtitles
* @return InputStream with converted subtitles.
*/
public static InputStream removeSubRipTags(File file) throws IOException {
BufferedReader input = FileUtil.bufferedReaderWithCorrectCharset(file);
ByteArrayOutputStream os = new ByteArrayOutputStream();
Writer writer = new OutputStreamWriter(os, Charset.forName(CHARSET_UTF_8));
Pattern pattern = Pattern.compile("\\</?(?:b|i|s|u|font[^\\>]*)\\>|\\{\\\\.*?}|\\\\h|\\\\N");
String line;
while ((line = input.readLine()) != null) {
line = pattern.matcher(line).replaceAll("") + "\n";
writer.write(line);
}
writer.flush();
writer.close();
LOGGER.trace("Removed tags from subtitles file: \"{}\"", file.getName());
return new ByteArrayInputStream(os.toByteArray());
}
}