/*
* PS3 Media Server, for streaming any medias to your PS3.
* Copyright (C) 2008 A.Brochard
*
* 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.dlna;
import java.io.*;
import java.net.InetAddress;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import net.pms.Messages;
import net.pms.PMS;
import net.pms.configuration.FormatConfiguration;
import net.pms.configuration.PmsConfiguration;
import net.pms.configuration.RendererConfiguration;
import net.pms.dlna.virtual.TranscodeVirtualFolder;
import net.pms.dlna.virtual.VirtualFolder;
import net.pms.dlna.virtual.VirtualVideoAction;
import net.pms.encoders.*;
import net.pms.external.AdditionalResourceFolderListener;
import net.pms.external.ExternalFactory;
import net.pms.external.ExternalListener;
import net.pms.external.StartStopListener;
import net.pms.formats.Format;
import net.pms.formats.FormatFactory;
import net.pms.image.ImageFormat;
import net.pms.image.ImageInfo;
import net.pms.io.OutputParams;
import net.pms.io.ProcessWrapper;
import net.pms.io.SizeLimitInputStream;
import net.pms.network.HTTPResource;
import net.pms.network.UPNPControl.Renderer;
import net.pms.util.*;
import static net.pms.util.StringUtil.*;
import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
/**
* Represents any item that can be browsed via the UPNP ContentDirectory service.
*
* TODO: Change all instance variables to private. For backwards compatibility
* with external plugin code the variables have all been marked as deprecated
* instead of changed to private, but this will surely change in the future.
* When everything has been changed to private, the deprecated note can be
* removed.
*/
public abstract class DLNAResource extends HTTPResource implements Cloneable, Runnable {
private final Map<String, Integer> requestIdToRefcount = new HashMap<>();
private boolean resolved;
private static final int STOP_PLAYING_DELAY = 4000;
private static final Logger LOGGER = LoggerFactory.getLogger(DLNAResource.class);
private final SimpleDateFormat SDF_DATE = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US);
private volatile ImageInfo thumbnailImageInfo = null;
protected PmsConfiguration configuration = PMS.getConfiguration();
// private boolean subsAreValidForStreaming = false;
protected static final int MAX_ARCHIVE_ENTRY_SIZE = 10000000;
protected static final int MAX_ARCHIVE_SIZE_SEEK = 800000000;
/**
* The name displayed on the renderer. Cached the first time getDisplayName(RendererConfiguration) is called.
*/
private String displayName;
/**
* The suffix added to the name. Contains additional info about audio and subtitles.
*/
private String nameSuffix = "";
/**
* @deprecated This field will be removed. Use {@link net.pms.configuration.PmsConfiguration#getTranscodeFolderName()} instead.
*/
@Deprecated
protected static final String TRANSCODE_FOLDER = Messages.getString("TranscodeVirtualFolder.0"); // localized #--TRANSCODE--#
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected int specificType;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected String id;
protected String pathId;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected DLNAResource parent;
/**
* @deprecated This field will be removed. Use {@link #getFormat()} and
* {@link #setFormat(Format)} instead.
*/
@Deprecated
protected Format ext;
/**
* The format of this resource.
*/
private Format format;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected DLNAMediaInfo media;
/**
* @deprecated Use {@link #getMediaAudio()} and {@link
* #setMediaAudio(DLNAMediaAudio)} to access this field.
*/
@Deprecated
protected DLNAMediaAudio media_audio;
/**
* @deprecated Use {@link #getMediaSubtitle()} and {@link
* #setMediaSubtitle(DLNAMediaSubtitle)} to access this field.
*/
@Deprecated
protected DLNAMediaSubtitle media_subtitle;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected long lastmodified; // TODO make private and rename lastmodified -> lastModified
/**
* Represents the transformation to be used to the file. If null, then
*
* @see Player
*/
private Player player;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected boolean discovered = false;
private ProcessWrapper externalProcess;
/**
* @deprecated Use #hasExternalSubtitles()
*/
@Deprecated
protected boolean srtFile;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected boolean hasExternalSubtitles;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected int updateId = 1;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
public static int systemUpdateId = 1;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected boolean noName;
private int nametruncate;
private DLNAResource first;
private DLNAResource second;
/**
* @deprecated Use standard getter and setter to access this field.
*
* The time range for the file containing the start and end time in seconds.
*/
@Deprecated
protected Range.Time splitRange = new Range.Time();
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected int splitTrack;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected String fakeParentId;
/**
* @deprecated Use standard getter and setter to access this field.
*/
// Ditlew - needs this in one of the derived classes
@Deprecated
protected RendererConfiguration defaultRenderer;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected boolean avisynth;
/**
* @deprecated Use standard getter and setter to access this field.
*/
@Deprecated
protected boolean skipTranscode = false;
private boolean allChildrenAreFolders = true;
/**
* @deprecated Use standard getter and setter to access this field.
*
* List of children objects associated with this DLNAResource. This is only valid when the DLNAResource is of the container type.
*/
@Deprecated
protected DLNAList children;
//protected List<DLNAResource> children;
/**
* @deprecated Use standard getter and setter to access this field.
*
* The numerical ID (1-based index) assigned to the last child of this folder. The next child is assigned this ID + 1.
*/
// FIXME should be lastChildId
@Deprecated
protected int lastChildrenId = 0; // XXX make private and rename lastChildrenId -> lastChildId
/**
* @deprecated Use standard getter and setter to access this field.
*
* The last time refresh was called.
*/
@Deprecated
protected long lastRefreshTime;
@SuppressWarnings("unused")
private String lastSearch;
private VirtualFolder dynamicPls;
protected HashMap<String, Object> attachments = null;
/**
* Returns parent object, usually a folder type of resource. In the DLDI
* queries, the UPNP server needs to give out the parent container where
* the item is. The <i>parent</i> represents such a container.
*
* @return Parent object.
*/
public DLNAResource getParent() {
return parent;
}
/**
* Set the parent object, usually a folder type of resource. In the DLDI
* queries, the UPNP server needs to give out the parent container where
* the item is. The <i>parent</i> represents such a container.
*
* @param parent Sets the parent object.
*/
public void setParent(DLNAResource parent) {
this.parent = parent;
}
/**
* Returns the id of this resource based on the index in its parent
* container. Its main purpose is to be unique in the parent container.
*
* @return The id string.
* @since 1.50
*/
public String getId() {
return id;
}
/**
* Returns the integer representation of the id of this resource based
* on the index in its parent container.
*
* @return The id integer.
* @since 6.4.1
*/
public int getIntId() {
return Integer.parseInt(getId());
}
/**
* Set the ID of this resource based on the index in its parent container.
* Its main purpose is to be unique in the parent container. The method is
* automatically called by addChildInternal, so most of the time it is not
* necessary to call it explicitly.
*
* @param id
* @since 1.50
* @see #addChildInternal(DLNAResource)
*/
protected void setId(String id) {
this.id = id;
}
public String getPathId() {
DLNAResource tmp = getParent();
ArrayList<String> res = new ArrayList<>();
res.add(getId());
while (tmp != null) {
res.add(0, tmp.getId());
tmp = tmp.getParent();
}
pathId = StringUtils.join(res, '.');
return pathId;
}
/**
* String representing this resource ID. This string is used by the UPNP
* ContentDirectory service. There is no hard spec on the actual numbering
* except for the root container that always has to be "0". In PMS the
* format used is <i>number($number)+</i>. A common client that expects a
* different format than the one used here is the XBox360. PMS translates
* the XBox360 queries on the fly. For more info, check
* http://www.mperfect.net/whsUpnp360/ .
*
* @return The resource id.
* @since 1.50
*/
public String getResourceId() {
/*if (getId() == null) {
return null;
}
if (parent != null) {
return parent.getResourceId() + '$' + getId();
} else {
return getId();
}*/
if (isFolder() && configuration.getAutoDiscover()) {
return getPathId();
}
return getId();
}
/**
* @see #setId(String)
* @param id
*/
protected void setIndexId(int id) {
setId(Integer.toString(id));
}
/**
*
* @return the unique id which identifies the DLNAResource relative to its parent.
*/
public String getInternalId() {
return getId();
}
/**
*
* @return true, if this contain can have a transcode folder
*/
public boolean isTranscodeFolderAvailable() {
return true;
}
/**
* Checks if is live subtitle folder available.
*
* @return true, if the live subtitle folder should be shown
*/
public boolean isLiveSubtitleFolderAvailable() {
return true;
}
/**
* Any {@link DLNAResource} needs to represent the container or item with a String.
*
* @return String to be showed in the UPNP client.
*/
public abstract String getName();
public abstract String getSystemName();
public abstract long length();
// Ditlew
public long length(RendererConfiguration mediaRenderer) {
return length();
}
public abstract InputStream getInputStream() throws IOException;
public abstract boolean isFolder();
public String getDlnaContentFeatures(RendererConfiguration mediaRenderer) {
// TODO: Determine renderer's correct localization value
int localizationValue = 1;
String dlnaOrgPnFlags = getDlnaOrgPnFlags(mediaRenderer, localizationValue);
return (dlnaOrgPnFlags != null ? (dlnaOrgPnFlags + ";") : "") + getDlnaOrgOpFlags(mediaRenderer) + ";DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000";
}
public DLNAResource getPrimaryResource() {
return first;
}
public DLNAResource getSecondaryResource() {
return second;
}
public String getFakeParentId() {
return fakeParentId;
}
public void setFakeParentId(String fakeParentId) {
this.fakeParentId = fakeParentId;
}
/**
* @return the fake parent id if specified, or the real parent id
*/
public String getParentId() {
if (getFakeParentId() != null) {
return getFakeParentId();
} else {
if (parent != null) {
return parent.getResourceId();
} else {
return "-1";
}
}
}
public DLNAResource() {
this.specificType = Format.UNKNOWN;
//this.children = new ArrayList<DLNAResource>();
this.children = new DLNAList();
this.updateId = 1;
lastSearch = null;
resHash = 0;
masterParent = null;
}
public DLNAResource(int specificType) {
this();
this.specificType = specificType;
}
/**
* Recursive function that searches through all of the children until it finds
* a {@link DLNAResource} that matches the name.<p>
* Only used by
* {@link net.pms.dlna.RootFolder#addWebFolder(File webConf)
* addWebFolder(File webConf)} while parsing the web.conf file.
*
* @param name String to be compared the name to.
* @return Returns a {@link DLNAResource} whose name matches the parameter name
* @see #getName()
*/
public DLNAResource searchByName(String name) {
for (DLNAResource child : children) {
if (child.getName().equals(name)) {
return child;
}
}
return null;
}
/**
* @param renderer Renderer for which to check if file is supported.
* @return true if the given {@link net.pms.configuration.RendererConfiguration
* RendererConfiguration} can understand type of media. Also returns true
* if this DLNAResource is a container.
*/
public boolean isCompatible(RendererConfiguration renderer) {
return format == null
|| format.isUnknown()
|| (format.isVideo() && renderer.isVideoSupported())
|| (format.isAudio() && renderer.isAudioSupported())
|| (format.isImage() && renderer.isImageSupported());
}
/**
* Adds a new DLNAResource to the child list. Only useful if this object is
* of the container type.
* <P>
* TODO: (botijo) check what happens with the child object. This function
* can and will transform the child object. If the transcode option is set,
* the child item is converted to a container with the real item and the
* transcode option folder. There is also a parser in order to get the right
* name and type, I suppose. Is this the right place to be doing things like
* these?
* <p>
* FIXME: Ideally the logic below is completely renderer-agnostic. Focus on
* harvesting generic data and transform it for a specific renderer as late
* as possible.
*
* @param child
* DLNAResource to add to a container type.
*/
public void addChild(DLNAResource child) {
addChild(child, true);
}
public void addChild(DLNAResource child, boolean isNew) {
// child may be null (spotted - via rootFolder.addChild() - in a misbehaving plugin
if (child == null) {
LOGGER.error("A plugin has attempted to add a null child to \"{}\"", getName());
LOGGER.debug("Error info:", new NullPointerException("Invalid DLNA resource"));
return;
}
child.parent = this;
child.masterParent = masterParent;
if (parent != null) {
defaultRenderer = parent.getDefaultRenderer();
}
if (PMS.filter(defaultRenderer, child)) {
LOGGER.debug("Resource " + child.getName() + " is filtered out for render " + defaultRenderer.getRendererName());
return;
}
if (configuration.useCode() && !PMS.get().masterCodeValid()) {
String code = PMS.get().codeDb().getCode(child);
if (StringUtils.isNotEmpty(code)) {
DLNAResource cobj = child.isCoded();
if (cobj == null || !((CodeEnter) cobj).getCode().equals(code)) {
LOGGER.debug("Resource " + child + " is coded add code folder");
CodeEnter ce = new CodeEnter(child);
ce.parent = this;
ce.defaultRenderer = this.getDefaultRenderer();
ce.setCode(code);
addChildInternal(ce);
return;
}
}
}
try {
if (child.isValid()) {
if (child.format != null) {
// Do not add unsupported media formats to the list
if (defaultRenderer != null && !defaultRenderer.supportsFormat(child.format)) {
LOGGER.trace("Ignoring file \"{}\" because it is not supported by renderer \"{}\"", child.getName(), defaultRenderer.getRendererName());
children.remove(child);
return;
}
// Hide watched videos depending user preference
if (FullyPlayed.isHideFullyPlayed(child)) {
LOGGER.trace("Ignoring video file \"{}\" because it has been watched", child.getName());
return;
}
}
LOGGER.trace("{} child \"{}\" with class \"{}\"", isNew ? "Adding new" : "Updating", child.getName(), child.getClass().getName());
if (allChildrenAreFolders && !child.isFolder()) {
allChildrenAreFolders = false;
}
child.resHash = Math.abs(child.getSystemName().hashCode() + resumeHash());
DLNAResource resumeRes = null;
boolean addResumeFile = false;
ResumeObj r = ResumeObj.create(child);
if (r != null) {
resumeRes = child.clone();
resumeRes.resume = r;
resumeRes.resHash = child.resHash;
addResumeFile = true;
}
if (child.format != null) {
// Determine transcoding possibilities if either
// - the format is known to be transcodable
// - we have media info (via parserV2, playback info, or a plugin)
if (child.format.transcodable() || child.media != null) {
if (child.media == null) {
child.media = new DLNAMediaInfo();
}
// Try to determine a player to use for transcoding.
Player playerTranscoding = null;
// First, try to match a player from recently played folder or based on the name of the DLNAResource
// or its parent. If the name ends in "[unique player id]", that player
// is preferred.
String name = getName();
if (!configuration.isHideRecentlyPlayedFolder()) {
playerTranscoding = child.player;
} else {
for (Player p : PlayerFactory.getPlayers()) {
String end = "[" + p.id() + "]";
if (name.endsWith(end)) {
nametruncate = name.lastIndexOf(end);
playerTranscoding = p;
LOGGER.trace("Selecting player based on name end");
break;
} else if (parent != null && parent.getName().endsWith(end)) {
parent.nametruncate = parent.getName().lastIndexOf(end);
playerTranscoding = p;
LOGGER.trace("Selecting player based on parent name end");
break;
}
}
}
// If no preferred player could be determined from the name, try to
// match a player based on media information and format.
if (playerTranscoding == null || (hasExternalSubtitles() && defaultRenderer.isSubtitlesStreamingSupported())) {
playerTranscoding = child.resolvePlayer(defaultRenderer);
}
child.setPlayer(playerTranscoding);
child.setPreferredMimeType(defaultRenderer);
if (resumeRes != null) {
resumeRes.player = playerTranscoding;
}
if (!allChildrenAreFolders) {
child.setDefaultRenderer(defaultRenderer);
// Should the child be added to the #--TRANSCODE--# folder?
if ((child.format.isVideo() || child.format.isAudio()) && child.isTranscodeFolderAvailable()) {
// true: create (and append) the #--TRANSCODE--# folder to this
// folder if supported/enabled and if it doesn't already exist
VirtualFolder transcodeFolder = getTranscodeFolder(true);
if (transcodeFolder != null) {
VirtualFolder fileTranscodeFolder = new FileTranscodeVirtualFolder(child.getDisplayName(), null);
DLNAResource newChild = child.clone();
newChild.player = playerTranscoding;
newChild.media = child.media;
fileTranscodeFolder.addChildInternal(newChild);
LOGGER.trace("Adding \"{}\" to transcode folder for player: \"{}\"", child.getName(), playerTranscoding);
transcodeFolder.updateChild(fileTranscodeFolder);
}
}
if (child.format.isVideo() && child.isSubSelectable() && !(this instanceof SubSelFile)) {
VirtualFolder vf = getSubSelector(true);
if (vf != null) {
DLNAResource newChild = child.clone();
newChild.player = playerTranscoding;
newChild.media = child.media;
LOGGER.trace("Duplicate subtitle " + child.getName() + " with player: " + playerTranscoding);
vf.addChild(new SubSelFile(newChild));
}
}
if (configuration.isDynamicPls() &&
!child.isFolder() &&
defaultRenderer != null &&
!defaultRenderer.isNoDynPlsFolder()) {
addDynamicPls(child);
}
for (ExternalListener listener : ExternalFactory.getExternalListeners()) {
if (listener instanceof AdditionalResourceFolderListener) {
try {
((AdditionalResourceFolderListener) listener).addAdditionalFolder(this, child);
} catch (Throwable t) {
LOGGER.error("Failed to add additional folder for listener of type: \"{}\"", listener.getClass(), t);
}
}
}
} else if (!child.format.isCompatible(child.media, defaultRenderer) && !child.isFolder()) {
LOGGER.trace("Ignoring file \"{}\" because it is not compatible with renderer \"{}\"", child.getName(), defaultRenderer.getRendererName());
children.remove(child);
}
}
if (resumeRes != null && resumeRes.media != null) {
resumeRes.media.setThumbready(false);
}
/**
* Secondary format is currently only used to provide 24-bit FLAC to PS3 by
* sending it as a fake video. This can be made more reusable with a renderer
* config setting like Mux24BitFlacToVideo if we ever have another purpose
* for it, which I doubt we will have.
*/
if (
child.format.getSecondaryFormat() != null &&
child.media != null &&
defaultRenderer != null &&
defaultRenderer.supportsFormat(child.format.getSecondaryFormat()) &&
defaultRenderer.isPS3()
) {
DLNAResource newChild = child.clone();
newChild.setFormat(newChild.format.getSecondaryFormat());
LOGGER.trace("Detected secondary format \"{}\" for \"{}\"", newChild.format.toString(), newChild.getName());
newChild.first = child;
child.second = newChild;
if (!newChild.format.isCompatible(newChild.media, defaultRenderer)) {
Player playerTranscoding = PlayerFactory.getPlayer(newChild);
newChild.setPlayer(playerTranscoding);
LOGGER.trace("Secondary format \"{}\" will use player \"{}\" for \"{}\"", newChild.format.toString(), newChild.getPlayer().name(), newChild.getName());
}
if (child.media != null && child.media.isSecondaryFormatValid()) {
addChild(newChild);
LOGGER.trace("Adding secondary format \"{}\" for \"{}\"", newChild.format.toString(), newChild.getName());
} else {
LOGGER.trace("Ignoring secondary format \"{}\" for \"{}\": invalid format", newChild.format.toString(), newChild.getName());
}
}
}
if (addResumeFile) {
resumeRes.setDefaultRenderer(child.getDefaultRenderer());
addChildInternal(resumeRes);
}
if (isNew) {
addChildInternal(child);
}
}
} catch (Throwable t) {
LOGGER.error("Error adding child: \"{}\"", child.getName(), t);
child.parent = null;
children.remove(child);
}
}
/**
* Determine whether we are a candidate for streaming or transcoding to the
* given renderer, and return the relevant player or null as appropriate.
*
* @param renderer The target renderer
* @return A player if transcoding or null if streaming
*/
public Player resolvePlayer(RendererConfiguration renderer) {
// Use device-specific pms conf, if any
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(renderer);
boolean parserV2 = media != null && renderer != null && renderer.isUseMediaInfo();
Player resolvedPlayer = null;
if (media == null) {
media = new DLNAMediaInfo();
}
if (format == null) {
// Shouldn't happen, this is just a desperate measure
Format f = FormatFactory.getAssociatedFormat(getSystemName());
setFormat(f != null ? f : FormatFactory.getAssociatedFormat(".mpg"));
}
// Check if we're a transcode folder item
if (isNoName() && (getParent() instanceof FileTranscodeVirtualFolder)) {
// Yes, leave everything as-is
resolvedPlayer = getPlayer();
LOGGER.trace("Selecting player {} based on transcode item settings", resolvedPlayer);
return resolvedPlayer;
}
boolean hasSubsToTranscode = false;
boolean hasEmbeddedSubs = false;
for (DLNAMediaSubtitle s : media.getSubtitleTracksList()) {
hasEmbeddedSubs = (hasEmbeddedSubs || s.isEmbedded());
}
/**
* At this stage, we know the media is compatible with the renderer based on its
* "Supported" lines, and can therefore be streamed to the renderer without a
* player. However, other details about the media can change this, such as
* whether it has subtitles that match this user's language settings, so here we
* perform those checks.
*/
if (format.isVideo() && !configurationSpecificToRenderer.isDisableSubtitles()) {
if (hasEmbeddedSubs || hasExternalSubtitles()) {
OutputParams params = new OutputParams(configurationSpecificToRenderer);
Player.setAudioAndSubs(getSystemName(), media, params); // set proper subtitles in accordance with user setting
if (params.sid != null) {
if (params.sid.isExternal()) {
if (renderer != null && renderer.isExternalSubtitlesFormatSupported(params.sid, media)) {
media_subtitle = params.sid;
media_subtitle.setSubsStreamable(true);
LOGGER.trace("This video has external subtitles that could be streamed");
} else {
hasSubsToTranscode = true;
LOGGER.trace("This video has external subtitles that should be transcoded");
}
} else if (params.sid.isEmbedded()) {
if (renderer != null && renderer.isEmbeddedSubtitlesFormatSupported(params.sid)) {
LOGGER.trace("This video has embedded subtitles that could be streamed");
} else {
hasSubsToTranscode = true;
LOGGER.trace("This video has embedded subtitles that should be transcoded");
}
}
}
} else {
LOGGER.trace("This video does not have subtitles");
}
}
if (configurationSpecificToRenderer.isDisableTranscoding()) {
LOGGER.trace("Final verdict: \"{}\" will be streamed since transcoding is disabled", getName());
return null;
}
String configurationSkipExtensions = configurationSpecificToRenderer.getDisableTranscodeForExtensions();
String rendererSkipExtensions = renderer == null ? null : renderer.getStreamedExtensions();
// Should transcoding be skipped for this format?
skipTranscode = format.skip(configurationSkipExtensions, rendererSkipExtensions);
if (skipTranscode) {
LOGGER.trace("Final verdict: \"{}\" will be streamed since it is forced by configuration", getName());
return null;
}
// Try to match a player based on media information and format.
resolvedPlayer = PlayerFactory.getPlayer(this);
if (resolvedPlayer != null) {
String configurationForceExtensions = configurationSpecificToRenderer.getForceTranscodeForExtensions();
String rendererForceExtensions = null;
if (renderer != null) {
rendererForceExtensions = renderer.getTranscodedExtensions();
}
// Should transcoding be forced for this format?
boolean forceTranscode = format.skip(configurationForceExtensions, rendererForceExtensions);
boolean isIncompatible = false;
String audioTracksList = getName() + media.getAudioTracksList().toString();
String prependTraceReason = "File \"{}\" will not be streamed because ";
if (forceTranscode) {
LOGGER.trace(prependTraceReason + "transcoding is forced by configuration", getName());
} else if (!format.isCompatible(media, renderer)) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "it is not supported by the renderer", getName());
} else if (
configurationSpecificToRenderer.isEncodedAudioPassthrough() &&
(
audioTracksList.contains("audio codec: AC3") ||
audioTracksList.contains("audio codec: DTS")
)
) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the audio will use the encoded audio passthrough feature", getName());
} else if (format.isVideo() && parserV2 && renderer != null) {
int maxBandwidth = renderer.getMaxBandwidth();
if (
renderer.isKeepAspectRatio() &&
!"16:9".equals(media.getAspectRatioContainer())
) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the renderer needs us to add borders to change the aspect ratio from {} to 16/9.", getName(), media.getAspectRatioContainer());
} else if (!renderer.isResolutionCompatibleWithRenderer(media.getWidth(), media.getHeight())) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the resolution is incompatible with the renderer.", getName());
} else if (media.getBitrate() > maxBandwidth) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the bitrate ({} b/s) is too high ({} b/s).", getName(), media.getBitrate(), maxBandwidth);
} else if (!renderer.isVideoBitDepthSupported(media.getVideoBitDepth())) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the video bit depth ({}) is not supported.", getName(), media.getVideoBitDepth());
} else if (renderer.isH264Level41Limited() && media.isH264()) {
if (media.getAvcLevel() != null) {
double h264Level = 4.1;
try {
h264Level = Double.parseDouble(media.getAvcLevel());
} catch (NumberFormatException e) {
LOGGER.trace("Could not convert {} to double: {}", media.getAvcLevel(), e.getMessage());
}
if (h264Level > 4.1) {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the H.264 level ({}) is not supported.", getName(), h264Level);
}
} else {
isIncompatible = true;
LOGGER.trace(prependTraceReason + "the H.264 level is unknown.", getName());
}
}
}
// Prefer transcoding over streaming if:
// 1) the media is unsupported by the renderer, or
// 2) there are subs to transcode
boolean preferTranscode = isIncompatible || hasSubsToTranscode;
// Transcode if:
// 1) transcoding is forced by configuration, or
// 2) transcoding is preferred and not prevented by configuration
if (forceTranscode || (preferTranscode && !isSkipTranscode())) {
if (parserV2) {
LOGGER.trace("Final verdict: \"{}\" will be transcoded with player \"{}\" with mime type \"{}\"", getName(), resolvedPlayer.toString(), renderer != null ? renderer.getMimeType(mimeType(resolvedPlayer), media) : media.getMimeType());
} else {
LOGGER.trace("Final verdict: \"{}\" will be transcoded with player \"{}\"", getName(), resolvedPlayer.toString());
}
} else {
resolvedPlayer = null;
LOGGER.trace("Final verdict: \"{}\" will be streamed", getName());
}
} else {
LOGGER.trace("Final verdict: \"{}\" will be streamed because no compatible player was found", getName());
}
return resolvedPlayer;
}
/**
* Set the mimetype for this resource according to the given renderer's
* supported preferences, if any.
*
* @param renderer The renderer
* @return The previous mimetype for this resource, or null
*/
public String setPreferredMimeType(RendererConfiguration renderer) {
String prev = media != null ? media.getMimeType() : null;
boolean parserV2 = media != null && renderer != null && renderer.isUseMediaInfo();
if (parserV2 && (format == null || !format.isImage())) {
// See which MIME type the renderer prefers in case it supports the media
String preferred = renderer.getFormatConfiguration().match(media);
if (preferred != null) {
/**
* Use the renderer's preferred MIME type for this file.
*/
if (!FormatConfiguration.MIMETYPE_AUTO.equals(preferred)) {
media.setMimeType(preferred);
}
LOGGER.trace("File \"{}\" will be sent with MIME type \"{}\"", getName(), preferred);
}
}
return prev;
}
/**
* Return the transcode folder for this resource.
* If UMS is configured to hide transcode folders, null is returned.
* If no folder exists and the create argument is false, null is returned.
* If no folder exists and the create argument is true, a new transcode folder is created.
* This method is called on the parent folder each time a child is added to that parent
* (via {@link addChild(DLNAResource)}.
*
* @param create
* @return the transcode virtual folder
*/
// XXX package-private: used by MapFile; should be protected?
TranscodeVirtualFolder getTranscodeFolder(boolean create) {
if (!isTranscodeFolderAvailable()) {
return null;
}
if (configuration.getHideTranscodeEnabled()) {
return null;
}
// search for transcode folder
for (DLNAResource child : children) {
if (child instanceof TranscodeVirtualFolder) {
return (TranscodeVirtualFolder) child;
}
}
if (create) {
TranscodeVirtualFolder transcodeFolder = new TranscodeVirtualFolder(null, configuration);
addChildInternal(transcodeFolder);
return transcodeFolder;
}
return null;
}
/**
* (Re)sets the given DLNA resource as follows:
* - if it's already one of our children, renew it
* - or if we have another child with the same name, replace it
* - otherwise add it as a new child.
*
* @param child the DLNA resource to update
*/
public void updateChild(DLNAResource child) {
DLNAResource found = children.contains(child) ?
child : searchByName(child.getName());
if (found != null) {
if (child != found) {
// Replace
child.parent = this;
child.setIndexId(GlobalIdRepo.parseIndex(found.getInternalId()));
children.set(children.indexOf(found), child);
}
// Renew
addChild(child, false);
} else {
// Not found, it's new
addChild(child, true);
}
}
/**
* Adds the supplied DLNA resource in the internal list of child nodes,
* and sets the parent to the current node. Avoids the side-effects
* associated with the {@link #addChild(DLNAResource)} method.
*
* @param child the DLNA resource to add to this node's list of children
*/
protected synchronized void addChildInternal(DLNAResource child) {
if (child.getInternalId() != null) {
LOGGER.debug(
"Node ({}) already has an ID ({}), which is overridden now. The previous parent node was: {}",
new Object[] {
child.getClass().getName(),
child.getResourceId(),
child.parent
}
);
}
children.add(child);
child.parent = this;
PMS.getGlobalRepo().add(child);
}
public synchronized DLNAResource getDLNAResource(String objectId, RendererConfiguration renderer) {
// this method returns exactly ONE (1) DLNAResource
// it's used when someone requests playback of media. The media must
// first have been discovered by someone first (unless it's a Temp item)
// Get/create/reconstruct it if it's a Temp item
if (objectId.contains("$Temp/")) {
return Temp.get(objectId, renderer);
}
// Now strip off the filename
objectId = StringUtils.substringBefore(objectId, "/");
DLNAResource dlna;
String[] ids = objectId.split("\\.");
if (objectId.equals("0")) {
dlna = renderer.getRootFolder();
} else {
// only allow the last one here
dlna = PMS.getGlobalRepo().get(ids[ids.length - 1]);
}
if (dlna == null) {
return null;
}
if (PMS.filter(renderer, dlna)) {
// apply filter to make sure we're not bypassing it...
LOGGER.debug("Resource " + dlna.getName() + " is filtered out for render " + renderer.getRendererName());
return null;
}
return dlna;
}
/**
* First thing it does it searches for an item matching the given objectID.
* If children is false, then it returns the found object as the only object in the list.
* TODO: (botijo) This function does a lot more than this!
*
* @param objectId ID to search for.
* @param children State if you want all the children in the returned list.
* @param start
* @param count
* @param renderer Renderer for which to do the actions.
* @return List of DLNAResource items.
* @throws IOException
*/
public synchronized List<DLNAResource> getDLNAResources(String objectId, boolean children, int start, int count, RendererConfiguration renderer) throws IOException {
return getDLNAResources(objectId, children, start, count, renderer, null);
}
public synchronized List<DLNAResource> getDLNAResources(String objectId, boolean returnChildren, int start, int count, RendererConfiguration renderer, String searchStr) {
ArrayList<DLNAResource> resources = new ArrayList<>();
// Get/create/reconstruct it if it's a Temp item
if (objectId.contains("$Temp/")) {
List<DLNAResource> items = Temp.asList(objectId);
return items != null ? items : resources;
}
// Now strip off the filename
objectId = StringUtils.substringBefore(objectId, "/");
DLNAResource dlna;
String[] ids = objectId.split("\\.");
if (objectId.equals("0")) {
dlna = renderer.getRootFolder();
} else {
dlna = PMS.getGlobalRepo().get(ids[ids.length - 1]);
}
if (dlna == null) {
// nothing in the cache do a traditional search
dlna = search(ids, renderer);
//dlna = search(objectId, count, renderer, searchStr);
}
if (dlna != null) {
if (!(dlna instanceof CodeEnter) && !isCodeValid(dlna)) {
LOGGER.debug("code is not valid any longer");
return resources;
}
String systemName = dlna.getSystemName();
dlna.setDefaultRenderer(renderer);
if (!returnChildren) {
resources.add(dlna);
dlna.refreshChildrenIfNeeded(searchStr);
} else {
dlna.discoverWithRenderer(renderer, count, true, searchStr);
if (count == 0) {
count = dlna.getChildren().size();
}
if (count > 0) {
ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(count);
int nParallelThreads = 3;
if (dlna instanceof DVDISOFile) {
nParallelThreads = 1; // Some DVD drives die with 3 parallel threads
}
ThreadPoolExecutor tpe = new ThreadPoolExecutor(
Math.min(count, nParallelThreads),
count,
20,
TimeUnit.SECONDS,
queue
);
for (int i = start; i < start + count; i++) {
if (i < dlna.getChildren().size()) {
final DLNAResource child = dlna.getChildren().get(i);
if (child != null) {
tpe.execute(child);
resources.add(child);
} else {
LOGGER.warn("null child at index {} in {}", i, systemName);
}
}
}
try {
tpe.shutdown();
tpe.awaitTermination(20, TimeUnit.SECONDS);
} catch (InterruptedException e) {
LOGGER.error("error while shutting down thread pool executor for " + systemName, e);
}
LOGGER.trace("End of analysis for " + systemName);
}
}
}
lastSearch = searchStr;
return resources;
}
protected void refreshChildrenIfNeeded(String search) {
if (isDiscovered() && shouldRefresh(search)) {
refreshChildren(search);
notifyRefresh();
}
}
/**
* Update the last refresh time.
*/
protected void notifyRefresh() {
lastRefreshTime = System.currentTimeMillis();
updateId += 1;
systemUpdateId += 1;
}
final protected void discoverWithRenderer(RendererConfiguration renderer, int count, boolean forced, String searchStr) {
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(renderer);
// Discover children if it hasn't been done already
if (!isDiscovered()) {
if (configurationSpecificToRenderer.getFolderLimit() && depthLimit()) {
if (renderer.getConfName().equalsIgnoreCase("Playstation 3") || renderer.isXbox360()) {
LOGGER.info("Depth limit potentionally hit for " + getDisplayName());
}
if (defaultRenderer != null) {
defaultRenderer.addFolderLimit(this);
}
}
discoverChildren(searchStr);
boolean ready;
if (renderer.isUseMediaInfo() && renderer.isDLNATreeHack()) {
ready = analyzeChildren(count);
} else {
ready = analyzeChildren(-1);
}
if (!renderer.isUseMediaInfo() || ready) {
setDiscovered(true);
}
notifyRefresh();
} else {
// if forced, then call the old 'refreshChildren' method
LOGGER.trace("discover {} refresh forced: {}", getResourceId(), forced);
/*if (forced && shouldRefresh(searchStr)) {
doRefreshChildren(searchStr);
notifyRefresh();
} */
if (forced) {
// This seems to follow the same code path as the else below in the case of MapFile, because
// refreshChildren calls shouldRefresh -> isRefreshNeeded -> doRefreshChildren, which is what happens below
// (refreshChildren is not overridden in MapFile)
if (refreshChildren(searchStr)) {
notifyRefresh();
}
} else {
// if not, then the regular isRefreshNeeded/doRefreshChildren pair.
if (shouldRefresh(searchStr)) {
doRefreshChildren(searchStr);
notifyRefresh();
}
}
}
}
private boolean shouldRefresh(String searchStr) {
return isRefreshNeeded();
}
@Override
public void run() {
try {
if (first == null) {
syncResolve();
if (second != null) {
second.syncResolve();
}
}
} catch (Exception e) {
LOGGER.warn("Unhandled exception while resolving {}: {}", getDisplayName(), e.getMessage());
LOGGER.debug("", e);
}
}
/**
* Recursive function that searches for a given ID.
*
* @param searchId ID to search for.
* @param count
* @param renderer
* @param searchStr
* @return Item found, or null otherwise.
* @see #getId()
*/
public DLNAResource search(String searchId, int count, RendererConfiguration renderer, String searchStr) {
if (id != null && searchId != null) {
String[] indexPath = searchId.split("\\$", 2);
if (id.equals(indexPath[0])) {
if (indexPath.length == 1 || indexPath[1].length() == 0) {
return this;
} else {
discoverWithRenderer(renderer, count, false, null);
for (DLNAResource file : children) {
DLNAResource found = file.search(indexPath[1], count, renderer, null);
if (found != null) {
// Make sure it's ready
//found.resolve();
return found;
}
}
}
} else {
return null;
}
}
return null;
}
private DLNAResource search(String[] searchIds, RendererConfiguration renderer) {
DLNAResource dlna;
for (String searchId : searchIds) {
if (searchId.equals("0")) {
dlna = renderer.getRootFolder();
} else {
dlna = PMS.getGlobalRepo().get(searchId);
}
if (dlna == null) {
LOGGER.debug("Bad id {} found in path", searchId);
return null;
}
dlna.discoverWithRenderer(renderer, 0, false, null);
}
return PMS.getGlobalRepo().get(searchIds[searchIds.length - 1]);
}
public DLNAResource search(String searchId) {
if (id != null && searchId != null) {
if (getResourceId().equals(searchId)) {
return this;
} else {
for (DLNAResource file : children) {
DLNAResource found = file.search(searchId);
if (found != null) {
// Make sure it's ready
//found.resolve();
return found;
}
}
}
}
return null;
}
/**
* TODO: (botijo) What is the intention of this function? Looks like a prototype to be overloaded.
*/
public void discoverChildren() {
}
public void discoverChildren(String str) {
discoverChildren();
}
/**
* TODO: (botijo) What is the intention of this function? Looks like a prototype to be overloaded.
*
* @param count
* @return Returns true
*/
public boolean analyzeChildren(int count) {
return true;
}
/**
* Reload the list of children.
*/
public void doRefreshChildren() {
}
public void doRefreshChildren(String search) {
doRefreshChildren();
}
/**
* @return true, if the container is changed, so refresh is needed.
* This could be called a lot of times.
*/
public boolean isRefreshNeeded() {
return false;
}
/**
* This method gets called only for the browsed folder, and not for the
* parent folders. (And in the media library scan step too). Override in
* plugins when you do not want to implement proper change tracking, and
* you do not care if the hierarchy of nodes getting invalid between.
*
* @return True when a refresh is needed, false otherwise.
*/
public boolean refreshChildren() {
if (isRefreshNeeded()) {
doRefreshChildren();
return true;
}
return false;
}
public boolean refreshChildren(String search) {
if (shouldRefresh(search)) {
doRefreshChildren(search);
return true;
}
return false;
}
/**
* @deprecated Use {@link #resolveFormat()} instead.
*/
@Deprecated
protected void checktype() {
resolveFormat();
}
/**
* Sets the resource's {@link net.pms.formats.Format} according to its filename
* if it isn't set already.
*
* @since 1.90.0
*/
protected void resolveFormat() {
if (format == null) {
format = FormatFactory.getAssociatedFormat(getSystemName());
}
if (format != null && format.isUnknown()) {
format.setType(getSpecificType());
}
}
/**
* Hook to lazily initialise immutable resources e.g. ISOs, zip files &c.
*
* @since 1.90.0
* @see #syncResolve()
*/
protected void resolveOnce() { }
/**
* Resolve events are hooks that allow DLNA resources to perform various forms
* of initialisation when navigated to or streamed i.e. they function as lazy
* constructors.
*
* This method is called by request handlers for a) requests for a stream
* or b) content directory browsing i.e. for potentially every request for a file or
* folder the renderer hasn't cached. Many resource types are immutable (e.g. playlists,
* zip files, DVD ISOs &c.) and only need to respond to this event once.
* Most resource types don't "subscribe" to this event at all. This default implementation
* provides hooks for immutable resources and handles the event for resource types that
* don't care about it. The rest override this method and handle it accordingly. Currently,
* the only resource type that overrides it is {@link RealFile}.
*
* Note: resolving a resource once (only) doesn't prevent children being added to or
* removed from it (if supported). There are other mechanisms for that e.g.
* {@link #doRefreshChildren()} (see {@link Feed} for an example).
*/
public synchronized final void syncResolve() {
resolve();
}
/**
* @deprecated Use {@link #syncResolve()} instead
*/
@Deprecated
public void resolve() {
if (!resolved) {
resolveOnce();
// if resolve() isn't overridden, this file/folder is immutable
// (or doesn't respond to resolve events, which amounts to the
// same thing), so don't spam it with this event again.
resolved = true;
}
}
// Ditlew
/**
* Returns the display name for the default renderer.
*
* @return The display name.
* @see #getDisplayName(RendererConfiguration, boolean)
*/
public String getDisplayName() {
return getDisplayName(null, true);
}
/**
* @param mediaRenderer Media Renderer for which to show information.
* @return String representing the item.
* @see #getDisplayName(RendererConfiguration, boolean)
*/
public String getDisplayName(RendererConfiguration mediaRenderer) {
return getDisplayName(mediaRenderer, true);
}
/**
* Returns the DisplayName that is shown to the Renderer.
* Extra info might be appended depending on the settings, like item duration.
* This is based on {@link #getName()}.
*
* @param mediaRenderer Media Renderer for which to show information.
* @param withSuffix Whether to include additional media info
* @return String representing the item.
*/
private String getDisplayName(RendererConfiguration mediaRenderer, boolean withSuffix) {
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(mediaRenderer);
// displayName shouldn't be cached, since device configurations may differ
// if (displayName != null) { // cached
// return withSuffix ? (displayName + nameSuffix) : displayName;
// }
// this unescape trick is to solve the problem of a name containing
// unicode stuff like \u005e
// if it's done here it will fix this for all objects
displayName = StringEscapeUtils.unescapeJava(getName());
nameSuffix = "";
String subtitleFormat;
String subtitleLanguage;
boolean isNamedNoEncoding = false;
boolean subsAreValidForStreaming = media_subtitle != null && media_subtitle.isStreamable() && mediaRenderer != null && mediaRenderer.streamSubsForTranscodedVideo();
if (this instanceof RealFile && !isFolder()) {
RealFile rf = (RealFile) this;
if (configurationSpecificToRenderer.isPrettifyFilenames() && getFormat() != null && getFormat().isVideo()) {
displayName = FileUtil.getFileNamePrettified(displayName, rf.getFile());
} else if (configurationSpecificToRenderer.isHideExtensions()) {
displayName = FileUtil.getFileNameWithoutExtension(displayName);
}
displayName = FullyPlayed.prefixDisplayName(displayName, rf, mediaRenderer);
}
if (player != null) {
if (isNoName()) {
displayName = "[" + player.name() + "]";
} else {
// Ditlew - WDTV Live don't show durations otherwise, and this is useful for finding the main title
if (mediaRenderer != null && mediaRenderer.isShowDVDTitleDuration() && media != null && media.getDvdtrack() > 0) {
nameSuffix += " - " + media.getDurationString();
}
if (!configurationSpecificToRenderer.isHideEngineNames()) {
nameSuffix += " [" + player.name() + "]";
}
}
} else {
if (isNoName()) {
displayName = Messages.getString("DLNAResource.0");
isNamedNoEncoding = true;
if (subsAreValidForStreaming) {
isNamedNoEncoding = false;
}
} else if (nametruncate > 0) {
displayName = displayName.substring(0, nametruncate).trim();
}
}
if (
hasExternalSubtitles() &&
!isNamedNoEncoding &&
media_audio == null &&
media_subtitle == null &&
!configurationSpecificToRenderer.hideSubsInfo() &&
(
player == null ||
player.isExternalSubtitlesSupported()
)
) {
nameSuffix += " " + Messages.getString("DLNAResource.1");
}
if (getMediaAudio() != null) {
String audioLanguage = "/" + getMediaAudio().getLangFullName();
if ("/Undetermined".equals(audioLanguage)) {
audioLanguage = "";
}
String audioTrackTitle = "";
if (
getMediaAudio().getAudioTrackTitleFromMetadata() != null &&
!"".equals(getMediaAudio().getAudioTrackTitleFromMetadata()) &&
mediaRenderer != null &&
mediaRenderer.isShowAudioMetadata()
) {
audioTrackTitle = " (" + getMediaAudio().getAudioTrackTitleFromMetadata() + ")";
}
displayName = player != null ? ("[" + player.name() + "]") : "";
nameSuffix = " {Audio: " + getMediaAudio().getAudioCodec() + audioLanguage + audioTrackTitle + "}";
}
if (
media_subtitle != null &&
media_subtitle.getId() != -1 &&
!configurationSpecificToRenderer.hideSubsInfo()
) {
subtitleFormat = media_subtitle.getType().getDescription();
if ("(Advanced) SubStation Alpha".equals(subtitleFormat)) {
subtitleFormat = "SSA";
} else if ("Blu-ray subtitles".equals(subtitleFormat)) {
subtitleFormat = "PGS";
}
subtitleLanguage = "/" + media_subtitle.getLangFullName();
if ("/Undetermined".equals(subtitleLanguage)) {
subtitleLanguage = "";
}
String subtitlesTrackTitle = "";
if (
media_subtitle.getSubtitlesTrackTitleFromMetadata() != null &&
!"".equals(media_subtitle.getSubtitlesTrackTitleFromMetadata()) &&
mediaRenderer != null &&
mediaRenderer.isShowSubMetadata()
) {
subtitlesTrackTitle = " (" + media_subtitle.getSubtitlesTrackTitleFromMetadata() + ")";
}
String subsDescription = Messages.getString("DLNAResource.2") + subtitleFormat + subtitleLanguage + subtitlesTrackTitle;
if (subsAreValidForStreaming) {
nameSuffix += " {" + Messages.getString("DLNAResource.3") + subsDescription + "}";
} else {
nameSuffix += " {" + subsDescription + "}";
}
}
if (isAvisynth()) {
displayName = (player != null ? ("[" + player.name()) : "") + " + AviSynth]";
}
if (getSplitRange().isEndLimitAvailable()) {
displayName = ">> " + convertTimeToString(getSplitRange().getStart(), DURATION_TIME_FORMAT);
}
return withSuffix ? (displayName + nameSuffix) : displayName;
}
/**
* Prototype for returning URLs.
*
* @return An empty URL
*/
protected String getFileURL() {
return getURL("");
}
/**
* @return Returns an URL pointing to an image representing the item. If
* none is available, "thumbnail0000.png" is used.
*/
protected String getThumbnailURL(DLNAImageProfile profile) {
StringBuilder sb = new StringBuilder(PMS.get().getServer().getURL());
sb.append("/get/").append(getResourceId()).append("/thumbnail0000");
if (profile != null) {
if (DLNAImageProfile.JPEG_RES_H_V.equals(profile)) {
sb.append("JPEG_RES").append(profile.getH()).append("x");
sb.append(profile.getV()).append("_");
} else {
sb.append(profile).append("_");
}
}
sb.append(encode(getName())).append(".");
if (profile != null) {
sb.append(profile.getDefaultExtension());
} else {
LOGGER.debug("Warning: Thumbnail without DLNA image profile requested, resulting URL is: \"{}\"", sb.toString());
}
return sb.toString();
}
/**
* @param prefix
* @return Returns a URL for a given media item. Not used for container types.
*/
public String getURL(String prefix) {
return getURL(prefix, false);
}
public String getURL(String prefix, boolean useSystemName) {
StringBuilder sb = new StringBuilder();
sb.append(PMS.get().getServer().getURL());
sb.append("/get/");
sb.append(getResourceId()); //id
sb.append('/');
sb.append(prefix);
sb.append(encode(useSystemName ? getSystemName() : getName()));
return sb.toString();
}
/**
* @param subs
* @return Returns a URL for a given subtitles item. Not used for container types.
*/
protected String getSubsURL(DLNAMediaSubtitle subs) {
StringBuilder sb = new StringBuilder();
sb.append(PMS.get().getServer().getURL());
sb.append("/get/");
sb.append(getResourceId()); //id
sb.append('/');
sb.append("subtitle0000");
sb.append(encode(subs.getExternalFile().getName()));
return sb.toString();
}
/**
* Transforms a String to URL encoded UTF-8.
*
* @param s
* @return Transformed string s in UTF-8 encoding.
*/
private static String encode(String s) {
try {
return URLEncoder.encode(s, "UTF-8");
} catch (UnsupportedEncodingException e) {
LOGGER.debug("Error while URL encoding \"{}\": {}", s, e.getMessage());
LOGGER.trace("", e);
}
return "";
}
/**
* @return Number of children objects. This might be used in the DLDI
* response, as some renderers might not have enough memory to hold the
* list for all children.
*/
public int childrenNumber() {
if (children == null) {
return 0;
}
return children.size();
}
/**
* (non-Javadoc)
*
* @see java.lang.Object#clone()
*/
@Override
protected DLNAResource clone() {
DLNAResource o = null;
try {
o = (DLNAResource) super.clone();
o.setId(null);
// Clear the cached display name and suffix
o.displayName = null;
o.nameSuffix = "";
// Make sure clones (typically #--TRANSCODE--# folder files)
// have the option to respond to resolve events
o.resolved = false;
if (media != null) {
o.media = (DLNAMediaInfo) media.clone();
}
} catch (CloneNotSupportedException e) {
LOGGER.error(null, e);
}
return o;
}
/**
* DLNA.ORG_OP flags
*
* Two booleans (binary digits) which determine what transport operations the renderer is allowed to
* perform (in the form of HTTP request headers): the first digit allows the renderer to send
* TimeSeekRange.DLNA.ORG (seek by time) headers; the second allows it to send RANGE (seek by byte)
* headers.
*
* 00 - no seeking (or even pausing) allowed
* 01 - seek by byte
* 10 - seek by time
* 11 - seek by both
*
* See here for an example of how these options can be mapped to keys on the renderer's controller:
* http://www.ps3mediaserver.org/forum/viewtopic.php?f=2&t=2908&p=12550#p12550
*
* Note that seek-by-byte is the preferred option for streamed files [1] and seek-by-time is the
* preferred option for transcoded files.
*
* [1] see http://www.ps3mediaserver.org/forum/viewtopic.php?f=6&t=15841&p=76201#p76201
*
* seek-by-time requires a) support by the renderer (via the SeekByTime renderer conf option)
* and b) support by the transcode engine.
*
* The seek-by-byte fallback doesn't work well with transcoded files [2], but it's better than
* disabling seeking (and pausing) altogether.
*
* [2] http://www.ps3mediaserver.org/forum/viewtopic.php?f=6&t=3507&p=16567#p16567 (bottom post)
*
* @param mediaRenderer
* Media Renderer for which to represent this information.
* @return String representation of the DLNA.ORG_OP flags
*/
private String getDlnaOrgOpFlags(RendererConfiguration mediaRenderer) {
String dlnaOrgOpFlags = "01"; // seek by byte (exclusive)
if (mediaRenderer.isSeekByTime() && player != null && player.isTimeSeekable()) {
/**
* Some renderers - e.g. the PS3 and Panasonic TVs - behave erratically when
* transcoding if we keep the default seek-by-byte permission on when permitting
* seek-by-time: http://www.ps3mediaserver.org/forum/viewtopic.php?f=6&t=15841
*
* It's not clear if this is a bug in the DLNA libraries of these renderers or a bug
* in UMS, but setting an option in the renderer conf that disables seek-by-byte when
* we permit seek-by-time - e.g.:
*
* SeekByTime = exclusive
*
* works around it.
*/
/**
* TODO (e.g. in a beta release): set seek-by-time (exclusive) here for *all* renderers:
* seek-by-byte isn't needed here (both the renderer and the engine support seek-by-time)
* and may be buggy on other renderers than the ones we currently handle.
*
* In the unlikely event that a renderer *requires* seek-by-both here, it can
* opt in with (e.g.):
*
* SeekByTime = both
*/
if (mediaRenderer.isSeekByTimeExclusive()) {
dlnaOrgOpFlags = "10"; // seek by time (exclusive)
} else {
dlnaOrgOpFlags = "11"; // seek by both
}
}
return "DLNA.ORG_OP=" + dlnaOrgOpFlags;
}
/**
* Creates the DLNA.ORG_PN to send.
* DLNA.ORG_PN is a string that tells the renderer what type of file to expect, like its
* container, framerate, codecs and resolution.
* Some renderers will not play a file if it has the wrong DLNA.ORG_PN string, while others
* are fine with any string or even nothing.
*
* @param mediaRenderer
* Media Renderer for which to represent this information.
* @param localizationValue
* @return String representation of the DLNA.ORG_PN flags
*/
@SuppressWarnings("deprecation")
private String getDlnaOrgPnFlags(RendererConfiguration mediaRenderer, int localizationValue) {
// Use device-specific pms conf, if any
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(mediaRenderer);
String mime = getRendererMimeType(mediaRenderer);
String dlnaOrgPnFlags = null;
if (mediaRenderer.isDLNAOrgPNUsed() || mediaRenderer.isAccurateDLNAOrgPN()) {
if (mediaRenderer.isPS3()) {
if (mime.equals(DIVX_TYPEMIME)) {
dlnaOrgPnFlags = "DLNA.ORG_PN=AVI";
} else if (mime.equals(WMV_TYPEMIME) && media != null && media.getHeight() > 700) {
dlnaOrgPnFlags = "DLNA.ORG_PN=WMVHIGH_PRO";
}
} else {
if (mime.equals(MPEG_TYPEMIME)) {
dlnaOrgPnFlags = "DLNA.ORG_PN=" + getMPEG_PS_PALLocalizedValue(localizationValue);
if (player != null) {
// VLC Web Video (Legacy) and tsMuxeR always output MPEG-TS
boolean isFileMPEGTS = TsMuxeRVideo.ID.equals(player.id()) || VideoLanVideoStreaming.ID.equals(player.id());
// Check if the renderer settings make the current engine always output MPEG-TS
if (
!isFileMPEGTS &&
mediaRenderer.isTranscodeToMPEGTS() &&
(
MEncoderVideo.ID.equals(player.id()) ||
FFMpegVideo.ID.equals(player.id()) ||
VLCVideo.ID.equals(player.id()) ||
AviSynthFFmpeg.ID.equals(player.id()) ||
AviSynthMEncoder.ID.equals(player.id())
)
) {
isFileMPEGTS = true;
}
boolean isMuxableResult = getMedia() != null && getMedia().isMuxable(mediaRenderer);
// If the engine is capable of automatically muxing to MPEG-TS and the setting is enabled, it might be MPEG-TS
if (
!isFileMPEGTS &&
(
(
configurationSpecificToRenderer.isMencoderMuxWhenCompatible() &&
MEncoderVideo.ID.equals(player.id())
) ||
(
configurationSpecificToRenderer.isFFmpegMuxWithTsMuxerWhenCompatible() &&
FFMpegVideo.ID.equals(player.id())
)
)
) {
/**
* Media renderer needs ORG_PN to be accurate.
* If the value does not match the media, it won't play the media.
* Often we can lazily predict the correct value to send, but due to
* MEncoder needing to mux via tsMuxeR, we need to work it all out
* before even sending the file list to these devices.
* This is very time-consuming so we should a) avoid using this
* chunk of code whenever possible, and b) design a better system.
* Ideally we would just mux to MPEG-PS instead of MPEG-TS so we could
* know it will always be PS, but most renderers will not accept H.264
* inside MPEG-PS. Another option may be to always produce MPEG-TS
* instead and we should check if that will be OK for all renderers.
*
* This code block comes from Player.setAudioAndSubs()
*/
if (mediaRenderer.isAccurateDLNAOrgPN()) {
boolean finishedMatchingPreferences = false;
OutputParams params = new OutputParams(configurationSpecificToRenderer);
if (params.aid == null && media != null && media.getFirstAudioTrack() != null) {
// check for preferred audio
DLNAMediaAudio dtsTrack = null;
StringTokenizer st = new StringTokenizer(configurationSpecificToRenderer.getAudioLanguages(), ",");
while (st.hasMoreTokens()) {
String lang = st.nextToken().trim();
LOGGER.trace("Looking for an audio track with lang: " + lang);
for (DLNAMediaAudio audio : media.getAudioTracksList()) {
if (audio.matchCode(lang)) {
params.aid = audio;
LOGGER.trace("Matched audio track: " + audio);
break;
}
if (dtsTrack == null && audio.isDTS()) {
dtsTrack = audio;
}
}
}
// preferred audio not found, take a default audio track, dts first if available
if (dtsTrack != null) {
params.aid = dtsTrack;
LOGGER.trace("Found priority audio track with DTS: " + dtsTrack);
} else {
params.aid = media.getAudioTracksList().get(0);
LOGGER.trace("Chose a default audio track: " + params.aid);
}
}
String currentLang = null;
DLNAMediaSubtitle matchedSub = null;
if (params.aid != null) {
currentLang = params.aid.getLang();
}
if (params.sid != null && params.sid.getId() == -1) {
LOGGER.trace("Don't want subtitles!");
params.sid = null;
media_subtitle = params.sid;
finishedMatchingPreferences = true;
}
/**
* Check for live subtitles
*/
if (!finishedMatchingPreferences && params.sid != null && !StringUtils.isEmpty(params.sid.getLiveSubURL())) {
LOGGER.debug("Live subtitles " + params.sid.getLiveSubURL());
try {
matchedSub = params.sid;
String file = OpenSubtitle.fetchSubs(matchedSub.getLiveSubURL(), matchedSub.getLiveSubFile());
if (!StringUtils.isEmpty(file)) {
matchedSub.setExternalFile(new File(file), null);
params.sid = matchedSub;
media_subtitle = params.sid;
finishedMatchingPreferences = true;
}
} catch (IOException e) {
}
}
if (!finishedMatchingPreferences) {
StringTokenizer st = new StringTokenizer(configurationSpecificToRenderer.getAudioSubLanguages(), ";");
/**
* Check for external and internal subtitles matching the user's language
* preferences
*/
boolean matchedInternalSubtitles = false;
boolean matchedExternalSubtitles = false;
while (st.hasMoreTokens()) {
String pair = st.nextToken();
if (pair.contains(",")) {
String audio = pair.substring(0, pair.indexOf(','));
String sub = pair.substring(pair.indexOf(',') + 1);
audio = audio.trim();
sub = sub.trim();
LOGGER.trace("Searching for a match for: " + currentLang + " with " + audio + " and " + sub);
if (Iso639.isCodesMatching(audio, currentLang) || (currentLang != null && audio.equals("*"))) {
if (sub.equals("off")) {
/**
* Ignore the "off" language for external subtitles if the user setting is enabled
* TODO: Prioritize multiple external subtitles properly instead of just taking the first one we load
*/
if (configurationSpecificToRenderer.isForceExternalSubtitles()) {
for (DLNAMediaSubtitle present_sub : media.getSubtitleTracksList()) {
if (present_sub.getExternalFile() != null) {
matchedSub = present_sub;
matchedExternalSubtitles = true;
LOGGER.trace("Ignoring the \"off\" language because there are external subtitles");
break;
}
}
}
if (!matchedExternalSubtitles) {
matchedSub = new DLNAMediaSubtitle();
matchedSub.setLang("off");
}
} else if (getMedia() != null) {
for (DLNAMediaSubtitle present_sub : media.getSubtitleTracksList()) {
if (present_sub.matchCode(sub) || sub.equals("*")) {
if (present_sub.getExternalFile() != null) {
if (configurationSpecificToRenderer.isAutoloadExternalSubtitles()) {
// Subtitle is external and we want external subtitles, look no further
matchedSub = present_sub;
LOGGER.trace("Matched external subtitles track: " + matchedSub);
break;
} else {
// Subtitle is external but we do not want external subtitles, keep searching
LOGGER.trace("External subtitles ignored because of user setting: " + present_sub);
}
} else if (!matchedInternalSubtitles) {
matchedSub = present_sub;
LOGGER.trace("Matched internal subtitles track: " + matchedSub);
if (configurationSpecificToRenderer.isAutoloadExternalSubtitles()) {
// Subtitle is internal and we will wait to see if an external one is available instead
matchedInternalSubtitles = true;
} else {
// Subtitle is internal and we will use it
break;
}
}
}
}
}
if (matchedSub != null && !matchedInternalSubtitles) {
break;
}
}
}
}
/**
* Check for external subtitles that were skipped in the above code block
* because they didn't match language preferences, if there wasn't already
* a match and the user settings specify it.
*/
if (matchedSub == null && configurationSpecificToRenderer.isForceExternalSubtitles()) {
for (DLNAMediaSubtitle present_sub : media.getSubtitleTracksList()) {
if (present_sub.getExternalFile() != null) {
matchedSub = present_sub;
LOGGER.trace("Matched external subtitles track that did not match language preferences: " + matchedSub);
break;
}
}
}
/**
* Disable chosen subtitles if the user has disabled all subtitles or
* if the language preferences have specified the "off" language.
*
* TODO: Can't we save a bunch of looping by checking for isDisableSubtitles
* just after the Live Subtitles check above?
*/
if (matchedSub != null && params.sid == null) {
if (configurationSpecificToRenderer.isDisableSubtitles() || (matchedSub.getLang() != null && matchedSub.getLang().equals("off"))) {
LOGGER.trace("Disabled the subtitles: " + matchedSub);
} else {
if (mediaRenderer.isExternalSubtitlesFormatSupported(matchedSub, media)) {
matchedSub.setSubsStreamable(true);
}
params.sid = matchedSub;
media_subtitle = params.sid;
}
}
/**
* Check for forced subtitles.
*/
if (!configurationSpecificToRenderer.isDisableSubtitles() && params.sid == null && media != null) {
// Check for subtitles again
File video = new File(getSystemName());
FileUtil.isSubtitlesExists(video, media, false);
if (configurationSpecificToRenderer.isAutoloadExternalSubtitles()) {
boolean forcedSubsFound = false;
// Priority to external subtitles
for (DLNAMediaSubtitle sub : media.getSubtitleTracksList()) {
if (matchedSub != null && matchedSub.getLang() != null && matchedSub.getLang().equals("off")) {
st = new StringTokenizer(configurationSpecificToRenderer.getForcedSubtitleTags(), ",");
while (sub.getSubtitlesTrackTitleFromMetadata() != null && st.hasMoreTokens()) {
String forcedTags = st.nextToken();
forcedTags = forcedTags.trim();
if (
sub.getSubtitlesTrackTitleFromMetadata().toLowerCase().contains(forcedTags) &&
Iso639.isCodesMatching(sub.getLang(), configurationSpecificToRenderer.getForcedSubtitleLanguage())
) {
LOGGER.trace("Forcing preferred subtitles: " + sub.getLang() + "/" + sub.getSubtitlesTrackTitleFromMetadata());
LOGGER.trace("Forced subtitles track: " + sub);
if (sub.getExternalFile() != null) {
LOGGER.trace("Found external forced file: " + sub.getExternalFile().getAbsolutePath());
}
if (mediaRenderer.isExternalSubtitlesFormatSupported(sub, media)) {
sub.setSubsStreamable(true);
}
params.sid = sub;
media_subtitle = params.sid;
forcedSubsFound = true;
break;
}
}
if (forcedSubsFound == true) {
break;
}
} else {
LOGGER.trace("Found subtitles track: " + sub);
if (sub.getExternalFile() != null) {
LOGGER.trace("Found external file: " + sub.getExternalFile().getAbsolutePath());
if (mediaRenderer.isExternalSubtitlesFormatSupported(sub, media)) {
sub.setSubsStreamable(true);
}
params.sid = sub;
media_subtitle = params.sid;
break;
}
}
}
}
if (
matchedSub != null &&
matchedSub.getLang() != null &&
matchedSub.getLang().equals("off")
) {
finishedMatchingPreferences = true;
}
if (!finishedMatchingPreferences && params.sid == null) {
st = new StringTokenizer(UMSUtils.getLangList(params.mediaRenderer), ",");
while (st.hasMoreTokens()) {
String lang = st.nextToken();
lang = lang.trim();
LOGGER.trace("Looking for a subtitle track with lang: " + lang);
for (DLNAMediaSubtitle sub : media.getSubtitleTracksList()) {
if (
sub.matchCode(lang) &&
!(
!configurationSpecificToRenderer.isAutoloadExternalSubtitles() &&
sub.getExternalFile() != null
)
) {
if (mediaRenderer.isExternalSubtitlesFormatSupported(sub, media)) {
sub.setSubsStreamable(true);
}
params.sid = sub;
LOGGER.trace("Matched subtitles track: " + params.sid);
break;
}
}
}
}
}
}
if (media_subtitle == null) {
LOGGER.trace("We do not want a subtitle for " + getName());
} else {
LOGGER.trace("We do want a subtitle for " + getName());
}
}
/**
* If:
* - There are no subtitles
* - This is not a DVD track
* - The media is muxable
* - The renderer accepts media muxed to MPEG-TS
* then the file is MPEG-TS
*/
if (
media_subtitle == null &&
!hasExternalSubtitles() &&
media != null &&
media.getDvdtrack() == 0 &&
isMuxableResult &&
mediaRenderer.isMuxH264MpegTS()
) {
isFileMPEGTS = true;
}
}
if (isFileMPEGTS) {
dlnaOrgPnFlags = "DLNA.ORG_PN=" + getMPEG_TS_SD_EU_ISOLocalizedValue(localizationValue);
if (
media.isH264() &&
!VideoLanVideoStreaming.ID.equals(player.id()) &&
isMuxableResult
) {
dlnaOrgPnFlags = "DLNA.ORG_PN=AVC_TS_HD_24_AC3_ISO";
if (mediaRenderer.isTranscodeToMPEGTSH264AAC()) {
dlnaOrgPnFlags = "DLNA.ORG_PN=AVC_TS_HP_HD_AAC";
}
}
}
} else if (media != null) {
if (media.isMpegTS()) {
dlnaOrgPnFlags = "DLNA.ORG_PN=" + getMPEG_TS_EULocalizedValue(localizationValue, media.isHDVideo());
if (media.isH264()) {
dlnaOrgPnFlags = "DLNA.ORG_PN=AVC_TS_HD_50_AC3";
if (mediaRenderer.isTranscodeToMPEGTSH264AAC()) {
dlnaOrgPnFlags = "DLNA.ORG_PN=AVC_TS_HP_HD_AAC";
}
}
}
}
} else if (mime.equals("video/vnd.dlna.mpeg-tts")) {
// patters - on Sony BDP m2ts clips aren't listed without this
dlnaOrgPnFlags = "DLNA.ORG_PN=" + getMPEG_TS_EULocalizedValue(localizationValue, media.isHDVideo());
} else if (mime.equals(JPEG_TYPEMIME)) {
int width = media.getWidth();
int height = media.getHeight();
if (width > 1024 || height > 768) { // 1024 * 768
dlnaOrgPnFlags = "DLNA.ORG_PN=JPEG_LRG";
} else if (width > 640 || height > 480) { // 640 * 480
dlnaOrgPnFlags = "DLNA.ORG_PN=JPEG_MED";
} else if (width > 160 || height > 160) { // 160 * 160
dlnaOrgPnFlags = "DLNA.ORG_PN=JPEG_SM";
} else {
dlnaOrgPnFlags = "DLNA.ORG_PN=JPEG_TN";
}
} else if (mime.equals(AUDIO_MP3_TYPEMIME)) {
dlnaOrgPnFlags = "DLNA.ORG_PN=MP3";
} else if (mime.substring(0, 9).equals(AUDIO_LPCM_TYPEMIME) || mime.equals(AUDIO_WAV_TYPEMIME)) {
dlnaOrgPnFlags = "DLNA.ORG_PN=LPCM";
}
}
if (dlnaOrgPnFlags != null) {
dlnaOrgPnFlags = "DLNA.ORG_PN=" + mediaRenderer.getDLNAPN(dlnaOrgPnFlags.substring(12));
}
}
return dlnaOrgPnFlags;
}
/**
* Gets the media renderer's mime type if available, returns a default mime type otherwise.
*
* @param mediaRenderer
* Media Renderer for which to represent this information.
* @return String representation of the mime type
*/
private String getRendererMimeType(RendererConfiguration mediaRenderer) {
// FIXME: There is a flaw here. In addChild(DLNAResource) the mime type
// is determined for the default renderer. This renderer may rewrite the
// mime type based on its configuration. Looking up that mime type is
// not guaranteed to return a match for another renderer.
String mime = mediaRenderer.getMimeType(mimeType(), media);
// Use our best guess if we have no valid mime type
if (mime == null || mime.contains("/transcode")) {
mime = HTTPResource.getDefaultMimeType(getType());
}
return mime;
}
/**
* @deprecated Use {@link #getDidlString(RendererConfiguration)} instead.
*
* @param mediaRenderer
* @return
*/
@Deprecated
public final String toString(RendererConfiguration mediaRenderer) {
return getDidlString(mediaRenderer);
}
/**
* Returns an XML (DIDL) representation of the DLNA node. It gives a
* complete representation of the item, with as many tags as available.
* Recommendations as per UPNP specification are followed where possible.
*
* @param mediaRenderer
* Media Renderer for which to represent this information. Useful
* for some hacks.
* @return String representing the item. An example would start like this:
* {@code <container id="0$1" childCount="1" parentID="0" restricted="1">}
*/
public final String getDidlString(RendererConfiguration mediaRenderer) {
// Use device-specific pms conf, if any
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(mediaRenderer);
StringBuilder sb = new StringBuilder();
boolean subsAreValidForStreaming = false;
boolean xbox360 = mediaRenderer.isXbox360();
// Cache this as some implementations actually call the file system
boolean isFolder = isFolder();
if (!isFolder) {
if (format != null && format.isVideo()) {
if (
!configurationSpecificToRenderer.isDisableSubtitles() &&
(player != null && mediaRenderer.streamSubsForTranscodedVideo() || player == null) &&
media_subtitle != null &&
media_subtitle.isStreamable()
) {
subsAreValidForStreaming = true;
LOGGER.trace("Setting subsAreValidForStreaming to true for " + media_subtitle.getExternalFile().getName());
} else {
LOGGER.trace("Not setting subsAreValidForStreaming and it is false for " + getName());
}
}
openTag(sb, "item");
} else {
openTag(sb, "container");
}
String id = getResourceId();
if (xbox360) {
// Ensure the xbox 360 doesn't confuse our ids with its own virtual folder ids.
id += "$";
}
addAttribute(sb, "id", id);
if (isFolder) {
if (!isDiscovered() && childrenNumber() == 0) {
// When a folder has not been scanned for resources, it will automatically have zero children.
// Some renderers like XBMC will assume a folder is empty when encountering childCount="0" and
// will not display the folder. By returning childCount="1" these renderers will still display
// the folder. When it is opened, its children will be discovered and childrenNumber() will be
// set to the right value.
addAttribute(sb, "childCount", 1);
} else {
addAttribute(sb, "childCount", childrenNumber());
}
}
id = getParentId();
if (xbox360 && getFakeParentId() == null) {
// Ensure the xbox 360 doesn't confuse our ids with its own virtual folder ids.
id += "$";
}
addAttribute(sb, "parentID", id);
addAttribute(sb, "restricted", "1");
endTag(sb);
StringBuilder wireshark = new StringBuilder();
final DLNAMediaAudio firstAudioTrack = media != null ? media.getFirstAudioTrack() : null;
/**
* Use the track title for audio files, otherwise use the filename.
*/
String title;
if (
firstAudioTrack != null &&
media.isAudio() &&
StringUtils.isNotBlank(firstAudioTrack.getSongname())
) {
title = firstAudioTrack.getSongname();
} else { // Ditlew - org
title = (isFolder || subsAreValidForStreaming) ? getDisplayName(null, false) : mediaRenderer.getUseSameExtension(getDisplayName(mediaRenderer, false));
}
title = resumeStr(title);
addXMLTagAndAttribute(
sb,
"dc:title",
encodeXML(mediaRenderer.getDcTitle(title, nameSuffix, this))
);
wireshark.append("\"").append(title).append("\"");
if (firstAudioTrack != null) {
if (StringUtils.isNotBlank(firstAudioTrack.getAlbum())) {
addXMLTagAndAttribute(sb, "upnp:album", encodeXML(firstAudioTrack.getAlbum()));
}
if (StringUtils.isNotBlank(firstAudioTrack.getArtist())) {
addXMLTagAndAttribute(sb, "upnp:artist", encodeXML(firstAudioTrack.getArtist()));
addXMLTagAndAttribute(sb, "dc:creator", encodeXML(firstAudioTrack.getArtist()));
}
if (StringUtils.isNotBlank(firstAudioTrack.getGenre())) {
addXMLTagAndAttribute(sb, "upnp:genre", encodeXML(firstAudioTrack.getGenre()));
}
if (firstAudioTrack.getTrack() > 0) {
addXMLTagAndAttribute(sb, "upnp:originalTrackNumber", "" + firstAudioTrack.getTrack());
}
}
MediaType mediaType = media != null ? media.getMediaType() : MediaType.UNKNOWN;
if (!isFolder && mediaType == MediaType.IMAGE) {
appendImage(sb, mediaRenderer);
} else if (!isFolder) {
int indexCount = 1;
if (mediaRenderer.isDLNALocalizationRequired()) {
indexCount = getDLNALocalesCount();
}
for (int c = 0; c < indexCount; c++) {
openTag(sb, "res");
addAttribute(sb, "xmlns:dlna", "urn:schemas-dlna-org:metadata-1-0/");
String dlnaOrgPnFlags = getDlnaOrgPnFlags(mediaRenderer, c);
String tempString = "http-get:*:" + getRendererMimeType(mediaRenderer) + ":" + (dlnaOrgPnFlags != null ? (dlnaOrgPnFlags + ";") : "") + getDlnaOrgOpFlags(mediaRenderer);
wireshark.append(' ').append(tempString);
addAttribute(sb, "protocolInfo", tempString);
if (subsAreValidForStreaming && mediaRenderer.offerSubtitlesByProtocolInfo() && !mediaRenderer.useClosedCaption()) {
addAttribute(sb, "pv:subtitleFileType", media_subtitle.getType().getExtension().toUpperCase());
wireshark.append(" pv:subtitleFileType=").append(media_subtitle.getType().getExtension().toUpperCase());
addAttribute(sb, "pv:subtitleFileUri", getSubsURL(media_subtitle));
wireshark.append(" pv:subtitleFileUri=").append(getSubsURL(media_subtitle));
}
if (getFormat() != null && getFormat().isVideo() && media != null && media.isMediaparsed()) {
if (player == null) {
wireshark.append(" size=").append(media.getSize());
addAttribute(sb, "size", media.getSize());
} else {
long transcoded_size = mediaRenderer.getTranscodedSize();
if (transcoded_size != 0) {
wireshark.append(" size=").append(transcoded_size);
addAttribute(sb, "size", transcoded_size);
}
}
if (media.getDuration() != null) {
if (getSplitRange().isEndLimitAvailable()) {
wireshark.append(" duration=").append(convertTimeToString(getSplitRange().getDuration(), DURATION_TIME_FORMAT));
addAttribute(sb, "duration", convertTimeToString(getSplitRange().getDuration(), DURATION_TIME_FORMAT));
} else {
wireshark.append(" duration=").append(media.getDurationString());
addAttribute(sb, "duration", media.getDurationString());
}
}
if (media.getResolution() != null) {
if (player != null && mediaRenderer.isKeepAspectRatio()) {
addAttribute(sb, "resolution", getResolutionForKeepAR(media.getWidth(), media.getHeight()));
} else {
addAttribute(sb, "resolution", media.getResolution());
}
}
addAttribute(sb, "bitrate", media.getRealVideoBitrate());
if (firstAudioTrack != null) {
if (firstAudioTrack.getAudioProperties().getNumberOfChannels() > 0) {
addAttribute(sb, "nrAudioChannels", firstAudioTrack.getAudioProperties().getNumberOfChannels());
}
if (firstAudioTrack.getSampleFrequency() != null) {
addAttribute(sb, "sampleFrequency", firstAudioTrack.getSampleFrequency());
}
}
} else if (getFormat() != null && getFormat().isImage()) {
if (media != null && media.isMediaparsed()) {
wireshark.append(" size=").append(media.getSize());
addAttribute(sb, "size", media.getSize());
if (media.getResolution() != null) {
addAttribute(sb, "resolution", media.getResolution());
}
} else {
wireshark.append(" size=").append(length());
addAttribute(sb, "size", length());
}
} else if (getFormat() != null && getFormat().isAudio()) {
if (media != null && media.isMediaparsed()) {
if (media.getBitrate() > 0) {
addAttribute(sb, "bitrate", media.getBitrate());
}
if (media.getDuration() != null && media.getDuration().doubleValue() != 0.0) {
wireshark.append(" duration=").append(convertTimeToString(media.getDuration(), DURATION_TIME_FORMAT));
addAttribute(sb, "duration", convertTimeToString(media.getDuration(), DURATION_TIME_FORMAT));
}
int transcodeFrequency = -1;
int transcodeNumberOfChannels = -1;
if (firstAudioTrack != null) {
if (player == null) {
if (firstAudioTrack.getSampleFrequency() != null) {
addAttribute(sb, "sampleFrequency", firstAudioTrack.getSampleFrequency());
}
if (firstAudioTrack.getAudioProperties().getNumberOfChannels() > 0) {
addAttribute(sb, "nrAudioChannels", firstAudioTrack.getAudioProperties().getNumberOfChannels());
}
} else {
if (configurationSpecificToRenderer.isAudioResample()) {
transcodeFrequency = mediaRenderer.isTranscodeAudioTo441() ? 44100 : 48000;
transcodeNumberOfChannels = 2;
} else {
transcodeFrequency = firstAudioTrack.getSampleRate();
transcodeNumberOfChannels = firstAudioTrack.getAudioProperties().getNumberOfChannels();
}
if (transcodeFrequency > 0) {
addAttribute(sb, "sampleFrequency", transcodeFrequency);
}
if (transcodeNumberOfChannels > 0) {
addAttribute(sb, "nrAudioChannels", transcodeNumberOfChannels);
}
}
}
if (player == null) {
if (media.getSize() != 0) {
wireshark.append(" size=").append(media.getSize());
addAttribute(sb, "size", media.getSize());
}
} else {
// Calculate WAV size
if (
firstAudioTrack != null &&
media.getDurationInSeconds() > 0.0 &&
transcodeFrequency > 0 &&
transcodeNumberOfChannels > 0
) {
int finalSize = (int) (media.getDurationInSeconds() * transcodeFrequency * 2 * transcodeNumberOfChannels);
LOGGER.trace("Calculated transcoded size for {}: {}", getSystemName(), finalSize);
wireshark.append(" size=").append(finalSize);
addAttribute(sb, "size", finalSize);
} else if (media.getSize() > 0){
LOGGER.trace("Could not calculate transcoded size for {}, using file size: {}", getSystemName(), media.getSize());
wireshark.append(" size=").append(media.getSize());
addAttribute(sb, "size", media.getSize());
}
}
} else {
wireshark.append(" size=").append(length());
addAttribute(sb, "size", length());
}
} else {
wireshark.append(" size=").append(DLNAMediaInfo.TRANS_SIZE).append(" duration=09:59:59");
addAttribute(sb, "size", DLNAMediaInfo.TRANS_SIZE);
addAttribute(sb, "duration", "09:59:59");
addAttribute(sb, "bitrate", "1000000");
}
endTag(sb);
// Add transcoded format extension to the output stream URL.
String transcodedExtension = "";
if (player != null && media != null) {
// Note: Can't use instanceof below because the audio classes inherit the corresponding video class
if (media.isVideo()) {
if (mediaRenderer.isTranscodeToMPEGTS()) {
transcodedExtension = "_transcoded_to.ts";
} else if (mediaRenderer.isTranscodeToWMV() && !xbox360) {
transcodedExtension = "_transcoded_to.wmv";
} else {
transcodedExtension = "_transcoded_to.mpg";
}
} else if (media.isAudio()) {
if (mediaRenderer.isTranscodeToMP3()) {
transcodedExtension = "_transcoded_to.mp3";
} else if (mediaRenderer.isTranscodeToWAV()) {
transcodedExtension = "_transcoded_to.wav";
} else {
transcodedExtension = "_transcoded_to.pcm";
}
}
}
wireshark.append(' ').append(getFileURL()).append(transcodedExtension);
sb.append(getFileURL()).append(transcodedExtension);
LOGGER.trace("Network debugger: " + wireshark.toString());
wireshark.setLength(0);
closeTag(sb, "res");
}
}
if (subsAreValidForStreaming) {
String subsURL = getSubsURL(media_subtitle);
if (mediaRenderer.useClosedCaption()) {
openTag(sb, "sec:CaptionInfoEx");
addAttribute(sb, "sec:type", "srt");
endTag(sb);
sb.append(subsURL);
closeTag(sb, "sec:CaptionInfoEx");
LOGGER.trace("Network debugger: sec:CaptionInfoEx: sec:type=srt " + subsURL);
} else if (mediaRenderer.offerSubtitlesAsResource()){
openTag(sb, "res");
String subtitlesFormat = media_subtitle.getType().getExtension();
if (StringUtils.isBlank(subtitlesFormat)) {
subtitlesFormat = "plain";
}
addAttribute(sb, "protocolInfo", "http-get:*:text/" + subtitlesFormat + ":*");
endTag(sb);
sb.append(subsURL);
closeTag(sb, "res");
LOGGER.trace("Network debugger: http-get:*:text/" + subtitlesFormat + ":*" + subsURL);
}
}
if (!(isFolder && !mediaRenderer.isSendFolderThumbnails())) {
if (mediaType != MediaType.IMAGE) {
appendThumbnail(sb, mediaType, mediaRenderer);
}
}
if (getLastModified() > 0 && mediaRenderer.isSendDateMetadata()) {
addXMLTagAndAttribute(sb, "dc:date", SDF_DATE.format(new Date(getLastModified())));
}
String uclass;
if (first != null && media != null && !media.isSecondaryFormatValid()) {
uclass = "dummy";
} else {
if (isFolder) {
uclass = "object.container.storageFolder";
if (xbox360 && getFakeParentId() != null) {
switch (getFakeParentId()) {
case "7":
uclass = "object.container.album.musicAlbum";
break;
case "6":
uclass = "object.container.person.musicArtist";
break;
case "5":
uclass = "object.container.genre.musicGenre";
break;
case "F":
uclass = "object.container.playlistContainer";
break;
}
}
} else if (mediaType == MediaType.IMAGE) {
uclass = "object.item.imageItem.photo";
} else if (mediaType == MediaType.AUDIO) {
uclass = "object.item.audioItem.musicTrack";
} else {
uclass = "object.item.videoItem";
}
}
addXMLTagAndAttribute(sb, "upnp:class", uclass);
if (isFolder) {
closeTag(sb, "container");
} else {
closeTag(sb, "item");
}
return sb.toString();
}
/**
* Generate and append image and thumbnail {@code res} and
* {@code upnp:albumArtURI} entries for the image.
*
* @param sb The {@link StringBuilder} to append the elements to.
* @param renderer the {@link Renderer} used for filtering or {@code null}
* for no filtering.
*/
@SuppressFBWarnings("SF_SWITCH_NO_DEFAULT")
private void appendImage(StringBuilder sb, Renderer renderer) {
/*
* There's no technical difference between the image itself and the
* thumbnail for an object.item.imageItem, they are all simply listed
* as <res> entries. To UMS there is a difference since the thumbnail
* is cached while the image itself is not. The idea here is therefore
* to offer any size smaller than or equal to the cached thumbnail
* using the cached thumbnail as the source, and offer anything bigger
* using the image itself as the source.
*
* If the thumbnail isn't parsed
* yet, we don't know the size of the thumbnail. In those situations
* we simply use the thumbnail for the _TN entries and the image for
* all others.
*/
ImageInfo imageInfo = media.getImageInfo();
ImageInfo thumbnailImageInfo = this.thumbnailImageInfo != null ? this.thumbnailImageInfo :
getMedia() != null && getMedia().getThumb() != null && getMedia().getThumb().getImageInfo() != null ?
getMedia().getThumb().getImageInfo() : null;
// Only include GIF elements if the source is a GIF and it's supported by the renderer.
boolean includeGIF =
imageInfo != null &&
imageInfo.getFormat() == ImageFormat.GIF &&
DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.GIF_LRG, renderer);
// Add elements in any order, it's sorted by priority later
List<DLNAImageResElement> resElements = new ArrayList<>();
// Always offer JPEG_TN as per DLNA standard
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_TN, thumbnailImageInfo != null ? thumbnailImageInfo : imageInfo, true));
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.PNG_TN, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.PNG_TN, thumbnailImageInfo != null ? thumbnailImageInfo : imageInfo, true));
}
if (imageInfo != null) {
if (
DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_RES_H_V, renderer) &&
imageInfo.getWidth() > 0 &&
imageInfo.getHeight() > 0
) {
// Offer the exact resolution as JPEG_RES_H_V
DLNAImageProfile exactResolution =
DLNAImageProfile.createJPEG_RES_H_V(imageInfo.getWidth(), imageInfo.getHeight());
resElements.add(new DLNAImageResElement(
exactResolution, imageInfo,
exactResolution.useThumbnailSource(imageInfo, thumbnailImageInfo)
));
}
// Always offer JPEG_SM for images as per DLNA standard
resElements.add(new DLNAImageResElement(
DLNAImageProfile.JPEG_SM, imageInfo,
DLNAImageProfile.JPEG_SM.useThumbnailSource(imageInfo, thumbnailImageInfo)
));
if (!DLNAImageProfile.PNG_TN.isResolutionCorrect(imageInfo)) {
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.PNG_LRG, renderer)) {
resElements.add(new DLNAImageResElement(
DLNAImageProfile.PNG_LRG, imageInfo,
DLNAImageProfile.PNG_LRG.useThumbnailSource(imageInfo, thumbnailImageInfo)
));
}
if (includeGIF) {
resElements.add(new DLNAImageResElement(
DLNAImageProfile.GIF_LRG, imageInfo,
DLNAImageProfile.GIF_LRG.useThumbnailSource(imageInfo, thumbnailImageInfo)
));
}
if (!DLNAImageProfile.JPEG_SM.isResolutionCorrect(imageInfo)) {
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_MED, renderer)) {
resElements.add(new DLNAImageResElement(
DLNAImageProfile.JPEG_MED, imageInfo,
DLNAImageProfile.JPEG_MED.useThumbnailSource(imageInfo, thumbnailImageInfo)
));
}
if (!DLNAImageProfile.JPEG_MED.isResolutionCorrect(imageInfo)) {
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.PNG_LRG, renderer)) {
resElements.add(new DLNAImageResElement(
DLNAImageProfile.JPEG_LRG, imageInfo,
DLNAImageProfile.JPEG_LRG.useThumbnailSource(imageInfo, thumbnailImageInfo)
));
}
}
}
}
} else {
// This shouldn't normally be the case, parsing must have failed or
// isn't finished yet so we just make a generic offer.
// Always offer JPEG_SM for images as per DLNA standard
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_SM, null, false));
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_LRG, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_LRG, null, false));
}
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.PNG_LRG, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.PNG_LRG, null, false));
}
LOGGER.debug("Warning: Image \"{}\" isn't parsed when DIDL-Lite is generated", this.getName());
}
// Sort the elements by priority
Collections.sort(resElements, DLNAImageResElement.getComparator(imageInfo != null ? imageInfo.getFormat() : ImageFormat.JPEG));
for (DLNAImageResElement resElement : resElements) {
addImageResource(sb, resElement);
}
for (DLNAImageResElement resElement : resElements) {
// Offering AlbumArt here breaks the standard, but some renderers need it
switch (resElement.getProfile().toInt()) {
case DLNAImageProfile.GIF_LRG_INT:
case DLNAImageProfile.JPEG_SM_INT:
case DLNAImageProfile.JPEG_TN_INT:
case DLNAImageProfile.PNG_LRG_INT:
case DLNAImageProfile.PNG_TN_INT:
addAlbumArt(sb, resElement.getProfile());
}
}
}
/**
* Generate and append the thumbnail {@code res} and
* {@code upnp:albumArtURI} entries for the thumbnail.
*
* @param sb the {@link StringBuilder} to append the response to.
* @param mediaType the {@link MediaType} of this {@link DLNAResource}.
* @param renderer the {@link Renderer} used for filtering or {@code null}
* for no filtering.
*/
@SuppressFBWarnings("SF_SWITCH_NO_DEFAULT")
private void appendThumbnail(StringBuilder sb, MediaType mediaType, Renderer renderer) {
/*
* JPEG_TN = Max 160 x 160; EXIF Ver.1.x or later or JFIF 1.02; SRGB or uncalibrated
* JPEG_SM = Max 640 x 480; EXIF Ver.1.x or later or JFIF 1.02; SRGB or uncalibrated
* PNG_TN = Max 160 x 160; Greyscale 8/16 bit, Truecolor 24 bit, Indexed-color 24 bit, Greyscale with alpha 8/16 bit or Truecolor with alpha 24 bit;
* PNG_SM doesn't exist!
*
* The standard dictates that thumbnails for images and videos should
* be given as a <res> element:
* > If a UPnP AV MediaServer exposes a CDS object with a <upnp:class>
* > designation of object.item.imageItem or object.item.videoItem (or
* > any class derived from them), then the UPnP AV MediaServer should
* > provide a <res> element for the thumbnail resource. (Multiple
* > thumbnail <res> elements are also allowed.)
*
* It also dictates that if a <res> thumbnail is available, it HAS to
* be offered as JPEG_TN (although not exclusively):
* > If a UPnP AV MediaServer exposes thumbnail images for image or video
* > content, then a UPnP AV MediaServer shall provide a thumbnail that
* > conforms to guideline 7.1.7 (GUN 6SXDY) in IEC 62481-2:2013 media
* > format profile and be declared with the JPEG_TN designation in the
* > fourth field of the res@protocolInfo attribute.
* >
* > When thumbnails are provided, the minimal expectation is to provide
* > JPEG thumbnails. However, vendors can also provide additional
* > thumbnails using the JPEG_TN or PNG_TN profiles.
*
* For videos content additional related images can be offered:
* > UPnP AV MediaServers that expose a video item can include in the
* > <item> element zero or more <res> elements referencing companion
* > images that provide additional descriptive information. Examples
* > of companion images include larger versions of thumbnails, posters
* > describing a movie, and others.
*
* For audio content, and ONLY for audio content, >upnp:albumArtURI>
* should be used:
* > If a UPnP AV MediaServer exposes a CDS object with a <upnp:class>
* > designation of object.item.audioItem or object.container.album.musicAlbum
* > (or any class derived from either class), then the UPnP AV MediaServer
* > should provide a <upnp:albumArtURI> element to present the URI for
* > the album art
* >
* > Unlike image or video content, thumbnails for audio content will
* > preferably be presented through the <upnp:albumArtURI> element.
*
* There's a difference between a thumbnail and album art. A thumbnail
* is a miniature still image of visual content, since audio isn't
* visual the concept is invalid. Album art is an image "tied to" that
* audio, but doesn't represent the audio itself.
*
* The same requirement of always providing a JPEG_TN applies to
* <upnp:albumArtURI> although formulated somewhat vaguer:
* > If album art thumbnails are provided, the desired expectation is
* > to have JPEG thumbnails. Additional thumbnails can also be provided.
*/
// Images add thumbnail resources together with the image resources in appendImage()
if (MediaType.IMAGE != mediaType) {
ImageInfo imageInfo = thumbnailImageInfo != null ? thumbnailImageInfo :
getMedia() != null && getMedia().getThumb() != null && getMedia().getThumb().getImageInfo() != null ?
getMedia().getThumb().getImageInfo() : null;
// Only include GIF elements if the source is a GIF and it's supported by the renderer.
boolean includeGIF =
imageInfo != null &&
imageInfo.getFormat() == ImageFormat.GIF &&
DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.GIF_LRG, renderer);
// Add elements in any order, it's sorted by priority later
List<DLNAImageResElement> resElements = new ArrayList<>();
// Always include JPEG_TN as per DLNA standard
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_TN, imageInfo, true));
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_SM, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_SM, imageInfo, true));
}
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.PNG_TN, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.PNG_TN, imageInfo, true));
}
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.PNG_LRG, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.PNG_LRG, imageInfo, true));
}
if (imageInfo != null) {
if (
DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_RES_H_V, renderer) &&
imageInfo.getWidth() > 0 &&
imageInfo.getHeight() > 0
) {
// Offer the exact resolution as JPEG_RES_H_V
DLNAImageProfile exactResolution =
DLNAImageProfile.createJPEG_RES_H_V(imageInfo.getWidth(), imageInfo.getHeight());
resElements.add(new DLNAImageResElement(exactResolution, imageInfo, true));
}
if (includeGIF) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.GIF_LRG, imageInfo, true));
}
if (!DLNAImageProfile.JPEG_SM.isResolutionCorrect(imageInfo)) {
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_MED, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_MED, imageInfo, true));
}
if (!DLNAImageProfile.JPEG_MED.isResolutionCorrect(imageInfo)) {
if (DLNAImageResElement.isImageProfileSupported(DLNAImageProfile.JPEG_LRG, renderer)) {
resElements.add(new DLNAImageResElement(DLNAImageProfile.JPEG_LRG, imageInfo, true));
}
}
}
}
// Sort the elements by priority
Collections.sort(resElements, DLNAImageResElement.getComparator(imageInfo != null ? imageInfo.getFormat() : ImageFormat.JPEG));
for (DLNAImageResElement resElement : resElements) {
addImageResource(sb, resElement);
}
for (DLNAImageResElement resElement : resElements) {
// Offering AlbumArt for video breaks the standard, but some renderers need it
switch (resElement.getProfile().toInt()) {
case DLNAImageProfile.GIF_LRG_INT:
case DLNAImageProfile.JPEG_SM_INT:
case DLNAImageProfile.JPEG_TN_INT:
case DLNAImageProfile.PNG_LRG_INT:
case DLNAImageProfile.PNG_TN_INT:
addAlbumArt(sb, resElement.getProfile());
}
}
}
}
private void addImageResource(StringBuilder sb, DLNAImageResElement resElement) {
if (resElement == null) {
throw new NullPointerException("resElement cannot be null");
}
if (!resElement.isResolutionKnown() && DLNAImageProfile.JPEG_RES_H_V.equals(resElement.getProfile())) {
throw new IllegalArgumentException("Resolution cannot be unknown for DLNAImageProfile.JPEG_RES_H_V");
}
String url;
if (resElement.isThumbnail()) {
url = getThumbnailURL(resElement.getProfile());
} else {
url = getURL(
(DLNAImageProfile.JPEG_RES_H_V.equals(resElement.getProfile()) ?
"JPEG_RES" + resElement.getWidth() + "x" + resElement.getHeight() :
resElement.getProfile().toString()
) + "_"
);
}
if (StringUtils.isNotBlank(url)) {
String ciFlag;
/*
* Some Panasonic TV's can't handle if the thumbnails have the CI
* flag set to 0 while the main resource doesn't have a CI flag.
* DLNA dictates that a missing CI flag should be interpreted as
* if it were 0, so the result should be the same.
*/
if (resElement.getCiFlag() == null || resElement.getCiFlag() == 0) {
ciFlag = "";
} else {
ciFlag = ";DLNA.ORG_CI=" + resElement.getCiFlag().toString();
}
openTag(sb, "res");
if (resElement.getSize() != null && resElement.getSize() > 0) {
addAttribute(sb, "size", resElement.getSize());
}
if (resElement.isResolutionKnown()) {
addAttribute(sb, "resolution", Integer.toString(resElement.getWidth()) + "x" + Integer.toString(resElement.getHeight()));
}
addAttribute(sb,
"protocolInfo",
"http-get:*:" + resElement.getProfile().getMimeType() + ":DLNA.ORG_PN=" +
resElement.getProfile() +
ciFlag + ";DLNA.ORG_FLAGS=00900000000000000000000000000000"
);
addAttribute(sb, "xmlns:dlna", "urn:schemas-dlna-org:metadata-1-0/");
endTag(sb);
sb.append(url);
closeTag(sb, "res");
}
}
private void addAlbumArt(StringBuilder sb, DLNAImageProfile thumbnailProfile) {
String albumArtURL = getThumbnailURL(thumbnailProfile);
if (StringUtils.isNotBlank(albumArtURL)) {
openTag(sb, "upnp:albumArtURI");
addAttribute(sb, "xmlns:dlna", "urn:schemas-dlna-org:metadata-1-0/");
addAttribute(sb, "dlna:profileID", thumbnailProfile);
endTag(sb);
sb.append(albumArtURL);
closeTag(sb, "upnp:albumArtURI");
}
}
private String getRequestId(String rendererId) {
return String.format("%s|%x|%s", rendererId, hashCode(), getSystemName());
}
/**
* Plugin implementation. When this item is going to play, it will notify all the
* StartStopListener objects available.
*
* @param rendererId
* @param incomingRenderer
*
* @see StartStopListener
*/
public void startPlaying(final String rendererId, final RendererConfiguration incomingRenderer) {
final String requestId = getRequestId(rendererId);
synchronized (requestIdToRefcount) {
Integer temp = requestIdToRefcount.get(requestId);
if (temp == null) {
temp = 0;
}
final Integer refCount = temp;
requestIdToRefcount.put(requestId, refCount + 1);
if (refCount == 0) {
final DLNAResource self = this;
Runnable r = new Runnable() {
@Override
public void run() {
InetAddress rendererIp;
try {
rendererIp = InetAddress.getByName(rendererId);
RendererConfiguration renderer;
if (incomingRenderer == null) {
renderer = RendererConfiguration.getRendererConfigurationBySocketAddress(rendererIp);
} else {
renderer = incomingRenderer;
}
String rendererName = "unknown renderer";
try {
renderer.setPlayingRes(self);
rendererName = renderer.getRendererName().replaceAll("\n", "");
} catch (NullPointerException e) { }
if (!quietPlay()) {
LOGGER.info("Started playing " + getName() + " on your " + rendererName);
LOGGER.debug("The full filename of which is: " + getSystemName() + " and the address of the renderer is: " + rendererId);
}
} catch (UnknownHostException ex) {
LOGGER.debug("" + ex);
}
startTime = System.currentTimeMillis();
for (final ExternalListener listener : ExternalFactory.getExternalListeners()) {
if (listener instanceof StartStopListener) {
// run these asynchronously for slow handlers (e.g. logging, scrobbling)
Runnable fireStartStopEvent = new Runnable() {
@Override
public void run() {
try {
((StartStopListener) listener).nowPlaying(media, self);
} catch (Throwable t) {
LOGGER.error("Notification of startPlaying event failed for StartStopListener {}", listener.getClass(), t);
}
}
};
new Thread(fireStartStopEvent, "StartPlaying Event for " + listener.name()).start();
}
}
}
};
new Thread(r, "StartPlaying Event").start();
}
}
}
/**
* Plugin implementation. When this item is going to stop playing, it will notify all the StartStopListener
* objects available.
*
* @see StartStopListener
*/
public void stopPlaying(final String rendererId, final RendererConfiguration incomingRenderer) {
final DLNAResource self = this;
final String requestId = getRequestId(rendererId);
Runnable defer = new Runnable() {
@Override
public void run() {
long start = startTime;
try {
Thread.sleep(STOP_PLAYING_DELAY);
} catch (InterruptedException e) {
LOGGER.error("stopPlaying sleep interrupted", e);
}
synchronized (requestIdToRefcount) {
final Integer refCount = requestIdToRefcount.get(requestId);
assert refCount != null;
assert refCount > 0;
requestIdToRefcount.put(requestId, refCount - 1);
if (start != startTime) {
return;
}
Runnable r = new Runnable() {
@Override
public void run() {
if (refCount == 1) {
requestIdToRefcount.put(requestId, 0);
InetAddress rendererIp;
try {
rendererIp = InetAddress.getByName(rendererId);
RendererConfiguration renderer;
if (incomingRenderer == null) {
renderer = RendererConfiguration.getRendererConfigurationBySocketAddress(rendererIp);
} else {
renderer = incomingRenderer;
}
String rendererName = "unknown renderer";
try {
// Reset only if another item hasn't already begun playing
if (renderer.getPlayingRes() == self) {
renderer.setPlayingRes(null);
}
rendererName = renderer.getRendererName();
} catch (NullPointerException e) { }
if (!quietPlay()) {
LOGGER.info("Stopped playing " + getName() + " on your " + rendererName);
LOGGER.debug("The full filename of which is: " + getSystemName() + " and the address of the renderer is: " + rendererId);
}
} catch (UnknownHostException ex) {
LOGGER.debug("" + ex);
}
internalStop();
for (final ExternalListener listener : ExternalFactory.getExternalListeners()) {
if (listener instanceof StartStopListener) {
// run these asynchronously for slow handlers (e.g. logging, scrobbling)
Runnable fireStartStopEvent = new Runnable() {
@Override
public void run() {
try {
((StartStopListener) listener).donePlaying(media, self);
} catch (Throwable t) {
LOGGER.error("Notification of donePlaying event failed for StartStopListener {}", listener.getClass(), t);
}
}
};
new Thread(fireStartStopEvent, "StopPlaying Event for " + listener.name()).start();
}
}
}
}
};
new Thread(r, "StopPlaying Event").start();
}
}
};
new Thread(defer, "StopPlaying Event Deferrer").start();
}
/**
* The system time when the resource was last (re)started.
*/
private long lastStartSystemTime;
/**
* Gets the system time when the resource was last (re)started.
*
* @return The system time when the resource was last (re)started
*/
public double getLastStartSystemTime() {
return lastStartSystemTime;
}
/**
* Sets the system time when the resource was last (re)started.
*
* @param startTime the system time to set
*/
public void setLastStartSystemTime(long startTime) {
lastStartSystemTime = startTime;
}
/**
* The most recently requested time offset in seconds.
*/
private double lastStartPosition;
/**
* Gets the most recently requested time offset in seconds.
*
* @return The most recently requested time offset in seconds
*/
public double getLastStartPosition() {
return lastStartPosition;
}
/**
* Sets the most recently requested time offset in seconds.
*
* @param startPosition the time offset in seconds
*/
public void setLastStartPosition(long startPosition) {
lastStartPosition = startPosition;
}
/**
* Returns an InputStream of this DLNAResource that starts at a given time, if possible. Very useful if video chapters are being used.
*
* @param range
* @param mediarenderer
* @return The inputstream
* @throws IOException
*/
public synchronized InputStream getInputStream(Range range, RendererConfiguration mediarenderer) throws IOException {
// Use device-specific pms conf, if any
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(mediarenderer);
LOGGER.trace("Asked stream chunk : " + range + " of " + getName() + " and player " + player);
// shagrath: small fix, regression on chapters
boolean timeseek_auto = false;
// Ditlew - WDTV Live
// Ditlew - We convert byteoffset to timeoffset here. This needs the stream to be CBR!
int cbr_video_bitrate = mediarenderer.getCBRVideoBitrate();
long low = range.isByteRange() && range.isStartOffsetAvailable() ? range.asByteRange().getStart() : 0;
long high = range.isByteRange() && range.isEndLimitAvailable() ? range.asByteRange().getEnd() : -1;
Range.Time timeRange = range.createTimeRange();
if (player != null && low > 0 && cbr_video_bitrate > 0) {
int used_bit_rated = (int) ((cbr_video_bitrate + 256) * 1024 / (double) 8 * 1.04); // 1.04 = container overhead
if (low > used_bit_rated) {
timeRange.setStart(low / (double) (used_bit_rated));
low = 0;
// WDTV Live - if set to TS it asks multiple times and ends by
// asking for an invalid offset which kills MEncoder
if (timeRange.getStartOrZero() > media.getDurationInSeconds()) {
return null;
}
// Should we rewind a little (in case our overhead isn't accurate enough)
int rewind_secs = mediarenderer.getByteToTimeseekRewindSeconds();
timeRange.rewindStart(rewind_secs);
// shagrath:
timeseek_auto = true;
}
}
if (low > 0 && media.getBitrate() > 0) {
lastStartPosition = (low * 8) / media.getBitrate();
LOGGER.trace("Estimating seek position from byte range:");
LOGGER.trace(" media.getBitrate: " + media.getBitrate());
LOGGER.trace(" low: " + low);
LOGGER.trace(" lastStartPosition: " + lastStartPosition);
} else {
lastStartPosition = timeRange.getStartOrZero();
LOGGER.trace("Setting lastStartPosition from time-seeking: " + lastStartPosition);
}
// Determine source of the stream
if (player == null && !isResume()) {
// No transcoding
if (this instanceof IPushOutput) {
PipedOutputStream out = new PipedOutputStream();
InputStream fis = new PipedInputStream(out);
((IPushOutput) this).push(out);
if (low > 0) {
fis.skip(low);
}
// http://www.ps3mediaserver.org/forum/viewtopic.php?f=11&t=12035
lastStartSystemTime = System.currentTimeMillis();
return wrap(fis, high, low);
}
InputStream fis = getInputStream();
if (fis != null) {
if (low > 0) {
fis.skip(low);
}
// http://www.ps3mediaserver.org/forum/viewtopic.php?f=11&t=12035
fis = wrap(fis, high, low);
if (timeRange.getStartOrZero() > 0 && this instanceof RealFile) {
fis.skip(MpegUtil.getPositionForTimeInMpeg(((RealFile) this).getFile(), (int) timeRange.getStartOrZero()));
}
}
lastStartSystemTime = System.currentTimeMillis();
return fis;
} else {
// Pipe transcoding result
OutputParams params = new OutputParams(configurationSpecificToRenderer);
params.aid = getMediaAudio();
params.sid = media_subtitle;
params.header = getHeaders();
params.mediaRenderer = mediarenderer;
timeRange.limit(getSplitRange());
params.timeseek = timeRange.getStartOrZero();
params.timeend = timeRange.getEndOrZero();
params.shift_scr = timeseek_auto;
if (this instanceof IPushOutput) {
params.stdin = (IPushOutput) this;
}
if (resume != null) {
if (range.isTimeRange()) {
resume.update((Range.Time) range, this);
}
params.timeseek = resume.getTimeOffset() / 1000;
if (player == null) {
player = new FFMpegVideo();
}
}
if (System.currentTimeMillis() - lastStartSystemTime < 500) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
LOGGER.error(null, e);
}
}
// (Re)start transcoding process if necessary
if (externalProcess == null || externalProcess.isDestroyed()) {
// First playback attempt => start new transcoding process
LOGGER.debug("Starting transcode/remux of " + getName() + " with media info: " + media);
lastStartSystemTime = System.currentTimeMillis();
externalProcess = player.launchTranscode(this, media, params);
if (params.waitbeforestart > 0) {
LOGGER.trace("Sleeping for {} milliseconds", params.waitbeforestart);
try {
Thread.sleep(params.waitbeforestart);
} catch (InterruptedException e) {
LOGGER.error(null, e);
}
LOGGER.trace("Finished sleeping for " + params.waitbeforestart + " milliseconds");
}
} else if (
params.timeseek > 0 &&
media != null &&
media.isMediaparsed() &&
media.getDurationInSeconds() > 0
) {
// Time seek request => stop running transcode process and start a new one
LOGGER.debug("Requesting time seek: " + params.timeseek + " seconds");
params.minBufferSize = 1;
Runnable r = new Runnable() {
@Override
public void run() {
externalProcess.stopProcess();
}
};
new Thread(r, "External Process Stopper").start();
lastStartSystemTime = System.currentTimeMillis();
ProcessWrapper newExternalProcess = player.launchTranscode(this, media, params);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
LOGGER.error(null, e);
}
if (newExternalProcess == null) {
LOGGER.trace("External process instance is null... sounds not good");
}
externalProcess = newExternalProcess;
}
if (externalProcess == null) {
return null;
}
InputStream is = null;
int timer = 0;
while (is == null && timer < 10) {
is = externalProcess.getInputStream(low);
timer++;
if (is == null) {
LOGGER.debug("External input stream instance is null... sounds not good, waiting 500ms");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
}
// fail fast: don't leave a process running indefinitely if it's
// not producing output after params.waitbeforestart milliseconds + 5 seconds
// this cleans up lingering MEncoder web video transcode processes that hang
// instead of exiting
if (is == null && !externalProcess.isDestroyed()) {
Runnable r = new Runnable() {
@Override
public void run() {
LOGGER.error("External input stream instance is null... stopping process");
externalProcess.stopProcess();
}
};
new Thread(r, "Hanging External Process Stopper").start();
}
return is;
}
}
/**
* Wrap an {@link InputStream} in a {@link SizeLimitInputStream} that sets a
* limit to the maximum number of bytes to be read from the original input
* stream. The number of bytes is determined by the high and low value
* (bytes = high - low). If the high value is less than the low value, the
* input stream is not wrapped and returned as is.
*
* @param input
* The input stream to wrap.
* @param high
* The high value.
* @param low
* The low value.
* @return The resulting input stream.
*/
private InputStream wrap(InputStream input, long high, long low) {
if (input != null && high > low) {
long bytes = (high - (low < 0 ? 0 : low)) + 1;
LOGGER.trace("Using size-limiting stream (" + bytes + " bytes)");
return new SizeLimitInputStream(input, bytes);
}
return input;
}
public String mimeType() {
return mimeType(player);
}
public String mimeType(Player player) {
if (player != null) {
// FIXME: This cannot be right. A player like FFmpeg can output many
// formats depending on the media and the renderer. Also, players are
// singletons. Therefore it is impossible to have exactly one mime
// type to return.
return player.mimeType();
} else if (media != null && media.isMediaparsed()) {
return media.getMimeType();
} else if (getFormat() != null) {
return getFormat().mimeType();
} else {
return getDefaultMimeType(getSpecificType());
}
}
/**
* Prototype function. Original comment: need to override if some thumbnail work is to be done when mediaparserv2 enabled
*/
public void checkThumbnail() {
// need to override if some thumbnail work is to be done when mediaparserv2 enabled
}
@Deprecated
protected void checkThumbnail(InputFile inputFile) {
checkThumbnail(inputFile, null);
}
/**
* Checks if a thumbnail exists, and, if not, generates one (if possible).
* Called from Request/RequestV2 in response to thumbnail requests e.g. HEAD /get/0$1$0$42$3/thumbnail0000%5BExample.mkv
* Calls DLNAMediaInfo.generateThumbnail, which in turn calls DLNAMediaInfo.parse.
*
* @param inputFile File to check or generate the thumbnail for.
* @param renderer The renderer profile
*/
protected void checkThumbnail(InputFile inputFile, RendererConfiguration renderer) {
// Use device-specific pms conf, if any
PmsConfiguration configurationSpecificToRenderer = PMS.getConfiguration(renderer);
if (
media != null &&
!media.isThumbready() &&
configurationSpecificToRenderer.isThumbnailGenerationEnabled() &&
(renderer == null || renderer.isThumbnails())
) {
Double seekPosition = (double) configurationSpecificToRenderer.getThumbnailSeekPos();
if (isResume()) {
Double resumePosition = (double) (resume.getTimeOffset() / 1000);
if (media.getDurationInSeconds() > 0 && resumePosition < media.getDurationInSeconds()) {
seekPosition = resumePosition;
}
}
media.generateThumbnail(inputFile, getFormat(), getType(), seekPosition, isResume(), renderer);
if (!isResume() && media.getThumb() != null && configurationSpecificToRenderer.getUseCache() && inputFile.getFile() != null) {
PMS.get().getDatabase().updateThumbnail(inputFile.getFile().getAbsolutePath(), inputFile.getFile().lastModified(), getType(), media);
}
}
}
/**
* Returns the input stream for this resource's generic thumbnail,
* which is the first of:
* <li> its Format icon, if any
* <li> the fallback image, if any
* <li> the {@link GenericIcons} icon
* <br><br>
* This is a wrapper around {@link #getGenericThumbnailInputStream0()} that
* stores the {@link ImageInfo} before returning the
* {@link InputStream}.
*
* @param fallback
* the fallback image, or {@code null}.
* @return The {@link DLNAThumbnailInputStream}.
* @throws IOException
*/
public final DLNAThumbnailInputStream getGenericThumbnailInputStream(String fallback) throws IOException {
DLNAThumbnailInputStream inputStream = getGenericThumbnailInputStreamInternal(fallback);
thumbnailImageInfo = inputStream.getImageInfo();
return inputStream;
}
/**
* Returns the input stream for this resource's generic thumbnail,
* which is the first of:
* <li> its Format icon, if any
* <li> the fallback image, if any
* <li> the {@link GenericIcons} icon
* <br><br>
* @param fallback
* the fallback image, or {@code null}.
*
* @return The {@link DLNAThumbnailInputStream}.
* @throws IOException
*/
protected DLNAThumbnailInputStream getGenericThumbnailInputStreamInternal(String fallback) throws IOException {
String thumb = fallback;
if (format != null && format.getIcon() != null) {
thumb = format.getIcon();
}
// Thumb could be:
if (thumb != null && isCodeValid(this)) {
// A local file
if (new File(thumb).exists()) {
return DLNAThumbnailInputStream.toThumbnailInputStream(new FileInputStream(thumb));
}
// A jar resource
InputStream is;
if ((is = getResourceInputStream(thumb)) != null) {
return DLNAThumbnailInputStream.toThumbnailInputStream(is);
}
// A URL
try {
return DLNAThumbnailInputStream.toThumbnailInputStream(downloadAndSend(thumb, true));
} catch (Exception e) {}
}
// Or none of the above
if (isFolder()) {
return GenericIcons.INSTANCE.getGenericFolderIcon();
} else {
return GenericIcons.INSTANCE.getGenericIcon(this);
}
}
/**
* Returns the input stream for this resource's thumbnail
* (or a default image if a thumbnail can't be found).
* Typically overridden by a subclass.<br>
* <br>
* This is a wrapper around {@link #getThumbnailInputStream()} that stores
* the {@link ImageInfo} before returning the {@link InputStream}.
*
* @return The {@link DLNAThumbnailInputStream}.
* @throws IOException
*/
public final DLNAThumbnailInputStream fetchThumbnailInputStream() throws IOException {
DLNAThumbnailInputStream inputStream = getThumbnailInputStream();
thumbnailImageInfo = inputStream.getImageInfo();
return inputStream;
}
/**
* Returns the input stream for this resource's thumbnail
* (or a default image if a thumbnail can't be found).
* Typically overridden by a subclass.
*
* @return The {@link DLNAThumbnailInputStream}.
* @throws IOException
*/
protected DLNAThumbnailInputStream getThumbnailInputStream() throws IOException {
String languageCode = null;
if (media_audio != null) {
languageCode = media_audio.getLang();
}
if (media_subtitle != null && media_subtitle.getId() != -1) {
languageCode = media_subtitle.getLang();
}
if ((media_subtitle != null || media_audio != null) && StringUtils.isBlank(languageCode)) {
languageCode = DLNAMediaLang.UND;
}
if (languageCode != null) {
String code = Iso639.getISO639_2Code(languageCode.toLowerCase());
return DLNAThumbnailInputStream.toThumbnailInputStream(getResourceInputStream("/images/codes/" + code + ".png"));
}
if (isAvisynth()) {
return DLNAThumbnailInputStream.toThumbnailInputStream(getResourceInputStream("/images/logo-avisynth.png"));
}
return getGenericThumbnailInputStream(null);
}
public int getType() {
if (getFormat() != null) {
return getFormat().getType();
} else {
return Format.UNKNOWN;
}
}
/**
* Prototype function.
*
* @return true if child can be added to other folder.
* @see #addChild(DLNAResource)
*/
public abstract boolean isValid();
public boolean allowScan() {
return false;
}
/**
* (non-Javadoc)
*
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
StringBuilder result = new StringBuilder();
result.append(getClass().getSimpleName());
result.append(" [id=");
result.append(id);
result.append(", name=");
result.append(getName());
result.append(", full path=");
result.append(getResourceId());
result.append(", ext=");
result.append(format);
result.append(", discovered=");
result.append(isDiscovered());
result.append(']');
return result.toString();
}
/**
* Returns the specific type of resource. Valid types are defined in {@link Format}.
*
* @return The specific type
*/
protected int getSpecificType() {
return specificType;
}
/**
* Set the specific type of this resource. Valid types are defined in {@link Format}.
*
* @param specificType The specific type to set.
*/
protected void setSpecificType(int specificType) {
this.specificType = specificType;
}
/**
* Returns the {@link Format} of this resource, which defines its capabilities.
*
* @return The format of this resource.
*/
public Format getFormat() {
return format;
}
/**
* Sets the {@link Format} of this resource, thereby defining its capabilities.
*
* @param format The format to set.
*/
public void setFormat(Format format) {
this.format = format;
// Set deprecated variable for backwards compatibility
ext = format;
}
/**
* @deprecated Use {@link #getFormat()} instead.
*
* @return The format of this resource.
*/
@Deprecated
public Format getExt() {
return getFormat();
}
/**
* @deprecated Use {@link #setFormat(Format)} instead.
*
* @param format The format to set.
*/
@Deprecated
protected void setExt(Format format) {
setFormat(format);
}
/**
* Returns the {@link DLNAMediaInfo} object for this resource, containing the
* specifics of this resource, e.g. the duration.
*
* @return The object containing detailed information.
*/
public DLNAMediaInfo getMedia() {
return media;
}
/**
* Sets the the {@link DLNAMediaInfo} object that contains all specifics for
* this resource.
*
* @param media The object containing detailed information.
* @since 1.50
*/
public void setMedia(DLNAMediaInfo media) {
this.media = media;
}
/**
* Returns the {@link DLNAMediaAudio} object for this resource that contains
* the audio specifics. A resource can have many audio tracks, this method
* returns the one that should be played.
*
* @return The audio object containing detailed information.
* @since 1.50
*/
public DLNAMediaAudio getMediaAudio() {
return media_audio;
}
/**
* Sets the {@link DLNAMediaAudio} object for this resource that contains
* the audio specifics. A resource can have many audio tracks, this method
* determines the one that should be played.
*
* @param mediaAudio The audio object containing detailed information.
* @since 1.50
*/
protected void setMediaAudio(DLNAMediaAudio mediaAudio) {
this.media_audio = mediaAudio;
}
/**
* Returns the {@link DLNAMediaSubtitle} object for this resource that
* contains the specifics for the subtitles. A resource can have many
* subtitles, this method returns the one that should be displayed.
*
* @return The subtitle object containing detailed information.
* @since 1.50
*/
public DLNAMediaSubtitle getMediaSubtitle() {
return media_subtitle;
}
/**
* Sets the {@link DLNAMediaSubtitle} object for this resource that
* contains the specifics for the subtitles. A resource can have many
* subtitles, this method determines the one that should be used.
*
* @param mediaSubtitle The subtitle object containing detailed information.
* @since 1.50
*/
public void setMediaSubtitle(DLNAMediaSubtitle mediaSubtitle) {
this.media_subtitle = mediaSubtitle;
}
/**
* @deprecated Use {@link #getLastModified()} instead.
*
* Returns the timestamp at which this resource was last modified.
*
* @return The timestamp.
*/
@Deprecated
public long getLastmodified() {
return getLastModified();
}
/**
* Returns the timestamp at which this resource was last modified.
*
* @return The timestamp.
* @since 1.71.0
*/
public long getLastModified() {
return lastmodified; // TODO rename lastmodified -> lastModified
}
/**
* @deprecated Use {@link #setLastModified(long)} instead.
*
* Sets the timestamp at which this resource was last modified.
*
* @param lastModified The timestamp to set.
* @since 1.50
*/
@Deprecated
protected void setLastmodified(long lastModified) {
setLastModified(lastModified);
}
/**
* Sets the timestamp at which this resource was last modified.
*
* @param lastModified The timestamp to set.
* @since 1.71.0
*/
protected void setLastModified(long lastModified) {
this.lastmodified = lastModified; // TODO rename lastmodified -> lastModified
}
/**
* Returns the {@link Player} object that is used to encode this resource
* for the renderer. Can be null.
*
* @return The player object.
*/
public Player getPlayer() {
return player;
}
/**
* Sets the {@link Player} object that is to be used to encode this
* resource for the renderer. The player object can be null.
*
* @param player The player object to set.
* @since 1.50
*/
public void setPlayer(Player player) {
this.player = player;
}
/**
* Returns true when the details of this resource have already been
* investigated. This helps is not doing the same work twice.
*
* @return True if discovered, false otherwise.
*/
public boolean isDiscovered() {
return discovered;
}
/**
* Set to true when the details of this resource have already been
* investigated. This helps is not doing the same work twice.
*
* @param discovered Set to true if this resource is discovered,
* false otherwise.
* @since 1.50
*/
protected void setDiscovered(boolean discovered) {
this.discovered = discovered;
}
/**
* @Deprecated use {@link #hasExternalSubtitles()} instead
*/
@Deprecated
protected boolean isSrtFile() {
return hasExternalSubtitles();
}
/**
* @Deprecated use {@link #hasExternalSubtitles()} instead
*/
@Deprecated
protected boolean isSubsFile() {
return hasExternalSubtitles();
}
/**
* Whether this resource has external subtitles.
*
* @return whether this resource has external subtitles
*/
protected boolean hasExternalSubtitles() {
return hasExternalSubtitles;
}
/**
* @Deprecated use {@link #setHasExternalSubtitles(boolean)} instead
*/
@Deprecated
protected void setSrtFile(boolean srtFile) {
setHasExternalSubtitles(srtFile);
}
/**
* @Deprecated use {@link #setHasExternalSubtitles(boolean)} instead
*/
@Deprecated
protected void setSubsFile(boolean srtFile) {
setHasExternalSubtitles(srtFile);
}
/**
* Sets whether this resource has external subtitles.
*
* @param value whether this resource has external subtitles
*/
protected void setHasExternalSubtitles(boolean value) {
this.hasExternalSubtitles = value;
}
/**
* Returns the updates id for this resource. When the resource needs
* to be refreshed, its id is updated.
*
* @return The updated id.
* @see #notifyRefresh()
*/
public int getUpdateId() {
return updateId;
}
/**
* Sets the updated id for this resource. When the resource needs
* to be refreshed, its id should be updated.
*
* @param updateId The updated id value to set.
* @since 1.50
*/
protected void setUpdateId(int updateId) {
this.updateId = updateId;
}
/**
* Returns the updates id for all resources. When all resources need
* to be refreshed, this id is updated.
*
* @return The system updated id.
* @since 1.50
*/
public static int getSystemUpdateId() {
return systemUpdateId;
}
/**
* Sets the updated id for all resources. When all resources need
* to be refreshed, this id should be updated.
*
* @param systemUpdateId The system updated id to set.
* @since 1.50
*/
public static void setSystemUpdateId(int systemUpdateId) {
DLNAResource.systemUpdateId = systemUpdateId;
}
/**
* Returns whether or not this is a nameless resource.
*
* @return True if the resource is nameless.
*/
public boolean isNoName() {
return noName;
}
/**
* Sets whether or not this is a nameless resource. This is particularly
* useful in the virtual TRANSCODE folder for a file, where the same file
* is copied many times with different audio and subtitle settings. In that
* case the name of the file becomes irrelevant and only the settings
* need to be shown.
*
* @param noName Set to true if the resource is nameless.
* @since 1.50
*/
protected void setNoName(boolean noName) {
this.noName = noName;
}
/**
* Returns the from - to time range for this resource.
*
* @return The time range.
*/
public Range.Time getSplitRange() {
return splitRange;
}
/**
* Sets the from - to time range for this resource.
*
* @param splitRange The time range to set.
* @since 1.50
*/
protected void setSplitRange(Range.Time splitRange) {
this.splitRange = splitRange;
}
/**
* Returns the number of the track to split from this resource.
*
* @return the splitTrack
* @since 1.50
*/
protected int getSplitTrack() {
return splitTrack;
}
/**
* Sets the number of the track from this resource to split.
*
* @param splitTrack The track number.
* @since 1.50
*/
protected void setSplitTrack(int splitTrack) {
this.splitTrack = splitTrack;
}
/**
* Returns the default renderer configuration for this resource.
*
* @return The default renderer configuration.
* @since 1.50
*/
public RendererConfiguration getDefaultRenderer() {
return defaultRenderer;
}
/**
* Sets the default renderer configuration for this resource.
*
* @param defaultRenderer The default renderer configuration to set.
* @since 1.50
*/
public void setDefaultRenderer(RendererConfiguration defaultRenderer) {
this.defaultRenderer = defaultRenderer;
configuration = PMS.getConfiguration(defaultRenderer);
}
/**
* Returns whether or not this resource is handled by AviSynth.
*
* @return True if handled by AviSynth, otherwise false.
* @since 1.50
*/
protected boolean isAvisynth() {
return avisynth;
}
/**
* Sets whether or not this resource is handled by AviSynth.
*
* @param avisynth Set to true if handled by Avisyth, otherwise false.
* @since 1.50
*/
protected void setAvisynth(boolean avisynth) {
this.avisynth = avisynth;
}
/**
* Returns true if transcoding should be skipped for this resource.
*
* @return True if transcoding should be skipped, false otherwise.
* @since 1.50
*/
protected boolean isSkipTranscode() {
return skipTranscode;
}
/**
* Set to true if transcoding should be skipped for this resource.
*
* @param skipTranscode Set to true if trancoding should be skipped, false
* otherwise.
* @since 1.50
*/
protected void setSkipTranscode(boolean skipTranscode) {
this.skipTranscode = skipTranscode;
}
/**
* Returns the list of children for this resource.
*
* @return List of children objects.
*/
public List<DLNAResource> getChildren() {
return children;
}
/**
* Sets the list of children for this resource.
*
* @param children The list of children to set.
* @since 1.50
*/
protected void setChildren(List<DLNAResource> children) {
this.children = (DLNAList) children;
}
/**
* @deprecated use {@link #getLastChildId()} instead.
*/
@Deprecated
protected int getLastChildrenId() {
return getLastChildId();
}
/**
* Returns the numerical ID of the last child added.
*
* @return The ID.
* @since 1.80.0
*/
protected int getLastChildId() {
return lastChildrenId;
}
/**
* @deprecated use {@link #setLastChildId(int)} instead.
*/
@Deprecated
protected void setLastChildrenId(int lastChildId) {
setLastChildId(lastChildId);
}
/**
* Sets the numerical ID of the last child added.
*
* @param lastChildId The ID to set.
* @since 1.80.0
*/
protected void setLastChildId(int lastChildId) {
this.lastChildrenId = lastChildId;
}
/**
* Returns the timestamp when this resource was last refreshed.
*
* @return The timestamp.
*/
long getLastRefreshTime() {
return lastRefreshTime;
}
/**
* Sets the timestamp when this resource was last refreshed.
*
* @param lastRefreshTime The timestamp to set.
* @since 1.50
*/
protected void setLastRefreshTime(long lastRefreshTime) {
this.lastRefreshTime = lastRefreshTime;
}
private static final int DEPTH_WARNING_LIMIT = 7;
private boolean depthLimit() {
DLNAResource tmp = this;
int depth = 0;
while (tmp != null) {
tmp = tmp.parent;
depth++;
}
return (depth > DEPTH_WARNING_LIMIT);
}
public boolean isSearched() {
return false;
}
public byte[] getHeaders() {
return null;
}
public void attach(String key, Object data) {
if (attachments == null) {
attachments = new HashMap<>();
}
attachments.put(key, data);
}
public Object getAttachment(String key) {
return attachments == null ? null : attachments.get(key);
}
public boolean isURLResolved() {
return false;
}
////////////////////////////////////////////////////
// Subtitle handling
////////////////////////////////////////////////////
private SubSelect getSubSelector(boolean create) {
if (
configuration.isDisableSubtitles() ||
!configuration.isAutoloadExternalSubtitles() ||
configuration.isHideLiveSubtitlesFolder() ||
!isLiveSubtitleFolderAvailable()
) {
return null;
}
// Search for transcode folder
for (DLNAResource r : children) {
if (r instanceof SubSelect) {
return (SubSelect) r;
}
}
if (create) {
SubSelect vf = new SubSelect();
addChildInternal(vf);
return vf;
}
return null;
}
public boolean isSubSelectable() {
return false;
}
////////////////////////////////////////////////////
// Resume handling
////////////////////////////////////////////////////
private ResumeObj resume;
private int resHash;
private long startTime;
private void internalStop() {
DLNAResource res = resumeStop();
final RootFolder root = ((defaultRenderer != null) ? defaultRenderer.getRootFolder() : null);
if (root != null) {
if (res == null) {
res = this.clone();
} else {
res = res.clone();
}
root.stopPlaying(res);
}
}
public int resumeHash() {
return resHash;
}
public ResumeObj getResume() {
return resume;
}
public void setResume(ResumeObj r) {
resume = r;
}
public boolean isResumeable() {
if (format != null) {
// Only resume videos
return format.isVideo();
}
return true;
}
private DLNAResource resumeStop() {
if (!configuration.isResumeEnabled() || !isResumeable()) {
return null;
}
notifyRefresh();
if (resume != null) {
resume.stop(startTime, (long) (media.getDurationInSeconds() * 1000));
if (resume.isDone()) {
parent.getChildren().remove(this);
} else if (getMedia() != null) {
media.setThumbready(false);
}
} else {
for (DLNAResource res : parent.getChildren()) {
if (res.isResume() && res.getName().equals(getName())) {
res.resume.stop(startTime, (long) (media.getDurationInSeconds() * 1000));
if (res.resume.isDone()) {
parent.getChildren().remove(res);
return null;
}
if (res.getMedia() != null) {
res.media.setThumbready(false);
}
return res;
}
}
ResumeObj r = ResumeObj.store(this, startTime);
if (r != null) {
DLNAResource clone = this.clone();
clone.resume = r;
clone.resHash = resHash;
if (clone.media != null) {
clone.media.setThumbready(false);
}
clone.player = player;
parent.addChildInternal(clone);
return clone;
}
}
return null;
}
public final boolean isResume() {
return isResumeable() && (resume != null);
}
public int minPlayTime() {
return configuration.getMinimumWatchedPlayTime();
}
private String resumeStr(String s) {
if (isResume()) {
return Messages.getString("PMS.134") + ": " + s;
} else {
return s;
}
}
public String resumeName() {
return resumeStr(getDisplayName());
}
/**
* Handle serialization.
*
* This method should be overridden by all media types that can be
* bookmarked, i.e. serialized to an external file.
* By default it just returns null which means the resource is ignored
* when serializing.
*/
public String write() {
return null;
}
private ExternalListener masterParent;
public void setMasterParent(ExternalListener r) {
if (masterParent == null) {
// If master is already set ignore this
masterParent = r;
}
}
public ExternalListener getMasterParent() {
return masterParent;
}
// Returns the index of the given child resource id, or -1 if not found
public int indexOf(String objectId) {
// Use the index id string only, omitting any trailing filename
String resourceId = StringUtils.substringBefore(objectId, "/");
if (resourceId != null) {
for (int i = 0; i < children.size(); i++) {
if (resourceId.equals(children.get(i).getResourceId())) {
return i;
}
}
}
return -1;
}
// Attempts to automatically create the appropriate container for
// the given uri. Defaults to mpeg video for indeterminate local uris.
public static DLNAResource autoMatch(String uri, String name) {
try {
uri = URLDecoder.decode(uri, "UTF-8");
} catch (UnsupportedEncodingException e) {
LOGGER.error("URL decoding error ", e);
}
boolean isweb = uri.matches("\\S+://.+");
Format f = FormatFactory.getAssociatedFormat(isweb ? "." + FileUtil.getUrlExtension(uri) : uri);
int type = f == null ? Format.VIDEO : f.getType();
if (name == null) {
name = new File(StringUtils.substringBefore(uri, "?")).getName();
}
DLNAResource d = isweb ?
type == Format.VIDEO ? new WebVideoStream(name, uri, null) :
type == Format.AUDIO ? new WebAudioStream(name, uri, null) :
type == Format.IMAGE ? new FeedItem(name, uri, null, null, Format.IMAGE) : null
:
new RealFile(new File(uri));
if (f == null && !isweb) {
d.setFormat(FormatFactory.getAssociatedFormat(".mpg"));
}
LOGGER.debug(d == null ?
("Could not auto-match " + uri) :
("Created auto-matched container: "+ d));
return d;
}
// A general-purpose free-floating folder
public static class unattachedFolder extends VirtualFolder {
public unattachedFolder(String name) {
super(name, null);
setId(name);
}
public DLNAResource add(DLNAResource d) {
if (d != null) {
addChild(d);
d.setId(d.getId() + "$" + getId());
return d;
}
return null;
}
public DLNAResource add(String uri, String name, RendererConfiguration r) {
DLNAResource d = autoMatch(uri, name);
if (d != null) {
// Set the auto-matched item's renderer
d.setDefaultRenderer(r);
// Cache our previous renderer and
// pretend to be a parent with the same renderer
RendererConfiguration prev = getDefaultRenderer();
setDefaultRenderer(r);
// Now add the item and resolve its rendering details
add(d);
d.syncResolve();
// Restore our previous renderer
setDefaultRenderer(prev);
}
return d;
}
public int getIndex(String objectId) {
return getIndex(objectId, null);
}
public int getIndex(String objectId, RendererConfiguration r) {
int index = indexOf(objectId);
if (index == -1 && r != null) {
index = indexOf(recreate(objectId, null, r).getResourceId());
}
return index;
}
public DLNAResource get(String objectId, RendererConfiguration r) {
int index = getIndex(objectId, r);
DLNAResource d = index > -1 ? getChildren().get(index) : null;
if (d != null && r != null && ! r.equals(d.getDefaultRenderer())) {
d.updateRendering(r);
}
return d;
}
public List<DLNAResource> asList(String objectId) {
int index = getIndex(objectId);
return index > -1 ? getChildren().subList(index, index + 1) : null;
}
// Try to recreate a lost item from a previous session
// using its objectId's trailing uri, if any
public DLNAResource recreate(String objectId, String name, RendererConfiguration r) {
try {
return add(StringUtils.substringAfter(objectId, "/"), name, r);
} catch (Exception e) {
return null;
}
}
}
// A temp folder for non-xmb items
public static final unattachedFolder Temp = new unattachedFolder("Temp");
// Returns whether the url appears to be ours
public static boolean isResourceUrl(String url) {
return url != null && url.startsWith(PMS.get().getServer().getURL() + "/get/");
}
// Returns the url's resourceId (i.e. index without trailing filename) if any or null
public static String parseResourceId(String url) {
return isResourceUrl(url) ? StringUtils.substringBetween(url + "/", "get/", "/") : null;
}
// Returns the url's objectId (i.e. index including trailing filename) if any or null
public static String parseObjectId(String url) {
return isResourceUrl(url) ? StringUtils.substringAfter(url, "get/") : null;
}
// Returns the DLNAResource pointed to by the uri if it exists
// or else a new Temp item (or null)
public static DLNAResource getValidResource(String uri, String name, RendererConfiguration r) {
LOGGER.debug("Validating uri " + uri);
String objectId = parseObjectId(uri);
if (objectId != null) {
if (objectId.startsWith("Temp$")) {
int index = Temp.indexOf(objectId);
return index > -1 ? Temp.getChildren().get(index) : Temp.recreate(objectId, name, r);
} else {
if (r == null) {
r = RendererConfiguration.getDefaultConf();
}
return PMS.get().getRootFolder(r).getDLNAResource(objectId, r);
}
} else {
return Temp.add(uri, name, r);
}
}
// Returns the uri if it's ours and exists or else the url of new Temp item (or null)
public static String getValidResourceURL(String uri, String name, RendererConfiguration r) {
if (isResourceUrl(uri)) {
// Check existence
return PMS.getGlobalRepo().exists(parseResourceId(uri)) ? uri : null; // TODO: attempt repair
} else {
DLNAResource d = Temp.add(uri, name, r);
if (d != null) {
return d.getURL("", true);
}
}
return null;
}
public static class Rendering {
RendererConfiguration r;
Player p;
DLNAMediaSubtitle s;
String m;
Rendering(DLNAResource d) {
r = d.getDefaultRenderer();
p = d.getPlayer();
s = d.getMediaSubtitle();
if (d.getMedia() != null) {
m = d.getMedia().getMimeType();
}
}
}
public Rendering updateRendering(RendererConfiguration r) {
Rendering rendering = new Rendering(this);
Player p = resolvePlayer(r);
LOGGER.debug("Switching rendering context to '{} [{}]' from '{} [{}]'", r, p, rendering.r, rendering.p);
setDefaultRenderer(r);
setPlayer(p);
setPreferredMimeType(r);
return rendering;
}
public void updateRendering(Rendering rendering) {
LOGGER.debug("Switching rendering context to '{} [{}]' from '{} [{}]'", rendering.r, rendering.p, getDefaultRenderer(), getPlayer());
setDefaultRenderer(rendering.r);
setPlayer(rendering.p);
media_subtitle = rendering.s;
if (media != null) {
media.setMimeType(rendering.m);
}
}
public DLNAResource isCoded() {
DLNAResource tmp = this;
while (tmp != null) {
if (tmp instanceof CodeEnter) {
return tmp;
}
tmp = tmp.getParent();
}
return null;
}
public boolean isCodeValid(DLNAResource r) {
DLNAResource res = r.isCoded();
if (res != null) {
if (res instanceof CodeEnter) {
return ((CodeEnter) res).validCode(r);
}
}
// normal case no code in path code is always valid
return true;
}
public boolean quietPlay() {
return false;
}
public long getStartTime() {
return startTime;
}
private void addDynamicPls(final DLNAResource child) {
final DLNAResource dynPls = PMS.get().getDynamicPls();
if (dynPls == child || child.getParent() == dynPls) {
return;
}
if (child instanceof VirtualVideoAction) {
// ignore these
return;
}
if (dynamicPls == null) {
dynamicPls = new VirtualFolder(Messages.getString("PMS.147"), null);
addChildInternal(dynamicPls);
dynamicPls.addChild(dynPls);
}
if (dynamicPls != null) {
String str = Messages.getString("PluginTab.9") + " " + child.getDisplayName() + " " + Messages.getString("PMS.148");
VirtualVideoAction vva = new VirtualVideoAction(str, true) {
@Override
public boolean enable() {
PMS.get().getDynamicPls().add(child);
return true;
}
};
vva.setParent(this);
dynamicPls.addChildInternal(vva);
}
}
public String getResolutionForKeepAR(int scaleWidth, int scaleHeight) {
double videoAspectRatio = (double) scaleWidth / (double) scaleHeight;
double rendererAspectRatio = 1.777777777777778;
if (videoAspectRatio > rendererAspectRatio) {
scaleHeight = (int) Math.round(scaleWidth / rendererAspectRatio);
} else {
scaleWidth = (int) Math.round(scaleHeight * rendererAspectRatio);
}
scaleWidth = Player.convertToModX(scaleWidth, 4);
scaleHeight = Player.convertToModX(scaleHeight, 4);
return scaleWidth + "x" + scaleHeight;
}
}