Package net.pms.dlna

Source Code of net.pms.dlna.DLNAResource

/*
* 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.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.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.io.OutputParams;
import net.pms.io.ProcessWrapper;
import net.pms.io.SizeLimitInputStream;
import net.pms.network.HTTPResource;
import net.pms.util.FileUtil;
import net.pms.util.ImagesUtil;
import net.pms.util.Iso639;
import net.pms.util.MpegUtil;
import static net.pms.util.StringUtil.*;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* 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 static final 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;

  /**
   * @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 standard getter and setter to access this field.
   */
  @Deprecated
  protected boolean srtFile;

  /**
   * @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;

  private String dlnaspec;

  /**
   * @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;
  private String dlnaOrgOpFlags;

  /**
   * @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 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;

  private String lastSearch;

  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
   */
  protected String getId() {
    return id;
  }

  /**
   * 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;
  }

  /**
   * 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();
    }
  }

  /**
   * @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;
  }

  /**
   * 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() {
    return (dlnaspec != null ? (dlnaspec + ";") : "") + getDlnaOrgOpFlags() + ";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<>();
    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;
    }

    try {
      if (child.isValid()) {
        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;
        }

        boolean parserV2 = child.media != null && defaultRenderer != null && defaultRenderer.isMediaParserV2();
        if (parserV2) {
          // See which mime type the renderer prefers in case it supports the media
          String mimeType = defaultRenderer.getFormatConfiguration().match(child.media);
          if (mimeType != null) {
            // Media is streamable
            if (!configuration.isDisableSubtitles() && child.isSubsFile() && defaultRenderer.isSubtitlesStreamingSupported()) {
              OutputParams params = new OutputParams(configuration);
              Player.setAudioAndSubs(child.getSystemName(), child.media, params); // set proper subtitles in accordance with user setting
              if (params.sid.isExternal() && defaultRenderer.isExternalSubtitlesFormatSupported(params.sid)) {
                child.media_subtitle = params.sid;
                child.media_subtitle.setSubsStreamable(true);
                LOGGER.trace("Set media_subtitle");
              } else {
                LOGGER.trace("Did not set media_subtitle because the subtitle format is not supported by this renderer");
              }
            } else {
              LOGGER.trace("Did not set media_subtitle because configuration.isDisableSubtitles is true, this is not a subtitle, or the renderer does not support streaming subtitles");
            }

            if (!FormatConfiguration.MIMETYPE_AUTO.equals(mimeType)) {
              // Override with the preferred mime type of the renderer
              LOGGER.trace("Overriding detected mime type \"{}\" for file \"{}\" with renderer preferred mime type \"{}\"",
                  child.media.getMimeType(), child.getName(), mimeType);
              child.media.setMimeType(mimeType);
            }

            LOGGER.trace("File \"{}\" can be streamed with mime type \"{}\"", child.getName(), child.media.getMimeType());
          } else {
            // Media is transcodable
            LOGGER.trace("File \"{}\" can be transcoded", child.getName());
          }
        } else if (child.media != null && defaultRenderer != null) {
          LOGGER.trace("Did not check for media_subtitle for \"{}\" because {} does not use MediaInfo, we will check for it soon", child.getName(), defaultRenderer);
        }

        if (child.format != null) {
          String configurationSkipExtensions = configuration.getDisableTranscodeForExtensions();
          String rendererSkipExtensions = null;

          if (defaultRenderer != null) {
            rendererSkipExtensions = defaultRenderer.getStreamedExtensions();
          }

          // Should transcoding be skipped for this format?
          boolean skip = child.format.skip(configurationSkipExtensions, rendererSkipExtensions);
          skipTranscode = skip;

          if (skip) {
            LOGGER.trace("File \"{}\" will be forced to skip transcoding by configuration", child.getName());
          }

          // 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 player = 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()) {
              player = child.player;
            } else {
              for (Player p : PlayerFactory.getPlayers()) {
                String end = "[" + p.id() + "]";

                if (name.endsWith(end)) {
                  nametruncate = name.lastIndexOf(end);
                  player = p;
                  LOGGER.trace("Selecting player based on name end");
                  break;
                } else if (parent != null && parent.getName().endsWith(end)) {
                  parent.nametruncate = parent.getName().lastIndexOf(end);
                  player = 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 (player == null) {
              player = PlayerFactory.getPlayer(child);
            }

            if (player != null && !allChildrenAreFolders) {
              String configurationForceExtensions = configuration.getForceTranscodeForExtensions();
              String rendererForceExtensions = null;

              if (defaultRenderer != null) {
                rendererForceExtensions = defaultRenderer.getTranscodedExtensions();
              }

              // Should transcoding be forced for this format?
              boolean forceTranscode = child.format.skip(configurationForceExtensions, rendererForceExtensions);

              if (forceTranscode) {
                LOGGER.trace("File \"{}\" will be forced to be transcoded by configuration", child.getName());
              }

              boolean hasEmbeddedSubs = false;
              boolean hasAnySubs = child.media.getSubtitleTracksList().size() > 0 || child.isSubsFile();
              boolean hasSubsToTranscode = false;

              if (!configuration.isDisableSubtitles() && hasAnySubs) {
                for (DLNAMediaSubtitle s : child.media.getSubtitleTracksList()) {
                  hasEmbeddedSubs = (hasEmbeddedSubs || s.isEmbedded());
                }

                if (!parserV2) {
                  if (child.isSubsFile() && defaultRenderer != null && defaultRenderer.isSubtitlesStreamingSupported()) {
                    OutputParams params = new OutputParams(configuration);
                    Player.setAudioAndSubs(child.getSystemName(), child.media, params); // set proper subtitles in accordance with user setting
                    if (params.sid.isExternal() && defaultRenderer.isExternalSubtitlesFormatSupported(params.sid)) {
                      child.media_subtitle = params.sid;
                      child.media_subtitle.setSubsStreamable(true);
                      LOGGER.trace("Set media_subtitle");
                    } else {
                      LOGGER.trace("Did not set media_subtitle because the subtitle format is not supported by this renderer");
                    }
                  } else {
                    LOGGER.trace("Did not set media_subtitle because this file does not have external subtitles, or the renderer does not support streaming subtitles");
                  }
                }

                if (child.isSubsFile()) {
                  if (child.media_subtitle == null) {
                    // Subtitles are not set for streaming
                    forceTranscode = true;
                    hasSubsToTranscode = true;
                    LOGGER.trace("Subtitles for \"{}\" need to be transcoded because media_subtitle is null", child.getName());
                  } else {
                    LOGGER.trace("Subtitles for \"{}\" will not be transcoded because media_subtitle is not null", child.getName());
                  }
                } else if (hasEmbeddedSubs) {
                  if (
                    defaultRenderer != null &&
                    (child.media_subtitle != null && !defaultRenderer.isEmbeddedSubtitlesFormatSupported(child.media_subtitle)) ||
                    !defaultRenderer.isEmbeddedSubtitlesSupported()
                  ) {
                    forceTranscode = true;
                    hasSubsToTranscode = true;
                    LOGGER.trace("Subtitles for \"{}\" need to be transcoded because the renderer does not support internal subtitles", child.getName());
                  } else {
                    LOGGER.trace("Subtitles for \"{}\" will not be transcoded because the renderer supports internal subtitles", child.getName());
                  }
                }
              }

              boolean isIncompatible = false;
              String audioTracksList = child.getName() + child.media.getAudioTracksList().toString();

              String prependTraceReason = "File \"{}\" will not be streamed because ";
              if (!child.format.isCompatible(child.media, defaultRenderer)) {
                isIncompatible = true;
                LOGGER.trace(prependTraceReason + "it is not supported by the renderer", child.getName());
              } else if (
                configuration.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", child.getName());
              } else if (defaultRenderer != null) {
                if (
                  defaultRenderer.isKeepAspectRatio() &&
                  !"16:9".equals(child.media.getAspectRatioContainer())
                ) {
                  isIncompatible = true;
                  LOGGER.trace(prependTraceReason + "the renderer needs us to add borders to change the aspect ratio from {} to 16/9.", child.getName(), child.media.getAspectRatioContainer());
                } else if (
                  defaultRenderer.isMaximumResolutionSpecified() &&
                  (
                    child.media.getWidth()  > defaultRenderer.getMaxVideoWidth() ||
                    child.media.getHeight() > defaultRenderer.getMaxVideoHeight()
                  )
                ) {
                  isIncompatible = true;
                  LOGGER.trace(prependTraceReason + "the resolution is too high for the renderer.", child.getName());
                } else if (child.media.getBitrate() > (defaultRenderer.getMaxBandwidth() / 2)) {
                  isIncompatible = true;
                  LOGGER.trace(prependTraceReason + "the bitrate is too high.", child.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())) {
                child.player = player;

                if (resumeRes != null) {
                  resumeRes.player = player;
                }

                if (parserV2) {
                  LOGGER.trace("Final verdict: \"{}\" will be transcoded with player \"{}\" with mime type \"{}\"", child.getName(), player.toString(), child.media.getMimeType());
                } else {
                  LOGGER.trace("Final verdict: \"{}\" will be transcoded with player \"{}\"", child.getName(), player.toString());
                }
              } else {
                LOGGER.trace("Final verdict: \"{}\" will be streamed", child.getName());
              }

              // 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 = player;
                  newChild.media = child.media;
                  fileTranscodeFolder.addChildInternal(newChild);
                  LOGGER.trace("Adding \"{}\" to transcode folder for player: \"{}\"", child.getName(), player.toString());

                  transcodeFolder.updateChild(fileTranscodeFolder);
                }
              }

              if (child.format.isVideo() && child.isSubSelectable() && !(this instanceof SubSelFile)) {
                VirtualFolder vf = getSubSelector(true);
                if (vf != null) {
                  DLNAResource newChild = child.clone();
                  newChild.setPlayer(player);
                  newChild.setMedia(child.media);
                  LOGGER.trace("Duplicate subtitle " + child.getName() + " with player: " + player.toString());

                  vf.addChild(new SubSelFile(newChild));
                }
              }

              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);
          }

          if (
            child.format.getSecondaryFormat() != null &&
            child.media != null &&
            defaultRenderer != null &&
            defaultRenderer.supportsFormat(child.format.getSecondaryFormat())
          ) {
            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 player = PlayerFactory.getPlayer(newChild);
              newChild.setPlayer(player);
              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) {
          addChildInternal(resumeRes);
        }
        if (isNew) {
          addChildInternal(child);
        }
      }
    } catch (Throwable t) {
      LOGGER.error("Error adding child: \"{}\"", child.getName(), t);

      child.parent = null;
      children.remove(child);
    }
  }

  /**
   * 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);
      addChildInternal(transcodeFolder);
      return transcodeFolder;
    }

    return null;
  }

  /**
   * (Re)sets the given DNLA 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(Integer.parseInt(found.getInternalId()));
        children.set(children.indexOf(found), child);
      }
      // Renew
      addChild(child, false);
    } else {
      // Not found, it's new
      addChild(child, true);
    }
  }

  /**
   * Adds the supplied DNLA 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;

    setLastChildId(getLastChildId() + 1);
    child.setIndexId(getLastChildId());
  }

  /**
   * 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) throws IOException {
    ArrayList<DLNAResource> resources = new ArrayList<>();
    DLNAResource dlna = search(objectId, count, renderer, searchStr);

    if (dlna != null) {
      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) {
    // Discover children if it hasn't been done already
    if (!isDiscovered()) {
      if (configuration.getFolderLimit() && depthLimit()) {
        if (renderer.getRendererName().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.isMediaParserV2() && renderer.isDLNATreeHack()) {
        ready = analyzeChildren(count);
      } else {
        ready = analyzeChildren(-1);
      }

      if (!renderer.isMediaParserV2() || ready) {
        setDiscovered(true);
      }

      notifyRefresh();
    } else {
      // if forced, then call the old 'refreshChildren' method
      LOGGER.trace("discover {} refresh forced: {}", getResourceId(), forced);
      if (forced) {
        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() {
    if (first == null) {
      resolve();
      if (second != null) {
        second.resolve();
      }
    }
  }

  /**
   * 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) {
              return found;
            }
          }
        }
      } else {
        return null;
      }
    }
    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 &amp;c.
   *
   * @since 1.90.0
   * @see #resolve()
   */
  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 &amp;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 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) {
    if (displayName != null) { // cached
      return withSuffix ? (displayName + nameSuffix) : displayName;
    }

    displayName = getName();
    String subtitleFormat;
    String subtitleLanguage;
    boolean isNamedNoEncoding = false;
    boolean subsAreValidForStreaming = media_subtitle != null && media_subtitle.isStreamable() && player == null;
    if (
      this instanceof RealFile &&
      (
        configuration.isHideExtensions() ||
        configuration.isPrettifyFilenames()
      ) &&
      !isFolder()
    ) {
      if (configuration.isPrettifyFilenames() && getFormat() != null && getFormat().isVideo()) {
        displayName = FileUtil.getFileNameWithRewriting(displayName);
      } else {
        displayName = FileUtil.getFileNameWithoutExtension(displayName);
      }
    }

    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 (!configuration.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 (
      isSubsFile() &&
      !isNamedNoEncoding &&
      media_audio == null &&
      media_subtitle == null &&
      !configuration.hideSubsInfo() &&
      (
        player == null ||
        player.isExternalSubtitlesSupported()
      )
    ) {
      nameSuffix += " " + Messages.getString("DLNAResource.1");
    }

    if (getMediaAudio() != null) {
      String audioLanguage = "/" + getMediaAudio().getLangFullName();
      if ("/Undetermined".equals(audioLanguage)) {
        audioLanguage = "";
      }

      displayName = player != null ? ("[" + player.name() + "]") : "";
      nameSuffix = " {Audio: " + getMediaAudio().getAudioCodec() + audioLanguage + ((getMediaAudio().getFlavor() != null && mediaRenderer != null && mediaRenderer.isShowAudioMetadata()) ? (" (" + getMediaAudio().getFlavor() + ")") : "") + "}";
    }

    if (
      media_subtitle != null &&
      media_subtitle.getId() != -1 &&
      !configuration.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 subsDescription = Messages.getString("DLNAResource.2") + subtitleFormat + subtitleLanguage + ((media_subtitle.getFlavor() != null && mediaRenderer != null && mediaRenderer.isShowSubMetadata()) ? (" (" + media_subtitle.getFlavor() + ")") : "");
      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 a URL pointing to an image representing the item. If
   * none is available, "thumbnail0000.png" is used.
   */
  protected String getThumbnailURL() {
    return getURL("thumbnail0000");
  }

  /**
   * @param prefix
   * @return Returns a URL for a given media item. Not used for container types.
   */
  protected String getURL(String prefix) {
    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(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 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("Caught exception", 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;
  }

  // this shouldn't be public
  @Deprecated
  public String getFlags() {
    return getDlnaOrgOpFlags();
  }

  // permit the renderer to seek by time, bytes or both
  private String getDlnaOrgOpFlags() {
    return "DLNA.ORG_OP=" + dlnaOrgOpFlags;
  }

  /**
   * @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="true">}
   */
  public final String getDidlString(RendererConfiguration mediaRenderer) {
    StringBuilder sb = new StringBuilder();
    boolean subsAreValidForStreaming = false;
    if (!isFolder()) {
      if (
        !configuration.isDisableSubtitles() &&
        player == null &&
        media_subtitle != null &&
        media_subtitle.isStreamable()
      ) {
        subsAreValidForStreaming = true;
        LOGGER.trace("Setting subsAreValidForStreaming to true for " + media_subtitle.getExternalFile().getName());
      } else if (subsAreValidForStreaming) {
        LOGGER.trace("Not setting subsAreValidForStreaming and it is true for " + getName());
      } else {
        LOGGER.trace("Not setting subsAreValidForStreaming and it is false for " + getName());
      }

      openTag(sb, "item");
    } else {
      openTag(sb, "container");
    }

    addAttribute(sb, "id", getResourceId());

    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());
      }
    }
    addAttribute(sb, "parentID", getParentId());
    addAttribute(sb, "restricted", "true");
    endTag(sb);

    StringBuilder wireshark = new StringBuilder();
    final DLNAMediaAudio firstAudioTrack = media != null ? media.getFirstAudioTrack() : null;
    if (firstAudioTrack != null && StringUtils.isNotBlank(firstAudioTrack.getSongname())) {
      wireshark.append(firstAudioTrack.getSongname()).append(player != null && !configuration.isHideEngineNames() ? (" [" + player.name() + "]") : "");
      addXMLTagAndAttribute(
        sb,
        "dc:title",
        encodeXML(mediaRenderer.getDcTitle(resumeStr(wireshark.toString()), nameSuffix, this))
      );
    } else { // Ditlew - org
      // Ditlew
      wireshark.append(((isFolder() || subsAreValidForStreaming) ? getDisplayName() : mediaRenderer.getUseSameExtension(getDisplayName(mediaRenderer))));
      String tmp = (isFolder() || subsAreValidForStreaming) ? getDisplayName(null, false) : mediaRenderer.getUseSameExtension(getDisplayName(mediaRenderer, false));
      addXMLTagAndAttribute(
        sb,
        "dc:title",
        encodeXML(mediaRenderer.getDcTitle(resumeStr(tmp), nameSuffix, this))
      );
    }

    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());
      }
    }

    if (!isFolder()) {
      int indexCount = 1;

      if (mediaRenderer.isDLNALocalizationRequired()) {
        indexCount = getDLNALocalesCount();
      }

      for (int c = 0; c < indexCount; c++) {
        openTag(sb, "res");

        /**
         * 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)
         */
        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
          }
        }

        addAttribute(sb, "xmlns:dlna", "urn:schemas-dlna-org:metadata-1-0/");

        // 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());

        // Use our best guess if we have no valid mime type
        if (mime == null || mime.contains("/transcode")) {
          mime = HTTPResource.getDefaultMimeType(getType());
        }

        dlnaspec = null;

        /**
         * In this code block, we determine 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.
         */
        if (mediaRenderer.isDLNAOrgPNUsed()) {
          if (mediaRenderer.isPS3()) {
            if (mime.equals(DIVX_TYPEMIME)) {
              dlnaspec = "DLNA.ORG_PN=AVI";
            } else if (mime.equals(WMV_TYPEMIME) && media != null && media.getHeight() > 700) {
              dlnaspec = "DLNA.ORG_PN=WMVHIGH_PRO";
            }
          } else {
            if (mime.equals(MPEG_TYPEMIME)) {
              dlnaspec = "DLNA.ORG_PN=" + getMPEG_PS_PALLocalizedValue(c);

              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())
                  )
                ) {
                  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 &&
                  (
                    (
                      configuration.isMencoderMuxWhenCompatible() &&
                      MEncoderVideo.ID.equals(player.id())
                    ) ||
                    (
                      configuration.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.
                   */
                  if (mediaRenderer.isAccurateDLNAOrgPN()) {
                    OutputParams params = new OutputParams(configuration);
                    Player.setAudioAndSubs(getSystemName(), media, params);
                    media_audio = params.aid;
                    media_subtitle = params.sid;
                    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 &&
                    !isSubsFile() &&
                    media != null &&
                    media.getDvdtrack() == 0 &&
                    isMuxableResult &&
                    mediaRenderer.isMuxH264MpegTS()
                  ) {
                    isFileMPEGTS = true;
                  }
                }

                if (isFileMPEGTS) {
                  dlnaspec = "DLNA.ORG_PN=" + getMPEG_TS_SD_EU_ISOLocalizedValue(c);
                  if (
                    media.isH264() &&
                    !VideoLanVideoStreaming.ID.equals(player.id()) &&
                    isMuxableResult
                  ) {
                    dlnaspec = "DLNA.ORG_PN=AVC_TS_HD_24_AC3_ISO";
                    if (mediaRenderer.isTranscodeToMPEGTSH264AAC()) {
                      dlnaspec = "DLNA.ORG_PN=AVC_TS_HP_HD_AAC";
                    }
                  }
                }
              } else if (media != null) {
                if (media.isMpegTS()) {
                  dlnaspec = "DLNA.ORG_PN=" + getMPEG_TS_SD_EULocalizedValue(c);
                  if (media.isH264()) {
                    dlnaspec = "DLNA.ORG_PN=AVC_TS_HD_50_AC3";
                    if (mediaRenderer.isTranscodeToMPEGTSH264AAC()) {
                      dlnaspec = "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
              dlnaspec = "DLNA.ORG_PN=" + getMPEG_TS_SD_EULocalizedValue(c);
            } else if (mime.equals(JPEG_TYPEMIME)) {
              dlnaspec = "DLNA.ORG_PN=JPEG_LRG";
            } else if (mime.equals(AUDIO_MP3_TYPEMIME)) {
              dlnaspec = "DLNA.ORG_PN=MP3";
            } else if (mime.substring(0, 9).equals(AUDIO_LPCM_TYPEMIME) || mime.equals(AUDIO_WAV_TYPEMIME)) {
              dlnaspec = "DLNA.ORG_PN=LPCM";
            }
          }

          if (dlnaspec != null) {
            dlnaspec = "DLNA.ORG_PN=" + mediaRenderer.getDLNAPN(dlnaspec.substring(12));
          }
        }

        String tempString = "http-get:*:" + mime + ":" + (dlnaspec != null ? (dlnaspec + ";") : "") + getDlnaOrgOpFlags();

        wireshark.append(" ").append(tempString);
        addAttribute(sb, "protocolInfo", tempString);

        if (subsAreValidForStreaming && !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 && media != 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) {
            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()) {
            addAttribute(sb, "bitrate", media.getBitrate());
            if (media.getDuration() != null) {
              wireshark.append(" duration=").append(convertTimeToString(media.getDuration(), DURATION_TIME_FORMAT));
              addAttribute(sb, "duration", convertTimeToString(media.getDuration(), DURATION_TIME_FORMAT));
            }
            if (firstAudioTrack != null && firstAudioTrack.getSampleFrequency() != null) {
              addAttribute(sb, "sampleFrequency", firstAudioTrack.getSampleFrequency());
            }
            if (firstAudioTrack != null) {
              addAttribute(sb, "nrAudioChannels", firstAudioTrack.getAudioProperties().getNumberOfChannels());
            }

            if (player == null) {
              wireshark.append(" size=").append(media.getSize());
              addAttribute(sb, "size", media.getSize());
            } else {
              // Calculate WAV size
              if (firstAudioTrack != null) {
                int defaultFrequency = mediaRenderer.isTranscodeAudioTo441() ? 44100 : 48000;
                if (!configuration.isAudioResample()) {
                  try {
                    // FIXME: Which exception could be thrown here?
                    defaultFrequency = firstAudioTrack.getSampleRate();
                  } catch (Exception e) {
                    LOGGER.debug("Caught exception", e);
                  }
                }
                int na = firstAudioTrack.getAudioProperties().getNumberOfChannels();
                if (na > 2) { // No 5.1 dump in MPlayer
                  na = 2;
                }
                int finalSize = (int) (media.getDurationInSeconds() * defaultFrequency * 2 * na);
                LOGGER.trace("Calculated size for " + getSystemName() + ": " + finalSize);
                wireshark.append(" size=").append(finalSize);
                addAttribute(sb, "size", finalSize);
              }
            }
          } 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);
        wireshark.append(" ").append(getFileURL());
        LOGGER.trace("Network debugger: " + wireshark.toString());
        wireshark.setLength(0);
        sb.append(getFileURL());
        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 {
        openTag(sb, "res");
        String format = media_subtitle.getType().getExtension();
        if (StringUtils.isBlank(format)) {
          format = "plain";
        }
        addAttribute(sb, "protocolInfo", "http-get:*:text/" + format + ":*");
        endTag(sb);
        sb.append(subsURL);
        closeTag(sb, "res");
        LOGGER.trace("Network debugger: http-get:*:text/" + format + ":*" + subsURL);
      }
    }

    appendThumbnail(mediaRenderer, sb, "JPEG_TN");
    appendThumbnail(mediaRenderer, sb, "JPEG_SM");

    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";
        boolean xbox360 = mediaRenderer.isXbox360();
        if (xbox360 && getFakeParentId() != null && getFakeParentId().equals("7")) {
          uclass = "object.container.album.musicAlbum";
        } else if (xbox360 && getFakeParentId() != null && getFakeParentId().equals("6")) {
          uclass = "object.container.person.musicArtist";
        } else if (xbox360 && getFakeParentId() != null && getFakeParentId().equals("5")) {
          uclass = "object.container.genre.musicGenre";
        } else if (xbox360 && getFakeParentId() != null && getFakeParentId().equals("F")) {
          uclass = "object.container.playlistContainer";
        }
      } else if (getFormat() != null && getFormat().isVideo()) {
        uclass = "object.item.videoItem";
      } else if (getFormat() != null && getFormat().isImage()) {
        uclass = "object.item.imageItem.photo";
      } else if (getFormat() != null && getFormat().isAudio()) {
        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 the response for the thumbnail based on the
   * configuration of the renderer.
   *
   * @param mediaRenderer The renderer configuration.
   * @param sb The StringBuilder to append the response to.
   */
  private void appendThumbnail(RendererConfiguration mediaRenderer, StringBuilder sb, String format) {
    final String thumbURL = getThumbnailURL();

    if (StringUtils.isNotBlank(thumbURL)) {
      if (mediaRenderer.getThumbNailAsResource()) {
        // Samsung 2012 (ES and EH) models do not recognize the "albumArtURI" element. Instead,
        // the "res" element should be used.
        // Also use "res" when faking JPEG thumbs.
        openTag(sb, "res");

        if (getThumbnailContentType().equals(PNG_TYPEMIME) && !mediaRenderer.isForceJPGThumbnails()) {
          addAttribute(sb, "protocolInfo", "http-get:*:image/png:DLNA.ORG_PN=PNG_TN");
        } else {
          addAttribute(sb, "protocolInfo", "http-get:*:image/jpeg:DLNA.ORG_PN=" + format);
        }

        endTag(sb);
        sb.append(thumbURL);
        closeTag(sb, "res");
      } else {
        // Renderers that can handle the "albumArtURI" element.
        openTag(sb, "upnp:albumArtURI");
        addAttribute(sb, "xmlns:dlna", "urn:schemas-dlna-org:metadata-1-0/");

        if (getThumbnailContentType().equals(PNG_TYPEMIME) && !mediaRenderer.isForceJPGThumbnails()) {
          addAttribute(sb, "dlna:profileID", "PNG_TN");
        } else {
          addAttribute(sb, "dlna:profileID", format);
        }

        endTag(sb);
        sb.append(thumbURL);
        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.
   *
   * @see StartStopListener
   */
  public void startPlaying(final String rendererId) {
    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 = RendererConfiguration.getRendererConfigurationBySocketAddress(rendererIp);
              String rendererName = "unknown renderer";
              try {
                rendererName = renderer.getRendererName().replaceAll("\n", "");
              } catch (NullPointerException e) { }
              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 DLNAResource self = this;
    final String requestId = getRequestId(rendererId);
    Runnable defer = new Runnable() {
      @Override
      public void run() {
        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);

          Runnable r = new Runnable() {
            @Override
            public void run() {
              if (refCount == 1) {
                InetAddress rendererIp;
                try {
                  rendererIp = InetAddress.getByName(rendererId);
                  RendererConfiguration renderer = RendererConfiguration.getRendererConfigurationBySocketAddress(rendererIp);
                  String rendererName = "unknown renderer";
                  try {
                    rendererName = renderer.getRendererName();
                  } catch (NullPointerException e) { }
                  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);
                }

                PMS.get().getFrame().setStatusLine("");

                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();
  }

  /**
   * 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
   */
  private long lastStart;
  public synchronized InputStream getInputStream(Range range, RendererConfiguration mediarenderer) throws IOException {
    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;
      }
    }

    // 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

        return wrap(fis, high, low);
      }

      InputStream fis;
      if (getFormat() != null && getFormat().isImage() && media != null && media.getOrientation() > 1 && mediarenderer.isAutoRotateBasedOnExif()) {
        // seems it's a jpeg file with an orientation setting to take care of
        fis = ImagesUtil.getAutoRotateInputStreamImage(getInputStream(), media.getOrientation());
        if (fis == null) { // error, let's return the original one
          fis = getInputStream();
        }
      } else {
        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()));
        }
      }
      return fis;
    } else {
      // Pipe transcoding result
      OutputParams params = new OutputParams(configuration);
      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 = (long) (resume.getTimeOffset() / 1000);
        if (player == null) {
          player = new FFMpegVideo();
        }
      }

      if(System.currentTimeMillis() - lastStart < 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.toString());

        lastStart = 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();
        lastStart = 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() {
    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
  }

  /**
   * 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.
   */
  protected void checkThumbnail(InputFile inputFile) {
    if (media != null && !media.isThumbready() && configuration.isThumbnailGenerationEnabled()) {
      Double seekPosition = (double) configuration.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());

      if (media.getThumb() != null && configuration.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:
   *          - its Format icon, if any
   *          - the fallback image, if any
   *          - the default video icon
   *
   * @param fallback
   *            the fallback image, or null.
   *
   * @return The InputStream
   * @throws IOException
   */
  public InputStream getGenericThumbnailInputStream(String fallback) throws IOException {
    String thumb = fallback;
    if (format != null && format.getIcon() != null) {
      thumb = format.getIcon();
    }

    // Thumb could be:
    if (thumb != null) {
      // A local file
      if (new File(thumb).exists()) {
        return new FileInputStream(thumb);
      }

      // A jar resource
      InputStream is;
      if ((is = getResourceInputStream(thumb)) != null) {
        return is;
      }

      // A URL
      try {
        return downloadAndSend(thumb, true);
      } catch (Exception e) {}
    }

    // Or none of the above
    String defaultThumbnailImage = "images/thumbnail-video-256.png";
    if (isFolder()) {
      defaultThumbnailImage = "images/thumbnail-folder-256.png";
      if (defaultRenderer != null && defaultRenderer.isForceJPGThumbnails()) {
        defaultThumbnailImage = "images/thumbnail-folder-120.jpg";
      }
    } else if (defaultRenderer != null && defaultRenderer.isForceJPGThumbnails()) {
      defaultThumbnailImage = "images/thumbnail-video-120.jpg";
    }
    return getResourceInputStream(defaultThumbnailImage);
  }

  /**
   * 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 InputStream
   * @throws IOException
   */
  public InputStream getThumbnailInputStream() throws IOException {
    String id = null;

    if (media_audio != null) {
      id = media_audio.getLang();
    }

    if (media_subtitle != null && media_subtitle.getId() != -1) {
      id = media_subtitle.getLang();
    }

    if ((media_subtitle != null || media_audio != null) && StringUtils.isBlank(id)) {
      id = DLNAMediaLang.UND;
    }

    if (id != null) {
      String code = Iso639.getISO639_2Code(id.toLowerCase());
      return getResourceInputStream("/images/codes/" + code + ".png");
    }

    if (isAvisynth()) {
      return getResourceInputStream("/images/logo-avisynth.png");
    }
    return getGenericThumbnailInputStream(null);
  }

  public String getThumbnailContentType() {
    return HTTPResource.JPEG_TYPEMIME;
  }

  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.
   */
  protected 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
   */
  protected 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 #isSubsFile()} instead
   */
  @Deprecated
  protected boolean isSrtFile() {
    return isSubsFile();
  }

  /**
   * Returns true if this resource has subtitles in a file.
   *
   * @return the srtFile
   * @since 1.50
   */
  protected boolean isSubsFile() {
    return srtFile;
  }

  /**
   * @Deprecated use {@link #setSubsFile()} instead
   */
  @Deprecated
  protected void setSrtFile(boolean srtFile) {
    setSubsFile(srtFile);
  }

  /**
   * Set to true if this resource has subtitles in a file.
   *
   * @param srtFile the srtFile to set
   * @since 1.50
   */
  protected void setSubsFile(boolean srtFile) {
    this.srtFile = srtFile;
  }

  /**
   * Returns the update counter for this resource. When the resource needs
   * to be refreshed, its counter is updated.
   *
   * @return The update counter.
   * @see #notifyRefresh()
   */
  public int getUpdateId() {
    return updateId;
  }

  /**
   * Sets the update counter for this resource. When the resource needs
   * to be refreshed, its counter should be updated.
   *
   * @param updateId The counter value to set.
   * @since 1.50
   */
  protected void setUpdateId(int updateId) {
    this.updateId = updateId;
  }

  /**
   * Returns the update counter for all resources. When all resources need
   * to be refreshed, this counter is updated.
   *
   * @return The system update counter.
   * @since 1.50
   */
  public static int getSystemUpdateId() {
    return systemUpdateId;
  }

  /**
   * Sets the update counter for all resources. When all resources need
   * to be refreshed, this counter should be updated.
   *
   * @param systemUpdateId The system update counter 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;
  }

  /**
   * 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 = 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()
    ) {
      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;
  }

  private boolean liveSubs(DLNAResource r) {
    DLNAMediaSubtitle s = r.media_subtitle;
    if (s != null) {
      return StringUtils.isNotEmpty(s.getLiveSubURL());
    }
    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.getMinPlayTime();
  }

  private String resumeStr(String s) {
    if (isResume()) {
      return Messages.getString("PMS.134") + ": " + s;
    } else {
      return s;
    }
  }

  public String resumeName() {
    return resumeStr(getDisplayName());
  }

  /**
   * Handle last played stuff
   *
   * This method should be overridden by all media types that should be
   * added to the last played list.
   * By default it just returns null which means the resource is ignored
   * in the last played file.
   */
  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;
  }
}
TOP

Related Classes of net.pms.dlna.DLNAResource

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.